Commit Graph

9 Commits

Author SHA1 Message Date
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
14133efba9 Quand le SDK ne voit rien, demander à CoreBluetooth ce que LUI voit
Autorisation Bluetooth accordée, alerte acceptée — et toujours zéro appareil.
Or un scan BLE en environnement normal voit toujours quelque chose : le
problème n'est donc pas la montre. Restait à savoir s'il venait de la radio ou
de notre usage du SDK, et rien dans le message ne permettait de trancher.

J'ai vérifié dans les sources ce qui pouvait faire taire le scan côté SDK, et
écarté deux hypothèses plutôt que de les supposer : `servicesToScanFor` est nil
chez eux aussi, et un `scanPreFilter` nil ACCEPTE les appareils découverts
(`if …, let filter = self.scanPreFilter` — la condition échoue, le rejet est
sauté). Ni l'un ni l'autre n'explique le silence.

Donc on mesure. Quand le SDK ne remonte aucune session, la sonde relance un scan
CoreBluetooth NU, sans le SDK, et le nombre part dans le message :

- 0 appareil en scan direct → la radio va bien mais ne voit rien : montre
  éteinte, hors de portée, ou connectée ailleurs de façon exclusive.
- N appareils en scan direct → la radio va bien, le problème est dans notre
  intégration du SDK, ni dans la montre ni dans l'iPhone.

Ce sont deux chantiers opposés, et deviner lequel a déjà coûté trois
allers-retours aujourd'hui. Deux tests verrouillent la distinction.

77 tests au vert sur tests-linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:01:28 +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
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
Sylvain Bettinelli
acbb2c6896 La montre enregistre enfin sa trace GPS
La cible watchOS ne contenait pas une ligne de localisation : ni
CLLocationManager, ni HKWorkoutRouteBuilder, et aucune clé NSLocation dans son
Info.plist. Tant que les séances partaient de l'app Exercice d'Apple, c'est
elle qui écrivait la trace et CoachHealthRoute la relisait après coup. Dès
qu'une séance démarre depuis CoachWatch, plus personne ne l'écrit : la sortie
n'aurait ni carte, ni parcours dans Santé.

