Compare commits
60 Commits
be1ffec5c1
...
feat/watch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aa7265147 | ||
|
|
d41e8a457b | ||
|
|
0d34908f4f | ||
|
|
98b1cd0c89 | ||
|
|
3b5b61268c | ||
|
|
a794323314 | ||
|
|
7e7be4a1d1 | ||
|
|
8e0c2738ce | ||
|
|
6031be8c1c | ||
|
|
8973a26e47 | ||
|
|
b15867273c | ||
|
|
31467851d9 | ||
|
|
2c9af6c684 | ||
|
|
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 |
@@ -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).
|
||||||
@@ -125,5 +126,6 @@ l'emplacement de la toolchain.
|
|||||||
## ⚠️ Limites connues sync
|
## ⚠️ Limites connues sync
|
||||||
|
|
||||||
- **Apple Watch sync** : impossible via API tierce — le wrapper HealthKit dans cette app est obligatoire pour récupérer les données Watch.
|
- **Apple Watch sync** : impossible via API tierce — le wrapper HealthKit dans cette app est obligatoire pour récupérer les données Watch.
|
||||||
|
- **⚠️ `@capgo/capacitor-health` ne déplie pas les séries condensées** (`HKQuantitySample.count > 1`, `Health.swift::readSamples` lit `quantity.doubleValue` seul). HealthKit condense les séances Apple — et depuis iOS/watchOS 27 en quelques heures : les pas d'une course sont devenus 4 tranches de 12 min (22/09/2026). `CoachHealthRoute.getStepSamples` déplie avec `HKQuantitySeriesSampleQuery` ; tout autre type lu par Capgo (FC en tête) peut subir le même sort — même remède si ça arrive. `getWorkoutDetails` expose aussi `activities` (une `HKWorkoutActivity` par étape d'un exercice personnalisé, constaté le même jour).
|
||||||
- **Polar AccessLink** : pas d'historique pré-enregistrement (limitation API).
|
- **Polar AccessLink** : pas d'historique pré-enregistrement (limitation API).
|
||||||
- **Push training-targets vers Polar** : nécessiterait migration AccessLink v4 (backlog).
|
- **Push training-targets vers Polar** : nécessiterait migration AccessLink v4 (backlog).
|
||||||
|
|||||||
181
COWORK.md
181
COWORK.md
@@ -76,10 +76,11 @@ ouvrait Safari**, où le cookie d'auth injecté dans la WKWebView n'existe pas :
|
|||||||
`CoachLiveView` s'ouvre d'elle-même (le JS de `/live` appelle
|
`CoachLiveView` s'ouvre d'elle-même (le JS de `/live` appelle
|
||||||
`openNativeLive`). C'est le point d'entrée qui manquait.
|
`openNativeLive`). C'est le point d'entrée qui manquait.
|
||||||
|
|
||||||
## 🆕 Moteur d'intervalles (2026-08-20, dev — non câblé, non compilé)
|
## 🆕 Chantier montre outdoor (2026-08-20, dev — non compilé)
|
||||||
|
|
||||||
Branche **`feat/watch-outdoor`** (poussée sur Gitea le 2026-08-20), commit
|
Branche **`feat/watch-outdoor`**, commits `f2fa3cc` (moteur d'intervalles) et
|
||||||
`f2fa3cc`. `IntervalEngine` guide les intervalles d'une séance au poignet.
|
`a04dfc4` (trace GPS). `IntervalEngine` guide les intervalles d'une séance au
|
||||||
|
poignet ; `RouteFilter` + `LocationTracker` enregistrent le parcours.
|
||||||
|
|
||||||
**Pourquoi ce code existe** : WorkoutKit ne sait pas exécuter une séance
|
**Pourquoi ce code existe** : WorkoutKit ne sait pas exécuter une séance
|
||||||
structurée dans une app tierce — son seul point d'exécution public est
|
structurée dans une app tierce — son seul point d'exécution public est
|
||||||
@@ -91,29 +92,167 @@ horloge interne. Il répond à « où en sommes-nous ? » à partir du temps et
|
|||||||
distance qu'on lui pousse. Le temps de référence est `elapsedTime` du builder,
|
distance qu'on lui pousse. Le temps de référence est `elapsedTime` du builder,
|
||||||
qui exclut déjà les pauses.
|
qui exclut déjà les pauses.
|
||||||
|
|
||||||
✅ **42 tests Swift verts sur Linux** (`./tests-linux/run.sh`, toolchain
|
✅ **57 tests Swift verts sur Linux** (`./tests-linux/run.sh`, toolchain
|
||||||
`~/workspace/toolchains/bin`), dont les 17 d'`IntervalEngine`. La logique est
|
`~/workspace/toolchains/bin`) : 17 pour `IntervalEngine`, 15 pour `RouteFilter`.
|
||||||
donc validée — ce qui reste est l'intégration, que Linux ne peut pas compiler.
|
Les deux ont été **vérifiés rouges** en neutralisant leur correction (5 échecs
|
||||||
|
sans les frontières théoriques du moteur, 4 sans le lissage du dénivelé). La
|
||||||
|
logique est donc validée — reste l'intégration, que Linux ne peut pas compiler.
|
||||||
|
|
||||||
⚠️ **ÉTAPE MAC OBLIGATOIRE — le fichier n'est PAS dans le projet Xcode.**
|
✅ **Plus d'« Add Files » à faire** — correction du 2026-08-20 après-midi. Une
|
||||||
Vérifié le 2026-08-20 : `grep -c "IntervalEngine" ios/App/App.xcodeproj/project.pbxproj`
|
note antérieure de ce fichier annonçait `IntervalEngine` absent du projet : ce
|
||||||
rend **0**. Xcode l'ignore donc totalement, et **ça compile très bien sans lui** —
|
n'est plus vrai. Les **trois** fichiers sont déclarés dans la cible `CoachWatch`
|
||||||
même piège que `VmaTestView.swift`. À faire :
|
directement dans le `pbxproj` (sauvegarde `project.pbxproj.bak-outdoor`).
|
||||||
|
|
||||||
1. `cp ios/App/App.xcodeproj/project.pbxproj ios/App/App.xcodeproj/project.pbxproj.bak-interval`
|
Contrôle avant de builder — chacun doit rendre **2** (déclaration + phase de
|
||||||
2. Clic droit sur le dossier **CoachWatch** → *Add Files to "App"…* →
|
compilation, soit 1 cible) :
|
||||||
`ios/App/CoachWatch/IntervalEngine.swift`
|
```bash
|
||||||
— **décocher** « Copy items if needed », **cocher la cible `CoachWatch`**.
|
for f in IntervalEngine RouteFilter LocationTracker; do
|
||||||
3. Contrôle : `grep -c "IntervalEngine.swift in Sources" ios/App/App.xcodeproj/project.pbxproj`
|
echo "$f: $(grep -c "$f.swift in Sources" ios/App/App.xcodeproj/project.pbxproj)"
|
||||||
doit rendre **1**.
|
done
|
||||||
4. Commit + push du `pbxproj` immédiatement (règle 3 de ce fichier).
|
```
|
||||||
|
|
||||||
|
⚠️ **À vérifier au premier build, dans cet ordre** :
|
||||||
|
1. **Une séance extérieure demande la localisation** (feuille « lorsque l'app
|
||||||
|
est active »). Si rien n'apparaît, la trace ne partira pas.
|
||||||
|
2. **La sortie a bien un parcours dans Santé** à la fin. C'est le test qui
|
||||||
|
compte : `finishRoute` doit être appelé APRÈS `finishWorkout`, sinon la
|
||||||
|
trace existe sans être associée à la séance.
|
||||||
|
3. **La FC remonte toujours sur `/live`** — `WorkoutManager` a été modifié,
|
||||||
|
c'est le test de non-régression prioritaire.
|
||||||
|
4. Écran éteint, poignet baissé : les positions continuent d'arriver. Si elles
|
||||||
|
s'arrêtent, chercher du côté du CPU (une boucle d'affichage trop rapide fait
|
||||||
|
suspendre l'app), pas du côté des autorisations.
|
||||||
|
|
||||||
|
⚠️ `Info.plist` de `CoachWatch` gagne `UIBackgroundModes = [location]` et
|
||||||
|
`NSLocationWhenInUseUsageDescription`. **La clé de background est vitale** :
|
||||||
|
armer `allowsBackgroundLocationUpdates` sans elle termine l'app. Un garde-fou
|
||||||
|
la vérifie au démarrage, mais si elle disparaissait du build, le suivi
|
||||||
|
retomberait silencieusement au premier plan seul.
|
||||||
|
|
||||||
|
## 🔜 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.xcodeproj` → 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.
|
||||||
- ⚠️ **Corrigé le 2026-08-20** : cette ligne disait `open ios/App/App.xcworkspace`. **Ce fichier n'existe pas** (vérifié : le dépôt ne contient que `App.xcodeproj`). Depuis **Capacitor 8, les dépendances passent par SPM** et il n'y a plus de workspace CocoaPods. La commande échouait donc telle quelle.
|
- Corrigé le 2026-08-20 : `open ios/App/App.xcworkspace` **n'existe pas**. Depuis Capacitor 8 : **`open ios/App/App.xcodeproj`** (SPM, pas CocoaPods).
|
||||||
- ⚠️ **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.
|
- 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 (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.
|
- **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.*
|
||||||
404
docs/DIAGNOSTIC-NATIF-2026-09-24.md
Normal file
404
docs/DIAGNOSTIC-NATIF-2026-09-24.md
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
# Diagnostic natif — les cinq constats ouverts de l'audit, vérifiés
|
||||||
|
|
||||||
|
> ## ⚠️ § 1.2 A ÉTÉ CORRIGÉ le 24/09, après ce diagnostic
|
||||||
|
>
|
||||||
|
> Sylvain a levé le gel sur ce point précis (« tu attends quoi ? »), puis sur
|
||||||
|
> **les quatre autres** (« pour le reste vas-y »). Les cinq sont donc corrigés :
|
||||||
|
> § 1.2 buildé et vérifié sur l'appareil, § 1.3 / 2.3 / 2.4 / 2.6 écrits et
|
||||||
|
> vérifiés en syntaxe, **pas encore compilés**.
|
||||||
|
>
|
||||||
|
> ⚠️ **§ 2.3 et § 2.4 touchent `WorkoutManager.swift`**, c'est-à-dire le chemin
|
||||||
|
> de fin de séance du live workout — ce que la consigne du 22/09 demandait de ne
|
||||||
|
> pas approcher. Fait sur demande explicite, en ne changeant aucune logique :
|
||||||
|
> les `try?` deviennent des `do/catch` qui journalisent, et le chemin d'échec
|
||||||
|
> gagne la clôture qui lui manquait. **À éprouver en séance réelle avant de
|
||||||
|
> considérer que ça tient.**
|
||||||
|
>
|
||||||
|
> ✅ **BUILD FAIT ET VÉRIFIÉ SUR L'APPAREIL, le 24/09.** Le diagnostic de
|
||||||
|
> `/settings` (« Diagnostiquer et pousser ») affiche sur l'iPhone de Sylvain :
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> Montre : snapshot reçu
|
||||||
|
> Zones envoyées : version frozen du 2026-08-12
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Les quatre causes d'échec silencieux sont donc écartées : session activée,
|
||||||
|
> montre appairée, app Watch installée, `updateApplicationContext` sans erreur.
|
||||||
|
> Et la date de version arrive au poignet — elle se perdait au transport.
|
||||||
|
>
|
||||||
|
> ⚠️ **Ce que cela ne prouve pas** : `updateApplicationContext` est asynchrone.
|
||||||
|
> iOS a *accepté* le contexte, il le livre quand il peut. Ce qui est établi,
|
||||||
|
> c'est que l'iPhone a réussi à le transmettre au système — pas que la montre
|
||||||
|
> affiche les bonnes bornes. Seul le poignet le dira, pendant une séance.
|
||||||
|
|
||||||
|
**24/09/2026. Diagnostic mené en lecture seule** — aucune ligne de Swift n'avait
|
||||||
|
été modifiée à sa rédaction.
|
||||||
|
Demandé par Sylvain après l'audit du 22/09, dont cinq constats étaient marqués
|
||||||
|
**[rapporté]** — issus de revues automatisées, jamais reconfrontés au code.
|
||||||
|
L'audit lui-même le disait : « les confirmer avant d'agir ».
|
||||||
|
|
||||||
|
Ce document dit ce que le code fait **aujourd'hui**, ce que l'audit décrivait
|
||||||
|
mal, et ce qu'il faudra corriger le jour où le gel sur le natif sera levé. Il ne
|
||||||
|
propose aucun geste : la correction demande un build Mac, et rien n'est réel
|
||||||
|
tant que ce build n'a pas tourné.
|
||||||
|
|
||||||
|
⚠️ **Les numéros de ligne de l'audit ne valent plus.** Les correctifs du 22/09
|
||||||
|
au soir ont décalé `WorkoutManager.swift` et `CoachHealthRoute.swift`. Les
|
||||||
|
références ci-dessous sont celles du dépôt à `6031be8`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verdict d'ensemble
|
||||||
|
|
||||||
|
**Les cinq sont confirmés.** Aucun faux positif. Deux sont plus graves que
|
||||||
|
l'audit ne le disait, un est plus nuancé.
|
||||||
|
|
||||||
|
| # | Constat | Vérifié | Écart avec l'audit |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1.2 | Zones cardiaques muettes vers la montre | ✅ | **Aggravé** : 4 sorties muettes, pas 1 |
|
||||||
|
| 1.3 | « Refusé » ≡ « aucune donnée » (sommeil) | ✅ | Exact |
|
||||||
|
| 2.3 | Séance perdue sans log | ✅ | **Nuancé** : `nil` n'est pas toujours une perte |
|
||||||
|
| 2.4 | `didFailWithError` laisse le GPS allumé | ✅ | **Aggravé** : `builder = nil` rend la récupération impossible |
|
||||||
|
| 2.6 | Tampon GPS sans verrou | ✅ | Exact |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.2 — Les zones cardiaques peuvent ne jamais atteindre la montre
|
||||||
|
|
||||||
|
**`ios/App/App/CoachWidgetBridge.swift:175-183`** (`pushToWatch`)
|
||||||
|
|
||||||
|
L'audit ne signalait que le `try?`. Il y a **quatre** sorties silencieuses sur
|
||||||
|
sept lignes :
|
||||||
|
|
||||||
|
```swift
|
||||||
|
guard WCSession.isSupported() else { return } // 1. muet
|
||||||
|
let session = WCSession.default
|
||||||
|
guard session.activationState == .activated, // 2. muet
|
||||||
|
let data = try? JSONEncoder().encode(snapshot) else { return } // 3. muet
|
||||||
|
try? session.updateApplicationContext(["widgetSnapshot": data]) // 4. avalé
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun log, aucune remontée au JS. Le snapshot porte `zones: HeartRateZones?`
|
||||||
|
(`CoachWidgetSnapshot.swift:96`) — les bornes Z1…Z5 et `clinicalTargetBpm`, la
|
||||||
|
cible sous bêtabloquant.
|
||||||
|
|
||||||
|
**Ce que fait le chemin voisin, dans le même dépôt**
|
||||||
|
(`CoachRoutineBridge.swift:87-101`) pour la routine de gainage : il vérifie
|
||||||
|
`isPaired` **et** `isWatchAppInstalled` avec une raison explicite, entoure
|
||||||
|
l'appel d'un `do/catch`, journalise l'échec et appelle `call.reject`.
|
||||||
|
|
||||||
|
Le chemin instrumenté porte des exercices de mobilité. Le chemin muet porte les
|
||||||
|
bornes cardiaques d'un patient post-infarctus.
|
||||||
|
|
||||||
|
### L'aggravant est réel, et il se compose avec le premier
|
||||||
|
|
||||||
|
`HeartRateZones` (`ios/App/App/HeartRateZones.swift`) ne porte **aucune date** :
|
||||||
|
ni `version`, ni `validFrom`, ni `computedAt`. Or le backend sert une version
|
||||||
|
**figée et datée** (`data/hr_zones_history.json`, via `app.current_zones()`) —
|
||||||
|
c'est même une règle non négociable du projet web (règle 9 du CLAUDE.md).
|
||||||
|
|
||||||
|
**La date existe côté serveur et se perd au transport.**
|
||||||
|
|
||||||
|
Et `CoachWidgetSnapshot.asOf(_:)` périme l'eau, le café, les kcal, la routine,
|
||||||
|
l'activité du jour et le score de forme — mais **pas `zones`**, délibérément
|
||||||
|
(« version figée, valable des semaines »). Le raisonnement est bon pour une
|
||||||
|
péremption journalière ; il laisse ouvert le cas d'une **révision** des bornes.
|
||||||
|
|
||||||
|
Conséquence composée : après un `POST /api/cardiac-zones/confirm`, si le push
|
||||||
|
échoue silencieusement, la montre affiche les anciennes bornes **sans limite de
|
||||||
|
temps et sans que rien ne le signale**, ni au poignet ni à l'iPhone.
|
||||||
|
|
||||||
|
**À corriger, le jour venu** : aligner `pushToWatch` sur `CoachRoutineBridge`
|
||||||
|
(do/catch, log, `isPaired`/`isWatchAppInstalled`), et faire voyager la date de
|
||||||
|
version des zones pour que la montre puisse dire « bornes du 12/08 » plutôt que
|
||||||
|
d'afficher un chiffre sans âge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.3 — « Accès refusé » et « aucune donnée » sont rendus identiques
|
||||||
|
|
||||||
|
**`ios/App/App/CoachSleep.swift`** — en-tête ligne 30 :
|
||||||
|
|
||||||
|
> `// r.available = false si HealthKit est indisponible ou non autorisé`
|
||||||
|
|
||||||
|
Le code teste `HKHealthStore.isHealthDataAvailable()` (l. 59, 63) et la
|
||||||
|
disponibilité du type (l. 68). **`authorizationStatus(for:)` n'apparaît nulle
|
||||||
|
part dans le fichier.** La requête aboutit et rend :
|
||||||
|
|
||||||
|
```swift
|
||||||
|
call.resolve(["available": true, "samples": out, ...]) // l. 92-96
|
||||||
|
```
|
||||||
|
|
||||||
|
La documentation du fichier promet donc quelque chose que le code ne fait pas.
|
||||||
|
|
||||||
|
**La nuance de l'audit est juste** : HealthKit ne distingue pas volontairement
|
||||||
|
« refusé » de « vide » **en lecture** — c'est une protection de vie privée
|
||||||
|
documentée par Apple. Mais `authorizationStatus(for:)` renvoie `.notDetermined`
|
||||||
|
de façon fiable, et c'est le cas fréquent : réinstallation de l'app,
|
||||||
|
autorisation jamais accordée. C'est celui-là qu'on peut distinguer.
|
||||||
|
|
||||||
|
**Conséquence côté serveur** : `sleep_score.py` calcule une régularité de
|
||||||
|
coucher sur des nuits absentes, sans savoir qu'elles sont absentes parce qu'on
|
||||||
|
ne nous laisse plus regarder. Une valeur fausse, pas une valeur manquante.
|
||||||
|
|
||||||
|
Même famille dans `CoachHealthRoute.swift` (l. 173 et 277 selon l'audit — à
|
||||||
|
revérifier, les lignes ont bougé).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.3 — Une séance peut disparaître sans un seul log
|
||||||
|
|
||||||
|
**`ios/App/CoachWatch/WorkoutManager.swift:383-398`**
|
||||||
|
|
||||||
|
```swift
|
||||||
|
LocationTracker.shared.stopTracking()
|
||||||
|
try? await builder.endCollection(at: date)
|
||||||
|
let workout = try? await builder.finishWorkout()
|
||||||
|
if let workout { … } else if let recovered = await self.recentlySavedWorkout() { … }
|
||||||
|
else { LocationTracker.shared.discardRoute() }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ce que l'audit ne disait pas, et qui change la lecture** : le commentaire du
|
||||||
|
code explique que `finishWorkout()` rend `nil` **sans erreur** quand la montre
|
||||||
|
est verrouillée, et que dans ce cas la séance **est** écrite dans HealthKit —
|
||||||
|
seul l'objet manque. C'est pour ça que `recentlySavedWorkout()` existe. Un `nil`
|
||||||
|
n'est donc pas une perte en soi, et le code le sait.
|
||||||
|
|
||||||
|
**Le défaut est plus précis que « la séance est perdue » :** le double `try?`
|
||||||
|
efface la différence entre deux situations opposées —
|
||||||
|
|
||||||
|
| Ce qui s'est passé | `workout` | La séance est-elle sauvée ? |
|
||||||
|
|---|---|---|
|
||||||
|
| Montre verrouillée à l'arrêt | `nil` | **Oui**, `recentlySavedWorkout()` la retrouve |
|
||||||
|
| `endCollection` a levé | `nil` | **Non**, il n'y a rien à retrouver |
|
||||||
|
|
||||||
|
Les deux chemins convergent vers le même `else`, et `discardRoute()` ne
|
||||||
|
journalise que la perte de la **trace**. `statusMessage` n'est pas touché :
|
||||||
|
après deux heures de sortie, l'utilisateur voit l'écran de fin habituel.
|
||||||
|
|
||||||
|
**À corriger** : remplacer les deux `try?` par un `do/catch` qui journalise
|
||||||
|
l'erreur — sans changer la logique de récupération, qui est bonne.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.4 — `didFailWithError` ne clôt rien et laisse le GPS allumé
|
||||||
|
|
||||||
|
**`ios/App/CoachWatch/WorkoutManager.swift:451-464`**
|
||||||
|
|
||||||
|
```swift
|
||||||
|
workoutLog.error("session didFailWithError domain=… code=…: \(message)")
|
||||||
|
Task { @MainActor in
|
||||||
|
self.statusMessage = "Erreur séance : \(message)"
|
||||||
|
self.isRunning = false
|
||||||
|
self.session = nil
|
||||||
|
self.builder = nil
|
||||||
|
ConnectivityManager.shared.sendEnded(reason: "session-failed")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Comparé au chemin `.ended` (l. 383-398), il manque **tout** : `stopTracking()`,
|
||||||
|
`endCollection`, `finishWorkout`, `finishRoute`/`discardRoute`.
|
||||||
|
|
||||||
|
**Plus grave que l'audit ne le dit** : `self.builder = nil` détruit la seule
|
||||||
|
référence au builder. Après cette ligne, plus aucun code ne peut tenter de
|
||||||
|
sauver ce qu'il avait collecté — la perte devient définitive dans la même
|
||||||
|
instruction qui l'ignore.
|
||||||
|
|
||||||
|
Deux conséquences distinctes :
|
||||||
|
1. **Le GPS continue de tourner.** `LocationTracker` n'est jamais arrêté, alors
|
||||||
|
que la séance est finie du point de vue de l'interface. Sur une montre, ça se
|
||||||
|
paie en batterie, et rien ne l'indique.
|
||||||
|
2. **Les données collectées sont jetées sans tentative de sauvegarde.**
|
||||||
|
|
||||||
|
⚠️ À la différence de § 2.3, ici `statusMessage` **est** renseigné : l'utilisateur
|
||||||
|
voit « Erreur séance ». Il sait qu'il s'est passé quelque chose ; il ne sait pas
|
||||||
|
qu'il a perdu la séance, et le GPS tourne toujours.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.6 — Accès concurrent non protégé sur le tampon GPS
|
||||||
|
|
||||||
|
**`ios/App/App/CoachHealthRoute.swift:183-205`** (`collectLocations`)
|
||||||
|
|
||||||
|
```swift
|
||||||
|
var all: [CLLocation] = []
|
||||||
|
let group = DispatchGroup()
|
||||||
|
var pluginErr: Error?
|
||||||
|
for route in routes {
|
||||||
|
group.enter()
|
||||||
|
let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in
|
||||||
|
if let err = err { pluginErr = err; group.leave(); return }
|
||||||
|
if let locs = locations { all.append(contentsOf: locs) } // ⚠️ sans verrou
|
||||||
|
…
|
||||||
|
```
|
||||||
|
|
||||||
|
`all` et `pluginErr` sont mutés depuis les callbacks de N requêtes, livrées sur
|
||||||
|
des files HealthKit indépendantes. **Aucun verrou.**
|
||||||
|
|
||||||
|
**Le même fichier utilise `NSLock` pour ce motif exact à quatre endroits** :
|
||||||
|
lignes 283, 380, 442 et 565. Ce n'est donc pas une convention absente du
|
||||||
|
projet — c'est une omission à un seul endroit.
|
||||||
|
|
||||||
|
**Déclencheur** : un workout portant plusieurs `HKWorkoutRoute` (multisport, ou
|
||||||
|
reprise après interruption). Avec une seule route, la boucle n'a qu'une
|
||||||
|
itération et le défaut est inobservable.
|
||||||
|
|
||||||
|
⚠️ **On ne sait pas s'il s'est déjà produit** : les routes vivent dans
|
||||||
|
HealthKit, pas sur le serveur, et rien ne journalise `routes.count`. Une
|
||||||
|
corruption de tableau en Swift ne lève pas forcément — elle peut donner une
|
||||||
|
trace tronquée ou un crash, sans rien qui désigne la cause.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ce que ce diagnostic ne dit pas
|
||||||
|
|
||||||
|
- **Aucune de ces corrections n'a été écrite.** Le gel du 22/09 sur le natif
|
||||||
|
tient ; Sylvain a autorisé la lecture, pas la modification.
|
||||||
|
- **Rien n'est vérifiable sans build Mac.** Ces cinq constats sont établis par
|
||||||
|
lecture du code, pas par exécution. Une lecture peut manquer un chemin.
|
||||||
|
- **La dette assumée du 22/09 reste ouverte** : le `defer { completion() }` de
|
||||||
|
`CoachWorkoutObserver` déclare la livraison traitée alors que les requêtes
|
||||||
|
sont encore en vol, sans `beginBackgroundTask`. Elle n'est pas revue ici, elle
|
||||||
|
était déjà documentée et assumée.
|
||||||
|
- **Le § 4 de l'audit (gravité 3, justesse et cohérence) n'a pas été revérifié.**
|
||||||
|
|
||||||
|
## Si un seul devait être corrigé
|
||||||
|
|
||||||
|
**§ 1.2**, et pas parce qu'il est le plus spectaculaire : parce que c'est le seul
|
||||||
|
dont la conséquence est un **chiffre faux affiché pendant l'effort**, chez
|
||||||
|
quelqu'un qui règle son intensité dessus. Les quatre autres perdent de la donnée
|
||||||
|
ou de la batterie — c'est réparable après coup. Une borne de zone périmée au
|
||||||
|
poignet ne se rattrape pas : elle a déjà servi à décider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ce qui a été corrigé (§ 1.2, le 24/09 après-midi)
|
||||||
|
|
||||||
|
Trois couches, parce que le défaut en traversait trois.
|
||||||
|
|
||||||
|
### 1. L'envoi ne se tait plus — `CoachWidgetBridge.swift`
|
||||||
|
|
||||||
|
`pushToWatch` rend désormais la **raison** de son échec, ou `nil` s'il a réussi,
|
||||||
|
et journalise dans `Logger(subsystem: "ch.hypnotruck.coach", category: "widget")`.
|
||||||
|
Les quatre sorties muettes deviennent **six sorties nommées** :
|
||||||
|
WatchConnectivity non supportée · session non activée · **aucune montre
|
||||||
|
appairée** · **app Watch non installée** · encodage impossible · envoi refusé.
|
||||||
|
Les deux gardes en gras viennent de `CoachRoutineBridge` : sans elles, l'absence
|
||||||
|
de montre se confondait avec un échec d'envoi.
|
||||||
|
|
||||||
|
Le log de succès nomme ce qui compte : `zones=oui depuis=2026-08-12`. Savoir
|
||||||
|
quelles bornes sont au poignet, et depuis quand, sans ouvrir la montre.
|
||||||
|
|
||||||
|
⚠️ **Un échec ne fait PAS échouer `setSnapshot`**, contrairement à
|
||||||
|
`CoachRoutineBridge` où l'envoi *est* l'opération. Ici l'App Group est déjà écrit
|
||||||
|
et les widgets iPhone rafraîchis : une montre verrouillée n'invalide rien. D'où
|
||||||
|
un `resolve` enrichi (`watchPushed`, `watchReason`) et non un `reject` — `ok`
|
||||||
|
est conservé, aucun appelant JS existant ne casse.
|
||||||
|
|
||||||
|
### 2. La date de version voyage — `HeartRateZones.swift` + `widget-bridge.js`
|
||||||
|
|
||||||
|
`zonesSince` (date de la version figée) et `zonesFrom` (`"frozen"` ou `"live"`)
|
||||||
|
rejoignent le modèle, en optionnels — un snapshot écrit avant reste décodable.
|
||||||
|
|
||||||
|
**Le serveur les servait déjà** : `/api/cardiac-zones` rend `zones_since` et
|
||||||
|
`zones_from` depuis `hr_zones_history.effective()`. Rien à changer côté backend ;
|
||||||
|
c'est `widget-bridge.js` qui ne les faisait pas monter dans le snapshot. Deux
|
||||||
|
lignes.
|
||||||
|
|
||||||
|
La montre peut donc maintenant distinguer des bornes **validées** de bornes
|
||||||
|
**provisoires**, et dire leur âge. Ce qu'elle en affiche reste à décider — c'est
|
||||||
|
de l'UI watchOS, hors de ce correctif.
|
||||||
|
|
||||||
|
### 3. Le garde-fou — `tests-linux/`
|
||||||
|
|
||||||
|
Trois tests dans `DayRolloverTests` : la date survit au passage de minuit
|
||||||
|
(`asOf`), elle survit à l'aller-retour JSON (ce qui n'est pas encodé n'arrive
|
||||||
|
pas au poignet), et un snapshot **antérieur** à ces champs se décode toujours —
|
||||||
|
un décodage qui lèverait laisserait la montre sans zones du tout, pire que des
|
||||||
|
zones sans date. La doublure `HeartRateZonesStub.swift` est tenue à l'identique.
|
||||||
|
|
||||||
|
91 tests verts (88 avant).
|
||||||
|
|
||||||
|
### Ce que ce correctif ne fait pas
|
||||||
|
|
||||||
|
- **Il ne prouve pas que le push fonctionne.** Il rend son échec *visible*. Si
|
||||||
|
l'envoi échouait pour une raison qu'on ignore, on le saura au prochain build —
|
||||||
|
c'était impossible jusqu'ici.
|
||||||
|
- **Il ne périme pas les zones.** Après une révision, la montre affichera
|
||||||
|
toujours les anciennes bornes tant qu'un push n'a pas réussi ; elle saura
|
||||||
|
seulement les dater. Décider qu'une borne de plus de N jours n'est plus
|
||||||
|
affichable est un choix produit, pas une correction de défaut.
|
||||||
|
- **Il ne touche pas au flux course à pied** (WorkoutKit, live workout FC, app
|
||||||
|
Exercice), conformément à la consigne du 22/09.
|
||||||
|
- ✅ **Compilé et vérifié le 24/09** — sans Console : le diagnostic de
|
||||||
|
`/settings` rend le statut en clair (`Montre : snapshot reçu` · `Zones
|
||||||
|
envoyées : version frozen du 2026-08-12`), parce que le plugin remonte
|
||||||
|
désormais `watchPushed` / `watchReason` jusqu'au JS.
|
||||||
|
⏳ **Reste à voir au poignet** : que la montre affiche ces bornes pendant une
|
||||||
|
séance. L'envoi est accepté par iOS ; la livraison est asynchrone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Les quatre autres, corrigés le 24/09 (« pour le reste vas-y »)
|
||||||
|
|
||||||
|
⛔ **Aucun n'est compilé.** Syntaxe vérifiée (`swiftc -parse`), 91 tests du banc
|
||||||
|
Linux verts — mais ces fichiers importent HealthKit, WatchKit et Capacitor,
|
||||||
|
absents de Swift pour Linux. Le banc ne les couvre pas et ne peut pas les
|
||||||
|
couvrir.
|
||||||
|
|
||||||
|
### § 1.3 — `.notDetermined` n'est plus confondu avec une nuit vide
|
||||||
|
|
||||||
|
`CoachSleep.readSamples` teste désormais `authorizationStatus(for: sleepType)`
|
||||||
|
et rend `{available: false, reason: "authorization not determined"}`.
|
||||||
|
|
||||||
|
⚠️ **Ce qui n'est PAS corrigé, et ne peut pas l'être** : un refus *explicite*
|
||||||
|
reste indiscernable d'une absence de données. HealthKit ne le révèle pas en
|
||||||
|
lecture, par conception — c'est une protection de vie privée, pas un oubli
|
||||||
|
d'Apple. Seul `.notDetermined` est fiable, et c'est le cas fréquent
|
||||||
|
(réinstallation, permission jamais demandée). L'en-tête du fichier dit
|
||||||
|
maintenant exactement ce que la promesse couvre, au lieu de promettre plus.
|
||||||
|
|
||||||
|
### § 2.3 — « séance sauvée » et « rien de sauvé » ne se confondent plus
|
||||||
|
|
||||||
|
Les deux `try?` deviennent des `do/catch`. **La logique de récupération n'a pas
|
||||||
|
changé** — `recentlySavedWorkout()` reste le repli quand `finishWorkout()` rend
|
||||||
|
`nil` sur montre verrouillée. Ce qui change : un `endCollection` qui lève est
|
||||||
|
journalisé, et le `else` final distingue les deux cas, dont un seul est une
|
||||||
|
perte. Dans ce cas seulement, `statusMessage` le dit à l'écran — une montre
|
||||||
|
verrouillée n'est pas une perte, et l'annoncer serait une fausse alerte.
|
||||||
|
|
||||||
|
### § 2.4 — le GPS s'arrête, et la séance est sauvée si elle peut l'être
|
||||||
|
|
||||||
|
`didFailWithError` commence par `stopTracking()` : c'est le seul geste qui
|
||||||
|
réussit à coup sûr, et le seul dont l'omission se paie en batterie pendant des
|
||||||
|
heures. Puis une tentative de clôture en best-effort (`endCollection`,
|
||||||
|
`finishWorkout`), avec `recentlySavedWorkout()` en second recours — une session
|
||||||
|
qui a échoué refusera souvent de clore, mais ne pas essayer garantissait la
|
||||||
|
perte. `builder = nil` n'intervient plus qu'à la fin, après les tentatives.
|
||||||
|
|
||||||
|
Trois issues, trois messages distincts : « Séance interrompue, mais
|
||||||
|
enregistrée », « Séance perdue : … », ou le message d'erreur d'origine.
|
||||||
|
|
||||||
|
### § 2.6 — le tampon GPS est verrouillé
|
||||||
|
|
||||||
|
`collectLocations` protège `all` et `pluginErr` par un `NSLock`, comme les
|
||||||
|
quatre autres endroits du même fichier le faisaient déjà.
|
||||||
|
|
||||||
|
⚠️ **Invérifiable sans le déclencheur** : il faut un workout portant plusieurs
|
||||||
|
`HKWorkoutRoute` (multisport, reprise après interruption). Avec une seule route,
|
||||||
|
la boucle n'a qu'une itération — c'est pourquoi le défaut a pu vivre longtemps
|
||||||
|
sans se manifester, et pourquoi sa correction ne se constatera pas non plus.
|
||||||
|
|
||||||
|
### Ce qu'il faut regarder au prochain build
|
||||||
|
|
||||||
|
| Quoi | Où | Ce qui prouve |
|
||||||
|
|---|---|---|
|
||||||
|
| § 1.3 | `/settings` → synchro santé | Retirer « Sommeil » dans Réglages → Santé, réinstaller : la synchro doit dire « non autorisé », pas enregistrer des nuits vides |
|
||||||
|
| § 2.3 | Console, catégorie `workout` | Une séance normale ne doit RIEN journaliser de neuf. Les nouveaux logs ne sortent qu'en cas d'échec |
|
||||||
|
| § 2.4 | Batterie, après une séance interrompue | Le GPS ne doit plus tourner une fois l'écran de fin affiché |
|
||||||
|
| § 2.6 | — | Rien à voir : le correctif supprime une corruption qui ne se manifestait pas de façon lisible |
|
||||||
|
|
||||||
|
⚠️ **§ 2.3 et § 2.4 sont sur le chemin de fin de séance du live workout.** Le
|
||||||
|
test qui compte n'est pas un log, c'est **une séance réelle menée jusqu'au bout,
|
||||||
|
puis retrouvée dans Santé et dans coach**. Tant qu'elle n'a pas eu lieu, ces
|
||||||
|
deux correctifs sont des intentions.
|
||||||
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
|
||||||
107
docs/siri-et-widget-watch-runbook-mac.md
Normal file
107
docs/siri-et-widget-watch-runbook-mac.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Runbook Mac — Siri + widget Hydratation de la montre
|
||||||
|
|
||||||
|
**Écrit le 25/09/2026, depuis Linux.** Le code Swift est committé ; il reste les
|
||||||
|
étapes Xcode, qui ne peuvent pas se faire d'ici.
|
||||||
|
|
||||||
|
> ⚠️ **Nom des cibles, vérifié le 25/09** : la cible watchOS s'appelle
|
||||||
|
> **`CoachWatchWidgetsExtension`** — le DOSSIER, lui, s'appelle
|
||||||
|
> `CoachWatchWidgets`. Ce runbook disait d'abord le nom du dossier ; corrigé.
|
||||||
|
> Les quatre cibles réelles : `App`, `CoachLiveActivity`, `CoachWatch`,
|
||||||
|
> `CoachWatchWidgetsExtension`.
|
||||||
|
|
||||||
|
> ⛔ **Rien de ce code n'a été compilé.** `AppIntents`, `WidgetKit` et `SwiftUI`
|
||||||
|
> sont des frameworks Apple, absents de Swift pour Linux — le CLAUDE.md de ce
|
||||||
|
> dépôt le dit. Ce qui a été vérifié à la place, par lecture croisée des
|
||||||
|
> sources : que chaque symbole appelé existe (`CoachQuickLog.append`,
|
||||||
|
> `todayTotal`, `pending`, `CoachWidgetStore.load`, `nextMidnight`,
|
||||||
|
> `fallbackQuickDrinks`, `resolvedQuickDrinks`, `waterMlToday`, `waterGoalMl`,
|
||||||
|
> `asOf`), et qu'aucun symbole n'est déclaré deux fois. **Les erreurs de type,
|
||||||
|
> de concurrence et d'API n'apparaîtront qu'au build.**
|
||||||
|
|
||||||
|
## Ce que ça apporte
|
||||||
|
|
||||||
|
| | Avant | Après |
|
||||||
|
|---|---|---|
|
||||||
|
| Siri | rien — aucun `AppShortcutsProvider` | 5 raccourcis, 16 phrases |
|
||||||
|
| Montre | complications d'affichage | widget Smart Stack qui **note un verre** |
|
||||||
|
| Intents | 1, enfermé dans l'extension iPhone | 3, partagés entre 3 cibles |
|
||||||
|
|
||||||
|
## Fichiers ajoutés / modifiés
|
||||||
|
|
||||||
|
- **`ios/App/App/CoachIntents.swift`** *(nouveau)* — `LogDrinkIntent` (déplacé),
|
||||||
|
`HydrationStatusIntent`, `TodaySessionIntent`, `CoachQuickError`.
|
||||||
|
- **`ios/App/App/CoachAppShortcuts.swift`** *(nouveau)* — les phrases Siri.
|
||||||
|
- **`ios/App/CoachWatchWidgets/CoachWatchHydration.swift`** *(nouveau)* — widget
|
||||||
|
Smart Stack de la montre.
|
||||||
|
- `ios/App/CoachLiveActivity/CoachQuickWidget.swift` — l'intent en a été
|
||||||
|
**retiré** (il vivait là, invisible des deux autres cibles).
|
||||||
|
- `ios/App/CoachWatchWidgets/CoachWatchComplications.swift` — le widget rejoint
|
||||||
|
le `WidgetBundle`.
|
||||||
|
|
||||||
|
## Étapes Xcode
|
||||||
|
|
||||||
|
⚠️ **Quitter Xcode puis `git pull` d'abord** (règle COWORK : jamais de pull
|
||||||
|
Xcode ouvert).
|
||||||
|
|
||||||
|
### 1. Target Membership — c'est l'étape qui décide de tout
|
||||||
|
|
||||||
|
| Fichier | App | CoachLiveActivity | CoachWatchWidgetsExtension |
|
||||||
|
|---|:--:|:--:|:--:|
|
||||||
|
| `CoachIntents.swift` | ✅ | ✅ | ✅ |
|
||||||
|
| `CoachAppShortcuts.swift` | ✅ | ❌ | ❌ |
|
||||||
|
| `CoachWatchHydration.swift` | ❌ | ❌ | ✅ |
|
||||||
|
| `CoachQuickLog.swift` | ✅ | ✅ | ✅ *(à vérifier)* |
|
||||||
|
| `CoachWidgetSnapshot.swift` | ✅ | ✅ | ✅ *(déjà le cas)* |
|
||||||
|
|
||||||
|
⚠️ **`CoachAppShortcuts.swift` dans la cible App UNIQUEMENT.** Apple attend un
|
||||||
|
seul `AppShortcutsProvider` par application ; un second dans une extension
|
||||||
|
produirait au mieux un doublon.
|
||||||
|
|
||||||
|
⚠️ **`CoachQuickLog.swift` doit être dans `CoachWatchWidgetsExtension`**, sinon le widget
|
||||||
|
de la montre compile mais n'écrit nulle part. Son en-tête le réclamait déjà.
|
||||||
|
|
||||||
|
### 2. Vérifier l'App Group sur la cible Watch
|
||||||
|
|
||||||
|
`group.ch.hypnotruck.coach` sur **CoachWatch** ET **CoachWatchWidgetsExtension**.
|
||||||
|
Vérifié depuis Linux le 25/09 : les quatre `.entitlements` le portent et les
|
||||||
|
`CODE_SIGN_ENTITLEMENTS` sont câblés. À reconfirmer dans Signing & Capabilities.
|
||||||
|
|
||||||
|
### 3. Build, puis les vérifications qui comptent
|
||||||
|
|
||||||
|
1. **Siri** — dire « Note un verre d'eau dans Coach ». Attendu : « Noté.
|
||||||
|
X,XX L d'eau aujourd'hui », **sans que l'app s'ouvre**.
|
||||||
|
2. **Raccourcis** — l'app *Raccourcis* doit lister les 5 actions sous « Coach ».
|
||||||
|
3. **Smart Stack** — ajouter le widget Hydratation, toucher « +25 cl », vérifier
|
||||||
|
que le total monte **et** que le badge des saisies en attente s'incrémente.
|
||||||
|
4. **Synchro** — ouvrir l'app iPhone, vérifier que la saisie faite au poignet
|
||||||
|
remonte sur `coach.hypnotruck.ch`.
|
||||||
|
5. **Minuit** — le total doit repartir de zéro (c'est `asOf(_:)` qui l'assure,
|
||||||
|
pas le réveil de la timeline : le piège a déjà été payé côté iPhone).
|
||||||
|
|
||||||
|
## Les trois questions que seul le build tranchera
|
||||||
|
|
||||||
|
1. **Une complication de CADRAN accepte-t-elle un bouton d'App Intent, ou est-ce
|
||||||
|
réservé à la Smart Stack ?** La doc Apple n'était pas récupérable depuis
|
||||||
|
Linux. Par prudence, `.accessoryCircular` n'est ici qu'un **affichage** ; si
|
||||||
|
le cadran accepte l'interactivité, on pourra l'enrichir.
|
||||||
|
2. **Les phrases Siri passent-elles la validation ?** Chaque phrase contient
|
||||||
|
`\(.applicationName)`, comme Apple l'exige, et le nom supposé est « Coach »
|
||||||
|
(`CFBundleDisplayName`). Si le nom affiché diffère, les phrases ne se
|
||||||
|
déclencheront pas — c'est silencieux.
|
||||||
|
3. **`ProvidesDialog` sur watchOS.** Les intents rendent
|
||||||
|
`some IntentResult & ProvidesDialog` pour que Siri réponde à voix haute. À
|
||||||
|
confirmer que la cible watchOS l'accepte sans ajustement.
|
||||||
|
|
||||||
|
## Ce qui a été volontairement écarté
|
||||||
|
|
||||||
|
- ⛔ **Saisie des repas depuis la montre.** Pas de caméra, pas de scanner de
|
||||||
|
code-barres, pas de saisie de texte confortable : un bouton « repas » au
|
||||||
|
poignet ne ferait que renvoyer vers l'iPhone. Décidé avec Sylvain le 25/09.
|
||||||
|
- ⛔ **Un paramètre libre de volume dans les phrases Siri.** Un intent
|
||||||
|
paramétré oblige Siri à poser une question — ce qui coûte plus de temps que
|
||||||
|
de sortir le téléphone, et vide le raccourci de son intérêt. Les volumes sont
|
||||||
|
donc fixés (250 ml, 500 ml, café 60 ml).
|
||||||
|
- ⛔ **Toute borne en bpm dans les réponses de Siri.** `TodaySession` n'en porte
|
||||||
|
pas, et il ne faut pas en ajouter : une fourchette cardiaque annoncée par
|
||||||
|
haut-parleur, hors de la recommandation du jour et de ses réserves,
|
||||||
|
reviendrait à prescrire une intensité à un post-infarctus.
|
||||||
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.
|
||||||
@@ -29,9 +29,13 @@
|
|||||||
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 */; };
|
||||||
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, ); }; };
|
||||||
@@ -48,7 +52,7 @@
|
|||||||
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 */; };
|
||||||
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed Foundation 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 */; };
|
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 */; };
|
||||||
@@ -59,6 +63,8 @@
|
|||||||
C0AC4E1Z0003HRZONES00001 /* 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 */; };
|
C0AC4E1Z0004HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
|
||||||
C0AC4E1Z0005HRZONES00001 /* 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 */; };
|
||||||
@@ -104,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 */ = {
|
||||||
@@ -149,6 +155,8 @@
|
|||||||
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; };
|
||||||
@@ -173,9 +181,12 @@
|
|||||||
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>"; };
|
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>"; };
|
||||||
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>"; };
|
||||||
@@ -208,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;
|
||||||
@@ -290,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 */,
|
||||||
@@ -318,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>";
|
||||||
@@ -336,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;
|
||||||
@@ -388,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 = (
|
||||||
);
|
);
|
||||||
@@ -400,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 */;
|
||||||
@@ -454,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;
|
||||||
@@ -478,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 = "";
|
||||||
@@ -564,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 */,
|
||||||
@@ -571,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;
|
||||||
};
|
};
|
||||||
@@ -598,6 +620,8 @@
|
|||||||
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */,
|
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */,
|
||||||
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */,
|
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */,
|
||||||
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */,
|
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */,
|
||||||
|
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */,
|
||||||
|
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -650,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",
|
||||||
@@ -668,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;
|
||||||
};
|
};
|
||||||
@@ -677,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";
|
||||||
@@ -740,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";
|
||||||
@@ -801,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",
|
||||||
@@ -826,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",
|
||||||
@@ -850,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",
|
||||||
@@ -869,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;
|
||||||
};
|
};
|
||||||
@@ -883,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",
|
||||||
@@ -924,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",
|
||||||
@@ -960,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;
|
||||||
@@ -989,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;
|
||||||
@@ -1070,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";
|
||||||
@@ -1086,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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
89
ios/App/App/CoachAppShortcuts.swift
Normal file
89
ios/App/App/CoachAppShortcuts.swift
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// CoachAppShortcuts.swift
|
||||||
|
// Les phrases que Siri écoute. C'est ce qui manquait pour « lier Siri à
|
||||||
|
// l'app » : les App Intents existaient (`CoachIntents.swift`), mais sans
|
||||||
|
// `AppShortcutsProvider` aucune phrase ne les déclenche — Siri ne sait pas
|
||||||
|
// quoi écouter, et les actions ne remontent que dans l'app Raccourcis.
|
||||||
|
//
|
||||||
|
// ⚠️ **Cible App UNIQUEMENT**, pas les extensions.
|
||||||
|
// Apple attend un seul `AppShortcutsProvider` par application ; en déclarer un
|
||||||
|
// second dans une extension widget produirait au mieux un doublon.
|
||||||
|
// Les INTENTS, eux, sont partagés (cf. `CoachIntents.swift`).
|
||||||
|
//
|
||||||
|
// ⚠️ **Chaque phrase doit contenir `\(.applicationName)`** — Apple le refuse
|
||||||
|
// autrement, et le raccourci n'apparaît simplement pas. Le nom vient de
|
||||||
|
// `CFBundleDisplayName` ; les variantes ci-dessous supposent « Coach ».
|
||||||
|
//
|
||||||
|
// ⚠️ **Dix phrases maximum par intent**, et les premières comptent le plus :
|
||||||
|
// ce sont celles que Siri propose spontanément.
|
||||||
|
import AppIntents
|
||||||
|
|
||||||
|
@available(iOS 17.0, watchOS 10.0, *)
|
||||||
|
struct CoachAppShortcuts: AppShortcutsProvider {
|
||||||
|
|
||||||
|
/// Couleur de la tuile dans l'app Raccourcis.
|
||||||
|
static var shortcutTileColor: ShortcutTileColor = .teal
|
||||||
|
|
||||||
|
static var appShortcuts: [AppShortcut] {
|
||||||
|
// ── Noter un verre d'eau ────────────────────────────────────────
|
||||||
|
// Le geste à une vingtaine de fois par jour : c'est lui qui justifie
|
||||||
|
// Siri. Les paramètres sont FIXÉS ici (250 ml) — un intent avec
|
||||||
|
// paramètre libre oblige Siri à poser une question, ce qui coûte plus
|
||||||
|
// de temps que de sortir le téléphone.
|
||||||
|
AppShortcut(
|
||||||
|
intent: LogDrinkIntent(ref: "water", amountMl: 250),
|
||||||
|
phrases: [
|
||||||
|
"Note un verre d'eau dans \(.applicationName)",
|
||||||
|
"J'ai bu un verre d'eau dans \(.applicationName)",
|
||||||
|
"Ajoute un verre d'eau dans \(.applicationName)",
|
||||||
|
"Un verre d'eau dans \(.applicationName)",
|
||||||
|
],
|
||||||
|
shortTitle: "Un verre d'eau",
|
||||||
|
systemImageName: "drop.fill"
|
||||||
|
)
|
||||||
|
|
||||||
|
AppShortcut(
|
||||||
|
intent: LogDrinkIntent(ref: "water", amountMl: 500),
|
||||||
|
phrases: [
|
||||||
|
"Note une bouteille d'eau dans \(.applicationName)",
|
||||||
|
"J'ai bu une bouteille dans \(.applicationName)",
|
||||||
|
"Ajoute une grande eau dans \(.applicationName)",
|
||||||
|
],
|
||||||
|
shortTitle: "Une bouteille d'eau",
|
||||||
|
systemImageName: "waterbottle.fill"
|
||||||
|
)
|
||||||
|
|
||||||
|
AppShortcut(
|
||||||
|
intent: LogDrinkIntent(ref: "coffee", amountMl: 60),
|
||||||
|
phrases: [
|
||||||
|
"Note un café dans \(.applicationName)",
|
||||||
|
"J'ai bu un café dans \(.applicationName)",
|
||||||
|
"Ajoute un café dans \(.applicationName)",
|
||||||
|
],
|
||||||
|
shortTitle: "Un café",
|
||||||
|
systemImageName: "cup.and.saucer.fill"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Consulter ───────────────────────────────────────────────────
|
||||||
|
AppShortcut(
|
||||||
|
intent: HydrationStatusIntent(),
|
||||||
|
phrases: [
|
||||||
|
"Combien j'ai bu dans \(.applicationName)",
|
||||||
|
"Où j'en suis côté hydratation dans \(.applicationName)",
|
||||||
|
"Mon hydratation dans \(.applicationName)",
|
||||||
|
],
|
||||||
|
shortTitle: "Mon hydratation",
|
||||||
|
systemImageName: "drop.circle"
|
||||||
|
)
|
||||||
|
|
||||||
|
AppShortcut(
|
||||||
|
intent: TodaySessionIntent(),
|
||||||
|
phrases: [
|
||||||
|
"Quelle est ma séance du jour dans \(.applicationName)",
|
||||||
|
"Ma séance dans \(.applicationName)",
|
||||||
|
"Qu'est-ce que je fais aujourd'hui dans \(.applicationName)",
|
||||||
|
],
|
||||||
|
shortTitle: "Ma séance du jour",
|
||||||
|
systemImageName: "figure.run"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
CAPPluginMethod(name: "deleteWorkout", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "deleteWorkout", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "getEffortScores", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "getEffortScores", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "getWorkoutDetails", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "getWorkoutDetails", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "getStepSamples", returnType: CAPPluginReturnPromise),
|
||||||
]
|
]
|
||||||
|
|
||||||
private lazy var store = HKHealthStore()
|
private lazy var store = HKHealthStore()
|
||||||
@@ -183,17 +184,36 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
var all: [CLLocation] = []
|
var all: [CLLocation] = []
|
||||||
let group = DispatchGroup()
|
let group = DispatchGroup()
|
||||||
var pluginErr: Error?
|
var pluginErr: Error?
|
||||||
|
/* ⚠️ `all` et `pluginErr` sont mutés depuis les callbacks de N requêtes,
|
||||||
|
livrées sur des files HealthKit indépendantes. Sans verrou, deux
|
||||||
|
`append` concurrents corrompent le tableau — silencieusement, ou par
|
||||||
|
un crash sans rapport apparent avec la cause.
|
||||||
|
|
||||||
|
Ce fichier verrouillait déjà ce motif à quatre autres endroits
|
||||||
|
(`getEffortScores`, `getWorkoutDetails`, et deux fois plus bas) :
|
||||||
|
c'était une omission ponctuelle, pas une convention absente.
|
||||||
|
§ 2.6 du diagnostic du 24/09/2026.
|
||||||
|
|
||||||
|
Déclencheur : un workout portant PLUSIEURS `HKWorkoutRoute`
|
||||||
|
(multisport, reprise après interruption). Avec une seule route la
|
||||||
|
boucle n'a qu'une itération, et le défaut est inobservable — c'est
|
||||||
|
pourquoi il a pu vivre longtemps sans se manifester. */
|
||||||
|
let lock = NSLock()
|
||||||
|
|
||||||
for route in routes {
|
for route in routes {
|
||||||
group.enter()
|
group.enter()
|
||||||
let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in
|
let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in
|
||||||
if let err = err {
|
if let err = err {
|
||||||
|
lock.lock()
|
||||||
pluginErr = err
|
pluginErr = err
|
||||||
|
lock.unlock()
|
||||||
group.leave()
|
group.leave()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let locs = locations {
|
if let locs = locations {
|
||||||
|
lock.lock()
|
||||||
all.append(contentsOf: locs)
|
all.append(contentsOf: locs)
|
||||||
|
lock.unlock()
|
||||||
}
|
}
|
||||||
if done {
|
if done {
|
||||||
group.leave()
|
group.leave()
|
||||||
@@ -396,6 +416,93 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
store.execute(wq)
|
store.execute(wq)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pas d'une fenêtre de temps, séries condensées **dépliées**.
|
||||||
|
///
|
||||||
|
/// Apple, *Accessing condensed workout samples* : « HealthKit enumerates the
|
||||||
|
/// data associated with a workout and adds that data to one or more
|
||||||
|
/// quantity series samples. After saving all the data to series objects,
|
||||||
|
/// HealthKit deletes the originals » — « while still preserving data
|
||||||
|
/// resolution ». Un échantillon condensé a `count > 1`, et son détail se
|
||||||
|
/// lit avec `HKQuantitySeriesSampleQuery`. Depuis watchOS 27 (constaté le
|
||||||
|
/// 21/09/2026), les pas d'une course arrivent en 4 à 7 tranches de 5 à
|
||||||
|
/// 12 min au lieu de ~850 échantillons de 2,5 s ; le plugin Capgo lit
|
||||||
|
/// `quantity.doubleValue` sans regarder `count`. Cette méthode dit si le
|
||||||
|
/// détail existe encore, et le rend s'il existe. Lecture seule.
|
||||||
|
@objc func getStepSamples(_ call: CAPPluginCall) {
|
||||||
|
guard HKHealthStore.isHealthDataAvailable(),
|
||||||
|
let stepType = HKObjectType.quantityType(forIdentifier: .stepCount) else {
|
||||||
|
call.resolve(["samples": [], "reason": "HealthKit unavailable"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let iso = ISO8601DateFormatter()
|
||||||
|
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
let isoPlain = ISO8601DateFormatter()
|
||||||
|
func date(_ s: String?) -> Date? {
|
||||||
|
guard let s = s else { return nil }
|
||||||
|
return iso.date(from: s) ?? isoPlain.date(from: s)
|
||||||
|
}
|
||||||
|
guard let start = date(call.getString("startDate")),
|
||||||
|
let end = date(call.getString("endDate")), end > start else {
|
||||||
|
call.reject("startDate et endDate requis (ISO 8601), fin après début")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
|
||||||
|
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
||||||
|
let query = HKSampleQuery(sampleType: stepType, predicate: window,
|
||||||
|
limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { [weak self] _, results, error in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if let error = error {
|
||||||
|
call.reject("Step fetch failed: \(error.localizedDescription)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let samples = (results as? [HKQuantitySample]) ?? []
|
||||||
|
let group = DispatchGroup()
|
||||||
|
let lock = NSLock()
|
||||||
|
var rows: [[String: Any]] = []
|
||||||
|
var nSeries = 0
|
||||||
|
var nExpanded = 0
|
||||||
|
|
||||||
|
for s in samples {
|
||||||
|
let src = s.sourceRevision.source
|
||||||
|
let base: [String: Any] = ["source": src.name, "sourceId": src.bundleIdentifier]
|
||||||
|
if s.count <= 1 {
|
||||||
|
var row = base
|
||||||
|
row["startDate"] = iso.string(from: s.startDate)
|
||||||
|
row["endDate"] = iso.string(from: s.endDate)
|
||||||
|
row["qty"] = s.quantity.doubleValue(for: .count())
|
||||||
|
lock.lock(); rows.append(row); lock.unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nSeries += 1
|
||||||
|
group.enter()
|
||||||
|
// Une série : ses quantités individuelles, chacune avec son
|
||||||
|
// intervalle. `done` marque le dernier appel du handler.
|
||||||
|
let inSeries = HKQuery.predicateForObject(with: s.uuid)
|
||||||
|
let detail = HKQuantitySeriesSampleQuery(quantityType: stepType,
|
||||||
|
predicate: inSeries) { _, quantity, interval, _, done, err in
|
||||||
|
if let quantity = quantity, let interval = interval {
|
||||||
|
var row = base
|
||||||
|
row["startDate"] = iso.string(from: interval.start)
|
||||||
|
row["endDate"] = iso.string(from: interval.end)
|
||||||
|
row["qty"] = quantity.doubleValue(for: .count())
|
||||||
|
row["fromSeries"] = true
|
||||||
|
lock.lock(); rows.append(row); nExpanded += 1; lock.unlock()
|
||||||
|
}
|
||||||
|
if done || err != nil { group.leave() }
|
||||||
|
}
|
||||||
|
self.store.execute(detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
group.notify(queue: .global()) {
|
||||||
|
rows.sort { ($0["startDate"] as? String ?? "") < ($1["startDate"] as? String ?? "") }
|
||||||
|
NSLog("[CoachHealthRoute] pas : %d échantillons, %d séries, %d dépliés", samples.count, nSeries, nExpanded)
|
||||||
|
call.resolve(["samples": rows, "raw": samples.count, "series": nSeries, "expanded": nExpanded])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store.execute(query)
|
||||||
|
}
|
||||||
|
|
||||||
/// Assemble métadonnées + statistiques d'une séance. Les champs absents ne
|
/// Assemble métadonnées + statistiques d'une séance. Les champs absents ne
|
||||||
/// sont pas émis : le backend distingue « pas mesuré » de « zéro ».
|
/// sont pas émis : le backend distingue « pas mesuré » de « zéro ».
|
||||||
private func details(for workout: HKWorkout, completion: @escaping ([String: Any]) -> Void) {
|
private func details(for workout: HKWorkout, completion: @escaping ([String: Any]) -> Void) {
|
||||||
@@ -423,6 +530,38 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
out["average_mets"] = (mets.doubleValue(for: unit) * 10).rounded() / 10
|
out["average_mets"] = (mets.doubleValue(for: unit) * 10).rounded() / 10
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Activités de la séance — lecture seule, pour VOIR ce qu'Exercice écrit.
|
||||||
|
// Apple : « you can use workout activities to […] represent the active
|
||||||
|
// and rest periods during interval training. All HKWorkout instances
|
||||||
|
// have at least one associated HKWorkoutActivity » (doc HKWorkoutActivity,
|
||||||
|
// lue le 22/09/2026). Apple ne dit PAS que l'app Exercice en écrit une
|
||||||
|
// par étape d'un exercice personnalisé : c'est ce que ce champ permet
|
||||||
|
// de constater. Le serveur ne s'en sert pas tant que ce n'est pas vu.
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
let iso = ISO8601DateFormatter()
|
||||||
|
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
var activities: [[String: Any]] = []
|
||||||
|
for a in workout.workoutActivities {
|
||||||
|
var row: [String: Any] = [
|
||||||
|
"start": iso.string(from: a.startDate),
|
||||||
|
"duration_s": (a.duration * 10).rounded() / 10,
|
||||||
|
"activity_type": Int(a.workoutConfiguration.activityType.rawValue),
|
||||||
|
"location": Int(a.workoutConfiguration.locationType.rawValue),
|
||||||
|
"events": a.workoutEvents.count,
|
||||||
|
]
|
||||||
|
if let end = a.endDate { row["end"] = iso.string(from: end) }
|
||||||
|
// Métadonnées en clair uniquement (String/Number/Bool) : une
|
||||||
|
// HKQuantity ne se sérialise pas, et on ne veut rien deviner.
|
||||||
|
var plain: [String: Any] = [:]
|
||||||
|
for (k, v) in a.metadata ?? [:] {
|
||||||
|
if v is String || v is NSNumber { plain[k] = v }
|
||||||
|
}
|
||||||
|
if !plain.isEmpty { row["metadata"] = plain }
|
||||||
|
activities.append(row)
|
||||||
|
}
|
||||||
|
out["activities"] = activities
|
||||||
|
}
|
||||||
|
|
||||||
// Statistiques agrégées sur la fenêtre exacte de la séance.
|
// Statistiques agrégées sur la fenêtre exacte de la séance.
|
||||||
let samplePredicate = HKQuery.predicateForObjects(from: workout)
|
let samplePredicate = HKQuery.predicateForObjects(from: workout)
|
||||||
var requests: [(key: String, id: HKQuantityTypeIdentifier, unit: HKUnit, option: HKStatisticsOptions, scale: Double)] = []
|
var requests: [(key: String, id: HKQuantityTypeIdentifier, unit: HKUnit, option: HKStatisticsOptions, scale: Double)] = []
|
||||||
|
|||||||
182
ios/App/App/CoachIntents.swift
Normal file
182
ios/App/App/CoachIntents.swift
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
// CoachIntents.swift
|
||||||
|
// App Intents partagés : app iPhone, widget iPhone, widget Watch, Siri.
|
||||||
|
//
|
||||||
|
// ⚠️ **Ce fichier doit appartenir à TOUTES les cibles qui s'en servent** :
|
||||||
|
// App + CoachLiveActivity + CoachWatchWidgets
|
||||||
|
// (cf. docs/widgets-runbook-mac.md, étape « Target Membership »).
|
||||||
|
//
|
||||||
|
// Pourquoi il existe
|
||||||
|
// ------------------
|
||||||
|
// `LogDrinkIntent` vivait dans `CoachQuickWidget.swift`, qui n'appartient qu'à
|
||||||
|
// l'extension widget iPhone. Deux conséquences, constatées le 25/09/2026 :
|
||||||
|
//
|
||||||
|
// - **Siri ne pouvait pas y accéder.** Les raccourcis (`AppShortcutsProvider`)
|
||||||
|
// se déclarent dans l'application principale, qui ne voyait pas cet intent.
|
||||||
|
// - **La montre non plus.** La cible `CoachWatchWidgets` ne portait que des
|
||||||
|
// complications d'affichage : aucune saisie n'était possible au poignet,
|
||||||
|
// alors que c'est le geste que Sylvain fait une vingtaine de fois par jour.
|
||||||
|
//
|
||||||
|
// L'extraire ici, plutôt que le dupliquer, évite deux implémentations qui
|
||||||
|
// divergeraient — le défaut déjà payé sur l'envoi vers la Watch.
|
||||||
|
//
|
||||||
|
// ⛔ Aucun appel réseau ici, et c'est structurel : une extension n'a ni session
|
||||||
|
// authentifiée ni droit de tenir une requête. L'intent écrit dans la file de
|
||||||
|
// `CoachQuickLog` (App Group), et l'application la transmet au serveur à sa
|
||||||
|
// prochaine ouverture. Rien n'est déclaré synchronisé tant qu'il ne l'est pas.
|
||||||
|
import AppIntents
|
||||||
|
import Foundation
|
||||||
|
#if canImport(WidgetKit)
|
||||||
|
import WidgetKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - Erreurs
|
||||||
|
|
||||||
|
enum CoachQuickError: Error, CustomLocalizedStringResourceConvertible {
|
||||||
|
case unavailable
|
||||||
|
var localizedStringResource: LocalizedStringResource {
|
||||||
|
"Stockage partagé indisponible — ouvrez l'app une fois."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Noter une boisson
|
||||||
|
|
||||||
|
/// Ajoute une boisson sans quitter l'écran d'accueil, le cadran, ou en dictant
|
||||||
|
/// à Siri.
|
||||||
|
@available(iOS 17.0, watchOS 10.0, *)
|
||||||
|
struct LogDrinkIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = "Noter une boisson"
|
||||||
|
static var description = IntentDescription(
|
||||||
|
"Ajoute un verre d'eau ou un café au journal, sans ouvrir l'application."
|
||||||
|
)
|
||||||
|
/// `false` : tout l'intérêt est de NE PAS ouvrir l'app.
|
||||||
|
static var openAppWhenRun: Bool = false
|
||||||
|
|
||||||
|
@Parameter(title: "Type") var ref: String
|
||||||
|
@Parameter(title: "Volume (ml)") var amountMl: Double
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
init(ref: String, amountMl: Double) {
|
||||||
|
self.ref = ref
|
||||||
|
self.amountMl = amountMl
|
||||||
|
}
|
||||||
|
|
||||||
|
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||||
|
let ok = CoachQuickLog.append(
|
||||||
|
CoachQuickEntry(kind: .drink, ref: ref, amount: amountMl)
|
||||||
|
)
|
||||||
|
#if canImport(WidgetKit)
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
#endif
|
||||||
|
// Un échec d'App Group doit remonter : afficher « ajouté » sur une
|
||||||
|
// saisie perdue est pire que ne rien afficher.
|
||||||
|
guard ok else { throw CoachQuickError.unavailable }
|
||||||
|
|
||||||
|
// ⚠️ Le dialogue annonce ce qui a été NOTÉ, pas ce qui a été envoyé au
|
||||||
|
// serveur — la file part à la prochaine ouverture de l'app. Dire
|
||||||
|
// « enregistré sur ton compte » serait faux pendant plusieurs heures.
|
||||||
|
let total = CoachQuickLog.todayTotal(
|
||||||
|
kind: "water",
|
||||||
|
syncedMl: CoachWidgetStore.load()?.asOf(Date()).waterMlToday ?? 0
|
||||||
|
)
|
||||||
|
if ref == "water" {
|
||||||
|
return .result(dialog: "Noté. \(Self.enLitres(total)) d'eau aujourd'hui.")
|
||||||
|
}
|
||||||
|
return .result(dialog: "Noté.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// « 1,25 L » plutôt que « 1250 ml » : Siri le lit à voix haute.
|
||||||
|
static func enLitres(_ ml: Double) -> String {
|
||||||
|
let litres = ml / 1000
|
||||||
|
let f = NumberFormatter()
|
||||||
|
f.numberStyle = .decimal
|
||||||
|
f.maximumFractionDigits = litres < 1 ? 2 : 1
|
||||||
|
return (f.string(from: NSNumber(value: litres)) ?? "\(litres)") + " L"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Où j'en suis
|
||||||
|
|
||||||
|
/// Répond « combien j'ai bu aujourd'hui », sans ouvrir l'app.
|
||||||
|
///
|
||||||
|
/// ⚠️ Cet intent ne fait que LIRE ce que l'appareil sait déjà : le snapshot
|
||||||
|
/// poussé par l'app plus la file locale. Il n'interroge pas le serveur — une
|
||||||
|
/// extension n'en a pas le droit, et Siri doit répondre tout de suite.
|
||||||
|
@available(iOS 17.0, watchOS 10.0, *)
|
||||||
|
struct HydrationStatusIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = "Où j'en suis côté hydratation"
|
||||||
|
static var description = IntentDescription(
|
||||||
|
"Dit combien d'eau a été notée aujourd'hui, et la cible du jour."
|
||||||
|
)
|
||||||
|
static var openAppWhenRun: Bool = false
|
||||||
|
|
||||||
|
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||||
|
let snapshot = CoachWidgetStore.load()?.asOf(Date())
|
||||||
|
let total = CoachQuickLog.todayTotal(
|
||||||
|
kind: "water", syncedMl: snapshot?.waterMlToday ?? 0)
|
||||||
|
guard let objectif = snapshot?.waterGoalMl, objectif > 0 else {
|
||||||
|
// Pas de cible connue : on donne le total, sans inventer de reste.
|
||||||
|
return .result(dialog: "\(LogDrinkIntent.enLitres(total)) d'eau aujourd'hui.")
|
||||||
|
}
|
||||||
|
let reste = max(0, objectif - total)
|
||||||
|
if reste <= 0 {
|
||||||
|
return .result(dialog: "\(LogDrinkIntent.enLitres(total)) : objectif atteint.")
|
||||||
|
}
|
||||||
|
// ⚠️ UN SEUL littéral interpolé, jamais de concaténation.
|
||||||
|
// `IntentDialog` est `ExpressibleByStringInterpolation` : un littéral
|
||||||
|
// passe, mais l'opérateur `+` produit une `String`, que le compilateur
|
||||||
|
// refuse — « Cannot convert value of type 'String' to expected argument
|
||||||
|
// type 'IntentDialog' ». Erreur rencontrée au premier build, 25/09.
|
||||||
|
let phrase = "\(LogDrinkIntent.enLitres(total)) sur \(LogDrinkIntent.enLitres(objectif)). Il reste \(LogDrinkIntent.enLitres(reste))."
|
||||||
|
return .result(dialog: IntentDialog(stringLiteral: phrase))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - La séance du jour
|
||||||
|
|
||||||
|
/// Dit la séance prévue aujourd'hui, telle que l'app l'a poussée.
|
||||||
|
///
|
||||||
|
/// ⛔ **Aucune borne en bpm n'est prononcée.** `TodaySession` n'en porte pas —
|
||||||
|
/// vérifié, elle a `sport`, `title`, `subtitle`, `done` — et il ne faut pas en
|
||||||
|
/// ajouter ici : une fourchette cardiaque dite par haut-parleur, hors de la
|
||||||
|
/// recommandation du jour et de ses réserves, reviendrait à prescrire une
|
||||||
|
/// intensité à un post-infarctus. Siri annonce QUOI ; l'app dit COMMENT.
|
||||||
|
/// `subtitle` (« 1h30 · Z2 ») est répété tel que l'app l'affiche, pas
|
||||||
|
/// reformulé : une zone nommée n'est pas une cible en battements.
|
||||||
|
@available(iOS 17.0, watchOS 10.0, *)
|
||||||
|
struct TodaySessionIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = "Ma séance du jour"
|
||||||
|
static var description = IntentDescription(
|
||||||
|
"Annonce le sport et la durée prévus aujourd'hui."
|
||||||
|
)
|
||||||
|
static var openAppWhenRun: Bool = false
|
||||||
|
|
||||||
|
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||||
|
guard let today = CoachWidgetStore.load()?.asOf(Date()).today else {
|
||||||
|
// Idem : pas de `+` dans un dialogue (cf. HydrationStatusIntent).
|
||||||
|
return .result(dialog: "Je n'ai pas encore ta séance du jour. Ouvre l'app une fois.")
|
||||||
|
}
|
||||||
|
let sport = Self.sportEnFrancais(today.sport)
|
||||||
|
if today.done {
|
||||||
|
return .result(dialog: "\(sport) : \(today.title). Déjà faite aujourd'hui.")
|
||||||
|
}
|
||||||
|
// `subtitle` porte l'objectif court tel que l'app l'affiche
|
||||||
|
// (« 1h30 · Z2 ») : on le répète, on ne le reformule pas.
|
||||||
|
if let objectif = today.subtitle, !objectif.isEmpty {
|
||||||
|
return .result(dialog: "\(sport) : \(today.title), \(objectif).")
|
||||||
|
}
|
||||||
|
return .result(dialog: "\(sport) : \(today.title).")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sportEnFrancais(_ sport: String) -> String {
|
||||||
|
switch sport {
|
||||||
|
case "running": return "Course"
|
||||||
|
case "cycling": return "Vélo"
|
||||||
|
case "walking": return "Marche"
|
||||||
|
case "hiking": return "Randonnée"
|
||||||
|
case "strength": return "Renforcement"
|
||||||
|
case "mobility": return "Mobilité"
|
||||||
|
case "rest": return "Repos"
|
||||||
|
default: return sport.capitalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,10 @@
|
|||||||
// startDate: '2026-08-01T00:00:00Z', endDate: '2026-08-19T00:00:00Z',
|
// startDate: '2026-08-01T00:00:00Z', endDate: '2026-08-19T00:00:00Z',
|
||||||
// });
|
// });
|
||||||
// // r.samples = [{startDate, endDate, sleepState, sourceName, timeZone, tzSource}, ...]
|
// // r.samples = [{startDate, endDate, sleepState, sourceName, timeZone, tzSource}, ...]
|
||||||
// // r.available = false si HealthKit est indisponible ou non autorisé
|
// // r.available = false si HealthKit est indisponible, si le type manque,
|
||||||
|
// // ou si l'autorisation n'a jamais été demandée (`.notDetermined`).
|
||||||
|
// // ⚠️ Un refus EXPLICITE reste indiscernable d'une absence de données :
|
||||||
|
// // HealthKit ne le révèle pas en lecture, par conception (cf. readSamples).
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
@@ -68,6 +71,28 @@ public class CoachSleepPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
call.resolve(["available": false, "samples": [], "reason": "sleepAnalysis unavailable"])
|
call.resolve(["available": false, "samples": [], "reason": "sleepAnalysis unavailable"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
/* ⚠️ Sans ce test, « on ne nous laisse plus regarder » et « il n'a pas
|
||||||
|
dormi » rendaient la MÊME réponse : `{available: true, samples: []}`.
|
||||||
|
Le serveur enregistrait alors des nuits vides, et `sleep_score.py`
|
||||||
|
calculait une régularité de coucher sur des trous — une valeur fausse,
|
||||||
|
pas une valeur manquante. L'en-tête de ce fichier promettait pourtant
|
||||||
|
`available = false` si non autorisé. § 1.3 du diagnostic du 24/09/2026.
|
||||||
|
|
||||||
|
Ce que HealthKit permet, et ce qu'il ne permet pas : Apple ne
|
||||||
|
distingue PAS volontairement « refusé » de « vide » en LECTURE, pour
|
||||||
|
ne pas révéler qu'une donnée existe. `.sharingDenied` n'est donc pas
|
||||||
|
fiable pour un type lu. En revanche `.notDetermined` l'est, et c'est
|
||||||
|
le cas fréquent : réinstallation de l'app, permission jamais demandée.
|
||||||
|
On ne traite que celui-là — le reste serait une affirmation qu'Apple
|
||||||
|
ne garantit pas. */
|
||||||
|
if store.authorizationStatus(for: sleepType) == .notDetermined {
|
||||||
|
call.resolve([
|
||||||
|
"available": false,
|
||||||
|
"samples": [],
|
||||||
|
"reason": "authorization not determined",
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let start = Self.parseISO(call.getString("startDate")),
|
guard let start = Self.parseISO(call.getString("startDate")),
|
||||||
let end = Self.parseISO(call.getString("endDate")) else {
|
let end = Self.parseISO(call.getString("endDate")) else {
|
||||||
call.reject("startDate et endDate requis (ISO 8601)")
|
call.reject("startDate et endDate requis (ISO 8601)")
|
||||||
|
|||||||
@@ -13,10 +13,18 @@
|
|||||||
// forme: { score: 72, label:"Prêt" }
|
// forme: { score: 72, label:"Prêt" }
|
||||||
// });
|
// });
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
import Capacitor
|
import Capacitor
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
import WatchConnectivity
|
import WatchConnectivity
|
||||||
|
|
||||||
|
/// ⚠️ Ce pont transporte les bornes de zones cardiaques vers la montre. Il était
|
||||||
|
/// le seul chemin WatchConnectivity du projet à ne rien journaliser — quatre
|
||||||
|
/// sorties silencieuses sur sept lignes — alors que `CoachRoutineBridge`, qui
|
||||||
|
/// porte des exercices de mobilité, journalise et remonte ses échecs.
|
||||||
|
/// Diagnostic du 24/09/2026 : `docs/DIAGNOSTIC-NATIF-2026-09-24.md` § 1.2.
|
||||||
|
private let widgetLog = Logger(subsystem: "ch.hypnotruck.coach", category: "widget")
|
||||||
|
|
||||||
@objc(CoachWidgetBridgePlugin)
|
@objc(CoachWidgetBridgePlugin)
|
||||||
public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
public let identifier = "CoachWidgetBridgePlugin"
|
public let identifier = "CoachWidgetBridgePlugin"
|
||||||
@@ -164,8 +172,15 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() }
|
if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() }
|
||||||
pushToWatch(snapshot)
|
// Le statut d'envoi remonte au JS : jusqu'ici, la page n'avait aucun
|
||||||
call.resolve(["ok": true])
|
// moyen de savoir si les bornes cardiaques étaient arrivées au poignet.
|
||||||
|
// Champs AJOUTÉS, `ok` conservé : aucun appelant existant ne casse.
|
||||||
|
let raison = pushToWatch(snapshot)
|
||||||
|
call.resolve([
|
||||||
|
"ok": true,
|
||||||
|
"watchPushed": raison == nil,
|
||||||
|
"watchReason": raison ?? "",
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Envoie le snapshot à l'Apple Watch pour ses complications. L'App Group
|
/// Envoie le snapshot à l'Apple Watch pour ses complications. L'App Group
|
||||||
@@ -173,14 +188,50 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
/// de SA montre, alimenté ici via WatchConnectivity.
|
/// de SA montre, alimenté ici via WatchConnectivity.
|
||||||
/// On n'est PAS le delegate WCSession (c'est CoachLiveBridge) : on se contente
|
/// On n'est PAS le delegate WCSession (c'est CoachLiveBridge) : on se contente
|
||||||
/// d'émettre, ce qui ne requiert pas d'être delegate, juste une session activée.
|
/// d'émettre, ce qui ne requiert pas d'être delegate, juste une session activée.
|
||||||
private func pushToWatch(_ snapshot: CoachWidgetSnapshot) {
|
/// Rend la raison de l'échec, ou `nil` si le snapshot est parti.
|
||||||
guard WCSession.isSupported() else { return }
|
///
|
||||||
|
/// ⚠️ Un échec ici ne fait PAS échouer `setSnapshot` : l'App Group est déjà
|
||||||
|
/// écrit et les widgets iPhone rafraîchis. Une montre absente ou verrouillée
|
||||||
|
/// n'invalide pas l'opération — d'où un `resolve` enrichi plutôt qu'un
|
||||||
|
/// `reject`, contrairement à `CoachRoutineBridge` où l'envoi EST l'opération.
|
||||||
|
@discardableResult
|
||||||
|
private func pushToWatch(_ snapshot: CoachWidgetSnapshot) -> String? {
|
||||||
|
guard WCSession.isSupported() else {
|
||||||
|
widgetLog.info("snapshot non poussé : WatchConnectivity non supporté")
|
||||||
|
return "WatchConnectivity non supporté"
|
||||||
|
}
|
||||||
let session = WCSession.default
|
let session = WCSession.default
|
||||||
guard session.activationState == .activated,
|
guard session.activationState == .activated else {
|
||||||
let data = try? JSONEncoder().encode(snapshot) else { return }
|
widgetLog.error("snapshot non poussé : session non activée (état \(session.activationState.rawValue, privacy: .public))")
|
||||||
|
return "session WatchConnectivity non activée"
|
||||||
|
}
|
||||||
|
// `isPaired` / `isWatchAppInstalled` comme dans CoachRoutineBridge : sans
|
||||||
|
// eux, l'absence de montre se confond avec un échec d'envoi.
|
||||||
|
guard session.isPaired else {
|
||||||
|
widgetLog.info("snapshot non poussé : aucune montre appairée")
|
||||||
|
return "aucune Watch appairée"
|
||||||
|
}
|
||||||
|
guard session.isWatchAppInstalled else {
|
||||||
|
widgetLog.info("snapshot non poussé : app Watch non installée")
|
||||||
|
return "app Watch non installée"
|
||||||
|
}
|
||||||
|
guard let data = try? JSONEncoder().encode(snapshot) else {
|
||||||
|
widgetLog.error("snapshot non poussé : encodage JSON impossible")
|
||||||
|
return "encodage du snapshot impossible"
|
||||||
|
}
|
||||||
|
do {
|
||||||
// updateApplicationContext : dernier état, sans budget (≠ transferCurrent
|
// updateApplicationContext : dernier état, sans budget (≠ transferCurrent
|
||||||
// ComplicationUserInfo qui réveille la complication mais est rationné ~50/j).
|
// ComplicationUserInfo qui réveille la complication mais est rationné ~50/j).
|
||||||
try? session.updateApplicationContext(["widgetSnapshot": data])
|
try session.updateApplicationContext(["widgetSnapshot": data])
|
||||||
|
// Les zones sont la raison d'être de ce log : savoir QUELLES bornes
|
||||||
|
// sont au poignet, et depuis quand, sans avoir à ouvrir la montre.
|
||||||
|
let z = snapshot.zones
|
||||||
|
widgetLog.info("snapshot poussé vers la Watch · zones=\(z != nil ? "oui" : "non", privacy: .public) depuis=\(z?.zonesSince ?? "?", privacy: .public)")
|
||||||
|
return nil
|
||||||
|
} catch {
|
||||||
|
widgetLog.error("updateApplicationContext a échoué : \(error.localizedDescription, privacy: .public)")
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Relit le snapshot RÉELLEMENT stocké dans l'App Group.
|
/// Relit le snapshot RÉELLEMENT stocké dans l'App Group.
|
||||||
|
|||||||
@@ -69,6 +69,21 @@ struct CoachWidgetSnapshot: Codable {
|
|||||||
var kcalToday: Double? = nil
|
var kcalToday: Double? = nil
|
||||||
var kcalGoal: Double? = nil
|
var kcalGoal: Double? = nil
|
||||||
|
|
||||||
|
/// Macros du jour et leurs cibles (25/09/2026, pour le suivi au poignet).
|
||||||
|
/// Mêmes règles que l'énergie : les totaux viennent du journal, les cibles
|
||||||
|
/// de `nutrition_goals.py` — rien n'est écrit en dur, pour qu'un changement
|
||||||
|
/// d'objectif suive.
|
||||||
|
/// ⚠️ Les cibles portent côté serveur une forme `{target, min, max}` :
|
||||||
|
/// `widget-bridge.js` n'en pousse que `target`. Une jauge sur une cible de
|
||||||
|
/// type « min » (les protéines) se lit donc comme un plancher atteint, pas
|
||||||
|
/// comme un plafond à ne pas dépasser.
|
||||||
|
var proteinToday: Double? = nil
|
||||||
|
var proteinGoal: Double? = nil
|
||||||
|
var fatToday: Double? = nil
|
||||||
|
var fatGoal: Double? = nil
|
||||||
|
var carbsToday: Double? = nil
|
||||||
|
var carbsGoal: Double? = nil
|
||||||
|
|
||||||
/// Boutons de saisie rapide, poussés depuis les préréglages de boisson.
|
/// Boutons de saisie rapide, poussés depuis les préréglages de boisson.
|
||||||
/// `nil` ⇒ repli aligné sur les préréglages par défaut du serveur.
|
/// `nil` ⇒ repli aligné sur les préréglages par défaut du serveur.
|
||||||
var quickDrinks: [QuickDrink]? = nil
|
var quickDrinks: [QuickDrink]? = nil
|
||||||
@@ -237,6 +252,13 @@ extension CoachWidgetSnapshot {
|
|||||||
s.waterMlToday = 0
|
s.waterMlToday = 0
|
||||||
s.coffeeCountToday = 0
|
s.coffeeCountToday = 0
|
||||||
s.kcalToday = 0
|
s.kcalToday = 0
|
||||||
|
// ⚠️ Les macros se périment avec l'énergie. Les oublier ici afficherait
|
||||||
|
// les grammes d'hier sous la date du jour — exactement le défaut que
|
||||||
|
// `asOf` existe pour éviter, et qui avait survécu un temps sur les
|
||||||
|
// totaux de boisson.
|
||||||
|
s.proteinToday = 0
|
||||||
|
s.fatToday = 0
|
||||||
|
s.carbsToday = 0
|
||||||
s.routine = nil
|
s.routine = nil
|
||||||
// L'activité de la veille n'est pas celle du jour, et le score de forme
|
// L'activité de la veille n'est pas celle du jour, et le score de forme
|
||||||
// du jour n'est calculé qu'après la nuit : afficher celui d'hier comme
|
// du jour n'est calculé qu'après la nuit : afficher celui d'hier comme
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -303,12 +310,29 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
NSLog("[CoachWorkoutObserver] anchored fired but 0 new workouts")
|
NSLog("[CoachWorkoutObserver] anchored fired but 0 new workouts")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Notifier le plus récent (par endDate) — généralement il y
|
// ⚠️ Les rapports serveur partent pour TOUTES les séances du lot.
|
||||||
// en a 1 mais Apple peut grouper plusieurs samples.
|
// Ils n'étaient envoyés que pour `latest`, alors que l'ancre venait
|
||||||
|
// d'être avancée quelques lignes plus haut : les autres séances du
|
||||||
|
// lot n'étaient donc jamais rapportées, et ne reviendraient jamais.
|
||||||
|
//
|
||||||
|
// Le cas se produit dès que l'iPhone a manqué un réveil : renfo le
|
||||||
|
// matin, vélo le soir, téléphone resté à la maison — au premier
|
||||||
|
// fire, la requête ancrée rend les deux, le vélo est notifié, et la
|
||||||
|
// routine du matin n'est jamais cochée. Le log disait « 2 new
|
||||||
|
// workout(s), notifying latest » : exact, et parfaitement muet sur
|
||||||
|
// ce qu'il venait de jeter.
|
||||||
|
for seance in workouts {
|
||||||
|
self.reportToRoutineIfStrength(seance)
|
||||||
|
self.reportPlanOrigin(seance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une seule notification en revanche : un « 🎉 » par séance d'un
|
||||||
|
// même lot serait du bruit. On garde la plus récente.
|
||||||
let latest = workouts.max(by: { $0.endDate < $1.endDate })!
|
let latest = workouts.max(by: { $0.endDate < $1.endDate })!
|
||||||
let uuid = latest.uuid.uuidString
|
let uuid = latest.uuid.uuidString
|
||||||
UserDefaults.standard.set(uuid, forKey: self.lastNotifiedKey)
|
UserDefaults.standard.set(uuid, forKey: self.lastNotifiedKey)
|
||||||
NSLog("[CoachWorkoutObserver] %d new workout(s), notifying latest %@", workouts.count, uuid)
|
NSLog("[CoachWorkoutObserver] %d nouvelle(s) séance(s) rapportée(s), notification pour %@",
|
||||||
|
workouts.count, uuid)
|
||||||
self.scheduleNotification(for: latest)
|
self.scheduleNotification(for: latest)
|
||||||
}
|
}
|
||||||
healthStore.execute(query)
|
healthStore.execute(query)
|
||||||
@@ -335,6 +359,37 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Envoie une requête et **lit le statut HTTP**.
|
||||||
|
///
|
||||||
|
/// ⚠️ Les deux POST de ce fichier jetaient la réponse (`{ data, _, error in }`)
|
||||||
|
/// et ne regardaient que `error`. Or un 401 sur token rotaté, un 404 ou un
|
||||||
|
/// 500 ne produisent PAS d'`error` : `reportPlanOrigin` journalisait donc
|
||||||
|
/// « plan-origin envoyé » sur une requête refusée, et `auto-complete`
|
||||||
|
/// sortait en silence au `guard` de parsing. Le bon pattern existait déjà
|
||||||
|
/// deux fichiers plus loin — `CoachRoutineBridge.postDay` vérifie
|
||||||
|
/// `(200..<300)` et journalise le code.
|
||||||
|
///
|
||||||
|
/// `traiter` ne reçoit le corps que sur un statut de succès.
|
||||||
|
private func envoyer(_ req: URLRequest, libelle: String,
|
||||||
|
traiter: ((Data) -> Void)? = nil) {
|
||||||
|
URLSession.shared.dataTask(with: req) { data, reponse, error in
|
||||||
|
if let error = error {
|
||||||
|
NSLog("[CoachWorkoutObserver] %@ : %@", libelle, error.localizedDescription)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let http = reponse as? HTTPURLResponse else {
|
||||||
|
NSLog("[CoachWorkoutObserver] %@ : réponse sans statut HTTP", libelle)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
NSLog("[CoachWorkoutObserver] %@ : HTTP %d — non pris en compte",
|
||||||
|
libelle, http.statusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
traiter?(data ?? Data())
|
||||||
|
}.resume()
|
||||||
|
}
|
||||||
|
|
||||||
/// Remonte au serveur la fin d'une séance de renfo, qui décide seul si elle
|
/// Remonte au serveur la fin d'une séance de renfo, qui décide seul si elle
|
||||||
/// correspond à une routine poussée dans l'app Exercice.
|
/// correspond à une routine poussée dans l'app Exercice.
|
||||||
///
|
///
|
||||||
@@ -352,19 +407,31 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
req.httpMethod = "POST"
|
req.httpMethod = "POST"
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie")
|
req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie")
|
||||||
|
// ⚠️ L'activité était écrite en dur à « functionalStrengthTraining »
|
||||||
|
// alors que le garde ci-dessus accepte AUSSI traditionalStrengthTraining :
|
||||||
|
// une séance de musculation classique arrivait au serveur étiquetée
|
||||||
|
// fonctionnelle. Si la règle de correspondance discrimine sur ce champ
|
||||||
|
// — et le commentaire ci-dessus dit que les règles vivent là-bas —,
|
||||||
|
// l'appariement était faussé.
|
||||||
|
//
|
||||||
|
// L'UUID et l'heure de début manquaient aussi : sans eux le serveur ne
|
||||||
|
// peut pas dédupliquer, et il doit évaluer sa fenêtre de 12 h depuis
|
||||||
|
// l'heure de RÉCEPTION. Une séance remontée avec six heures de retard
|
||||||
|
// (iPhone resté à la maison) était donc datée de son arrivée.
|
||||||
req.httpBody = try? JSONSerialization.data(withJSONObject: [
|
req.httpBody = try? JSONSerialization.data(withJSONObject: [
|
||||||
"activity": "functionalStrengthTraining",
|
"activity": activity == .traditionalStrengthTraining
|
||||||
|
? "traditionalStrengthTraining" : "functionalStrengthTraining",
|
||||||
"duration_min": workout.duration / 60,
|
"duration_min": workout.duration / 60,
|
||||||
|
"uuid": workout.uuid.uuidString,
|
||||||
|
"start": ISO8601DateFormatter().string(from: workout.startDate),
|
||||||
])
|
])
|
||||||
req.timeoutInterval = 15
|
req.timeoutInterval = 15
|
||||||
|
|
||||||
URLSession.shared.dataTask(with: req) { data, _, error in
|
envoyer(req, libelle: "auto-complete routine") { data in
|
||||||
if let error = error {
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
NSLog("[CoachWorkoutObserver] auto-complete routine: %@", error.localizedDescription)
|
NSLog("[CoachWorkoutObserver] auto-complete : réponse illisible")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let data = data,
|
|
||||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
|
||||||
if (json["matched"] as? Bool) == true {
|
if (json["matched"] as? Bool) == true {
|
||||||
let n = (json["done"] as? [String])?.count ?? 0
|
let n = (json["done"] as? [String])?.count ?? 0
|
||||||
NSLog("[CoachWorkoutObserver] routine '%@' cochée automatiquement (%d exercices)",
|
NSLog("[CoachWorkoutObserver] routine '%@' cochée automatiquement (%d exercices)",
|
||||||
@@ -372,11 +439,68 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
} else {
|
} else {
|
||||||
NSLog("[CoachWorkoutObserver] pas une routine (%@)", (json["reason"] as? String) ?? "?")
|
NSLog("[CoachWorkoutObserver] pas une routine (%@)", (json["reason"] as? String) ?? "?")
|
||||||
}
|
}
|
||||||
}.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
|
||||||
|
// Le log de succès ne part plus que sur un 2xx : il s'écrivait
|
||||||
|
// auparavant sur un 401 ou un 500, puisque seul `error` était lu.
|
||||||
|
self.envoyer(req, libelle: "plan-origin") { _ in
|
||||||
|
NSLog("[CoachWorkoutObserver] plan-origin accepté (from_plan=%@)",
|
||||||
|
fromPlan ? "true" : "false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#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 {
|
||||||
|
|||||||
@@ -32,6 +32,23 @@ struct HeartRateZones: Codable, Equatable, Sendable {
|
|||||||
/// vers Z2-Z3. C'est le repère clinique, celui qui compte ici.
|
/// vers Z2-Z3. C'est le repère clinique, celui qui compte ici.
|
||||||
var clinicalTargetBpm: Int?
|
var clinicalTargetBpm: Int?
|
||||||
|
|
||||||
|
/// Date de la version FIGÉE dont viennent ces bornes (`YYYY-MM-DD`), telle
|
||||||
|
/// que servie par `/api/cardiac-zones` (`zones_since`).
|
||||||
|
///
|
||||||
|
/// ⚠️ Sans elle, la montre affichait des bornes sans âge. Le backend tient
|
||||||
|
/// une version datée (`data/hr_zones_history.json`, règle 9 du CLAUDE.md
|
||||||
|
/// web) parce qu'une référence qui bouge avec son propre bruit ne permet
|
||||||
|
/// pas de juger une séance ; **cette date se perdait au transport**. Après
|
||||||
|
/// une révision des bornes, rien au poignet ne distinguait les nouvelles
|
||||||
|
/// des anciennes — et si le push échouait en silence (cf. § 1.2 du
|
||||||
|
/// diagnostic du 24/09/2026), les anciennes restaient indéfiniment.
|
||||||
|
var zonesSince: String? = nil
|
||||||
|
|
||||||
|
/// `"frozen"` (version confirmée) ou `"live"` (calcul glissant, avant la
|
||||||
|
/// première version figée). Distingue « bornes validées » de « bornes
|
||||||
|
/// provisoires » — la montre ne pouvait pas faire la différence.
|
||||||
|
var zonesFrom: String? = nil
|
||||||
|
|
||||||
/// L'effort est-il dans la zone d'entraînement utile ?
|
/// L'effort est-il dans la zone d'entraînement utile ?
|
||||||
func isUseful(bpm: Int) -> Bool {
|
func isUseful(bpm: Int) -> Bool {
|
||||||
guard let target = clinicalTargetBpm else { return false }
|
guard let target = clinicalTargetBpm else { return false }
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -17,41 +17,14 @@ import SwiftUI
|
|||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
// MARK: - Intents
|
// MARK: - Intents
|
||||||
|
//
|
||||||
/// Ajoute une boisson sans quitter l'écran d'accueil.
|
// ⚠️ `LogDrinkIntent` et `CoachQuickError` ont DÉMÉNAGÉ dans
|
||||||
@available(iOS 17.0, watchOS 10.0, *)
|
// `ios/App/App/CoachIntents.swift` le 25/09/2026, pour être partagés entre
|
||||||
struct LogDrinkIntent: AppIntent {
|
// trois cibles : ce widget, l'app (d'où Siri les découvre) et le widget Watch.
|
||||||
static var title: LocalizedStringResource = "Noter une boisson"
|
// Les laisser ici les rendait invisibles aux deux autres — Siri n'avait aucun
|
||||||
/// `false` : tout l'intérêt est de NE PAS ouvrir l'app.
|
// intent à écouter, et la montre ne savait pas noter un verre.
|
||||||
static var openAppWhenRun: Bool = false
|
// ⛔ Ne pas les redéclarer ici : deux définitions du même symbole dans une
|
||||||
|
// cible qui compile les deux fichiers, et le build casse.
|
||||||
@Parameter(title: "Type") var ref: String
|
|
||||||
@Parameter(title: "Volume (ml)") var amountMl: Double
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
init(ref: String, amountMl: Double) {
|
|
||||||
self.ref = ref
|
|
||||||
self.amountMl = amountMl
|
|
||||||
}
|
|
||||||
|
|
||||||
func perform() async throws -> some IntentResult {
|
|
||||||
let ok = CoachQuickLog.append(
|
|
||||||
CoachQuickEntry(kind: .drink, ref: ref, amount: amountMl)
|
|
||||||
)
|
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
|
||||||
// Un échec d'App Group doit remonter : afficher « ajouté » sur une
|
|
||||||
// saisie perdue est pire que ne rien afficher.
|
|
||||||
guard ok else { throw CoachQuickError.unavailable }
|
|
||||||
return .result()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CoachQuickError: Error, CustomLocalizedStringResourceConvertible {
|
|
||||||
case unavailable
|
|
||||||
var localizedStringResource: LocalizedStringResource {
|
|
||||||
"Stockage partagé indisponible — ouvrez l'app une fois."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Timeline
|
// MARK: - Timeline
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -15,9 +15,32 @@ import Foundation
|
|||||||
*
|
*
|
||||||
* - il se teste intégralement sur Linux (cf. `tests-linux/`), là où
|
* - il se teste intégralement sur Linux (cf. `tests-linux/`), là où
|
||||||
* `WorkoutManager` ne le peut pas ;
|
* `WorkoutManager` ne le peut pas ;
|
||||||
* - le temps de référence est `HKLiveWorkoutBuilder.elapsedTime`, qui
|
* - le temps de référence est poussé par l'appelant ;
|
||||||
* **exclut déjà les pauses** : le moteur n'a donc rien à savoir de la
|
*
|
||||||
* pause, ni de l'auto-pause de la montre ;
|
* ⚠️ **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
|
* - 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
|
* déroulé, ce qui rend la reprise après crash triviale
|
||||||
* (`handleActiveWorkoutRecovery`).
|
* (`handleActiveWorkoutRecovery`).
|
||||||
|
|||||||
352
ios/App/CoachWatch/LocationTracker.swift
Normal file
352
ios/App/CoachWatch/LocationTracker.swift
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
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 {
|
||||||
|
// ⚠️ Ici se jouait une perte silencieuse. `pendingRoute` ne contient
|
||||||
|
// que des points DÉJÀ acceptés par le filtre et DÉJÀ comptés dans
|
||||||
|
// `distanceMeters` — ils attendent seulement leur écriture, qui ne
|
||||||
|
// part qu'au seuil de `routeFlushThreshold` (25). À 1 Hz, c'est une
|
||||||
|
// douzaine de points en moyenne, jusqu'à 24. Les jeter creusait un
|
||||||
|
// écart visible entre la distance affichée et la trace enregistrée :
|
||||||
|
// six arrêts sur une sortie, et jusqu'à 144 points manquaient à la
|
||||||
|
// HKWorkoutRoute, avec un D+ recalculé qui divergeait d'autant.
|
||||||
|
//
|
||||||
|
// On les écrit donc au lieu de les perdre. Le raisonnement du
|
||||||
|
// commentaire d'origine visait le point qui SUIT la reprise, à des
|
||||||
|
// centaines de mètres de là — mais celui-là n'est pas encore dans
|
||||||
|
// le tampon, et c'est `implausibleSpeed` qui l'écarte. Nettoyer la
|
||||||
|
// pause est l'affaire du filtre, pas du tampon d'écriture.
|
||||||
|
Task { await flushPendingRoute() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,67 @@ 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
|
||||||
try? await builder.endCollection(at: date)
|
// Ordre imposé par Apple : couper le GPS, clore la collecte,
|
||||||
_ = try? await builder.finishWorkout()
|
// sauvegarder la séance, et SEULEMENT ensuite clore la trace en
|
||||||
|
// l'associant au workout obtenu.
|
||||||
|
LocationTracker.shared.stopTracking()
|
||||||
|
/* ⚠️ Les deux `try?` d'avant rendaient indiscernables deux
|
||||||
|
situations OPPOSÉES, toutes deux menant à `workout == nil` :
|
||||||
|
· montre verrouillée à l'arrêt → la séance EST écrite ;
|
||||||
|
· `endCollection` a levé → il n'y a rien à retrouver.
|
||||||
|
Elles tombaient dans le même `else`, et seul le sort de la TRACE
|
||||||
|
était journalisé — pas celui de la séance. Après deux heures de
|
||||||
|
sortie, l'écran de fin restait le même.
|
||||||
|
§ 2.3 du diagnostic du 24/09/2026. La logique de récupération
|
||||||
|
ci-dessous n'a pas changé : seul l'échec devient visible. */
|
||||||
|
var collecteClose = true
|
||||||
|
do {
|
||||||
|
try await builder.endCollection(at: date)
|
||||||
|
} catch {
|
||||||
|
collecteClose = false
|
||||||
|
workoutLog.error("endCollection a echoue : \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
// ⚠️ `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.
|
||||||
|
var workout: HKWorkout?
|
||||||
|
do {
|
||||||
|
workout = try await builder.finishWorkout()
|
||||||
|
} catch {
|
||||||
|
workoutLog.error("finishWorkout a echoue : \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
if collecteClose {
|
||||||
|
// La collecte s'est close proprement mais rien n'est
|
||||||
|
// retrouvable : anormal, et jamais journalisé jusqu'ici.
|
||||||
|
workoutLog.error("seance introuvable apres un finishWorkout() nil : rien a associer a la trace")
|
||||||
|
} else {
|
||||||
|
workoutLog.error("SEANCE PERDUE : endCollection a leve, rien n'a ete ecrit dans HealthKit")
|
||||||
|
/* Le dire à l'écran. C'est le cœur du constat : l'utilisateur
|
||||||
|
voyait l'écran de fin habituel après une séance perdue.
|
||||||
|
Uniquement dans ce cas — une montre verrouillée n'est pas
|
||||||
|
une perte, et l'annoncer serait une fausse alerte. */
|
||||||
|
self.statusMessage = "Séance non enregistrée — rien n'a pu être écrit dans Santé"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.isRunning = false
|
self.isRunning = false
|
||||||
@@ -284,14 +440,88 @@ 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
|
||||||
let ns = error as NSError
|
let ns = error as NSError
|
||||||
workoutLog.error("session didFailWithError domain=\(ns.domain) code=\(ns.code): \(message)")
|
workoutLog.error("session didFailWithError domain=\(ns.domain) code=\(ns.code): \(message)")
|
||||||
|
let builder = workoutSession.associatedWorkoutBuilder()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.statusMessage = "Erreur séance : \(message)"
|
/* ⚠️ Ce chemin ne clôturait RIEN. Comparé à `.ended`, il manquait
|
||||||
|
`stopTracking()`, `endCollection`, `finishWorkout` et
|
||||||
|
`finishRoute`/`discardRoute` — et `self.builder = nil` détruisait
|
||||||
|
la seule référence au builder : la perte devenait définitive dans
|
||||||
|
l'instruction même qui l'ignorait. Le GPS, lui, continuait de
|
||||||
|
tourner après la fin apparente de la séance, sans que rien ne
|
||||||
|
l'indique. § 2.4 du diagnostic du 24/09/2026.
|
||||||
|
|
||||||
|
Le GPS d'abord : c'est le seul geste qui réussit à coup sûr, et
|
||||||
|
le seul dont l'omission se paie en batterie pendant des heures.
|
||||||
|
La sauvegarde ensuite, en best-effort — une session qui a échoué
|
||||||
|
refusera souvent de clore, mais ne pas essayer garantit la perte. */
|
||||||
|
LocationTracker.shared.stopTracking()
|
||||||
|
var sauvee: HKWorkout?
|
||||||
|
do {
|
||||||
|
try await builder.endCollection(at: Date())
|
||||||
|
sauvee = try await builder.finishWorkout()
|
||||||
|
} catch {
|
||||||
|
workoutLog.error("cloture apres echec impossible : \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
if let sauvee {
|
||||||
|
workoutLog.info("seance sauvee malgre l'echec de session")
|
||||||
|
await LocationTracker.shared.finishRoute(with: sauvee)
|
||||||
|
self.statusMessage = "Séance interrompue, mais enregistrée"
|
||||||
|
} else if let recovered = await self.recentlySavedWorkout() {
|
||||||
|
workoutLog.info("seance retrouvee dans HealthKit apres l'echec de session")
|
||||||
|
await LocationTracker.shared.finishRoute(with: recovered)
|
||||||
|
self.statusMessage = "Séance interrompue, mais enregistrée"
|
||||||
|
} else {
|
||||||
|
LocationTracker.shared.discardRoute()
|
||||||
|
workoutLog.error("SEANCE PERDUE apres didFailWithError : rien n'a pu etre sauve")
|
||||||
|
self.statusMessage = "Séance perdue : \(message)"
|
||||||
|
}
|
||||||
self.isRunning = false
|
self.isRunning = false
|
||||||
|
self.isPaused = false
|
||||||
self.session = nil
|
self.session = nil
|
||||||
self.builder = nil
|
self.builder = nil
|
||||||
ConnectivityManager.shared.sendEnded(reason: "session-failed")
|
ConnectivityManager.shared.sendEnded(reason: "session-failed")
|
||||||
|
|||||||
@@ -300,5 +300,11 @@ struct CoachWatchWidgetsBundle: WidgetBundle {
|
|||||||
CoachWatchFormeWidget()
|
CoachWatchFormeWidget()
|
||||||
CoachWatchSessionWidget()
|
CoachWatchSessionWidget()
|
||||||
CoachWatchRoutineWidget()
|
CoachWatchRoutineWidget()
|
||||||
|
// Smart Stack : le seul widget de la montre qui fasse AGIR — noter un
|
||||||
|
// verre sans sortir le téléphone. Ajouté le 25/09/2026.
|
||||||
|
if #available(watchOS 10.0, *) {
|
||||||
|
CoachWatchHydrationWidget()
|
||||||
|
CoachWatchNutritionWidget()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
327
ios/App/CoachWatchWidgets/CoachWatchHydration.swift
Normal file
327
ios/App/CoachWatchWidgets/CoachWatchHydration.swift
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
// CoachWatchHydration.swift
|
||||||
|
// Widget « Hydratation » pour la Smart Stack de l'Apple Watch : voir où l'on
|
||||||
|
// en est, et **noter un verre sans sortir le téléphone**.
|
||||||
|
//
|
||||||
|
// ⚠️ Cible : CoachWatchWidgets. Dépend de `CoachIntents.swift`,
|
||||||
|
// `CoachQuickLog.swift` et `CoachWidgetSnapshot.swift`, qui doivent appartenir
|
||||||
|
// à CETTE cible aussi (cf. docs/widgets-runbook-mac.md).
|
||||||
|
//
|
||||||
|
// Pourquoi ce widget, et pas un de plus pour les repas
|
||||||
|
// -----------------------------------------------------
|
||||||
|
// Tranché avec Sylvain le 25/09/2026. Boire se note une vingtaine de fois par
|
||||||
|
// jour : c'est le seul geste où la montre bat vraiment le téléphone — le
|
||||||
|
// poignet est déjà levé. Les repas, non : pas de caméra, pas de scanner de
|
||||||
|
// code-barres, pas de saisie de texte confortable. Un bouton « repas » au
|
||||||
|
// poignet ne ferait que renvoyer vers l'iPhone, pour du code à maintenir.
|
||||||
|
//
|
||||||
|
// ⚠️ **Rien ne part sur le réseau ici.** Comme sur l'iPhone, l'intent écrit
|
||||||
|
// dans la file de `CoachQuickLog` (App Group de la montre) et l'application la
|
||||||
|
// transmet à sa prochaine ouverture. Le widget affiche donc son propre total —
|
||||||
|
// snapshot serveur + file locale — sinon il afficherait un chiffre périmé juste
|
||||||
|
// après une saisie, et on douterait de ce qu'on vient de faire.
|
||||||
|
//
|
||||||
|
// ⚠️ **Interactivité : Smart Stack, pas cadran.** Les boutons d'App Intent
|
||||||
|
// demandent watchOS 10+. Reste à confirmer AU BUILD si une complication de
|
||||||
|
// cadran les accepte : dans le doute, la famille `.accessoryCircular` ci-dessous
|
||||||
|
// n'est qu'un AFFICHAGE (un tap ouvre l'app), et seule `.accessoryRectangular`
|
||||||
|
// porte les boutons.
|
||||||
|
import AppIntents
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
// MARK: - Timeline
|
||||||
|
|
||||||
|
struct CoachWatchHydrationEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let waterMl: Double
|
||||||
|
let goalMl: Double?
|
||||||
|
/// Saisies encore en attente d'envoi, tous jours confondus.
|
||||||
|
let pending: Int
|
||||||
|
let quickDrinks: [CoachWidgetSnapshot.QuickDrink]
|
||||||
|
|
||||||
|
/// 0…1, borné. Sans cible connue, pas de jauge inventée.
|
||||||
|
var fraction: Double? {
|
||||||
|
guard let goal = goalMl, goal > 0 else { return nil }
|
||||||
|
return min(1, max(0, waterMl / goal))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CoachWatchHydrationProvider: TimelineProvider {
|
||||||
|
func placeholder(in context: Context) -> CoachWatchHydrationEntry {
|
||||||
|
CoachWatchHydrationEntry(
|
||||||
|
date: Date(), waterMl: 1250, goalMl: 2750, pending: 0,
|
||||||
|
quickDrinks: CoachWidgetSnapshot.fallbackQuickDrinks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSnapshot(in context: Context,
|
||||||
|
completion: @escaping (CoachWatchHydrationEntry) -> Void) {
|
||||||
|
completion(current())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réveil à minuit, comme le widget iPhone : les totaux du jour doivent
|
||||||
|
/// repartir de zéro. ⚠️ `asOf(_:)` est ce qui le rend effectif — sans lui
|
||||||
|
/// le rechargement a lieu mais réaffiche les chiffres de la veille, défaut
|
||||||
|
/// déjà payé côté iPhone.
|
||||||
|
func getTimeline(in context: Context,
|
||||||
|
completion: @escaping (Timeline<CoachWatchHydrationEntry>) -> Void) {
|
||||||
|
let now = Date()
|
||||||
|
let minuit = CoachWidgetSnapshot.nextMidnight(after: now)
|
||||||
|
completion(Timeline(entries: [current(at: now), current(at: minuit)],
|
||||||
|
policy: .after(minuit.addingTimeInterval(60))))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func current(at day: Date = Date()) -> CoachWatchHydrationEntry {
|
||||||
|
let snapshot = CoachWidgetStore.load()?.asOf(day)
|
||||||
|
return CoachWatchHydrationEntry(
|
||||||
|
date: day,
|
||||||
|
waterMl: CoachQuickLog.todayTotal(
|
||||||
|
kind: "water", syncedMl: snapshot?.waterMlToday ?? 0, day: day),
|
||||||
|
goalMl: snapshot?.waterGoalMl,
|
||||||
|
pending: CoachQuickLog.pending().count,
|
||||||
|
quickDrinks: snapshot?.resolvedQuickDrinks
|
||||||
|
?? CoachWidgetSnapshot.fallbackQuickDrinks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Vues
|
||||||
|
|
||||||
|
/// Les deux premières boissons à base d'eau, sinon le repli. Deux et pas trois :
|
||||||
|
/// sur 160 pt de large, un troisième bouton descend les cibles sous le seuil
|
||||||
|
/// tactile, et c'est un geste qu'on fait en marchant.
|
||||||
|
private func deuxBoutonsEau(_ drinks: [CoachWidgetSnapshot.QuickDrink])
|
||||||
|
-> [CoachWidgetSnapshot.QuickDrink] {
|
||||||
|
let eau = drinks.filter { $0.ref == "water" }
|
||||||
|
let retenus = eau.isEmpty ? CoachWidgetSnapshot.fallbackQuickDrinks
|
||||||
|
.filter({ $0.ref == "water" }) : eau
|
||||||
|
return Array(retenus.prefix(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enLitres(_ ml: Double) -> String {
|
||||||
|
let l = ml / 1000
|
||||||
|
return String(format: l < 1 ? "%.2f L" : "%.1f L", l)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(watchOS 10.0, *)
|
||||||
|
struct CoachWatchHydrationView: View {
|
||||||
|
@Environment(\.widgetFamily) private var family
|
||||||
|
let entry: CoachWatchHydrationEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch family {
|
||||||
|
case .accessoryRectangular:
|
||||||
|
rectangulaire
|
||||||
|
case .accessoryInline:
|
||||||
|
Text("Eau \(enLitres(entry.waterMl))")
|
||||||
|
default:
|
||||||
|
circulaire
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// La seule famille qui porte les boutons : il y faut la place.
|
||||||
|
private var rectangulaire: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "drop.fill")
|
||||||
|
Text(enLitres(entry.waterMl))
|
||||||
|
.font(.headline)
|
||||||
|
if let goal = entry.goalMl {
|
||||||
|
Text("/ \(enLitres(goal))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
// Ce qui attend d'être envoyé se dit, plutôt que de laisser
|
||||||
|
// croire que tout est synchronisé.
|
||||||
|
if entry.pending > 0 {
|
||||||
|
Text("·\(entry.pending)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let f = entry.fraction {
|
||||||
|
ProgressView(value: f).progressViewStyle(.linear)
|
||||||
|
}
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(deuxBoutonsEau(entry.quickDrinks), id: \.volumeMl) { d in
|
||||||
|
Button(intent: LogDrinkIntent(ref: d.ref, amountMl: d.volumeMl)) {
|
||||||
|
Text("+\(Int(d.volumeMl / 10)) cl")
|
||||||
|
.font(.caption2)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.containerBackground(.clear, for: .widget)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cadran : affichage seul. Un tap ouvre l'app — voir l'avertissement en
|
||||||
|
/// tête de fichier sur l'interactivité hors Smart Stack.
|
||||||
|
private var circulaire: some View {
|
||||||
|
Gauge(value: entry.fraction ?? 0) {
|
||||||
|
Image(systemName: "drop.fill")
|
||||||
|
} currentValueLabel: {
|
||||||
|
Text(enLitres(entry.waterMl)).font(.system(size: 12))
|
||||||
|
}
|
||||||
|
.gaugeStyle(.accessoryCircular)
|
||||||
|
.containerBackground(.clear, for: .widget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget
|
||||||
|
|
||||||
|
@available(watchOS 10.0, *)
|
||||||
|
struct CoachWatchHydrationWidget: Widget {
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: "CoachWatchHydration",
|
||||||
|
provider: CoachWatchHydrationProvider()) { entry in
|
||||||
|
CoachWatchHydrationView(entry: entry)
|
||||||
|
}
|
||||||
|
.configurationDisplayName("Hydratation")
|
||||||
|
.description("Où j'en suis, et noter un verre sans ouvrir l'app.")
|
||||||
|
.supportedFamilies([.accessoryRectangular, .accessoryCircular, .accessoryInline])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Nutrition
|
||||||
|
//
|
||||||
|
// ⚠️ **Affichage seulement, et c'est délibéré.** La saisie des repas au poignet
|
||||||
|
// a été écartée le 25/09 : pas de caméra, pas de scanner de code-barres, pas de
|
||||||
|
// saisie de texte confortable — un bouton n'aurait fait que renvoyer vers
|
||||||
|
// l'iPhone. Mais VOIR où l'on en est n'a aucune de ces limites, et c'est ce que
|
||||||
|
// Sylvain a demandé ensuite.
|
||||||
|
//
|
||||||
|
// ⛔ Rien n'est calculé ici. Les totaux viennent du journal et les cibles de
|
||||||
|
// `nutrition_goals.py`, tous deux poussés par l'app. Le seul calcul est un
|
||||||
|
// reste (cible − consommé), la même soustraction que la page /meals affiche.
|
||||||
|
|
||||||
|
struct CoachWatchNutritionEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let kcal: Double?
|
||||||
|
let kcalGoal: Double?
|
||||||
|
let protein: Double?
|
||||||
|
let proteinGoal: Double?
|
||||||
|
let fat: Double?
|
||||||
|
let fatGoal: Double?
|
||||||
|
let carbs: Double?
|
||||||
|
let carbsGoal: Double?
|
||||||
|
|
||||||
|
/// Reste d'énergie. `nil` sans cible — une jauge sans objectif n'a pas de
|
||||||
|
/// sens, et un « reste » inventé encore moins.
|
||||||
|
var kcalRestantes: Double? {
|
||||||
|
guard let g = kcalGoal, let c = kcal else { return nil }
|
||||||
|
return max(0, g - c)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fraction: Double? {
|
||||||
|
guard let g = kcalGoal, g > 0, let c = kcal else { return nil }
|
||||||
|
return min(1, max(0, c / g))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CoachWatchNutritionProvider: TimelineProvider {
|
||||||
|
func placeholder(in context: Context) -> CoachWatchNutritionEntry {
|
||||||
|
CoachWatchNutritionEntry(date: Date(), kcal: 1186, kcalGoal: 1908,
|
||||||
|
protein: 74, proteinGoal: 118,
|
||||||
|
fat: 41, fatGoal: 59,
|
||||||
|
carbs: 158, carbsGoal: 225)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSnapshot(in context: Context,
|
||||||
|
completion: @escaping (CoachWatchNutritionEntry) -> Void) {
|
||||||
|
completion(current())
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimeline(in context: Context,
|
||||||
|
completion: @escaping (Timeline<CoachWatchNutritionEntry>) -> Void) {
|
||||||
|
let now = Date()
|
||||||
|
let minuit = CoachWidgetSnapshot.nextMidnight(after: now)
|
||||||
|
completion(Timeline(entries: [current(at: now), current(at: minuit)],
|
||||||
|
policy: .after(minuit.addingTimeInterval(60))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `asOf(_:)` remet les totaux du jour à zéro quand le snapshot date
|
||||||
|
/// d'hier — macros comprises depuis le 25/09. Sans lui, le réveil de
|
||||||
|
/// minuit réafficherait les grammes de la veille.
|
||||||
|
private func current(at day: Date = Date()) -> CoachWatchNutritionEntry {
|
||||||
|
let s = CoachWidgetStore.load()?.asOf(day)
|
||||||
|
return CoachWatchNutritionEntry(
|
||||||
|
date: day,
|
||||||
|
kcal: s?.kcalToday, kcalGoal: s?.kcalGoal,
|
||||||
|
protein: s?.proteinToday, proteinGoal: s?.proteinGoal,
|
||||||
|
fat: s?.fatToday, fatGoal: s?.fatGoal,
|
||||||
|
carbs: s?.carbsToday, carbsGoal: s?.carbsGoal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(watchOS 10.0, *)
|
||||||
|
struct CoachWatchNutritionView: View {
|
||||||
|
@Environment(\.widgetFamily) private var family
|
||||||
|
let entry: CoachWatchNutritionEntry
|
||||||
|
|
||||||
|
/// « 74/118 g » ; « 74 g » sans cible. Jamais de cible inventée.
|
||||||
|
private func macro(_ nom: String, _ valeur: Double?, _ cible: Double?) -> String {
|
||||||
|
guard let v = valeur else { return "\(nom) —" }
|
||||||
|
if let c = cible { return "\(nom) \(Int(v))/\(Int(c))" }
|
||||||
|
return "\(nom) \(Int(v))"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch family {
|
||||||
|
case .accessoryRectangular:
|
||||||
|
rectangulaire
|
||||||
|
case .accessoryInline:
|
||||||
|
Text(entry.kcalRestantes.map { "\(Int($0)) kcal restantes" }
|
||||||
|
?? "\(Int(entry.kcal ?? 0)) kcal")
|
||||||
|
default:
|
||||||
|
circulaire
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rectangulaire: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
if let reste = entry.kcalRestantes {
|
||||||
|
Text("\(Int(reste))").font(.headline)
|
||||||
|
Text("kcal restantes").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("\(Int(entry.kcal ?? 0)) kcal").font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let f = entry.fraction {
|
||||||
|
ProgressView(value: f).progressViewStyle(.linear)
|
||||||
|
}
|
||||||
|
// P / L / G sur une ligne : trois valeurs tiennent, pas davantage.
|
||||||
|
Text([macro("P", entry.protein, entry.proteinGoal),
|
||||||
|
macro("L", entry.fat, entry.fatGoal),
|
||||||
|
macro("G", entry.carbs, entry.carbsGoal)].joined(separator: " "))
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.containerBackground(.clear, for: .widget)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var circulaire: some View {
|
||||||
|
Gauge(value: entry.fraction ?? 0) {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
} currentValueLabel: {
|
||||||
|
Text("\(Int(entry.kcalRestantes ?? entry.kcal ?? 0))")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
}
|
||||||
|
.gaugeStyle(.accessoryCircular)
|
||||||
|
.containerBackground(.clear, for: .widget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(watchOS 10.0, *)
|
||||||
|
struct CoachWatchNutritionWidget: Widget {
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: "CoachWatchNutrition",
|
||||||
|
provider: CoachWatchNutritionProvider()) { entry in
|
||||||
|
CoachWatchNutritionView(entry: entry)
|
||||||
|
}
|
||||||
|
.configurationDisplayName("Nutrition")
|
||||||
|
.description("Calories restantes et macros du jour.")
|
||||||
|
.supportedFamilies([.accessoryRectangular, .accessoryCircular, .accessoryInline])
|
||||||
|
}
|
||||||
|
}
|
||||||
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,6 @@ struct HeartRateZones: Codable, Equatable, Sendable {
|
|||||||
var z5: [Int]?
|
var z5: [Int]?
|
||||||
var fcmaxUsed: Int?
|
var fcmaxUsed: Int?
|
||||||
var clinicalTargetBpm: Int?
|
var clinicalTargetBpm: Int?
|
||||||
|
var zonesSince: String?
|
||||||
|
var zonesFrom: String?
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
@@ -41,9 +41,16 @@ final class DayRolloverTests: XCTestCase {
|
|||||||
s.waterMlToday = 1660
|
s.waterMlToday = 1660
|
||||||
s.coffeeCountToday = 3
|
s.coffeeCountToday = 3
|
||||||
s.kcalToday = 1186
|
s.kcalToday = 1186
|
||||||
|
s.proteinToday = 74
|
||||||
|
s.fatToday = 41
|
||||||
|
s.carbsToday = 158
|
||||||
s.waterGoalMl = 2750
|
s.waterGoalMl = 2750
|
||||||
s.kcalGoal = 1950
|
s.kcalGoal = 1950
|
||||||
s.zones = HeartRateZones(z2: [109, 118], fcmaxUsed: 152, clinicalTargetBpm: 112)
|
s.proteinGoal = 118
|
||||||
|
s.fatGoal = 59
|
||||||
|
s.carbsGoal = 225
|
||||||
|
s.zones = HeartRateZones(z2: [109, 118], fcmaxUsed: 152, clinicalTargetBpm: 112,
|
||||||
|
zonesSince: "2026-08-12", zonesFrom: "frozen")
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +75,28 @@ final class DayRolloverTests: XCTestCase {
|
|||||||
XCTAssertEqual(vu.kcalToday, 0)
|
XCTAssertEqual(vu.kcalToday, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testMacrosResetLikeEnergy() {
|
||||||
|
// ⚠️ Ajoutées le 25/09/2026 pour le suivi au poignet. Les oublier dans
|
||||||
|
// `asOf` afficherait les grammes d'hier sous la date du jour — le
|
||||||
|
// défaut même que cette fonction existe pour empêcher, et qui avait
|
||||||
|
// survécu un temps sur les totaux de boisson.
|
||||||
|
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
|
||||||
|
XCTAssertEqual(vu.proteinToday, 0)
|
||||||
|
XCTAssertEqual(vu.fatToday, 0)
|
||||||
|
XCTAssertEqual(vu.carbsToday, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMacroGoalsSurviveTheNight() {
|
||||||
|
// Une CIBLE n'est pas une mesure : elle ne se périme pas à minuit.
|
||||||
|
// Sans elle, la jauge disparaîtrait chaque nuit jusqu'à la prochaine
|
||||||
|
// ouverture de l'app.
|
||||||
|
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
|
||||||
|
XCTAssertEqual(vu.proteinGoal, 118)
|
||||||
|
XCTAssertEqual(vu.fatGoal, 59)
|
||||||
|
XCTAssertEqual(vu.carbsGoal, 225)
|
||||||
|
XCTAssertEqual(vu.kcalGoal, 1950)
|
||||||
|
}
|
||||||
|
|
||||||
func testYesterdaysDayScopedFieldsAreDropped() {
|
func testYesterdaysDayScopedFieldsAreDropped() {
|
||||||
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
|
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
|
||||||
XCTAssertNil(vu.routine, "la routine se recoche chaque jour")
|
XCTAssertNil(vu.routine, "la routine se recoche chaque jour")
|
||||||
@@ -94,6 +123,42 @@ final class DayRolloverTests: XCTestCase {
|
|||||||
"les zones sont une version figée, valable des semaines")
|
"les zones sont une version figée, valable des semaines")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// La date de version des zones doit survivre au transport et au passage de
|
||||||
|
/// minuit — sans elle, la montre affiche des bornes sans âge, et après une
|
||||||
|
/// révision rien ne distingue les nouvelles des anciennes.
|
||||||
|
/// Cf. `docs/DIAGNOSTIC-NATIF-2026-09-24.md` § 1.2.
|
||||||
|
func testZonesCarryTheirVersionDate() {
|
||||||
|
let vu = snapshot(at: "2026-08-10 12:00").asOf(date("2026-08-18 09:00"), calendar: zurich)
|
||||||
|
XCTAssertEqual(vu.zones?.zonesSince, "2026-08-12",
|
||||||
|
"la date de la version figée se perdait au transport")
|
||||||
|
XCTAssertEqual(vu.zones?.zonesFrom, "frozen",
|
||||||
|
"« figée » et « provisoire » ne doivent pas se confondre au poignet")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Le snapshot voyage en JSON jusqu'à la montre : ce qui n'est pas encodé
|
||||||
|
/// n'arrive pas. Le test précédent vérifie la recopie en mémoire, celui-ci
|
||||||
|
/// le passage par le fil.
|
||||||
|
func testVersionDateSurvivesJsonRoundTrip() throws {
|
||||||
|
let original = snapshot(at: "2026-08-10 12:00")
|
||||||
|
let data = try JSONEncoder().encode(original)
|
||||||
|
let relu = try JSONDecoder().decode(CoachWidgetSnapshot.self, from: data)
|
||||||
|
XCTAssertEqual(relu.zones?.zonesSince, "2026-08-12")
|
||||||
|
XCTAssertEqual(relu.zones?.zonesFrom, "frozen")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ⚠️ Un snapshot écrit AVANT l'ajout de ces champs doit rester décodable :
|
||||||
|
/// la montre en garde un en cache, et un décodage qui lève la laisserait
|
||||||
|
/// sans zones du tout — pire que des zones sans date.
|
||||||
|
func testOlderSnapshotWithoutVersionDateStillDecodes() throws {
|
||||||
|
// `updatedAt` est un `Date` : l'encodeur par défaut l'écrit en secondes
|
||||||
|
// depuis la date de référence, pas en ISO — comme les autres tests de
|
||||||
|
// compatibilité ascendante de ce banc (cf. QuickDrinkTests).
|
||||||
|
let json = #"{"updatedAt": 1755000000, "zones": {"z2": [109, 118], "fcmaxUsed": 152}}"#
|
||||||
|
let relu = try JSONDecoder().decode(CoachWidgetSnapshot.self, from: Data(json.utf8))
|
||||||
|
XCTAssertEqual(relu.zones?.fcmaxUsed, 152)
|
||||||
|
XCTAssertNil(relu.zones?.zonesSince, "absente, pas inventée")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Au-delà du lendemain : ne rien inventer
|
// MARK: - Au-delà du lendemain : ne rien inventer
|
||||||
|
|
||||||
func testAfterTwoDaysTheSessionIsUnknown() {
|
func testAfterTwoDaysTheSessionIsUnknown() {
|
||||||
|
|||||||
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