Sylvain corrige : il a réinitialisé la montre de son propre chef, la montre n'avait pas planté. Cette section affirmait « les redémarrages n'ont rien changé, il a FALLU une réinitialisation » — une inférence présentée comme une observation, et personne n'avait épuisé les gestes réversibles avant. Ce qui reste établi : après un essai BLE refusé, l'interface USB ne répondait plus dans la foulée — plus aucun /dev/cu.usbmodem*, system_profiler muet, même câble et même port qu'une heure plus tôt. Ce qui ne l'est pas, et qui est retiré : que ce soit durable, qu'une réinitialisation soit le remède, et que « le firmware dégrade son état ». Cette dernière formule extrapolait depuis une observation unique. Le plantage du 17/08 sur requête malformée, lui, reste établi. Si le cas se reproduit, la marche à suivre demande maintenant d'épuiser les gestes réversibles — débrancher, changer de port et de câble, redémarrer la montre, laisser reposer — et de noter lequel a marché. C'est la mesure qui manque au dossier. Conséquence sur une décision prise ce matin : le refus d'ajouter un bouton « Réessayer » s'appuyait sur ce « coût matériel avéré ». La prémisse ne tient plus telle quelle. Le bouton reste absent pour une autre raison — la voie BLE n'est pas viable, outiller la répétition d'un chemin qu'on n'emprunte pas serait à contre-emploi — et la section dit désormais que la décision revient à Sylvain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
24 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 pluginios/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 faitPolarBleApiImpl.scanPreFilterànil: accepte les appareils. La condition estif self.session(peripheral) == nil, let filter = self.scanPreFilter— sans filtre, leif letéchoue et le rejet est sauté.addClient()suffit à lancer le scan :scanningNeeded()rend vrai dès queclientCount != 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.
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 :
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
La V3 accepte-t-elle une connexion BLE tierce ?Non — mesuré le 31/08, voir ci-dessus.- 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.
- 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) :
- 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.
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) : « 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.