Compare commits
50 Commits
main
...
a5cbb70210
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5cbb70210 | ||
|
|
adb8177f2f | ||
|
|
157250b3eb | ||
|
|
bb1261dfd8 | ||
|
|
316a35f33b | ||
|
|
446b611afd | ||
|
|
e5c8967ea9 | ||
|
|
3bd90c8bb7 | ||
|
|
3755cafa1f | ||
|
|
d5330b8f1a | ||
|
|
c16b92ce29 | ||
|
|
a29569c05c | ||
|
|
b1e7cf6d47 | ||
|
|
e6cdc67451 | ||
|
|
263a8f694f | ||
|
|
150e78efa0 | ||
|
|
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 |
@@ -15,7 +15,8 @@ Instructions pour Claude Code sur ce projet. Lis ce fichier en début de session
|
|||||||
## 🔒 Règles non négociables
|
## 🔒 Règles non négociables
|
||||||
|
|
||||||
1. **Le code web n'est PAS ici** — pour modifier l'UI/UX, c'est `coach_sportif/web/`. Ce repo ne touche QUE le natif (Swift, Kotlin, plugins Capacitor, config).
|
1. **Le code web n'est PAS ici** — pour modifier l'UI/UX, c'est `coach_sportif/web/`. Ce repo ne touche QUE le natif (Swift, Kotlin, plugins Capacitor, config).
|
||||||
2. **Cookie inject AppDelegate** (iOS) : mécanisme critique pour l'auth WKWebView. Toute modif `AppDelegate.swift` doit préserver ce flux.
|
2. **Cookie inject AppDelegate** (iOS) : mécanisme critique pour l'auth WKWebView. Toute modif `AppDelegate.swift` doit préserver ce flux. Il vit dans `didFinishLaunchingWithOptions`, qui **reste appelé** en cycle de vie par scènes et **avant** la connexion de la scène — Apple documente la séquence, l'ordre cookie → WebView est donc garanti.
|
||||||
|
2bis. **⚠️ Cycle de vie UIScene depuis le 22/09/2026** (obligatoire iOS 27, Capacitor ≥ 8.5.2). Trois callbacks ont quitté `AppDelegate.swift` pour `SceneDelegate.swift`, et UIKit **ne les appelle plus sur l'AppDelegate même si on les y remet** : retour OAuth Google (`scene(_:openURLContexts:)`), Universal Links (`scene(_:continue:)`), flush des saisies widget (`sceneDidBecomeActive`). Aucun ne produit d'erreur de compilation s'il est mal placé — la panne est silencieuse. Le `SceneDelegate` instancie **`MainViewController`**, jamais un `CAPBridgeViewController` nu comme le template Capacitor : la sous-classe porte l'enregistrement des plugins et la config WKWebView. ⛔ Ne pas lancer `npx cap migrate` sur ce projet (le guide officiel exclut les AppDelegate à gestion d'URL personnalisée), ni `npm audit fix --force` (il redescend le CLI en v7 et supprime le support UIScene).
|
||||||
3. **Health Connect Android** : nécessite la privacy policy URL dans le manifest (validation Play Console).
|
3. **Health Connect Android** : nécessite la privacy policy URL dans le manifest (validation Play Console).
|
||||||
4. **Pas d'extrapolation** : root cause vérifiable avant tout fix natif. iOS+Android = doubles surfaces de bug.
|
4. **Pas d'extrapolation** : root cause vérifiable avant tout fix natif. iOS+Android = doubles surfaces de bug.
|
||||||
5. **Default yes** : décider et exécuter (sauf actions destructives).
|
5. **Default yes** : décider et exécuter (sauf actions destructives).
|
||||||
|
|||||||
178
COWORK.md
178
COWORK.md
@@ -76,11 +76,183 @@ ouvrait Safari**, où le cookie d'auth injecté dans la WKWebView n'existe pas :
|
|||||||
`CoachLiveView` s'ouvre d'elle-même (le JS de `/live` appelle
|
`CoachLiveView` s'ouvre d'elle-même (le JS de `/live` appelle
|
||||||
`openNativeLive`). C'est le point d'entrée qui manquait.
|
`openNativeLive`). C'est le point d'entrée qui manquait.
|
||||||
|
|
||||||
|
## 🆕 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.
|
||||||
|
|
||||||
|
## 📋 Pré-requis `feat/watch-outdoor` — checklist dev (2026-09-20)
|
||||||
|
|
||||||
|
✅ 34 commits accumulés depuis `main`. Code **complet & testé** (57 tests verts Linux).
|
||||||
|
|
||||||
|
- `IntervalEngine.swift` + `RouteFilter.swift` + `LocationTracker.swift` — déclarés dans `CoachWatch` (3 fichiers × 2 déclarations = 6 attendues)
|
||||||
|
- `CoachWorkoutKit.swift` et `CoachWorkoutObserver.swift` — modifiés pour route (enregistrement, finition, sauvegarde)
|
||||||
|
- `Info.plist` (CoachWatch) — `UIBackgroundModes = [location]` + `NSLocationWhenInUseUsageDescription` présents
|
||||||
|
- Toutes les corrections BLE/Polar revertées (chantier jeté, n'impacte pas feat/watch-outdoor)
|
||||||
|
- Branche stable, dernière corr le 20/08 soir
|
||||||
|
|
||||||
|
**À tester côté Mac avant toute release** (4 points critiques) :
|
||||||
|
1. **Permission localisation** — une sortie extérieure demande "Localisation lorsque l'app est active". Si rien, pas de trace.
|
||||||
|
2. **Trace dans Santé** — à la fin du workout, vérifier `Santé > Données > Athlétisme > Trajets` : la sortie doit apparaître.
|
||||||
|
3. **FC remonte sur `/live`** — `WorkoutManager` modifié, **test de régression prioritaire**.
|
||||||
|
4. **Écran verrouillé** — positions continuent d'arriver (test suivi GPS en background). S'arrêtent = boucle d'affichage trop rapide (CPU), pas permission.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 👉 À reprendre
|
## 👉 À reprendre
|
||||||
- ✅ **Phase 4 login — FAIT & DÉPLOYÉ** (révocation Apple à la suppression de compte) : code complet côté backend `coach_sportif` (commit `5ae6e2d`) + clé `.p8` déployée sur le VPS prod (vérifié 2026-06-26). Rien à coder. Détails : `coach_sportif/COWORK.md`.
|
- ✅ **Phase 4 login — FAIT & DÉPLOYÉ** (révocation Apple à la suppression de compte) : code complet côté backend `coach_sportif` (commit `5ae6e2d`) + clé `.p8` déployée sur le VPS prod (vérifié 2026-06-26). Rien à coder. Détails : `coach_sportif/COWORK.md`.
|
||||||
- **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 `feat/watch-outdoor` — prêt Mac** : **builder + tester 4 points critiques** (voir checklist ci-dessus). Workflow exact ligne 20.
|
||||||
- ⚠️ **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.
|
- Corrigé le 2026-08-20 : `open ios/App/App.xcworkspace` **n'existe pas**. Depuis Capacitor 8 : **`open ios/App/App.xcodeproj`** (SPM, pas CocoaPods).
|
||||||
- ⚠️ **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.
|
- Fixes robustesse 2026-07-01 (non compilés avant) à valider : `CoachWidgetBridge` (score Float ↔ NSNumber.intValue ; KO App Group → reject), `CoachAppleAuth` (double-tap → rejette ancien call).
|
||||||
|
- **Checklist pré-App-Store** : `ios/App/App/App.entitlements` a `aps-environment = development`. **TestFlight réclame `production`** — à basculer avant archive (Xcode peut le gérer via profil dist auto, à trancher).
|
||||||
- **watchOS — bloqué Mac** : test device + TestFlight de la cible `CoachWatch` (Phases 1-3 réalisées, build vert sim). Cf. `HANDOFF-WATCHOS.md`.
|
- **watchOS — bloqué Mac** : test device + TestFlight de la cible `CoachWatch` (Phases 1-3 réalisées, build vert sim). Cf. `HANDOFF-WATCHOS.md`.
|
||||||
- **Android — bloqué externe** : Play Console ($25 validé), upload AAB + Internal Testing depuis machine avec Android SDK.
|
- **Android — bloqué externe** : Play Console ($25 validé), upload AAB + Internal Testing depuis machine avec Android SDK.
|
||||||
- Backend/web : voir `coach_sportif/COWORK.md`.
|
- Backend/web : voir `coach_sportif/COWORK.md`.
|
||||||
|
|||||||
543
docs/AUDIT-2026-09-22.md
Normal file
543
docs/AUDIT-2026-09-22.md
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
# Audit complet — app mobile Coach Hypnotruck
|
||||||
|
|
||||||
|
**Date** : 2026-09-22 · **Portée** : `coach-ios` (natif iOS/watchOS) + `coach_sportif/web`
|
||||||
|
(l'UI réelle, servie dans la WKWebView) · **Branche auditée** : `feat/watch-outdoor` @ `e6cdc67`
|
||||||
|
|
||||||
|
## Comment lire ce document
|
||||||
|
|
||||||
|
Chaque constat porte son statut de vérification. La distinction n'est pas
|
||||||
|
cosmétique : c'est une app de santé, et un constat non vérifié relayé comme un
|
||||||
|
fait est exactement la dérive que la règle 3 interdit.
|
||||||
|
|
||||||
|
| Marque | Signification |
|
||||||
|
|---|---|
|
||||||
|
| **[vérifié]** | Lu ligne à ligne dans le fichier cité, ou calculé, pendant cet audit |
|
||||||
|
| **[rapporté]** | Remonté par une revue automatisée, **non revérifié** — à confirmer avant d'agir |
|
||||||
|
| **[doc]** | Adossé à une source Apple citée verbatim, URL donnée |
|
||||||
|
|
||||||
|
⚠️ Ce document date du jour de son écriture. Il constate, il ne prescrit pas un
|
||||||
|
état. `git log` fait foi pour ce qui a bougé depuis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ce qui va bien, et qu'il ne faut pas casser
|
||||||
|
|
||||||
|
Le socle est sain, et plusieurs points sont au-dessus de la moyenne du domaine.
|
||||||
|
|
||||||
|
- **[vérifié] Aucun secret n'a jamais été commité.** `git log --all` sur
|
||||||
|
`coach-ios.local.json` et `ios/App/App/CoachAuth.swift` est vide. Le
|
||||||
|
`.gitignore` les couvre (lignes 23-24).
|
||||||
|
- **[vérifié] `npm audit` : 0 vulnérabilité.** Capacitor 8.3.1 homogène sur
|
||||||
|
tous les paquets.
|
||||||
|
- **[vérifié] 88 tests Swift verts sur Linux** (10 DayRollover + 7 FormeBand +
|
||||||
|
17 IntervalEngine + 31 PolarPftpStep + 8 QuickDrink + 15 RouteFilter). Le
|
||||||
|
chiffre de 57 qui circulait est faux.
|
||||||
|
- **[vérifié] La politique de confidentialité existe** — `HTTP 200` sur
|
||||||
|
`/privacy`, liée depuis `settings.html:796` et `landing.html:207`. Le TODO du
|
||||||
|
README (« à héberger ») est **périmé**, de même que la ligne « missing
|
||||||
|
privacy policy » de tout audit antérieur.
|
||||||
|
- **[vérifié] La suppression de compte existe** (`settings.html:797` →
|
||||||
|
`POST /account/delete`, `routers/auth.py:313`). C'est l'exigence 5.1.1(v),
|
||||||
|
motif de rejet fréquent : elle est déjà satisfaite.
|
||||||
|
- **[vérifié] Universal Links opérationnels** — AASA servi en
|
||||||
|
`application/json`, Team ID `TZ2PVTRKNY`, routes `/live` `/routine` `/forme`
|
||||||
|
`/calendar`.
|
||||||
|
- **[vérifié] Purpose strings HealthKit précises et en français**, nommant les
|
||||||
|
types de données et leur finalité. Conforme à la lettre de 5.1.1(ii).
|
||||||
|
- **[rapporté] `CoachWorkoutKit.swift` est le fichier le plus sain du chemin
|
||||||
|
santé** : autorisation vérifiée deux fois, échec remonté jusqu'à l'écran,
|
||||||
|
relecture post-envoi qui rend ce qui est constaté et non ce qui est supposé.
|
||||||
|
- **[rapporté] Aucune confusion kcal/kJ, m/s ↔ km/h, ni mètres/miles** dans le
|
||||||
|
code Swift. Aucun `@MainActor` manquant, aucun cycle de rétention.
|
||||||
|
- **[vérifié] La barre d'onglets web est le seul composant d'UI protégé par un
|
||||||
|
test** (`tests/test_bottom_tabs.py`) — et c'est le seul qui n'a pas dérivé.
|
||||||
|
Le lien de cause à effet mérite d'être noté.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Gravité 1 — sécurité des données de santé
|
||||||
|
|
||||||
|
### 1.1 [vérifié] La FC au poignet se fige sur la dernière valeur connue, sans le dire
|
||||||
|
|
||||||
|
`ios/App/CoachWatch/WorkoutManager.swift:199` et `:217-224`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
if let hr { heartRate = hr } // 199 — hr nil → l'ancienne valeur reste
|
||||||
|
...
|
||||||
|
ConnectivityManager.shared.sendSample([
|
||||||
|
"heartRate": heartRate, // 218 — la propriété stockée, PAS `hr`
|
||||||
|
...
|
||||||
|
"ts": now.timeIntervalSince1970 // 223 — horodatage NEUF
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
`didCollectDataOf` se déclenche dès qu'*un* type est collecté. Quand le capteur
|
||||||
|
optique décroche — bracelet desserré, froid, arythmie : **la FA thermoablée est
|
||||||
|
précisément le terrain à risque** — les calories continuent d'arriver, `hr` vaut
|
||||||
|
`nil`, et la ligne 218 réémet la dernière FC connue avec un horodatage frais.
|
||||||
|
|
||||||
|
Conséquence en trois étages :
|
||||||
|
1. Au poignet, l'écran affiche une FC stable et sa zone colorée, indéfiniment.
|
||||||
|
2. Sur l'iPhone, `LiveStore.ingest` pose `lastSampleAt = Date()` et
|
||||||
|
`status = .live`. Le watchdog **ne basculera jamais en `.lost`** : il reçoit
|
||||||
|
des samples frais.
|
||||||
|
3. Aucun log, aucun compteur.
|
||||||
|
|
||||||
|
**C'est le défaut le plus grave de l'audit.** Un utilisateur post-infarctus sous
|
||||||
|
bêtabloquants lit 138 bpm en Z3, conclut qu'il peut tenir la pente, et sa FC
|
||||||
|
réelle est inconnue. Ce n'est pas une erreur de logique : c'est une information
|
||||||
|
absente du modèle. `heartRate` a besoin d'un `heartRateAt: Date` posé uniquement
|
||||||
|
quand `hr != nil`, d'un seuil d'âge au-delà duquel la vue affiche « — » (elle
|
||||||
|
sait déjà le faire, `LiveStore.heartRateText:152`), et `sendSample` d'un champ
|
||||||
|
d'âge pour que l'iPhone applique la même règle.
|
||||||
|
|
||||||
|
### 1.2 [rapporté] Les zones cardiaques peuvent ne jamais atteindre la montre
|
||||||
|
|
||||||
|
`ios/App/App/CoachWidgetBridge.swift:183` — `try? session.updateApplicationContext(...)`
|
||||||
|
|
||||||
|
`try?` avale l'échec (payload trop gros, session non activée, montre non
|
||||||
|
appairée). Ce snapshot transporte les bornes de zones affichées pendant
|
||||||
|
l'effort. Deux fichiers plus loin, `CoachRoutineBridge.pushToWatch:92-101`
|
||||||
|
appelle **la même API** avec `do/catch`, log et `call.reject`.
|
||||||
|
|
||||||
|
Le chemin instrumenté est celui de la routine de gainage ; le chemin muet est
|
||||||
|
celui qui porte les bornes cardiaques d'un patient post-infarctus. Aggravant :
|
||||||
|
`HeartRateZones` ne porte aucune date de validité, donc après une révision des
|
||||||
|
bornes la montre peut afficher les anciennes indéfiniment. C'est le motif
|
||||||
|
« zones datées » déjà tranché côté serveur (`app.current_zones()`), non
|
||||||
|
répercuté au poignet.
|
||||||
|
|
||||||
|
### 1.3 [rapporté] « Accès refusé » et « aucune donnée » sont rendus identiques
|
||||||
|
|
||||||
|
`ios/App/App/CoachSleep.swift:92-96` — l'en-tête du fichier (ligne 30) promet
|
||||||
|
`available = false` si non autorisé ; le code ne teste jamais l'autorisation et
|
||||||
|
rend `{available: true, samples: []}`.
|
||||||
|
|
||||||
|
Si l'utilisateur retire « Sommeil » dans Réglages → Santé, le backend enregistre
|
||||||
|
des nuits vides et `sleep_score.py` calcule une régularité sur des trous. Rien
|
||||||
|
ne distingue « il n'a pas dormi » de « on ne nous laisse plus regarder ».
|
||||||
|
HealthKit ne distingue effectivement pas refusé de vide en lecture, mais
|
||||||
|
`authorizationStatus(for:)` couvre `.notDetermined`, qui est le cas fréquent
|
||||||
|
(réinstallation, permission jamais accordée). Même famille :
|
||||||
|
`CoachHealthRoute.swift:173` et `:277`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Gravité 2 — perte silencieuse de données
|
||||||
|
|
||||||
|
Le projet a déjà vécu ce scénario côté backend : un cron a privé quatre mois de
|
||||||
|
séances de leur charge cardiaque en répondant « rien de neuf » — ce qui était
|
||||||
|
vrai de son point de vue. Les trois défauts ci-dessous ont la même forme.
|
||||||
|
|
||||||
|
### 2.1 [rapporté] Séances multiples : toutes sauf une sont perdues définitivement
|
||||||
|
|
||||||
|
`ios/App/App/CoachWorkoutObserver.swift:297-319`. L'ancre est sauvegardée
|
||||||
|
(l. 298) **avant** que seul `latest` (l. 315) soit traité. Or
|
||||||
|
`scheduleNotification` est le seul appelant de `reportToRoutineIfStrength` et
|
||||||
|
`reportPlanOrigin`. Renfo le matin + vélo le soir, iPhone resté à la maison :
|
||||||
|
au premier fire, le vélo est notifié, le renfo n'est jamais POSTé, et l'ancre
|
||||||
|
ayant avancé, il ne reviendra jamais. Le log dit « 2 new workout(s), notifying
|
||||||
|
latest » — exact, et parfaitement muet sur ce qui vient d'être jeté.
|
||||||
|
|
||||||
|
**Correction** : boucler sur `workouts` pour les rapports serveur ; garder
|
||||||
|
`latest` uniquement pour la notification locale.
|
||||||
|
|
||||||
|
### 2.2 [rapporté] Les POST natifs partent, puis l'app se déclare suspendable
|
||||||
|
|
||||||
|
Même fichier, `:266-268`, `:352-382`, `:398-438`. Le `defer { completion() }`
|
||||||
|
s'exécute dès que le corps rend la main, alors que deux requêtes sont en vol sur
|
||||||
|
`URLSession.shared`, sans `beginBackgroundTask`. iOS est libre de suspendre.
|
||||||
|
|
||||||
|
**Aggravant, indépendamment vérifiable** : le statut HTTP n'est jamais lu
|
||||||
|
(l. 368 et 431). Un 401 sur token rotaté, un 500, un 404 ne produisent pas
|
||||||
|
d'`error` — et `plan-origin envoyé` est journalisé comme un succès. Le bon
|
||||||
|
pattern existe déjà à deux fichiers de là (`CoachRoutineBridge.postDay:154-159`
|
||||||
|
vérifie `(200..<300).contains(http.statusCode)`).
|
||||||
|
|
||||||
|
### 2.3 [rapporté] Une séance entière peut disparaître sans un seul log
|
||||||
|
|
||||||
|
`ios/App/CoachWatch/WorkoutManager.swift:310-324`. `try? await
|
||||||
|
builder.endCollection(...)` puis `try? await builder.finishWorkout()` : si le
|
||||||
|
premier lève, le second lève aussi, la récupération ne trouve rien, et le chemin
|
||||||
|
finit sur `discardRoute()` qui logue la perte de la *trace* mais pas de la
|
||||||
|
*séance*. `statusMessage` n'est pas touché : l'utilisateur voit l'écran de fin
|
||||||
|
normal après 2 h de sortie.
|
||||||
|
|
||||||
|
### 2.4 [rapporté] `didFailWithError` ne clôt rien et laisse le GPS allumé
|
||||||
|
|
||||||
|
Même fichier, `:377-389`. Comparé au chemin `.ended`, il manque
|
||||||
|
`stopTracking()`, `endCollection`, `finishWorkout`, `finishRoute`/`discardRoute`.
|
||||||
|
Tout ce que le builder a collecté est perdu sans tentative de sauvegarde, et le
|
||||||
|
GPS continue de tourner après la fin apparente de la séance.
|
||||||
|
|
||||||
|
### 2.5 [vérifié] Chaque pause efface jusqu'à 24 points de trace *antérieurs*
|
||||||
|
|
||||||
|
`ios/App/CoachWatch/LocationTracker.swift:147` — `pendingRoute.removeAll()`.
|
||||||
|
|
||||||
|
Le commentaire vise les points *postérieurs* à la pause. Mais `pendingRoute` ne
|
||||||
|
contient que des points **déjà acceptés par le filtre et déjà comptés dans la
|
||||||
|
distance affichée**, en attente d'écriture (seuil de flush : 25). À 1 Hz, c'est
|
||||||
|
une douzaine de points en moyenne, jusqu'à 24. L'écart est donc visible : la
|
||||||
|
distance les inclut, la trace HealthKit non. Six arrêts sur une sortie VTTAE →
|
||||||
|
jusqu'à 144 points disparus, et un D+ recalculé qui diverge de celui affiché.
|
||||||
|
|
||||||
|
**Correction** : `await flushPendingRoute()` avant de vider, ou ne pas vider —
|
||||||
|
le point de reprise est de toute façon filtré par `implausibleSpeed`.
|
||||||
|
|
||||||
|
### 2.6 [rapporté] Accès concurrent non protégé sur le tampon GPS
|
||||||
|
|
||||||
|
`ios/App/App/CoachHealthRoute.swift:182-203` : `all` et `pluginErr` sont mutés
|
||||||
|
depuis les callbacks de N requêtes livrées sur des files HealthKit
|
||||||
|
indépendantes, sans verrou — alors que `getEffortScores:282` et
|
||||||
|
`getWorkoutDetails:379` du **même fichier** utilisent un `NSLock` pour ce motif
|
||||||
|
exact. Déclencheur : un workout portant plusieurs `HKWorkoutRoute` (multisport,
|
||||||
|
reprise après interruption).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Gravité 3 — justesse et cohérence
|
||||||
|
|
||||||
|
| # | Constat | Emplacement |
|
||||||
|
|---|---|---|
|
||||||
|
| 3.1 | **[rapporté]** `humidity_pct` vaut 0 ou 1, jamais 65 — `HKUnit.percent()` est une fraction (1.0 = 100 %), le `× 100` manque | `CoachHealthRoute.swift:415-417` |
|
||||||
|
| 3.2 | **[rapporté]** Une séance `traditionalStrengthTraining` est POSTée en `functionalStrengthTraining` (valeur en dur l. 363 alors que le garde accepte deux types). Le payload n'a ni UUID ni horodatage de début → serveur incapable de dédupliquer | `CoachWorkoutObserver.swift:352-364` |
|
||||||
|
| 3.3 | **[rapporté]** `value > 0` confond un zéro mesuré avec une absence de mesure, alors que le commentaire l. 399 promet l'inverse. L'erreur de la requête est jetée | `CoachHealthRoute.swift:449-462` |
|
||||||
|
| 3.4 | **[rapporté]** `startObserving` répond `observing: true` même sur refus (le `success` est ignoré, et Apple le met à `true` dès l'affichage de la feuille). `getStatus` montre trois voyants verts et zéro donnée | `CoachWorkoutObserver.swift:218-228` |
|
||||||
|
| 3.5 | **[rapporté]** La garde anti-doublon `lastNotifiedKey` est écrite (l. 317) et **jamais lue**. Le commentaire affirme une protection absente | `CoachWorkoutObserver.swift:60-63` |
|
||||||
|
| 3.6 | **[rapporté]** Un cookie d'auth périmé ne peut jamais être remplacé : les deux injections testent le *nom* du cookie, jamais sa valeur ni son expiration. Les appels natifs continuent de partir avec la constante compilée | `AppDelegate.swift:31-34`, `MainViewController.swift:63-65` |
|
||||||
|
| 3.7 | **[vérifié]** `STYLE_GUIDE.md:233` annonce `--text-secondary` à 9,7:1 ; le calcul donne **6,36:1** sur noir, 5,95:1 sur `.ap-card` | `docs/STYLE_GUIDE.md` |
|
||||||
|
|
||||||
|
Le flux d'injection du cookie lui-même (3.6) est correct et bien documenté — la
|
||||||
|
règle non négociable n° 2 du CLAUDE.md est respectée. Le défaut est le critère
|
||||||
|
de remplacement, pas le mécanisme.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Conformité App Store — sources Apple
|
||||||
|
|
||||||
|
Méthode : `developer.apple.com` uniquement, verbatim + URL. La distinction
|
||||||
|
exige / recommande / **ne dit pas** est le cœur de cette section.
|
||||||
|
|
||||||
|
### 5.1 [doc] `aps-environment = development` — non-problème, sous réserve d'une vérification
|
||||||
|
|
||||||
|
`App.entitlements` porte `development`. La
|
||||||
|
[doc Apple](https://developer.apple.com/documentation/bundleresources/entitlements/aps-environment)
|
||||||
|
dit verbatim :
|
||||||
|
|
||||||
|
> « **Xcode sets the value of the entitlement based on your app's current
|
||||||
|
> provisioning profile.** […] Production provisioning profile and [prerelease
|
||||||
|
> versions and beta testers] use `production`. »
|
||||||
|
|
||||||
|
TestFlight utilise donc `production`, et [TN2265](https://developer.apple.com/library/archive/technotes/tn2265/_index.html)
|
||||||
|
ajoute que Xcode **re-signe** à la soumission avec le profil de distribution.
|
||||||
|
|
||||||
|
**Ce que la doc ne dit pas** : si Xcode réécrit le *fichier source*
|
||||||
|
`.entitlements`. N'éditez pas le fichier sur la foi d'un blog. Apple publie une
|
||||||
|
procédure de vérification, et c'est la seule qui conclut :
|
||||||
|
|
||||||
|
> Organizer → archive → Distribute/Validate → feuille Summary → dépliant
|
||||||
|
> **Binary and Entitlements** → lire la valeur réelle dans le binaire signé.
|
||||||
|
|
||||||
|
**Le risque APNs supposé est sans objet [vérifié le 2026-09-22]** : il n'y a
|
||||||
|
pas d'APNs. `web/push_notify.py` envoie via **ntfy**, en utilisant le `token`
|
||||||
|
du device comme *nom de topic*. L'en-tête du fichier annonce « V2+ : remplacer
|
||||||
|
par APNs (iOS) + FCM (Android) » — ce n'est pas fait. Voir §5.1bis, qui est le
|
||||||
|
vrai sujet.
|
||||||
|
|
||||||
|
### 5.1bis [vérifié] Les rappels sont natifs et fonctionnent ; c'est le chemin ntfy qui est résiduel
|
||||||
|
|
||||||
|
⚠️ **Correction d'une première version de cette section, qui affirmait que « la
|
||||||
|
chaîne de notifications natives n'existe pas ». C'était faux**, et la méthode
|
||||||
|
qui a produit l'erreur mérite d'être notée : j'avais cherché
|
||||||
|
`PushNotifications` (le plugin *distant*) sans chercher `LocalNotifications`.
|
||||||
|
Conclure d'un grep négatif qu'une fonctionnalité est absente suppose d'avoir
|
||||||
|
cherché le bon terme.
|
||||||
|
|
||||||
|
**Ce qui marche, en natif** — `web/static/local-notifications.js` programme via
|
||||||
|
`Capacitor.Plugins.LocalNotifications` :
|
||||||
|
|
||||||
|
- rappel « Séance pas encore faite » (l. 97) ;
|
||||||
|
- résumé du jour : sport + durée, « Jour OFF », « Peu récupéré » (l. 150-171) ;
|
||||||
|
- hydratation à 10 h / 13 h / 16 h (l. 184) ;
|
||||||
|
- « Heure de te coucher ? » à 23 h (l. 197).
|
||||||
|
|
||||||
|
Ce sont de vraies notifications locales iOS, planifiées sur l'appareil, sans
|
||||||
|
serveur. **Le README a donc raison sur le fond** (les rappels de séance sont
|
||||||
|
natifs), mais il attribue la fonction au mauvais plugin : c'est
|
||||||
|
`@capacitor/local-notifications` qui la rend, pas
|
||||||
|
`@capacitor/push-notifications`.
|
||||||
|
|
||||||
|
**Ce qui reste du chemin ntfy** — `web/push_notify.py` envoie via ntfy (le
|
||||||
|
`token` du device sert de *nom de topic*), et il a deux appelants vivants :
|
||||||
|
|
||||||
|
| Appelant | Ce qu'il expose |
|
||||||
|
|---|---|
|
||||||
|
| `web/routers/api_notifications.py:16` | `POST /register-device`, `GET /devices`, `DELETE /devices/{id}`, `POST /test` |
|
||||||
|
| `tools/notify_morning.py:24` | notification matinale, déclenchée par cron |
|
||||||
|
|
||||||
|
L'intention est que **plus rien ne passe par ntfy** (confirmé par Sylvain le
|
||||||
|
2026-09-22). Ce chemin est donc du code résiduel qui double une fonction déjà
|
||||||
|
assurée en natif.
|
||||||
|
|
||||||
|
⚠️ **Ne pas supprimer avant d'avoir vérifié le cron sur nexus.** Si
|
||||||
|
`notify_morning.py` y est encore planifié, retirer le module casserait le cron
|
||||||
|
— et un cron cassé dans ce projet a déjà tenu quatre mois sans être vu. La
|
||||||
|
vérification n'a **pas** pu être faite depuis le conteneur de dev (pas de
|
||||||
|
résolution DNS pour `nexus`) :
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh nexus "crontab -l | grep -i notify; systemctl list-timers --all | grep -i notify"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ce qui reste vrai de la remarque App Store** : `@capacitor/push-notifications`
|
||||||
|
est installé et `aps-environment` déclaré, alors que **rien n'appelle ce
|
||||||
|
plugin** (aucun enregistrement de token APNs côté serveur, aucun appel
|
||||||
|
`PushNotifications` côté web). Une capability déclarée sans usage tombe sous
|
||||||
|
2.3.1(a) (« hidden, dormant, or undocumented features »). Les rappels étant
|
||||||
|
couverts par les notifications locales, le plus simple est de **retirer le
|
||||||
|
plugin distant et l'entitlement** — sauf si des push serveur sont prévues.
|
||||||
|
|
||||||
|
### 5.2 [doc] Ce qu'Apple exige, et où on en est
|
||||||
|
|
||||||
|
| Exigence | Source | État |
|
||||||
|
|---|---|---|
|
||||||
|
| Privacy policy URL dans App Store Connect | 5.1.1(i) + ASC Help, « required for all apps » | à renseigner à la soumission |
|
||||||
|
| Privacy policy accessible **dans** l'app | 5.1.1(i), « in an easily accessible manner » | **[vérifié] fait** — `settings.html:796` |
|
||||||
|
| Privacy policy pour toute app HealthKit | [doc HealthKit](https://developer.apple.com/documentation/healthkit/protecting-user-privacy) | **[vérifié] fait** |
|
||||||
|
| Contenu : collecte + usages + tiers + **rétention/suppression et révocation** | 5.1.1(i) | à relire — c'est le point que les policies génériques oublient |
|
||||||
|
| Suppression de compte dans l'app | 5.1.1(v) | **[vérifié] fait** — `POST /account/delete` |
|
||||||
|
| Purpose strings précises | 5.1.1(ii) + doc HealthKit | **[vérifié] fait** |
|
||||||
|
| Nutrition labels renseignés | « required to submit » | **à faire** : Health, Fitness, Precise Location, identifiants, photos — tous « Linked to You » (il y a un compte) |
|
||||||
|
| Aucune donnée HealthKit en pub/marketing | 5.1.3(i), 5.1.2(vi), 2.5.18 | conforme |
|
||||||
|
| Aucune donnée de santé dans iCloud | 5.1.3(ii) | **[vérifié]** aucun entitlement iCloud |
|
||||||
|
| Notes for Review spécifiques + compte de démo **avec backend allumé** | 2.3.1(a), 2.1(a) | à préparer |
|
||||||
|
|
||||||
|
⚠️ **Piège propre à l'architecture webview**, [User Privacy and Data Use](https://developer.apple.com/app-store/user-privacy-and-data-use/) :
|
||||||
|
|
||||||
|
> « **If you are using a webview for app functionality, it should be treated the
|
||||||
|
> same way as native functionality in your app** »
|
||||||
|
|
||||||
|
Tout ce que fait `coach.hypnotruck.ch` dans la WKWebView compte comme du natif
|
||||||
|
pour les nutrition labels **et** pour l'ATT. Si le site embarque le moindre
|
||||||
|
analytics tiers, il doit être déclaré.
|
||||||
|
|
||||||
|
### 5.3 [doc] Ce que la doc Apple **ne tranche pas** — à connaître, pas à corriger
|
||||||
|
|
||||||
|
- **Guideline 4.2 n'a aucun critère mesurable.** « Useful, unique, app-like »
|
||||||
|
n'est pas quantifié. Personne ne peut certifier que HealthKit + widgets +
|
||||||
|
Live Activity + watchOS + BLE « suffit ». Le levier réel est 2.3.1(a) :
|
||||||
|
**décrire nommément les fonctions natives dans les Notes for Review**.
|
||||||
|
- **2.5.2** (« may not download […] code which introduces or changes features »)
|
||||||
|
n'a pas d'exception publiée pour une UI web distante chargée via `server.url`.
|
||||||
|
Zone non tranchée — c'est le risque documentaire réel de l'architecture, et il
|
||||||
|
est structurel, pas corrigeable.
|
||||||
|
- **« Third party »** n'est pas défini : la doc ne dit pas si le backend du
|
||||||
|
développeur en est un. Aucune exigence technique publiée sur le stockage
|
||||||
|
(chiffrement, juridiction, rétention).
|
||||||
|
- **`limitsNavigationsToAppBoundDomains: false`** : la page Apple ne décrit
|
||||||
|
**aucune** conséquence, et `WKAppBoundDomains` est absente de la référence
|
||||||
|
Info.plist. Aucun lien publié entre app-bound domains et données de santé.
|
||||||
|
La config actuelle est cohérente (pas de `WKAppBoundDomains` + flag `false` =
|
||||||
|
comportement pré-iOS 14, sans restriction), et passer à `true` casserait
|
||||||
|
`evaluateJavaScript` et `messageHandlers`, dont Capacitor a besoin.
|
||||||
|
**Ne pas y toucher pour des raisons de conformité.**
|
||||||
|
- **Aucune clé Info.plist de privacy policy n'existe.** Si une source en
|
||||||
|
réclame une, c'est inventé.
|
||||||
|
|
||||||
|
### 5.4 [doc] Signal de positionnement
|
||||||
|
|
||||||
|
5.1.1(ix) vise les « highly regulated fields (such as […] healthcare) » avec un
|
||||||
|
« should » (pas « must »). Mais 1.4.1 prévient :
|
||||||
|
|
||||||
|
> « **Medical apps that could provide inaccurate data or information […] may be
|
||||||
|
> reviewed with greater scrutiny.** »
|
||||||
|
|
||||||
|
Le vocabulaire de la fiche App Store a donc un effet documenté sur le niveau de
|
||||||
|
scrutin : « coaching sportif » et « suivi cardiaque post-infarctus » ne
|
||||||
|
déclenchent pas la même lecture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Ergonomie — l'app est-elle utilisable **par un sportif** ?
|
||||||
|
|
||||||
|
C'est l'exigence produit : simple, ergonomique, pour des sportifs. Rappel
|
||||||
|
d'architecture — l'UI réelle vit dans `coach_sportif/web/`, le wrapper ne fait
|
||||||
|
que l'afficher. Les constats ci-dessous portent donc sur le web.
|
||||||
|
|
||||||
|
### 6.1 [vérifié] Le geste central du sportif n'existe pas
|
||||||
|
|
||||||
|
Le mot « Démarrer » n'apparaît que **deux fois** dans tout `web/templates/`, et
|
||||||
|
les deux fois pour la routine anti-douleurs (`routine.html:27,42`). La troisième
|
||||||
|
occurrence est un commentaire qui décrit une fonctionnalité absente :
|
||||||
|
`widgets/today-session.html:6` annonce un CTA « Démarrer » / « Voir détails »
|
||||||
|
que le markup ne contient pas.
|
||||||
|
|
||||||
|
| Action | Taps |
|
||||||
|
|---|---|
|
||||||
|
| Voir la séance du jour | 0 (mais ~420 px sous le haut de page) |
|
||||||
|
| Voir ses étapes et sa zone cardiaque cible | 2 |
|
||||||
|
| Envoyer la séance à la Watch | 3 + scroll dans la modale |
|
||||||
|
| **Démarrer la séance** | **impossible** — `/live` est passif, et hors onglets par défaut |
|
||||||
|
|
||||||
|
Avant l'effort : 3 taps pour préparer. Pendant l'effort : l'app ne sert à rien
|
||||||
|
sans la montre. Pour une app « prévue pour les sportifs », c'est le trou central.
|
||||||
|
|
||||||
|
### 6.2 [vérifié] La typographie descend à 8 px sur l'information la plus sensible
|
||||||
|
|
||||||
|
17 règles CSS descendent à ≤ 0,62 rem, la plupart en `--text-tertiary` :
|
||||||
|
|
||||||
|
| Sélecteur | Taille | Ce que ça porte |
|
||||||
|
|---|---|---|
|
||||||
|
| `.ap-tile__pill` (`style.css:7053`) | **0,5 rem = 8 px** | **l'intensité de la séance**, « ADAPTÉ IA », « FAIT ✓ » |
|
||||||
|
| `.ap-tile__meta` (`:7068`) | 0,62 rem ≈ 9,9 px | durée de la séance, km, FC |
|
||||||
|
| `.entry-src`, `.entry-ecart` | 0,58 rem ≈ 9,3 px | provenance / écart d'une mesure |
|
||||||
|
|
||||||
|
**L'intensité d'effort d'un post-infarctus est affichée en majuscules de 8 px,
|
||||||
|
en gris, sur noir.** Au total, 127 règles combinent `--text-tertiary` avec une
|
||||||
|
police < 0,78 rem.
|
||||||
|
|
||||||
|
**Ratios réels [vérifié par calcul]**, fonction validée sur le témoin WCAG
|
||||||
|
officiel (`#767676`/blanc = 4,54) :
|
||||||
|
|
||||||
|
| Token | sur `#000` | sur `.ap-card` | sur `--bg-tertiary` |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--text-secondary` | **6,36** (le guide annonce 9,7 — faux) | 5,95 | 5,27 |
|
||||||
|
| `--text-tertiary` | 5,42 | 5,22 | **4,68** (limite AA) |
|
||||||
|
|
||||||
|
Les tokens passent AA, mais avec deux fois moins de marge que le guide ne le
|
||||||
|
croit. Et le problème n'est pas le ratio : c'est la taille. **Aucune
|
||||||
|
échappatoire** : pas de mode clair (`_layout.html:20` force
|
||||||
|
`background:#000 !important`, et les tokens clairs de `critical.css:153-166`
|
||||||
|
sont du code mort), pas de Dynamic Type (`-webkit-text-size-adjust: 100%` +
|
||||||
|
`rem` sur racine fixe), aucun réglage de taille dans `/settings`.
|
||||||
|
|
||||||
|
Un thème sombre est le pire cas en extérieur : c'est le fond — la plus grande
|
||||||
|
surface — qui est noyé par la lumière réfléchie.
|
||||||
|
|
||||||
|
### 6.3 [rapporté] Les cibles tactiles fautives sont exactement les actions fréquentes
|
||||||
|
|
||||||
|
Les composants génériques respectent le 44×44 du style guide (`.ap-icon-btn`,
|
||||||
|
`.tab-item`, `.more-item`, `.coach-fab`). Mais :
|
||||||
|
|
||||||
|
| Élément | Taille | Rôle |
|
||||||
|
|---|---|---|
|
||||||
|
| `.qr-action` | ~**29 × 35** | ajout rapide d'un favori — le geste le plus fréquent |
|
||||||
|
| `.hero-tab` | ~**23 px** | sélecteur primaire de `/meals` |
|
||||||
|
| `.ap-home-hero__cta-mini` | **32 × 32** | **le CTA du hero d'accueil** |
|
||||||
|
| `.meal-section__add-btn` | **34 × 34** | porte d'entrée de la saisie de repas |
|
||||||
|
| `.ap-icon-btn.--sm` | **36 × 36** | navigation jour de `/meals` — usage **primaire**, ce que §2 du guide interdit |
|
||||||
|
|
||||||
|
Saisir un repas connu = 3 taps, **dont 2 sous le seuil**. Pire : dans
|
||||||
|
`.quick-row`, le bouton favori (étoile) et le bouton ajouter (+) sont adjacents,
|
||||||
|
même taille ~29 px : avec des doigts moites ou des gants, on met en favori au
|
||||||
|
lieu d'ajouter, **sans retour d'erreur** puisque les deux « marchent ».
|
||||||
|
|
||||||
|
Aucun test ne verrouille le 44×44 ni le contraste — alors que le CLAUDE.md pose
|
||||||
|
« un garde-fou contre la récidive → un test, pas une phrase ». Les règles
|
||||||
|
d'ergonomie sont les seules du projet à n'être qu'une phrase. D'où la dérive.
|
||||||
|
|
||||||
|
### 6.4 [rapporté] Densité et navigation
|
||||||
|
|
||||||
|
- **Accueil : 28 chiffres, dont un seul concerne la séance à faire** (sa durée,
|
||||||
|
en 9,9 px gris). Les 27 autres sont du constat sur le passé.
|
||||||
|
- **`/workout` : 20 tuiles toutes visibles par défaut**, 100-130 valeurs avec
|
||||||
|
les tableaux. `/sleep` : 8 tuiles.
|
||||||
|
- **19 sections de navigation, 4 visibles**, avec des doublons sémantiques
|
||||||
|
réels : `/mesures` + `/body` + `/profile` pour le corps ; `/calendar` +
|
||||||
|
`/week` + `/dashboards` + `/reviews` pour la planification.
|
||||||
|
- **La place la plus accessible au pouce** — le FAB 56 px, fixe, sur toutes les
|
||||||
|
pages — **est occupée par le chat IA**, pas par « logger » ou « démarrer ».
|
||||||
|
|
||||||
|
### 6.5 Le meilleur travail d'ergonomie de l'app est éteint
|
||||||
|
|
||||||
|
`drinks-quick.html` et `meal-capture.html` ont des boutons `min-height: 44px`,
|
||||||
|
un geste unique, et un commentaire qui pose le bon raisonnement (« boire se note
|
||||||
|
vingt fois par jour ; un geste qui coûte deux navigations ne se fait pas »).
|
||||||
|
**Ils ne sont ni dans le layout par défaut, ni en production.** Les allumer dans
|
||||||
|
`_default_home_layout()` est un gain immédiat pour un coût nul.
|
||||||
|
|
||||||
|
### 6.6 La tension honnêteté / ergonomie, à trancher
|
||||||
|
|
||||||
|
Le hero affiche TSB signé, HRV en ms, FC repos + écart % — quatre grandeurs à
|
||||||
|
interpréter, dont aucune ne dit quoi faire. La seule phrase actionnable est
|
||||||
|
suivie d'un encadré de 40 mots à 11,2 px expliquant qu'aucune publication ne la
|
||||||
|
fonde.
|
||||||
|
|
||||||
|
Ce disclaimer est **juste** : c'est la règle 3ter, et sur une app de santé il
|
||||||
|
n'est pas négociable. Mais son coût est payé au pire endroit. La question de
|
||||||
|
conception est : *comment rester honnête sans occuper le premier écran ?* Le
|
||||||
|
composant existe déjà — `<details class="settings-fold">`, dont §3c du guide
|
||||||
|
décrit les deux règles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Ce qui n'a pas été audité
|
||||||
|
|
||||||
|
Par honnêteté sur la couverture :
|
||||||
|
|
||||||
|
- **Aucun build Xcode** — impossible depuis Linux. WidgetKit, SwiftUI, HealthKit
|
||||||
|
et WatchKit ne compilent pas ici. Rien de ce qui touche à l'UI native, à la
|
||||||
|
signature ou au comportement sur device n'a été vérifié autrement que par
|
||||||
|
lecture.
|
||||||
|
- **`CoachPolarBLE.swift`** (644 L, 86 commits de diagnostic entre le 11/08 et
|
||||||
|
le 20/09) n'a pas été revu. C'est le chantier le plus instable du dépôt.
|
||||||
|
- **Android** : seul le manifest a été lu. Health Connect n'est pas intégré, et
|
||||||
|
la validation Play Console reste en attente.
|
||||||
|
- **Le backend** (`coach_sportif/web`) n'a été audité que sur l'ergonomie et
|
||||||
|
deux points de conformité. Ni sécurité serveur, ni justesse des formules.
|
||||||
|
- **Le contenu de la politique de confidentialité** n'a pas été relu contre les
|
||||||
|
trois exigences de 5.1.1(i) — son existence a été vérifiée, pas son contenu.
|
||||||
|
|
||||||
|
*(`web/push_notify.py` a été lu après coup : voir §5.1bis, qui remplace le
|
||||||
|
risque APNs initialement supposé.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Ordre de traitement proposé
|
||||||
|
|
||||||
|
**D'abord, parce que la conséquence est une décision d'intensité prise sur un
|
||||||
|
chiffre mort :**
|
||||||
|
|
||||||
|
1. **§1.1 — horodater la FC** et afficher « — » au-delà d'un seuil d'âge, des
|
||||||
|
deux côtés du pont. Correctif petit et local.
|
||||||
|
2. **§6.2 — remonter le plancher typographique** : interdire < 0,7 rem, bannir
|
||||||
|
`--text-tertiary` sous 0,78 rem. En priorité `.ap-tile__pill` (l'intensité de
|
||||||
|
séance en 8 px).
|
||||||
|
|
||||||
|
**Ensuite, la perte de données :**
|
||||||
|
|
||||||
|
3. **§2.1 + §2.2 ensemble** — même scénario, même correctif structurel : boucler
|
||||||
|
sur le lot, tenir un `beginBackgroundTask`, lire le statut HTTP comme
|
||||||
|
`CoachRoutineBridge` le fait déjà.
|
||||||
|
4. **§2.3 + §2.4** — deux faces de la même omission dans le cycle de vie de la
|
||||||
|
séance watchOS.
|
||||||
|
5. **§2.5** — une ligne.
|
||||||
|
|
||||||
|
**Puis le produit :**
|
||||||
|
|
||||||
|
6. **§6.1 — un bouton « Démarrer »** ≥ 44 px sur la tuile du jour, avec la zone
|
||||||
|
cible lisible à côté.
|
||||||
|
7. **§6.5 — allumer `drinks-quick` et `meal-capture`.** Coût nul.
|
||||||
|
8. **§6.3 — porter les cibles primaires à 44 px**, et écarter l'étoile du `+`.
|
||||||
|
|
||||||
|
**Avant soumission :**
|
||||||
|
|
||||||
|
9. **Nettoyer le résiduel de notifications (§5.1bis)** — vérifier le cron
|
||||||
|
`notify_morning` sur nexus, puis retirer le chemin ntfy et le plugin
|
||||||
|
`@capacitor/push-notifications` + son entitlement, que rien n'appelle.
|
||||||
|
Corriger la ligne du README qui attribue les rappels au mauvais plugin.
|
||||||
|
Puis vérifier `aps-environment` dans le Summary de l'archive (§5.1).
|
||||||
|
10. Nutrition labels, Notes for Review nommant les fonctions natives, compte de
|
||||||
|
démo avec backend allumé (§5.2).
|
||||||
|
|
||||||
|
**Garde-fous, parce que « un test, pas une phrase » :**
|
||||||
|
|
||||||
|
11. Deux tests web : 44×44 sur les classes primaires, ratio de contraste des
|
||||||
|
tokens. Ce sont les seules règles du projet non verrouillées, et ce sont
|
||||||
|
celles qui ont dérivé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Audit mené par lecture du code et des sources Apple. Les constats **[rapporté]**
|
||||||
|
proviennent de revues automatisées et n'ont pas été revérifiés ligne à ligne :
|
||||||
|
les confirmer avant d'agir.*
|
||||||
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 */; };
|
01468471ECA28EA66AFE2D6C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EB6C76F68679844EC72178F /* Foundation.framework */; };
|
||||||
0318E7E610501FC1F958251B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3342ACCE085010828CF63565 /* Assets.xcassets */; };
|
0318E7E610501FC1F958251B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3342ACCE085010828CF63565 /* Assets.xcassets */; };
|
||||||
19F735674999FD63AB1C566F /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
|
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 */; };
|
1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
|
||||||
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */; };
|
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */; };
|
||||||
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969DB880395BDEB107C9F2FE /* RoutineStore.swift */; };
|
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969DB880395BDEB107C9F2FE /* RoutineStore.swift */; };
|
||||||
@@ -30,15 +29,18 @@
|
|||||||
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */; };
|
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */; };
|
||||||
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; };
|
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; };
|
||||||
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60061E60061E60061E060002 /* CoachGoogleAuth.swift */; };
|
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60061E60061E60061E060002 /* CoachGoogleAuth.swift */; };
|
||||||
|
60BB59C4096F4C8EB22DE183 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */; };
|
||||||
6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.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 */; };
|
694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */; };
|
||||||
79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276928E86E785EEA434057C /* CoachWidgets.swift */; };
|
79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276928E86E785EEA434057C /* CoachWidgets.swift */; };
|
||||||
|
8807F51A3045719400A4D4E8 /* PolarPftpStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8807F5193045719400A4D4E8 /* PolarPftpStep.swift */; };
|
||||||
|
8807F51B3045719400A4D4E8 /* CoachPolarBLE.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8807F5183045719400A4D4E8 /* CoachPolarBLE.swift */; };
|
||||||
|
8807F51D3045721100A4D4E8 /* PolarBleSdk in Frameworks */ = {isa = PBXBuildFile; productRef = 8807F51C3045721100A4D4E8 /* PolarBleSdk */; };
|
||||||
880A76D1302B601000F582D5 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D0302B601000F582D5 /* WidgetKit.framework */; };
|
880A76D1302B601000F582D5 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D0302B601000F582D5 /* WidgetKit.framework */; };
|
||||||
880A76D3302B601000F582D5 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D2302B601000F582D5 /* SwiftUI.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, ); }; };
|
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 */; };
|
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 */; };
|
88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 88924CCD2FD2A733002F3A80 /* GoogleSignIn */; };
|
||||||
88ABE0043024C45A002636FD /* CoachQuickLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0023024C45A002636FD /* CoachQuickLog.swift */; };
|
88ABE0043024C45A002636FD /* CoachQuickLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0023024C45A002636FD /* CoachQuickLog.swift */; };
|
||||||
88ABE0053024C45A002636FD /* CoachQuickSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0033024C45A002636FD /* CoachQuickSync.swift */; };
|
88ABE0053024C45A002636FD /* CoachQuickSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0033024C45A002636FD /* CoachQuickSync.swift */; };
|
||||||
@@ -50,14 +52,19 @@
|
|||||||
95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; };
|
95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; };
|
||||||
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; };
|
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; };
|
||||||
97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.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 Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
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 */; };
|
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 */; };
|
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; };
|
||||||
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; };
|
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; };
|
||||||
B130613187FE0661DDF636C9 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.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 */; };
|
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 */; };
|
||||||
|
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */; };
|
||||||
|
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */; };
|
||||||
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
|
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
|
||||||
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
|
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
|
||||||
E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61D7B2F5AFEF834423E07AB /* Foundation.framework */; };
|
E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61D7B2F5AFEF834423E07AB /* Foundation.framework */; };
|
||||||
@@ -103,15 +110,15 @@
|
|||||||
name = "Embed Foundation Extensions";
|
name = "Embed Foundation Extensions";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
C4916F81E61FABAAD62EE4F9 /* Embed App Extensions */ = {
|
C4916F81E61FABAAD62EE4F9 /* Embed Foundation Extensions */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
dstPath = "";
|
dstPath = "";
|
||||||
dstSubfolderSpec = 13;
|
dstSubfolderSpec = 13;
|
||||||
files = (
|
files = (
|
||||||
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */,
|
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
name = "Embed App Extensions";
|
name = "Embed Foundation Extensions";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */ = {
|
F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */ = {
|
||||||
@@ -148,10 +155,13 @@
|
|||||||
60061E60061E60061E060002 /* CoachGoogleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachGoogleAuth.swift; sourceTree = "<group>"; };
|
60061E60061E60061E060002 /* CoachGoogleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachGoogleAuth.swift; sourceTree = "<group>"; };
|
||||||
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveStore.swift; sourceTree = "<group>"; };
|
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveStore.swift; sourceTree = "<group>"; };
|
||||||
82122ACF7E5A608AD1D55D24 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
82122ACF7E5A608AD1D55D24 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
8807F5183045719400A4D4E8 /* CoachPolarBLE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachPolarBLE.swift; sourceTree = "<group>"; };
|
||||||
|
8807F5193045719400A4D4E8 /* PolarPftpStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolarPftpStep.swift; sourceTree = "<group>"; };
|
||||||
880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachWatchWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachWatchWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
880A76D0302B601000F582D5 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
88ABE0023024C45A002636FD /* CoachQuickLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachQuickLog.swift; sourceTree = "<group>"; };
|
||||||
@@ -163,18 +173,21 @@
|
|||||||
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; };
|
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; };
|
||||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||||
96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetSnapshot.swift; sourceTree = "<group>"; };
|
96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetSnapshot.swift; sourceTree = "<group>"; };
|
||||||
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
AA5C5902EFB7F60563678E46 /* RoutineView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineView.swift; sourceTree = "<group>"; };
|
||||||
|
B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.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>"; };
|
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>"; };
|
||||||
|
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>"; };
|
||||||
C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineGuidedView.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>"; };
|
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>"; };
|
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; };
|
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; };
|
||||||
@@ -206,6 +219,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
|
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
|
||||||
|
8807F51D3045721100A4D4E8 /* PolarBleSdk in Frameworks */,
|
||||||
88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */,
|
88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -288,6 +302,8 @@
|
|||||||
504EC3061FED79650016851F /* App */ = {
|
504EC3061FED79650016851F /* App */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
8807F5183045719400A4D4E8 /* CoachPolarBLE.swift */,
|
||||||
|
8807F5193045719400A4D4E8 /* PolarPftpStep.swift */,
|
||||||
88ABE0023024C45A002636FD /* CoachQuickLog.swift */,
|
88ABE0023024C45A002636FD /* CoachQuickLog.swift */,
|
||||||
88ABE0033024C45A002636FD /* CoachQuickSync.swift */,
|
88ABE0033024C45A002636FD /* CoachQuickSync.swift */,
|
||||||
88A837392FAA741C00DDD144 /* App.entitlements */,
|
88A837392FAA741C00DDD144 /* App.entitlements */,
|
||||||
@@ -316,6 +332,7 @@
|
|||||||
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */,
|
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */,
|
||||||
340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */,
|
340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */,
|
||||||
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */,
|
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */,
|
||||||
|
B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */,
|
||||||
);
|
);
|
||||||
path = App;
|
path = App;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -323,6 +340,7 @@
|
|||||||
5A8EA0CF92316E437E9053A0 /* CoachWatch */ = {
|
5A8EA0CF92316E437E9053A0 /* CoachWatch */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
88342F7C30376311002D1811 /* IntervalEngine.swift */,
|
||||||
F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */,
|
F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */,
|
||||||
8A06CDFF5B4F7852B6329862 /* ContentView.swift */,
|
8A06CDFF5B4F7852B6329862 /* ContentView.swift */,
|
||||||
82122ACF7E5A608AD1D55D24 /* Info.plist */,
|
82122ACF7E5A608AD1D55D24 /* Info.plist */,
|
||||||
@@ -333,6 +351,8 @@
|
|||||||
A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */,
|
A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */,
|
||||||
969DB880395BDEB107C9F2FE /* RoutineStore.swift */,
|
969DB880395BDEB107C9F2FE /* RoutineStore.swift */,
|
||||||
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */,
|
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */,
|
||||||
|
C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */,
|
||||||
|
C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */,
|
||||||
3342ACCE085010828CF63565 /* Assets.xcassets */,
|
3342ACCE085010828CF63565 /* Assets.xcassets */,
|
||||||
);
|
);
|
||||||
path = CoachWatch;
|
path = CoachWatch;
|
||||||
@@ -385,7 +405,7 @@
|
|||||||
504EC3011FED79650016851F /* Frameworks */,
|
504EC3011FED79650016851F /* Frameworks */,
|
||||||
504EC3021FED79650016851F /* Resources */,
|
504EC3021FED79650016851F /* Resources */,
|
||||||
F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */,
|
F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */,
|
||||||
C4916F81E61FABAAD62EE4F9 /* Embed App Extensions */,
|
C4916F81E61FABAAD62EE4F9 /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -397,6 +417,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
|
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
|
||||||
88924CCD2FD2A733002F3A80 /* GoogleSignIn */,
|
88924CCD2FD2A733002F3A80 /* GoogleSignIn */,
|
||||||
|
8807F51C3045721100A4D4E8 /* PolarBleSdk */,
|
||||||
);
|
);
|
||||||
productName = App;
|
productName = App;
|
||||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||||
@@ -451,7 +472,7 @@
|
|||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastSwiftUpdateCheck = 2660;
|
LastSwiftUpdateCheck = 2660;
|
||||||
LastUpgradeCheck = 2650;
|
LastUpgradeCheck = 2700;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
504EC3031FED79650016851F = {
|
504EC3031FED79650016851F = {
|
||||||
CreatedOnToolsVersion = 9.2;
|
CreatedOnToolsVersion = 9.2;
|
||||||
@@ -475,6 +496,7 @@
|
|||||||
packageReferences = (
|
packageReferences = (
|
||||||
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
|
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
|
||||||
88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */,
|
88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */,
|
||||||
|
8807F51730456ED400A4D4E8 /* XCRemoteSwiftPackageReference "polar-ble-sdk" */,
|
||||||
);
|
);
|
||||||
productRefGroup = 504EC3051FED79650016851F /* Products */;
|
productRefGroup = 504EC3051FED79650016851F /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -561,6 +583,8 @@
|
|||||||
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */,
|
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */,
|
||||||
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */,
|
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */,
|
||||||
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */,
|
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */,
|
||||||
|
8807F51A3045719400A4D4E8 /* PolarPftpStep.swift in Sources */,
|
||||||
|
8807F51B3045719400A4D4E8 /* CoachPolarBLE.swift in Sources */,
|
||||||
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */,
|
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */,
|
||||||
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */,
|
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */,
|
||||||
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */,
|
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */,
|
||||||
@@ -568,6 +592,7 @@
|
|||||||
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */,
|
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */,
|
||||||
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */,
|
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */,
|
||||||
E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */,
|
E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */,
|
||||||
|
60BB59C4096F4C8EB22DE183 /* SceneDelegate.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -591,9 +616,12 @@
|
|||||||
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */,
|
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */,
|
||||||
95D0478A499056FE922211EA /* RoutineView.swift in Sources */,
|
95D0478A499056FE922211EA /* RoutineView.swift in Sources */,
|
||||||
6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */,
|
6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */,
|
||||||
|
88342F7D30376311002D1811 /* IntervalEngine.swift in Sources */,
|
||||||
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */,
|
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */,
|
||||||
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */,
|
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */,
|
||||||
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */,
|
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */,
|
||||||
|
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */,
|
||||||
|
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -646,10 +674,11 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "";
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "";
|
||||||
CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements;
|
CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = CoachWatch/Info.plist;
|
INFOPLIST_FILE = CoachWatch/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -664,7 +693,7 @@
|
|||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 4;
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
WATCHOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -673,6 +702,7 @@
|
|||||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
@@ -736,6 +766,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
@@ -797,10 +828,10 @@
|
|||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -822,10 +853,10 @@
|
|||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -846,10 +877,11 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "";
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "";
|
||||||
CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements;
|
CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = CoachWatch/Info.plist;
|
INFOPLIST_FILE = CoachWatch/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -865,7 +897,7 @@
|
|||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 4;
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
WATCHOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@@ -879,12 +911,13 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = CoachWatchWidgets/Info.plist;
|
INFOPLIST_FILE = CoachWatchWidgets/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets;
|
INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -920,12 +953,13 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = CoachWatchWidgets/Info.plist;
|
INFOPLIST_FILE = CoachWatchWidgets/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets;
|
INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -956,7 +990,7 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements;
|
CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
DEVELOPMENT_TEAM = TZ2PVTRKNY;
|
DEVELOPMENT_TEAM = TZ2PVTRKNY;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -985,7 +1019,7 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements;
|
CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
DEVELOPMENT_TEAM = TZ2PVTRKNY;
|
DEVELOPMENT_TEAM = TZ2PVTRKNY;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -1066,6 +1100,14 @@
|
|||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
8807F51730456ED400A4D4E8 /* XCRemoteSwiftPackageReference "polar-ble-sdk" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/polarofficial/polar-ble-sdk";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 8.2.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = {
|
88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/google/GoogleSignIn-iOS";
|
repositoryURL = "https://github.com/google/GoogleSignIn-iOS";
|
||||||
@@ -1082,6 +1124,11 @@
|
|||||||
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
|
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
|
||||||
productName = "CapApp-SPM";
|
productName = "CapApp-SPM";
|
||||||
};
|
};
|
||||||
|
8807F51C3045721100A4D4E8 /* PolarBleSdk */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 8807F51730456ED400A4D4E8 /* XCRemoteSwiftPackageReference "polar-ble-sdk" */;
|
||||||
|
productName = PolarBleSdk;
|
||||||
|
};
|
||||||
88924CCD2FD2A733002F3A80 /* GoogleSignIn */ = {
|
88924CCD2FD2A733002F3A80 /* GoogleSignIn */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */;
|
package = 88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */;
|
||||||
|
|||||||
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
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "a07fb3cab82bafe1a29362b82b6680a574f65c3772bc11737e36d9b15949d1dd",
|
"originHash" : "71d869ed5304dbc1f922c9e16fb695db4fb02f0cacefa12d4a10572958e936b8",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "app-check",
|
"identity" : "app-check",
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/ionic-team/capacitor-swift-pm.git",
|
"location" : "https://github.com/ionic-team/capacitor-swift-pm.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "f1a8fadf1437c23b825c818fb6509c9dbbae2f61",
|
"revision" : "0b6882e9a3288342aacf36348e5a94e4f1dd7b13",
|
||||||
"version" : "8.3.1"
|
"version" : "8.5.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -73,6 +73,15 @@
|
|||||||
"version" : "2.1.1"
|
"version" : "2.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "polar-ble-sdk",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/polarofficial/polar-ble-sdk",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "3d15da61dd0c63e6be2582d03fd80f6c7ba02e11",
|
||||||
|
"version" : "8.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "promises",
|
"identity" : "promises",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -81,6 +90,24 @@
|
|||||||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
|
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
|
||||||
"version" : "2.4.0"
|
"version" : "2.4.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-protobuf",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-protobuf.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8",
|
||||||
|
"version" : "1.38.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "zip",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/marmelroy/Zip.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "67fa55813b9e7b3b9acee9c0ae501def28746d76",
|
||||||
|
"version" : "2.1.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 3
|
"version" : 3
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2650"
|
LastUpgradeVersion = "2700"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2650"
|
LastUpgradeVersion = "2700"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
@@ -55,7 +55,6 @@
|
|||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "D5BF41648D334DC24770B9D2"
|
BlueprintIdentifier = "D5BF41648D334DC24770B9D2"
|
||||||
BuildableName = "CoachWatch.app"
|
BuildableName = "CoachWatch.app"
|
||||||
BlueprintName = "CoachWatch"
|
|
||||||
ReferencedContainer = "container:App.xcodeproj">
|
ReferencedContainer = "container:App.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
@@ -72,7 +71,6 @@
|
|||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "D5BF41648D334DC24770B9D2"
|
BlueprintIdentifier = "D5BF41648D334DC24770B9D2"
|
||||||
BuildableName = "CoachWatch.app"
|
BuildableName = "CoachWatch.app"
|
||||||
BlueprintName = "CoachWatch"
|
|
||||||
ReferencedContainer = "container:App.xcodeproj">
|
ReferencedContainer = "container:App.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Capacitor
|
import Capacitor
|
||||||
#if canImport(GoogleSignIn)
|
|
||||||
import GoogleSignIn
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
|
// Conservée comme dans le template Capacitor 8.5. En cycle par scènes,
|
||||||
|
// c'est le SceneDelegate qui possède la fenêtre affichée.
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
@@ -47,28 +46,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
HTTPCookieStorage.shared.setCookie(cookie)
|
HTTPCookieStorage.shared.setCookie(cookie)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillResignActive(_ application: UIApplication) {}
|
// ─── Cycle de vie par scènes (obligatoire iOS 27) ───────────────────
|
||||||
func applicationDidEnterBackground(_ application: UIApplication) {}
|
//
|
||||||
func applicationWillEnterForeground(_ application: UIApplication) {}
|
// ⚠️ Depuis l'adoption du `UIApplicationSceneManifest`, UIKit n'appelle
|
||||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
// PLUS `applicationDidBecomeActive`, `application(_:open:options:)` ni
|
||||||
// Les saisies faites depuis le widget (eau, café) attendent dans l'App
|
// `application(_:continue:restorationHandler:)` — « even if you keep them
|
||||||
// Group : une extension n'a ni session ni droit de tenir une requête.
|
// implemented » (Apple, *Transitioning to the UIKit scene-based life
|
||||||
// On les transmet ici, à chaque retour au premier plan.
|
// cycle*). Les trois ont déménagé dans `SceneDelegate.swift` :
|
||||||
Task { await CoachQuickSync.flush() }
|
//
|
||||||
|
// applicationDidBecomeActive -> sceneDidBecomeActive (CoachQuickSync)
|
||||||
|
// application(_:open:options:) -> scene(_:openURLContexts:) (OAuth Google)
|
||||||
|
// application(_:continue:…) -> scene(_:continue:) (Universal Links)
|
||||||
|
//
|
||||||
|
// Elles ne sont pas conservées ici en doublon : une méthode morte qui a
|
||||||
|
// l'air vivante est exactement ce qui fait chercher la panne au mauvais
|
||||||
|
// endroit. Les callbacks de notification distante, eux, restent sur
|
||||||
|
// l'AppDelegate — `UISceneDelegate` n'en a aucun équivalent.
|
||||||
|
func application(_ application: UIApplication,
|
||||||
|
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||||
|
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||||
|
let config = UISceneConfiguration(name: "Default Configuration",
|
||||||
|
sessionRole: connectingSceneSession.role)
|
||||||
|
config.delegateClass = SceneDelegate.self
|
||||||
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {}
|
func applicationWillTerminate(_ application: UIApplication) {}
|
||||||
|
|
||||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
|
||||||
#if canImport(GoogleSignIn)
|
|
||||||
// Retour du flux OAuth Google (reversed client id URL scheme).
|
|
||||||
if GIDSignIn.sharedInstance.handle(url) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
|
||||||
}
|
|
||||||
|
|
||||||
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
|
||||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
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] = [
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "sendInterval", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "sendInterval", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "scheduledWorkouts", returnType: CAPPluginReturnPromise),
|
||||||
]
|
]
|
||||||
|
|
||||||
@objc func isAvailable(_ call: CAPPluginCall) {
|
@objc func isAvailable(_ call: CAPPluginCall) {
|
||||||
if #available(iOS 17.0, *) {
|
guard #available(iOS 17.0, *) else {
|
||||||
call.resolve(["available": true, "min_ios": "17.0"])
|
|
||||||
} else {
|
|
||||||
call.resolve(["available": false, "reason": "iOS 17+ required for WorkoutKit"])
|
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 {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await sendCustomWorkout(
|
let scheduled = try await sendCustomWorkout(
|
||||||
activity: activity,
|
activity: activity,
|
||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
warmupMin: warmupMin,
|
warmupMin: warmupMin,
|
||||||
@@ -143,9 +173,12 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
"sent": true,
|
"sent": true,
|
||||||
"activityUsed": Int(activity.rawValue),
|
"activityUsed": Int(activity.rawValue),
|
||||||
"activityFallback": activity != requestedActivity,
|
"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 {
|
} 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?)],
|
stepsSpec: [(min: Double, hrZone: Int?, bpm: ClosedRange<Double>?, isWork: Bool, label: String?)],
|
||||||
repeats: Int,
|
repeats: Int,
|
||||||
cooldownMin: Double
|
cooldownMin: Double
|
||||||
) async throws {
|
) async throws -> [[String: Any]] {
|
||||||
|
|
||||||
// 1. Warmup — un WorkoutStep optionnel. Si warmupMin == 0, on passe
|
// 1. Warmup — un WorkoutStep optionnel. Si warmupMin == 0, on passe
|
||||||
// nil au CustomWorkout (la signature accepte WorkoutStep?). Passer
|
// 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
|
// 8. Demande d'autorisation explicite (idempotent — déclenche dialog
|
||||||
// iOS la 1re fois, return immédiat si déjà accordé).
|
// 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 {
|
do {
|
||||||
try await WorkoutScheduler.shared.requestAuthorization()
|
try await WorkoutScheduler.shared.requestAuthorization()
|
||||||
NSLog("[CoachWorkoutKit] requestAuthorization OK")
|
NSLog("[CoachWorkoutKit] requestAuthorization OK")
|
||||||
} catch {
|
} catch {
|
||||||
NSLog("[CoachWorkoutKit] requestAuthorization failed: \(error)")
|
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
|
// 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)
|
try await WorkoutScheduler.shared.schedule(plan, at: comps)
|
||||||
NSLog("[CoachWorkoutKit] schedule OK at \(scheduleDate)")
|
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 Capacitor
|
||||||
import HealthKit
|
import HealthKit
|
||||||
import UserNotifications
|
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)
|
@objc(CoachWorkoutObserverPlugin)
|
||||||
public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
@@ -375,8 +382,66 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
}.resume()
|
}.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) {
|
private func scheduleNotification(for workout: HKWorkout) {
|
||||||
reportToRoutineIfStrength(workout)
|
reportToRoutineIfStrength(workout)
|
||||||
|
reportPlanOrigin(workout)
|
||||||
let durationMin = Int(workout.duration / 60)
|
let durationMin = Int(workout.duration / 60)
|
||||||
var bodyParts: [String] = ["\(durationMin) min"]
|
var bodyParts: [String] = ["\(durationMin) min"]
|
||||||
if let kcal = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()), kcal > 0 {
|
if let kcal = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()), kcal > 0 {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
<false/>
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<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>
|
<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>
|
<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>
|
<key>NSHealthShareUsageDescription</key>
|
||||||
@@ -48,6 +50,25 @@
|
|||||||
<string>location</string>
|
<string>location</string>
|
||||||
<string>fetch</string>
|
<string>fetch</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
<key>UISceneConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>Default Configuration</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||||
|
<key>UISceneStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ class MainViewController: CAPBridgeViewController {
|
|||||||
// l'app watchOS, et relaie les coches faites sur la montre vers
|
// l'app watchOS, et relaie les coches faites sur la montre vers
|
||||||
// /api/routine/day (cf. CoachRoutineBridge.swift).
|
// /api/routine/day (cf. CoachRoutineBridge.swift).
|
||||||
bridge?.registerPluginInstance(CoachRoutineBridgePlugin())
|
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.
|
/// 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
ios/App/App/SceneDelegate.swift
Normal file
84
ios/App/App/SceneDelegate.swift
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import UIKit
|
||||||
|
import Capacitor
|
||||||
|
#if canImport(GoogleSignIn)
|
||||||
|
import GoogleSignIn
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Cycle de vie par scènes, **obligatoire** depuis iOS 27 : « apps built with
|
||||||
|
/// the latest SDK must adopt the scene-based life cycle or they fail to launch »
|
||||||
|
/// (Apple, *Transitioning to the UIKit scene-based life cycle*). Sans ce
|
||||||
|
/// fichier et le `UIApplicationSceneManifest` de l'Info.plist, l'app ne démarre
|
||||||
|
/// pas du tout — c'est un assert au lancement, pas un avertissement.
|
||||||
|
///
|
||||||
|
/// ⚠️ **Ce qui arrive ici arrive en SILENCE.** Une fois le manifeste en place,
|
||||||
|
/// UIKit cesse d'appeler `application(_:open:options:)`,
|
||||||
|
/// `application(_:continue:restorationHandler:)` et `applicationDidBecomeActive`
|
||||||
|
/// sur l'AppDelegate — « even if you keep them implemented » (Apple). Rien ne
|
||||||
|
/// casse à la compilation : le retour OAuth Google part vers Safari et ne
|
||||||
|
/// revient jamais, et les saisies du widget restent bloquées dans l'App Group.
|
||||||
|
/// Les trois sont donc repris ci-dessous.
|
||||||
|
///
|
||||||
|
/// Ce que la migration NE change PAS, et c'est ce qui la rendait acceptable :
|
||||||
|
/// `application(_:didFinishLaunchingWithOptions:)` reste appelé, et toujours
|
||||||
|
/// **avant** que la scène ne se connecte — donc avant que la WKWebView charge.
|
||||||
|
/// L'injection du cookie d'auth garde son ordre (Apple documente la séquence :
|
||||||
|
/// launch → création des scènes → `scene(_:willConnectTo:)`). Les callbacks de
|
||||||
|
/// notification distante ne bougent pas non plus : `UISceneDelegate` n'a aucun
|
||||||
|
/// équivalent, et ils ne sont pas dépréciés.
|
||||||
|
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
var window: UIWindow?
|
||||||
|
|
||||||
|
func scene(_ scene: UIScene,
|
||||||
|
willConnectTo session: UISceneSession,
|
||||||
|
options connectionOptions: UIScene.ConnectionOptions) {
|
||||||
|
guard let windowScene = scene as? UIWindowScene else { return }
|
||||||
|
|
||||||
|
// ⚠️ Ne PAS conditionner cette création à `window == nil`.
|
||||||
|
//
|
||||||
|
// C'était ma première version, par prudence : le manifeste déclarant
|
||||||
|
// `UISceneStoryboardFile = Main`, je supposais qu'UIKit avait déjà
|
||||||
|
// monté la fenêtre et qu'il ne fallait pas l'écraser. Résultat sur
|
||||||
|
// appareil : **écran noir permanent, sans la moindre erreur**. UIKit
|
||||||
|
// fournit bien une fenêtre, mais ne la rend pas visible à notre place ;
|
||||||
|
// la garde sautait donc `makeKeyAndVisible()` et rien ne s'affichait.
|
||||||
|
//
|
||||||
|
// Le template de Capacitor crée la fenêtre sans condition, et c'est le
|
||||||
|
// chemin éprouvé. Le seul écart nécessaire est `MainViewController` au
|
||||||
|
// lieu d'un `CAPBridgeViewController` nu : notre sous-classe enregistre
|
||||||
|
// les neuf plugins in-app dans `capacitorDidLoad()`. Elle ne dépend de
|
||||||
|
// rien du storyboard, donc l'instancier ici est sans perte.
|
||||||
|
let fenetre = UIWindow(windowScene: windowScene)
|
||||||
|
fenetre.rootViewController = MainViewController()
|
||||||
|
window = fenetre
|
||||||
|
fenetre.makeKeyAndVisible()
|
||||||
|
|
||||||
|
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||||
|
// Retour du flux OAuth Google. `handle(url)` prend UNE url alors que la
|
||||||
|
// scène en livre un ensemble : on laisse à Capacitor ce que Google n'a
|
||||||
|
// pas consommé, plutôt que de tout lui passer ou de tout lui cacher.
|
||||||
|
var restants = URLContexts
|
||||||
|
#if canImport(GoogleSignIn)
|
||||||
|
restants = URLContexts.filter { !GIDSignIn.sharedInstance.handle($0.url) }
|
||||||
|
#endif
|
||||||
|
guard !restants.isEmpty else { return }
|
||||||
|
SceneDelegateProxy.shared.scene(scene, openURLContexts: restants)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||||
|
// Universal Links : les .widgetURL des widgets, de la Live Activity et
|
||||||
|
// des complications Watch passent par là. Sans ce relais, ils
|
||||||
|
// ouvriraient Safari, où le cookie injecté dans la WKWebView n'existe
|
||||||
|
// pas.
|
||||||
|
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||||
|
// Reprend `applicationDidBecomeActive`, qu'UIKit n'appelle plus. Les
|
||||||
|
// saisies faites depuis le widget (eau, café) attendent dans l'App
|
||||||
|
// Group : une extension n'a ni session ni droit de tenir une requête.
|
||||||
|
Task { await CoachQuickSync.flush() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import PackageDescription
|
|||||||
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
|
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "CapApp-SPM",
|
name: "CapApp-SPM",
|
||||||
platforms: [.iOS(.v15)],
|
platforms: [.iOS(.v"$)],
|
||||||
products: [
|
products: [
|
||||||
.library(
|
.library(
|
||||||
name: "CapApp-SPM",
|
name: "CapApp-SPM",
|
||||||
|
|||||||
@@ -24,6 +24,18 @@
|
|||||||
<string>Coach Hypnotruck lit ta fréquence cardiaque et tes calories pendant la séance pour les afficher en direct sur ton iPhone.</string>
|
<string>Coach Hypnotruck lit ta fréquence cardiaque et tes calories pendant la séance pour les afficher en direct sur ton iPhone.</string>
|
||||||
<key>NSHealthUpdateUsageDescription</key>
|
<key>NSHealthUpdateUsageDescription</key>
|
||||||
<string>Coach Hypnotruck enregistre ta séance dans l'app Santé.</string>
|
<string>Coach Hypnotruck enregistre ta séance dans l'app Santé.</string>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Coach Hypnotruck suit ta position pendant la séance pour tracer ton parcours, afficher la carte et mesurer le dénivelé.</string>
|
||||||
|
<!-- ⚠️ `location` est requis par `allowsBackgroundLocationUpdates` : armer ce
|
||||||
|
drapeau sans la clé TERMINE l'app (« is a fatal error that terminates
|
||||||
|
the app », doc Apple). `LocationTracker.backgroundModeDeclared` vérifie
|
||||||
|
donc cette clé avant d'armer quoi que ce soit. La clé qui porte
|
||||||
|
`location` sur watchOS est `UIBackgroundModes`, distincte de
|
||||||
|
`WKBackgroundModes` qui porte `workout-processing`. -->
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>location</string>
|
||||||
|
</array>
|
||||||
<key>WKApplication</key>
|
<key>WKApplication</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>WKBackgroundModes</key>
|
<key>WKBackgroundModes</key>
|
||||||
|
|||||||
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,8 +31,59 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
private let minSendInterval: TimeInterval = 0.5
|
private let minSendInterval: TimeInterval = 0.5
|
||||||
private var simTask: Task<Void, Never>?
|
private var simTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// Date du dernier échantillon de FC **réellement mesuré**. Posée nulle
|
||||||
|
/// part ailleurs que là où HealthKit nous donne une valeur.
|
||||||
|
private var heartRateAt: Date?
|
||||||
|
|
||||||
|
/// Au-delà de ce délai sans nouvel échantillon, on cesse d'afficher et
|
||||||
|
/// d'émettre la dernière FC connue.
|
||||||
|
///
|
||||||
|
/// ⚠️ Cette valeur est un **choix d'ingénierie, pas une donnée
|
||||||
|
/// physiologique** : Apple ne publie pas de cadence garantie pour
|
||||||
|
/// l'échantillonnage de la FC en séance. Elle est posée au-dessus du
|
||||||
|
/// `lostThreshold` de 8 s de `LiveStore` côté iPhone, pour que la FC ne se
|
||||||
|
/// périme jamais *avant* que la connexion elle-même soit déclarée perdue —
|
||||||
|
/// deux signaux contradictoires seraient illisibles au poignet.
|
||||||
|
///
|
||||||
|
/// Si « — » clignote en usage normal, c'est cette valeur qu'il faut
|
||||||
|
/// augmenter, pas la péremption qu'il faut retirer.
|
||||||
|
///
|
||||||
|
/// ⚠️ Limite assumée : la péremption est évaluée dans `ingest`, donc elle
|
||||||
|
/// suppose qu'un échantillon — d'un type quelconque — continue d'arriver.
|
||||||
|
/// C'est le cas du scénario visé (le capteur FC décroche pendant que
|
||||||
|
/// calories et distance continuent). Si plus RIEN n'arrive, l'écran entier
|
||||||
|
/// se fige, chronomètre compris, ce qui se voit ; un timer dédié coûterait
|
||||||
|
/// de la batterie pour couvrir un cas déjà visible autrement.
|
||||||
|
private let heartRateStaleAfter: TimeInterval = 10
|
||||||
|
|
||||||
|
/// La FC connue si elle est encore fraîche, 0 sinon.
|
||||||
|
///
|
||||||
|
/// 0 est la convention « inconnu » partagée par toute la chaîne :
|
||||||
|
/// `HeartRateMetric` n'affiche alors plus de zone, et `heartRateText` côté
|
||||||
|
/// iPhone rend « — ».
|
||||||
|
private var freshHeartRate: Double {
|
||||||
|
guard let at = heartRateAt,
|
||||||
|
Date().timeIntervalSince(at) <= heartRateStaleAfter else { return 0 }
|
||||||
|
return heartRate
|
||||||
|
}
|
||||||
|
|
||||||
private override init() { super.init() }
|
private override init() { super.init() }
|
||||||
|
|
||||||
|
/// Remet les mesures à « inconnu » avant une nouvelle séance.
|
||||||
|
///
|
||||||
|
/// `WorkoutManager` est un singleton : sans ça, les valeurs de la séance
|
||||||
|
/// précédente restaient affichées jusqu'au premier échantillon de la
|
||||||
|
/// nouvelle — une FC d'hier présentée comme celle de maintenant.
|
||||||
|
private func resetMetrics() {
|
||||||
|
heartRate = 0
|
||||||
|
heartRateAt = nil
|
||||||
|
activeEnergyKcal = 0
|
||||||
|
distanceMeters = 0
|
||||||
|
speedKmh = 0
|
||||||
|
elapsedSec = 0
|
||||||
|
lastSentAt = .distantPast
|
||||||
|
}
|
||||||
|
|
||||||
func startWorkout(activityType: HKWorkoutActivityType,
|
func startWorkout(activityType: HKWorkoutActivityType,
|
||||||
locationType: HKWorkoutSessionLocationType) async {
|
locationType: HKWorkoutSessionLocationType) async {
|
||||||
guard !isRunning, session == nil else { return }
|
guard !isRunning, session == nil else { return }
|
||||||
@@ -40,6 +91,7 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
statusMessage = "HealthKit indisponible sur cet appareil"
|
statusMessage = "HealthKit indisponible sur cet appareil"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
resetMetrics()
|
||||||
let distanceType = Self.distanceType(for: activityType)
|
let distanceType = Self.distanceType(for: activityType)
|
||||||
let hr = HKQuantityType(.heartRate)
|
let hr = HKQuantityType(.heartRate)
|
||||||
let energy = HKQuantityType(.activeEnergyBurned)
|
let energy = HKQuantityType(.activeEnergyBurned)
|
||||||
@@ -49,8 +101,18 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
// ne la produit pas, les stats restent vides : `ingest` retombe alors
|
// ne la produit pas, les stats restent vides : `ingest` retombe alors
|
||||||
// sur la vitesse moyenne dérivée de la distance.
|
// sur la vitesse moyenne dérivée de la distance.
|
||||||
let speedType = Self.speedType(for: activityType)
|
let speedType = Self.speedType(for: activityType)
|
||||||
let read: Set<HKObjectType> = [hr, energy, distanceType, speedType]
|
// La trace exige d'être demandée en lecture ET en écriture — Apple :
|
||||||
let share: Set<HKSampleType> = [HKQuantityType.workoutType(), hr, energy, distanceType]
|
// « you must request permission to read and share both HKWorkout and
|
||||||
|
// HKWorkoutRoute samples ». Sans elle, `finishRoute` échoue et la
|
||||||
|
// sortie n'a pas de parcours.
|
||||||
|
let route = HKSeriesType.workoutRoute()
|
||||||
|
let outdoor = (locationType == .outdoor)
|
||||||
|
var read: Set<HKObjectType> = [hr, energy, distanceType, speedType]
|
||||||
|
var share: Set<HKSampleType> = [HKQuantityType.workoutType(), hr, energy, distanceType]
|
||||||
|
if outdoor {
|
||||||
|
read.insert(route)
|
||||||
|
share.insert(route)
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await healthStore.requestAuthorization(toShare: share, read: read)
|
try await healthStore.requestAuthorization(toShare: share, read: read)
|
||||||
@@ -66,6 +128,12 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Demandée ici, donc au premier plan : watchOS n'affiche pas la feuille
|
||||||
|
// d'autorisation depuis l'arrière-plan. Volontairement « quand
|
||||||
|
// l'app est active » et jamais « toujours » — sur watchOS ce dernier
|
||||||
|
// mène à un parcours d'autorisation qui boucle.
|
||||||
|
if outdoor { LocationTracker.shared.requestAuthorization() }
|
||||||
|
|
||||||
let config = HKWorkoutConfiguration()
|
let config = HKWorkoutConfiguration()
|
||||||
config.activityType = activityType
|
config.activityType = activityType
|
||||||
config.locationType = locationType
|
config.locationType = locationType
|
||||||
@@ -87,6 +155,19 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
isRunning = true
|
isRunning = true
|
||||||
statusMessage = nil
|
statusMessage = nil
|
||||||
workoutLog.info("beginCollection OK, isRunning=true")
|
workoutLog.info("beginCollection OK, isRunning=true")
|
||||||
|
|
||||||
|
// GPS uniquement en extérieur : en salle il ne ferait que vider la
|
||||||
|
// batterie pour une trace immobile.
|
||||||
|
if outdoor {
|
||||||
|
// Apple déconseille d'instancier un HKWorkoutRouteBuilder
|
||||||
|
// directement : celui du builder de séance associe la trace.
|
||||||
|
let routeBuilder = builder.seriesBuilder(for: HKSeriesType.workoutRoute())
|
||||||
|
as? HKWorkoutRouteBuilder
|
||||||
|
if routeBuilder == nil {
|
||||||
|
workoutLog.error("seriesBuilder n'a pas rendu un HKWorkoutRouteBuilder")
|
||||||
|
}
|
||||||
|
LocationTracker.shared.start(activity: activityType, routeBuilder: routeBuilder)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
self.session = nil
|
self.session = nil
|
||||||
self.builder = nil
|
self.builder = nil
|
||||||
@@ -135,6 +216,7 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
/// Mode test : FC simulée, sans HealthKit, pour valider le flux watch -> iPhone -> /live.
|
/// Mode test : FC simulée, sans HealthKit, pour valider le flux watch -> iPhone -> /live.
|
||||||
func startSimulation() {
|
func startSimulation() {
|
||||||
guard !isRunning else { return }
|
guard !isRunning else { return }
|
||||||
|
resetMetrics()
|
||||||
isRunning = true
|
isRunning = true
|
||||||
statusMessage = "Mode test — FC simulée"
|
statusMessage = "Mode test — FC simulée"
|
||||||
let startedAt = Date()
|
let startedAt = Date()
|
||||||
@@ -146,6 +228,7 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
let kcal = elapsed / 6
|
let kcal = elapsed / 6
|
||||||
let dist = elapsed * 2.6
|
let dist = elapsed * 2.6
|
||||||
self.heartRate = hr
|
self.heartRate = hr
|
||||||
|
self.heartRateAt = Date() // sinon la FC simulée se périme
|
||||||
self.activeEnergyKcal = kcal
|
self.activeEnergyKcal = kcal
|
||||||
self.distanceMeters = dist
|
self.distanceMeters = dist
|
||||||
self.elapsedSec = elapsed
|
self.elapsedSec = elapsed
|
||||||
@@ -167,7 +250,24 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
/// Appelé sur le main depuis le callback de collecte (hors-main).
|
/// Appelé sur le main depuis le callback de collecte (hors-main).
|
||||||
private func ingest(hr: Double?, kcal: Double?, dist: Double?, speedMs: Double?,
|
private func ingest(hr: Double?, kcal: Double?, dist: Double?, speedMs: Double?,
|
||||||
elapsed: TimeInterval) {
|
elapsed: TimeInterval) {
|
||||||
if let hr { heartRate = hr }
|
// ⚠️ `didCollectDataOf` se déclenche dès qu'UN type est collecté. Quand
|
||||||
|
// le capteur optique décroche — bracelet desserré, froid, arythmie —
|
||||||
|
// les calories continuent d'arriver et `hr` vaut nil. Sans la date
|
||||||
|
// ci-dessous, la dernière FC connue restait affichée, et repartait vers
|
||||||
|
// l'iPhone avec un horodatage NEUF : le watchdog de `LiveStore`, qui
|
||||||
|
// ne regarde que l'arrivée de samples, ne pouvait alors jamais la
|
||||||
|
// déclarer perdue. Une FC morte s'affichait stable, avec sa zone
|
||||||
|
// colorée, indéfiniment — de quoi valider un effort sur un chiffre qui
|
||||||
|
// n'existe plus.
|
||||||
|
if let hr {
|
||||||
|
heartRate = hr
|
||||||
|
heartRateAt = Date()
|
||||||
|
} else if freshHeartRate == 0 {
|
||||||
|
// Périmée : on retombe sur la convention « inconnu » plutôt que de
|
||||||
|
// laisser la vue afficher une valeur que plus rien ne soutient.
|
||||||
|
heartRate = 0
|
||||||
|
heartRateAt = nil
|
||||||
|
}
|
||||||
if let kcal { activeEnergyKcal = kcal }
|
if let kcal { activeEnergyKcal = kcal }
|
||||||
if let dist { distanceMeters = dist }
|
if let dist { distanceMeters = dist }
|
||||||
elapsedSec = elapsed
|
elapsedSec = elapsed
|
||||||
@@ -186,7 +286,10 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
guard heartRateUpdated || now.timeIntervalSince(lastSentAt) >= minSendInterval else { return }
|
guard heartRateUpdated || now.timeIntervalSince(lastSentAt) >= minSendInterval else { return }
|
||||||
lastSentAt = now
|
lastSentAt = now
|
||||||
ConnectivityManager.shared.sendSample([
|
ConnectivityManager.shared.sendSample([
|
||||||
"heartRate": heartRate,
|
// `freshHeartRate`, pas `heartRate` : ne jamais envoyer une valeur
|
||||||
|
// périmée sous un horodatage neuf. L'iPhone ne peut pas deviner
|
||||||
|
// l'âge d'un chiffre qu'on lui présente comme actuel.
|
||||||
|
"heartRate": freshHeartRate,
|
||||||
"activeEnergyKcal": activeEnergyKcal,
|
"activeEnergyKcal": activeEnergyKcal,
|
||||||
"distanceMeters": distanceMeters,
|
"distanceMeters": distanceMeters,
|
||||||
"speedKmh": speedKmh,
|
"speedKmh": speedKmh,
|
||||||
@@ -264,14 +367,35 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
|||||||
let paused = (toState == .paused)
|
let paused = (toState == .paused)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.isPaused = paused
|
self.isPaused = paused
|
||||||
|
// Le flux GPS reste ouvert pendant la pause : watchOS interdit
|
||||||
|
// de redémarrer la localisation depuis l'arrière-plan, donc on
|
||||||
|
// ignore les points au lieu de couper le manager.
|
||||||
|
LocationTracker.shared.setPaused(paused)
|
||||||
ConnectivityManager.shared.sendPaused(paused)
|
ConnectivityManager.shared.sendPaused(paused)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard toState == .ended else { return }
|
guard toState == .ended else { return }
|
||||||
let builder = workoutSession.associatedWorkoutBuilder()
|
let builder = workoutSession.associatedWorkoutBuilder()
|
||||||
Task {
|
Task { @MainActor in
|
||||||
|
// Ordre imposé par Apple : couper le GPS, clore la collecte,
|
||||||
|
// sauvegarder la séance, et SEULEMENT ensuite clore la trace en
|
||||||
|
// l'associant au workout obtenu.
|
||||||
|
LocationTracker.shared.stopTracking()
|
||||||
try? await builder.endCollection(at: date)
|
try? await builder.endCollection(at: date)
|
||||||
_ = try? await builder.finishWorkout()
|
// ⚠️ `finishWorkout()` rend `nil` SANS erreur quand la montre est
|
||||||
|
// verrouillée : ce n'est pas un échec, 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
|
Task { @MainActor in
|
||||||
self.isRunning = false
|
self.isRunning = false
|
||||||
@@ -284,6 +408,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,
|
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
|
||||||
didFailWithError error: Error) {
|
didFailWithError error: Error) {
|
||||||
let message = error.localizedDescription
|
let message = error.localizedDescription
|
||||||
|
|||||||
40
package-lock.json
generated
40
package-lock.json
generated
@@ -9,12 +9,12 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^8.3.1",
|
"@capacitor/android": "^8.5.2",
|
||||||
"@capacitor/app": "^8.1.0",
|
"@capacitor/app": "^8.1.0",
|
||||||
"@capacitor/core": "^8.3.1",
|
"@capacitor/core": "^8.5.2",
|
||||||
"@capacitor/geolocation": "^8.2.0",
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
"@capacitor/haptics": "^8.0.2",
|
"@capacitor/haptics": "^8.0.2",
|
||||||
"@capacitor/ios": "^8.3.1",
|
"@capacitor/ios": "^8.5.2",
|
||||||
"@capacitor/local-notifications": "^8.0.2",
|
"@capacitor/local-notifications": "^8.0.2",
|
||||||
"@capacitor/push-notifications": "^8.0.3",
|
"@capacitor/push-notifications": "^8.0.3",
|
||||||
"@capacitor/splash-screen": "^8.0.1",
|
"@capacitor/splash-screen": "^8.0.1",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/assets": "^3.0.5",
|
"@capacitor/assets": "^3.0.5",
|
||||||
"@capacitor/cli": "^8.3.1"
|
"@capacitor/cli": "^8.5.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -51,12 +51,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@capacitor/android": {
|
"node_modules/@capacitor/android": {
|
||||||
"version": "8.3.1",
|
"version": "8.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.5.2.tgz",
|
||||||
"integrity": "sha512-hjskIG8YcBEh3X4yaTXvE9gcqpdcxunTgFruSKnuPxtMxAUzEK4Oq25x0Z1g3cz+MQPc+lRG09R7Ovc+ydKsNw==",
|
"integrity": "sha512-rtiaz78egCcS+vs29wkWCL3C+RhazKMSygXcjutwWP8NmO6r5UBxOvGf3Wjz5+ahobyQ723tn9gCePwRwcNDZQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@capacitor/core": "^8.3.0"
|
"@capacitor/core": "^8.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@capacitor/app": {
|
"node_modules/@capacitor/app": {
|
||||||
@@ -452,10 +452,11 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@capacitor/cli": {
|
"node_modules/@capacitor/cli": {
|
||||||
"version": "8.3.1",
|
"version": "8.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.2.tgz",
|
||||||
"integrity": "sha512-1sPGW4THTDfR6YjXwZ0jM7oAfAtciPOHN00qs/3sNAQx1kKrrEYSfDPwCm1/xlAgi0OeL69SiRfw314Ans+1sw==",
|
"integrity": "sha512-O2kpA5ZPJUAmliDCcmpMf8xnJwMezxlFPUrlyOAjbL3U9MFNY3l4ZpPI/UXwvyPmR8ETcVF9pFr+VdWq61rqTQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ionic/cli-framework-output": "^2.2.8",
|
"@ionic/cli-framework-output": "^2.2.8",
|
||||||
"@ionic/utils-subprocess": "^3.0.1",
|
"@ionic/utils-subprocess": "^3.0.1",
|
||||||
@@ -473,6 +474,7 @@
|
|||||||
"semver": "^7.6.3",
|
"semver": "^7.6.3",
|
||||||
"tar": "^7.5.3",
|
"tar": "^7.5.3",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
|
"xcode": "^3.0.1",
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -484,9 +486,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@capacitor/core": {
|
"node_modules/@capacitor/core": {
|
||||||
"version": "8.3.1",
|
"version": "8.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.2.tgz",
|
||||||
"integrity": "sha512-UF8ItlHguU1Z6GXfPTeT2gakf+ctNI8pAS1kwSBQlsJMlfD4OPoto/SmKnOxKCQvnF4WRcdWeg6C0zREUNaAQg==",
|
"integrity": "sha512-agnQbGdhGw/KZFuLf7H9SY2o+oE7upkr/WPQsYaokXP6f9chaE8iVmbKkfN6SVl4w3pmz7oZ3Ur1hbkmy1hx+w==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
@@ -511,11 +514,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@capacitor/ios": {
|
"node_modules/@capacitor/ios": {
|
||||||
"version": "8.3.1",
|
"version": "8.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.5.2.tgz",
|
||||||
"integrity": "sha512-BEhLyYYHWJLib4mpaPMaaylbC8meqgxbNYwQJH2svsSLW7yo/hFie+Zoo66a44XnqcMd2tvmAuzimWunXZi/xA==",
|
"integrity": "sha512-eOT2S67AfTFG7KN0T3oxwAajrJz4rSye+ODfvChiwrk0F/+kWufFM1qU6cDjGJUOfwdAoeuEpZyWHSwZlu4GFg==",
|
||||||
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@capacitor/core": "^8.3.0"
|
"@capacitor/core": "^8.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@capacitor/local-notifications": {
|
"node_modules/@capacitor/local-notifications": {
|
||||||
|
|||||||
@@ -15,12 +15,12 @@
|
|||||||
"author": "Sylvain Bettinelli",
|
"author": "Sylvain Bettinelli",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^8.3.1",
|
"@capacitor/android": "^8.5.2",
|
||||||
"@capacitor/app": "^8.1.0",
|
"@capacitor/app": "^8.1.0",
|
||||||
"@capacitor/core": "^8.3.1",
|
"@capacitor/core": "^8.5.2",
|
||||||
"@capacitor/geolocation": "^8.2.0",
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
"@capacitor/haptics": "^8.0.2",
|
"@capacitor/haptics": "^8.0.2",
|
||||||
"@capacitor/ios": "^8.3.1",
|
"@capacitor/ios": "^8.5.2",
|
||||||
"@capacitor/local-notifications": "^8.0.2",
|
"@capacitor/local-notifications": "^8.0.2",
|
||||||
"@capacitor/push-notifications": "^8.0.3",
|
"@capacitor/push-notifications": "^8.0.3",
|
||||||
"@capacitor/splash-screen": "^8.0.1",
|
"@capacitor/splash-screen": "^8.0.1",
|
||||||
@@ -30,6 +30,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/assets": "^3.0.5",
|
"@capacitor/assets": "^3.0.5",
|
||||||
"@capacitor/cli": "^8.3.1"
|
"@capacitor/cli": "^8.5.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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