Le filtrage vit dans `RouteFilter`, en Foundation pur, donc testable ici :
rejet des points imprécis (> 50 m, seuil de l'exemple Apple), des sauts
impossibles, des doublons et des points arrivés dans le désordre, puis cumul de
la distance (haversine) et du dénivelé.

Le dénivelé a demandé deux passes. L'hystérésis seule laissait passer 297 m de
D+ sur un parcours PLAT : une oscillation d'amplitude égale au seuil est
comptée à chaque alternance, ce qui est inhérent à tout seuil. D'où un lissage
préalable, qui annule le bruit alternant. Contrepartie assumée et testée : une
pointe franchie en quelques points est écrêtée, donc sous-comptée — sans
conséquence, le D+ qui fait foi restant celui, barométrique, qu'Apple écrit
dans les métadonnées de la séance.

Quatre pièges documentés, tous traités dans le code :
- `allowsBackgroundLocationUpdates` sans `UIBackgroundModes` = `location`
  TERMINE l'app. Un garde-fou vérifie le plist avant d'armer le drapeau.
- Ne jamais demander « Always » sur watchOS : Apple décrit ce prompt comme
  « mostly a placeholder » et le parcours comme un comportement indéfini.
- Interdit de relancer la localisation depuis l'arrière-plan : la pause garde
  le flux ouvert et ignore les points, au lieu de couper le manager.
- Le CPU tue le GPS — d'où l'insertion des points par lots et la trace
  d'affichage bornée à 1500 points.

Corrigé après lecture de la doc : `finishRoute` s'appelle APRÈS `finishWorkout`
et reçoit le workout pour s'y associer. J'avais écrit l'inverse, avec nil, ce
qui aurait perdu l'association à chaque sortie. Et `finishWorkout()` rend nil
sans erreur quand la montre est verrouillée : on sauvegarde alors la trace sans
association plutôt que de la jeter.

15 tests, dont 4 vérifiés rouges en désactivant le lissage. Les trois fichiers
sont déclarés dans la cible CoachWatch (pbxproj sauvegardé en .bak-outdoor) :
aucun « Add Files » à faire sur le Mac.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:37:24 +00:00
Sylvain Bettinelli
f2fa3cccb0 Le moteur d'intervalles, pour guider une séance dans notre app
WorkoutKit ne sait pas exécuter une séance structurée dans une app tierce :
son seul point d'exécution public est `WorkoutPlan.openInWorkoutApp()`, qui
ouvre l'app Exercice d'Apple. Guider les intervalles du plan coach au poignet
impose donc d'écrire la machine à états nous-mêmes.

`IntervalEngine` est volontairement pur — aucun HealthKit, aucun timer, aucune
horloge interne. Il répond à « où en sommes-nous ? » à partir du temps et de la
distance qu'on lui pousse. Le temps de référence est `elapsedTime` du builder,
qui exclut déjà les pauses : le moteur n'a rien à savoir de la pause ni de
l'auto-pause. Et comme il est une fonction de la suite des ticks, rejouer
celle-ci redonne le même déroulé — ce qui rend la reprise après crash triviale.

Le point délicat est la dérive. Quand un tick arrive en retard (app suspendue
poignet baissé, collecte irrégulière), l'étape suivante démarre à la frontière
THÉORIQUE de la précédente, jamais à l'instant du tick. Sinon chaque
répétition offre quelques secondes de rab et l'erreur s'accumule : sur un
9×(1'/1'), le décalage final se compte en dizaines de secondes. Pour la même
raison `update` boucle au lieu de tester une fois — un tick manqué peut
franchir plusieurs étapes courtes d'un coup.

17 tests, dont 5 vérifiés rouges en neutralisant les frontières théoriques.
Rien n'est encore câblé : le fichier n'est pas membre de la cible CoachWatch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 07:50:27 +00:00
Sylvain Bettinelli
4cff1d4603 Les widgets reçoivent la bande et les préréglages au lieu de les inventer
La couleur du score de forme était décidée ici, avec un barème périmé
(vert >= 75, jaune >= 50). Le serveur avait recalé le sien sur la
distribution réelle (65/55/40) parce que le score est borné à [25, 75] :
une bande haute à 75 est inatteignable. Mesuré sur 115 jours de
production, le vert n'est jamais sorti et le rouge couvrait 68 jours.
La correction n'avait pas atteint le binaire.

`FormeScore` porte désormais la bande servie par le serveur, et
`resolvedBand` ne sert que de repli — aligné sur les seuils serveur, pas
sur les anciens.

Le widget de saisie rapide enregistrait un café à 100 ml, volume qui ne
correspond à aucun préréglage : le serveur avait séparé l'expresso (60)
du mug (250) parce qu'un volume unique faussait le journal, et ces
préréglages sont personnalisables. Les boutons viennent maintenant du
snapshot ; `fallbackQuickDrinks` reprend les préréglages par défaut du
serveur.

15 tests ajoutés au banc d'essai Linux, qui compile réellement ces deux
fichiers : 25 tests verts. Le reste (SwiftUI, WidgetKit) n'est pas
compilable ici et n'a été que relu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:42:19 +00:00
Sylvain Bettinelli
1d2d7a0e18 Une toolchain Swift sur Linux, pour ne plus ecrire de dates a l'aveugle
J'ai ecrit `asOf(_:)` hier sans pouvoir le compiler. Swift 6.3 est desormais
installe sur la machine de dev (Debian 12 x86_64) et `tests-linux/` compile
et teste la logique PURE des widgets.

10 tests, dont 5 verifies rouges en neutralisant la peremption. Ils couvrent
la remise a zero, la conservation des objectifs et des zones, le report de la
seance du lendemain, et le passage a l'heure d'hiver du 25 octobre — ou un
`+86400` naif ferait basculer le widget une heure trop tot, la veille au soir.

Les sources du paquet sont des liens symboliques vers les vrais fichiers : le
test porte sur le code livre, pas sur une copie.

⚠️ Ne remplace PAS le build Xcode et ne le remplacera jamais : WidgetKit,
SwiftUI, HealthKit et WatchKit sont absents de Swift pour Linux. Ni l'app, ni
les widgets, ni les vues ne se compilent ici.
2026-08-18 07:42:59 +00:00