Commit Graph

12 Commits

Author SHA1 Message Date
Sylvain Bettinelli
adcf4088f3 La Vantage refuse la connexion tierce : la question de fond est tranchée
Cherchée au bon endroit, la montre EST atteinte : « 12 appareils vus, 1 portant
PsFTP ». Le service FEEE est exposé, fetchGattClient rend un BlePsFtpClient.
Tout le chemin fonctionne.

Mais waitPsFtpReady n'aboutit pas, et la montre affiche « connexion impossible »
pendant que les réglages iOS la disent connectée à Flow. Elle refuse la seconde
session. Le canal PsFTP de la V3 n'est donc pas partageable — cohérent avec
l'USB, où il fallait déjà fermer Flow.

C'est la réponse à la question ouverte depuis le 17/08, et elle est négative.
Elle valait d'être obtenue : elle ferme une hypothèse au lieu de la laisser
traîner.

Une dernière porte existe et n'a PAS été poussée : dissocier la montre de Flow
pour voir si elle accepte alors notre connexion. Délibérément non tenté —
sacrifier la synchronisation quotidienne, qui alimente coach en données de
séance, pour un confort d'envoi, n'a pas le bon rapport. C'est écrit pour que
personne ne s'y engage sans avoir mis les deux côtés dans la balance.

Noté aussi le geste de remise en état : après un refus, redémarrer la montre
libère son canal.

Le câble, lui, fonctionne intégralement et a écrit la séance du jour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:46:14 +00:00
Sylvain Bettinelli
3cc7d935ad La montre ne diffuse pas parce qu'elle est déjà connectée : la chercher ailleurs
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>
2026-08-31 13:40:12 +00:00
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
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
Sylvain Bettinelli
0f96122cca Arrêt du chantier BLE : l'approche « piloter le listener à la main » est la mauvaise
Cinq causes trouvées et corrigées aujourd'hui, chacune par une mesure — et le
publisher reste muet. Le manager du SDK existe pourtant : l'avertissement
CoreBluetooth sur le restore identifier ne peut apparaître que s'il a été
instancié.

Ce que ça enseigne compte plus que la sixième hypothèse : instancier
CBDeviceListenerImpl soi-même revient à réimplémenter ce que PolarBleApiImpl
fait, sans en voir le détail. On avance d'un cran par essai, et chaque essai
coûte à Sylvain un cycle Xcode complet. Continuer à pousser des correctifs sur
des mécanismes internes que je ne peux pas exécuter, c'est exactement
l'extrapolation que ce projet s'interdit.

Le runbook porte donc les cinq causes déjà écartées — pour que personne ne les
revérifie — et la piste qui me paraît juste : passer par
PolarBleApiDefaultImpl.polarImplementation, le chemin supporté, avec son point
bloquant énoncé (le listener y est privé ; reste à voir si une API publique
donne accès à la session ou au client PsFTP, faute de quoi la voie est fermée
elle aussi).

Rien du travail serveur n'est perdu : le câble fonctionne intégralement, et
c'est lui qui a écrit la séance du jour sur la montre.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:25:52 +00:00
Sylvain Bettinelli
94b1f6243d Sans filtre, on ouvrait une session sur les 43 appareils BLE du voisinage
Les traces le disent en clair : le SDK tentait de discuter avec l'Apple Watch.

    API MISUSE: <CBPeripheral … name = Apple Watch de Sylvain,
    state = disconnected> can only accept commands while in the connected state

Sans `scanPreFilter`, le listener remonte TOUS les appareils BLE alentour — 43
mesurés ce matin. Le code ouvrait une session sur chacun à tour de rôle pour lui
demander s'il portait PsFTP, avec 12 s d'échéance par appareil. D'où l'envoi qui
tournait sans fin, et ces erreurs sur un appareil qui n'a rien à voir.

Le SDK officiel pose exactement ce filtre — `deviceFilter` dans
`PolarBleApiImpl` — et je ne l'avais pas repris. Le voilà : `polarDeviceId` non
vide, avec un repli sur le nom quand l'identifiant n'est pas encore décodé au
moment du filtrage.

