Compare commits
28 Commits
19fbfd116e
...
feat/watch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d939b8c3c | ||
|
|
9903424235 | ||
|
|
0a721c249b | ||
|
|
2a4274b1ad | ||
|
|
422b6ac3e6 | ||
|
|
7d68605e83 | ||
|
|
474afce348 | ||
|
|
5fc19bf095 | ||
|
|
adcf4088f3 | ||
|
|
3cc7d935ad | ||
|
|
60dd22eaeb | ||
|
|
eef76daa45 | ||
|
|
753d7b248e | ||
|
|
0f96122cca | ||
|
|
01607ed75c | ||
|
|
94b1f6243d | ||
|
|
81582ce969 | ||
|
|
1389edb1f7 | ||
|
|
3de149591e | ||
|
|
14133efba9 | ||
|
|
6deb61e86e | ||
|
|
179528d563 | ||
|
|
0110368e17 | ||
|
|
184c08a447 | ||
|
|
a44fa0046a | ||
|
|
3e657f5917 | ||
|
|
2f2d7696fa | ||
|
|
891a8bddce |
100
COWORK.md
100
COWORK.md
@@ -129,6 +129,106 @@ armer `allowsBackgroundLocationUpdates` sans elle termine l'app. Un garde-fou
|
|||||||
la vérifie au démarrage, mais si elle disparaissait du build, le suivi
|
la vérifie au démarrage, mais si elle disparaissait du build, le suivi
|
||||||
retomberait silencieusement au premier plan seul.
|
retomberait silencieusement au premier plan seul.
|
||||||
|
|
||||||
|
## 🔜 Câbler IntervalEngine — analyse du 2026-08-20 (rien de codé)
|
||||||
|
|
||||||
|
`IntervalEngine` **compile sur device** (build validé le 20/08, l'app tourne),
|
||||||
|
mais **aucune vue ne l'appelle** : le moteur est un guide sans itinéraire.
|
||||||
|
|
||||||
|
### Le chaînon manquant
|
||||||
|
|
||||||
|
Le moteur attend un `CoachSessionPlan` (liste d'étapes : durée, zone, bornes
|
||||||
|
bpm). **Rien ne le lui fournit** — `ConnectivityManager` reçoit les zones, le
|
||||||
|
`CoachWidgetSnapshot` et la routine, mais aucun plan structuré. Vérifié par
|
||||||
|
lecture du fichier, pas supposé.
|
||||||
|
|
||||||
|
En revanche **le serveur sait déjà produire ces étapes** :
|
||||||
|
`coach_sportif/web/fitness_export.py::_blocks_from_session` déplie une séance
|
||||||
|
en blocs chronométrés, répétitions comprises (un bug de prod du 12/08 avait
|
||||||
|
fait disparaître un 7×(1'C/1'M) : les `repeat` sont désormais dépliés), et
|
||||||
|
`_zone_bpm(zone, hr_zones)` convertit une zone en bornes bpm. Ces briques
|
||||||
|
servent déjà à l'export TCX Workout.
|
||||||
|
|
||||||
|
Indice que le chemin était prévu : les `CodingKeys` de `CoachPlanStep` sont en
|
||||||
|
snake_case (`duration_sec`, `hr_zone`, `hr_bpm_min`…) — le type a été écrit
|
||||||
|
pour décoder du JSON venant du serveur.
|
||||||
|
|
||||||
|
### Les trois couches, dans l'ordre
|
||||||
|
|
||||||
|
1. ✅ **Serveur — FAIT le 2026-08-21** (`coach_sportif` `c8d1541`, déployé et
|
||||||
|
vérifié en prod). **`GET /api/plan/session[?date=YYYY-MM-DD][&type=...]`**
|
||||||
|
rend la séance dépliée au format `CoachSessionPlan` : clés snake_case,
|
||||||
|
`hr_zone` en entier, durées en secondes. Mesuré en prod le 21/08 sur la
|
||||||
|
séance du jour (CDC J13, 15×(1'/1')) : **32 étapes, 40 min**, alternance
|
||||||
|
`work`/`recovery` correcte, bornes Z1 100-109 / Z2 109-118.
|
||||||
|
- Les bornes BPM viennent des **zones datées** (`current_zones`), jamais des
|
||||||
|
`hr_min`/`hr_max` du plan (hérités d'avant l'unification du 12/08).
|
||||||
|
- Les `repeat` sont **dépliés** — le moteur ne sait pas répéter.
|
||||||
|
- `404` = pas de séance ce jour · `422` = séance non chronométrée (repos,
|
||||||
|
durée ouverte) : rien à dérouler, à distinguer côté montre.
|
||||||
|
- ⚠️ Route volontairement **hors `/api/v1/*`** (JWT que la WebView n'a pas).
|
||||||
|
Un test le verrouille : ne pas la « ranger » dans l'API native.
|
||||||
|
2. **iPhone** — pousser ce plan vers la montre par `WCSession`, à côté de ce
|
||||||
|
qui part déjà.
|
||||||
|
3. **Montre** — `ConnectivityManager` publie un `sessionPlan` ; une vue appelle
|
||||||
|
`engine.update(elapsed:distance:)` à chaque tick et joue une haptique sur
|
||||||
|
`events.transitions`.
|
||||||
|
|
||||||
|
### ⚠️ Le piège à trancher AVANT de câbler
|
||||||
|
|
||||||
|
L'en-tête d'`IntervalEngine.swift` affirmait que
|
||||||
|
`HKLiveWorkoutBuilder.elapsedTime` « exclut déjà les pauses ». **La doc Apple
|
||||||
|
dit l'inverse**, vérifié à la source le 20/08 : *« The elapsed time for the
|
||||||
|
workout based on the builder's current contents, including pauses. »*
|
||||||
|
|
||||||
|
Câbler le moteur dessus telle quelle ferait avancer le déroulé **pendant les
|
||||||
|
pauses** — un fractionné mis en pause pour traverser une route se déroulerait
|
||||||
|
à l'arrêt. La propriété qui exclut réellement les pauses est
|
||||||
|
`HKWorkoutBuilder.elapsedTime(at:)`. Les deux textes d'Apple se contredisent
|
||||||
|
frontalement ; le commentaire du fichier a été corrigé, **le choix de la source
|
||||||
|
de temps reste à faire**.
|
||||||
|
|
||||||
|
⚠️ Et jamais un `Timer` de vue SwiftUI : il ne tourne pas écran éteint, donc
|
||||||
|
pendant l'essentiel d'une séance (même famille de piège que `VmaTestView`).
|
||||||
|
|
||||||
|
## 📌 Point du 2026-08-21 — build OK, vérifs GPS EN SUSPENS
|
||||||
|
|
||||||
|
**Build Xcode passé sur iPhone (iOS 26.6)**, après un `git pull` qui partait de
|
||||||
|
`be1ffec` : le Mac n'avait donc **ni `LocationTracker`, ni `RouteFilter`, ni le
|
||||||
|
correctif du doublon pbxproj** (`19fbfd1`) — le build du 20/08 ne les contenait
|
||||||
|
pas. Contrôles avant build : 2/2/2 déclarations, `UIBackgroundModes = [location]`
|
||||||
|
et `NSLocationWhenInUseUsageDescription` présents, un seul `@main` par cible.
|
||||||
|
|
||||||
|
**Une erreur de compilation, corrigée (`3e657f5`)** :
|
||||||
|
`finishRoute(with:)` **n'accepte pas d'optionnel** — vérifié dans la doc Apple :
|
||||||
|
`func finishRoute(with workout: HKWorkout, metadata:)`, « You must have already
|
||||||
|
saved this workout to the HealthKit store ». Le commentaire du fichier
|
||||||
|
promettait une trace « sauvegardée sans association » : **c'est impossible**,
|
||||||
|
aucune API ne clôt une route orpheline. Traité en amont : quand
|
||||||
|
`finishWorkout()` rend `nil` (montre verrouillée), la séance EST dans HealthKit
|
||||||
|
et `WorkoutManager.recentlySavedWorkout()` va la rechercher — dernier workout de
|
||||||
|
`HKSource.default()` **croisant** les 5 dernières minutes (chevauchement, jamais
|
||||||
|
`startDate` : filtrer sur le début raterait toute séance longue). Sinon
|
||||||
|
`discardRoute()` jette la trace explicitement et le journalise comme une perte.
|
||||||
|
|
||||||
|
⏳ **Les 4 vérifications au poignet n'ont PAS encore été faites** — la séance
|
||||||
|
doit être lancée **depuis l'app sur la montre**, les logs du 21/08 ne montrent
|
||||||
|
que l'app iPhone (sync HealthKit complète et saine : 7 séances, 7 traces, 13
|
||||||
|
scores d'effort, App Group en écriture `ok:true`).
|
||||||
|
|
||||||
|
Relevé au passage, non traité : avertissement `UIScene lifecycle will soon be
|
||||||
|
required` (Capacitor, échéance future) ; un échantillon de pas venant de
|
||||||
|
`"iPhone de …"` et non de la Watch — à surveiller, **la cadence étant dérivée
|
||||||
|
des pas relus par séance**, deux sources sur une même séance la fausseraient.
|
||||||
|
|
||||||
|
## 🖥️ Vues d'entraînement au choix (WorkOutDoors) — cadré le 21/08, rien codé
|
||||||
|
|
||||||
|
Cadrage complet : **`docs/watch-training-screens.md`**. En deux lignes :
|
||||||
|
`LiveWorkoutView` a **cinq métriques en dur**, aucune notion de champ ni de
|
||||||
|
page. Décision prise : la configuration s'édite **côté web** (réutiliser
|
||||||
|
`TileManager`) et voyage par `WCSession` — donc **aucun rebuild pour changer ses
|
||||||
|
écrans**. Le modèle et le catalogue sont du Foundation pur, donc écrits et
|
||||||
|
testés sur Linux ; seul le rendu `TabView` exige Xcode. Châssis ≈ 1 jour.
|
||||||
|
|
||||||
## 👉 À reprendre
|
## 👉 À 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 — 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.
|
||||||
|
|||||||
479
docs/polar-ble-runbook-mac.md
Normal file
479
docs/polar-ble-runbook-mac.md
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
# Écrire la séance sur la Polar en Bluetooth — ce qui reste à faire sur le Mac
|
||||||
|
|
||||||
|
Le code est écrit et poussé. Ce qui suit ne peut se faire que sur le Mac mini,
|
||||||
|
Xcode ouvert, montre à portée. À lire en entier avant le premier envoi : la
|
||||||
|
première tentative est celle qui peut bloquer la montre.
|
||||||
|
|
||||||
|
## Pourquoi ce chemin, et pas un autre
|
||||||
|
|
||||||
|
Contrainte posée le 2026-08-31 : **`coach → la montre`, sans intermédiaire.**
|
||||||
|
Pas de TrainingPeaks, pas de Polar Flow, pas d'Intervals.icu. Ça élimine toutes
|
||||||
|
les autres voies, qui passent toutes par un tiers. Reste l'écriture directe du
|
||||||
|
fichier d'objectif sur la montre par PFTP — prouvée sur la Vantage V3 le
|
||||||
|
2026-08-17, en USB. Le Bluetooth est ce qui la rend utilisable.
|
||||||
|
|
||||||
|
Le détail de ce qui a été mesuré sur la montre est dans le dépôt `coach_sportif`,
|
||||||
|
`tools/polar/README.md`. Il fait foi.
|
||||||
|
|
||||||
|
## 1. Ajouter le SDK Polar au projet
|
||||||
|
|
||||||
|
⚠️ **Pas dans `ios/App/CapApp-SPM/Package.swift`** : ce fichier porte
|
||||||
|
« DO NOT MODIFY — managed by Capacitor CLI » et sera réécrit. Ajouter le paquet
|
||||||
|
au `.xcodeproj` :
|
||||||
|
|
||||||
|
Xcode → File → Add Package Dependencies…
|
||||||
|
https://github.com/polarofficial/polar-ble-sdk
|
||||||
|
→ produit « PolarBleSdk », cible « App »
|
||||||
|
|
||||||
|
Tant que le paquet n'est pas là, `CoachPolarBLE.isAvailable()` répond
|
||||||
|
`{available: false, reason: "PolarBleSdk absent de la cible"}` — le fichier
|
||||||
|
compile quand même, tout le code radio est derrière `#if canImport(PolarBleSdk)`.
|
||||||
|
|
||||||
|
## 2. Target Membership
|
||||||
|
|
||||||
|
Deux fichiers, cible **App** :
|
||||||
|
|
||||||
|
- `ios/App/App/CoachPolarBLE.swift` — le plugin
|
||||||
|
- `ios/App/App/PolarPftpStep.swift` — les garde-fous et l'en-tête PFTP
|
||||||
|
|
||||||
|
Le second n'importe que Foundation : il est aussi lié dans
|
||||||
|
`tests-linux/Sources/CoachModel/` et couvert par 12 tests qui tournent sur
|
||||||
|
Linux (`./tests-linux/run.sh`).
|
||||||
|
|
||||||
|
## 3. Permission Bluetooth
|
||||||
|
|
||||||
|
`NSBluetoothAlwaysUsageDescription` est dans `Info.plist`, en français.
|
||||||
|
|
||||||
|
⚠️ **La clé ne suffit pas à déclencher la demande.** iOS ne sollicite
|
||||||
|
l'autorisation qu'à la création d'un `CBCentralManager`. Or
|
||||||
|
`CBDeviceListenerImpl.search()` commence par
|
||||||
|
`monitorBleState().filter { $0 == .poweredOn }` : sans manager, cet état
|
||||||
|
n'arrive jamais, le flux n'émet rien, le scan ne démarre pas — et le timeout
|
||||||
|
conclut « aucun appareil ». Mesuré le 31/08 : 30 s de scan, aucune alerte, et
|
||||||
|
« Bluetooth » absent de Réglages → coach (seuls Position, Caméra, Mouvement,
|
||||||
|
Actualisation, Données cellulaires y figuraient).
|
||||||
|
|
||||||
|
C'est pourquoi `PolarPsFtpWriter` commence par une `SondeBluetooth` : elle crée
|
||||||
|
le manager, attend son premier état, et traduit ce qu'il dit — autorisation
|
||||||
|
refusée, Bluetooth éteint, matériel absent. **Au premier lancement après ce
|
||||||
|
correctif, iOS affichera l'alerte d'autorisation : l'accepter.**
|
||||||
|
|
||||||
|
## 4. Le premier envoi — en `dryRun`, toujours
|
||||||
|
|
||||||
|
Depuis la console Safari attachée à la WebView :
|
||||||
|
|
||||||
|
```js
|
||||||
|
const r = await fetch('/api/plan/polar-target?date=2026-08-31&hour=18')
|
||||||
|
const plan = await r.json()
|
||||||
|
await window.Capacitor.Plugins.CoachPolarBLE.send({ ...plan, dryRun: true })
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu :
|
||||||
|
|
||||||
|
```
|
||||||
|
{ sent: false, dryRun: true, log: [
|
||||||
|
"mkdir /U/0/20260831/TST/ (0 o)",
|
||||||
|
"mkdir /U/0/20260831/TST/180000/ (0 o)",
|
||||||
|
"put /U/0/20260831/TST/180000/TST.BPB (240 o)",
|
||||||
|
"put /U/0/20260831/TST/180000/ID.BPB (51 o)" ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lire ce journal avant d'ôter `dryRun`.** Un `mkdir` avec des octets, ou un
|
||||||
|
`put` vers un chemin terminé par « / », est la requête exacte qui a bloqué la
|
||||||
|
montre le 2026-08-17 — le plugin la refuse, mais la voir refusée vaut mieux que
|
||||||
|
de la découvrir en radio.
|
||||||
|
|
||||||
|
## 5. L'envoi réel
|
||||||
|
|
||||||
|
Retirer `dryRun`. Fermer l'app Polar Flow d'abord : **le canal BLE ne se
|
||||||
|
partage pas**, et une synchro Flow en cours empêchera la connexion.
|
||||||
|
|
||||||
|
En cas d'échec, le message distingue **trois causes opposées** — la distinction
|
||||||
|
a été ajoutée le 31/08 après un premier essai où un message unique ne permettait
|
||||||
|
pas de conclure :
|
||||||
|
|
||||||
|
| Message | Cause probable | Geste |
|
||||||
|
|---|---|---|
|
||||||
|
| « aucun appareil Bluetooth détecté » | Bluetooth éteint, **autorisation refusée à coach**, montre hors de portée | Réglages → coach → Bluetooth |
|
||||||
|
| « N appareil(s) vu(s), M portant PsFTP mais sans réponse » | canal déjà occupé | fermer **complètement** Polar Flow |
|
||||||
|
| « N appareil(s) vu(s), aucun n'expose PsFTP » | la V3 n'annonce pas le service tant qu'elle est appairée à Flow | c'est l'inconnue de fond |
|
||||||
|
|
||||||
|
⚠️ Le scan se fait par **fenêtres successives** de 4 s jusqu'au timeout, et non
|
||||||
|
en une passe : CoreBluetooth peut n'être pas encore `poweredOn` au premier
|
||||||
|
appel, et une fenêtre unique conclurait à tort qu'aucun appareil n'existe.
|
||||||
|
|
||||||
|
## L'heure de l'objectif — ce qu'on sait, et ce qu'on ne sait pas
|
||||||
|
|
||||||
|
Un objectif est rangé sous `/U/0/<AAAAMMJJ>/TST/<HHMMSS>/`, et le `start_time`
|
||||||
|
écrit dans le fichier doit concorder avec ce dossier : c'est le couple qui situe
|
||||||
|
la séance dans la journée. `build_session.py --hour` et le paramètre `hour` des
|
||||||
|
routes produisent les deux ensemble, il n'y a donc rien à accorder à la main.
|
||||||
|
|
||||||
|
⚠️ **En revanche, on ignore si la montre conditionne l'affichage à cette
|
||||||
|
heure.** Propose-t-elle l'objectif toute la journée, ou seulement à son
|
||||||
|
approche ? Le test du 2026-08-17 ne l'a pas mesuré. À observer au premier envoi
|
||||||
|
réussi : écrire un objectif daté 18 h et regarder s'il apparaît immédiatement.
|
||||||
|
|
||||||
|
- S'il apparaît tout de suite → l'heure n'est qu'une étiquette, en fixer une par
|
||||||
|
défaut suffit.
|
||||||
|
- S'il n'apparaît qu'à l'approche → il faut écrire à l'heure du départ. Sans
|
||||||
|
conséquence pratique : l'envoi se fait de toute façon juste avant de sortir.
|
||||||
|
|
||||||
|
## 🔴 Où ça bloque au 31/08 : le scan du SDK ne démarre pas
|
||||||
|
|
||||||
|
Mesuré, pas supposé : **43 appareils vus par un scan CoreBluetooth nu, 0 remonté
|
||||||
|
par le SDK Polar.** La radio, l'autorisation et la montre sont donc hors de
|
||||||
|
cause — le problème est dans notre usage du SDK.
|
||||||
|
|
||||||
|
Ce qui a été écarté **sur les sources**, pour ne pas le refaire :
|
||||||
|
|
||||||
|
- `servicesToScanFor` à `nil` : c'est aussi ce que fait `PolarBleApiImpl`.
|
||||||
|
- `scanPreFilter` à `nil` : **accepte** les appareils. La condition est
|
||||||
|
`if self.session(peripheral) == nil, let filter = self.scanPreFilter` — sans
|
||||||
|
filtre, le `if let` échoue et le rejet est sauté.
|
||||||
|
- `addClient()` suffit à lancer le scan : `scanningNeeded()` rend vrai dès que
|
||||||
|
`clientCount != 0`, aucune action « admin » n'est requise.
|
||||||
|
|
||||||
|
**CAUSE RACINE TROUVÉE** — confirmée par l'instrumentation : le publisher de
|
||||||
|
`search()` n'émettait **rien du tout**, ni valeur, ni complétion, ni erreur.
|
||||||
|
|
||||||
|
`CBDeviceListenerImpl.manager` est une **`lazy var`** : le `CBCentralManager`
|
||||||
|
n'existe qu'au premier accès.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
fileprivate lazy var manager: SDKCBCentralManager = { … }()
|
||||||
|
```
|
||||||
|
|
||||||
|
Or `search()` commence par `monitorBleState().filter { $0 == .poweredOn }`, et
|
||||||
|
`monitorBleState()` se contente de rendre `bleStateSubject` — il ne touche
|
||||||
|
jamais `manager`. Les seuls accès du chemin de recherche
|
||||||
|
(`retrieveConnectedPeripherals`, `retrievePeripherals`) sont **à l'intérieur du
|
||||||
|
`flatMap`**, donc après le filtre. Le manager n'était donc jamais instancié,
|
||||||
|
`centralManagerDidUpdateState` jamais appelé, le sujet jamais alimenté : le
|
||||||
|
filtre bloquait pour toujours.
|
||||||
|
|
||||||
|
**Deuxième cause, découverte juste après** : sans `scanPreFilter`, le listener
|
||||||
|
remonte **tous** les appareils BLE alentour — 43 mesurés. Le code ouvrait alors
|
||||||
|
une session sur chacun à tour de rôle, dont l'Apple Watch
|
||||||
|
(`API MISUSE: … name = Apple Watch de Sylvain … can only accept commands while
|
||||||
|
in the connected state`), à 12 s d'échéance chacun : l'envoi paraissait ne
|
||||||
|
jamais finir. Le SDK officiel pose ce filtre (`deviceFilter` dans
|
||||||
|
`PolarBleApiImpl`) ; ne pas le poser était l'omission.
|
||||||
|
|
||||||
|
**Correctif** : appeler `listener.blePowered()` — `return manager.state ==
|
||||||
|
.poweredOn`, le seul accès public exécuté immédiatement — **après** s'être
|
||||||
|
abonné à `search()`. L'ordre est critique : si `bleStateSubject` est un
|
||||||
|
`PassthroughSubject`, un état émis avant l'abonnement serait perdu et on
|
||||||
|
retomberait sur le même silence.
|
||||||
|
|
||||||
|
## Le démarrage du SDK — deux détails qui décident de tout
|
||||||
|
|
||||||
|
Lus dans les sources, après plusieurs essais infructueux le 31/08 :
|
||||||
|
|
||||||
|
**1. `bleStateSubject` est un `CurrentValueSubject<BleState, Never>(.unknown)`**,
|
||||||
|
pas un PassthroughSubject. Il **rejoue sa valeur à chaque abonnement** : il est
|
||||||
|
donc inutile d'être abonné au moment de l'émission. Un correctif intermédiaire
|
||||||
|
réveillait le manager *après* s'être abonné « pour ne pas rater l'événement » —
|
||||||
|
raisonnement juste pour un PassthroughSubject, faux ici, et qui exposait au
|
||||||
|
piège suivant.
|
||||||
|
|
||||||
|
**2. `centralManagerDidUpdateState` fait
|
||||||
|
`BleState(rawValue: self.manager.state.rawValue)`** — il accède à la `lazy var`
|
||||||
|
depuis le délégué. Si CoreBluetooth appelle le délégué avant que l'initialisation
|
||||||
|
de la lazy soit terminée, la propriété se réentre.
|
||||||
|
|
||||||
|
**3. `blePowered()` et `search()` ne lisent pas la même chose.** `blePowered()`
|
||||||
|
rend `manager.state == .poweredOn` — l'état de CoreBluetooth. `search()` filtre
|
||||||
|
sur `bleStateSubject`, qui n'est alimenté que par `centralManagerDidUpdateState`.
|
||||||
|
Le manager peut donc être allumé sans que le délégué ait publié quoi que ce
|
||||||
|
soit : le sujet reste à `.unknown` et le filtre bloque. Attendre `blePowered()`
|
||||||
|
ne prouve rien sur ce que `search()` verra.
|
||||||
|
|
||||||
|
⇒ **Séquence correcte** : créer le listener, poser `scanPreFilter`, appeler
|
||||||
|
`blePowered()` **une fois** (son seul rôle : instancier la lazy pour que le
|
||||||
|
délégué puisse tourner), puis attendre sur **`monitorBleState()`** — public, et
|
||||||
|
c'est la source que `search()` lit — jusqu'à `.poweredOn`. Seulement ensuite,
|
||||||
|
`search()`, une fois, sur toute la durée. Un abonnement par tranche relancerait le cycle
|
||||||
|
`addClient()`/`removeClient()` du scanner et l'empêcherait de découvrir quoi que
|
||||||
|
ce soit.
|
||||||
|
|
||||||
|
**Ce qui a été corrigé et vérifié en chemin** (ne pas refaire) :
|
||||||
|
|
||||||
|
| Cause | Preuve |
|
||||||
|
|---|---|
|
||||||
|
| Autorisation Bluetooth jamais demandée | pas de ligne « Bluetooth » dans Réglages → coach |
|
||||||
|
| Manager `lazy` jamais réveillé | publisher muet ; `blePowered()` le crée |
|
||||||
|
| Aucun filtre de scan | 43 appareils, sessions ouvertes jusque sur l'Apple Watch |
|
||||||
|
| Attentes sans limite de temps | envoi figé sans message |
|
||||||
|
| Fenêtres de scan successives | `bleStateSubject` ne publie que sur changement |
|
||||||
|
|
||||||
|
**⚠️ Ce que ça enseigne** : instancier `CBDeviceListenerImpl` soi-même revient à
|
||||||
|
réimplémenter ce que `PolarBleApiImpl` fait, sans en voir le détail. On avance
|
||||||
|
d'un cran à chaque essai, et chaque essai coûte un cycle Xcode.
|
||||||
|
|
||||||
|
### Piste pour la reprise : passer par l'API publique
|
||||||
|
|
||||||
|
Plutôt que de piloter le listener à la main, utiliser
|
||||||
|
`PolarBleApiDefaultImpl.polarImplementation(…)` — le chemin que Polar supporte —
|
||||||
|
pour la découverte et la connexion (`searchForDevice()`, `connectToDevice(_:)`),
|
||||||
|
puis n'atteindre `BlePsFtpClient` qu'une fois la session établie par le SDK.
|
||||||
|
|
||||||
|
⚠️ À vérifier d'abord, et c'est le point bloquant de cette piste :
|
||||||
|
`PolarBleApiImpl` garde son `listener` privé. Il faut donc chercher si une API
|
||||||
|
publique donne accès à la session ou au client PsFTP — sinon cette voie est
|
||||||
|
fermée elle aussi, et il faudra soit forker le SDK, soit renoncer au Bluetooth
|
||||||
|
et garder le câble.
|
||||||
|
|
||||||
|
**Le câble, lui, fonctionne intégralement** : `tools/polar/README.md` du dépôt
|
||||||
|
`coach_sportif`. Rien de ce qui a été livré côté serveur n'est perdu.
|
||||||
|
|
||||||
|
## 🔑 La montre ne s'annonce pas : elle est déjà connectée
|
||||||
|
|
||||||
|
Mesuré le 31/08 : **4020 advertisements vus** (HomePod, Furbo…), **aucune
|
||||||
|
Polar**. Le scan fonctionne parfaitement — la Vantage ne diffuse simplement pas.
|
||||||
|
|
||||||
|
C'est le comportement normal du BLE : **un appareil connecté cesse d'émettre**.
|
||||||
|
La montre est liée à l'iPhone par l'app Flow, donc elle est invisible au scan,
|
||||||
|
par conception. L'attendre était sans espoir, et tous les correctifs de scan qui
|
||||||
|
ont précédé ne pouvaient rien y changer.
|
||||||
|
|
||||||
|
`search()` prévoit ce cas, mais seulement quand on lui passe des UUID :
|
||||||
|
|
||||||
|
```swift
|
||||||
|
foundPeripherals = self.manager.retrieveConnectedPeripherals(withServices: uuids!)
|
||||||
|
```
|
||||||
|
|
||||||
|
⇒ **Appeler `search([BlePsFtpClient.PSFTP_SERVICE], identifiers: nil,
|
||||||
|
fetchKnownDevices: true)`**, et non `search(nil, …)` : c'est cette branche qui
|
||||||
|
rend les périphériques déjà connectés exposant PsFTP et leur fabrique une
|
||||||
|
session.
|
||||||
|
|
||||||
|
⚠️ Dédupliquer aussi les sessions vues : `AllowDuplicates` est armé côté SDK, le
|
||||||
|
même appareil revient à chaque advertisement.
|
||||||
|
|
||||||
|
## ⛔ RÉPONSE DE FOND (31/08) : la V3 refuse la connexion tierce
|
||||||
|
|
||||||
|
La question ouverte depuis le 17/08 est tranchée, par la mesure.
|
||||||
|
|
||||||
|
Une fois la montre cherchée au bon endroit (`retrieveConnectedPeripherals`), le
|
||||||
|
plugin **l'atteint** : « 12 appareils vus, **1 portant PsFTP** ». Le service FEEE
|
||||||
|
est donc bien exposé et `fetchGattClient` rend un `BlePsFtpClient`.
|
||||||
|
|
||||||
|
Mais `waitPsFtpReady` n'aboutit pas, et **la montre affiche « connexion
|
||||||
|
impossible »** pendant que les réglages iOS la disent toujours connectée à Flow.
|
||||||
|
Elle refuse donc la seconde session.
|
||||||
|
|
||||||
|
⇒ **Le canal PsFTP de la Vantage V3 n'est pas partageable.** Tant qu'elle est
|
||||||
|
liée à l'app Flow, aucune app tierce ne peut ouvrir de session — ce qui est
|
||||||
|
cohérent avec l'USB, où il fallait déjà fermer Flow.
|
||||||
|
|
||||||
|
⚠️ **Ce qui n'a PAS été tenté, et qui est la dernière porte** : dissocier la
|
||||||
|
montre de Flow (oublier l'appareil côté iOS) pour voir si elle accepte alors
|
||||||
|
notre connexion. Non tenté délibérément — cela reviendrait à sacrifier la
|
||||||
|
synchronisation quotidienne, qui alimente coach en données, pour un confort
|
||||||
|
d'envoi. Le rapport n'y est pas.
|
||||||
|
|
||||||
|
⚠️ Après un essai de connexion refusé, **redémarrer la montre** (appui long sur
|
||||||
|
OK) pour libérer son canal, puis rouvrir Flow.
|
||||||
|
|
||||||
|
## Ce qui reste inconnu
|
||||||
|
|
||||||
|
1. ~~La V3 accepte-t-elle une connexion BLE tierce ?~~ **Non** — mesuré le
|
||||||
|
31/08, voir ci-dessus.
|
||||||
|
2. **Le filtrage de la montre** se fait sur « expose le service PsFTP (FEEE) »,
|
||||||
|
pas sur le nom : ce que la V3 met dans son advertisement n'a pas été observé,
|
||||||
|
et s'appuyer dessus aurait été une supposition. À resserrer une fois vu. Un
|
||||||
|
capteur H10 s'écarte de lui-même, il ne porte pas PsFTP.
|
||||||
|
3. **Le firmware plante sur requête malformée.** Les garde-fous couvrent les
|
||||||
|
formes connues ; ils ne prouvent pas qu'il n'en existe pas d'autres.
|
||||||
|
|
||||||
|
## 🟠 CE QUE L'ESSAI BLE A COÛTÉ (31/08) — corrigé le 01/09
|
||||||
|
|
||||||
|
⚠️ **Cette section a d'abord été écrite plus alarmante que les faits.** Sylvain
|
||||||
|
l'a corrigée le 01/09 : **la réinitialisation était de son propre chef**, pas une
|
||||||
|
nécessité constatée. Distinguer ce qui a été observé de ce qui en a été déduit.
|
||||||
|
|
||||||
|
**Observé** — après la tentative de connexion Bluetooth refusée par la montre
|
||||||
|
(« connexion impossible » à l'écran), **son interface USB ne répondait plus** :
|
||||||
|
plus aucun `/dev/cu.usbmodem*`, et `system_profiler` ne listait plus rien —
|
||||||
|
alors que la montre chargeait normalement, et que c'était le même câble, le même
|
||||||
|
port et la même montre qu'une heure plus tôt.
|
||||||
|
|
||||||
|
**Déduit à tort** — « les redémarrages n'ont rien changé, il a *fallu* une
|
||||||
|
réinitialisation ». La réinitialisation a été **choisie**, pas imposée : aucune
|
||||||
|
autre voie n'a été épuisée avant. On ne sait donc pas si l'USB serait revenu
|
||||||
|
seul, après un délai, un autre port ou un autre câble.
|
||||||
|
|
||||||
|
⇒ **Ce qui reste établi** : un essai BLE refusé peut laisser l'USB muet dans la
|
||||||
|
foulée. ⇒ **Ce qui ne l'est pas** : que ce soit durable, ni qu'une
|
||||||
|
réinitialisation soit le remède. Si le cas se reproduit, épuiser d'abord les
|
||||||
|
gestes réversibles — débrancher/rebrancher, changer de port et de câble,
|
||||||
|
redémarrer la montre, laisser reposer — et **noter lequel a marché**. C'est
|
||||||
|
cette mesure qui manque.
|
||||||
|
|
||||||
|
⚠️ **Conséquence à assumer, dans sa juste mesure** : une tentative de connexion
|
||||||
|
PsFTP refusée peut laisser l'USB muet — observé une fois. Que le firmware
|
||||||
|
« dégrade durablement son état » était une extrapolation depuis cette
|
||||||
|
observation unique : **retirée**. Le plantage du 17/08 sur requête malformée,
|
||||||
|
lui, reste établi et documenté plus haut.
|
||||||
|
|
||||||
|
⇒ **Ne pas multiplier les essais sans raison**, et prévoir que le câble puisse
|
||||||
|
ne pas répondre juste après. Écrire la séance PAR CÂBLE D'ABORD quand c'est
|
||||||
|
possible — non parce que la casse est certaine, mais parce qu'un aller-retour de
|
||||||
|
diagnostic avant une sortie coûte plus que deux minutes d'anticipation.
|
||||||
|
|
||||||
|
## Si la montre se bloque
|
||||||
|
|
||||||
|
Logo Polar puis écran noir. **Appui long sur OK, 10-15 s.** À défaut,
|
||||||
|
BACK + DOWN pendant 10 s. En dernier recours, réinitialisation d'usine par
|
||||||
|
FlowSync — les données déjà synchronisées dans Flow sont préservées.
|
||||||
|
|
||||||
|
## Après l'envoi
|
||||||
|
|
||||||
|
L'objectif est **éphémère** : une synchro Flow supprime ce qui est déposé à une
|
||||||
|
date qu'elle ignore (mesuré). C'est pour ça que l'envoi se fait juste avant de
|
||||||
|
sortir, et c'est pour ça que le Bluetooth était la condition — brancher la
|
||||||
|
montre au Mac cinq minutes avant de courir n'était pas un usage.
|
||||||
|
|
||||||
|
⚠️ Les cibles écrites sont des **numéros de zone**, pas des battements : la
|
||||||
|
montre les exécute selon les zones réglées dans Flow. Vérifiées alignées le
|
||||||
|
2026-08-17, à 1 bpm près sur la frontière Z2/Z3. La réponse du serveur porte les
|
||||||
|
bornes de `current_zones()` pour ce contrôle — les afficher avant l'envoi.
|
||||||
|
|
||||||
|
## 2026-09-01 — Après réinitialisation, le BLE marche. Et ANCS n'est pas la voie.
|
||||||
|
|
||||||
|
### Fait neuf : l'écriture BLE a abouti
|
||||||
|
|
||||||
|
Rapporté par Sylvain : **après la réinitialisation complète de la montre** (celle
|
||||||
|
qu'a imposée l'incident USB), l'envoi de l'exercice par Bluetooth **a
|
||||||
|
fonctionné**. Il ajoute lui-même : « ce n'est pas viable ».
|
||||||
|
|
||||||
|
Ça confirme le diagnostic du 31/08 plutôt que de l'infirmer : le refus ne vient
|
||||||
|
pas d'une interdiction des apps tierces, mais de **l'occupation du canal PsFTP
|
||||||
|
par Flow**. Montre neuve, pas encore liée à Flow ⇒ le canal est libre ⇒ notre
|
||||||
|
session s'ouvre. Dès que Flow reprend la main, elle se referme.
|
||||||
|
|
||||||
|
⇒ **Le prix d'accès au BLE reste le même** : ne pas avoir Flow. Et depuis le
|
||||||
|
01/09 ce prix a augmenté — la Polar est devenue la montre de sport, donc Flow
|
||||||
|
est le seul canal par lequel les séances entrent dans HealthKit puis dans coach.
|
||||||
|
|
||||||
|
### La question de Sylvain : « et le protocole des notifications iOS ? »
|
||||||
|
|
||||||
|
Bonne intuition — les notifications iOS arrivent bien à la montre **pendant que
|
||||||
|
Flow est connecté**. Mais le canal ne peut pas porter une séance, et la raison
|
||||||
|
est structurelle.
|
||||||
|
|
||||||
|
**ANCS — Apple Notification Center Service** ([spec Apple][ancs]) :
|
||||||
|
|
||||||
|
- **Les rôles sont inversés par rapport à PsFTP.** « The publisher of the ANCS
|
||||||
|
service (**the iOS device**) shall be referred to as the *Notification
|
||||||
|
Provider* » ; « any client of the ANCS service (**an accessory**) shall be
|
||||||
|
referred to as a *Notification Consumer* ». C'est l'**iPhone** qui est
|
||||||
|
serveur, la montre qui est cliente — d'où l'absence de conflit avec Flow : la
|
||||||
|
montre ne cède rien, elle consomme.
|
||||||
|
- **Trois caractéristiques, et rien d'autre** : Notification Source
|
||||||
|
(`9FBF120D-…`), Control Point (`69D1D8F3-…`), Data Source (`22EAC6E9-…`).
|
||||||
|
- **Aucun transport de binaire.** La spec ne décrit aucun mécanisme de transfert
|
||||||
|
de fichier ni de payload applicatif : uniquement des métadonnées de
|
||||||
|
notification et des commandes d'action prédéfinies.
|
||||||
|
|
||||||
|
⇒ **On ne fera pas passer un `.BPB` par ANCS.** Ce n'est pas une limite de notre
|
||||||
|
implémentation, c'est ce que le protocole est.
|
||||||
|
|
||||||
|
### Ce qu'ANCS permet quand même, et qui n'est pas rien
|
||||||
|
|
||||||
|
ANCS transporte du **texte affiché au poignet**, sans câble, sans dissocier, et
|
||||||
|
**sans toucher à Flow**. Une notification locale iOS émise par coach est relayée
|
||||||
|
par ANCS et s'affiche sur la montre. Le canal existe déjà côté projet :
|
||||||
|
`@capacitor/local-notifications` est installé, et `web/static/local-notifications.js`
|
||||||
|
est chargé par `_layout.html` — **aucun build Xcode nécessaire**.
|
||||||
|
|
||||||
|
⚠️ Ce que ça ne fait PAS, et il faut le dire avant de le construire : pas
|
||||||
|
d'objectif structuré, **pas d'alerte de zone**, pas de guidage par étape, pas de
|
||||||
|
comparaison à l'exécution. C'est un **pense-bête au poignet**, pas un
|
||||||
|
remplacement du `.BPB`. La longueur réellement affichable par la V3 n'est pas
|
||||||
|
documentée et devra être mesurée.
|
||||||
|
|
||||||
|
⇒ Le seul chemin qui écrit un vrai objectif **en préservant Flow** reste le
|
||||||
|
**câble**.
|
||||||
|
|
||||||
|
[ancs]: https://developer.apple.com/library/archive/documentation/CoreBluetooth/Reference/AppleNotificationCenterServiceSpecification/Specification/Specification.html
|
||||||
|
|
||||||
|
### La séquence du 01/09, et ce qu'elle apprend sur la fenêtre
|
||||||
|
|
||||||
|
Trois envois consécutifs, trois résultats différents — c'est la chronologie qui
|
||||||
|
explique tout :
|
||||||
|
|
||||||
|
| # | résultat | lecture |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **réussi** | montre fraîchement réinitialisée, **pas encore liée à Flow** : canal libre |
|
||||||
|
| 2 | **errorcode 104** `DIRECTORY_EXISTS` | canal **toujours** libre, la session s'ouvre — seul le `mkdir` bute sur un dossier déjà créé au 1ᵉʳ envoi |
|
||||||
|
| 3 | **« canal probablement occupé »** | Flow a repris la main entre-temps |
|
||||||
|
|
||||||
|
⇒ Le canal n'est pas ouvert ou fermé « en général » : il y a une **fenêtre**,
|
||||||
|
qui commence quand la montre n'est liée à personne et se referme dès que Flow
|
||||||
|
se reconnecte. C'est cohérent avec le 31/08 et avec l'USB, où il faut fermer
|
||||||
|
Flow.
|
||||||
|
|
||||||
|
⚠️ **Le message d'erreur du plugin conseillait « fermer complètement l'app Polar
|
||||||
|
Flow ». C'est faux, et ça l'était déjà le 31/08** : la montre affichait
|
||||||
|
« connexion impossible » pendant que les réglages iOS la disaient **toujours
|
||||||
|
connectée** à Flow. iOS maintient le lien d'un accessoire appairé, app fermée ou
|
||||||
|
non. Message corrigé le 01/09 — il nomme désormais le geste qui libère
|
||||||
|
réellement le canal (redémarrer la montre) et rappelle que le câble est le
|
||||||
|
chemin fiable.
|
||||||
|
|
||||||
|
**Hypothèse ouverte, non vérifiée** : redémarrer la montre puis écrire *dans la
|
||||||
|
foulée*, avant que Flow ne se reconnecte, pourrait suffire. Ça expliquerait les
|
||||||
|
envois 1 et 2. Ce n'est pas une méthode tant que ça n'a pas été reproduit
|
||||||
|
plusieurs fois — et ⛔ chaque essai a un coût matériel avéré (l'USB désactivé le
|
||||||
|
31/08). **Écrire la séance par câble AVANT tout essai.**
|
||||||
|
|
||||||
|
## ⛔ Déconnecter Flow automatiquement : iOS l'interdit (01/09)
|
||||||
|
|
||||||
|
Question de Sylvain : peut-on déconnecter Polar Flow avant chaque envoi, sans
|
||||||
|
geste manuel ? **Non, et ce n'est pas contournable** — c'est une garantie
|
||||||
|
d'isolation entre apps, pas une lacune de notre code.
|
||||||
|
|
||||||
|
Apple, `cancelPeripheralConnection(_:)` :
|
||||||
|
|
||||||
|
> « Because other apps may still have a connection to the peripheral, canceling
|
||||||
|
> a local connection **does not guarantee that the underlying physical link is
|
||||||
|
> immediately disconnected**. »
|
||||||
|
|
||||||
|
Une app n'annule que **sa propre** connexion. Rien dans CoreBluetooth ni dans le
|
||||||
|
SDK Polar ne permet de rompre celle d'une autre app, et une app ne peut pas non
|
||||||
|
plus couper le Bluetooth du système. Le `disconnectFromDevice` du SDK ne ferme
|
||||||
|
que notre session.
|
||||||
|
|
||||||
|
### Le geste manuel le plus court : le mode avion de la montre
|
||||||
|
|
||||||
|
Plus rapide qu'un redémarrage, et documenté par Polar
|
||||||
|
([Vantage V3, Réglages rapides][qs]) : « Le mode avion coupe toute communication
|
||||||
|
sans fil sur votre montre […] vous ne pouvez pas synchroniser vos données avec
|
||||||
|
l'application Polar Flow ».
|
||||||
|
|
||||||
|
**Mode avion ON → OFF**, puis envoyer **immédiatement**. Le ON coupe Flow ; le
|
||||||
|
OFF rouvre une fenêtre pendant laquelle le canal PsFTP est libre, avant que Flow
|
||||||
|
ne se reconnecte. ⚠️ Pendant le ON, notre app ne peut pas se connecter non plus :
|
||||||
|
tout le sans-fil est coupé, c'est bien un ON *puis* OFF.
|
||||||
|
|
||||||
|
⚠️ **Cette course n'a pas été reproduite plusieurs fois** — c'est une hypothèse
|
||||||
|
cohérente avec les envois réussis du 01/09, pas une méthode établie.
|
||||||
|
|
||||||
|
### Pourquoi il n'y a pas de bouton « Réessayer » dans l'UI
|
||||||
|
|
||||||
|
⚠️ **Écrit sur une prémisse trop forte, corrigée le 01/09.** L'argument était
|
||||||
|
« chaque tentative refusée a un coût matériel avéré — celle du 31/08 a désactivé
|
||||||
|
l'USB jusqu'à une réinitialisation ». Or la réinitialisation était **un choix de
|
||||||
|
Sylvain**, pas une nécessité constatée. Ce qui reste : l'USB a été muet après un
|
||||||
|
essai, une fois, et personne n'a cherché s'il serait revenu seul.
|
||||||
|
|
||||||
|
Le bouton n'est donc pas refusé sur un risque « avéré ». Il reste absent parce
|
||||||
|
que **la voie BLE elle-même n'est pas viable** (course contre Flow, geste manuel
|
||||||
|
sur la montre à chaque envoi) : outiller la répétition d'un chemin qu'on
|
||||||
|
n'emprunte pas serait du travail à contre-emploi. **C'est à Sylvain de trancher
|
||||||
|
s'il veut ce bouton** — la décision lui revient, et l'argument matériel ne doit
|
||||||
|
plus peser plus qu'il ne vaut.
|
||||||
|
|
||||||
|
[qs]: https://support.polar.com/e_manuals/vantage-v3/polar-vantage-v3-user-manual-francais/quick-settings.htm
|
||||||
83
docs/watch-plan-origin-runbook-mac.md
Normal file
83
docs/watch-plan-origin-runbook-mac.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Runbook Mac — le build qui rend l'envoi vérifiable
|
||||||
|
|
||||||
|
**Écrit le 03/09/2026, à suivre sur le Mac mini.** Compte 20 minutes, dont la
|
||||||
|
moitié d'attente. Ce build ne change rien à ce que la montre reçoit : il rend
|
||||||
|
l'app capable de **dire** ce qu'elle a fait, ce qu'elle ne savait pas faire.
|
||||||
|
|
||||||
|
Le dossier complet — deux épisodes de « la montre ne dit plus rien », leur
|
||||||
|
chronologie et ce qu'elle élimine — est dans `coach_sportif`,
|
||||||
|
[`docs/GUIDE-MONTRE.md`](../../coach_sportif/docs/GUIDE-MONTRE.md) §5quater et
|
||||||
|
§5quinquies. Ici : les gestes.
|
||||||
|
|
||||||
|
## Ce qui change
|
||||||
|
|
||||||
|
Deux fichiers, **déjà déclarés dans la target App** — donc rien à ajouter dans
|
||||||
|
Xcode, pas de Target Membership à cocher, pas de nouveau framework à lier
|
||||||
|
(WorkoutKit était déjà importé par `CoachWorkoutKit.swift`) :
|
||||||
|
|
||||||
|
- `ios/App/App/CoachWorkoutKit.swift`
|
||||||
|
- `ios/App/App/CoachWorkoutObserver.swift`
|
||||||
|
|
||||||
|
Branche : `feat/watch-outdoor`. Le côté serveur est **déjà en prod**, il attend
|
||||||
|
juste que l'app l'appelle.
|
||||||
|
|
||||||
|
## 1. Récupérer et compiler
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/coach-ios # ou l'emplacement du clone sur le Mac
|
||||||
|
git checkout feat/watch-outdoor
|
||||||
|
git pull
|
||||||
|
npx cap sync ios
|
||||||
|
open ios/App/App.xcworkspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans Xcode : cible **App**, ton iPhone connecté, ▶︎. Rien d'autre à toucher.
|
||||||
|
|
||||||
|
⚠️ **Ce code n'a jamais été compilé** — WorkoutKit et HealthKit n'existent pas
|
||||||
|
dans Swift pour Linux, et `tests-linux/` ne couvre que ce qui n'importe que
|
||||||
|
Foundation. Les signatures Apple ont été vérifiées une par une dans la doc DocC
|
||||||
|
avant d'être écrites, mais si quelque chose casse, c'est là que ça cassera :
|
||||||
|
|
||||||
|
| symptôme | où regarder |
|
||||||
|
|---|---|
|
||||||
|
| `value of type 'HKWorkout' has no member 'workoutPlan'` | l'extension vient de WorkoutKit : vérifier que `import WorkoutKit` est bien pris (il est sous `#if canImport`) |
|
||||||
|
| erreur sur `case .custom(let custom)` | `WorkoutPlan.workout` est l'enum `WorkoutPlan.Workout` — cas `custom`, `goal`, `pacer`, `swimBikeRun` |
|
||||||
|
| erreur de conversion sur `sw.date` | c'est un `DateComponents`, pas une `Date` — d'où le `Calendar.current.date(from:)` |
|
||||||
|
|
||||||
|
## 2. Vérifier sans courir (2 minutes, dans le canapé)
|
||||||
|
|
||||||
|
1. Ouvre **Programme**, la séance du jour, et appuie sur l'envoi Apple Watch.
|
||||||
|
Le message doit maintenant **nommer la séance programmée** :
|
||||||
|
« Programmée sur la montre : CDC · … ».
|
||||||
|
- S'il dit « la montre ne la liste pas encore » : le scheduler n'a pas encore
|
||||||
|
publié — regarde *Exercice → Programmées* au poignet avant de conclure.
|
||||||
|
- S'il dit « Autorisation « Séances programmées » absente » : **c'est la
|
||||||
|
réponse au silence**. Réglages iPhone → coach → autoriser, puis renvoyer.
|
||||||
|
C'est exactement ce qu'une réinstallation de l'app remet à zéro, et
|
||||||
|
l'ancienne version affichait « Envoyé » quand même.
|
||||||
|
|
||||||
|
2. Console Xcode (facultatif), filtre `CoachWorkoutKit` : `schedule OK`,
|
||||||
|
`authorizationState`.
|
||||||
|
|
||||||
|
## 3. Après ta prochaine séance
|
||||||
|
|
||||||
|
Le plugin remonte tout seul, en fin de séance, si elle venait de la séance
|
||||||
|
programmée ou d'un départ à la main. Pour le lire :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# sur nexus, là où vivent les données (node n'y est pas installé)
|
||||||
|
venv/bin/python3 tools/watch_alert_check.py <AAAA-MM-JJ> --dump /tmp/wac.json
|
||||||
|
# puis en local, là où node existe
|
||||||
|
python3 tools/watch_alert_check.py --from /tmp/wac.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Trois réponses possibles, et une seule était disponible jusqu'ici :
|
||||||
|
|
||||||
|
- **« Séance lancée depuis la séance programmée »** → les alertes comptées
|
||||||
|
étaient bien dues. Si tu n'as rien entendu, le défaut est ailleurs que dans
|
||||||
|
l'envoi et le lancement.
|
||||||
|
- **« Séance lancée À LA MAIN »** → aucune alerte n'était possible, le silence
|
||||||
|
est normal. C'est le cas que rien ne savait détecter, et qui devient plausible
|
||||||
|
depuis que la Polar est la montre de sport.
|
||||||
|
- **« Origine inconnue »** → séance antérieure au build, ou lecture en échec.
|
||||||
|
Ce n'est **pas** un « non » : l'app préfère se taire qu'affirmer à tort.
|
||||||
107
docs/watch-training-screens.md
Normal file
107
docs/watch-training-screens.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Vues d'entraînement au choix sur la montre — cadrage du 2026-08-21
|
||||||
|
|
||||||
|
Chantier **non commencé**. Ce document fixe ce qui a été établi le 21/08 pour
|
||||||
|
qu'une session ultérieure reprenne sans refaire l'analyse.
|
||||||
|
|
||||||
|
Objectif : des écrans de séance configurables façon **WorkOutDoors** — plusieurs
|
||||||
|
pages, des champs au choix, un profil par sport.
|
||||||
|
|
||||||
|
## Point de départ réel (vérifié, pas supposé)
|
||||||
|
|
||||||
|
`LiveWorkoutView` (`ios/App/CoachWatch/ContentView.swift`, ~l.392-441) est un
|
||||||
|
`ScrollView` avec **cinq métriques écrites en dur** : FC + zone, calories,
|
||||||
|
distance, vitesse km/h, durée — puis l'état iPhone et les boutons
|
||||||
|
Pause / Terminer.
|
||||||
|
|
||||||
|
**Il n'existe aujourd'hui aucune notion de champ, de page, ni de configuration.**
|
||||||
|
Tout est à créer.
|
||||||
|
|
||||||
|
## Les quatre briques manquantes
|
||||||
|
|
||||||
|
### 1. Un catalogue de champs
|
||||||
|
|
||||||
|
Chaque métrique doit devenir une valeur identifiable (`id` stable, libellé,
|
||||||
|
unité, couleur, formatage) et non une ligne de vue. C'est ce qui permet à une
|
||||||
|
page de déclarer `["hr", "pace", "distance"]` sans que la vue connaisse les
|
||||||
|
champs à l'avance.
|
||||||
|
|
||||||
|
**Disponibles immédiatement, aucune collecte nouvelle :**
|
||||||
|
|
||||||
|
| Champ | Source |
|
||||||
|
|---|---|
|
||||||
|
| FC + zone | `WorkoutManager.heartRate` + `ConnectivityManager.zones` |
|
||||||
|
| Calories | `WorkoutManager.activeEnergyKcal` |
|
||||||
|
| Distance | `WorkoutManager.distanceMeters` |
|
||||||
|
| Vitesse km/h | `WorkoutManager.speedKmh` |
|
||||||
|
| Durée | `WorkoutManager.elapsedSec` |
|
||||||
|
| **Allure min/km** | dérivée de `speedKmh` (rien à collecter) |
|
||||||
|
| **D+ / D−** | `LocationTracker.ascentMeters` / `descentMeters` |
|
||||||
|
| **Précision GPS** | `LocationTracker.horizontalAccuracy` |
|
||||||
|
| **Étape du fractionné** | `IntervalEngine`, dès qu'il est câblé (cf. COWORK) |
|
||||||
|
|
||||||
|
**Demandent du travail :**
|
||||||
|
|
||||||
|
- **FC moyenne** et **temps passé par zone** : rien ne les accumule aujourd'hui.
|
||||||
|
- **Cadence** : non collectée sur la montre.
|
||||||
|
- **Puissance de course** (`runningPower`, watchOS 9+).
|
||||||
|
- **Tours / laps** : aucun `HKWorkoutEvent` posé aujourd'hui.
|
||||||
|
|
||||||
|
### 2. Un modèle de page
|
||||||
|
|
||||||
|
```
|
||||||
|
WorkoutScreenConfig { sport: String, pages: [WorkoutPage] }
|
||||||
|
WorkoutPage { id: String, fields: [String] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Rendu par un `TabView` paginé (défilement vertical + Digital Crown, watchOS 10+).
|
||||||
|
1 à 4 champs par page selon la taille d'écran — l'Ultra en tient davantage.
|
||||||
|
|
||||||
|
💡 **Ce modèle est du Foundation pur** : il s'écrit et se teste **sur Linux**
|
||||||
|
(`./tests-linux/run.sh`), comme `IntervalEngine` et ses 17 tests. Seul le rendu
|
||||||
|
SwiftUI exigera Xcode.
|
||||||
|
|
||||||
|
### 3. Où l'utilisateur choisit — décision prise le 21/08
|
||||||
|
|
||||||
|
**La configuration s'édite côté web et se pousse à la montre.** Pas d'écran de
|
||||||
|
réglages sur la montre.
|
||||||
|
|
||||||
|
- `TileManager` (long-press, drag, toggle) existe déjà côté web : le paradigme
|
||||||
|
est là, éprouvé sur les tuiles.
|
||||||
|
- Même chemin de transport que la routine et les zones : `WCSession` →
|
||||||
|
`ConnectivityManager`.
|
||||||
|
- **Conséquence décisive** : changer ses écrans ne demanderait **aucun rebuild
|
||||||
|
Xcode**. C'est exactement ce qui rend la routine agréable aujourd'hui — « les
|
||||||
|
blocs voyagent avec l'état, rien n'est codé en dur côté watchOS ».
|
||||||
|
- Un **profil par sport** (course ≠ vélo ≠ renfo), comme WorkOutDoors.
|
||||||
|
|
||||||
|
⚠️ **Repli obligatoire** : sans configuration reçue, la montre affiche les cinq
|
||||||
|
champs actuels. Jamais d'écran vide — la config peut ne pas être arrivée, et une
|
||||||
|
séance ne s'interrompt pas pour ça.
|
||||||
|
|
||||||
|
### 4. Deux contraintes watchOS, à tenir dès la conception
|
||||||
|
|
||||||
|
- **Le CPU tue le GPS** (piège n°4 documenté dans `LocationTracker.swift`) :
|
||||||
|
watchOS suspend une app trop gourmande et les positions s'arrêtent **sans
|
||||||
|
aucune erreur**. ⇒ rafraîchissement **1 Hz maximum**, et ne recalculer **que
|
||||||
|
la page visible**, jamais les quatre. Ne pas utiliser un `Timer` de vue.
|
||||||
|
- **Écran always-on** : en luminance réduite (`@Environment(\.isLuminanceReduced)`),
|
||||||
|
espacer les mises à jour, sinon la batterie fond sur une sortie longue.
|
||||||
|
|
||||||
|
## Effort estimé
|
||||||
|
|
||||||
|
| Lot | Estimation |
|
||||||
|
|---|---|
|
||||||
|
| Châssis (catalogue + modèle + config web + push + `TabView`) | ~1 jour |
|
||||||
|
| Chaque métrique dérivée supplémentaire | 30 min à 2 h |
|
||||||
|
| Always-on propre | ½ jour |
|
||||||
|
|
||||||
|
## Ordre proposé
|
||||||
|
|
||||||
|
1. **Modèle + catalogue en Foundation pur**, testés sur Linux — sans Mac.
|
||||||
|
2. **Endpoint serveur + éditeur web** (réutiliser `TileManager`).
|
||||||
|
3. **Push `WCSession`** et publication dans `ConnectivityManager`.
|
||||||
|
4. **Rendu `TabView`** dans `LiveWorkoutView`, avec le repli en dur.
|
||||||
|
5. Métriques accumulées (FC moyenne, temps en zone) — chacune isolément.
|
||||||
|
|
||||||
|
⚠️ Ne pas commencer par le rendu : c'est la seule partie qui exige le Mac, et
|
||||||
|
elle ne se juge qu'une fois les données au bon format.
|
||||||
644
ios/App/App/CoachPolarBLE.swift
Normal file
644
ios/App/App/CoachPolarBLE.swift
Normal file
@@ -0,0 +1,644 @@
|
|||||||
|
// CoachPolarBLE.swift
|
||||||
|
// Écrit une séance du plan sur une Polar Vantage V3, en Bluetooth, sans cloud
|
||||||
|
// ni compte ni API tierce.
|
||||||
|
//
|
||||||
|
// POURQUOI CE PLUGIN EXISTE
|
||||||
|
//
|
||||||
|
// L'écriture d'un objectif sur la montre par le protocole PFTP est prouvée
|
||||||
|
// depuis le 2026-08-17 : la montre accepte le fichier et l'affiche. Mais elle
|
||||||
|
// ne l'était qu'en USB, donc il fallait brancher la montre à un Mac cinq
|
||||||
|
// minutes avant de sortir — ce qui n'est pas un usage.
|
||||||
|
//
|
||||||
|
// Le Bluetooth n'est donc pas un confort : c'est ce qui rend le chemin
|
||||||
|
// utilisable. Et c'est le seul chemin qui satisfasse la contrainte posée le
|
||||||
|
// 2026-08-31 — `coach → la montre`, sans TrainingPeaks, sans Polar Flow, sans
|
||||||
|
// Intervals.icu. Toutes les autres voies passent par quelqu'un d'autre.
|
||||||
|
//
|
||||||
|
// CE QUE CE PLUGIN FAIT, ET CE QU'IL NE FAIT PAS
|
||||||
|
//
|
||||||
|
// Il transporte. Rien d'autre. Les octets viennent du serveur
|
||||||
|
// (`GET /api/plan/polar-target`), qui les produit avec un encodeur verrouillé
|
||||||
|
// octet pour octet contre des fichiers relus sur la montre. **Ne jamais
|
||||||
|
// réencoder un objectif ici** : ce serait perdre la seule garantie dont on
|
||||||
|
// dispose que le firmware acceptera le fichier.
|
||||||
|
//
|
||||||
|
// ⚠️ LE FIRMWARE PLANTE SUR REQUÊTE MALFORMÉE
|
||||||
|
//
|
||||||
|
// Le 2026-08-17, un PUT de 174 octets vers un chemin terminé par « / » — donc
|
||||||
|
// « écrire du contenu dans un dossier » — a bloqué une Vantage V3 : logo Polar
|
||||||
|
// puis écran noir, récupérée par un appui long sur OK. Le firmware ne refuse
|
||||||
|
// pas proprement, il s'effondre. Le BLE utilise LE MÊME protocole PFTP : le
|
||||||
|
// risque est identique ici.
|
||||||
|
//
|
||||||
|
// D'où deux verrous, repris de `tools/polar/polar_ftp.py` :
|
||||||
|
// - un chemin terminé par « / » est un DOSSIER : contenu obligatoirement vide ;
|
||||||
|
// - un chemin sans « / » final est un FICHIER : contenu obligatoirement non vide.
|
||||||
|
// Et un mode `dryRun` qui construit chaque requête et la décrit sans rien
|
||||||
|
// émettre — l'équivalent du `--dry-run` qui a manqué le jour du plantage.
|
||||||
|
//
|
||||||
|
// CHAÎNE SDK — vérifiée sur les sources le 2026-08-31, tout est public
|
||||||
|
//
|
||||||
|
// CBDeviceListenerImpl(queue, clients:identifier:) ble/endpoints/corebluetooth/central
|
||||||
|
// listener.search(_:identifiers:fetchKnownDevices:) -> AnyPublisher<BleDeviceSession, Error>
|
||||||
|
// listener.openSessionDirect(_:)
|
||||||
|
// session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE) // CBUUID("FEEE")
|
||||||
|
// client.waitPsFtpReady(_:) async throws
|
||||||
|
// client.write(_ header: NSData, data: InputStream) -> AsyncThrowingStream<UInt, Error>
|
||||||
|
//
|
||||||
|
// Aucun fork, aucun symbole interne.
|
||||||
|
//
|
||||||
|
// ⚠️ Le cadrage série `[0x05, taille, taille]` de la version USB N'A PAS SA
|
||||||
|
// PLACE ICI. Il appartient au transport RFC76 sur CDC-ACM ; en Bluetooth c'est
|
||||||
|
// le SDK qui s'en charge. On ne passe que l'en-tête PbPFtpOperation et les
|
||||||
|
// données brutes. Reporter le cadrage produirait une requête malformée — c'est-
|
||||||
|
// à-dire précisément ce qui plante la montre.
|
||||||
|
//
|
||||||
|
// USAGE CÔTÉ JS
|
||||||
|
//
|
||||||
|
// await window.Capacitor.Plugins.CoachPolarBLE.isAvailable()
|
||||||
|
// → { available: bool }
|
||||||
|
//
|
||||||
|
// await window.Capacitor.Plugins.CoachPolarBLE.send({
|
||||||
|
// mkdir: ['/U/0/20260831/TST/', '/U/0/20260831/TST/180000/'],
|
||||||
|
// dir: '/U/0/20260831/TST/180000/',
|
||||||
|
// files: [{ name: 'TST.BPB', b64: '...' }, { name: 'ID.BPB', b64: '...' }],
|
||||||
|
// dryRun: true, // par défaut FALSE, mais à utiliser au premier essai
|
||||||
|
// timeoutSec: 30,
|
||||||
|
// })
|
||||||
|
// → { sent: bool, dryRun: bool, log: [string] }
|
||||||
|
//
|
||||||
|
// Le corps de `send` est exactement la réponse de `/api/plan/polar-target` :
|
||||||
|
// le JS n'a rien à recomposer.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Capacitor
|
||||||
|
#if canImport(PolarBleSdk)
|
||||||
|
import PolarBleSdk
|
||||||
|
import CoreBluetooth
|
||||||
|
import Combine
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@objc(CoachPolarBLEPlugin)
|
||||||
|
public class CoachPolarBLEPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
|
// CAPBridgedPlugin : sans cette déclaration explicite, Capacitor 8
|
||||||
|
// n'expose pas les méthodes au bridge et `Capacitor.Plugins.CoachPolarBLE`
|
||||||
|
// reste undefined (même piège que CoachWorkoutKit).
|
||||||
|
public let identifier = "CoachPolarBLEPlugin"
|
||||||
|
public let jsName = "CoachPolarBLE"
|
||||||
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
|
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "send", returnType: CAPPluginReturnPromise),
|
||||||
|
]
|
||||||
|
|
||||||
|
@objc func isAvailable(_ call: CAPPluginCall) {
|
||||||
|
#if canImport(PolarBleSdk)
|
||||||
|
call.resolve(["available": true])
|
||||||
|
#else
|
||||||
|
call.resolve(["available": false,
|
||||||
|
"reason": "PolarBleSdk absent de la cible — ajouter le paquet dans Xcode"])
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func send(_ call: CAPPluginCall) {
|
||||||
|
guard let etapes = Self.parseSteps(call) else {
|
||||||
|
call.reject("payload invalide : `dir`, `files[]` (name + b64) requis")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let dryRun = call.getBool("dryRun") ?? false
|
||||||
|
let timeout = call.getDouble("timeoutSec") ?? 30
|
||||||
|
|
||||||
|
// Les verrous d'abord, la radio ensuite. Une requête refusée ici est une
|
||||||
|
// montre qui ne plante pas.
|
||||||
|
do {
|
||||||
|
try etapes.forEach { try $0.validate() }
|
||||||
|
} catch {
|
||||||
|
call.reject("\(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
call.resolve(["sent": false, "dryRun": true,
|
||||||
|
"log": etapes.map { $0.describe }])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(PolarBleSdk)
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let issue = try await PolarPsFtpWriter().ecrire(etapes, timeout: timeout)
|
||||||
|
call.resolve(["sent": true, "dryRun": false,
|
||||||
|
"log": issue.journal,
|
||||||
|
// L'UI s'en sert pour prévenir d'un renvoi : la
|
||||||
|
// montre garde son index en cache, et l'objectif
|
||||||
|
// réécrit n'apparaît qu'après un redémarrage.
|
||||||
|
"alreadyExisted": issue.dossierPreexistant])
|
||||||
|
} catch {
|
||||||
|
call.reject("\(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
call.reject("PolarBleSdk absent de la cible — ajouter le paquet dans Xcode")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Traduit le corps JS en étapes PFTP, dans l'ordre où elles doivent partir.
|
||||||
|
static func parseSteps(_ call: CAPPluginCall) -> [PftpStep]? {
|
||||||
|
guard let dir = call.getString("dir"), !dir.isEmpty else { return nil }
|
||||||
|
var etapes: [PftpStep] = []
|
||||||
|
|
||||||
|
// Les dossiers d'abord, du parent vers l'enfant : `/U/0/<date>/TST/` puis
|
||||||
|
// `<heure>/`. Aucun des deux n'existe d'avance pour une date neuve, et un
|
||||||
|
// mkdir dans le désordre échoue.
|
||||||
|
for chemin in call.getArray("mkdir", String.self) ?? [] {
|
||||||
|
etapes.append(PftpStep(path: chemin, data: Data()))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let fichiers = call.getArray("files") as? [[String: Any]], !fichiers.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for fichier in fichiers {
|
||||||
|
guard let nom = fichier["name"] as? String,
|
||||||
|
let b64 = fichier["b64"] as? String,
|
||||||
|
let octets = Data(base64Encoded: b64) else { return nil }
|
||||||
|
etapes.append(PftpStep(path: dir + nom, data: octets))
|
||||||
|
}
|
||||||
|
return etapes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Le transport
|
||||||
|
|
||||||
|
#if canImport(PolarBleSdk)
|
||||||
|
|
||||||
|
/// Ouvre une session PsFTP sur la première montre qui répond, et écrit.
|
||||||
|
///
|
||||||
|
/// ⚠️ Le filtrage se fait sur « expose PsFTP », pas sur le nom ni l'identifiant
|
||||||
|
/// annoncé : ce que la V3 met dans son advertisement n'a pas été observé, et
|
||||||
|
/// s'appuyer dessus serait une supposition. À resserrer une fois qu'on l'aura vu
|
||||||
|
/// — en attendant, un capteur Polar (H10) ne portant pas PsFTP est écarté de
|
||||||
|
/// lui-même.
|
||||||
|
final class PolarPsFtpWriter {
|
||||||
|
private let queue = DispatchQueue(label: "ch.hypnotruck.coach.polarble")
|
||||||
|
private var abonnements = Set<AnyCancellable>()
|
||||||
|
/// Le publisher de `search()` a-t-il émis quoi que ce soit — valeur, fin ou
|
||||||
|
/// erreur ? S'il reste muet, `monitorBleState()` n'a jamais émis
|
||||||
|
/// `.poweredOn` et le scan n'a jamais démarré : ce n'est pas « aucun
|
||||||
|
/// appareil », c'est « on n'a jamais cherché ».
|
||||||
|
private var publisherAParle = false
|
||||||
|
private var derniereErreurSdk: String?
|
||||||
|
|
||||||
|
/// Écrit les étapes et rend le journal, plus un drapeau : le dossier de
|
||||||
|
/// destination existait-il déjà ? Ce drapeau remonte jusqu'à l'UI, qui doit
|
||||||
|
/// avertir — un objectif réécrit n'apparaît qu'après redémarrage de la
|
||||||
|
/// montre.
|
||||||
|
func ecrire(_ etapes: [PftpStep], timeout: Double) async throws
|
||||||
|
-> (journal: [String], dossierPreexistant: Bool) {
|
||||||
|
// ⚠️ PRÉAMBULE INDISPENSABLE — sans lui, rien ne se passe et rien ne le
|
||||||
|
// dit. `CBDeviceListenerImpl.search()` commence par
|
||||||
|
// `monitorBleState().filter { $0 == .poweredOn }` : tant que cet état
|
||||||
|
// n'arrive pas, le flux n'émet jamais, le scan ne démarre pas, et on
|
||||||
|
// conclut « aucun appareil » au bout du timeout.
|
||||||
|
//
|
||||||
|
// Or l'état ne peut arriver que si un `CBCentralManager` existe — et
|
||||||
|
// c'est sa création qui déclenche l'alerte d'autorisation d'iOS.
|
||||||
|
// Mesuré le 2026-08-31 : le scan a tourné 30 s sans qu'iOS demande quoi
|
||||||
|
// que ce soit, et « Bluetooth » n'apparaissait même pas dans les
|
||||||
|
// réglages de l'app. L'autorisation n'avait jamais été sollicitée.
|
||||||
|
//
|
||||||
|
// On crée donc le manager nous-mêmes, on attend son premier état, et on
|
||||||
|
// traduit ce qu'il dit — au lieu de laisser un silence passer pour une
|
||||||
|
// absence de montre.
|
||||||
|
let sonde = SondeBluetooth()
|
||||||
|
try await sonde.attendreEtatUtilisable(timeout: min(timeout, 15))
|
||||||
|
|
||||||
|
let listener = CBDeviceListenerImpl(
|
||||||
|
queue,
|
||||||
|
clients: [{ transport in BlePsFtpClient(gattServiceTransmitter: transport) }],
|
||||||
|
identifier: 1)
|
||||||
|
|
||||||
|
// ⚠️ NE PAS FILTRER À L'AVEUGLE — on trie après avoir vu.
|
||||||
|
//
|
||||||
|
// Une version précédente posait `scanPreFilter` sur « polarDeviceId non
|
||||||
|
// vide OU nom contenant polar ». Si la Vantage ne s'annonce pas ainsi,
|
||||||
|
// elle était rejetée par NOUS, aucune session n'était créée, et le
|
||||||
|
// publisher de `search()` ne disait rien — ce que le message
|
||||||
|
// interprétait à tort comme « le scan n'a pas démarré ».
|
||||||
|
//
|
||||||
|
// On laisse donc tout remonter et on trie dans la boucle : les appareils
|
||||||
|
// qui ressemblent à du Polar d'abord, et on n'ouvre de session que sur
|
||||||
|
// ceux-là. Si aucun ne ressemble, on RAPPORTE ce qu'on a vu au lieu de
|
||||||
|
// conclure.
|
||||||
|
|
||||||
|
// ⚠️ RÉVEILLER LA LAZY, PUIS ATTENDRE — dans cet ordre, et avant tout
|
||||||
|
// abonnement.
|
||||||
|
//
|
||||||
|
// `CBDeviceListenerImpl.manager` est une `lazy var` : le
|
||||||
|
// CBCentralManager n'existe qu'au premier accès, et `search()` ne le
|
||||||
|
// touche jamais avant son filtre `$0 == .poweredOn`. Sans réveil, on
|
||||||
|
// attend un état que rien ne peut produire.
|
||||||
|
//
|
||||||
|
// Deux détails, tirés des sources, qui décident du succès :
|
||||||
|
//
|
||||||
|
// 1. `bleStateSubject` est un **CurrentValueSubject**, initialisé à
|
||||||
|
// `.unknown`. Il REJOUE sa valeur à chaque abonnement — inutile donc
|
||||||
|
// d'être abonné au moment de l'émission. Une version précédente
|
||||||
|
// réveillait après l'abonnement « pour ne pas rater l'événement » :
|
||||||
|
// raisonnement de PassthroughSubject, faux ici, et qui exposait au
|
||||||
|
// piège suivant.
|
||||||
|
//
|
||||||
|
// 2. `centralManagerDidUpdateState` fait
|
||||||
|
// `BleState(rawValue: self.manager.state.rawValue)` — il accède à la
|
||||||
|
// lazy DEPUIS le délégué. Si CoreBluetooth appelle le délégué avant
|
||||||
|
// que l'initialisation de la lazy soit terminée, la propriété se
|
||||||
|
// réentre. D'où : réveiller tôt, hors de tout abonnement, et laisser
|
||||||
|
// l'état se poser.
|
||||||
|
//
|
||||||
|
// ⚠️ ET SURTOUT : attendre le SUJET, pas l'état du manager.
|
||||||
|
//
|
||||||
|
// `blePowered()` lit `manager.state` — l'état de CoreBluetooth. Mais
|
||||||
|
// `search()` filtre sur `bleStateSubject`, qui n'est alimenté que par
|
||||||
|
// `centralManagerDidUpdateState`. Le manager peut donc être allumé
|
||||||
|
// (`blePowered()` vrai) sans que le délégué ait encore publié quoi que
|
||||||
|
// ce soit : le sujet reste à `.unknown`, le filtre bloque, et le
|
||||||
|
// publisher se tait. C'est exactement ce qui a été mesuré le
|
||||||
|
// 2026-08-31, y compris après avoir attendu `blePowered()`.
|
||||||
|
//
|
||||||
|
// On attend donc sur `monitorBleState()`, qui est public et qui EST la
|
||||||
|
// source lue par `search()`. `blePowered()` ne sert plus qu'à une
|
||||||
|
// chose : instancier la lazy pour que le délégué puisse tourner.
|
||||||
|
_ = listener.blePowered()
|
||||||
|
let etat = await listener.premierEtatPret(timeout: min(timeout, 12))
|
||||||
|
if !etat {
|
||||||
|
throw PolarPftpError.bluetoothUnusable(
|
||||||
|
"le listener du SDK Polar n'a jamais publié l'état « prêt » "
|
||||||
|
+ "(son délégué n'a pas été appelé). Bluetooth actif côté "
|
||||||
|
+ "système, mais inutilisable par le SDK.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let (client, session) = try await trouverClient(listener, timeout: timeout,
|
||||||
|
sonde: sonde)
|
||||||
|
defer { listener.closeSessionDirect(session) }
|
||||||
|
|
||||||
|
var journal: [String] = []
|
||||||
|
var dossierPreexistant = false
|
||||||
|
for etape in etapes {
|
||||||
|
// Revalidé juste avant l'émission : entre la validation d'entrée et
|
||||||
|
// ce point, rien ne doit avoir introduit un chemin de dossier avec
|
||||||
|
// du contenu.
|
||||||
|
try etape.validate()
|
||||||
|
do {
|
||||||
|
try await put(client, etape)
|
||||||
|
journal.append("✔ " + etape.describe)
|
||||||
|
} catch BlePsFtpException.responseError(let code)
|
||||||
|
where etape.isDirectory && PftpCode.mkdirEstSatisfait(par: code) {
|
||||||
|
// `mkdir -p` : le dossier est là, c'est tout ce qu'on demandait.
|
||||||
|
//
|
||||||
|
// ⚠️ Rapporté par Sylvain le 01/09/2026 sous la forme
|
||||||
|
// « errorcode 104 » — ce qui donnait à croire à un refus de la
|
||||||
|
// montre alors que la session BLE s'était parfaitement ouverte.
|
||||||
|
// Le commentaire de `parseSteps` disait « aucun des deux
|
||||||
|
// n'existe d'avance pour une date neuve » : c'est vrai d'une
|
||||||
|
// date NEUVE, et faux dès le second envoi vers la même date.
|
||||||
|
//
|
||||||
|
// Le renvoi est signalé plus haut, jamais avalé en silence :
|
||||||
|
// réécrire un objectif déjà présent n'est visible qu'après un
|
||||||
|
// redémarrage de la montre (index en cache, constaté le 31/08).
|
||||||
|
dossierPreexistant = true
|
||||||
|
journal.append("• " + etape.describe + " — existait déjà")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (journal, dossierPreexistant)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func trouverClient(_ listener: CBDeviceListenerImpl,
|
||||||
|
timeout: Double,
|
||||||
|
sonde: SondeBluetooth) async throws
|
||||||
|
-> (BlePsFtpClient, BleDeviceSession) {
|
||||||
|
let debut = Date()
|
||||||
|
var vues = 0 // appareils BLE aperçus, tous confondus
|
||||||
|
var candidats = 0 // ceux qui ressemblent à un Polar
|
||||||
|
var noms: [String] = []
|
||||||
|
var sansPsFtp = 0 // aperçus mais ne portant pas le service FEEE
|
||||||
|
var muettes = 0 // portant FEEE mais dont waitPsFtpReady échoue
|
||||||
|
|
||||||
|
// ⚠️ UN SEUL ABONNEMENT, SUR TOUTE LA DURÉE — pas des fenêtres.
|
||||||
|
//
|
||||||
|
// Non pas parce qu'une émission serait ratée — `bleStateSubject` est un
|
||||||
|
// CurrentValueSubject, il rejoue sa valeur à chaque abonnement — mais
|
||||||
|
// parce que chaque abonnement relance le cycle `addClient()` /
|
||||||
|
// `removeClient()` du scanner. Redémarrer un scan toutes les 4 s
|
||||||
|
// l'empêche de découvrir quoi que ce soit, et la découverte BLE demande
|
||||||
|
// plusieurs secondes ininterrompues.
|
||||||
|
//
|
||||||
|
// L'état, lui, est acquis avant d'arriver ici : `blePowered()` a été
|
||||||
|
// sondé jusqu'à devenir vrai.
|
||||||
|
do {
|
||||||
|
// `AllowDuplicates` est armé côté SDK : le même appareil revient à
|
||||||
|
// chaque advertisement (4020 émissions pour une poignée d'appareils
|
||||||
|
// réels). On déduplique avant de compter et d'ouvrir quoi que ce soit.
|
||||||
|
var dejaVues = Set<String>()
|
||||||
|
let toutes = try await sessionsVues(listener, fenetre: timeout)
|
||||||
|
.filter { dejaVues.insert(Self.etiquette($0)).inserted }
|
||||||
|
vues = toutes.count
|
||||||
|
noms = toutes.map { Self.etiquette($0) }
|
||||||
|
|
||||||
|
// Ouvrir une session coûte plusieurs secondes : on ne les tente que
|
||||||
|
// sur ce qui ressemble à un Polar, sinon 43 appareils feraient des
|
||||||
|
// minutes d'attente (mesuré, avec des erreurs sur l'Apple Watch).
|
||||||
|
let sessions = toutes.filter { Self.ressembleAPolar($0) }
|
||||||
|
candidats = sessions.count
|
||||||
|
for session in sessions {
|
||||||
|
// Marge au-delà du scan : ouvrir une session prend du temps, et
|
||||||
|
// couper ici ferait échouer la seule montre trouvée.
|
||||||
|
if Date().timeIntervalSince(debut) > timeout * 2 { break }
|
||||||
|
listener.openSessionDirect(session)
|
||||||
|
guard let client = session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE)
|
||||||
|
as? BlePsFtpClient else {
|
||||||
|
sansPsFtp += 1
|
||||||
|
listener.closeSessionDirect(session)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
// ⚠️ `waitPsFtpReady` n'a AUCUNE limite de temps. Si la
|
||||||
|
// montre ne finit pas la négociation — canal déjà pris,
|
||||||
|
// écran éteint, appairage en cours — l'attente ne rend
|
||||||
|
// jamais la main et l'envoi paraît figé, sans message.
|
||||||
|
// Constaté le 2026-08-31 : « envoi » tournant sans fin.
|
||||||
|
// On lui donne donc une échéance, et un dépassement compte
|
||||||
|
// comme une montre muette : c'est ce qu'il est.
|
||||||
|
try await Self.avecEcheance(seconds: 12) {
|
||||||
|
try await client.waitPsFtpReady(true)
|
||||||
|
}
|
||||||
|
return (client, session)
|
||||||
|
} catch {
|
||||||
|
muettes += 1
|
||||||
|
listener.closeSessionDirect(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vues > 0 && candidats == 0 {
|
||||||
|
// Le scan marche, mais rien ne ressemble à un Polar. Dire ce qu'on
|
||||||
|
// a vu : c'est la seule façon de savoir sous quel nom la montre
|
||||||
|
// s'annonce — ou si elle ne s'annonce pas du tout.
|
||||||
|
throw PolarPftpError.aucunPolarParmi(vues: vues,
|
||||||
|
exemples: Array(noms.prefix(8)))
|
||||||
|
}
|
||||||
|
if vues == 0 {
|
||||||
|
// Le SDK n'a rien remonté : est-ce la radio, ou notre usage du SDK ?
|
||||||
|
// Un scan nu tranche, et son résultat part dans le message.
|
||||||
|
let bruts = await sonde.compterAppareils(pendant: 6)
|
||||||
|
throw PolarPftpError.sdkSilencieux(vusParCoreBluetooth: bruts,
|
||||||
|
publisherAParle: publisherAParle,
|
||||||
|
erreurSdk: derniereErreurSdk)
|
||||||
|
}
|
||||||
|
throw PolarPftpError.watchNotFound(timeout, vues: vues,
|
||||||
|
sansPsFtp: sansPsFtp, muettes: muettes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Les appareils vus pendant une fenêtre de recherche, appairés compris.
|
||||||
|
private func sessionsVues(_ listener: CBDeviceListenerImpl,
|
||||||
|
fenetre: Double) async throws -> [BleDeviceSession] {
|
||||||
|
try await withCheckedThrowingContinuation { suite in
|
||||||
|
var vues: [BleDeviceSession] = []
|
||||||
|
var rendu = false
|
||||||
|
let rendre = {
|
||||||
|
guard !rendu else { return }
|
||||||
|
rendu = true
|
||||||
|
suite.resume(returning: vues)
|
||||||
|
}
|
||||||
|
// ⚠️ PASSER L'UUID DU SERVICE — c'est ce qui trouve une montre DÉJÀ
|
||||||
|
// CONNECTÉE.
|
||||||
|
//
|
||||||
|
// Mesuré le 2026-08-31 : 4020 advertisements vus (HomePod, Furbo…),
|
||||||
|
// aucune Polar. Normal — **un appareil BLE connecté cesse
|
||||||
|
// d'émettre**. La Vantage est liée à l'iPhone par l'app Flow : elle
|
||||||
|
// est donc invisible au scan, par conception, et l'attendre était
|
||||||
|
// sans espoir.
|
||||||
|
//
|
||||||
|
// Avec un `uuids` non nil, `search()` emprunte une autre branche :
|
||||||
|
// `manager.retrieveConnectedPeripherals(withServices: uuids!)`, qui
|
||||||
|
// rend les périphériques déjà connectés au système exposant ce
|
||||||
|
// service, et leur fabrique une session. Passer `nil` — ce que
|
||||||
|
// faisait ce code — n'exécutait jamais cette branche.
|
||||||
|
listener.search([BlePsFtpClient.PSFTP_SERVICE],
|
||||||
|
identifiers: nil, fetchKnownDevices: true)
|
||||||
|
.sink(receiveCompletion: { [weak self] fin in
|
||||||
|
// Distinguer « le publisher a terminé » de « il n'a
|
||||||
|
// jamais rien dit » : dans le second cas, `flatMap`
|
||||||
|
// n'a pas été atteint, donc `monitorBleState()` n'a
|
||||||
|
// jamais émis `.poweredOn` — le scan n'a pas démarré.
|
||||||
|
if case .failure(let e) = fin {
|
||||||
|
self?.derniereErreurSdk = String(describing: e)
|
||||||
|
}
|
||||||
|
self?.publisherAParle = true
|
||||||
|
rendre()
|
||||||
|
},
|
||||||
|
receiveValue: { [weak self] session in
|
||||||
|
self?.publisherAParle = true
|
||||||
|
vues.append(session)
|
||||||
|
})
|
||||||
|
.store(in: &abonnements)
|
||||||
|
|
||||||
|
|
||||||
|
// La recherche ne se termine pas d'elle-même : on lui donne une
|
||||||
|
// fenêtre, puis on travaille avec ce qu'on a vu.
|
||||||
|
queue.asyncAfter(deadline: .now() + fenetre) { rendre() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ce qu'on peut dire d'un appareil vu, pour l'afficher.
|
||||||
|
static func etiquette(_ session: BleDeviceSession) -> String {
|
||||||
|
let c = session.advertisementContent
|
||||||
|
let nom = c.name.isEmpty ? "(sans nom)" : c.name
|
||||||
|
return c.polarDeviceId.isEmpty ? nom : "\(nom) [\(c.polarDeviceId)]"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vrai si l'appareil a une chance d'être une montre Polar.
|
||||||
|
///
|
||||||
|
/// ⚠️ Volontairement LARGE : mieux vaut tenter une session de trop que
|
||||||
|
/// d'exclure la montre et conclure qu'elle n'existe pas.
|
||||||
|
static func ressembleAPolar(_ session: BleDeviceSession) -> Bool {
|
||||||
|
let c = session.advertisementContent
|
||||||
|
if !c.polarDeviceId.isEmpty { return true }
|
||||||
|
if !c.polarDeviceType.isEmpty { return true }
|
||||||
|
let nom = c.name.lowercased()
|
||||||
|
return nom.contains("polar") || nom.contains("vantage") || nom.contains("grit")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attend qu'une condition devienne vraie, en la sondant. Rend faux au bout
|
||||||
|
/// du délai.
|
||||||
|
///
|
||||||
|
/// ⚠️ Conservé pour d'autres usages, mais NE PAS s'en servir pour l'état
|
||||||
|
/// BLE : `blePowered()` lit le manager, pas le sujet que `search()` écoute.
|
||||||
|
///
|
||||||
|
/// Sonder plutôt qu'écouter : l'état du SDK se lit (`blePowered()`), et
|
||||||
|
/// c'est justement cette lecture qui instancie le CBCentralManager. Le
|
||||||
|
/// premier appel réveille, les suivants observent.
|
||||||
|
static func attendre(seconds: Double, _ condition: @escaping () -> Bool) async -> Bool {
|
||||||
|
let echeance = Date().addingTimeInterval(seconds)
|
||||||
|
while Date() < echeance {
|
||||||
|
if condition() { return true }
|
||||||
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
}
|
||||||
|
return condition()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Court `operation` avec une échéance, et lève si elle est dépassée.
|
||||||
|
///
|
||||||
|
/// Le SDK Polar rend des `async` sans limite de temps : une montre qui ne
|
||||||
|
/// répond pas fige l'appel indéfiniment. Un envoi qui n'aboutit pas doit
|
||||||
|
/// dire pourquoi, pas tourner en silence.
|
||||||
|
static func avecEcheance<T: Sendable>(seconds: Double,
|
||||||
|
_ operation: @escaping @Sendable () async throws -> T)
|
||||||
|
async throws -> T {
|
||||||
|
try await withThrowingTaskGroup(of: T.self) { groupe in
|
||||||
|
groupe.addTask { try await operation() }
|
||||||
|
groupe.addTask {
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||||
|
throw PolarPftpError.echeanceDepassee(seconds)
|
||||||
|
}
|
||||||
|
guard let premier = try await groupe.next() else {
|
||||||
|
throw PolarPftpError.echeanceDepassee(seconds)
|
||||||
|
}
|
||||||
|
groupe.cancelAll()
|
||||||
|
return premier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func put(_ client: BlePsFtpClient, _ etape: PftpStep) async throws {
|
||||||
|
let entree = InputStream(data: etape.data)
|
||||||
|
// Même raison que pour waitPsFtpReady : une écriture qui n'aboutit pas
|
||||||
|
// doit échouer, pas figer l'app.
|
||||||
|
// `write` rend un flux de progression ; on le consomme jusqu'au bout,
|
||||||
|
// et l'absence d'erreur vaut acquittement — c'est l'équivalent BLE du
|
||||||
|
// `05 00 00` observé en USB.
|
||||||
|
try await Self.avecEcheance(seconds: 20) {
|
||||||
|
for try await _ in client.write(etape.header() as NSData, data: entree) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
/// Vérifie que le Bluetooth est utilisable, et dit pourquoi il ne l'est pas.
|
||||||
|
///
|
||||||
|
/// Ne scanne rien : elle instancie un `CBCentralManager` et lit son premier
|
||||||
|
/// état. C'est cette instanciation qui provoque la demande d'autorisation iOS
|
||||||
|
/// — et donc l'apparition de la ligne « Bluetooth » dans les réglages de l'app.
|
||||||
|
private final class SondeBluetooth: NSObject, CBCentralManagerDelegate {
|
||||||
|
private var manager: CBCentralManager?
|
||||||
|
private var suite: CheckedContinuation<CBManagerState, Never>?
|
||||||
|
private var repondu = false
|
||||||
|
private var vus = Set<UUID>()
|
||||||
|
|
||||||
|
func attendreEtatUtilisable(timeout: Double) async throws {
|
||||||
|
let etat = await withCheckedContinuation { (c: CheckedContinuation<CBManagerState, Never>) in
|
||||||
|
suite = c
|
||||||
|
// `showPowerAlert: false` : c'est nous qui expliquons, pas une
|
||||||
|
// alerte système au milieu d'un envoi.
|
||||||
|
manager = CBCentralManager(delegate: self, queue: nil,
|
||||||
|
options: [CBCentralManagerOptionShowPowerAlertKey: false])
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in
|
||||||
|
self?.repondre(self?.manager?.state ?? .unknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch etat {
|
||||||
|
case .poweredOn:
|
||||||
|
return
|
||||||
|
case .unauthorized:
|
||||||
|
throw PolarPftpError.bluetoothUnusable(
|
||||||
|
"coach n'a pas l'autorisation d'utiliser le Bluetooth. "
|
||||||
|
+ "Réglages → coach → activer Bluetooth.")
|
||||||
|
case .poweredOff:
|
||||||
|
throw PolarPftpError.bluetoothUnusable(
|
||||||
|
"le Bluetooth est désactivé sur l'iPhone.")
|
||||||
|
case .unsupported:
|
||||||
|
throw PolarPftpError.bluetoothUnusable(
|
||||||
|
"cet appareil ne prend pas en charge le Bluetooth LE.")
|
||||||
|
default:
|
||||||
|
throw PolarPftpError.bluetoothUnusable(
|
||||||
|
"le Bluetooth n'a pas répondu (état « \(etat.rawValue) »). "
|
||||||
|
+ "Réessayer ; si ça persiste, redémarrer l'app.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compte les appareils BLE qu'un scan CoreBluetooth NU voit, sans le SDK.
|
||||||
|
///
|
||||||
|
/// Sert à trancher un diagnostic, pas à travailler : si CoreBluetooth voit
|
||||||
|
/// des appareils et que le SDK n'en remonte aucun, le problème est dans
|
||||||
|
/// notre usage du SDK ; si les deux voient zéro, il est dans la radio ou
|
||||||
|
/// l'environnement. Sans cette mesure on ne peut que deviner — ce qui a
|
||||||
|
/// coûté trois allers-retours le 2026-08-31.
|
||||||
|
func compterAppareils(pendant: Double) async -> Int {
|
||||||
|
vus.removeAll()
|
||||||
|
manager?.scanForPeripherals(withServices: nil,
|
||||||
|
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(pendant * 1_000_000_000))
|
||||||
|
manager?.stopScan()
|
||||||
|
return vus.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
|
||||||
|
advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||||
|
vus.insert(peripheral.identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func repondre(_ etat: CBManagerState) {
|
||||||
|
guard !repondu else { return }
|
||||||
|
repondu = true
|
||||||
|
suite?.resume(returning: etat)
|
||||||
|
suite = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||||
|
// `.unknown` est l'état transitoire du démarrage : ne pas conclure
|
||||||
|
// dessus, le vrai état suit.
|
||||||
|
if central.state != .unknown { repondre(central.state) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extension CBDeviceListenerImpl {
|
||||||
|
/// Vrai dès que le listener PUBLIE l'état `.poweredOn` — c'est-à-dire dès
|
||||||
|
/// que `search()` pourra franchir son filtre.
|
||||||
|
///
|
||||||
|
/// `monitorBleState()` rend un `CurrentValueSubject`, donc l'abonnement
|
||||||
|
/// reçoit immédiatement la valeur courante (`.unknown` au départ) puis les
|
||||||
|
/// suivantes. On attend la première qui vaut `.poweredOn`.
|
||||||
|
/// Porte son échéance elle-même plutôt que d'être enveloppée : le listener
|
||||||
|
/// n'est pas `Sendable`, et le faire traverser une TaskGroup se heurterait
|
||||||
|
/// à la concurrence stricte de Swift 6.
|
||||||
|
func premierEtatPret(timeout: Double) async -> Bool {
|
||||||
|
await withCheckedContinuation { suite in
|
||||||
|
let boite = BoiteJeton()
|
||||||
|
boite.jeton = monitorBleState()
|
||||||
|
.sink(receiveCompletion: { _ in boite.rendre(false, suite) },
|
||||||
|
receiveValue: { etat in
|
||||||
|
if etat == .poweredOn { boite.rendre(true, suite) }
|
||||||
|
})
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) {
|
||||||
|
boite.rendre(false, suite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Garde l'abonnement en vie et garantit une reprise unique de la continuation
|
||||||
|
/// — la reprendre deux fois est un crash, et il y a ici deux chemins de sortie
|
||||||
|
/// concurrents : l'état publié et l'échéance.
|
||||||
|
private final class BoiteJeton: @unchecked Sendable {
|
||||||
|
var jeton: AnyCancellable?
|
||||||
|
private var rendu = false
|
||||||
|
private let verrou = NSLock()
|
||||||
|
|
||||||
|
func rendre(_ valeur: Bool, _ suite: CheckedContinuation<Bool, Never>) {
|
||||||
|
verrou.lock()
|
||||||
|
defer { verrou.unlock() }
|
||||||
|
guard !rendu else { return }
|
||||||
|
rendu = true
|
||||||
|
suite.resume(returning: valeur)
|
||||||
|
jeton?.cancel()
|
||||||
|
jeton = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,13 +40,43 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
public let pluginMethods: [CAPPluginMethod] = [
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "sendInterval", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "sendInterval", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "scheduledWorkouts", returnType: CAPPluginReturnPromise),
|
||||||
]
|
]
|
||||||
|
|
||||||
@objc func isAvailable(_ call: CAPPluginCall) {
|
@objc func isAvailable(_ call: CAPPluginCall) {
|
||||||
if #available(iOS 17.0, *) {
|
guard #available(iOS 17.0, *) else {
|
||||||
call.resolve(["available": true, "min_ios": "17.0"])
|
|
||||||
} else {
|
|
||||||
call.resolve(["available": false, "reason": "iOS 17+ required for WorkoutKit"])
|
call.resolve(["available": false, "reason": "iOS 17+ required for WorkoutKit"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// `available` disait « iOS 17+ », rien de plus : une autorisation
|
||||||
|
// révoquée ou jamais accordée rendait exactement la même réponse.
|
||||||
|
Task {
|
||||||
|
let state = await WorkoutScheduler.shared.authorizationState
|
||||||
|
call.resolve([
|
||||||
|
"available": true,
|
||||||
|
"min_ios": "17.0",
|
||||||
|
"supported": WorkoutScheduler.isSupported,
|
||||||
|
"authorization": Self.describe(state),
|
||||||
|
"authorized": state == .authorized,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ce que la montre a réellement en attente, interrogeable sans rien envoyer.
|
||||||
|
@objc func scheduledWorkouts(_ call: CAPPluginCall) {
|
||||||
|
guard #available(iOS 17.0, *) else {
|
||||||
|
call.reject("WorkoutKit requires iOS 17.0 or later")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
let state = await WorkoutScheduler.shared.authorizationState
|
||||||
|
let scheduled = await Self.scheduledSummaries()
|
||||||
|
call.resolve([
|
||||||
|
"authorization": Self.describe(state),
|
||||||
|
"authorized": state == .authorized,
|
||||||
|
"count": scheduled.count,
|
||||||
|
"scheduled": scheduled,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +161,7 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await sendCustomWorkout(
|
let scheduled = try await sendCustomWorkout(
|
||||||
activity: activity,
|
activity: activity,
|
||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
warmupMin: warmupMin,
|
warmupMin: warmupMin,
|
||||||
@@ -143,9 +173,12 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
"sent": true,
|
"sent": true,
|
||||||
"activityUsed": Int(activity.rawValue),
|
"activityUsed": Int(activity.rawValue),
|
||||||
"activityFallback": activity != requestedActivity,
|
"activityFallback": activity != requestedActivity,
|
||||||
|
// Ce que le scheduler liste APRÈS l'envoi : c'est la preuve,
|
||||||
|
// le reste n'est qu'une intention.
|
||||||
|
"scheduled": scheduled,
|
||||||
])
|
])
|
||||||
} catch {
|
} catch {
|
||||||
call.reject("Failed to send workout: \(error.localizedDescription)")
|
call.reject(error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,7 +232,7 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
stepsSpec: [(min: Double, hrZone: Int?, bpm: ClosedRange<Double>?, isWork: Bool, label: String?)],
|
stepsSpec: [(min: Double, hrZone: Int?, bpm: ClosedRange<Double>?, isWork: Bool, label: String?)],
|
||||||
repeats: Int,
|
repeats: Int,
|
||||||
cooldownMin: Double
|
cooldownMin: Double
|
||||||
) async throws {
|
) async throws -> [[String: Any]] {
|
||||||
|
|
||||||
// 1. Warmup — un WorkoutStep optionnel. Si warmupMin == 0, on passe
|
// 1. Warmup — un WorkoutStep optionnel. Si warmupMin == 0, on passe
|
||||||
// nil au CustomWorkout (la signature accepte WorkoutStep?). Passer
|
// nil au CustomWorkout (la signature accepte WorkoutStep?). Passer
|
||||||
@@ -273,11 +306,24 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
|
|
||||||
// 8. Demande d'autorisation explicite (idempotent — déclenche dialog
|
// 8. Demande d'autorisation explicite (idempotent — déclenche dialog
|
||||||
// iOS la 1re fois, return immédiat si déjà accordé).
|
// iOS la 1re fois, return immédiat si déjà accordé).
|
||||||
|
//
|
||||||
|
// ⚠️ L'échec ne se ravale plus. Il l'était jusqu'au 03/09/2026 : un
|
||||||
|
// NSLog, puis on continuait comme si de rien n'était. Une
|
||||||
|
// réinstallation de l'app remet l'autorisation à `notDetermined`
|
||||||
|
// (celle du 31/08, chantier CoachPolarBLE, tombe dans la fenêtre du
|
||||||
|
// silence de la montre — cf. GUIDE-MONTRE.md §5quater), et rien à
|
||||||
|
// l'écran n'aurait dit que plus aucune séance n'arrivait au poignet.
|
||||||
do {
|
do {
|
||||||
try await WorkoutScheduler.shared.requestAuthorization()
|
try await WorkoutScheduler.shared.requestAuthorization()
|
||||||
NSLog("[CoachWorkoutKit] requestAuthorization OK")
|
NSLog("[CoachWorkoutKit] requestAuthorization OK")
|
||||||
} catch {
|
} catch {
|
||||||
NSLog("[CoachWorkoutKit] requestAuthorization failed: \(error)")
|
NSLog("[CoachWorkoutKit] requestAuthorization failed: \(error)")
|
||||||
|
throw CoachWorkoutKitError.authorization(error.localizedDescription)
|
||||||
|
}
|
||||||
|
let authState = await WorkoutScheduler.shared.authorizationState
|
||||||
|
guard authState == .authorized else {
|
||||||
|
NSLog("[CoachWorkoutKit] authorizationState = %@", String(describing: authState))
|
||||||
|
throw CoachWorkoutKitError.notAuthorized(Self.describe(authState))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8bis. Efface les workouts déjà programmés par l'app avant d'en
|
// 8bis. Efface les workouts déjà programmés par l'app avant d'en
|
||||||
@@ -299,5 +345,67 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
)
|
)
|
||||||
try await WorkoutScheduler.shared.schedule(plan, at: comps)
|
try await WorkoutScheduler.shared.schedule(plan, at: comps)
|
||||||
NSLog("[CoachWorkoutKit] schedule OK at \(scheduleDate)")
|
NSLog("[CoachWorkoutKit] schedule OK at \(scheduleDate)")
|
||||||
|
|
||||||
|
// 10. Relecture : ce qui compte n'est pas que `schedule()` soit rentré
|
||||||
|
// sans erreur, c'est que la séance soit RÉELLEMENT dans la liste du
|
||||||
|
// scheduler. C'est la seule chose qui réponde, sans aller regarder
|
||||||
|
// la montre, à « est-ce que ma séance est programmée ? ».
|
||||||
|
// ⚠️ Une liste vide n'est PAS traitée comme un échec : le scheduler
|
||||||
|
// est asynchrone et rien chez Apple ne garantit qu'il ait publié la
|
||||||
|
// séance à l'instant où on le relit. Inventer une erreur à chaque
|
||||||
|
// envoi coûterait plus cher que le silence qu'on cherche. On rend
|
||||||
|
// ce qu'on voit, l'appelant le dit.
|
||||||
|
return await Self.scheduledSummaries()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Les séances programmées PAR CETTE APP, telles que le scheduler les rend.
|
||||||
|
@available(iOS 17.0, *)
|
||||||
|
private static func scheduledSummaries() async -> [[String: Any]] {
|
||||||
|
let plans = await WorkoutScheduler.shared.scheduledWorkouts
|
||||||
|
return plans.map { sw in
|
||||||
|
var out: [String: Any] = [
|
||||||
|
"date": ISO8601DateFormatter().string(
|
||||||
|
from: Calendar.current.date(from: sw.date) ?? Date()
|
||||||
|
),
|
||||||
|
"complete": sw.complete,
|
||||||
|
]
|
||||||
|
if case let .custom(custom) = sw.plan.workout {
|
||||||
|
// `displayName` est optionnel côté Apple : ne jamais poser un
|
||||||
|
// Optional dans le dictionnaire rendu au JS, il n'est pas
|
||||||
|
// sérialisable par le bridge Capacitor.
|
||||||
|
out["displayName"] = custom.displayName ?? ""
|
||||||
|
out["blocks"] = custom.blocks.count
|
||||||
|
out["steps"] = custom.blocks.reduce(0) { $0 + $1.steps.count }
|
||||||
|
out["iterations"] = custom.blocks.map { $0.iterations }
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 17.0, *)
|
||||||
|
private static func describe(_ state: WorkoutScheduler.AuthorizationState) -> String {
|
||||||
|
switch state {
|
||||||
|
case .authorized: return "authorized"
|
||||||
|
case .denied: return "denied"
|
||||||
|
case .notDetermined: return "notDetermined"
|
||||||
|
case .restricted: return "restricted"
|
||||||
|
@unknown default: return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erreurs qui doivent remonter jusqu'à l'écran, pas jusqu'au seul NSLog.
|
||||||
|
enum CoachWorkoutKitError: LocalizedError {
|
||||||
|
case authorization(String)
|
||||||
|
case notAuthorized(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .authorization(let detail):
|
||||||
|
return "Autorisation « Séances programmées » refusée par iOS : \(detail)"
|
||||||
|
case .notAuthorized(let state):
|
||||||
|
return "Autorisation « Séances programmées » absente (\(state)) — "
|
||||||
|
+ "Réglages iPhone → coach → Séances, puis renvoyer la séance."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ import Foundation
|
|||||||
import Capacitor
|
import Capacitor
|
||||||
import HealthKit
|
import HealthKit
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
// Pour l'extension `HKWorkout.workoutPlan` (iOS 17+), qui rend la composition
|
||||||
|
// du plan dont une séance est issue — ou nil si elle a été lancée à la main.
|
||||||
|
// Même garde que `CoachWorkoutKit.swift` : sur un SDK sans WorkoutKit, le
|
||||||
|
// fichier doit continuer à compiler, quitte à ne rien remonter.
|
||||||
|
#if canImport(WorkoutKit)
|
||||||
|
import WorkoutKit
|
||||||
|
#endif
|
||||||
|
|
||||||
@objc(CoachWorkoutObserverPlugin)
|
@objc(CoachWorkoutObserverPlugin)
|
||||||
public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
@@ -375,8 +382,66 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
}.resume()
|
}.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dit au serveur si la séance vient d'une **séance programmée** ou d'un
|
||||||
|
/// démarrage à la main dans l'app Exercice.
|
||||||
|
///
|
||||||
|
/// C'est la seule chose qui distingue « la montre s'est tue alors qu'elle
|
||||||
|
/// devait parler » de « la séance n'a jamais été celle qu'on avait
|
||||||
|
/// poussée » : `tools/watch_alert_check.py` compte les alertes attendues à
|
||||||
|
/// partir du plan ENVOYÉ, mais rien, jusqu'ici, ne disait ce qui avait été
|
||||||
|
/// LANCÉ (cf. `coach_sportif/docs/GUIDE-MONTRE.md` §5bis, « angle mort »).
|
||||||
|
/// Le nom HealthKit ne le dit pas : il vaut « Course extérieure » dans les
|
||||||
|
/// deux cas.
|
||||||
|
///
|
||||||
|
/// Même parti pris que `reportToRoutineIfStrength` : on n'envoie que des
|
||||||
|
/// faits bruts, l'interprétation vit côté serveur donc sans rebuild iOS.
|
||||||
|
private func reportPlanOrigin(_ workout: HKWorkout) {
|
||||||
|
#if canImport(WorkoutKit)
|
||||||
|
guard #available(iOS 17.0, *) else { return }
|
||||||
|
Task {
|
||||||
|
var fromPlan = false
|
||||||
|
var planName = ""
|
||||||
|
do {
|
||||||
|
if let plan = try await workout.workoutPlan {
|
||||||
|
fromPlan = true
|
||||||
|
if case let .custom(custom) = plan.workout {
|
||||||
|
planName = custom.displayName ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Une lecture qui échoue n'est PAS un « lancé à la main » :
|
||||||
|
// sans réponse, on n'envoie rien plutôt qu'un faux négatif.
|
||||||
|
NSLog("[CoachWorkoutObserver] workoutPlan illisible: %@", error.localizedDescription)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let url = URL(string: "https://coach.hypnotruck.ch/api/workout/plan-origin") else { return }
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie")
|
||||||
|
req.httpBody = try? JSONSerialization.data(withJSONObject: [
|
||||||
|
"uuid": workout.uuid.uuidString,
|
||||||
|
"start": ISO8601DateFormatter().string(from: workout.startDate),
|
||||||
|
"duration_min": workout.duration / 60,
|
||||||
|
"activity": Int(workout.workoutActivityType.rawValue),
|
||||||
|
"from_plan": fromPlan,
|
||||||
|
"plan_name": planName,
|
||||||
|
])
|
||||||
|
req.timeoutInterval = 15
|
||||||
|
URLSession.shared.dataTask(with: req) { _, _, error in
|
||||||
|
if let error = error {
|
||||||
|
NSLog("[CoachWorkoutObserver] plan-origin: %@", error.localizedDescription)
|
||||||
|
} else {
|
||||||
|
NSLog("[CoachWorkoutObserver] plan-origin envoyé (from_plan=%@)", fromPlan ? "true" : "false")
|
||||||
|
}
|
||||||
|
}.resume()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
private func scheduleNotification(for workout: HKWorkout) {
|
private func scheduleNotification(for workout: HKWorkout) {
|
||||||
reportToRoutineIfStrength(workout)
|
reportToRoutineIfStrength(workout)
|
||||||
|
reportPlanOrigin(workout)
|
||||||
let durationMin = Int(workout.duration / 60)
|
let durationMin = Int(workout.duration / 60)
|
||||||
var bodyParts: [String] = ["\(durationMin) min"]
|
var bodyParts: [String] = ["\(durationMin) min"]
|
||||||
if let kcal = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()), kcal > 0 {
|
if let kcal = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()), kcal > 0 {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
<false/>
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
|
<string>Coach Hypnotruck se connecte en Bluetooth à votre montre Polar pour y déposer la séance du jour, avec ses phases et ses zones cardiaques. La connexion ne sert qu'à cet envoi et aucune donnée n'est lue sur la montre.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Coach Hypnotruck utilise l'appareil photo pour scanner les codes-barres des aliments, photographier vos assiettes et photographier les étiquettes nutritionnelles. Ces images servent à identifier l'aliment et à estimer ses valeurs nutritionnelles, puis à alimenter votre journal alimentaire.</string>
|
<string>Coach Hypnotruck utilise l'appareil photo pour scanner les codes-barres des aliments, photographier vos assiettes et photographier les étiquettes nutritionnelles. Ces images servent à identifier l'aliment et à estimer ses valeurs nutritionnelles, puis à alimenter votre journal alimentaire.</string>
|
||||||
<key>NSHealthShareUsageDescription</key>
|
<key>NSHealthShareUsageDescription</key>
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`).
|
||||||
|
|||||||
@@ -165,21 +165,30 @@ final class LocationTracker: NSObject, ObservableObject {
|
|||||||
/// pas l'appeler du tout fait perdre **toute** la trace — le builder est
|
/// pas l'appeler du tout fait perdre **toute** la trace — le builder est
|
||||||
/// invalidé à sa libération.
|
/// invalidé à sa libération.
|
||||||
///
|
///
|
||||||
/// ⚠️ `workout` peut être `nil` : `finishWorkout()` réussit mais ne rend
|
/// ⚠️ **Une trace ne peut PAS être sauvegardée sans workout.** Une version
|
||||||
/// pas l'objet **quand la montre est verrouillée** (confirmé par un
|
/// antérieure de ce commentaire l'affirmait ; c'est faux, vérifié à la
|
||||||
/// ingénieur Apple). On sauvegarde alors la trace sans association plutôt
|
/// source le 21/08 : la signature est
|
||||||
/// que de la jeter — une route orpheline vaut mieux qu'une sortie sans
|
/// `finishRoute(with workout: HKWorkout, metadata:)` — non optionnelle —
|
||||||
/// parcours. Une route ne peut être associée qu'une fois, et jamais après
|
/// et Apple précise « You must have already saved this workout to the
|
||||||
/// coup.
|
/// 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.
|
||||||
///
|
///
|
||||||
/// Échoue aussi si aucune position n'a été insérée — cas normal d'une
|
/// 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.
|
/// séance en salle, à ne pas remonter comme une anomalie.
|
||||||
func finishRoute(with workout: HKWorkout?) async {
|
func finishRoute(with workout: HKWorkout) async {
|
||||||
await flushPendingRoute()
|
await flushPendingRoute()
|
||||||
guard let builder = routeBuilder else { return }
|
guard let builder = routeBuilder else { return }
|
||||||
self.routeBuilder = nil
|
self.routeBuilder = nil
|
||||||
|
|
||||||
guard filter.acceptedCount > 0 else {
|
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")
|
locationLog.info("aucune position retenue : pas de trace a clore")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -191,6 +200,19 @@ final class LocationTracker: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
// MARK: Écriture HealthKit
|
||||||
|
|
||||||
/// Écrit les points en attente. Par lots : une écriture par position
|
/// Écrit les points en attente. Par lots : une écriture par position
|
||||||
|
|||||||
@@ -309,10 +309,19 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
|||||||
LocationTracker.shared.stopTracking()
|
LocationTracker.shared.stopTracking()
|
||||||
try? await builder.endCollection(at: date)
|
try? await builder.endCollection(at: date)
|
||||||
// ⚠️ `finishWorkout()` rend `nil` SANS erreur quand la montre est
|
// ⚠️ `finishWorkout()` rend `nil` SANS erreur quand la montre est
|
||||||
// verrouillée : ce n'est pas un échec. On poursuit avec `nil`, la
|
// verrouillée : ce n'est pas un échec, la séance EST écrite dans
|
||||||
// trace sera sauvegardée sans association plutôt que perdue.
|
// HealthKit — seul l'objet manque. Comme `finishRoute(with:)`
|
||||||
|
// exige un `HKWorkout` non optionnel (vérifié dans la doc Apple),
|
||||||
|
// on va rechercher celle qui vient d'être sauvegardée.
|
||||||
let workout = try? await builder.finishWorkout()
|
let workout = try? await builder.finishWorkout()
|
||||||
await LocationTracker.shared.finishRoute(with: workout)
|
if let workout {
|
||||||
|
await LocationTracker.shared.finishRoute(with: workout)
|
||||||
|
} else if let recovered = await self.recentlySavedWorkout() {
|
||||||
|
workoutLog.info("workout recupere apres un finishWorkout() nil")
|
||||||
|
await LocationTracker.shared.finishRoute(with: recovered)
|
||||||
|
} else {
|
||||||
|
LocationTracker.shared.discardRoute()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.isRunning = false
|
self.isRunning = false
|
||||||
@@ -325,6 +334,46 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// La séance que HealthKit vient d'enregistrer, quand `finishWorkout()`
|
||||||
|
/// n'a rien rendu.
|
||||||
|
///
|
||||||
|
/// ⚠️ La fenêtre porte sur le **chevauchement**, pas sur la date de début :
|
||||||
|
/// sans `.strictStartDate`, un workout est retenu dès qu'il croise
|
||||||
|
/// l'intervalle. Filtrer sur son `startDate` raterait toute séance de plus
|
||||||
|
/// de quelques minutes — c'est le piège qui avait rendu muettes les
|
||||||
|
/// notifications de fin de séance côté iPhone.
|
||||||
|
///
|
||||||
|
/// Restreinte à ce que **cette app** a écrit (`HKSource.default()`) : sans
|
||||||
|
/// ça, une séance enregistrée en parallèle par l'app Exercice d'Apple
|
||||||
|
/// pourrait récupérer notre trace.
|
||||||
|
private func recentlySavedWorkout() async -> HKWorkout? {
|
||||||
|
let recent = HKQuery.predicateForSamples(
|
||||||
|
withStart: Date(timeIntervalSinceNow: -Self.recoveryWindow), end: nil)
|
||||||
|
// Type écrit en toutes lettres : `predicateForObjects(from:)` a cinq
|
||||||
|
// surcharges (HKSource, Set<HKSource>, HKWorkout, Set<HKSourceRevision>,
|
||||||
|
// Set<HKDevice>) et un `.default()` abrégé s'y résout mal.
|
||||||
|
let mine = HKQuery.predicateForObjects(from: HKSource.default())
|
||||||
|
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [recent, mine])
|
||||||
|
let newestFirst = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||||||
|
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
let query = HKSampleQuery(sampleType: .workoutType(),
|
||||||
|
predicate: predicate,
|
||||||
|
limit: 1,
|
||||||
|
sortDescriptors: [newestFirst]) { _, samples, error in
|
||||||
|
if let error {
|
||||||
|
workoutLog.error("recuperation du workout impossible : \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
continuation.resume(returning: samples?.first as? HKWorkout)
|
||||||
|
}
|
||||||
|
healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fenêtre de recherche du workout de repli. Large assez pour couvrir une
|
||||||
|
/// sauvegarde lente, courte assez pour ne pas ramasser la séance d'avant.
|
||||||
|
private static let recoveryWindow: TimeInterval = 5 * 60
|
||||||
|
|
||||||
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
|
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
|
||||||
didFailWithError error: Error) {
|
didFailWithError error: Error) {
|
||||||
let message = error.localizedDescription
|
let message = error.localizedDescription
|
||||||
|
|||||||
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
|
||||||
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user