Compare commits
34 Commits
main
...
feat/watch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d939b8c3c | ||
|
|
9903424235 | ||
|
|
0a721c249b | ||
|
|
2a4274b1ad | ||
|
|
422b6ac3e6 | ||
|
|
7d68605e83 | ||
|
|
474afce348 | ||
|
|
5fc19bf095 | ||
|
|
adcf4088f3 | ||
|
|
3cc7d935ad | ||
|
|
60dd22eaeb | ||
|
|
eef76daa45 | ||
|
|
753d7b248e | ||
|
|
0f96122cca | ||
|
|
01607ed75c | ||
|
|
94b1f6243d | ||
|
|
81582ce969 | ||
|
|
1389edb1f7 | ||
|
|
3de149591e | ||
|
|
14133efba9 | ||
|
|
6deb61e86e | ||
|
|
179528d563 | ||
|
|
0110368e17 | ||
|
|
184c08a447 | ||
|
|
a44fa0046a | ||
|
|
3e657f5917 | ||
|
|
2f2d7696fa | ||
|
|
891a8bddce | ||
|
|
19fbfd116e | ||
|
|
eb523ff205 | ||
|
|
acbb2c6896 | ||
|
|
be1ffec5c1 | ||
|
|
1a5b9cbcad | ||
|
|
f2fa3cccb0 |
156
COWORK.md
156
COWORK.md
@@ -76,9 +76,163 @@ 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
|
||||
`openNativeLive`). C'est le point d'entrée qui manquait.
|
||||
|
||||
## 🆕 Chantier montre outdoor (2026-08-20, dev — non compilé)
|
||||
|
||||
Branche **`feat/watch-outdoor`**, commits `f2fa3cc` (moteur d'intervalles) et
|
||||
`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
|
||||
structurée dans une app tierce — son seul point d'exécution public est
|
||||
`WorkoutPlan.openInWorkoutApp()`, qui ouvre l'app Exercice d'Apple. Il faut donc
|
||||
écrire la machine à états nous-mêmes.
|
||||
|
||||
**Le moteur est volontairement pur** : aucun HealthKit, aucun timer, aucune
|
||||
horloge interne. Il répond à « où en sommes-nous ? » à partir du temps et de la
|
||||
distance qu'on lui pousse. Le temps de référence est `elapsedTime` du builder,
|
||||
qui exclut déjà les pauses.
|
||||
|
||||
✅ **57 tests Swift verts sur Linux** (`./tests-linux/run.sh`, toolchain
|
||||
`~/workspace/toolchains/bin`) : 17 pour `IntervalEngine`, 15 pour `RouteFilter`.
|
||||
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.
|
||||
|
||||
✅ **Plus d'« Add Files » à faire** — correction du 2026-08-20 après-midi. Une
|
||||
note antérieure de ce fichier annonçait `IntervalEngine` absent du projet : ce
|
||||
n'est plus vrai. Les **trois** fichiers sont déclarés dans la cible `CoachWatch`
|
||||
directement dans le `pbxproj` (sauvegarde `project.pbxproj.bak-outdoor`).
|
||||
|
||||
Contrôle avant de builder — chacun doit rendre **2** (déclaration + phase de
|
||||
compilation, soit 1 cible) :
|
||||
```bash
|
||||
for f in IntervalEngine RouteFilter LocationTracker; do
|
||||
echo "$f: $(grep -c "$f.swift in Sources" ios/App/App.xcodeproj/project.pbxproj)"
|
||||
done
|
||||
```
|
||||
|
||||
⚠️ **À 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.
|
||||
|
||||
## 🔜 Câbler IntervalEngine — analyse du 2026-08-20 (rien de codé)
|
||||
|
||||
`IntervalEngine` **compile sur device** (build validé le 20/08, l'app tourne),
|
||||
mais **aucune vue ne l'appelle** : le moteur est un guide sans itinéraire.
|
||||
|
||||
### Le chaînon manquant
|
||||
|
||||
Le moteur attend un `CoachSessionPlan` (liste d'étapes : durée, zone, bornes
|
||||
bpm). **Rien ne le lui fournit** — `ConnectivityManager` reçoit les zones, le
|
||||
`CoachWidgetSnapshot` et la routine, mais aucun plan structuré. Vérifié par
|
||||
lecture du fichier, pas supposé.
|
||||
|
||||
En revanche **le serveur sait déjà produire ces étapes** :
|
||||
`coach_sportif/web/fitness_export.py::_blocks_from_session` déplie une séance
|
||||
en blocs chronométrés, répétitions comprises (un bug de prod du 12/08 avait
|
||||
fait disparaître un 7×(1'C/1'M) : les `repeat` sont désormais dépliés), et
|
||||
`_zone_bpm(zone, hr_zones)` convertit une zone en bornes bpm. Ces briques
|
||||
servent déjà à l'export TCX Workout.
|
||||
|
||||
Indice que le chemin était prévu : les `CodingKeys` de `CoachPlanStep` sont en
|
||||
snake_case (`duration_sec`, `hr_zone`, `hr_bpm_min`…) — le type a été écrit
|
||||
pour décoder du JSON venant du serveur.
|
||||
|
||||
### Les trois couches, dans l'ordre
|
||||
|
||||
1. ✅ **Serveur — FAIT le 2026-08-21** (`coach_sportif` `c8d1541`, déployé et
|
||||
vérifié en prod). **`GET /api/plan/session[?date=YYYY-MM-DD][&type=...]`**
|
||||
rend la séance dépliée au format `CoachSessionPlan` : clés snake_case,
|
||||
`hr_zone` en entier, durées en secondes. Mesuré en prod le 21/08 sur la
|
||||
séance du jour (CDC J13, 15×(1'/1')) : **32 étapes, 40 min**, alternance
|
||||
`work`/`recovery` correcte, bornes Z1 100-109 / Z2 109-118.
|
||||
- Les bornes BPM viennent des **zones datées** (`current_zones`), jamais des
|
||||
`hr_min`/`hr_max` du plan (hérités d'avant l'unification du 12/08).
|
||||
- Les `repeat` sont **dépliés** — le moteur ne sait pas répéter.
|
||||
- `404` = pas de séance ce jour · `422` = séance non chronométrée (repos,
|
||||
durée ouverte) : rien à dérouler, à distinguer côté montre.
|
||||
- ⚠️ Route volontairement **hors `/api/v1/*`** (JWT que la WebView n'a pas).
|
||||
Un test le verrouille : ne pas la « ranger » dans l'API native.
|
||||
2. **iPhone** — pousser ce plan vers la montre par `WCSession`, à côté de ce
|
||||
qui part déjà.
|
||||
3. **Montre** — `ConnectivityManager` publie un `sessionPlan` ; une vue appelle
|
||||
`engine.update(elapsed:distance:)` à chaque tick et joue une haptique sur
|
||||
`events.transitions`.
|
||||
|
||||
### ⚠️ Le piège à trancher AVANT de câbler
|
||||
|
||||
L'en-tête d'`IntervalEngine.swift` affirmait que
|
||||
`HKLiveWorkoutBuilder.elapsedTime` « exclut déjà les pauses ». **La doc Apple
|
||||
dit l'inverse**, vérifié à la source le 20/08 : *« The elapsed time for the
|
||||
workout based on the builder's current contents, including pauses. »*
|
||||
|
||||
Câbler le moteur dessus telle quelle ferait avancer le déroulé **pendant les
|
||||
pauses** — un fractionné mis en pause pour traverser une route se déroulerait
|
||||
à l'arrêt. La propriété qui exclut réellement les pauses est
|
||||
`HKWorkoutBuilder.elapsedTime(at:)`. Les deux textes d'Apple se contredisent
|
||||
frontalement ; le commentaire du fichier a été corrigé, **le choix de la source
|
||||
de temps reste à faire**.
|
||||
|
||||
⚠️ Et jamais un `Timer` de vue SwiftUI : il ne tourne pas écran éteint, donc
|
||||
pendant l'essentiel d'une séance (même famille de piège que `VmaTestView`).
|
||||
|
||||
## 📌 Point du 2026-08-21 — build OK, vérifs GPS EN SUSPENS
|
||||
|
||||
**Build Xcode passé sur iPhone (iOS 26.6)**, après un `git pull` qui partait de
|
||||
`be1ffec` : le Mac n'avait donc **ni `LocationTracker`, ni `RouteFilter`, ni le
|
||||
correctif du doublon pbxproj** (`19fbfd1`) — le build du 20/08 ne les contenait
|
||||
pas. Contrôles avant build : 2/2/2 déclarations, `UIBackgroundModes = [location]`
|
||||
et `NSLocationWhenInUseUsageDescription` présents, un seul `@main` par cible.
|
||||
|
||||
**Une erreur de compilation, corrigée (`3e657f5`)** :
|
||||
`finishRoute(with:)` **n'accepte pas d'optionnel** — vérifié dans la doc Apple :
|
||||
`func finishRoute(with workout: HKWorkout, metadata:)`, « You must have already
|
||||
saved this workout to the HealthKit store ». Le commentaire du fichier
|
||||
promettait une trace « sauvegardée sans association » : **c'est impossible**,
|
||||
aucune API ne clôt une route orpheline. Traité en amont : quand
|
||||
`finishWorkout()` rend `nil` (montre verrouillée), la séance EST dans HealthKit
|
||||
et `WorkoutManager.recentlySavedWorkout()` va la rechercher — dernier workout de
|
||||
`HKSource.default()` **croisant** les 5 dernières minutes (chevauchement, jamais
|
||||
`startDate` : filtrer sur le début raterait toute séance longue). Sinon
|
||||
`discardRoute()` jette la trace explicitement et le journalise comme une perte.
|
||||
|
||||
⏳ **Les 4 vérifications au poignet n'ont PAS encore été faites** — la séance
|
||||
doit être lancée **depuis l'app sur la montre**, les logs du 21/08 ne montrent
|
||||
que l'app iPhone (sync HealthKit complète et saine : 7 séances, 7 traces, 13
|
||||
scores d'effort, App Group en écriture `ok:true`).
|
||||
|
||||
Relevé au passage, non traité : avertissement `UIScene lifecycle will soon be
|
||||
required` (Capacitor, échéance future) ; un échantillon de pas venant de
|
||||
`"iPhone de …"` et non de la Watch — à surveiller, **la cadence étant dérivée
|
||||
des pas relus par séance**, deux sources sur une même séance la fausseraient.
|
||||
|
||||
## 🖥️ Vues d'entraînement au choix (WorkOutDoors) — cadré le 21/08, rien codé
|
||||
|
||||
Cadrage complet : **`docs/watch-training-screens.md`**. En deux lignes :
|
||||
`LiveWorkoutView` a **cinq métriques en dur**, aucune notion de champ ni de
|
||||
page. Décision prise : la configuration s'édite **côté web** (réutiliser
|
||||
`TileManager`) et voyage par `WCSession` — donc **aucun rebuild pour changer ses
|
||||
écrans**. Le modèle et le catalogue sont du Foundation pur, donc écrits et
|
||||
testés sur Linux ; seul le rendu `TabView` exige Xcode. Châssis ≈ 1 jour.
|
||||
|
||||
## 👉 À 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`.
|
||||
- **iOS — bloqué Mac** : builder + uploader TestFlight le natif accumulé (login Apple+Google natif, watchOS live, HeartRateRangeAlert). Sur le Mac : `cd ~/coach-ios && git pull && npm install && npx cap sync ios && open ios/App/App.xcworkspace` → Clean Build Folder → Archive → Upload.
|
||||
- **iOS — bloqué Mac** : builder + uploader TestFlight le natif accumulé (login Apple+Google natif, watchOS live, HeartRateRangeAlert). Sur le Mac : `cd ~/coach-ios && git pull && npm install && npx cap sync ios && open ios/App/App.xcodeproj` → Clean Build Folder → Archive → Upload.
|
||||
- ⚠️ **Corrigé le 2026-08-20** : cette ligne disait `open ios/App/App.xcworkspace`. **Ce fichier n'existe pas** (vérifié : le dépôt ne contient que `App.xcodeproj`). Depuis **Capacitor 8, les dépendances passent par SPM** et il n'y a plus de workspace CocoaPods. La commande échouait donc telle quelle.
|
||||
- ⚠️ **Fixes robustesse 2026-07-01 (dev, non compilés)** à valider au prochain build : `CoachWidgetBridge` (score lu en `NSNumber.intValue` → corrige un score float perdu ; `save` renvoie Bool → `call.reject` si App Group KO), `CoachAppleAuth` (double-tap : rejette l'ancien `pendingCall` ; erreur testée par domaine `ASAuthorizationError`). Swift pur, aucun nouveau fichier ni capability.
|
||||
- ⚠️ **Checklist pré-App-Store (audit sécu 2026-07-01)** : `ios/App/App/App.entitlements` a `aps-environment = development`. Pour la soumission **App Store/TestFlight**, l'APNs prod exige `production`. NON changé côté dev (casserait le push en dev device — arbitrage de signing à faire sur Mac : soit basculer `production` avant l'archive de distribution, soit laisser Xcode le gérer via le profil de distribution auto). À trancher/tester au moment du build release.
|
||||
- **watchOS — bloqué Mac** : test device + TestFlight de la cible `CoachWatch` (Phases 1-3 réalisées, build vert sim). Cf. `HANDOFF-WATCHOS.md`.
|
||||
|
||||
479
docs/polar-ble-runbook-mac.md
Normal file
479
docs/polar-ble-runbook-mac.md
Normal file
@@ -0,0 +1,479 @@
|
||||
# Écrire la séance sur la Polar en Bluetooth — ce qui reste à faire sur le Mac
|
||||
|
||||
Le code est écrit et poussé. Ce qui suit ne peut se faire que sur le Mac mini,
|
||||
Xcode ouvert, montre à portée. À lire en entier avant le premier envoi : la
|
||||
première tentative est celle qui peut bloquer la montre.
|
||||
|
||||
## Pourquoi ce chemin, et pas un autre
|
||||
|
||||
Contrainte posée le 2026-08-31 : **`coach → la montre`, sans intermédiaire.**
|
||||
Pas de TrainingPeaks, pas de Polar Flow, pas d'Intervals.icu. Ça élimine toutes
|
||||
les autres voies, qui passent toutes par un tiers. Reste l'écriture directe du
|
||||
fichier d'objectif sur la montre par PFTP — prouvée sur la Vantage V3 le
|
||||
2026-08-17, en USB. Le Bluetooth est ce qui la rend utilisable.
|
||||
|
||||
Le détail de ce qui a été mesuré sur la montre est dans le dépôt `coach_sportif`,
|
||||
`tools/polar/README.md`. Il fait foi.
|
||||
|
||||
## 1. Ajouter le SDK Polar au projet
|
||||
|
||||
⚠️ **Pas dans `ios/App/CapApp-SPM/Package.swift`** : ce fichier porte
|
||||
« DO NOT MODIFY — managed by Capacitor CLI » et sera réécrit. Ajouter le paquet
|
||||
au `.xcodeproj` :
|
||||
|
||||
Xcode → File → Add Package Dependencies…
|
||||
https://github.com/polarofficial/polar-ble-sdk
|
||||
→ produit « PolarBleSdk », cible « App »
|
||||
|
||||
Tant que le paquet n'est pas là, `CoachPolarBLE.isAvailable()` répond
|
||||
`{available: false, reason: "PolarBleSdk absent de la cible"}` — le fichier
|
||||
compile quand même, tout le code radio est derrière `#if canImport(PolarBleSdk)`.
|
||||
|
||||
## 2. Target Membership
|
||||
|
||||
Deux fichiers, cible **App** :
|
||||
|
||||
- `ios/App/App/CoachPolarBLE.swift` — le plugin
|
||||
- `ios/App/App/PolarPftpStep.swift` — les garde-fous et l'en-tête PFTP
|
||||
|
||||
Le second n'importe que Foundation : il est aussi lié dans
|
||||
`tests-linux/Sources/CoachModel/` et couvert par 12 tests qui tournent sur
|
||||
Linux (`./tests-linux/run.sh`).
|
||||
|
||||
## 3. Permission Bluetooth
|
||||
|
||||
`NSBluetoothAlwaysUsageDescription` est dans `Info.plist`, en français.
|
||||
|
||||
⚠️ **La clé ne suffit pas à déclencher la demande.** iOS ne sollicite
|
||||
l'autorisation qu'à la création d'un `CBCentralManager`. Or
|
||||
`CBDeviceListenerImpl.search()` commence par
|
||||
`monitorBleState().filter { $0 == .poweredOn }` : sans manager, cet état
|
||||
n'arrive jamais, le flux n'émet rien, le scan ne démarre pas — et le timeout
|
||||
conclut « aucun appareil ». Mesuré le 31/08 : 30 s de scan, aucune alerte, et
|
||||
« Bluetooth » absent de Réglages → coach (seuls Position, Caméra, Mouvement,
|
||||
Actualisation, Données cellulaires y figuraient).
|
||||
|
||||
C'est pourquoi `PolarPsFtpWriter` commence par une `SondeBluetooth` : elle crée
|
||||
le manager, attend son premier état, et traduit ce qu'il dit — autorisation
|
||||
refusée, Bluetooth éteint, matériel absent. **Au premier lancement après ce
|
||||
correctif, iOS affichera l'alerte d'autorisation : l'accepter.**
|
||||
|
||||
## 4. Le premier envoi — en `dryRun`, toujours
|
||||
|
||||
Depuis la console Safari attachée à la WebView :
|
||||
|
||||
```js
|
||||
const r = await fetch('/api/plan/polar-target?date=2026-08-31&hour=18')
|
||||
const plan = await r.json()
|
||||
await window.Capacitor.Plugins.CoachPolarBLE.send({ ...plan, dryRun: true })
|
||||
```
|
||||
|
||||
Attendu :
|
||||
|
||||
```
|
||||
{ sent: false, dryRun: true, log: [
|
||||
"mkdir /U/0/20260831/TST/ (0 o)",
|
||||
"mkdir /U/0/20260831/TST/180000/ (0 o)",
|
||||
"put /U/0/20260831/TST/180000/TST.BPB (240 o)",
|
||||
"put /U/0/20260831/TST/180000/ID.BPB (51 o)" ] }
|
||||
```
|
||||
|
||||
**Lire ce journal avant d'ôter `dryRun`.** Un `mkdir` avec des octets, ou un
|
||||
`put` vers un chemin terminé par « / », est la requête exacte qui a bloqué la
|
||||
montre le 2026-08-17 — le plugin la refuse, mais la voir refusée vaut mieux que
|
||||
de la découvrir en radio.
|
||||
|
||||
## 5. L'envoi réel
|
||||
|
||||
Retirer `dryRun`. Fermer l'app Polar Flow d'abord : **le canal BLE ne se
|
||||
partage pas**, et une synchro Flow en cours empêchera la connexion.
|
||||
|
||||
En cas d'échec, le message distingue **trois causes opposées** — la distinction
|
||||
a été ajoutée le 31/08 après un premier essai où un message unique ne permettait
|
||||
pas de conclure :
|
||||
|
||||
| Message | Cause probable | Geste |
|
||||
|---|---|---|
|
||||
| « aucun appareil Bluetooth détecté » | Bluetooth éteint, **autorisation refusée à coach**, montre hors de portée | Réglages → coach → Bluetooth |
|
||||
| « N appareil(s) vu(s), M portant PsFTP mais sans réponse » | canal déjà occupé | fermer **complètement** Polar Flow |
|
||||
| « N appareil(s) vu(s), aucun n'expose PsFTP » | la V3 n'annonce pas le service tant qu'elle est appairée à Flow | c'est l'inconnue de fond |
|
||||
|
||||
⚠️ Le scan se fait par **fenêtres successives** de 4 s jusqu'au timeout, et non
|
||||
en une passe : CoreBluetooth peut n'être pas encore `poweredOn` au premier
|
||||
appel, et une fenêtre unique conclurait à tort qu'aucun appareil n'existe.
|
||||
|
||||
## L'heure de l'objectif — ce qu'on sait, et ce qu'on ne sait pas
|
||||
|
||||
Un objectif est rangé sous `/U/0/<AAAAMMJJ>/TST/<HHMMSS>/`, et le `start_time`
|
||||
écrit dans le fichier doit concorder avec ce dossier : c'est le couple qui situe
|
||||
la séance dans la journée. `build_session.py --hour` et le paramètre `hour` des
|
||||
routes produisent les deux ensemble, il n'y a donc rien à accorder à la main.
|
||||
|
||||
⚠️ **En revanche, on ignore si la montre conditionne l'affichage à cette
|
||||
heure.** Propose-t-elle l'objectif toute la journée, ou seulement à son
|
||||
approche ? Le test du 2026-08-17 ne l'a pas mesuré. À observer au premier envoi
|
||||
réussi : écrire un objectif daté 18 h et regarder s'il apparaît immédiatement.
|
||||
|
||||
- S'il apparaît tout de suite → l'heure n'est qu'une étiquette, en fixer une par
|
||||
défaut suffit.
|
||||
- S'il n'apparaît qu'à l'approche → il faut écrire à l'heure du départ. Sans
|
||||
conséquence pratique : l'envoi se fait de toute façon juste avant de sortir.
|
||||
|
||||
## 🔴 Où ça bloque au 31/08 : le scan du SDK ne démarre pas
|
||||
|
||||
Mesuré, pas supposé : **43 appareils vus par un scan CoreBluetooth nu, 0 remonté
|
||||
par le SDK Polar.** La radio, l'autorisation et la montre sont donc hors de
|
||||
cause — le problème est dans notre usage du SDK.
|
||||
|
||||
Ce qui a été écarté **sur les sources**, pour ne pas le refaire :
|
||||
|
||||
- `servicesToScanFor` à `nil` : c'est aussi ce que fait `PolarBleApiImpl`.
|
||||
- `scanPreFilter` à `nil` : **accepte** les appareils. La condition est
|
||||
`if self.session(peripheral) == nil, let filter = self.scanPreFilter` — sans
|
||||
filtre, le `if let` échoue et le rejet est sauté.
|
||||
- `addClient()` suffit à lancer le scan : `scanningNeeded()` rend vrai dès que
|
||||
`clientCount != 0`, aucune action « admin » n'est requise.
|
||||
|
||||
**CAUSE RACINE TROUVÉE** — confirmée par l'instrumentation : le publisher de
|
||||
`search()` n'émettait **rien du tout**, ni valeur, ni complétion, ni erreur.
|
||||
|
||||
`CBDeviceListenerImpl.manager` est une **`lazy var`** : le `CBCentralManager`
|
||||
n'existe qu'au premier accès.
|
||||
|
||||
```swift
|
||||
fileprivate lazy var manager: SDKCBCentralManager = { … }()
|
||||
```
|
||||
|
||||
Or `search()` commence par `monitorBleState().filter { $0 == .poweredOn }`, et
|
||||
`monitorBleState()` se contente de rendre `bleStateSubject` — il ne touche
|
||||
jamais `manager`. Les seuls accès du chemin de recherche
|
||||
(`retrieveConnectedPeripherals`, `retrievePeripherals`) sont **à l'intérieur du
|
||||
`flatMap`**, donc après le filtre. Le manager n'était donc jamais instancié,
|
||||
`centralManagerDidUpdateState` jamais appelé, le sujet jamais alimenté : le
|
||||
filtre bloquait pour toujours.
|
||||
|
||||
**Deuxième cause, découverte juste après** : sans `scanPreFilter`, le listener
|
||||
remonte **tous** les appareils BLE alentour — 43 mesurés. Le code ouvrait alors
|
||||
une session sur chacun à tour de rôle, dont l'Apple Watch
|
||||
(`API MISUSE: … name = Apple Watch de Sylvain … can only accept commands while
|
||||
in the connected state`), à 12 s d'échéance chacun : l'envoi paraissait ne
|
||||
jamais finir. Le SDK officiel pose ce filtre (`deviceFilter` dans
|
||||
`PolarBleApiImpl`) ; ne pas le poser était l'omission.
|
||||
|
||||
**Correctif** : appeler `listener.blePowered()` — `return manager.state ==
|
||||
.poweredOn`, le seul accès public exécuté immédiatement — **après** s'être
|
||||
abonné à `search()`. L'ordre est critique : si `bleStateSubject` est un
|
||||
`PassthroughSubject`, un état émis avant l'abonnement serait perdu et on
|
||||
retomberait sur le même silence.
|
||||
|
||||
## Le démarrage du SDK — deux détails qui décident de tout
|
||||
|
||||
Lus dans les sources, après plusieurs essais infructueux le 31/08 :
|
||||
|
||||
**1. `bleStateSubject` est un `CurrentValueSubject<BleState, Never>(.unknown)`**,
|
||||
pas un PassthroughSubject. Il **rejoue sa valeur à chaque abonnement** : il est
|
||||
donc inutile d'être abonné au moment de l'émission. Un correctif intermédiaire
|
||||
réveillait le manager *après* s'être abonné « pour ne pas rater l'événement » —
|
||||
raisonnement juste pour un PassthroughSubject, faux ici, et qui exposait au
|
||||
piège suivant.
|
||||
|
||||
**2. `centralManagerDidUpdateState` fait
|
||||
`BleState(rawValue: self.manager.state.rawValue)`** — il accède à la `lazy var`
|
||||
depuis le délégué. Si CoreBluetooth appelle le délégué avant que l'initialisation
|
||||
de la lazy soit terminée, la propriété se réentre.
|
||||
|
||||
**3. `blePowered()` et `search()` ne lisent pas la même chose.** `blePowered()`
|
||||
rend `manager.state == .poweredOn` — l'état de CoreBluetooth. `search()` filtre
|
||||
sur `bleStateSubject`, qui n'est alimenté que par `centralManagerDidUpdateState`.
|
||||
Le manager peut donc être allumé sans que le délégué ait publié quoi que ce
|
||||
soit : le sujet reste à `.unknown` et le filtre bloque. Attendre `blePowered()`
|
||||
ne prouve rien sur ce que `search()` verra.
|
||||
|
||||
⇒ **Séquence correcte** : créer le listener, poser `scanPreFilter`, appeler
|
||||
`blePowered()` **une fois** (son seul rôle : instancier la lazy pour que le
|
||||
délégué puisse tourner), puis attendre sur **`monitorBleState()`** — public, et
|
||||
c'est la source que `search()` lit — jusqu'à `.poweredOn`. Seulement ensuite,
|
||||
`search()`, une fois, sur toute la durée. Un abonnement par tranche relancerait le cycle
|
||||
`addClient()`/`removeClient()` du scanner et l'empêcherait de découvrir quoi que
|
||||
ce soit.
|
||||
|
||||
**Ce qui a été corrigé et vérifié en chemin** (ne pas refaire) :
|
||||
|
||||
| Cause | Preuve |
|
||||
|---|---|
|
||||
| Autorisation Bluetooth jamais demandée | pas de ligne « Bluetooth » dans Réglages → coach |
|
||||
| Manager `lazy` jamais réveillé | publisher muet ; `blePowered()` le crée |
|
||||
| Aucun filtre de scan | 43 appareils, sessions ouvertes jusque sur l'Apple Watch |
|
||||
| Attentes sans limite de temps | envoi figé sans message |
|
||||
| Fenêtres de scan successives | `bleStateSubject` ne publie que sur changement |
|
||||
|
||||
**⚠️ Ce que ça enseigne** : instancier `CBDeviceListenerImpl` soi-même revient à
|
||||
réimplémenter ce que `PolarBleApiImpl` fait, sans en voir le détail. On avance
|
||||
d'un cran à chaque essai, et chaque essai coûte un cycle Xcode.
|
||||
|
||||
### Piste pour la reprise : passer par l'API publique
|
||||
|
||||
Plutôt que de piloter le listener à la main, utiliser
|
||||
`PolarBleApiDefaultImpl.polarImplementation(…)` — le chemin que Polar supporte —
|
||||
pour la découverte et la connexion (`searchForDevice()`, `connectToDevice(_:)`),
|
||||
puis n'atteindre `BlePsFtpClient` qu'une fois la session établie par le SDK.
|
||||
|
||||
⚠️ À vérifier d'abord, et c'est le point bloquant de cette piste :
|
||||
`PolarBleApiImpl` garde son `listener` privé. Il faut donc chercher si une API
|
||||
publique donne accès à la session ou au client PsFTP — sinon cette voie est
|
||||
fermée elle aussi, et il faudra soit forker le SDK, soit renoncer au Bluetooth
|
||||
et garder le câble.
|
||||
|
||||
**Le câble, lui, fonctionne intégralement** : `tools/polar/README.md` du dépôt
|
||||
`coach_sportif`. Rien de ce qui a été livré côté serveur n'est perdu.
|
||||
|
||||
## 🔑 La montre ne s'annonce pas : elle est déjà connectée
|
||||
|
||||
Mesuré le 31/08 : **4020 advertisements vus** (HomePod, Furbo…), **aucune
|
||||
Polar**. Le scan fonctionne parfaitement — la Vantage ne diffuse simplement pas.
|
||||
|
||||
C'est le comportement normal du BLE : **un appareil connecté cesse d'émettre**.
|
||||
La montre est liée à l'iPhone par l'app Flow, donc elle est invisible au scan,
|
||||
par conception. L'attendre était sans espoir, et tous les correctifs de scan qui
|
||||
ont précédé ne pouvaient rien y changer.
|
||||
|
||||
`search()` prévoit ce cas, mais seulement quand on lui passe des UUID :
|
||||
|
||||
```swift
|
||||
foundPeripherals = self.manager.retrieveConnectedPeripherals(withServices: uuids!)
|
||||
```
|
||||
|
||||
⇒ **Appeler `search([BlePsFtpClient.PSFTP_SERVICE], identifiers: nil,
|
||||
fetchKnownDevices: true)`**, et non `search(nil, …)` : c'est cette branche qui
|
||||
rend les périphériques déjà connectés exposant PsFTP et leur fabrique une
|
||||
session.
|
||||
|
||||
⚠️ Dédupliquer aussi les sessions vues : `AllowDuplicates` est armé côté SDK, le
|
||||
même appareil revient à chaque advertisement.
|
||||
|
||||
## ⛔ RÉPONSE DE FOND (31/08) : la V3 refuse la connexion tierce
|
||||
|
||||
La question ouverte depuis le 17/08 est tranchée, par la mesure.
|
||||
|
||||
Une fois la montre cherchée au bon endroit (`retrieveConnectedPeripherals`), le
|
||||
plugin **l'atteint** : « 12 appareils vus, **1 portant PsFTP** ». Le service FEEE
|
||||
est donc bien exposé et `fetchGattClient` rend un `BlePsFtpClient`.
|
||||
|
||||
Mais `waitPsFtpReady` n'aboutit pas, et **la montre affiche « connexion
|
||||
impossible »** pendant que les réglages iOS la disent toujours connectée à Flow.
|
||||
Elle refuse donc la seconde session.
|
||||
|
||||
⇒ **Le canal PsFTP de la Vantage V3 n'est pas partageable.** Tant qu'elle est
|
||||
liée à l'app Flow, aucune app tierce ne peut ouvrir de session — ce qui est
|
||||
cohérent avec l'USB, où il fallait déjà fermer Flow.
|
||||
|
||||
⚠️ **Ce qui n'a PAS été tenté, et qui est la dernière porte** : dissocier la
|
||||
montre de Flow (oublier l'appareil côté iOS) pour voir si elle accepte alors
|
||||
notre connexion. Non tenté délibérément — cela reviendrait à sacrifier la
|
||||
synchronisation quotidienne, qui alimente coach en données, pour un confort
|
||||
d'envoi. Le rapport n'y est pas.
|
||||
|
||||
⚠️ Après un essai de connexion refusé, **redémarrer la montre** (appui long sur
|
||||
OK) pour libérer son canal, puis rouvrir Flow.
|
||||
|
||||
## Ce qui reste inconnu
|
||||
|
||||
1. ~~La V3 accepte-t-elle une connexion BLE tierce ?~~ **Non** — mesuré le
|
||||
31/08, voir ci-dessus.
|
||||
2. **Le filtrage de la montre** se fait sur « expose le service PsFTP (FEEE) »,
|
||||
pas sur le nom : ce que la V3 met dans son advertisement n'a pas été observé,
|
||||
et s'appuyer dessus aurait été une supposition. À resserrer une fois vu. Un
|
||||
capteur H10 s'écarte de lui-même, il ne porte pas PsFTP.
|
||||
3. **Le firmware plante sur requête malformée.** Les garde-fous couvrent les
|
||||
formes connues ; ils ne prouvent pas qu'il n'en existe pas d'autres.
|
||||
|
||||
## 🟠 CE QUE L'ESSAI BLE A COÛTÉ (31/08) — corrigé le 01/09
|
||||
|
||||
⚠️ **Cette section a d'abord été écrite plus alarmante que les faits.** Sylvain
|
||||
l'a corrigée le 01/09 : **la réinitialisation était de son propre chef**, pas une
|
||||
nécessité constatée. Distinguer ce qui a été observé de ce qui en a été déduit.
|
||||
|
||||
**Observé** — après la tentative de connexion Bluetooth refusée par la montre
|
||||
(« connexion impossible » à l'écran), **son interface USB ne répondait plus** :
|
||||
plus aucun `/dev/cu.usbmodem*`, et `system_profiler` ne listait plus rien —
|
||||
alors que la montre chargeait normalement, et que c'était le même câble, le même
|
||||
port et la même montre qu'une heure plus tôt.
|
||||
|
||||
**Déduit à tort** — « les redémarrages n'ont rien changé, il a *fallu* une
|
||||
réinitialisation ». La réinitialisation a été **choisie**, pas imposée : aucune
|
||||
autre voie n'a été épuisée avant. On ne sait donc pas si l'USB serait revenu
|
||||
seul, après un délai, un autre port ou un autre câble.
|
||||
|
||||
⇒ **Ce qui reste établi** : un essai BLE refusé peut laisser l'USB muet dans la
|
||||
foulée. ⇒ **Ce qui ne l'est pas** : que ce soit durable, ni qu'une
|
||||
réinitialisation soit le remède. Si le cas se reproduit, épuiser d'abord les
|
||||
gestes réversibles — débrancher/rebrancher, changer de port et de câble,
|
||||
redémarrer la montre, laisser reposer — et **noter lequel a marché**. C'est
|
||||
cette mesure qui manque.
|
||||
|
||||
⚠️ **Conséquence à assumer, dans sa juste mesure** : une tentative de connexion
|
||||
PsFTP refusée peut laisser l'USB muet — observé une fois. Que le firmware
|
||||
« dégrade durablement son état » était une extrapolation depuis cette
|
||||
observation unique : **retirée**. Le plantage du 17/08 sur requête malformée,
|
||||
lui, reste établi et documenté plus haut.
|
||||
|
||||
⇒ **Ne pas multiplier les essais sans raison**, et prévoir que le câble puisse
|
||||
ne pas répondre juste après. Écrire la séance PAR CÂBLE D'ABORD quand c'est
|
||||
possible — non parce que la casse est certaine, mais parce qu'un aller-retour de
|
||||
diagnostic avant une sortie coûte plus que deux minutes d'anticipation.
|
||||
|
||||
## Si la montre se bloque
|
||||
|
||||
Logo Polar puis écran noir. **Appui long sur OK, 10-15 s.** À défaut,
|
||||
BACK + DOWN pendant 10 s. En dernier recours, réinitialisation d'usine par
|
||||
FlowSync — les données déjà synchronisées dans Flow sont préservées.
|
||||
|
||||
## Après l'envoi
|
||||
|
||||
L'objectif est **éphémère** : une synchro Flow supprime ce qui est déposé à une
|
||||
date qu'elle ignore (mesuré). C'est pour ça que l'envoi se fait juste avant de
|
||||
sortir, et c'est pour ça que le Bluetooth était la condition — brancher la
|
||||
montre au Mac cinq minutes avant de courir n'était pas un usage.
|
||||
|
||||
⚠️ Les cibles écrites sont des **numéros de zone**, pas des battements : la
|
||||
montre les exécute selon les zones réglées dans Flow. Vérifiées alignées le
|
||||
2026-08-17, à 1 bpm près sur la frontière Z2/Z3. La réponse du serveur porte les
|
||||
bornes de `current_zones()` pour ce contrôle — les afficher avant l'envoi.
|
||||
|
||||
## 2026-09-01 — Après réinitialisation, le BLE marche. Et ANCS n'est pas la voie.
|
||||
|
||||
### Fait neuf : l'écriture BLE a abouti
|
||||
|
||||
Rapporté par Sylvain : **après la réinitialisation complète de la montre** (celle
|
||||
qu'a imposée l'incident USB), l'envoi de l'exercice par Bluetooth **a
|
||||
fonctionné**. Il ajoute lui-même : « ce n'est pas viable ».
|
||||
|
||||
Ça confirme le diagnostic du 31/08 plutôt que de l'infirmer : le refus ne vient
|
||||
pas d'une interdiction des apps tierces, mais de **l'occupation du canal PsFTP
|
||||
par Flow**. Montre neuve, pas encore liée à Flow ⇒ le canal est libre ⇒ notre
|
||||
session s'ouvre. Dès que Flow reprend la main, elle se referme.
|
||||
|
||||
⇒ **Le prix d'accès au BLE reste le même** : ne pas avoir Flow. Et depuis le
|
||||
01/09 ce prix a augmenté — la Polar est devenue la montre de sport, donc Flow
|
||||
est le seul canal par lequel les séances entrent dans HealthKit puis dans coach.
|
||||
|
||||
### La question de Sylvain : « et le protocole des notifications iOS ? »
|
||||
|
||||
Bonne intuition — les notifications iOS arrivent bien à la montre **pendant que
|
||||
Flow est connecté**. Mais le canal ne peut pas porter une séance, et la raison
|
||||
est structurelle.
|
||||
|
||||
**ANCS — Apple Notification Center Service** ([spec Apple][ancs]) :
|
||||
|
||||
- **Les rôles sont inversés par rapport à PsFTP.** « The publisher of the ANCS
|
||||
service (**the iOS device**) shall be referred to as the *Notification
|
||||
Provider* » ; « any client of the ANCS service (**an accessory**) shall be
|
||||
referred to as a *Notification Consumer* ». C'est l'**iPhone** qui est
|
||||
serveur, la montre qui est cliente — d'où l'absence de conflit avec Flow : la
|
||||
montre ne cède rien, elle consomme.
|
||||
- **Trois caractéristiques, et rien d'autre** : Notification Source
|
||||
(`9FBF120D-…`), Control Point (`69D1D8F3-…`), Data Source (`22EAC6E9-…`).
|
||||
- **Aucun transport de binaire.** La spec ne décrit aucun mécanisme de transfert
|
||||
de fichier ni de payload applicatif : uniquement des métadonnées de
|
||||
notification et des commandes d'action prédéfinies.
|
||||
|
||||
⇒ **On ne fera pas passer un `.BPB` par ANCS.** Ce n'est pas une limite de notre
|
||||
implémentation, c'est ce que le protocole est.
|
||||
|
||||
### Ce qu'ANCS permet quand même, et qui n'est pas rien
|
||||
|
||||
ANCS transporte du **texte affiché au poignet**, sans câble, sans dissocier, et
|
||||
**sans toucher à Flow**. Une notification locale iOS émise par coach est relayée
|
||||
par ANCS et s'affiche sur la montre. Le canal existe déjà côté projet :
|
||||
`@capacitor/local-notifications` est installé, et `web/static/local-notifications.js`
|
||||
est chargé par `_layout.html` — **aucun build Xcode nécessaire**.
|
||||
|
||||
⚠️ Ce que ça ne fait PAS, et il faut le dire avant de le construire : pas
|
||||
d'objectif structuré, **pas d'alerte de zone**, pas de guidage par étape, pas de
|
||||
comparaison à l'exécution. C'est un **pense-bête au poignet**, pas un
|
||||
remplacement du `.BPB`. La longueur réellement affichable par la V3 n'est pas
|
||||
documentée et devra être mesurée.
|
||||
|
||||
⇒ Le seul chemin qui écrit un vrai objectif **en préservant Flow** reste le
|
||||
**câble**.
|
||||
|
||||
[ancs]: https://developer.apple.com/library/archive/documentation/CoreBluetooth/Reference/AppleNotificationCenterServiceSpecification/Specification/Specification.html
|
||||
|
||||
### La séquence du 01/09, et ce qu'elle apprend sur la fenêtre
|
||||
|
||||
Trois envois consécutifs, trois résultats différents — c'est la chronologie qui
|
||||
explique tout :
|
||||
|
||||
| # | résultat | lecture |
|
||||
|---|---|---|
|
||||
| 1 | **réussi** | montre fraîchement réinitialisée, **pas encore liée à Flow** : canal libre |
|
||||
| 2 | **errorcode 104** `DIRECTORY_EXISTS` | canal **toujours** libre, la session s'ouvre — seul le `mkdir` bute sur un dossier déjà créé au 1ᵉʳ envoi |
|
||||
| 3 | **« canal probablement occupé »** | Flow a repris la main entre-temps |
|
||||
|
||||
⇒ Le canal n'est pas ouvert ou fermé « en général » : il y a une **fenêtre**,
|
||||
qui commence quand la montre n'est liée à personne et se referme dès que Flow
|
||||
se reconnecte. C'est cohérent avec le 31/08 et avec l'USB, où il faut fermer
|
||||
Flow.
|
||||
|
||||
⚠️ **Le message d'erreur du plugin conseillait « fermer complètement l'app Polar
|
||||
Flow ». C'est faux, et ça l'était déjà le 31/08** : la montre affichait
|
||||
« connexion impossible » pendant que les réglages iOS la disaient **toujours
|
||||
connectée** à Flow. iOS maintient le lien d'un accessoire appairé, app fermée ou
|
||||
non. Message corrigé le 01/09 — il nomme désormais le geste qui libère
|
||||
réellement le canal (redémarrer la montre) et rappelle que le câble est le
|
||||
chemin fiable.
|
||||
|
||||
**Hypothèse ouverte, non vérifiée** : redémarrer la montre puis écrire *dans la
|
||||
foulée*, avant que Flow ne se reconnecte, pourrait suffire. Ça expliquerait les
|
||||
envois 1 et 2. Ce n'est pas une méthode tant que ça n'a pas été reproduit
|
||||
plusieurs fois — et ⛔ chaque essai a un coût matériel avéré (l'USB désactivé le
|
||||
31/08). **Écrire la séance par câble AVANT tout essai.**
|
||||
|
||||
## ⛔ Déconnecter Flow automatiquement : iOS l'interdit (01/09)
|
||||
|
||||
Question de Sylvain : peut-on déconnecter Polar Flow avant chaque envoi, sans
|
||||
geste manuel ? **Non, et ce n'est pas contournable** — c'est une garantie
|
||||
d'isolation entre apps, pas une lacune de notre code.
|
||||
|
||||
Apple, `cancelPeripheralConnection(_:)` :
|
||||
|
||||
> « Because other apps may still have a connection to the peripheral, canceling
|
||||
> a local connection **does not guarantee that the underlying physical link is
|
||||
> immediately disconnected**. »
|
||||
|
||||
Une app n'annule que **sa propre** connexion. Rien dans CoreBluetooth ni dans le
|
||||
SDK Polar ne permet de rompre celle d'une autre app, et une app ne peut pas non
|
||||
plus couper le Bluetooth du système. Le `disconnectFromDevice` du SDK ne ferme
|
||||
que notre session.
|
||||
|
||||
### Le geste manuel le plus court : le mode avion de la montre
|
||||
|
||||
Plus rapide qu'un redémarrage, et documenté par Polar
|
||||
([Vantage V3, Réglages rapides][qs]) : « Le mode avion coupe toute communication
|
||||
sans fil sur votre montre […] vous ne pouvez pas synchroniser vos données avec
|
||||
l'application Polar Flow ».
|
||||
|
||||
**Mode avion ON → OFF**, puis envoyer **immédiatement**. Le ON coupe Flow ; le
|
||||
OFF rouvre une fenêtre pendant laquelle le canal PsFTP est libre, avant que Flow
|
||||
ne se reconnecte. ⚠️ Pendant le ON, notre app ne peut pas se connecter non plus :
|
||||
tout le sans-fil est coupé, c'est bien un ON *puis* OFF.
|
||||
|
||||
⚠️ **Cette course n'a pas été reproduite plusieurs fois** — c'est une hypothèse
|
||||
cohérente avec les envois réussis du 01/09, pas une méthode établie.
|
||||
|
||||
### Pourquoi il n'y a pas de bouton « Réessayer » dans l'UI
|
||||
|
||||
⚠️ **Écrit sur une prémisse trop forte, corrigée le 01/09.** L'argument était
|
||||
« chaque tentative refusée a un coût matériel avéré — celle du 31/08 a désactivé
|
||||
l'USB jusqu'à une réinitialisation ». Or la réinitialisation était **un choix de
|
||||
Sylvain**, pas une nécessité constatée. Ce qui reste : l'USB a été muet après un
|
||||
essai, une fois, et personne n'a cherché s'il serait revenu seul.
|
||||
|
||||
Le bouton n'est donc pas refusé sur un risque « avéré ». Il reste absent parce
|
||||
que **la voie BLE elle-même n'est pas viable** (course contre Flow, geste manuel
|
||||
sur la montre à chaque envoi) : outiller la répétition d'un chemin qu'on
|
||||
n'emprunte pas serait du travail à contre-emploi. **C'est à Sylvain de trancher
|
||||
s'il veut ce bouton** — la décision lui revient, et l'argument matériel ne doit
|
||||
plus peser plus qu'il ne vaut.
|
||||
|
||||
[qs]: https://support.polar.com/e_manuals/vantage-v3/polar-vantage-v3-user-manual-francais/quick-settings.htm
|
||||
83
docs/watch-plan-origin-runbook-mac.md
Normal file
83
docs/watch-plan-origin-runbook-mac.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Runbook Mac — le build qui rend l'envoi vérifiable
|
||||
|
||||
**Écrit le 03/09/2026, à suivre sur le Mac mini.** Compte 20 minutes, dont la
|
||||
moitié d'attente. Ce build ne change rien à ce que la montre reçoit : il rend
|
||||
l'app capable de **dire** ce qu'elle a fait, ce qu'elle ne savait pas faire.
|
||||
|
||||
Le dossier complet — deux épisodes de « la montre ne dit plus rien », leur
|
||||
chronologie et ce qu'elle élimine — est dans `coach_sportif`,
|
||||
[`docs/GUIDE-MONTRE.md`](../../coach_sportif/docs/GUIDE-MONTRE.md) §5quater et
|
||||
§5quinquies. Ici : les gestes.
|
||||
|
||||
## Ce qui change
|
||||
|
||||
Deux fichiers, **déjà déclarés dans la target App** — donc rien à ajouter dans
|
||||
Xcode, pas de Target Membership à cocher, pas de nouveau framework à lier
|
||||
(WorkoutKit était déjà importé par `CoachWorkoutKit.swift`) :
|
||||
|
||||
- `ios/App/App/CoachWorkoutKit.swift`
|
||||
- `ios/App/App/CoachWorkoutObserver.swift`
|
||||
|
||||
Branche : `feat/watch-outdoor`. Le côté serveur est **déjà en prod**, il attend
|
||||
juste que l'app l'appelle.
|
||||
|
||||
## 1. Récupérer et compiler
|
||||
|
||||
```bash
|
||||
cd ~/coach-ios # ou l'emplacement du clone sur le Mac
|
||||
git checkout feat/watch-outdoor
|
||||
git pull
|
||||
npx cap sync ios
|
||||
open ios/App/App.xcworkspace
|
||||
```
|
||||
|
||||
Dans Xcode : cible **App**, ton iPhone connecté, ▶︎. Rien d'autre à toucher.
|
||||
|
||||
⚠️ **Ce code n'a jamais été compilé** — WorkoutKit et HealthKit n'existent pas
|
||||
dans Swift pour Linux, et `tests-linux/` ne couvre que ce qui n'importe que
|
||||
Foundation. Les signatures Apple ont été vérifiées une par une dans la doc DocC
|
||||
avant d'être écrites, mais si quelque chose casse, c'est là que ça cassera :
|
||||
|
||||
| symptôme | où regarder |
|
||||
|---|---|
|
||||
| `value of type 'HKWorkout' has no member 'workoutPlan'` | l'extension vient de WorkoutKit : vérifier que `import WorkoutKit` est bien pris (il est sous `#if canImport`) |
|
||||
| erreur sur `case .custom(let custom)` | `WorkoutPlan.workout` est l'enum `WorkoutPlan.Workout` — cas `custom`, `goal`, `pacer`, `swimBikeRun` |
|
||||
| erreur de conversion sur `sw.date` | c'est un `DateComponents`, pas une `Date` — d'où le `Calendar.current.date(from:)` |
|
||||
|
||||
## 2. Vérifier sans courir (2 minutes, dans le canapé)
|
||||
|
||||
1. Ouvre **Programme**, la séance du jour, et appuie sur l'envoi Apple Watch.
|
||||
Le message doit maintenant **nommer la séance programmée** :
|
||||
« Programmée sur la montre : CDC · … ».
|
||||
- S'il dit « la montre ne la liste pas encore » : le scheduler n'a pas encore
|
||||
publié — regarde *Exercice → Programmées* au poignet avant de conclure.
|
||||
- S'il dit « Autorisation « Séances programmées » absente » : **c'est la
|
||||
réponse au silence**. Réglages iPhone → coach → autoriser, puis renvoyer.
|
||||
C'est exactement ce qu'une réinstallation de l'app remet à zéro, et
|
||||
l'ancienne version affichait « Envoyé » quand même.
|
||||
|
||||
2. Console Xcode (facultatif), filtre `CoachWorkoutKit` : `schedule OK`,
|
||||
`authorizationState`.
|
||||
|
||||
## 3. Après ta prochaine séance
|
||||
|
||||
Le plugin remonte tout seul, en fin de séance, si elle venait de la séance
|
||||
programmée ou d'un départ à la main. Pour le lire :
|
||||
|
||||
```bash
|
||||
# sur nexus, là où vivent les données (node n'y est pas installé)
|
||||
venv/bin/python3 tools/watch_alert_check.py <AAAA-MM-JJ> --dump /tmp/wac.json
|
||||
# puis en local, là où node existe
|
||||
python3 tools/watch_alert_check.py --from /tmp/wac.json
|
||||
```
|
||||
|
||||
Trois réponses possibles, et une seule était disponible jusqu'ici :
|
||||
|
||||
- **« Séance lancée depuis la séance programmée »** → les alertes comptées
|
||||
étaient bien dues. Si tu n'as rien entendu, le défaut est ailleurs que dans
|
||||
l'envoi et le lancement.
|
||||
- **« Séance lancée À LA MAIN »** → aucune alerte n'était possible, le silence
|
||||
est normal. C'est le cas que rien ne savait détecter, et qui devient plausible
|
||||
depuis que la Polar est la montre de sport.
|
||||
- **« Origine inconnue »** → séance antérieure au build, ou lecture en échec.
|
||||
Ce n'est **pas** un « non » : l'app préfère se taire qu'affirmer à tort.
|
||||
107
docs/watch-training-screens.md
Normal file
107
docs/watch-training-screens.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Vues d'entraînement au choix sur la montre — cadrage du 2026-08-21
|
||||
|
||||
Chantier **non commencé**. Ce document fixe ce qui a été établi le 21/08 pour
|
||||
qu'une session ultérieure reprenne sans refaire l'analyse.
|
||||
|
||||
Objectif : des écrans de séance configurables façon **WorkOutDoors** — plusieurs
|
||||
pages, des champs au choix, un profil par sport.
|
||||
|
||||
## Point de départ réel (vérifié, pas supposé)
|
||||
|
||||
`LiveWorkoutView` (`ios/App/CoachWatch/ContentView.swift`, ~l.392-441) est un
|
||||
`ScrollView` avec **cinq métriques écrites en dur** : FC + zone, calories,
|
||||
distance, vitesse km/h, durée — puis l'état iPhone et les boutons
|
||||
Pause / Terminer.
|
||||
|
||||
**Il n'existe aujourd'hui aucune notion de champ, de page, ni de configuration.**
|
||||
Tout est à créer.
|
||||
|
||||
## Les quatre briques manquantes
|
||||
|
||||
### 1. Un catalogue de champs
|
||||
|
||||
Chaque métrique doit devenir une valeur identifiable (`id` stable, libellé,
|
||||
unité, couleur, formatage) et non une ligne de vue. C'est ce qui permet à une
|
||||
page de déclarer `["hr", "pace", "distance"]` sans que la vue connaisse les
|
||||
champs à l'avance.
|
||||
|
||||
**Disponibles immédiatement, aucune collecte nouvelle :**
|
||||
|
||||
| Champ | Source |
|
||||
|---|---|
|
||||
| FC + zone | `WorkoutManager.heartRate` + `ConnectivityManager.zones` |
|
||||
| Calories | `WorkoutManager.activeEnergyKcal` |
|
||||
| Distance | `WorkoutManager.distanceMeters` |
|
||||
| Vitesse km/h | `WorkoutManager.speedKmh` |
|
||||
| Durée | `WorkoutManager.elapsedSec` |
|
||||
| **Allure min/km** | dérivée de `speedKmh` (rien à collecter) |
|
||||
| **D+ / D−** | `LocationTracker.ascentMeters` / `descentMeters` |
|
||||
| **Précision GPS** | `LocationTracker.horizontalAccuracy` |
|
||||
| **Étape du fractionné** | `IntervalEngine`, dès qu'il est câblé (cf. COWORK) |
|
||||
|
||||
**Demandent du travail :**
|
||||
|
||||
- **FC moyenne** et **temps passé par zone** : rien ne les accumule aujourd'hui.
|
||||
- **Cadence** : non collectée sur la montre.
|
||||
- **Puissance de course** (`runningPower`, watchOS 9+).
|
||||
- **Tours / laps** : aucun `HKWorkoutEvent` posé aujourd'hui.
|
||||
|
||||
### 2. Un modèle de page
|
||||
|
||||
```
|
||||
WorkoutScreenConfig { sport: String, pages: [WorkoutPage] }
|
||||
WorkoutPage { id: String, fields: [String] }
|
||||
```
|
||||
|
||||
Rendu par un `TabView` paginé (défilement vertical + Digital Crown, watchOS 10+).
|
||||
1 à 4 champs par page selon la taille d'écran — l'Ultra en tient davantage.
|
||||
|
||||
💡 **Ce modèle est du Foundation pur** : il s'écrit et se teste **sur Linux**
|
||||
(`./tests-linux/run.sh`), comme `IntervalEngine` et ses 17 tests. Seul le rendu
|
||||
SwiftUI exigera Xcode.
|
||||
|
||||
### 3. Où l'utilisateur choisit — décision prise le 21/08
|
||||
|
||||
**La configuration s'édite côté web et se pousse à la montre.** Pas d'écran de
|
||||
réglages sur la montre.
|
||||
|
||||
- `TileManager` (long-press, drag, toggle) existe déjà côté web : le paradigme
|
||||
est là, éprouvé sur les tuiles.
|
||||
- Même chemin de transport que la routine et les zones : `WCSession` →
|
||||
`ConnectivityManager`.
|
||||
- **Conséquence décisive** : changer ses écrans ne demanderait **aucun rebuild
|
||||
Xcode**. C'est exactement ce qui rend la routine agréable aujourd'hui — « les
|
||||
blocs voyagent avec l'état, rien n'est codé en dur côté watchOS ».
|
||||
- Un **profil par sport** (course ≠ vélo ≠ renfo), comme WorkOutDoors.
|
||||
|
||||
⚠️ **Repli obligatoire** : sans configuration reçue, la montre affiche les cinq
|
||||
champs actuels. Jamais d'écran vide — la config peut ne pas être arrivée, et une
|
||||
séance ne s'interrompt pas pour ça.
|
||||
|
||||
### 4. Deux contraintes watchOS, à tenir dès la conception
|
||||
|
||||
- **Le CPU tue le GPS** (piège n°4 documenté dans `LocationTracker.swift`) :
|
||||
watchOS suspend une app trop gourmande et les positions s'arrêtent **sans
|
||||
aucune erreur**. ⇒ rafraîchissement **1 Hz maximum**, et ne recalculer **que
|
||||
la page visible**, jamais les quatre. Ne pas utiliser un `Timer` de vue.
|
||||
- **Écran always-on** : en luminance réduite (`@Environment(\.isLuminanceReduced)`),
|
||||
espacer les mises à jour, sinon la batterie fond sur une sortie longue.
|
||||
|
||||
## Effort estimé
|
||||
|
||||
| Lot | Estimation |
|
||||
|---|---|
|
||||
| Châssis (catalogue + modèle + config web + push + `TabView`) | ~1 jour |
|
||||
| Chaque métrique dérivée supplémentaire | 30 min à 2 h |
|
||||
| Always-on propre | ½ jour |
|
||||
|
||||
## Ordre proposé
|
||||
|
||||
1. **Modèle + catalogue en Foundation pur**, testés sur Linux — sans Mac.
|
||||
2. **Endpoint serveur + éditeur web** (réutiliser `TileManager`).
|
||||
3. **Push `WCSession`** et publication dans `ConnectivityManager`.
|
||||
4. **Rendu `TabView`** dans `LiveWorkoutView`, avec le repli en dur.
|
||||
5. Métriques accumulées (FC moyenne, temps en zone) — chacune isolément.
|
||||
|
||||
⚠️ Ne pas commencer par le rendu : c'est la seule partie qui exige le Mac, et
|
||||
elle ne se juge qu'une fois les données au bon format.
|
||||
@@ -10,7 +10,6 @@
|
||||
01468471ECA28EA66AFE2D6C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EB6C76F68679844EC72178F /* Foundation.framework */; };
|
||||
0318E7E610501FC1F958251B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3342ACCE085010828CF63565 /* Assets.xcassets */; };
|
||||
19F735674999FD63AB1C566F /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
|
||||
C0AC4E1Z0002HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
|
||||
1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
|
||||
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */; };
|
||||
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969DB880395BDEB107C9F2FE /* RoutineStore.swift */; };
|
||||
@@ -19,6 +18,8 @@
|
||||
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */; };
|
||||
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, ); }; };
|
||||
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 */; };
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
@@ -31,14 +32,13 @@
|
||||
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; };
|
||||
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60061E60061E60061E060002 /* CoachGoogleAuth.swift */; };
|
||||
6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */; };
|
||||
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */; };
|
||||
694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */; };
|
||||
79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276928E86E785EEA434057C /* CoachWidgets.swift */; };
|
||||
880A76D1302B601000F582D5 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D0302B601000F582D5 /* WidgetKit.framework */; };
|
||||
880A76D3302B601000F582D5 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D2302B601000F582D5 /* SwiftUI.framework */; };
|
||||
880A76DE302B601100F582D5 /* CoachWatchWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
880A76E6302B62FB00F582D5 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
|
||||
C0AC4E1Z0003HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
|
||||
88342F7D30376311002D1811 /* IntervalEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88342F7C30376311002D1811 /* IntervalEngine.swift */; };
|
||||
88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 88924CCD2FD2A733002F3A80 /* GoogleSignIn */; };
|
||||
88ABE0043024C45A002636FD /* CoachQuickLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0023024C45A002636FD /* CoachQuickLog.swift */; };
|
||||
88ABE0053024C45A002636FD /* CoachQuickSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0033024C45A002636FD /* CoachQuickSync.swift */; };
|
||||
@@ -50,14 +50,17 @@
|
||||
95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; };
|
||||
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; };
|
||||
97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
|
||||
C0AC4E1Z0004HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
|
||||
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */; };
|
||||
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */; };
|
||||
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; };
|
||||
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; };
|
||||
B130613187FE0661DDF636C9 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
|
||||
C0AC4E1Z0005HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
|
||||
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; };
|
||||
C0AC4E1Z0002HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
|
||||
C0AC4E1Z0003HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
|
||||
C0AC4E1Z0004HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
|
||||
C0AC4E1Z0005HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
|
||||
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
|
||||
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
|
||||
E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61D7B2F5AFEF834423E07AB /* Foundation.framework */; };
|
||||
@@ -152,6 +155,7 @@
|
||||
880A76D0302B601000F582D5 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||
880A76D2302B601000F582D5 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
880A76E7302B634400F582D5 /* CoachWatchWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CoachWatchWidgetsExtension.entitlements; sourceTree = "<group>"; };
|
||||
88342F7C30376311002D1811 /* IntervalEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntervalEngine.swift; sourceTree = "<group>"; };
|
||||
884A1F9D3020E3C100457184 /* CoachLiveActivity.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CoachLiveActivity.entitlements; sourceTree = "<group>"; };
|
||||
88A837392FAA741C00DDD144 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
88ABE0023024C45A002636FD /* CoachQuickLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachQuickLog.swift; sourceTree = "<group>"; };
|
||||
@@ -160,21 +164,23 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HeartRateZones.swift; sourceTree = "<group>"; };
|
||||
969DB880395BDEB107C9F2FE /* RoutineStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineStore.swift; sourceTree = "<group>"; };
|
||||
97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachRoutineBridge.swift; sourceTree = "<group>"; };
|
||||
9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VmaTestView.swift; sourceTree = "<group>"; };
|
||||
A420239FC6D065C8A599B61C /* ConnectivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityManager.swift; sourceTree = "<group>"; };
|
||||
A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAppleAuth.swift; sourceTree = "<group>"; };
|
||||
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = "<group>"; };
|
||||
AA5C5902EFB7F60563678E46 /* RoutineView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineView.swift; sourceTree = "<group>"; };
|
||||
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
|
||||
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; };
|
||||
C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HeartRateZones.swift; sourceTree = "<group>"; };
|
||||
C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineGuidedView.swift; sourceTree = "<group>"; };
|
||||
A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VmaTestView.swift; sourceTree = "<group>"; };
|
||||
D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = CoachWatch.entitlements; sourceTree = "<group>"; };
|
||||
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; };
|
||||
D61D7B2F5AFEF834423E07AB /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||
@@ -323,6 +329,7 @@
|
||||
5A8EA0CF92316E437E9053A0 /* CoachWatch */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
88342F7C30376311002D1811 /* IntervalEngine.swift */,
|
||||
F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */,
|
||||
8A06CDFF5B4F7852B6329862 /* ContentView.swift */,
|
||||
82122ACF7E5A608AD1D55D24 /* Info.plist */,
|
||||
@@ -333,6 +340,8 @@
|
||||
A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */,
|
||||
969DB880395BDEB107C9F2FE /* RoutineStore.swift */,
|
||||
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */,
|
||||
C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */,
|
||||
C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */,
|
||||
3342ACCE085010828CF63565 /* Assets.xcassets */,
|
||||
);
|
||||
path = CoachWatch;
|
||||
@@ -591,9 +600,12 @@
|
||||
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */,
|
||||
95D0478A499056FE922211EA /* RoutineView.swift in Sources */,
|
||||
6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */,
|
||||
88342F7D30376311002D1811 /* IntervalEngine.swift in Sources */,
|
||||
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */,
|
||||
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */,
|
||||
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */,
|
||||
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */,
|
||||
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */,
|
||||
);
|
||||
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
644
ios/App/App/CoachPolarBLE.swift
Normal file
644
ios/App/App/CoachPolarBLE.swift
Normal file
@@ -0,0 +1,644 @@
|
||||
// CoachPolarBLE.swift
|
||||
// Écrit une séance du plan sur une Polar Vantage V3, en Bluetooth, sans cloud
|
||||
// ni compte ni API tierce.
|
||||
//
|
||||
// POURQUOI CE PLUGIN EXISTE
|
||||
//
|
||||
// L'écriture d'un objectif sur la montre par le protocole PFTP est prouvée
|
||||
// depuis le 2026-08-17 : la montre accepte le fichier et l'affiche. Mais elle
|
||||
// ne l'était qu'en USB, donc il fallait brancher la montre à un Mac cinq
|
||||
// minutes avant de sortir — ce qui n'est pas un usage.
|
||||
//
|
||||
// Le Bluetooth n'est donc pas un confort : c'est ce qui rend le chemin
|
||||
// utilisable. Et c'est le seul chemin qui satisfasse la contrainte posée le
|
||||
// 2026-08-31 — `coach → la montre`, sans TrainingPeaks, sans Polar Flow, sans
|
||||
// Intervals.icu. Toutes les autres voies passent par quelqu'un d'autre.
|
||||
//
|
||||
// CE QUE CE PLUGIN FAIT, ET CE QU'IL NE FAIT PAS
|
||||
//
|
||||
// Il transporte. Rien d'autre. Les octets viennent du serveur
|
||||
// (`GET /api/plan/polar-target`), qui les produit avec un encodeur verrouillé
|
||||
// octet pour octet contre des fichiers relus sur la montre. **Ne jamais
|
||||
// réencoder un objectif ici** : ce serait perdre la seule garantie dont on
|
||||
// dispose que le firmware acceptera le fichier.
|
||||
//
|
||||
// ⚠️ LE FIRMWARE PLANTE SUR REQUÊTE MALFORMÉE
|
||||
//
|
||||
// Le 2026-08-17, un PUT de 174 octets vers un chemin terminé par « / » — donc
|
||||
// « écrire du contenu dans un dossier » — a bloqué une Vantage V3 : logo Polar
|
||||
// puis écran noir, récupérée par un appui long sur OK. Le firmware ne refuse
|
||||
// pas proprement, il s'effondre. Le BLE utilise LE MÊME protocole PFTP : le
|
||||
// risque est identique ici.
|
||||
//
|
||||
// D'où deux verrous, repris de `tools/polar/polar_ftp.py` :
|
||||
// - un chemin terminé par « / » est un DOSSIER : contenu obligatoirement vide ;
|
||||
// - un chemin sans « / » final est un FICHIER : contenu obligatoirement non vide.
|
||||
// Et un mode `dryRun` qui construit chaque requête et la décrit sans rien
|
||||
// émettre — l'équivalent du `--dry-run` qui a manqué le jour du plantage.
|
||||
//
|
||||
// CHAÎNE SDK — vérifiée sur les sources le 2026-08-31, tout est public
|
||||
//
|
||||
// CBDeviceListenerImpl(queue, clients:identifier:) ble/endpoints/corebluetooth/central
|
||||
// listener.search(_:identifiers:fetchKnownDevices:) -> AnyPublisher<BleDeviceSession, Error>
|
||||
// listener.openSessionDirect(_:)
|
||||
// session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE) // CBUUID("FEEE")
|
||||
// client.waitPsFtpReady(_:) async throws
|
||||
// client.write(_ header: NSData, data: InputStream) -> AsyncThrowingStream<UInt, Error>
|
||||
//
|
||||
// Aucun fork, aucun symbole interne.
|
||||
//
|
||||
// ⚠️ Le cadrage série `[0x05, taille, taille]` de la version USB N'A PAS SA
|
||||
// PLACE ICI. Il appartient au transport RFC76 sur CDC-ACM ; en Bluetooth c'est
|
||||
// le SDK qui s'en charge. On ne passe que l'en-tête PbPFtpOperation et les
|
||||
// données brutes. Reporter le cadrage produirait une requête malformée — c'est-
|
||||
// à-dire précisément ce qui plante la montre.
|
||||
//
|
||||
// USAGE CÔTÉ JS
|
||||
//
|
||||
// await window.Capacitor.Plugins.CoachPolarBLE.isAvailable()
|
||||
// → { available: bool }
|
||||
//
|
||||
// await window.Capacitor.Plugins.CoachPolarBLE.send({
|
||||
// mkdir: ['/U/0/20260831/TST/', '/U/0/20260831/TST/180000/'],
|
||||
// dir: '/U/0/20260831/TST/180000/',
|
||||
// files: [{ name: 'TST.BPB', b64: '...' }, { name: 'ID.BPB', b64: '...' }],
|
||||
// dryRun: true, // par défaut FALSE, mais à utiliser au premier essai
|
||||
// timeoutSec: 30,
|
||||
// })
|
||||
// → { sent: bool, dryRun: bool, log: [string] }
|
||||
//
|
||||
// Le corps de `send` est exactement la réponse de `/api/plan/polar-target` :
|
||||
// le JS n'a rien à recomposer.
|
||||
|
||||
import Foundation
|
||||
import Capacitor
|
||||
#if canImport(PolarBleSdk)
|
||||
import PolarBleSdk
|
||||
import CoreBluetooth
|
||||
import Combine
|
||||
#endif
|
||||
|
||||
@objc(CoachPolarBLEPlugin)
|
||||
public class CoachPolarBLEPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
// CAPBridgedPlugin : sans cette déclaration explicite, Capacitor 8
|
||||
// n'expose pas les méthodes au bridge et `Capacitor.Plugins.CoachPolarBLE`
|
||||
// reste undefined (même piège que CoachWorkoutKit).
|
||||
public let identifier = "CoachPolarBLEPlugin"
|
||||
public let jsName = "CoachPolarBLE"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "send", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
@objc func isAvailable(_ call: CAPPluginCall) {
|
||||
#if canImport(PolarBleSdk)
|
||||
call.resolve(["available": true])
|
||||
#else
|
||||
call.resolve(["available": false,
|
||||
"reason": "PolarBleSdk absent de la cible — ajouter le paquet dans Xcode"])
|
||||
#endif
|
||||
}
|
||||
|
||||
@objc func send(_ call: CAPPluginCall) {
|
||||
guard let etapes = Self.parseSteps(call) else {
|
||||
call.reject("payload invalide : `dir`, `files[]` (name + b64) requis")
|
||||
return
|
||||
}
|
||||
let dryRun = call.getBool("dryRun") ?? false
|
||||
let timeout = call.getDouble("timeoutSec") ?? 30
|
||||
|
||||
// Les verrous d'abord, la radio ensuite. Une requête refusée ici est une
|
||||
// montre qui ne plante pas.
|
||||
do {
|
||||
try etapes.forEach { try $0.validate() }
|
||||
} catch {
|
||||
call.reject("\(error)")
|
||||
return
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
call.resolve(["sent": false, "dryRun": true,
|
||||
"log": etapes.map { $0.describe }])
|
||||
return
|
||||
}
|
||||
|
||||
#if canImport(PolarBleSdk)
|
||||
Task {
|
||||
do {
|
||||
let issue = try await PolarPsFtpWriter().ecrire(etapes, timeout: timeout)
|
||||
call.resolve(["sent": true, "dryRun": false,
|
||||
"log": issue.journal,
|
||||
// L'UI s'en sert pour prévenir d'un renvoi : la
|
||||
// montre garde son index en cache, et l'objectif
|
||||
// réécrit n'apparaît qu'après un redémarrage.
|
||||
"alreadyExisted": issue.dossierPreexistant])
|
||||
} catch {
|
||||
call.reject("\(error)")
|
||||
}
|
||||
}
|
||||
#else
|
||||
call.reject("PolarBleSdk absent de la cible — ajouter le paquet dans Xcode")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Traduit le corps JS en étapes PFTP, dans l'ordre où elles doivent partir.
|
||||
static func parseSteps(_ call: CAPPluginCall) -> [PftpStep]? {
|
||||
guard let dir = call.getString("dir"), !dir.isEmpty else { return nil }
|
||||
var etapes: [PftpStep] = []
|
||||
|
||||
// Les dossiers d'abord, du parent vers l'enfant : `/U/0/<date>/TST/` puis
|
||||
// `<heure>/`. Aucun des deux n'existe d'avance pour une date neuve, et un
|
||||
// mkdir dans le désordre échoue.
|
||||
for chemin in call.getArray("mkdir", String.self) ?? [] {
|
||||
etapes.append(PftpStep(path: chemin, data: Data()))
|
||||
}
|
||||
|
||||
guard let fichiers = call.getArray("files") as? [[String: Any]], !fichiers.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
for fichier in fichiers {
|
||||
guard let nom = fichier["name"] as? String,
|
||||
let b64 = fichier["b64"] as? String,
|
||||
let octets = Data(base64Encoded: b64) else { return nil }
|
||||
etapes.append(PftpStep(path: dir + nom, data: octets))
|
||||
}
|
||||
return etapes
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Le transport
|
||||
|
||||
#if canImport(PolarBleSdk)
|
||||
|
||||
/// Ouvre une session PsFTP sur la première montre qui répond, et écrit.
|
||||
///
|
||||
/// ⚠️ Le filtrage se fait sur « expose PsFTP », pas sur le nom ni l'identifiant
|
||||
/// annoncé : ce que la V3 met dans son advertisement n'a pas été observé, et
|
||||
/// s'appuyer dessus serait une supposition. À resserrer une fois qu'on l'aura vu
|
||||
/// — en attendant, un capteur Polar (H10) ne portant pas PsFTP est écarté de
|
||||
/// lui-même.
|
||||
final class PolarPsFtpWriter {
|
||||
private let queue = DispatchQueue(label: "ch.hypnotruck.coach.polarble")
|
||||
private var abonnements = Set<AnyCancellable>()
|
||||
/// Le publisher de `search()` a-t-il émis quoi que ce soit — valeur, fin ou
|
||||
/// erreur ? S'il reste muet, `monitorBleState()` n'a jamais émis
|
||||
/// `.poweredOn` et le scan n'a jamais démarré : ce n'est pas « aucun
|
||||
/// appareil », c'est « on n'a jamais cherché ».
|
||||
private var publisherAParle = false
|
||||
private var derniereErreurSdk: String?
|
||||
|
||||
/// Écrit les étapes et rend le journal, plus un drapeau : le dossier de
|
||||
/// destination existait-il déjà ? Ce drapeau remonte jusqu'à l'UI, qui doit
|
||||
/// avertir — un objectif réécrit n'apparaît qu'après redémarrage de la
|
||||
/// montre.
|
||||
func ecrire(_ etapes: [PftpStep], timeout: Double) async throws
|
||||
-> (journal: [String], dossierPreexistant: Bool) {
|
||||
// ⚠️ PRÉAMBULE INDISPENSABLE — sans lui, rien ne se passe et rien ne le
|
||||
// dit. `CBDeviceListenerImpl.search()` commence par
|
||||
// `monitorBleState().filter { $0 == .poweredOn }` : tant que cet état
|
||||
// n'arrive pas, le flux n'émet jamais, le scan ne démarre pas, et on
|
||||
// conclut « aucun appareil » au bout du timeout.
|
||||
//
|
||||
// Or l'état ne peut arriver que si un `CBCentralManager` existe — et
|
||||
// c'est sa création qui déclenche l'alerte d'autorisation d'iOS.
|
||||
// Mesuré le 2026-08-31 : le scan a tourné 30 s sans qu'iOS demande quoi
|
||||
// que ce soit, et « Bluetooth » n'apparaissait même pas dans les
|
||||
// réglages de l'app. L'autorisation n'avait jamais été sollicitée.
|
||||
//
|
||||
// On crée donc le manager nous-mêmes, on attend son premier état, et on
|
||||
// traduit ce qu'il dit — au lieu de laisser un silence passer pour une
|
||||
// absence de montre.
|
||||
let sonde = SondeBluetooth()
|
||||
try await sonde.attendreEtatUtilisable(timeout: min(timeout, 15))
|
||||
|
||||
let listener = CBDeviceListenerImpl(
|
||||
queue,
|
||||
clients: [{ transport in BlePsFtpClient(gattServiceTransmitter: transport) }],
|
||||
identifier: 1)
|
||||
|
||||
// ⚠️ NE PAS FILTRER À L'AVEUGLE — on trie après avoir vu.
|
||||
//
|
||||
// Une version précédente posait `scanPreFilter` sur « polarDeviceId non
|
||||
// vide OU nom contenant polar ». Si la Vantage ne s'annonce pas ainsi,
|
||||
// elle était rejetée par NOUS, aucune session n'était créée, et le
|
||||
// publisher de `search()` ne disait rien — ce que le message
|
||||
// interprétait à tort comme « le scan n'a pas démarré ».
|
||||
//
|
||||
// On laisse donc tout remonter et on trie dans la boucle : les appareils
|
||||
// qui ressemblent à du Polar d'abord, et on n'ouvre de session que sur
|
||||
// ceux-là. Si aucun ne ressemble, on RAPPORTE ce qu'on a vu au lieu de
|
||||
// conclure.
|
||||
|
||||
// ⚠️ RÉVEILLER LA LAZY, PUIS ATTENDRE — dans cet ordre, et avant tout
|
||||
// abonnement.
|
||||
//
|
||||
// `CBDeviceListenerImpl.manager` est une `lazy var` : le
|
||||
// CBCentralManager n'existe qu'au premier accès, et `search()` ne le
|
||||
// touche jamais avant son filtre `$0 == .poweredOn`. Sans réveil, on
|
||||
// attend un état que rien ne peut produire.
|
||||
//
|
||||
// Deux détails, tirés des sources, qui décident du succès :
|
||||
//
|
||||
// 1. `bleStateSubject` est un **CurrentValueSubject**, initialisé à
|
||||
// `.unknown`. Il REJOUE sa valeur à chaque abonnement — inutile donc
|
||||
// d'être abonné au moment de l'émission. Une version précédente
|
||||
// réveillait après l'abonnement « pour ne pas rater l'événement » :
|
||||
// raisonnement de PassthroughSubject, faux ici, et qui exposait au
|
||||
// piège suivant.
|
||||
//
|
||||
// 2. `centralManagerDidUpdateState` fait
|
||||
// `BleState(rawValue: self.manager.state.rawValue)` — il accède à la
|
||||
// lazy DEPUIS le délégué. Si CoreBluetooth appelle le délégué avant
|
||||
// que l'initialisation de la lazy soit terminée, la propriété se
|
||||
// réentre. D'où : réveiller tôt, hors de tout abonnement, et laisser
|
||||
// l'état se poser.
|
||||
//
|
||||
// ⚠️ ET SURTOUT : attendre le SUJET, pas l'état du manager.
|
||||
//
|
||||
// `blePowered()` lit `manager.state` — l'état de CoreBluetooth. Mais
|
||||
// `search()` filtre sur `bleStateSubject`, qui n'est alimenté que par
|
||||
// `centralManagerDidUpdateState`. Le manager peut donc être allumé
|
||||
// (`blePowered()` vrai) sans que le délégué ait encore publié quoi que
|
||||
// ce soit : le sujet reste à `.unknown`, le filtre bloque, et le
|
||||
// publisher se tait. C'est exactement ce qui a été mesuré le
|
||||
// 2026-08-31, y compris après avoir attendu `blePowered()`.
|
||||
//
|
||||
// On attend donc sur `monitorBleState()`, qui est public et qui EST la
|
||||
// source lue par `search()`. `blePowered()` ne sert plus qu'à une
|
||||
// chose : instancier la lazy pour que le délégué puisse tourner.
|
||||
_ = listener.blePowered()
|
||||
let etat = await listener.premierEtatPret(timeout: min(timeout, 12))
|
||||
if !etat {
|
||||
throw PolarPftpError.bluetoothUnusable(
|
||||
"le listener du SDK Polar n'a jamais publié l'état « prêt » "
|
||||
+ "(son délégué n'a pas été appelé). Bluetooth actif côté "
|
||||
+ "système, mais inutilisable par le SDK.")
|
||||
}
|
||||
|
||||
let (client, session) = try await trouverClient(listener, timeout: timeout,
|
||||
sonde: sonde)
|
||||
defer { listener.closeSessionDirect(session) }
|
||||
|
||||
var journal: [String] = []
|
||||
var dossierPreexistant = false
|
||||
for etape in etapes {
|
||||
// Revalidé juste avant l'émission : entre la validation d'entrée et
|
||||
// ce point, rien ne doit avoir introduit un chemin de dossier avec
|
||||
// du contenu.
|
||||
try etape.validate()
|
||||
do {
|
||||
try await put(client, etape)
|
||||
journal.append("✔ " + etape.describe)
|
||||
} catch BlePsFtpException.responseError(let code)
|
||||
where etape.isDirectory && PftpCode.mkdirEstSatisfait(par: code) {
|
||||
// `mkdir -p` : le dossier est là, c'est tout ce qu'on demandait.
|
||||
//
|
||||
// ⚠️ Rapporté par Sylvain le 01/09/2026 sous la forme
|
||||
// « errorcode 104 » — ce qui donnait à croire à un refus de la
|
||||
// montre alors que la session BLE s'était parfaitement ouverte.
|
||||
// Le commentaire de `parseSteps` disait « aucun des deux
|
||||
// n'existe d'avance pour une date neuve » : c'est vrai d'une
|
||||
// date NEUVE, et faux dès le second envoi vers la même date.
|
||||
//
|
||||
// Le renvoi est signalé plus haut, jamais avalé en silence :
|
||||
// réécrire un objectif déjà présent n'est visible qu'après un
|
||||
// redémarrage de la montre (index en cache, constaté le 31/08).
|
||||
dossierPreexistant = true
|
||||
journal.append("• " + etape.describe + " — existait déjà")
|
||||
}
|
||||
}
|
||||
return (journal, dossierPreexistant)
|
||||
}
|
||||
|
||||
private func trouverClient(_ listener: CBDeviceListenerImpl,
|
||||
timeout: Double,
|
||||
sonde: SondeBluetooth) async throws
|
||||
-> (BlePsFtpClient, BleDeviceSession) {
|
||||
let debut = Date()
|
||||
var vues = 0 // appareils BLE aperçus, tous confondus
|
||||
var candidats = 0 // ceux qui ressemblent à un Polar
|
||||
var noms: [String] = []
|
||||
var sansPsFtp = 0 // aperçus mais ne portant pas le service FEEE
|
||||
var muettes = 0 // portant FEEE mais dont waitPsFtpReady échoue
|
||||
|
||||
// ⚠️ UN SEUL ABONNEMENT, SUR TOUTE LA DURÉE — pas des fenêtres.
|
||||
//
|
||||
// Non pas parce qu'une émission serait ratée — `bleStateSubject` est un
|
||||
// CurrentValueSubject, il rejoue sa valeur à chaque abonnement — mais
|
||||
// parce que chaque abonnement relance le cycle `addClient()` /
|
||||
// `removeClient()` du scanner. Redémarrer un scan toutes les 4 s
|
||||
// l'empêche de découvrir quoi que ce soit, et la découverte BLE demande
|
||||
// plusieurs secondes ininterrompues.
|
||||
//
|
||||
// L'état, lui, est acquis avant d'arriver ici : `blePowered()` a été
|
||||
// sondé jusqu'à devenir vrai.
|
||||
do {
|
||||
// `AllowDuplicates` est armé côté SDK : le même appareil revient à
|
||||
// chaque advertisement (4020 émissions pour une poignée d'appareils
|
||||
// réels). On déduplique avant de compter et d'ouvrir quoi que ce soit.
|
||||
var dejaVues = Set<String>()
|
||||
let toutes = try await sessionsVues(listener, fenetre: timeout)
|
||||
.filter { dejaVues.insert(Self.etiquette($0)).inserted }
|
||||
vues = toutes.count
|
||||
noms = toutes.map { Self.etiquette($0) }
|
||||
|
||||
// Ouvrir une session coûte plusieurs secondes : on ne les tente que
|
||||
// sur ce qui ressemble à un Polar, sinon 43 appareils feraient des
|
||||
// minutes d'attente (mesuré, avec des erreurs sur l'Apple Watch).
|
||||
let sessions = toutes.filter { Self.ressembleAPolar($0) }
|
||||
candidats = sessions.count
|
||||
for session in sessions {
|
||||
// Marge au-delà du scan : ouvrir une session prend du temps, et
|
||||
// couper ici ferait échouer la seule montre trouvée.
|
||||
if Date().timeIntervalSince(debut) > timeout * 2 { break }
|
||||
listener.openSessionDirect(session)
|
||||
guard let client = session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE)
|
||||
as? BlePsFtpClient else {
|
||||
sansPsFtp += 1
|
||||
listener.closeSessionDirect(session)
|
||||
continue
|
||||
}
|
||||
do {
|
||||
// ⚠️ `waitPsFtpReady` n'a AUCUNE limite de temps. Si la
|
||||
// montre ne finit pas la négociation — canal déjà pris,
|
||||
// écran éteint, appairage en cours — l'attente ne rend
|
||||
// jamais la main et l'envoi paraît figé, sans message.
|
||||
// Constaté le 2026-08-31 : « envoi » tournant sans fin.
|
||||
// On lui donne donc une échéance, et un dépassement compte
|
||||
// comme une montre muette : c'est ce qu'il est.
|
||||
try await Self.avecEcheance(seconds: 12) {
|
||||
try await client.waitPsFtpReady(true)
|
||||
}
|
||||
return (client, session)
|
||||
} catch {
|
||||
muettes += 1
|
||||
listener.closeSessionDirect(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
if vues > 0 && candidats == 0 {
|
||||
// Le scan marche, mais rien ne ressemble à un Polar. Dire ce qu'on
|
||||
// a vu : c'est la seule façon de savoir sous quel nom la montre
|
||||
// s'annonce — ou si elle ne s'annonce pas du tout.
|
||||
throw PolarPftpError.aucunPolarParmi(vues: vues,
|
||||
exemples: Array(noms.prefix(8)))
|
||||
}
|
||||
if vues == 0 {
|
||||
// Le SDK n'a rien remonté : est-ce la radio, ou notre usage du SDK ?
|
||||
// Un scan nu tranche, et son résultat part dans le message.
|
||||
let bruts = await sonde.compterAppareils(pendant: 6)
|
||||
throw PolarPftpError.sdkSilencieux(vusParCoreBluetooth: bruts,
|
||||
publisherAParle: publisherAParle,
|
||||
erreurSdk: derniereErreurSdk)
|
||||
}
|
||||
throw PolarPftpError.watchNotFound(timeout, vues: vues,
|
||||
sansPsFtp: sansPsFtp, muettes: muettes)
|
||||
}
|
||||
|
||||
/// Les appareils vus pendant une fenêtre de recherche, appairés compris.
|
||||
private func sessionsVues(_ listener: CBDeviceListenerImpl,
|
||||
fenetre: Double) async throws -> [BleDeviceSession] {
|
||||
try await withCheckedThrowingContinuation { suite in
|
||||
var vues: [BleDeviceSession] = []
|
||||
var rendu = false
|
||||
let rendre = {
|
||||
guard !rendu else { return }
|
||||
rendu = true
|
||||
suite.resume(returning: vues)
|
||||
}
|
||||
// ⚠️ PASSER L'UUID DU SERVICE — c'est ce qui trouve une montre DÉJÀ
|
||||
// CONNECTÉE.
|
||||
//
|
||||
// Mesuré le 2026-08-31 : 4020 advertisements vus (HomePod, Furbo…),
|
||||
// aucune Polar. Normal — **un appareil BLE connecté cesse
|
||||
// d'émettre**. La Vantage est liée à l'iPhone par l'app Flow : elle
|
||||
// est donc invisible au scan, par conception, et l'attendre était
|
||||
// sans espoir.
|
||||
//
|
||||
// Avec un `uuids` non nil, `search()` emprunte une autre branche :
|
||||
// `manager.retrieveConnectedPeripherals(withServices: uuids!)`, qui
|
||||
// rend les périphériques déjà connectés au système exposant ce
|
||||
// service, et leur fabrique une session. Passer `nil` — ce que
|
||||
// faisait ce code — n'exécutait jamais cette branche.
|
||||
listener.search([BlePsFtpClient.PSFTP_SERVICE],
|
||||
identifiers: nil, fetchKnownDevices: true)
|
||||
.sink(receiveCompletion: { [weak self] fin in
|
||||
// Distinguer « le publisher a terminé » de « il n'a
|
||||
// jamais rien dit » : dans le second cas, `flatMap`
|
||||
// n'a pas été atteint, donc `monitorBleState()` n'a
|
||||
// jamais émis `.poweredOn` — le scan n'a pas démarré.
|
||||
if case .failure(let e) = fin {
|
||||
self?.derniereErreurSdk = String(describing: e)
|
||||
}
|
||||
self?.publisherAParle = true
|
||||
rendre()
|
||||
},
|
||||
receiveValue: { [weak self] session in
|
||||
self?.publisherAParle = true
|
||||
vues.append(session)
|
||||
})
|
||||
.store(in: &abonnements)
|
||||
|
||||
|
||||
// La recherche ne se termine pas d'elle-même : on lui donne une
|
||||
// fenêtre, puis on travaille avec ce qu'on a vu.
|
||||
queue.asyncAfter(deadline: .now() + fenetre) { rendre() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Ce qu'on peut dire d'un appareil vu, pour l'afficher.
|
||||
static func etiquette(_ session: BleDeviceSession) -> String {
|
||||
let c = session.advertisementContent
|
||||
let nom = c.name.isEmpty ? "(sans nom)" : c.name
|
||||
return c.polarDeviceId.isEmpty ? nom : "\(nom) [\(c.polarDeviceId)]"
|
||||
}
|
||||
|
||||
/// Vrai si l'appareil a une chance d'être une montre Polar.
|
||||
///
|
||||
/// ⚠️ Volontairement LARGE : mieux vaut tenter une session de trop que
|
||||
/// d'exclure la montre et conclure qu'elle n'existe pas.
|
||||
static func ressembleAPolar(_ session: BleDeviceSession) -> Bool {
|
||||
let c = session.advertisementContent
|
||||
if !c.polarDeviceId.isEmpty { return true }
|
||||
if !c.polarDeviceType.isEmpty { return true }
|
||||
let nom = c.name.lowercased()
|
||||
return nom.contains("polar") || nom.contains("vantage") || nom.contains("grit")
|
||||
}
|
||||
|
||||
/// Attend qu'une condition devienne vraie, en la sondant. Rend faux au bout
|
||||
/// du délai.
|
||||
///
|
||||
/// ⚠️ Conservé pour d'autres usages, mais NE PAS s'en servir pour l'état
|
||||
/// BLE : `blePowered()` lit le manager, pas le sujet que `search()` écoute.
|
||||
///
|
||||
/// Sonder plutôt qu'écouter : l'état du SDK se lit (`blePowered()`), et
|
||||
/// c'est justement cette lecture qui instancie le CBCentralManager. Le
|
||||
/// premier appel réveille, les suivants observent.
|
||||
static func attendre(seconds: Double, _ condition: @escaping () -> Bool) async -> Bool {
|
||||
let echeance = Date().addingTimeInterval(seconds)
|
||||
while Date() < echeance {
|
||||
if condition() { return true }
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
|
||||
/// Court `operation` avec une échéance, et lève si elle est dépassée.
|
||||
///
|
||||
/// Le SDK Polar rend des `async` sans limite de temps : une montre qui ne
|
||||
/// répond pas fige l'appel indéfiniment. Un envoi qui n'aboutit pas doit
|
||||
/// dire pourquoi, pas tourner en silence.
|
||||
static func avecEcheance<T: Sendable>(seconds: Double,
|
||||
_ operation: @escaping @Sendable () async throws -> T)
|
||||
async throws -> T {
|
||||
try await withThrowingTaskGroup(of: T.self) { groupe in
|
||||
groupe.addTask { try await operation() }
|
||||
groupe.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
throw PolarPftpError.echeanceDepassee(seconds)
|
||||
}
|
||||
guard let premier = try await groupe.next() else {
|
||||
throw PolarPftpError.echeanceDepassee(seconds)
|
||||
}
|
||||
groupe.cancelAll()
|
||||
return premier
|
||||
}
|
||||
}
|
||||
|
||||
private func put(_ client: BlePsFtpClient, _ etape: PftpStep) async throws {
|
||||
let entree = InputStream(data: etape.data)
|
||||
// Même raison que pour waitPsFtpReady : une écriture qui n'aboutit pas
|
||||
// doit échouer, pas figer l'app.
|
||||
// `write` rend un flux de progression ; on le consomme jusqu'au bout,
|
||||
// et l'absence d'erreur vaut acquittement — c'est l'équivalent BLE du
|
||||
// `05 00 00` observé en USB.
|
||||
try await Self.avecEcheance(seconds: 20) {
|
||||
for try await _ in client.write(etape.header() as NSData, data: entree) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
|
||||
/// Vérifie que le Bluetooth est utilisable, et dit pourquoi il ne l'est pas.
|
||||
///
|
||||
/// Ne scanne rien : elle instancie un `CBCentralManager` et lit son premier
|
||||
/// état. C'est cette instanciation qui provoque la demande d'autorisation iOS
|
||||
/// — et donc l'apparition de la ligne « Bluetooth » dans les réglages de l'app.
|
||||
private final class SondeBluetooth: NSObject, CBCentralManagerDelegate {
|
||||
private var manager: CBCentralManager?
|
||||
private var suite: CheckedContinuation<CBManagerState, Never>?
|
||||
private var repondu = false
|
||||
private var vus = Set<UUID>()
|
||||
|
||||
func attendreEtatUtilisable(timeout: Double) async throws {
|
||||
let etat = await withCheckedContinuation { (c: CheckedContinuation<CBManagerState, Never>) in
|
||||
suite = c
|
||||
// `showPowerAlert: false` : c'est nous qui expliquons, pas une
|
||||
// alerte système au milieu d'un envoi.
|
||||
manager = CBCentralManager(delegate: self, queue: nil,
|
||||
options: [CBCentralManagerOptionShowPowerAlertKey: false])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in
|
||||
self?.repondre(self?.manager?.state ?? .unknown)
|
||||
}
|
||||
}
|
||||
switch etat {
|
||||
case .poweredOn:
|
||||
return
|
||||
case .unauthorized:
|
||||
throw PolarPftpError.bluetoothUnusable(
|
||||
"coach n'a pas l'autorisation d'utiliser le Bluetooth. "
|
||||
+ "Réglages → coach → activer Bluetooth.")
|
||||
case .poweredOff:
|
||||
throw PolarPftpError.bluetoothUnusable(
|
||||
"le Bluetooth est désactivé sur l'iPhone.")
|
||||
case .unsupported:
|
||||
throw PolarPftpError.bluetoothUnusable(
|
||||
"cet appareil ne prend pas en charge le Bluetooth LE.")
|
||||
default:
|
||||
throw PolarPftpError.bluetoothUnusable(
|
||||
"le Bluetooth n'a pas répondu (état « \(etat.rawValue) »). "
|
||||
+ "Réessayer ; si ça persiste, redémarrer l'app.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Compte les appareils BLE qu'un scan CoreBluetooth NU voit, sans le SDK.
|
||||
///
|
||||
/// Sert à trancher un diagnostic, pas à travailler : si CoreBluetooth voit
|
||||
/// des appareils et que le SDK n'en remonte aucun, le problème est dans
|
||||
/// notre usage du SDK ; si les deux voient zéro, il est dans la radio ou
|
||||
/// l'environnement. Sans cette mesure on ne peut que deviner — ce qui a
|
||||
/// coûté trois allers-retours le 2026-08-31.
|
||||
func compterAppareils(pendant: Double) async -> Int {
|
||||
vus.removeAll()
|
||||
manager?.scanForPeripherals(withServices: nil,
|
||||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
|
||||
try? await Task.sleep(nanoseconds: UInt64(pendant * 1_000_000_000))
|
||||
manager?.stopScan()
|
||||
return vus.count
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
|
||||
advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||
vus.insert(peripheral.identifier)
|
||||
}
|
||||
|
||||
private func repondre(_ etat: CBManagerState) {
|
||||
guard !repondu else { return }
|
||||
repondu = true
|
||||
suite?.resume(returning: etat)
|
||||
suite = nil
|
||||
}
|
||||
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
// `.unknown` est l'état transitoire du démarrage : ne pas conclure
|
||||
// dessus, le vrai état suit.
|
||||
if central.state != .unknown { repondre(central.state) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension CBDeviceListenerImpl {
|
||||
/// Vrai dès que le listener PUBLIE l'état `.poweredOn` — c'est-à-dire dès
|
||||
/// que `search()` pourra franchir son filtre.
|
||||
///
|
||||
/// `monitorBleState()` rend un `CurrentValueSubject`, donc l'abonnement
|
||||
/// reçoit immédiatement la valeur courante (`.unknown` au départ) puis les
|
||||
/// suivantes. On attend la première qui vaut `.poweredOn`.
|
||||
/// Porte son échéance elle-même plutôt que d'être enveloppée : le listener
|
||||
/// n'est pas `Sendable`, et le faire traverser une TaskGroup se heurterait
|
||||
/// à la concurrence stricte de Swift 6.
|
||||
func premierEtatPret(timeout: Double) async -> Bool {
|
||||
await withCheckedContinuation { suite in
|
||||
let boite = BoiteJeton()
|
||||
boite.jeton = monitorBleState()
|
||||
.sink(receiveCompletion: { _ in boite.rendre(false, suite) },
|
||||
receiveValue: { etat in
|
||||
if etat == .poweredOn { boite.rendre(true, suite) }
|
||||
})
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) {
|
||||
boite.rendre(false, suite)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Garde l'abonnement en vie et garantit une reprise unique de la continuation
|
||||
/// — la reprendre deux fois est un crash, et il y a ici deux chemins de sortie
|
||||
/// concurrents : l'état publié et l'échéance.
|
||||
private final class BoiteJeton: @unchecked Sendable {
|
||||
var jeton: AnyCancellable?
|
||||
private var rendu = false
|
||||
private let verrou = NSLock()
|
||||
|
||||
func rendre(_ valeur: Bool, _ suite: CheckedContinuation<Bool, Never>) {
|
||||
verrou.lock()
|
||||
defer { verrou.unlock() }
|
||||
guard !rendu else { return }
|
||||
rendu = true
|
||||
suite.resume(returning: valeur)
|
||||
jeton?.cancel()
|
||||
jeton = nil
|
||||
}
|
||||
}
|
||||
@@ -40,13 +40,43 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "sendInterval", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "scheduledWorkouts", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
@objc func isAvailable(_ call: CAPPluginCall) {
|
||||
if #available(iOS 17.0, *) {
|
||||
call.resolve(["available": true, "min_ios": "17.0"])
|
||||
} else {
|
||||
guard #available(iOS 17.0, *) else {
|
||||
call.resolve(["available": false, "reason": "iOS 17+ required for WorkoutKit"])
|
||||
return
|
||||
}
|
||||
// `available` disait « iOS 17+ », rien de plus : une autorisation
|
||||
// révoquée ou jamais accordée rendait exactement la même réponse.
|
||||
Task {
|
||||
let state = await WorkoutScheduler.shared.authorizationState
|
||||
call.resolve([
|
||||
"available": true,
|
||||
"min_ios": "17.0",
|
||||
"supported": WorkoutScheduler.isSupported,
|
||||
"authorization": Self.describe(state),
|
||||
"authorized": state == .authorized,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// Ce que la montre a réellement en attente, interrogeable sans rien envoyer.
|
||||
@objc func scheduledWorkouts(_ call: CAPPluginCall) {
|
||||
guard #available(iOS 17.0, *) else {
|
||||
call.reject("WorkoutKit requires iOS 17.0 or later")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
let state = await WorkoutScheduler.shared.authorizationState
|
||||
let scheduled = await Self.scheduledSummaries()
|
||||
call.resolve([
|
||||
"authorization": Self.describe(state),
|
||||
"authorized": state == .authorized,
|
||||
"count": scheduled.count,
|
||||
"scheduled": scheduled,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +161,7 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await sendCustomWorkout(
|
||||
let scheduled = try await sendCustomWorkout(
|
||||
activity: activity,
|
||||
displayName: displayName,
|
||||
warmupMin: warmupMin,
|
||||
@@ -143,9 +173,12 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
"sent": true,
|
||||
"activityUsed": Int(activity.rawValue),
|
||||
"activityFallback": activity != requestedActivity,
|
||||
// Ce que le scheduler liste APRÈS l'envoi : c'est la preuve,
|
||||
// le reste n'est qu'une intention.
|
||||
"scheduled": scheduled,
|
||||
])
|
||||
} catch {
|
||||
call.reject("Failed to send workout: \(error.localizedDescription)")
|
||||
call.reject(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,7 +232,7 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
stepsSpec: [(min: Double, hrZone: Int?, bpm: ClosedRange<Double>?, isWork: Bool, label: String?)],
|
||||
repeats: Int,
|
||||
cooldownMin: Double
|
||||
) async throws {
|
||||
) async throws -> [[String: Any]] {
|
||||
|
||||
// 1. Warmup — un WorkoutStep optionnel. Si warmupMin == 0, on passe
|
||||
// nil au CustomWorkout (la signature accepte WorkoutStep?). Passer
|
||||
@@ -273,11 +306,24 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
|
||||
// 8. Demande d'autorisation explicite (idempotent — déclenche dialog
|
||||
// iOS la 1re fois, return immédiat si déjà accordé).
|
||||
//
|
||||
// ⚠️ L'échec ne se ravale plus. Il l'était jusqu'au 03/09/2026 : un
|
||||
// NSLog, puis on continuait comme si de rien n'était. Une
|
||||
// réinstallation de l'app remet l'autorisation à `notDetermined`
|
||||
// (celle du 31/08, chantier CoachPolarBLE, tombe dans la fenêtre du
|
||||
// silence de la montre — cf. GUIDE-MONTRE.md §5quater), et rien à
|
||||
// l'écran n'aurait dit que plus aucune séance n'arrivait au poignet.
|
||||
do {
|
||||
try await WorkoutScheduler.shared.requestAuthorization()
|
||||
NSLog("[CoachWorkoutKit] requestAuthorization OK")
|
||||
} catch {
|
||||
NSLog("[CoachWorkoutKit] requestAuthorization failed: \(error)")
|
||||
throw CoachWorkoutKitError.authorization(error.localizedDescription)
|
||||
}
|
||||
let authState = await WorkoutScheduler.shared.authorizationState
|
||||
guard authState == .authorized else {
|
||||
NSLog("[CoachWorkoutKit] authorizationState = %@", String(describing: authState))
|
||||
throw CoachWorkoutKitError.notAuthorized(Self.describe(authState))
|
||||
}
|
||||
|
||||
// 8bis. Efface les workouts déjà programmés par l'app avant d'en
|
||||
@@ -299,5 +345,67 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
)
|
||||
try await WorkoutScheduler.shared.schedule(plan, at: comps)
|
||||
NSLog("[CoachWorkoutKit] schedule OK at \(scheduleDate)")
|
||||
|
||||
// 10. Relecture : ce qui compte n'est pas que `schedule()` soit rentré
|
||||
// sans erreur, c'est que la séance soit RÉELLEMENT dans la liste du
|
||||
// scheduler. C'est la seule chose qui réponde, sans aller regarder
|
||||
// la montre, à « est-ce que ma séance est programmée ? ».
|
||||
// ⚠️ Une liste vide n'est PAS traitée comme un échec : le scheduler
|
||||
// est asynchrone et rien chez Apple ne garantit qu'il ait publié la
|
||||
// séance à l'instant où on le relit. Inventer une erreur à chaque
|
||||
// envoi coûterait plus cher que le silence qu'on cherche. On rend
|
||||
// ce qu'on voit, l'appelant le dit.
|
||||
return await Self.scheduledSummaries()
|
||||
}
|
||||
|
||||
/// Les séances programmées PAR CETTE APP, telles que le scheduler les rend.
|
||||
@available(iOS 17.0, *)
|
||||
private static func scheduledSummaries() async -> [[String: Any]] {
|
||||
let plans = await WorkoutScheduler.shared.scheduledWorkouts
|
||||
return plans.map { sw in
|
||||
var out: [String: Any] = [
|
||||
"date": ISO8601DateFormatter().string(
|
||||
from: Calendar.current.date(from: sw.date) ?? Date()
|
||||
),
|
||||
"complete": sw.complete,
|
||||
]
|
||||
if case let .custom(custom) = sw.plan.workout {
|
||||
// `displayName` est optionnel côté Apple : ne jamais poser un
|
||||
// Optional dans le dictionnaire rendu au JS, il n'est pas
|
||||
// sérialisable par le bridge Capacitor.
|
||||
out["displayName"] = custom.displayName ?? ""
|
||||
out["blocks"] = custom.blocks.count
|
||||
out["steps"] = custom.blocks.reduce(0) { $0 + $1.steps.count }
|
||||
out["iterations"] = custom.blocks.map { $0.iterations }
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
private static func describe(_ state: WorkoutScheduler.AuthorizationState) -> String {
|
||||
switch state {
|
||||
case .authorized: return "authorized"
|
||||
case .denied: return "denied"
|
||||
case .notDetermined: return "notDetermined"
|
||||
case .restricted: return "restricted"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Erreurs qui doivent remonter jusqu'à l'écran, pas jusqu'au seul NSLog.
|
||||
enum CoachWorkoutKitError: LocalizedError {
|
||||
case authorization(String)
|
||||
case notAuthorized(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .authorization(let detail):
|
||||
return "Autorisation « Séances programmées » refusée par iOS : \(detail)"
|
||||
case .notAuthorized(let state):
|
||||
return "Autorisation « Séances programmées » absente (\(state)) — "
|
||||
+ "Réglages iPhone → coach → Séances, puis renvoyer la séance."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,13 @@ import Foundation
|
||||
import Capacitor
|
||||
import HealthKit
|
||||
import UserNotifications
|
||||
// Pour l'extension `HKWorkout.workoutPlan` (iOS 17+), qui rend la composition
|
||||
// du plan dont une séance est issue — ou nil si elle a été lancée à la main.
|
||||
// Même garde que `CoachWorkoutKit.swift` : sur un SDK sans WorkoutKit, le
|
||||
// fichier doit continuer à compiler, quitte à ne rien remonter.
|
||||
#if canImport(WorkoutKit)
|
||||
import WorkoutKit
|
||||
#endif
|
||||
|
||||
@objc(CoachWorkoutObserverPlugin)
|
||||
public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
@@ -375,8 +382,66 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}.resume()
|
||||
}
|
||||
|
||||
/// Dit au serveur si la séance vient d'une **séance programmée** ou d'un
|
||||
/// démarrage à la main dans l'app Exercice.
|
||||
///
|
||||
/// C'est la seule chose qui distingue « la montre s'est tue alors qu'elle
|
||||
/// devait parler » de « la séance n'a jamais été celle qu'on avait
|
||||
/// poussée » : `tools/watch_alert_check.py` compte les alertes attendues à
|
||||
/// partir du plan ENVOYÉ, mais rien, jusqu'ici, ne disait ce qui avait été
|
||||
/// LANCÉ (cf. `coach_sportif/docs/GUIDE-MONTRE.md` §5bis, « angle mort »).
|
||||
/// Le nom HealthKit ne le dit pas : il vaut « Course extérieure » dans les
|
||||
/// deux cas.
|
||||
///
|
||||
/// Même parti pris que `reportToRoutineIfStrength` : on n'envoie que des
|
||||
/// faits bruts, l'interprétation vit côté serveur donc sans rebuild iOS.
|
||||
private func reportPlanOrigin(_ workout: HKWorkout) {
|
||||
#if canImport(WorkoutKit)
|
||||
guard #available(iOS 17.0, *) else { return }
|
||||
Task {
|
||||
var fromPlan = false
|
||||
var planName = ""
|
||||
do {
|
||||
if let plan = try await workout.workoutPlan {
|
||||
fromPlan = true
|
||||
if case let .custom(custom) = plan.workout {
|
||||
planName = custom.displayName ?? ""
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Une lecture qui échoue n'est PAS un « lancé à la main » :
|
||||
// sans réponse, on n'envoie rien plutôt qu'un faux négatif.
|
||||
NSLog("[CoachWorkoutObserver] workoutPlan illisible: %@", error.localizedDescription)
|
||||
return
|
||||
}
|
||||
guard let url = URL(string: "https://coach.hypnotruck.ch/api/workout/plan-origin") else { return }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie")
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: [
|
||||
"uuid": workout.uuid.uuidString,
|
||||
"start": ISO8601DateFormatter().string(from: workout.startDate),
|
||||
"duration_min": workout.duration / 60,
|
||||
"activity": Int(workout.workoutActivityType.rawValue),
|
||||
"from_plan": fromPlan,
|
||||
"plan_name": planName,
|
||||
])
|
||||
req.timeoutInterval = 15
|
||||
URLSession.shared.dataTask(with: req) { _, _, error in
|
||||
if let error = error {
|
||||
NSLog("[CoachWorkoutObserver] plan-origin: %@", error.localizedDescription)
|
||||
} else {
|
||||
NSLog("[CoachWorkoutObserver] plan-origin envoyé (from_plan=%@)", fromPlan ? "true" : "false")
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func scheduleNotification(for workout: HKWorkout) {
|
||||
reportToRoutineIfStrength(workout)
|
||||
reportPlanOrigin(workout)
|
||||
let durationMin = Int(workout.duration / 60)
|
||||
var bodyParts: [String] = ["\(durationMin) min"]
|
||||
if let kcal = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()), kcal > 0 {
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>Coach Hypnotruck se connecte en Bluetooth à votre montre Polar pour y déposer la séance du jour, avec ses phases et ses zones cardiaques. La connexion ne sert qu'à cet envoi et aucune donnée n'est lue sur la montre.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Coach Hypnotruck utilise l'appareil photo pour scanner les codes-barres des aliments, photographier vos assiettes et photographier les étiquettes nutritionnelles. Ces images servent à identifier l'aliment et à estimer ses valeurs nutritionnelles, puis à alimenter votre journal alimentaire.</string>
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
|
||||
@@ -36,6 +36,11 @@ class MainViewController: CAPBridgeViewController {
|
||||
// l'app watchOS, et relaie les coches faites sur la montre vers
|
||||
// /api/routine/day (cf. CoachRoutineBridge.swift).
|
||||
bridge?.registerPluginInstance(CoachRoutineBridgePlugin())
|
||||
// Écriture d'un objectif sur la Polar Vantage V3 en Bluetooth (PFTP).
|
||||
// Ne transporte que des octets produits par le serveur, et refuse toute
|
||||
// requête malformée avant d'ouvrir la radio — le firmware plante au lieu
|
||||
// de refuser (cf. CoachPolarBLE.swift et PolarPftpStep.swift).
|
||||
bridge?.registerPluginInstance(CoachPolarBLEPlugin())
|
||||
}
|
||||
|
||||
/// Injecte le cookie d'auth dans le magasin de la WKWebView.
|
||||
|
||||
246
ios/App/App/PolarPftpStep.swift
Normal file
246
ios/App/App/PolarPftpStep.swift
Normal file
@@ -0,0 +1,246 @@
|
||||
// PolarPftpStep.swift
|
||||
// Une opération d'écriture PFTP, et les garde-fous qui empêchent de planter la
|
||||
// montre. N'importe QUE Foundation, délibérément.
|
||||
//
|
||||
// POURQUOI CE FICHIER EST SÉPARÉ DU PLUGIN
|
||||
//
|
||||
// `CoachPolarBLE.swift` importe Capacitor et le SDK Polar : il ne peut être
|
||||
// compilé que sur un Mac, dans Xcode. Or ce qui est ici est exactement la
|
||||
// partie qu'il faut pouvoir vérifier — la sérialisation de l'en-tête, et les
|
||||
// deux règles qui décident si une requête part ou non vers le firmware.
|
||||
//
|
||||
// Lié dans `tests-linux/Sources/CoachModel/` par un lien symbolique : les tests
|
||||
// portent sur le fichier livré, pas sur une copie qui dériverait en silence.
|
||||
//
|
||||
// ⚠️ CE QUE CES GARDE-FOUS PROTÈGENT
|
||||
//
|
||||
// Le 2026-08-17, un PUT de 174 octets vers un chemin terminé par « / » a bloqué
|
||||
// une Polar Vantage V3 : logo Polar puis écran noir, récupérée par un appui
|
||||
// long sur OK (10-15 s). Le firmware ne renvoie pas d'erreur applicative sur
|
||||
// une requête malformée — il s'effondre. Le Bluetooth utilise le même protocole
|
||||
// PFTP que l'USB : changer de transport n'enlève rien au risque.
|
||||
//
|
||||
// Les mêmes règles existent dans `tools/polar/polar_ftp.py::pftp_put()` du
|
||||
// dépôt coach_sportif. Les deux doivent dire la même chose.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Codes d'erreur du protocole PFTP, tels que Polar les publie.
|
||||
///
|
||||
/// Source : `pftp_error.proto` du SDK officiel — 100 `UNIDENTIFIED_HOST_ERROR`,
|
||||
/// 101 `INVALID_COMMAND`, 102 `INVALID_PARAMETER`, 103 `NO_SUCH_FILE_OR_DIRECTORY`,
|
||||
/// **104 `DIRECTORY_EXISTS`**, 105 `FILE_EXISTS`, 106 `OPERATION_NOT_PERMITTED`,
|
||||
/// 107 `NO_SUCH_USER`, 108 `TIMEOUT`.
|
||||
/// https://github.com/polarofficial/polar-ble-sdk/blob/master/sources/Android/android-communications/library/src/sdk/proto/pftp_error.proto
|
||||
///
|
||||
/// Le SDK iOS les remonte tels quels par
|
||||
/// `BlePsFtpException.responseError(errorCode: Int)`.
|
||||
public enum PftpCode {
|
||||
/// Le dossier visé par un `mkdir` existe déjà.
|
||||
///
|
||||
/// ⚠️ **Ce n'est pas un échec d'envoi.** Rencontré le 01/09/2026 sur un
|
||||
/// second envoi vers la même date : la session BLE s'était bien ouverte, et
|
||||
/// c'est notre séquence de `mkdir` qui n'était pas idempotente. Le message
|
||||
/// « errorcode 104 » donnait donc à croire à un refus de la montre alors
|
||||
/// que le transport fonctionnait.
|
||||
public static let directoryExists = 104
|
||||
|
||||
/// Codes sur lesquels un `mkdir` peut être considéré comme satisfait : le
|
||||
/// dossier est là, c'est tout ce qu'on lui demandait. L'équivalent de
|
||||
/// `mkdir -p`.
|
||||
///
|
||||
/// ⚠️ Strictement limité à la création de dossier. Un `put` de fichier ne
|
||||
/// doit JAMAIS être avalé de la sorte — 105 `FILE_EXISTS` signifierait que
|
||||
/// l'objectif est déjà écrit, ce que l'appelant doit savoir.
|
||||
public static func mkdirEstSatisfait(par code: Int) -> Bool {
|
||||
code == directoryExists
|
||||
}
|
||||
}
|
||||
|
||||
/// Un PUT PFTP : un chemin, un contenu. Un contenu vide crée un dossier.
|
||||
public struct PftpStep: Equatable {
|
||||
public let path: String
|
||||
public let data: Data
|
||||
|
||||
public init(path: String, data: Data) {
|
||||
self.path = path
|
||||
self.data = data
|
||||
}
|
||||
|
||||
/// Un chemin terminé par « / » désigne un dossier, jamais un fichier.
|
||||
public var isDirectory: Bool { path.hasSuffix("/") }
|
||||
|
||||
public var describe: String {
|
||||
"\(isDirectory ? "mkdir" : "put ") \(path) (\(data.count) o)"
|
||||
}
|
||||
|
||||
/// Refuse les formes qui ont fait, ou feraient, planter la montre.
|
||||
///
|
||||
/// Dernier rempart avant l'émission : à appeler à l'entrée du plugin ET
|
||||
/// juste avant chaque écriture.
|
||||
public func validate() throws {
|
||||
if isDirectory && !data.isEmpty {
|
||||
throw PolarPftpError.malformed(
|
||||
"refus d'écrire \(data.count) octets vers « \(path) » : un chemin "
|
||||
+ "terminé par « / » désigne un dossier. C'est cette requête exacte "
|
||||
+ "qui a fait planter une Vantage V3 le 2026-08-17.")
|
||||
}
|
||||
if !isDirectory && data.isEmpty {
|
||||
throw PolarPftpError.malformed(
|
||||
"refus d'écrire un fichier vide vers « \(path) » : ajouter « / » "
|
||||
+ "pour créer un dossier, ou fournir un contenu.")
|
||||
}
|
||||
if !path.hasPrefix("/U/0/") {
|
||||
throw PolarPftpError.malformed(
|
||||
"chemin « \(path) » hors de /U/0/ : refusé par précaution, rien "
|
||||
+ "d'autre n'a jamais été écrit sur cette montre.")
|
||||
}
|
||||
}
|
||||
|
||||
/// `PbPFtpOperation { command = 1 (varint), path = 2 (string) }`.
|
||||
///
|
||||
/// Sérialisation identique à `encode_operation()` de `polar_ftp.py` :
|
||||
/// `0x08` (champ 1, varint) + commande, puis `0x12` (champ 2, délimité) +
|
||||
/// longueur + chemin. `PUT` vaut 1, d'après `pftp_request.proto` du SDK
|
||||
/// (`enum Command { GET = 0; PUT = 1; MERGE = 2; REMOVE = 3; }`).
|
||||
///
|
||||
/// ⚠️ Pas de cadrage `[0x05, taille, taille]` ici : celui-là appartient au
|
||||
/// transport série RFC76 de la version USB. En Bluetooth, le SDK cadre
|
||||
/// lui-même — l'ajouter produirait une requête malformée, c'est-à-dire
|
||||
/// exactement ce qui plante la montre.
|
||||
public func header() -> Data {
|
||||
var bytes = Data([0x08, 0x01, 0x12])
|
||||
let path = Array(self.path.utf8)
|
||||
bytes.append(contentsOf: PftpStep.varint(path.count))
|
||||
bytes.append(contentsOf: path)
|
||||
return bytes
|
||||
}
|
||||
|
||||
/// Varint protobuf, 7 bits par octet, bit de poids fort = continuation.
|
||||
public static func varint(_ value: Int) -> [UInt8] {
|
||||
var n = value, out: [UInt8] = []
|
||||
repeat {
|
||||
var b = UInt8(n & 0x7F)
|
||||
n >>= 7
|
||||
if n > 0 { b |= 0x80 }
|
||||
out.append(b)
|
||||
} while n > 0
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
public enum PolarPftpError: Error, CustomStringConvertible, Equatable {
|
||||
case malformed(String)
|
||||
/// `vues` : appareils BLE aperçus · `sansPsFtp` : sans le service FEEE ·
|
||||
/// `muettes` : avec FEEE mais qui n'ont pas répondu.
|
||||
case watchNotFound(Double, vues: Int, sansPsFtp: Int, muettes: Int)
|
||||
case psftpUnavailable
|
||||
/// La radio elle-même est inutilisable : autorisation, Bluetooth éteint,
|
||||
/// matériel. Distinct de `watchNotFound` — ici on n'a même pas pu chercher.
|
||||
case bluetoothUnusable(String)
|
||||
/// Le SDK n'a remonté aucune session, mais CoreBluetooth, interrogé
|
||||
/// directement, a vu `vusParCoreBluetooth` appareils. Deux diagnostics
|
||||
/// opposés selon ce nombre.
|
||||
/// Une opération du SDK n'a pas rendu la main dans le délai imparti.
|
||||
case echeanceDepassee(Double)
|
||||
/// Le scan a vu des appareils, mais aucun ne ressemble à un Polar. Les
|
||||
/// exemples servent à voir sous quel nom la montre s'annonce réellement.
|
||||
case aucunPolarParmi(vues: Int, exemples: [String])
|
||||
case sdkSilencieux(vusParCoreBluetooth: Int,
|
||||
publisherAParle: Bool = true,
|
||||
erreurSdk: String? = nil)
|
||||
|
||||
/// ⚠️ Un échec de connexion a plusieurs causes OPPOSÉES, et un message
|
||||
/// unique les confond — c'est ce qui s'est produit au premier essai du
|
||||
/// 2026-08-31. Ne jamais fusionner ces branches.
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .malformed(let why):
|
||||
return why
|
||||
case .psftpUnavailable:
|
||||
return "session ouverte mais le service PsFTP (FEEE) n'a pas répondu"
|
||||
case .bluetoothUnusable(let why):
|
||||
return why
|
||||
|
||||
case .aucunPolarParmi(let vues, let exemples):
|
||||
return "\(vues) appareil(s) Bluetooth vus, aucun ne ressemble à une "
|
||||
+ "montre Polar. Vus : \(exemples.joined(separator: ", ")). "
|
||||
+ "Si la montre est dans cette liste sous un autre nom, c'est le "
|
||||
+ "tri qui est trop strict ; si elle n'y est pas, elle ne "
|
||||
+ "s'annonce pas — probablement parce qu'elle est déjà liée à "
|
||||
+ "Polar Flow."
|
||||
|
||||
case .echeanceDepassee(let seconds):
|
||||
return "la montre n'a pas répondu en \(Int(seconds)) s. Réveiller son "
|
||||
+ "écran et la rapprocher de l'iPhone ; vérifier que Polar Flow "
|
||||
+ "est bien fermée."
|
||||
|
||||
|
||||
case .sdkSilencieux(_, false, _):
|
||||
// ⚠️ Ne PAS conclure « le scan n'a pas démarré » : le publisher de
|
||||
// `search()` reste également muet quand le scan tourne mais ne
|
||||
// découvre rien — `scanSubject` n'émet que sur découverte, et le
|
||||
// `prepend(knownSessions)` d'une liste vide n'émet pas. Une version
|
||||
// précédente affirmait le contraire et a fait chercher au mauvais
|
||||
// endroit pendant trois itérations.
|
||||
return "le SDK Polar n'a remonté aucun appareil : soit son scan n'a "
|
||||
+ "pas démarré, soit il tourne sans rien découvrir. L'état "
|
||||
+ "Bluetooth, lui, a bien été atteint avant le scan."
|
||||
|
||||
case .sdkSilencieux(_, _, .some(let erreur)):
|
||||
return "le SDK Polar a répondu par une erreur : \(erreur)"
|
||||
|
||||
case .sdkSilencieux(0, _, _):
|
||||
return "aucun appareil Bluetooth alentour, même en scan direct. "
|
||||
+ "La radio fonctionne mais ne voit rien : montre éteinte, hors "
|
||||
+ "de portée, ou déjà connectée à un autre appareil de façon "
|
||||
+ "exclusive."
|
||||
|
||||
case .sdkSilencieux(let bruts, _, _):
|
||||
return "\(bruts) appareil(s) Bluetooth vus en scan direct, mais le SDK "
|
||||
+ "Polar n'en remonte aucun. La radio va bien : le problème est "
|
||||
+ "dans l'intégration du SDK, pas dans la montre ni dans l'iPhone."
|
||||
|
||||
|
||||
case .watchNotFound(let seconds, 0, _, _):
|
||||
// Rien du tout : le problème est en amont de la montre.
|
||||
return "aucun appareil Bluetooth détecté en \(Int(seconds)) s. "
|
||||
+ "Vérifier, dans l'ordre : le Bluetooth activé sur l'iPhone ; "
|
||||
+ "l'autorisation Bluetooth accordée à coach (Réglages → coach) ; "
|
||||
+ "la montre allumée et à portée."
|
||||
|
||||
case .watchNotFound(let seconds, let vues, _, let muettes) where muettes > 0:
|
||||
// Le service est là mais ne répond pas : canal déjà pris.
|
||||
//
|
||||
// ⚠️ Ce message conseillait « fermer l'app Polar Flow ». C'était
|
||||
// faux, et mesuré comme tel le 31/08/2026 : la montre affichait
|
||||
// « connexion impossible » pendant que les réglages iOS la
|
||||
// disaient TOUJOURS connectée à Flow. iOS maintient le lien d'un
|
||||
// accessoire appairé, app fermée ou non — fermer l'app ne rend pas
|
||||
// le canal.
|
||||
//
|
||||
// Ce qui l'a rendu, le 01/09 : une montre fraîchement
|
||||
// réinitialisée, donc pas encore liée à Flow. Redémarrer la montre
|
||||
// libère aussi son canal, mais Flow le reprend.
|
||||
return "\(vues) appareil(s) vu(s), \(muettes) portant PsFTP mais sans "
|
||||
+ "réponse en \(Int(seconds)) s. Le canal PsFTP de la Vantage "
|
||||
+ "n'accepte qu'une session, et Polar Flow la détient tant que "
|
||||
+ "la montre lui est appairée — fermer l'app n'y change rien, "
|
||||
+ "iOS maintient le lien. Redémarrer la montre (appui long sur "
|
||||
+ "OK) libère le canal ; il faut écrire dans la foulée, avant "
|
||||
+ "que Flow ne le reprenne. Chemin fiable : le câble."
|
||||
|
||||
case .watchNotFound(let seconds, let vues, _, _):
|
||||
// Des appareils, mais aucun ne porte le service.
|
||||
// ⚠️ Ce message parlait d'« inconnue restante ». Elle a été levée le
|
||||
// 31/08/2026 : la montre EST atteignable et expose bien PsFTP
|
||||
// (« 12 appareils vus, 1 portant PsFTP ») — quand elle est
|
||||
// connectée. N'en voir aucun est donc autre chose : montre éteinte,
|
||||
// hors de portée, ou pas encore connectée à l'iPhone.
|
||||
return "\(vues) appareil(s) Bluetooth vu(s) en \(Int(seconds)) s, aucun "
|
||||
+ "n'expose PsFTP. La Vantage expose bien ce service quand elle "
|
||||
+ "est connectée (vérifié le 31/08) : la chercher plutôt du côté "
|
||||
+ "de la montre — allumée, à portée, et reliée à l'iPhone."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<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>
|
||||
<true/>
|
||||
<key>WKBackgroundModes</key>
|
||||
|
||||
383
ios/App/CoachWatch/IntervalEngine.swift
Normal file
383
ios/App/CoachWatch/IntervalEngine.swift
Normal file
@@ -0,0 +1,383 @@
|
||||
import Foundation
|
||||
|
||||
/* Déroulé d'une séance structurée au poignet.
|
||||
*
|
||||
* **Pourquoi ce fichier existe.** WorkoutKit ne sait pas exécuter une séance
|
||||
* dans une app tierce : son seul point d'exécution public est
|
||||
* `WorkoutPlan.openInWorkoutApp()`, qui ouvre l'app *Exercice* d'Apple. Pour
|
||||
* guider les intervalles du plan coach dans NOTRE app, la machine à états doit
|
||||
* être écrite ici.
|
||||
*
|
||||
* Le moteur est **volontairement pur** — aucun import HealthKit, aucun timer,
|
||||
* aucune horloge interne. Il ne fait que répondre à la question « où en
|
||||
* sommes-nous ? » à partir du temps et de la distance que l'appelant lui
|
||||
* pousse. Trois conséquences :
|
||||
*
|
||||
* - il se teste intégralement sur Linux (cf. `tests-linux/`), là où
|
||||
* `WorkoutManager` ne le peut pas ;
|
||||
* - le temps de référence est poussé par l'appelant ;
|
||||
*
|
||||
* ⚠️ **CE COMMENTAIRE AFFIRMAIT LE CONTRAIRE DE SA SOURCE** (relevé le
|
||||
* 2026-08-20, avant tout câblage). Il disait que
|
||||
* `HKLiveWorkoutBuilder.elapsedTime` « exclut déjà les pauses », donc que
|
||||
* le moteur n'avait rien à savoir de la pause. La doc Apple dit
|
||||
* l'inverse, mot pour mot : « The elapsed time for the workout based on
|
||||
* the builder's current contents, **including pauses**. »
|
||||
* (developer.apple.com/documentation/healthkit/hkliveworkoutbuilder/elapsedtime)
|
||||
*
|
||||
* Conséquence si on câble le moteur sur cette propriété telle quelle :
|
||||
* une pause de 5 min ferait avancer le déroulé de 5 min d'effort. Un
|
||||
* fractionné mis en pause pour traverser une route se déroulerait tout
|
||||
* seul, à l'arrêt.
|
||||
*
|
||||
* La propriété qui exclut réellement les pauses est
|
||||
* `HKWorkoutBuilder.elapsedTime(at:)` — « The duration of a workout
|
||||
* doesn't include intervals between pause and resume events. » Ce n'est
|
||||
* PAS la même API, et les deux textes d'Apple se contredisent
|
||||
* frontalement sur ce point : à trancher avant le câblage.
|
||||
*
|
||||
* Le moteur, lui, reste correct : il ne fait qu'intégrer ce qu'on lui
|
||||
* pousse. C'est l'appelant qui devra fournir un temps réellement actif ;
|
||||
*
|
||||
* - il ignore donc l'auto-pause de la montre, à condition que la source de
|
||||
* temps ci-dessus soit correcte ;
|
||||
* - il est rejouable : réinjecter la même suite de ticks redonne le même
|
||||
* déroulé, ce qui rend la reprise après crash triviale
|
||||
* (`handleActiveWorkoutRecovery`).
|
||||
*/
|
||||
|
||||
// MARK: - Modèle
|
||||
|
||||
/// Objectif de fin d'une étape.
|
||||
///
|
||||
/// `open` décrit une étape qui ne se termine que sur ordre de l'utilisateur —
|
||||
/// l'échauffement libre « pars quand tu es prêt », ou le retour au calme.
|
||||
public enum StepGoal: Equatable, Sendable {
|
||||
case time(TimeInterval)
|
||||
case distance(Double) // mètres
|
||||
case open
|
||||
}
|
||||
|
||||
/// Une étape du plan, telle qu'elle arrive de l'iPhone.
|
||||
///
|
||||
/// Les bornes de FC sont transportées **en bpm ET en numéro de zone**. Les deux
|
||||
/// sont nécessaires : la zone sert à l'affichage (« Z2 »), les bpm sont la seule
|
||||
/// cible exécutable, et elles proviennent de `app.current_zones()` — zones
|
||||
/// Karvonen sous bêtabloquant, jamais un % de FCmax générique.
|
||||
public struct CoachPlanStep: Codable, Equatable, Sendable {
|
||||
public enum Kind: String, Codable, Sendable {
|
||||
case warmup, work, recovery, cooldown
|
||||
}
|
||||
|
||||
public var kind: Kind
|
||||
public var durationSec: Double?
|
||||
public var distanceM: Double?
|
||||
public var hrZone: Int?
|
||||
public var hrMinBpm: Double?
|
||||
public var hrMaxBpm: Double?
|
||||
public var label: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case durationSec = "duration_sec"
|
||||
case distanceM = "distance_m"
|
||||
case hrZone = "hr_zone"
|
||||
case hrMinBpm = "hr_bpm_min"
|
||||
case hrMaxBpm = "hr_bpm_max"
|
||||
case label
|
||||
}
|
||||
|
||||
public init(kind: Kind,
|
||||
durationSec: Double? = nil,
|
||||
distanceM: Double? = nil,
|
||||
hrZone: Int? = nil,
|
||||
hrMinBpm: Double? = nil,
|
||||
hrMaxBpm: Double? = nil,
|
||||
label: String? = nil) {
|
||||
self.kind = kind
|
||||
self.durationSec = durationSec
|
||||
self.distanceM = distanceM
|
||||
self.hrZone = hrZone
|
||||
self.hrMinBpm = hrMinBpm
|
||||
self.hrMaxBpm = hrMaxBpm
|
||||
self.label = label
|
||||
}
|
||||
|
||||
/// La durée prime sur la distance quand les deux sont fournies : le plan
|
||||
/// coach est écrit en temps, la distance n'est qu'une alternative.
|
||||
public var goal: StepGoal {
|
||||
if let d = durationSec, d > 0 { return .time(d) }
|
||||
if let m = distanceM, m > 0 { return .distance(m) }
|
||||
return .open
|
||||
}
|
||||
|
||||
/// Libellé affichable, avec repli sur le type d'étape. Le plan porte les
|
||||
/// consignes réelles dans `label` (« cadence 170+, foulée courte ») : c'est
|
||||
/// cette intention qu'on veut au poignet, pas un « Course » générique.
|
||||
public var displayLabel: String {
|
||||
if let l = label?.trimmingCharacters(in: .whitespacesAndNewlines), !l.isEmpty {
|
||||
return l
|
||||
}
|
||||
switch kind {
|
||||
case .warmup: return "Échauffement"
|
||||
case .work: return "Effort"
|
||||
case .recovery: return "Récupération"
|
||||
case .cooldown: return "Retour au calme"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// La séance du jour, poussée par l'iPhone.
|
||||
public struct CoachSessionPlan: Codable, Equatable, Sendable {
|
||||
public var date: String
|
||||
public var sport: String
|
||||
public var title: String?
|
||||
public var steps: [CoachPlanStep]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case date, sport, title, steps
|
||||
}
|
||||
|
||||
public init(date: String, sport: String, title: String? = nil, steps: [CoachPlanStep]) {
|
||||
self.date = date
|
||||
self.sport = sport
|
||||
self.title = title
|
||||
self.steps = steps
|
||||
}
|
||||
|
||||
/// Durée totale prévue. `nil` dès qu'une étape est ouverte ou en distance :
|
||||
/// mieux vaut ne rien annoncer qu'annoncer un total faux.
|
||||
public var plannedDurationSec: TimeInterval? {
|
||||
var total: TimeInterval = 0
|
||||
for step in steps {
|
||||
guard case .time(let d) = step.goal else { return nil }
|
||||
total += d
|
||||
}
|
||||
return total
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Moteur
|
||||
|
||||
/// Ce que le moteur signale à l'appelant entre deux ticks.
|
||||
///
|
||||
/// Les événements sont **rendus, jamais joués** par le moteur : c'est
|
||||
/// `WorkoutManager` qui décide d'un haptique ou d'un `HKWorkoutEvent`. Cette
|
||||
/// séparation est ce qui garde le moteur testable.
|
||||
public enum IntervalEvent: Equatable, Sendable {
|
||||
case stepStarted(index: Int)
|
||||
case stepFinished(index: Int)
|
||||
case planFinished
|
||||
/// Émis une seule fois par étape, à mi-parcours d'une étape mesurable.
|
||||
case halfway(index: Int)
|
||||
|
||||
/// Vrai si l'événement fait changer d'étape.
|
||||
///
|
||||
/// L'annonce de mi-parcours voyage dans le même flux que les transitions —
|
||||
/// pratique pour l'appelant, qui n'a qu'une boucle à écrire — mais elle ne
|
||||
/// déplace rien. Sans ce distinguo, « rien n'a bougé » et « aucun événement »
|
||||
/// se confondent, et c'est exactement l'erreur que les premiers tests de ce
|
||||
/// fichier ont commise.
|
||||
public var isTransition: Bool {
|
||||
switch self {
|
||||
case .stepStarted, .stepFinished, .planFinished: return true
|
||||
case .halfway: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == IntervalEvent {
|
||||
/// Les seuls événements qui déplacent le curseur de séance.
|
||||
public var transitions: [IntervalEvent] { filter(\.isTransition) }
|
||||
}
|
||||
|
||||
/// Instantané de progression, destiné à l'affichage.
|
||||
public struct IntervalProgress: Equatable, Sendable {
|
||||
public var stepIndex: Int
|
||||
public var step: CoachPlanStep?
|
||||
public var nextStep: CoachPlanStep?
|
||||
/// Temps passé dans l'étape courante (secondes actives).
|
||||
public var elapsedInStep: TimeInterval
|
||||
/// Temps restant, `nil` pour une étape ouverte ou en distance.
|
||||
public var remainingInStep: TimeInterval?
|
||||
/// Distance restante en mètres, `nil` si l'étape n'est pas en distance.
|
||||
public var remainingDistanceM: Double?
|
||||
/// 0…1, `nil` pour une étape ouverte.
|
||||
public var fraction: Double?
|
||||
public var isFinished: Bool
|
||||
}
|
||||
|
||||
/// Machine à états du déroulé. `struct` mutable : pas d'état caché, copiable,
|
||||
/// donc inspectable dans les tests.
|
||||
public struct IntervalEngine: Equatable, Sendable {
|
||||
public let steps: [CoachPlanStep]
|
||||
|
||||
public private(set) var index: Int = 0
|
||||
public private(set) var isFinished: Bool = false
|
||||
|
||||
/// Origine de l'étape courante, dans le référentiel du builder.
|
||||
private var stepStartElapsed: TimeInterval = 0
|
||||
private var stepStartDistance: Double = 0
|
||||
private var lastElapsed: TimeInterval = 0
|
||||
private var lastDistance: Double = 0
|
||||
private var halfwayAnnounced = false
|
||||
|
||||
public init(steps: [CoachPlanStep]) {
|
||||
self.steps = steps
|
||||
self.isFinished = steps.isEmpty
|
||||
}
|
||||
|
||||
public init(plan: CoachSessionPlan) {
|
||||
self.init(steps: plan.steps)
|
||||
}
|
||||
|
||||
public var currentStep: CoachPlanStep? {
|
||||
guard !isFinished, steps.indices.contains(index) else { return nil }
|
||||
return steps[index]
|
||||
}
|
||||
|
||||
public var nextStep: CoachPlanStep? {
|
||||
let n = index + 1
|
||||
guard !isFinished, steps.indices.contains(n) else { return nil }
|
||||
return steps[n]
|
||||
}
|
||||
|
||||
/// Pousse l'état de la séance et récupère les transitions franchies.
|
||||
///
|
||||
/// `elapsed` est le temps **actif** (`HKLiveWorkoutBuilder.elapsedTime`),
|
||||
/// `distance` la distance cumulée en mètres depuis le départ.
|
||||
///
|
||||
/// ⚠️ Une boucle, pas un `if` : un tick peut arriver en retard — l'app est
|
||||
/// suspendue poignet baissé, la collecte HealthKit est irrégulière — et
|
||||
/// franchir **plusieurs** étapes courtes d'un coup. Traiter une seule
|
||||
/// transition par tick ferait dériver le déroulé sans que rien ne le dise.
|
||||
@discardableResult
|
||||
public mutating func update(elapsed: TimeInterval, distance: Double) -> [IntervalEvent] {
|
||||
guard !isFinished else { return [] }
|
||||
|
||||
// Le temps actif ne recule pas ; la distance non plus. Un recul signale
|
||||
// un appelant fautif : on borne plutôt que de produire des durées
|
||||
// négatives qui se propageraient dans l'affichage.
|
||||
lastElapsed = max(elapsed, lastElapsed)
|
||||
lastDistance = max(distance, lastDistance)
|
||||
|
||||
var events: [IntervalEvent] = []
|
||||
if !hasStarted {
|
||||
hasStarted = true
|
||||
events.append(.stepStarted(index: 0))
|
||||
}
|
||||
|
||||
while !isFinished, let step = currentStep, isComplete(step) {
|
||||
events.append(.stepFinished(index: index))
|
||||
advanceIndex(at: lastElapsed, distance: lastDistance)
|
||||
if isFinished {
|
||||
events.append(.planFinished)
|
||||
} else {
|
||||
events.append(.stepStarted(index: index))
|
||||
}
|
||||
}
|
||||
|
||||
if !halfwayAnnounced, let f = progress().fraction, f >= 0.5, !isFinished {
|
||||
halfwayAnnounced = true
|
||||
events.append(.halfway(index: index))
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
/// Termine l'étape courante sur ordre de l'utilisateur (bouton « suivant »,
|
||||
/// ou fin d'une étape ouverte).
|
||||
@discardableResult
|
||||
public mutating func advanceManually() -> [IntervalEvent] {
|
||||
guard !isFinished, currentStep != nil else { return [] }
|
||||
var events: [IntervalEvent] = [.stepFinished(index: index)]
|
||||
advanceIndex(at: lastElapsed, distance: lastDistance)
|
||||
events.append(isFinished ? .planFinished : .stepStarted(index: index))
|
||||
return events
|
||||
}
|
||||
|
||||
public func progress() -> IntervalProgress {
|
||||
guard !isFinished, let step = currentStep else {
|
||||
return IntervalProgress(stepIndex: index, step: nil, nextStep: nil,
|
||||
elapsedInStep: 0, remainingInStep: nil,
|
||||
remainingDistanceM: nil, fraction: nil,
|
||||
isFinished: true)
|
||||
}
|
||||
let inStep = max(0, lastElapsed - stepStartElapsed)
|
||||
var remaining: TimeInterval?
|
||||
var remainingDistance: Double?
|
||||
var fraction: Double?
|
||||
|
||||
switch step.goal {
|
||||
case .time(let target):
|
||||
remaining = max(0, target - inStep)
|
||||
fraction = target > 0 ? min(1, inStep / target) : nil
|
||||
case .distance(let target):
|
||||
let done = max(0, lastDistance - stepStartDistance)
|
||||
remainingDistance = max(0, target - done)
|
||||
fraction = target > 0 ? min(1, done / target) : nil
|
||||
case .open:
|
||||
break
|
||||
}
|
||||
|
||||
return IntervalProgress(stepIndex: index,
|
||||
step: step,
|
||||
nextStep: nextStep,
|
||||
elapsedInStep: inStep,
|
||||
remainingInStep: remaining,
|
||||
remainingDistanceM: remainingDistance,
|
||||
fraction: fraction,
|
||||
isFinished: false)
|
||||
}
|
||||
|
||||
// MARK: Privé
|
||||
|
||||
private var hasStarted = false
|
||||
|
||||
private func isComplete(_ step: CoachPlanStep) -> Bool {
|
||||
switch step.goal {
|
||||
case .time(let target):
|
||||
return (lastElapsed - stepStartElapsed) >= target
|
||||
case .distance(let target):
|
||||
return (lastDistance - stepStartDistance) >= target
|
||||
case .open:
|
||||
// Une étape ouverte ne se termine JAMAIS toute seule : seul
|
||||
// `advanceManually()` la clôt. Sinon la séance défilerait d'un coup.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Fait démarrer l'étape suivante à la frontière **théorique** de l'étape
|
||||
/// qui vient de finir, pas à l'instant du tick.
|
||||
///
|
||||
/// ⚠️ C'est la subtilité de tout ce fichier. Si un tick arrive 3 s après la
|
||||
/// fin d'un intervalle d'une minute, caler le départ de la suivante sur ce
|
||||
/// tick lui offre 3 s de rab — et l'erreur **s'accumule** à chaque
|
||||
/// répétition. Sur un 9×(1'/1'), le décalage final se compte en dizaines de
|
||||
/// secondes.
|
||||
private mutating func advanceIndex(at elapsed: TimeInterval, distance: Double) {
|
||||
if let step = currentStep {
|
||||
switch step.goal {
|
||||
case .time(let target):
|
||||
stepStartElapsed += target
|
||||
stepStartDistance = distance
|
||||
case .distance(let target):
|
||||
stepStartDistance += target
|
||||
stepStartElapsed = elapsed
|
||||
case .open:
|
||||
stepStartElapsed = elapsed
|
||||
stepStartDistance = distance
|
||||
}
|
||||
}
|
||||
// Une étape ouverte ou terminée à la main ne peut pas laisser l'origine
|
||||
// derrière le tick courant.
|
||||
stepStartElapsed = min(stepStartElapsed, elapsed)
|
||||
stepStartDistance = min(stepStartDistance, distance)
|
||||
|
||||
index += 1
|
||||
halfwayAnnounced = false
|
||||
if index >= steps.count {
|
||||
isFinished = true
|
||||
}
|
||||
}
|
||||
}
|
||||
340
ios/App/CoachWatch/LocationTracker.swift
Normal file
340
ios/App/CoachWatch/LocationTracker.swift
Normal file
@@ -0,0 +1,340 @@
|
||||
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.
|
||||
///
|
||||
/// ⚠️ **Une trace ne peut PAS être sauvegardée sans workout.** Une version
|
||||
/// antérieure de ce commentaire l'affirmait ; c'est faux, vérifié à la
|
||||
/// source le 21/08 : la signature est
|
||||
/// `finishRoute(with workout: HKWorkout, metadata:)` — non optionnelle —
|
||||
/// et Apple précise « You must have already saved this workout to the
|
||||
/// HealthKit store ». Il n'existe aucune API pour clore une route
|
||||
/// orpheline. Le cas « montre verrouillée », où `finishWorkout()` rend
|
||||
/// `nil` sans erreur, se traite donc **en amont** : `WorkoutManager` va
|
||||
/// rechercher dans HealthKit le workout qui vient d'y être écrit. Ici, si
|
||||
/// aucun workout n'arrive, il ne reste qu'à jeter la trace explicitement
|
||||
/// (`discardRoute()`) — un builder abandonné sans `discard()` laisse ses
|
||||
/// données en suspens.
|
||||
///
|
||||
/// Ne fait rien 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 {
|
||||
// Séance en salle : rien à clore. Volontairement SANS `discard()` —
|
||||
// c'est le chemin le plus fréquent, et il fonctionnait tel quel ;
|
||||
// on n'y introduit pas un appel qu'aucun build n'a validé.
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Abandonne la trace en cours.
|
||||
///
|
||||
/// Seul recours quand aucun workout n'a pu être associé : sans `discard()`,
|
||||
/// le builder garde ses données côté HealthKit et « any further calls to
|
||||
/// the builder raise an exception ». La sortie existera alors sans
|
||||
/// parcours — perte réelle, à tracer comme telle plutôt qu'à masquer.
|
||||
func discardRoute() {
|
||||
guard let builder = routeBuilder else { return }
|
||||
self.routeBuilder = nil
|
||||
builder.discard()
|
||||
locationLog.error("trace abandonnee : aucun workout a associer (\(self.filter.acceptedCount) points perdus)")
|
||||
}
|
||||
|
||||
// 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
|
||||
// sur la vitesse moyenne dérivée de la distance.
|
||||
let speedType = Self.speedType(for: activityType)
|
||||
let read: Set<HKObjectType> = [hr, energy, distanceType, speedType]
|
||||
let share: Set<HKSampleType> = [HKQuantityType.workoutType(), hr, energy, distanceType]
|
||||
// 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)
|
||||
@@ -66,6 +76,12 @@ final class WorkoutManager: NSObject, ObservableObject {
|
||||
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
|
||||
@@ -87,6 +103,19 @@ final class WorkoutManager: NSObject, ObservableObject {
|
||||
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
|
||||
@@ -264,14 +293,35 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
||||
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 {
|
||||
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.finishWorkout()
|
||||
// ⚠️ `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
|
||||
@@ -284,6 +334,46 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
1
tests-linux/Sources/CoachModel/IntervalEngine.swift
Symbolic link
1
tests-linux/Sources/CoachModel/IntervalEngine.swift
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../ios/App/CoachWatch/IntervalEngine.swift
|
||||
1
tests-linux/Sources/CoachModel/PolarPftpStep.swift
Symbolic link
1
tests-linux/Sources/CoachModel/PolarPftpStep.swift
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../ios/App/App/PolarPftpStep.swift
|
||||
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
|
||||
267
tests-linux/Tests/CoachModelTests/IntervalEngineTests.swift
Normal file
267
tests-linux/Tests/CoachModelTests/IntervalEngineTests.swift
Normal file
@@ -0,0 +1,267 @@
|
||||
import XCTest
|
||||
@testable import CoachModel
|
||||
|
||||
/* Le déroulé d'une séance structurée au poignet.
|
||||
*
|
||||
* Ces tests portent sur ce qu'aucun build Xcode ne rattrape : la dérive
|
||||
* silencieuse. Un intervalle qui démarre trois secondes trop tard ne casse
|
||||
* rien, ne lève rien, et se voit seulement au neuvième tour — sur le terrain,
|
||||
* pas en relecture.
|
||||
*/
|
||||
final class IntervalEngineTests: XCTestCase {
|
||||
|
||||
// Séance réelle du plan (CDC · reprise) : 5' marche, 3×(1' course / 1'
|
||||
// marche), 5' marche. C'est le gabarit le plus fréquent chez l'utilisateur.
|
||||
private func cdcPlan(reps: Int = 3) -> [CoachPlanStep] {
|
||||
var steps: [CoachPlanStep] = [
|
||||
CoachPlanStep(kind: .warmup, durationSec: 300, hrZone: 1, label: "5 min marche")
|
||||
]
|
||||
for _ in 0..<reps {
|
||||
steps.append(CoachPlanStep(kind: .work, durationSec: 60, hrZone: 2,
|
||||
label: "1' course, cadence 170+"))
|
||||
steps.append(CoachPlanStep(kind: .recovery, durationSec: 60, hrZone: 1,
|
||||
label: "1' marche"))
|
||||
}
|
||||
steps.append(CoachPlanStep(kind: .cooldown, durationSec: 300, hrZone: 1,
|
||||
label: "5 min marche"))
|
||||
return steps
|
||||
}
|
||||
|
||||
// MARK: Déroulé nominal
|
||||
|
||||
func testFirstStepIsAnnouncedOnFirstTick() {
|
||||
var engine = IntervalEngine(steps: cdcPlan())
|
||||
let events = engine.update(elapsed: 0, distance: 0)
|
||||
XCTAssertEqual(events, [.stepStarted(index: 0)])
|
||||
XCTAssertEqual(engine.index, 0)
|
||||
XCTAssertEqual(engine.currentStep?.label, "5 min marche")
|
||||
XCTAssertEqual(engine.nextStep?.kind, .work)
|
||||
}
|
||||
|
||||
func testStepBoundaryEmitsFinishThenStart() {
|
||||
var engine = IntervalEngine(steps: cdcPlan())
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
XCTAssertTrue(engine.update(elapsed: 299, distance: 0).transitions.isEmpty,
|
||||
"Une seconde avant la fin, rien ne doit basculer")
|
||||
|
||||
let events = engine.update(elapsed: 300, distance: 0)
|
||||
XCTAssertEqual(events, [.stepFinished(index: 0), .stepStarted(index: 1)])
|
||||
XCTAssertEqual(engine.index, 1)
|
||||
}
|
||||
|
||||
func testWholeSessionRunsToCompletion() {
|
||||
var engine = IntervalEngine(steps: cdcPlan())
|
||||
var finished = false
|
||||
// 16 min de séance échantillonnées à la seconde.
|
||||
for t in stride(from: 0.0, through: 960.0, by: 1.0) {
|
||||
if engine.update(elapsed: t, distance: 0).contains(.planFinished) {
|
||||
finished = true
|
||||
XCTAssertEqual(t, 960, "5 min + 3×(1'+1') + 5 min = 16 min = 960 s")
|
||||
break
|
||||
}
|
||||
}
|
||||
XCTAssertTrue(finished)
|
||||
XCTAssertTrue(engine.isFinished)
|
||||
XCTAssertNil(engine.currentStep)
|
||||
}
|
||||
|
||||
// MARK: La dérive — le cœur du sujet
|
||||
|
||||
func testLateTickDoesNotGrantExtraTimeToTheNextStep() {
|
||||
// L'app est suspendue poignet baissé : le tick suivant arrive 3 s en
|
||||
// retard. L'intervalle d'une minute qui démarre ne doit PAS hériter de
|
||||
// ces 3 secondes.
|
||||
var engine = IntervalEngine(steps: cdcPlan())
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
engine.update(elapsed: 303, distance: 0) // 3 s de retard sur la frontière
|
||||
|
||||
XCTAssertEqual(engine.index, 1)
|
||||
XCTAssertEqual(engine.progress().elapsedInStep, 3, accuracy: 0.001,
|
||||
"L'étape a commencé à 300 s, pas à 303 s")
|
||||
XCTAssertEqual(engine.progress().remainingInStep ?? -1, 57, accuracy: 0.001)
|
||||
|
||||
// Et la frontière suivante tombe bien à 360 s, pas à 363 s.
|
||||
XCTAssertTrue(engine.update(elapsed: 359, distance: 0).transitions.isEmpty)
|
||||
XCTAssertEqual(engine.update(elapsed: 360, distance: 0),
|
||||
[.stepFinished(index: 1), .stepStarted(index: 2)])
|
||||
}
|
||||
|
||||
func testNoCumulativeDriftOverManyRepetitions() {
|
||||
// Neuf répétitions, chaque tick arrivant systématiquement en retard de
|
||||
// 2,5 s. Sans frontières théoriques, l'erreur s'accumulerait à ~22 s.
|
||||
var engine = IntervalEngine(steps: cdcPlan(reps: 9))
|
||||
var t = 0.0
|
||||
var lastStart: TimeInterval = 0
|
||||
var events: [IntervalEvent] = []
|
||||
while !engine.isFinished && t < 3000 {
|
||||
events = engine.update(elapsed: t, distance: 0)
|
||||
if events.contains(where: { if case .stepStarted = $0 { return true }; return false }) {
|
||||
lastStart = t
|
||||
}
|
||||
t += 2.5
|
||||
}
|
||||
XCTAssertTrue(engine.isFinished)
|
||||
// Dernière étape (retour au calme) : démarre à 1380 s en théorie.
|
||||
// Avec des ticks tous les 2,5 s, on la détecte au plus tard 2,5 s après.
|
||||
XCTAssertEqual(lastStart, 1380, accuracy: 2.5,
|
||||
"La fin de séance ne doit pas dériver avec les répétitions")
|
||||
}
|
||||
|
||||
func testSeveralShortStepsCrossedInASingleTick() {
|
||||
// Tick manqué de 30 s sur des intervalles de 10 s : trois étapes
|
||||
// franchies d'un coup. Traiter une seule transition par tick ferait
|
||||
// décrocher le déroulé sans rien signaler.
|
||||
let steps = (0..<6).map { i in
|
||||
CoachPlanStep(kind: i.isMultiple(of: 2) ? .work : .recovery,
|
||||
durationSec: 10, label: "step\(i)")
|
||||
}
|
||||
var engine = IntervalEngine(steps: steps)
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
let events = engine.update(elapsed: 30, distance: 0)
|
||||
|
||||
XCTAssertEqual(engine.index, 3)
|
||||
XCTAssertEqual(events, [
|
||||
.stepFinished(index: 0), .stepStarted(index: 1),
|
||||
.stepFinished(index: 1), .stepStarted(index: 2),
|
||||
.stepFinished(index: 2), .stepStarted(index: 3),
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: Étapes en distance et étapes ouvertes
|
||||
|
||||
func testDistanceStepCompletesOnDistanceNotTime() {
|
||||
let steps = [
|
||||
CoachPlanStep(kind: .work, distanceM: 1000, label: "1 km"),
|
||||
CoachPlanStep(kind: .recovery, durationSec: 60, label: "récup"),
|
||||
]
|
||||
var engine = IntervalEngine(steps: steps)
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
|
||||
XCTAssertTrue(engine.update(elapsed: 600, distance: 999).transitions.isEmpty,
|
||||
"Dix minutes ne terminent pas une étape en distance")
|
||||
XCTAssertEqual(engine.progress().remainingDistanceM ?? -1, 1, accuracy: 0.001)
|
||||
|
||||
let events = engine.update(elapsed: 610, distance: 1000)
|
||||
XCTAssertEqual(events, [.stepFinished(index: 0), .stepStarted(index: 1)])
|
||||
}
|
||||
|
||||
func testOpenStepNeverEndsByItself() {
|
||||
let steps = [
|
||||
CoachPlanStep(kind: .warmup, label: "Pars quand tu es prêt"),
|
||||
CoachPlanStep(kind: .work, durationSec: 60, label: "effort"),
|
||||
]
|
||||
var engine = IntervalEngine(steps: steps)
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
XCTAssertTrue(engine.update(elapsed: 3600, distance: 8000).isEmpty,
|
||||
"Une étape ouverte ne se termine que sur ordre")
|
||||
XCTAssertNil(engine.progress().fraction)
|
||||
XCTAssertNil(engine.progress().remainingInStep)
|
||||
|
||||
let events = engine.advanceManually()
|
||||
XCTAssertEqual(events, [.stepFinished(index: 0), .stepStarted(index: 1)])
|
||||
// L'effort démarre maintenant, pas au début de la séance.
|
||||
XCTAssertEqual(engine.progress().remainingInStep ?? -1, 60, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testManualSkipStartsNextStepNow() {
|
||||
var engine = IntervalEngine(steps: cdcPlan())
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
engine.update(elapsed: 120, distance: 0) // au milieu de l'échauffement
|
||||
let events = engine.advanceManually()
|
||||
|
||||
XCTAssertEqual(events, [.stepFinished(index: 0), .stepStarted(index: 1)])
|
||||
XCTAssertEqual(engine.progress().elapsedInStep, 0, accuracy: 0.001,
|
||||
"Sauter une étape ne doit pas amputer la suivante")
|
||||
XCTAssertEqual(engine.progress().remainingInStep ?? -1, 60, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testManualAdvanceOnLastStepFinishesThePlan() {
|
||||
var engine = IntervalEngine(steps: [CoachPlanStep(kind: .work, label: "libre")])
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
XCTAssertEqual(engine.advanceManually(), [.stepFinished(index: 0), .planFinished])
|
||||
XCTAssertTrue(engine.isFinished)
|
||||
XCTAssertTrue(engine.advanceManually().isEmpty, "Plus rien après la fin")
|
||||
}
|
||||
|
||||
// MARK: Robustesse
|
||||
|
||||
func testHalfwayAnnouncedOncePerStep() {
|
||||
var engine = IntervalEngine(steps: cdcPlan())
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
XCTAssertTrue(engine.update(elapsed: 149, distance: 0).isEmpty)
|
||||
XCTAssertEqual(engine.update(elapsed: 150, distance: 0), [.halfway(index: 0)])
|
||||
XCTAssertTrue(engine.update(elapsed: 151, distance: 0).isEmpty,
|
||||
"La mi-parcours ne s'annonce qu'une fois")
|
||||
XCTAssertTrue(engine.update(elapsed: 200, distance: 0).isEmpty)
|
||||
}
|
||||
|
||||
func testGoingBackwardsInTimeIsClamped() {
|
||||
// Un appelant fautif ou une reprise après crash peut réinjecter un
|
||||
// temps antérieur. Mieux vaut borner que produire des durées négatives
|
||||
// qui remonteraient jusqu'à l'affichage.
|
||||
var engine = IntervalEngine(steps: cdcPlan())
|
||||
engine.update(elapsed: 0, distance: 0)
|
||||
engine.update(elapsed: 200, distance: 500)
|
||||
engine.update(elapsed: 10, distance: 5)
|
||||
|
||||
XCTAssertEqual(engine.progress().elapsedInStep, 200, accuracy: 0.001)
|
||||
XCTAssertEqual(engine.index, 0)
|
||||
}
|
||||
|
||||
func testEmptyPlanIsFinishedImmediately() {
|
||||
var engine = IntervalEngine(steps: [])
|
||||
XCTAssertTrue(engine.isFinished)
|
||||
XCTAssertTrue(engine.update(elapsed: 0, distance: 0).isEmpty)
|
||||
XCTAssertTrue(engine.progress().isFinished)
|
||||
}
|
||||
|
||||
func testReplayingTicksReproducesTheSameState() {
|
||||
// Propriété qui rend la reprise après crash triviale : le moteur est
|
||||
// une fonction de la suite des ticks, sans horloge interne.
|
||||
let ticks = stride(from: 0.0, through: 700.0, by: 3.0)
|
||||
var a = IntervalEngine(steps: cdcPlan())
|
||||
var b = IntervalEngine(steps: cdcPlan())
|
||||
for t in ticks { a.update(elapsed: t, distance: t * 2) }
|
||||
for t in ticks { b.update(elapsed: t, distance: t * 2) }
|
||||
XCTAssertEqual(a, b)
|
||||
XCTAssertEqual(a.progress(), b.progress())
|
||||
}
|
||||
|
||||
// MARK: Décodage du plan poussé par l'iPhone
|
||||
|
||||
func testDecodesPlanFromPhonePayload() throws {
|
||||
let json = """
|
||||
{
|
||||
"date": "2026-08-21",
|
||||
"sport": "running",
|
||||
"title": "CDC · Reprise séance 1",
|
||||
"steps": [
|
||||
{"kind": "warmup", "duration_sec": 300, "hr_zone": 1,
|
||||
"hr_bpm_min": 90, "hr_bpm_max": 105, "label": "5 min marche"},
|
||||
{"kind": "work", "duration_sec": 60, "hr_zone": 2,
|
||||
"hr_bpm_min": 110, "hr_bpm_max": 125, "label": "1' course, cadence 170+"}
|
||||
]
|
||||
}
|
||||
"""
|
||||
let plan = try JSONDecoder().decode(CoachSessionPlan.self, from: Data(json.utf8))
|
||||
XCTAssertEqual(plan.steps.count, 2)
|
||||
XCTAssertEqual(plan.steps[0].hrMaxBpm, 105)
|
||||
XCTAssertEqual(plan.steps[1].displayLabel, "1' course, cadence 170+")
|
||||
XCTAssertEqual(plan.plannedDurationSec, 360)
|
||||
XCTAssertEqual(plan.steps[1].goal, .time(60))
|
||||
}
|
||||
|
||||
func testPlannedDurationIsNilWhenAStepIsOpen() {
|
||||
let plan = CoachSessionPlan(date: "2026-08-21", sport: "running", steps: [
|
||||
CoachPlanStep(kind: .warmup, durationSec: 300),
|
||||
CoachPlanStep(kind: .work, label: "libre"),
|
||||
])
|
||||
XCTAssertNil(plan.plannedDurationSec,
|
||||
"Mieux vaut ne rien annoncer qu'annoncer un total faux")
|
||||
}
|
||||
|
||||
func testMissingLabelFallsBackToStepKind() {
|
||||
XCTAssertEqual(CoachPlanStep(kind: .recovery).displayLabel, "Récupération")
|
||||
XCTAssertEqual(CoachPlanStep(kind: .work, label: " ").displayLabel, "Effort")
|
||||
}
|
||||
}
|
||||
339
tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift
Normal file
339
tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift
Normal file
@@ -0,0 +1,339 @@
|
||||
// Les garde-fous qui empêchent de bloquer une Polar Vantage V3.
|
||||
//
|
||||
// POURQUOI CES TESTS EXISTENT VRAIMENT
|
||||
//
|
||||
// Le 2026-08-17, une requête PFTP malformée — 174 octets écrits vers un chemin
|
||||
// terminé par « / », donc « du contenu dans un dossier » — a bloqué la montre :
|
||||
// logo Polar puis écran noir, récupérée par un appui long sur OK. Le firmware ne
|
||||
// renvoie pas d'erreur applicative sur ce genre de requête, il s'effondre. Le
|
||||
// Bluetooth utilise le même protocole que l'USB : changer de transport n'a rien
|
||||
// enlevé au risque.
|
||||
//
|
||||
// Ces règles ne peuvent donc pas être vérifiées « à la relecture ». Elles sont
|
||||
// la seule chose qui se dresse entre un bug de construction de chemin et une
|
||||
// montre à plusieurs centaines de francs, et elles sont exécutées ici parce que
|
||||
// le reste du plugin (Capacitor, PolarBleSdk) ne compile que sur un Mac.
|
||||
//
|
||||
// Les mêmes règles existent côté serveur dans
|
||||
// `coach_sportif/tools/polar/polar_ftp.py::pftp_put()`. Si l'une des deux
|
||||
// change, l'autre doit suivre.
|
||||
|
||||
import XCTest
|
||||
@testable import CoachModel
|
||||
|
||||
final class PolarPftpStepTests: XCTestCase {
|
||||
|
||||
// MARK: - Les deux formes interdites
|
||||
|
||||
func testDossierAvecContenuEstRefuse() throws {
|
||||
// La requête exacte qui a planté la montre le 2026-08-17.
|
||||
let etape = PftpStep(path: "/U/0/20260831/TST/180000/TST.BPB/",
|
||||
data: Data(repeating: 0x42, count: 174))
|
||||
XCTAssertThrowsError(try etape.validate()) { erreur in
|
||||
XCTAssertTrue("\(erreur)".contains("2026-08-17"),
|
||||
"le message doit rappeler l'incident, pas seulement refuser")
|
||||
}
|
||||
}
|
||||
|
||||
func testFichierVideEstRefuse() {
|
||||
// L'inverse : sans slash final la montre attend un fichier, et un
|
||||
// fichier vide n'a aucun sens — c'est un mkdir mal écrit.
|
||||
let etape = PftpStep(path: "/U/0/20260831/TST/180000/TST.BPB", data: Data())
|
||||
XCTAssertThrowsError(try etape.validate())
|
||||
}
|
||||
|
||||
func testCheminHorsDeUZeroEstRefuse() {
|
||||
// Rien d'autre que /U/0/ n'a jamais été écrit sur cette montre : tout
|
||||
// le reste est une exploration en écriture, donc un risque de blocage.
|
||||
let etape = PftpStep(path: "/SYS/quelquechose.BPB", data: Data([1, 2, 3]))
|
||||
XCTAssertThrowsError(try etape.validate())
|
||||
}
|
||||
|
||||
// MARK: - Les deux formes valides
|
||||
|
||||
func testDossierSansContenuEstAccepte() throws {
|
||||
try PftpStep(path: "/U/0/20260831/TST/", data: Data()).validate()
|
||||
try PftpStep(path: "/U/0/20260831/TST/180000/", data: Data()).validate()
|
||||
}
|
||||
|
||||
func testFichierAvecContenuEstAccepte() throws {
|
||||
try PftpStep(path: "/U/0/20260831/TST/180000/TST.BPB",
|
||||
data: Data(repeating: 0x42, count: 240)).validate()
|
||||
}
|
||||
|
||||
// MARK: - L'en-tête PbPFtpOperation
|
||||
|
||||
func testEnTeteReproduitLaSerialisationPython() {
|
||||
// `encode_operation(PUT, path)` de polar_ftp.py :
|
||||
// 0x08 (champ 1, varint) + 0x01 (PUT)
|
||||
// 0x12 (champ 2, délimité) + longueur + chemin
|
||||
let etape = PftpStep(path: "/U/0/", data: Data())
|
||||
let attendu = Data([0x08, 0x01, 0x12, 0x05]) + Data("/U/0/".utf8)
|
||||
XCTAssertEqual(etape.header(), attendu)
|
||||
}
|
||||
|
||||
func testEnTeteAvecCheminReel() {
|
||||
let chemin = "/U/0/20260831/TST/180000/TST.BPB"
|
||||
let entete = PftpStep(path: chemin, data: Data([0x00])).header()
|
||||
XCTAssertEqual(Array(entete.prefix(3)), [0x08, 0x01, 0x12])
|
||||
XCTAssertEqual(entete[3], UInt8(chemin.utf8.count))
|
||||
XCTAssertEqual(entete.suffix(chemin.utf8.count), Data(chemin.utf8))
|
||||
}
|
||||
|
||||
func testEnTeteNePorteAucunCadrageSerie() {
|
||||
// Le préfixe [0x05, taille, taille] appartient au transport RFC76 de la
|
||||
// version USB. En Bluetooth, le SDK cadre lui-même : le reporter ici
|
||||
// produirait une requête malformée — c'est-à-dire ce qui plante la
|
||||
// montre. Le premier octet doit être le tag protobuf, jamais 0x05.
|
||||
let entete = PftpStep(path: "/U/0/test.BPB", data: Data([1])).header()
|
||||
XCTAssertEqual(entete.first, 0x08)
|
||||
}
|
||||
|
||||
// MARK: - Varint
|
||||
|
||||
func testVarintSurUnOctetEnDessousDe128() {
|
||||
XCTAssertEqual(PftpStep.varint(0), [0x00])
|
||||
XCTAssertEqual(PftpStep.varint(5), [0x05])
|
||||
XCTAssertEqual(PftpStep.varint(127), [0x7F])
|
||||
}
|
||||
|
||||
func testVarintPasseADeuxOctetsA128() {
|
||||
// Un chemin de plus de 127 caractères existe : `/U/0/<date>/TST/<heure>/`
|
||||
// plus un nom de fichier reste court, mais l'encodage doit être juste
|
||||
// pour que la montre lise le bon nombre d'octets.
|
||||
XCTAssertEqual(PftpStep.varint(128), [0x80, 0x01])
|
||||
XCTAssertEqual(PftpStep.varint(300), [0xAC, 0x02])
|
||||
}
|
||||
|
||||
func testEnTeteAvecCheminLongEncodeLaLongueurSurDeuxOctets() {
|
||||
let chemin = "/U/0/" + String(repeating: "a", count: 200)
|
||||
let entete = PftpStep(path: chemin, data: Data([1])).header()
|
||||
XCTAssertEqual(Array(entete[3...4]), PftpStep.varint(chemin.utf8.count))
|
||||
}
|
||||
|
||||
// MARK: - Description
|
||||
|
||||
func testLaDescriptionDistingueMkdirEtPut() {
|
||||
XCTAssertTrue(PftpStep(path: "/U/0/x/", data: Data()).describe.hasPrefix("mkdir"))
|
||||
XCTAssertTrue(PftpStep(path: "/U/0/x.BPB", data: Data([1])).describe.hasPrefix("put"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Les trois causes d'échec ne doivent pas se confondre
|
||||
//
|
||||
// Au premier essai Bluetooth du 2026-08-31, un message unique disait « aucune
|
||||
// montre exposant PsFTP » — sans distinguer « rien vu du tout » (Bluetooth
|
||||
// éteint, permission refusée) de « la montre est là mais ne répond pas » (canal
|
||||
// occupé par Polar Flow). Ce sont des causes opposées et des gestes différents.
|
||||
|
||||
final class PolarPftpErrorTests: XCTestCase {
|
||||
|
||||
func testAucunAppareilVuPointeVersLIPhone() {
|
||||
let e = PolarPftpError.watchNotFound(30, vues: 0, sansPsFtp: 0, muettes: 0)
|
||||
XCTAssertTrue(e.description.contains("autorisation Bluetooth"))
|
||||
XCTAssertFalse(e.description.contains("Polar Flow"),
|
||||
"sans aucun appareil vu, accuser Flow envoie sur une fausse piste")
|
||||
}
|
||||
|
||||
func testUneMontreMuettePointeVersFlow() {
|
||||
let e = PolarPftpError.watchNotFound(30, vues: 3, sansPsFtp: 2, muettes: 1)
|
||||
XCTAssertTrue(e.description.contains("Polar Flow"))
|
||||
}
|
||||
|
||||
/// ⚠️ Le message conseillait « fermer complètement l'app Polar Flow ».
|
||||
/// Mesuré faux le 31/08/2026 : la montre affichait « connexion impossible »
|
||||
/// pendant que les réglages iOS la disaient toujours connectée à Flow. iOS
|
||||
/// maintient le lien d'un accessoire appairé, app fermée ou non.
|
||||
func testLeMessageNeConseillePlusDeFermerLApp() {
|
||||
let e = PolarPftpError.watchNotFound(30, vues: 3, sansPsFtp: 2, muettes: 1)
|
||||
XCTAssertTrue(e.description.contains("fermer l'app n'y change rien"),
|
||||
"le geste inutile doit être explicitement écarté")
|
||||
XCTAssertTrue(e.description.contains("Redémarrer la montre"),
|
||||
"le geste qui libère réellement le canal doit être donné")
|
||||
XCTAssertTrue(e.description.contains("câble"),
|
||||
"le chemin fiable doit être rappelé")
|
||||
}
|
||||
|
||||
/// L'« inconnue restante » a été levée le 31/08 : la montre expose bien
|
||||
/// PsFTP quand elle est connectée. Le message ne doit plus la présenter
|
||||
/// comme une question ouverte.
|
||||
func testDesAppareilsSansPsFtpNePresententPlusUneInconnue() {
|
||||
let e = PolarPftpError.watchNotFound(30, vues: 4, sansPsFtp: 4, muettes: 0)
|
||||
XCTAssertTrue(e.description.contains("n'expose PsFTP"))
|
||||
XCTAssertTrue(e.description.contains("4 appareil"))
|
||||
XCTAssertFalse(e.description.contains("inconnue"),
|
||||
"la question a été tranchée, ne pas la rouvrir dans l'UI")
|
||||
}
|
||||
|
||||
func testLesTroisMessagesSontDistincts() {
|
||||
let messages = Set([
|
||||
PolarPftpError.watchNotFound(30, vues: 0, sansPsFtp: 0, muettes: 0).description,
|
||||
PolarPftpError.watchNotFound(30, vues: 3, sansPsFtp: 2, muettes: 1).description,
|
||||
PolarPftpError.watchNotFound(30, vues: 4, sansPsFtp: 4, muettes: 0).description,
|
||||
])
|
||||
XCTAssertEqual(messages.count, 3)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - « La radio ne marche pas » ≠ « la montre est introuvable »
|
||||
//
|
||||
// Le 2026-08-31, le scan a tourné 30 s et rendu « aucun appareil » — alors que
|
||||
// la vraie cause était en amont : iOS n'avait jamais demandé l'autorisation
|
||||
// Bluetooth, et la ligne n'apparaissait même pas dans les réglages de l'app.
|
||||
// Un message parlant de la montre envoyait chercher au mauvais endroit.
|
||||
|
||||
extension PolarPftpErrorTests {
|
||||
|
||||
func testRadioInutilisableNeParlePasDeLaMontre() {
|
||||
let e = PolarPftpError.bluetoothUnusable(
|
||||
"coach n'a pas l'autorisation d'utiliser le Bluetooth.")
|
||||
XCTAssertFalse(e.description.contains("montre"))
|
||||
XCTAssertTrue(e.description.contains("autorisation"))
|
||||
}
|
||||
|
||||
func testRadioInutilisableEstDistinctDeMontreIntrouvable() {
|
||||
let radio = PolarPftpError.bluetoothUnusable("Bluetooth désactivé").description
|
||||
let montre = PolarPftpError.watchNotFound(30, vues: 0,
|
||||
sansPsFtp: 0, muettes: 0).description
|
||||
XCTAssertNotEqual(radio, montre)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Radio muette ou SDK muet : ce n'est pas le même diagnostic
|
||||
//
|
||||
// Le 2026-08-31, le SDK ne remontait aucune session et rien ne disait si
|
||||
// CoreBluetooth lui-même voyait quelque chose. Sans cette mesure, impossible de
|
||||
// savoir s'il fallait chercher du côté de la montre ou de notre code.
|
||||
|
||||
extension PolarPftpErrorTests {
|
||||
|
||||
func testAucunAppareilEnScanDirectAccuseLEnvironnement() {
|
||||
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 0)
|
||||
XCTAssertTrue(e.description.contains("hors\u{00A0}de portée")
|
||||
|| e.description.contains("hors de portée"))
|
||||
XCTAssertFalse(e.description.contains("intégration"))
|
||||
}
|
||||
|
||||
func testDesAppareilsEnScanDirectAccusentLeSDK() {
|
||||
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 12)
|
||||
XCTAssertTrue(e.description.contains("intégration du SDK"))
|
||||
XCTAssertTrue(e.description.contains("12"))
|
||||
XCTAssertTrue(e.description.contains("La radio va bien"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - « On n'a jamais cherché » n'est pas « on n'a rien trouvé »
|
||||
//
|
||||
// `search()` du SDK commence par `monitorBleState().filter { $0 == .poweredOn }`.
|
||||
// Si cet état n'arrive pas, le publisher ne dit RIEN : ni valeur, ni fin, ni
|
||||
// erreur. Le timeout conclut alors « aucun appareil » alors que le scan n'a
|
||||
// jamais démarré. Mesuré le 2026-08-31 : 43 appareils en scan direct, 0 par le
|
||||
// SDK.
|
||||
|
||||
extension PolarPftpErrorTests {
|
||||
|
||||
func testPublisherMuetNAccusePasLaMontre() {
|
||||
// ⚠️ Assoupli le 2026-08-31 : le message affirmait « le scan n'a pas
|
||||
// démarré », ce qui était une conclusion de trop — le publisher se tait
|
||||
// aussi quand le scan tourne sans rien découvrir. Ce test vérifie
|
||||
// désormais qu'on n'accuse ni la montre, ni une cause unique.
|
||||
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
|
||||
publisherAParle: false)
|
||||
XCTAssertTrue(e.description.contains("aucun appareil"))
|
||||
XCTAssertFalse(e.description.contains("Ce n'est pas la montre"),
|
||||
"ne rien affirmer sur la montre : on n'en sait rien ici")
|
||||
}
|
||||
|
||||
func testUneErreurDuSdkEstRapporteeTelleQuelle() {
|
||||
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
|
||||
publisherAParle: true,
|
||||
erreurSdk: "bleNotReady")
|
||||
XCTAssertTrue(e.description.contains("bleNotReady"))
|
||||
}
|
||||
|
||||
func testPublisherActifSansSessionAccuseLIntegration() {
|
||||
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
|
||||
publisherAParle: true)
|
||||
XCTAssertTrue(e.description.contains("intégration du SDK"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Un envoi qui n'aboutit pas doit le dire
|
||||
//
|
||||
// Les `async` du SDK Polar n'ont aucune limite de temps. Le 2026-08-31, un envoi
|
||||
// est resté figé sans message : `waitPsFtpReady` attendait une montre qui ne
|
||||
// finissait pas la négociation. Une attente sans fin est pire qu'une erreur —
|
||||
// elle ne dit rien et n'apprend rien.
|
||||
|
||||
extension PolarPftpErrorTests {
|
||||
|
||||
func testLEcheanceDitQuoiFaire() {
|
||||
let e = PolarPftpError.echeanceDepassee(12)
|
||||
XCTAssertTrue(e.description.contains("12"))
|
||||
XCTAssertTrue(e.description.contains("écran"))
|
||||
XCTAssertTrue(e.description.contains("Polar Flow"))
|
||||
}
|
||||
|
||||
func testLEcheanceNeSeConfondPasAvecUnScanVide() {
|
||||
let echeance = PolarPftpError.echeanceDepassee(12).description
|
||||
let vide = PolarPftpError.watchNotFound(30, vues: 0,
|
||||
sansPsFtp: 0, muettes: 0).description
|
||||
XCTAssertNotEqual(echeance, vide)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ne pas conclure plus que ce qu'on a mesuré
|
||||
//
|
||||
// Le publisher de `search()` reste muet dans DEUX cas : le scan n'a pas démarré,
|
||||
// ou il tourne sans rien découvrir (`scanSubject` n'émet que sur découverte).
|
||||
// Un message affirmant le premier a fait chercher au mauvais endroit pendant
|
||||
// trois itérations du 2026-08-31.
|
||||
|
||||
extension PolarPftpErrorTests {
|
||||
|
||||
func testLeSilenceDuSdkNAffirmePasQueLeScanEstArrete() {
|
||||
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
|
||||
publisherAParle: false)
|
||||
XCTAssertTrue(e.description.contains("soit"),
|
||||
"le message doit énoncer les deux possibilités")
|
||||
XCTAssertFalse(e.description.contains("n'a jamais signalé"))
|
||||
}
|
||||
|
||||
func testAucunPolarListeCeQuiAEteVu() {
|
||||
let e = PolarPftpError.aucunPolarParmi(
|
||||
vues: 43, exemples: ["Apple Watch de Sylvain", "(sans nom)"])
|
||||
XCTAssertTrue(e.description.contains("43"))
|
||||
XCTAssertTrue(e.description.contains("Apple Watch de Sylvain"))
|
||||
XCTAssertTrue(e.description.contains("Polar Flow"),
|
||||
"l'hypothèse du lien exclusif doit être énoncée")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codes d'erreur PFTP (01/09/2026)
|
||||
|
||||
/// « errorcode 104 » rapporté par Sylvain sur un second envoi vers la même
|
||||
/// date. Ce n'était pas un refus de la montre : la session BLE s'était ouverte,
|
||||
/// et c'est notre séquence de `mkdir` qui n'était pas idempotente.
|
||||
final class PftpCodeTests: XCTestCase {
|
||||
|
||||
func test104EstBienDirectoryExists() {
|
||||
XCTAssertEqual(PftpCode.directoryExists, 104)
|
||||
}
|
||||
|
||||
func testUnMkdirSurDossierExistantEstSatisfait() {
|
||||
XCTAssertTrue(PftpCode.mkdirEstSatisfait(par: 104))
|
||||
}
|
||||
|
||||
func testLesAutresCodesRestentDesEchecs() {
|
||||
// 103 NO_SUCH_FILE_OR_DIRECTORY : le parent manque, l'ordre des mkdir
|
||||
// est en cause — surtout pas à avaler.
|
||||
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 103))
|
||||
// 105 FILE_EXISTS : l'objectif est déjà écrit. L'appelant DOIT le
|
||||
// savoir — le remplacer n'est visible qu'après redémarrage de la montre.
|
||||
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 105))
|
||||
// 106 OPERATION_NOT_PERMITTED, 108 TIMEOUT : de vrais refus.
|
||||
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 106))
|
||||
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 108))
|
||||
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 0))
|
||||
}
|
||||
}
|
||||
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