C'est le pendant de la découverte précédente : le manager lazy empêchait de
voir quoi que ce soit, et une fois réveillé, l'absence de filtre faisait tout
voir. Les deux se cachaient l'un l'autre.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:20:05 +00:00
Sylvain Bettinelli
1389edb1f7 Cause racine : le CBCentralManager du SDK est lazy, et search() ne le réveille jamais
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>
2026-08-31 13:11:20 +00:00
Sylvain Bettinelli
3de149591e Le scan du SDK ne démarre pas, et le message le dira au lieu d'accuser la montre
43 appareils vus par un scan CoreBluetooth nu, 0 remonté par le SDK Polar. La
radio, l'autorisation et la montre sont hors de cause — c'est notre usage du SDK.

Trois suspects écartés sur les sources plutôt que supposés, et écrits dans le
runbook pour que personne ne les revérifie : servicesToScanFor à nil est aussi
ce que fait PolarBleApiImpl ; un scanPreFilter nil ACCEPTE les appareils (le
`if let` échoue, le rejet est sauté) ; et addClient() suffit à lancer le scan,
scanningNeeded() rendant vrai dès que clientCount != 0.

Reste une hypothèse : search() commence par
`monitorBleState().filter { $0 == .poweredOn }`. Si cet état n'arrive jamais, le
publisher ne dit RIEN — ni valeur, ni complétion, ni erreur — et le timeout
conclut « aucun appareil » alors qu'on n'a jamais cherché. Ce sont deux
diagnostics opposés que le même message couvrait.

L'instrumentation note donc si le publisher a parlé, et relaie une éventuelle
erreur du SDK telle quelle. Trois messages distincts en sortent, trois tests les
verrouillent.

Piste notée pour la reprise : le SDK pose powerStateObserver et
deviceSessionStateObserver sur son listener et passe identifier: 0 ; nous ne
posons aucun observateur et passons 1.

80 tests au vert sur tests-linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:06:15 +00:00
Sylvain Bettinelli
6deb61e86e iOS n'avait jamais demandé l'autorisation Bluetooth, et le scan attendait un état qui n'arrivait pas
« aucun appareil Bluetooth détecté » après 30 s de scan. La cause n'était pas la
montre : Réglages → coach ne contenait même pas de ligne Bluetooth. L'alerte
d'autorisation n'avait jamais été présentée.

La raison est dans le SDK. `CBDeviceListenerImpl.search()` commence par
`monitorBleState().filter { $0 == .poweredOn }` — tant que cet état n'arrive
pas, le flux n'émet rien et le scan ne démarre jamais. Or l'état ne peut venir
que d'un CBCentralManager, et c'est sa création qui déclenche la demande
d'autorisation. Aucun manager n'existant, on attendait un signal que rien ne
pouvait produire, puis on concluait « aucun appareil » au timeout. Avoir la clé
NSBluetoothAlwaysUsageDescription dans Info.plist ne suffit pas : elle fournit
le texte de l'alerte, elle ne la provoque pas.

