Files
coach-ios/docs/polar-ble-runbook-mac.md
Sylvain Bettinelli eef76daa45 blePowered() lit le manager, search() lit le sujet : ce ne sont pas les mêmes
Le message n'avait pas bougé alors qu'il n'aurait plus dû pouvoir apparaître :
attendre `blePowered()` avait donc réussi, et le publisher se taisait quand même.
C'est ce qui a montré l'erreur.

`blePowered()` rend `manager.state == .poweredOn` — l'état de CoreBluetooth. Or
`search()` filtre sur `bleStateSubject`, alimenté uniquement par
`centralManagerDidUpdateState`. Le manager peut donc être allumé sans que le
délégué ait publié quoi que ce soit : le sujet reste à `.unknown`, le filtre
bloque, le publisher se tait. Attendre `blePowered()` ne prouvait rien sur ce que
`search()` allait voir — c'était la mauvaise sonde.

On attend désormais sur `monitorBleState()`, qui est public et qui EST la source
lue par search(). `blePowered()` garde un seul rôle : instancier la lazy pour
que le délégué puisse tourner.

L'attente porte son échéance elle-même plutôt que de passer par avecEcheance :
le listener n'est pas Sendable et le faire traverser une TaskGroup se heurterait
à la concurrence stricte de Swift 6. Et `BoiteJeton` garantit une reprise unique
de la continuation — deux chemins de sortie concurrents (l'état publié et
l'échéance), et reprendre deux fois est un crash, pas un avertissement.

82 tests au vert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:31:30 +00:00

258 lines
13 KiB
Markdown

# É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.
## Ce qui reste inconnu, et ne se lèvera qu'ici
1. **La V3 accepte-t-elle une connexion BLE tierce** alors qu'elle est appairée
à l'app Flow ? Toujours pas testé — on n'a pas encore atteint la montre.
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.
## 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.