Files
coach-ios/docs/polar-ble-runbook-mac.md
Sylvain Bettinelli 753d7b248e CurrentValueSubject, pas Passthrough : il fallait réveiller AVANT, pas après
La lecture des sources tranche ce que trois essais n'avaient pas résolu, et
elle retourne mon raisonnement précédent.

`bleStateSubject` est un `CurrentValueSubject<BleState, Never>(.unknown)` : il
rejoue sa valeur à chaque abonnement. Être abonné au moment de l'émission
n'apporte donc rien — et le correctif intermédiaire, qui réveillait le manager
APRÈS s'être abonné « pour ne pas rater l'événement », était un raisonnement de
PassthroughSubject appliqué au mauvais type.

Pire, il exposait à un second piège : `centralManagerDidUpdateState` fait
`BleState(rawValue: self.manager.state.rawValue)`, soit un accès à la lazy var
DEPUIS le délégué. Réveillée au milieu d'un abonnement, la propriété peut se
réentrer avant la fin de sa propre initialisation.

Séquence corrigée : créer le listener, poser le filtre, sonder `blePowered()`
jusqu'à ce qu'il réponde vrai — hors de tout abonnement, le premier appel
instancie, les suivants observent — puis appeler search() une seule fois. Si
l'état n'est pas atteint en 10 s, on le dit au lieu de scanner dans le vide.

Le commentaire qui affirmait « bleStateSubject ne publie que sur changement » est
corrigé plutôt que laissé : il était faux, et un commentaire qui ment coûte plus
cher que pas de commentaire. L'abonnement unique reste, mais pour la vraie
raison — chaque abonnement relance le cycle addClient/removeClient du scanner, et
la découverte BLE demande plusieurs secondes ininterrompues.

82 tests au vert.

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

12 KiB

É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 :

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.

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.

Séquence correcte : créer le listener, poser scanPreFilter, réveiller la lazy en sondant blePowered() jusqu'à ce qu'elle réponde vrai — hors de tout abonnement — et seulement ensuite appeler 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.