`SondeBluetooth` crée donc le manager, attend son premier état non-`.unknown`
(l'état transitoire du démarrage, sur lequel il ne faut pas conclure), et
traduit ce qu'il dit : autorisation refusée, Bluetooth éteint, matériel absent.
Le cas d'erreur est distinct de watchNotFound — on n'a même pas pu chercher, et
parler de la montre envoyait chercher au mauvais endroit. Deux tests verrouillent
cette distinction.

Au premier lancement après ce correctif, iOS présentera l'alerte : il faut
l'accepter. Ensuite seulement on saura si la Vantage accepte une connexion
tierce, qui reste la question de fond.

75 tests au vert sur tests-linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 12:57:02 +00:00
Sylvain Bettinelli
179528d563 Trois causes opposées disaient la même chose : le premier essai BLE n'a rien appris
Sylvain a lancé l'envoi Bluetooth et obtenu « aucune montre Polar exposant
PsFTP ». Ce message ne permet pas de conclure : il couvre à la fois « aucun
appareil vu » (Bluetooth éteint, autorisation refusée à l'app, montre hors de
portée) et « la montre est là mais ne répond pas » (canal déjà pris par Polar
Flow). Ce sont des causes opposées et des gestes différents. Un essai qui
n'apprend rien est un essai perdu, et celui-là demandait de brancher une montre.

L'erreur porte désormais ce qui a été observé — appareils vus, combien sans le
service FEEE, combien avec mais muets — et rend trois messages distincts, chacun
nommant le geste correspondant. Quatre tests verrouillent la distinction, dont
celui qui interdit d'accuser Polar Flow quand rien n'a été vu : ce serait envoyer
sur une fausse piste.

Le scan devient aussi répétitif, par fenêtres de 4 s jusqu'au timeout, au lieu
d'une passe unique de 10 s. CoreBluetooth peut n'être pas encore poweredOn au
premier appel : le scan ne démarre alors jamais et une fenêtre unique conclut à
tort qu'aucun appareil n'existe. C'est une cause plausible du premier échec, et
elle n'était pas couverte.

73 tests au vert sur tests-linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 12:47:28 +00:00
Sylvain Bettinelli
0110368e17 L'heure de l'objectif est une question ouverte, pas un réglage à deviner
Sylvain ne sait pas encore à quelle heure il court, et j'allais lui faire
choisir une valeur comme si elle était contrainte. Elle ne l'est peut-être pas.

Ce qui est établi : le dossier <HHMMSS> et le start_time du fichier doivent
concorder, et les outils les produisent ensemble — rien à accorder à la main.

Ce qui ne l'est pas : la montre conditionne-t-elle l'affichage de l'objectif à
cette heure, ou le propose-t-elle toute la journée ? Le test du 17/08 ne l'a pas
mesuré. Écrit comme observation à faire au premier envoi plutôt que tranché au
jugé, avec les deux conséquences selon la réponse — dont aucune n'est bloquante.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 07:17:45 +00:00
Sylvain Bettinelli
184c08a447 La séance part sur la Polar en Bluetooth, et refuse de partir mal formée
Contrainte posée aujourd'hui : coach → la montre, sans TrainingPeaks, sans
Polar Flow, sans Intervals.icu. Toutes les autres voies passent par un tiers ;
celle-ci est la seule qui reste, et elle était déjà prouvée en USB le 17/08.
Manquait le transport qui la rende utilisable — brancher la montre au Mac cinq
minutes avant de sortir n'est pas un usage.

`CoachPolarBLE` ne fait que transporter. Les octets viennent du serveur
(GET /api/plan/polar-target), produits par un encodeur verrouillé octet pour
octet contre des fichiers relus sur la montre. Rien n'est réencodé ici : le
faire perdrait la seule garantie que le firmware acceptera le fichier.

La chaîne SDK a été vérifiée sur les sources avant d'écrire une ligne, et elle
est entièrement publique — CBDeviceListenerImpl, search, openSessionDirect,
fetchGattClient(PSFTP_SERVICE), waitPsFtpReady, write(header:data:). Ni fork,
ni symbole interne.

Le morceau qui compte vraiment est ailleurs. `PolarPftpStep` est séparé du
plugin et n'importe que Foundation, pour être exécuté sur Linux : il porte
l'en-tête PbPFtpOperation et les deux règles qui décident si une requête part.
Le 17/08, un PUT de 174 octets vers un chemin terminé par « / » a bloqué la
montre — écran noir, appui long sur OK pour la récupérer. Le firmware ne refuse
pas, il s'effondre, et le Bluetooth utilise le même protocole. Ces règles ne
peuvent donc pas être vérifiées « à la relecture » : 12 tests les exécutent,
dont celui qui interdit de reporter ici le cadrage série [0x05, taille, taille]
de la version USB — en BLE c'est le SDK qui cadre, l'ajouter produirait
précisément une requête malformée.

L'en-tête produit par Swift a été comparé à celui de polar_ftp.py sur trois
chemins, dont un de 205 caractères pour le varint sur deux octets : mêmes
octets. 69 tests au vert sur tests-linux.

Un mode dryRun construit et décrit chaque requête sans rien émettre — le
--dry-run qui manquait le jour du plantage. Le runbook Mac dit de s'en servir au
premier essai, et liste les trois inconnues qui ne se lèveront que montre en
main, la première étant : la V3 accepte-t-elle une connexion BLE tierce pendant
qu'elle est appairée à Flow ?

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