L'instrumentation a tranché : le publisher de search() n'émettait RIEN — ni
valeur, ni complétion, ni erreur. Ce n'était donc pas « aucun appareil », c'était
« on n'a jamais cherché ».
CBDeviceListenerImpl.manager est une `lazy var` : le CBCentralManager n'existe
qu'au premier accès. 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 qui bloque. Le manager n'était
jamais créé, centralManagerDidUpdateState jamais appelé, le sujet jamais
alimenté : le filtre attendait un état que rien ne pouvait produire.
`blePowered()` — `return manager.state == .poweredOn` — est le seul accès public
exécuté immédiatement. On l'appelle donc pour instancier le manager, et on le
fait APRÈS s'être abonné : si bleStateSubject est un PassthroughSubject, un état
émis avant l'abonnement serait perdu et on retomberait sur le même silence. Cet
ordre est écrit dans le code, c'est exactement ce qu'une relecture inverserait.
Trois mesures ont conduit ici, aucune supposition : l'absence de ligne Bluetooth
dans les réglages de l'app, puis 43 appareils en scan nu contre 0 par le SDK,
puis le publisher muet. Chacune a éliminé une famille de causes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
189 lines
9.0 KiB
Markdown
189 lines
9.0 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.
|
|
|
|
**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.
|
|
|
|
## 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.
|