4020 advertisements vus — HomePod, Furbo, des sans-nom — et aucune Polar. Le scan marchait donc parfaitement depuis le correctif précédent. C'est la montre qui ne s'annonce pas. Et c'est normal : un appareil BLE connecté cesse d'émettre. La Vantage est liée à l'iPhone par l'app Flow, donc invisible au scan par conception. Tous les correctifs de scan qui ont précédé ne pouvaient rien y changer — on cherchait dans le seul endroit où elle ne pouvait pas être. `search()` prévoit exactement ce cas, mais seulement si on lui passe des UUID : `manager.retrieveConnectedPeripherals(withServices: uuids!)` rend les périphériques DÉJÀ connectés exposant le service, et leur fabrique une session. Je passais `nil`, donc cette branche n'était jamais empruntée. Au passage, déduplication des sessions : AllowDuplicates est armé côté SDK, d'où les 4020 pour une poignée d'appareils réels — sans quoi on ouvrirait quarante fois la même session. Ce que la journée aura montré : chaque message d'erreur qui affirmait une cause unique a fait perdre du temps, et chaque message qui RAPPORTAIT ce qu'il avait vu a fait avancer. La liste des appareils vus valait tous les raisonnements. 84 tests au vert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
282 lines
14 KiB
Markdown
282 lines
14 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.
|
|
|
|
## 🔑 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.
|
|
|
|
## 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.
|