Compare commits

...

28 Commits

Author SHA1 Message Date
Sylvain Bettinelli
3d939b8c3c Le build de ce soir tient sur une page, et l'import ne peut plus surprendre
Sylvain build ce soir en rentrant : docs/watch-plan-origin-runbook-mac.md porte
les gestes — les deux fichiers touchés sont déjà dans la target App, donc aucun
Target Membership ni framework à ajouter, et WorkoutKit était déjà lié par
CoachWorkoutKit.swift. Le runbook dit aussi quoi vérifier sans courir (le
message d'envoi doit NOMMER la séance programmée, ou dire l'autorisation
manquante) et comment lire l'origine de la séance après coup.

`import WorkoutKit` passe sous `#if canImport`, comme dans CoachWorkoutKit, et
reportPlanOrigin avec lui : ce code n'ayant jamais vu de compilateur, autant que
l'absence du framework le rende inerte plutôt qu'incompilable. Les trois erreurs
les plus probables sont listées avec leur cause dans le runbook, pour ne pas
avoir à rouvrir la doc DocC devant Xcode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 07:16:50 +00:00
Sylvain Bettinelli
9903424235 Le plugin affirmait l'envoi sans jamais relire ce que la montre avait reçu
Deux épisodes de « la montre ne dit plus rien » ont buté sur la même absence :
rien, côté app, ne distinguait « iOS a accepté l'appel » de « la séance est au
poignet ». Détail du dossier et chronologie dans coach_sportif,
docs/GUIDE-MONTRE.md §5quater et §5quinquies.

CoachWorkoutKit :
- requestAuthorization() n'avale plus son échec. Il était consigné dans un NSLog
  et l'envoi continuait : une réinstallation de l'app remet l'autorisation à
  notDetermined — celle du 31/08, chantier CoachPolarBLE, tombe dans la fenêtre
  du silence — et l'écran affichait « Envoyé » alors que rien ne partait.
- authorizationState est lu et rendu au JS, par isAvailable() comme par la
  nouvelle méthode scheduledWorkouts(). isAvailable() ne répondait jusqu'ici
  qu'« iOS 17+ » : une autorisation révoquée rendait la même réponse.
- schedule() est suivi d'une relecture de WorkoutScheduler.scheduledWorkouts, et
  sendInterval rend ce qu'elle liste (nom, date, blocs, itérations). Une liste
  vide n'est PAS traitée comme un échec : le scheduler publie de façon
  asynchrone et Apple ne garantit aucun délai — inventer une erreur à chaque
  envoi coûterait plus cher que le silence qu'on cherche.

CoachWorkoutObserver : à chaque fin de séance, HKWorkout.workoutPlan dit si elle
vient d'une séance programmée. Le fait part vers /api/workout/plan-origin, sur
le modèle de reportToRoutineIfStrength — faits bruts ici, interprétation côté
serveur, donc ajustable sans rebuild. Une lecture en échec n'envoie rien plutôt
qu'un faux « lancée à la main » : côté serveur, l'absence s'affiche « inconnu ».

⚠️ Rien de tout cela ne se compile ici : WorkoutKit et HealthKit sont absents de
Swift pour Linux, et tests-linux ne couvre que ce qui n'importe que Foundation.
À builder sur le Mac mini. Les signatures ont été vérifiées une par une dans la
doc DocC — authorizationState (get async, 4 cas), scheduledWorkouts,
ScheduledWorkoutPlan.date en DateComponents, WorkoutPlan.workout,
CustomWorkout.displayName optionnel, HKWorkout.workoutPlan (get async throws).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 06:24:08 +00:00
Sylvain Bettinelli
0a721c249b La réinitialisation du 31/08 était un choix, pas une nécessité constatée
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>
2026-09-01 10:42:50 +00:00
Sylvain Bettinelli
2a4274b1ad Déconnecter Flow automatiquement est impossible, et le mode avion est le raccourci
Question de Sylvain : déconnecter Polar Flow avant chaque envoi, sans geste
manuel. La réponse est non, et elle vient de la doc Apple plutôt que de mon
souvenir. 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 rompt
celle d'une autre app, et une app ne peut pas davantage couper le Bluetooth
système. C'est une garantie d'isolation entre apps, pas une lacune de notre code.

Ce qui existe en revanche, et que le manuel Polar documente : le mode avion des
Réglages rapides de la Vantage V3, qui « coupe toute communication sans fil » —
donc Flow. ON puis OFF est plus court qu'un redémarrage et rouvre la même
fenêtre. Noté comme hypothèse cohérente avec les envois réussis du 01/09, pas
comme méthode établie : la course n'a pas été reproduite.

Écrit aussi pourquoi l'UI n'aura pas de bouton « Réessayer », alors qu'il serait
trivial : chaque session PsFTP refusée a un coût matériel avéré — celle du 31/08
a désactivé l'USB de la montre jusqu'à une réinitialisation. Un bouton qui
banalise la répétition transformerait un risque connu en réflexe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 09:34:13 +00:00
Sylvain Bettinelli
422b6ac3e6 « Fermer l'app Polar Flow » était un conseil faux, et mesuré tel dès le 31/08
Sylvain enchaîne un troisième envoi et reçoit « le canal est probablement
occupé » — notre propre message, déclenché par watchNotFound avec des montres
muettes : le service PsFTP est là, il ne répond pas.

La chronologie de ses trois envois explique tout. Le premier réussit, montre
fraîchement réinitialisée donc pas encore liée à Flow. Le deuxième renvoie 104
DIRECTORY_EXISTS, ce qui prouve que le canal était ENCORE libre. Le troisième
échoue : Flow a repris la main entre-temps. Le canal n'est donc pas ouvert ou
fermé en général — il y a une fenêtre, qui se referme dès que Flow se
reconnecte.

Le message conseillait de fermer complètement l'app Polar Flow. C'était faux, et
la mesure du 31/08 le disait déjà : 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. Le message nomme maintenant
le geste qui libère réellement le canal — redémarrer la montre — précise que
fermer l'app n'y change rien, et rappelle que le câble est le chemin fiable.

Corrigé au passage le message des appareils sans PsFTP, qui présentait encore la
question comme « l'inconnue restante » alors qu'elle a été tranchée le 31/08 :
la montre expose bien PsFTP quand elle est connectée. Un test interdit désormais
de la rouvrir dans l'UI.

88 tests Linux verts, dont deux nouveaux qui verrouillent l'absence du conseil
inutile et la présence du geste utile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 08:30:48 +00:00
Sylvain Bettinelli
7d68605e83 « errorcode 104 » n'était pas un refus : mkdir sur un dossier déjà là
Sylvain a rapporté un errorcode 104 en testant l'envoi. Le code vient du
protocole PFTP de Polar, et pftp_error.proto du SDK officiel le nomme :
104 = DIRECTORY_EXISTS. Ce n'est donc pas un refus de la montre — la session BLE
s'était parfaitement ouverte, le transport fonctionnait, et c'est notre séquence
de mkdir qui n'était pas idempotente.

L'hypothèse fautive était écrite noir sur blanc dans parseSteps : « aucun des
deux n'existe d'avance pour une date neuve ». Vrai d'une date neuve, faux dès le
second envoi vers la même date — exactement ce que Sylvain venait de faire après
avoir réussi un premier envoi.

Un mkdir qui bute sur 104 est désormais considéré comme satisfait, à la manière
d'un mkdir -p : le dossier est là, c'est tout ce qu'on lui demandait. Strictement
limité à la création de dossier — un put de fichier n'est jamais avalé, 105
FILE_EXISTS signifierait que l'objectif est déjà écrit et l'appelant doit le
savoir.

Le renvoi n'est pas passé sous silence pour autant : ecrire() rend un drapeau
dossierPreexistant, le plugin le résout en alreadyExisted, et l'UI de coach
affiche « Un objectif existait déjà à cette date. Redémarrer la montre pour que
le nouveau s'affiche. » C'est la contrepartie de l'index en cache constaté le
31/08 — sans cet avertissement, l'envoi annoncerait un succès que la montre ne
montrerait pas.

Les codes PFTP sont définis dans PolarPftpStep.swift, donc couverts par les
tests Linux : 87 tests verts, dont 3 nouveaux qui vérifient que 104 est
satisfaisant pour un mkdir et que 103, 105, 106 et 108 restent des échecs.

⚠️ CoachPolarBLE.swift n'est pas compilable ici — Capacitor et le SDK Polar
n'existent pas sur Linux. La modification du plugin est à vérifier au prochain
build Xcode ; la logique des codes, elle, est testée.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 08:25:26 +00:00
Sylvain Bettinelli
474afce348 Le BLE marche sur montre réinitialisée, et ANCS ne portera jamais un .BPB
Deux apports de Sylvain le 01/09, consignés avant qu'ils se perdent.

Premier : après la réinitialisation complète imposée par l'incident USB, l'envoi
Bluetooth a abouti. Il juge lui-même que ce n'est pas viable. Cela confirme le
diagnostic du 31/08 au lieu de l'infirmer — le refus ne venait pas d'une
interdiction des apps tierces mais de l'occupation du canal PsFTP par Flow.
Montre non encore liée, canal libre, session ouverte. Le prix d'accès au BLE
reste donc « ne pas avoir Flow », et ce prix a augmenté ce matin : la Polar étant
devenue la montre de sport, Flow est le seul canal qui fait entrer les séances
dans HealthKit puis dans coach.

Second : sa question sur le protocole des notifications iOS. L'intuition est
bonne — elles arrivent à la montre pendant que Flow est connecté — mais la spec
ANCS d'Apple, lue à la source, ferme la piste pour un transfert de séance. Les
rôles y sont inversés par rapport à PsFTP : l'iPhone est le serveur
(Notification Provider), la montre est cliente (Notification Consumer), d'où
l'absence de conflit avec Flow. Et le service n'expose que trois
caractéristiques — Notification Source, Control Point, Data Source — sans aucun
mécanisme de transfert de binaire ou de payload applicatif.

Ce qu'ANCS permet en revanche, et qui est noté comme piste : afficher du texte au
poignet sans câble, sans dissocier et sans toucher à Flow, via une notification
locale iOS. Le canal est déjà en place côté projet — @capacitor/local-notifications
installé, local-notifications.js chargé par _layout.html — donc sans build Xcode.
Écrit avec ses limites : pas d'objectif structuré, pas d'alerte de zone, pas de
guidage par étape. Un pense-bête, pas un remplacement du .BPB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 08:21:00 +00:00
Sylvain Bettinelli
5fc19bf095 L'essai BLE a désactivé l'USB de la montre : il a fallu une réinitialisation
Après la connexion Bluetooth refusée par la Vantage (« connexion impossible » à
l'écran), son interface USB a cessé d'exister — plus aucun /dev/cu.usbmodem*,
system_profiler muet, alors que la montre chargeait et que c'était le même
câble, le même port et la même montre qu'une heure plus tôt. Les redémarrages
n'y ont rien fait. Seule une réinitialisation l'a récupérée.

Ce n'est donc pas une hypothèse : une tentative de session PsFTP refusée peut
désactiver durablement l'interface USB de cette montre. Le firmware ne se
contente pas de refuser, il dégrade son état — cohérent avec le plantage du
17/08 sur requête malformée.

Écrit en tête du runbook avec la règle qui en découle : ne pas retenter d'essai
BLE sans nécessité, jamais juste avant une sortie, et écrire la séance par câble
AVANT tout essai. Le repli n'est pas garanti disponible après coup.

C'est le coût réel de la journée sur le matériel, et il devait être consigné
avant tout le reste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 16:11:34 +00:00
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
60dd22eaeb Mon filtre rejetait peut-être la montre, et mon message accusait le scan
Le dépôt était bien à jour : c'est moi qui avais mal lu mon propre code. Le
message incriminé EST atteignable par la nouvelle version — l'état Bluetooth est
publié, on passe au scan, et le publisher se tait quand même.

Deux erreurs à moi, pas une du SDK.

D'abord le message. Le publisher de `search()` reste muet dans DEUX cas : le
scan n'a pas démarré, ou il tourne sans rien découvrir — `scanSubject` n'émet
que sur découverte, et un `prepend` de liste vide n'émet pas. Affirmer « le scan
n'a pas démarré » a fait chercher au mauvais endroit pendant trois itérations.
Le message énonce désormais les deux possibilités, et le test qui verrouillait
l'ancienne formulation vérifie maintenant qu'on n'affirme rien de trop.

Ensuite le filtre. `scanPreFilter` rejetait tout ce qui n'avait ni polarDeviceId
ni « polar » dans le nom. Si la Vantage ne s'annonce pas ainsi, c'est NOUS qui
l'écartions, aucune session n'était créée, et le silence qui suivait était
interprété comme une panne du SDK. On laisse donc tout remonter et on trie
après : un test de ressemblance volontairement large (polarDeviceId,
polarDeviceType, ou nom contenant polar/vantage/grit), et on n'ouvre de session
que sur les candidats — ouvrir 43 sessions coûtait des minutes.

Et si rien ne ressemble à un Polar, on RAPPORTE ce qu'on a vu : nombre et noms
des huit premiers. C'est la seule façon de savoir sous quel nom la montre
s'annonce, ou de constater qu'elle ne s'annonce pas du tout — auquel cas
l'hypothèse du lien exclusif avec Polar Flow devient la bonne.

84 tests au vert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:36:28 +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
01607ed75c Les fenêtres de scan se privaient de l'unique émission d'état
Le manager lazy était bien réveillé — l'avertissement CoreBluetooth sur le
restore identifier le prouve, il ne peut apparaître que si le manager existe.
Et pourtant le publisher restait muet.

La cause est mon découpage en cycles de 4 s. `bleStateSubject` ne publie que sur
CHANGEMENT d'état : le premier cycle réveillait le manager et recevait
`.poweredOn`, mais aux cycles suivants l'état ne changeait plus, donc plus rien
n'était émis. Chaque nouvel abonnement attendait une transition qui avait déjà
eu lieu.

Un seul abonnement, de la durée du timeout, supprime le problème : on reçoit
l'émission là où elle se produit, et le scan tourne sans être relancé. La boucle
d'ouverture de session garde une marge au-delà du scan — couper à la seconde du
timeout ferait échouer la seule montre trouvée.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:23:31 +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
81582ce969 Une attente sans fin ne dit rien : échéances sur les appels du SDK
L'envoi tournait sans jamais rendre la main, ni message ni erreur. Cause : les
`async` du SDK Polar n'ont aucune limite de temps, et `waitPsFtpReady` attend
indéfiniment une montre qui ne finit pas sa négociation — canal déjà pris, écran
éteint, appairage en cours.

Une attente sans fin est pire qu'un échec : elle n'apprend rien, et c'est
exactement ce qu'on cherche à éviter depuis ce matin. `waitPsFtpReady` a
désormais 12 s, chaque écriture 20 s, et un dépassement compte comme une montre
muette — c'est ce qu'elle est. Le message dit quoi faire : réveiller l'écran,
rapprocher, fermer Flow.

Le fait qu'on soit arrivé jusqu'à ce blocage est en soi une information : avant
le réveil du manager lazy, on n'atteignait même pas la phase de connexion.

82 tests au vert sur tests-linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:17:08 +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
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
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
Sylvain Bettinelli
a44fa0046a Cadrage des vues d'entraînement au choix, et point d'étape du 21/08
Le chantier « écrans configurables façon WorkOutDoors » est cadré, rien n'est
codé. docs/watch-training-screens.md fixe l'analyse pour qu'une session
ultérieure n'ait pas à la refaire.

Ce qui a été établi :

- LiveWorkoutView a cinq métriques ÉCRITES EN DUR ; il n'existe aucune notion
  de champ, de page ni de configuration. Tout est à créer.
- Neuf champs sont disponibles sans aucune collecte nouvelle (dont allure, D+,
  précision GPS, étape du fractionné) ; FC moyenne, temps en zone, cadence,
  puissance et laps demandent du travail.
- Décision : la configuration s'édite côté WEB (TileManager existe déjà) et
  voyage par WCSession — donc changer ses écrans ne demandera aucun rebuild
  Xcode, comme pour la routine.
- Le modèle et le catalogue sont du Foundation pur : écrits et testés sur
  Linux, seul le rendu TabView exige le Mac. Ne pas commencer par le rendu.
- Deux contraintes tenues dès la conception : 1 Hz maximum et page visible
  seule (le CPU suspend l'app et arrête le GPS sans erreur), et espacement des
  mises à jour en luminance réduite.

COWORK gagne le point d'étape du 21/08 : build passé, correctif finishRoute, et
surtout le fait que les 4 vérifications au poignet ne sont PAS encore faites —
les logs du jour ne montrent que l'app iPhone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 09:53:46 +00:00
Sylvain Bettinelli
3e657f5917 Une trace GPS ne peut pas être sauvegardée sans séance
Erreur de compilation remontée du Mac :
`Value of optional type 'HKWorkout?' must be unwrapped`.

Le fichier promettait quelque chose d'impossible. Vérifié à la source ce jour
(DocC HealthKit) : la signature est `finishRoute(with workout: HKWorkout,
metadata:)` — non optionnelle — et Apple précise « You must have already saved
this workout to the HealthKit store ». Il n'existe aucune API pour clore une
route orpheline. Le commentaire qui annonçait « la trace sera sauvegardée sans
association plutôt que perdue » décrivait un comportement inatteignable.

Le vrai recours tient à ce que dit le bug d'Apple : quand la montre est
verrouillée, `finishWorkout()` rend `nil` mais la séance EST écrite dans
HealthKit — seul l'objet manque. `WorkoutManager` va donc la rechercher :
dernier workout écrit par CETTE app (HKSource.default(), sinon une séance de
l'app Exercice pourrait récupérer notre trace), croisant les 5 dernières
minutes.

⚠️ La fenêtre porte sur le chevauchement, pas sur `startDate` : filtrer sur le
début raterait toute séance de plus de quelques minutes — le piège qui avait
rendu muettes les notifications de fin de séance côté iPhone.

Si rien n'est récupérable, `discardRoute()` jette la trace explicitement et le
journalise comme une perte : un builder abandonné sans `discard()` laisse ses
données en suspens, et « any further calls to the builder raise an exception ».

Le chemin « séance en salle » (aucune position retenue) reste inchangé,
volontairement sans `discard()` : c'est le cas le plus fréquent, il
fonctionnait, et aucun build ne l'a validé avec cet appel.

`HKSource` est écrit en toutes lettres : `predicateForObjects(from:)` a cinq
surcharges et un `.default()` abrégé s'y résout mal.

swiftc -parse OK, 57 tests Swift verts (inchangés : ces fichiers importent
HealthKit, hors de portée de Linux).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 09:22:50 +00:00
Sylvain Bettinelli
2f2d7696fa COWORK : la couche serveur du plan de séance est livrée
GET /api/plan/session (coach_sportif c8d1541) rend la séance du jour au format
CoachSessionPlan, déployé et vérifié en prod le 21/08 : 32 étapes / 40 min sur
le 15×(1'/1') du jour, alternance work/recovery correcte, bornes issues des
zones datées.

Reste les couches 2 (pousser le plan par WCSession) et 3 (la vue qui appelle
engine.update et joue l'haptique sur les transitions).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 09:11:31 +00:00
Sylvain Bettinelli
891a8bddce IntervalEngine : le commentaire sur les pauses disait l'inverse d'Apple
L'en-tête affirmait que `HKLiveWorkoutBuilder.elapsedTime` « exclut déjà les
pauses », et en tirait que le moteur n'avait rien à savoir de la pause ni de
l'auto-pause.

La doc Apple dit le contraire, vérifié à la source le 2026-08-20 :
« The elapsed time for the workout based on the builder's current contents,
including pauses. »

Conséquence si on câblait le moteur dessus telle quelle : une pause de 5 min
ferait avancer le déroulé de 5 min d'effort — un fractionné mis en pause pour
traverser une route se déroulerait à l'arrêt.

La propriété qui exclut réellement les pauses est
`HKWorkoutBuilder.elapsedTime(at:)` : « The duration of a workout doesn't
include intervals between pause and resume events. » Les deux textes d'Apple
se contredisent frontalement ; le choix de la source de temps reste à faire
avant le câblage.

Le moteur lui-même reste correct : il n'intègre que ce qu'on lui pousse.
Aucun comportement modifié, 57 tests Swift toujours verts.

COWORK gagne l'analyse complète du câblage (3 couches, le serveur sait déjà
produire les étapes via _blocks_from_session) pour reprise à froid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:52:21 +00:00
15 changed files with 2293 additions and 20 deletions

100
COWORK.md
View File

@@ -129,6 +129,106 @@ armer `allowsBackgroundLocationUpdates` sans elle termine l'app. Un garde-fou
la vérifie au démarrage, mais si elle disparaissait du build, le suivi la vérifie au démarrage, mais si elle disparaissait du build, le suivi
retomberait silencieusement au premier plan seul. retomberait silencieusement au premier plan seul.
## 🔜 Câbler IntervalEngine — analyse du 2026-08-20 (rien de codé)
`IntervalEngine` **compile sur device** (build validé le 20/08, l'app tourne),
mais **aucune vue ne l'appelle** : le moteur est un guide sans itinéraire.
### Le chaînon manquant
Le moteur attend un `CoachSessionPlan` (liste d'étapes : durée, zone, bornes
bpm). **Rien ne le lui fournit**`ConnectivityManager` reçoit les zones, le
`CoachWidgetSnapshot` et la routine, mais aucun plan structuré. Vérifié par
lecture du fichier, pas supposé.
En revanche **le serveur sait déjà produire ces étapes** :
`coach_sportif/web/fitness_export.py::_blocks_from_session` déplie une séance
en blocs chronométrés, répétitions comprises (un bug de prod du 12/08 avait
fait disparaître un 7×(1'C/1'M) : les `repeat` sont désormais dépliés), et
`_zone_bpm(zone, hr_zones)` convertit une zone en bornes bpm. Ces briques
servent déjà à l'export TCX Workout.
Indice que le chemin était prévu : les `CodingKeys` de `CoachPlanStep` sont en
snake_case (`duration_sec`, `hr_zone`, `hr_bpm_min`…) — le type a été écrit
pour décoder du JSON venant du serveur.
### Les trois couches, dans l'ordre
1.**Serveur — FAIT le 2026-08-21** (`coach_sportif` `c8d1541`, déployé et
vérifié en prod). **`GET /api/plan/session[?date=YYYY-MM-DD][&type=...]`**
rend la séance dépliée au format `CoachSessionPlan` : clés snake_case,
`hr_zone` en entier, durées en secondes. Mesuré en prod le 21/08 sur la
séance du jour (CDC J13, 15×(1'/1')) : **32 étapes, 40 min**, alternance
`work`/`recovery` correcte, bornes Z1 100-109 / Z2 109-118.
- Les bornes BPM viennent des **zones datées** (`current_zones`), jamais des
`hr_min`/`hr_max` du plan (hérités d'avant l'unification du 12/08).
- Les `repeat` sont **dépliés** — le moteur ne sait pas répéter.
- `404` = pas de séance ce jour · `422` = séance non chronométrée (repos,
durée ouverte) : rien à dérouler, à distinguer côté montre.
- ⚠️ Route volontairement **hors `/api/v1/*`** (JWT que la WebView n'a pas).
Un test le verrouille : ne pas la « ranger » dans l'API native.
2. **iPhone** — pousser ce plan vers la montre par `WCSession`, à côté de ce
qui part déjà.
3. **Montre**`ConnectivityManager` publie un `sessionPlan` ; une vue appelle
`engine.update(elapsed:distance:)` à chaque tick et joue une haptique sur
`events.transitions`.
### ⚠️ Le piège à trancher AVANT de câbler
L'en-tête d'`IntervalEngine.swift` affirmait que
`HKLiveWorkoutBuilder.elapsedTime` « exclut déjà les pauses ». **La doc Apple
dit l'inverse**, vérifié à la source le 20/08 : *« The elapsed time for the
workout based on the builder's current contents, including pauses. »*
Câbler le moteur dessus telle quelle ferait avancer le déroulé **pendant les
pauses** — un fractionné mis en pause pour traverser une route se déroulerait
à l'arrêt. La propriété qui exclut réellement les pauses est
`HKWorkoutBuilder.elapsedTime(at:)`. Les deux textes d'Apple se contredisent
frontalement ; le commentaire du fichier a été corrigé, **le choix de la source
de temps reste à faire**.
⚠️ Et jamais un `Timer` de vue SwiftUI : il ne tourne pas écran éteint, donc
pendant l'essentiel d'une séance (même famille de piège que `VmaTestView`).
## 📌 Point du 2026-08-21 — build OK, vérifs GPS EN SUSPENS
**Build Xcode passé sur iPhone (iOS 26.6)**, après un `git pull` qui partait de
`be1ffec` : le Mac n'avait donc **ni `LocationTracker`, ni `RouteFilter`, ni le
correctif du doublon pbxproj** (`19fbfd1`) — le build du 20/08 ne les contenait
pas. Contrôles avant build : 2/2/2 déclarations, `UIBackgroundModes = [location]`
et `NSLocationWhenInUseUsageDescription` présents, un seul `@main` par cible.
**Une erreur de compilation, corrigée (`3e657f5`)** :
`finishRoute(with:)` **n'accepte pas d'optionnel** — vérifié dans la doc Apple :
`func finishRoute(with workout: HKWorkout, metadata:)`, « You must have already
saved this workout to the HealthKit store ». Le commentaire du fichier
promettait une trace « sauvegardée sans association » : **c'est impossible**,
aucune API ne clôt une route orpheline. Traité en amont : quand
`finishWorkout()` rend `nil` (montre verrouillée), la séance EST dans HealthKit
et `WorkoutManager.recentlySavedWorkout()` va la rechercher — dernier workout de
`HKSource.default()` **croisant** les 5 dernières minutes (chevauchement, jamais
`startDate` : filtrer sur le début raterait toute séance longue). Sinon
`discardRoute()` jette la trace explicitement et le journalise comme une perte.
**Les 4 vérifications au poignet n'ont PAS encore été faites** — la séance
doit être lancée **depuis l'app sur la montre**, les logs du 21/08 ne montrent
que l'app iPhone (sync HealthKit complète et saine : 7 séances, 7 traces, 13
scores d'effort, App Group en écriture `ok:true`).
Relevé au passage, non traité : avertissement `UIScene lifecycle will soon be
required` (Capacitor, échéance future) ; un échantillon de pas venant de
`"iPhone de …"` et non de la Watch — à surveiller, **la cadence étant dérivée
des pas relus par séance**, deux sources sur une même séance la fausseraient.
## 🖥️ Vues d'entraînement au choix (WorkOutDoors) — cadré le 21/08, rien codé
Cadrage complet : **`docs/watch-training-screens.md`**. En deux lignes :
`LiveWorkoutView` a **cinq métriques en dur**, aucune notion de champ ni de
page. Décision prise : la configuration s'édite **côté web** (réutiliser
`TileManager`) et voyage par `WCSession` — donc **aucun rebuild pour changer ses
écrans**. Le modèle et le catalogue sont du Foundation pur, donc écrits et
testés sur Linux ; seul le rendu `TabView` exige Xcode. Châssis ≈ 1 jour.
## 👉 À reprendre ## 👉 À reprendre
-**Phase 4 login — FAIT & DÉPLOYÉ** (révocation Apple à la suppression de compte) : code complet côté backend `coach_sportif` (commit `5ae6e2d`) + clé `.p8` déployée sur le VPS prod (vérifié 2026-06-26). Rien à coder. Détails : `coach_sportif/COWORK.md`. -**Phase 4 login — FAIT & DÉPLOYÉ** (révocation Apple à la suppression de compte) : code complet côté backend `coach_sportif` (commit `5ae6e2d`) + clé `.p8` déployée sur le VPS prod (vérifié 2026-06-26). Rien à coder. Détails : `coach_sportif/COWORK.md`.
- **iOS — bloqué Mac** : builder + uploader TestFlight le natif accumulé (login Apple+Google natif, watchOS live, HeartRateRangeAlert). Sur le Mac : `cd ~/coach-ios && git pull && npm install && npx cap sync ios && open ios/App/App.xcodeproj` → Clean Build Folder → Archive → Upload. - **iOS — bloqué Mac** : builder + uploader TestFlight le natif accumulé (login Apple+Google natif, watchOS live, HeartRateRangeAlert). Sur le Mac : `cd ~/coach-ios && git pull && npm install && npx cap sync ios && open ios/App/App.xcodeproj` → Clean Build Folder → Archive → Upload.

View File

@@ -0,0 +1,479 @@
# É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.
**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 :
```swift
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
1. ~~La V3 accepte-t-elle une connexion BLE tierce ?~~ **Non** — mesuré le
31/08, voir ci-dessus.
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.
## 🟠 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][ancs]) :
- **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**.
[ancs]: https://developer.apple.com/library/archive/documentation/CoreBluetooth/Reference/AppleNotificationCenterServiceSpecification/Specification/Specification.html
### 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][qs]) : « 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.
[qs]: https://support.polar.com/e_manuals/vantage-v3/polar-vantage-v3-user-manual-francais/quick-settings.htm

View File

@@ -0,0 +1,83 @@
# Runbook Mac — le build qui rend l'envoi vérifiable
**Écrit le 03/09/2026, à suivre sur le Mac mini.** Compte 20 minutes, dont la
moitié d'attente. Ce build ne change rien à ce que la montre reçoit : il rend
l'app capable de **dire** ce qu'elle a fait, ce qu'elle ne savait pas faire.
Le dossier complet — deux épisodes de « la montre ne dit plus rien », leur
chronologie et ce qu'elle élimine — est dans `coach_sportif`,
[`docs/GUIDE-MONTRE.md`](../../coach_sportif/docs/GUIDE-MONTRE.md) §5quater et
§5quinquies. Ici : les gestes.
## Ce qui change
Deux fichiers, **déjà déclarés dans la target App** — donc rien à ajouter dans
Xcode, pas de Target Membership à cocher, pas de nouveau framework à lier
(WorkoutKit était déjà importé par `CoachWorkoutKit.swift`) :
- `ios/App/App/CoachWorkoutKit.swift`
- `ios/App/App/CoachWorkoutObserver.swift`
Branche : `feat/watch-outdoor`. Le côté serveur est **déjà en prod**, il attend
juste que l'app l'appelle.
## 1. Récupérer et compiler
```bash
cd ~/coach-ios # ou l'emplacement du clone sur le Mac
git checkout feat/watch-outdoor
git pull
npx cap sync ios
open ios/App/App.xcworkspace
```
Dans Xcode : cible **App**, ton iPhone connecté, ▶︎. Rien d'autre à toucher.
⚠️ **Ce code n'a jamais été compilé** — WorkoutKit et HealthKit n'existent pas
dans Swift pour Linux, et `tests-linux/` ne couvre que ce qui n'importe que
Foundation. Les signatures Apple ont été vérifiées une par une dans la doc DocC
avant d'être écrites, mais si quelque chose casse, c'est là que ça cassera :
| symptôme | où regarder |
|---|---|
| `value of type 'HKWorkout' has no member 'workoutPlan'` | l'extension vient de WorkoutKit : vérifier que `import WorkoutKit` est bien pris (il est sous `#if canImport`) |
| erreur sur `case .custom(let custom)` | `WorkoutPlan.workout` est l'enum `WorkoutPlan.Workout` — cas `custom`, `goal`, `pacer`, `swimBikeRun` |
| erreur de conversion sur `sw.date` | c'est un `DateComponents`, pas une `Date` — d'où le `Calendar.current.date(from:)` |
## 2. Vérifier sans courir (2 minutes, dans le canapé)
1. Ouvre **Programme**, la séance du jour, et appuie sur l'envoi Apple Watch.
Le message doit maintenant **nommer la séance programmée** :
« Programmée sur la montre : CDC · … ».
- S'il dit « la montre ne la liste pas encore » : le scheduler n'a pas encore
publié — regarde *Exercice → Programmées* au poignet avant de conclure.
- S'il dit « Autorisation « Séances programmées » absente » : **c'est la
réponse au silence**. Réglages iPhone → coach → autoriser, puis renvoyer.
C'est exactement ce qu'une réinstallation de l'app remet à zéro, et
l'ancienne version affichait « Envoyé » quand même.
2. Console Xcode (facultatif), filtre `CoachWorkoutKit` : `schedule OK`,
`authorizationState`.
## 3. Après ta prochaine séance
Le plugin remonte tout seul, en fin de séance, si elle venait de la séance
programmée ou d'un départ à la main. Pour le lire :
```bash
# sur nexus, là où vivent les données (node n'y est pas installé)
venv/bin/python3 tools/watch_alert_check.py <AAAA-MM-JJ> --dump /tmp/wac.json
# puis en local, là où node existe
python3 tools/watch_alert_check.py --from /tmp/wac.json
```
Trois réponses possibles, et une seule était disponible jusqu'ici :
- **« Séance lancée depuis la séance programmée »** → les alertes comptées
étaient bien dues. Si tu n'as rien entendu, le défaut est ailleurs que dans
l'envoi et le lancement.
- **« Séance lancée À LA MAIN »** → aucune alerte n'était possible, le silence
est normal. C'est le cas que rien ne savait détecter, et qui devient plausible
depuis que la Polar est la montre de sport.
- **« Origine inconnue »** → séance antérieure au build, ou lecture en échec.
Ce n'est **pas** un « non » : l'app préfère se taire qu'affirmer à tort.

View File

@@ -0,0 +1,107 @@
# Vues d'entraînement au choix sur la montre — cadrage du 2026-08-21
Chantier **non commencé**. Ce document fixe ce qui a été établi le 21/08 pour
qu'une session ultérieure reprenne sans refaire l'analyse.
Objectif : des écrans de séance configurables façon **WorkOutDoors** — plusieurs
pages, des champs au choix, un profil par sport.
## Point de départ réel (vérifié, pas supposé)
`LiveWorkoutView` (`ios/App/CoachWatch/ContentView.swift`, ~l.392-441) est un
`ScrollView` avec **cinq métriques écrites en dur** : FC + zone, calories,
distance, vitesse km/h, durée — puis l'état iPhone et les boutons
Pause / Terminer.
**Il n'existe aujourd'hui aucune notion de champ, de page, ni de configuration.**
Tout est à créer.
## Les quatre briques manquantes
### 1. Un catalogue de champs
Chaque métrique doit devenir une valeur identifiable (`id` stable, libellé,
unité, couleur, formatage) et non une ligne de vue. C'est ce qui permet à une
page de déclarer `["hr", "pace", "distance"]` sans que la vue connaisse les
champs à l'avance.
**Disponibles immédiatement, aucune collecte nouvelle :**
| Champ | Source |
|---|---|
| FC + zone | `WorkoutManager.heartRate` + `ConnectivityManager.zones` |
| Calories | `WorkoutManager.activeEnergyKcal` |
| Distance | `WorkoutManager.distanceMeters` |
| Vitesse km/h | `WorkoutManager.speedKmh` |
| Durée | `WorkoutManager.elapsedSec` |
| **Allure min/km** | dérivée de `speedKmh` (rien à collecter) |
| **D+ / D** | `LocationTracker.ascentMeters` / `descentMeters` |
| **Précision GPS** | `LocationTracker.horizontalAccuracy` |
| **Étape du fractionné** | `IntervalEngine`, dès qu'il est câblé (cf. COWORK) |
**Demandent du travail :**
- **FC moyenne** et **temps passé par zone** : rien ne les accumule aujourd'hui.
- **Cadence** : non collectée sur la montre.
- **Puissance de course** (`runningPower`, watchOS 9+).
- **Tours / laps** : aucun `HKWorkoutEvent` posé aujourd'hui.
### 2. Un modèle de page
```
WorkoutScreenConfig { sport: String, pages: [WorkoutPage] }
WorkoutPage { id: String, fields: [String] }
```
Rendu par un `TabView` paginé (défilement vertical + Digital Crown, watchOS 10+).
1 à 4 champs par page selon la taille d'écran — l'Ultra en tient davantage.
💡 **Ce modèle est du Foundation pur** : il s'écrit et se teste **sur Linux**
(`./tests-linux/run.sh`), comme `IntervalEngine` et ses 17 tests. Seul le rendu
SwiftUI exigera Xcode.
### 3. Où l'utilisateur choisit — décision prise le 21/08
**La configuration s'édite côté web et se pousse à la montre.** Pas d'écran de
réglages sur la montre.
- `TileManager` (long-press, drag, toggle) existe déjà côté web : le paradigme
est là, éprouvé sur les tuiles.
- Même chemin de transport que la routine et les zones : `WCSession`
`ConnectivityManager`.
- **Conséquence décisive** : changer ses écrans ne demanderait **aucun rebuild
Xcode**. C'est exactement ce qui rend la routine agréable aujourd'hui — « les
blocs voyagent avec l'état, rien n'est codé en dur côté watchOS ».
- Un **profil par sport** (course ≠ vélo ≠ renfo), comme WorkOutDoors.
⚠️ **Repli obligatoire** : sans configuration reçue, la montre affiche les cinq
champs actuels. Jamais d'écran vide — la config peut ne pas être arrivée, et une
séance ne s'interrompt pas pour ça.
### 4. Deux contraintes watchOS, à tenir dès la conception
- **Le CPU tue le GPS** (piège n°4 documenté dans `LocationTracker.swift`) :
watchOS suspend une app trop gourmande et les positions s'arrêtent **sans
aucune erreur**. ⇒ rafraîchissement **1 Hz maximum**, et ne recalculer **que
la page visible**, jamais les quatre. Ne pas utiliser un `Timer` de vue.
- **Écran always-on** : en luminance réduite (`@Environment(\.isLuminanceReduced)`),
espacer les mises à jour, sinon la batterie fond sur une sortie longue.
## Effort estimé
| Lot | Estimation |
|---|---|
| Châssis (catalogue + modèle + config web + push + `TabView`) | ~1 jour |
| Chaque métrique dérivée supplémentaire | 30 min à 2 h |
| Always-on propre | ½ jour |
## Ordre proposé
1. **Modèle + catalogue en Foundation pur**, testés sur Linux — sans Mac.
2. **Endpoint serveur + éditeur web** (réutiliser `TileManager`).
3. **Push `WCSession`** et publication dans `ConnectivityManager`.
4. **Rendu `TabView`** dans `LiveWorkoutView`, avec le repli en dur.
5. Métriques accumulées (FC moyenne, temps en zone) — chacune isolément.
⚠️ Ne pas commencer par le rendu : c'est la seule partie qui exige le Mac, et
elle ne se juge qu'une fois les données au bon format.

View File

@@ -0,0 +1,644 @@
// CoachPolarBLE.swift
// Écrit une séance du plan sur une Polar Vantage V3, en Bluetooth, sans cloud
// ni compte ni API tierce.
//
// POURQUOI CE PLUGIN EXISTE
//
// L'écriture d'un objectif sur la montre par le protocole PFTP est prouvée
// depuis le 2026-08-17 : la montre accepte le fichier et l'affiche. Mais elle
// ne l'était qu'en USB, donc il fallait brancher la montre à un Mac cinq
// minutes avant de sortir ce qui n'est pas un usage.
//
// Le Bluetooth n'est donc pas un confort : c'est ce qui rend le chemin
// utilisable. Et c'est le seul chemin qui satisfasse la contrainte posée le
// 2026-08-31 `coach la montre`, sans TrainingPeaks, sans Polar Flow, sans
// Intervals.icu. Toutes les autres voies passent par quelqu'un d'autre.
//
// CE QUE CE PLUGIN FAIT, ET CE QU'IL NE FAIT PAS
//
// Il transporte. Rien d'autre. Les octets viennent du serveur
// (`GET /api/plan/polar-target`), qui les produit avec un encodeur verrouillé
// octet pour octet contre des fichiers relus sur la montre. **Ne jamais
// réencoder un objectif ici** : ce serait perdre la seule garantie dont on
// dispose que le firmware acceptera le fichier.
//
// LE FIRMWARE PLANTE SUR REQUÊTE MALFORMÉE
//
// Le 2026-08-17, un PUT de 174 octets vers un chemin terminé par « / » donc
// « écrire du contenu dans un dossier » a bloqué une Vantage V3 : logo Polar
// puis écran noir, récupérée par un appui long sur OK. Le firmware ne refuse
// pas proprement, il s'effondre. Le BLE utilise LE MÊME protocole PFTP : le
// risque est identique ici.
//
// D'où deux verrous, repris de `tools/polar/polar_ftp.py` :
// - un chemin terminé par « / » est un DOSSIER : contenu obligatoirement vide ;
// - un chemin sans « / » final est un FICHIER : contenu obligatoirement non vide.
// Et un mode `dryRun` qui construit chaque requête et la décrit sans rien
// émettre l'équivalent du `--dry-run` qui a manqué le jour du plantage.
//
// CHAÎNE SDK vérifiée sur les sources le 2026-08-31, tout est public
//
// CBDeviceListenerImpl(queue, clients:identifier:) ble/endpoints/corebluetooth/central
// listener.search(_:identifiers:fetchKnownDevices:) -> AnyPublisher<BleDeviceSession, Error>
// listener.openSessionDirect(_:)
// session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE) // CBUUID("FEEE")
// client.waitPsFtpReady(_:) async throws
// client.write(_ header: NSData, data: InputStream) -> AsyncThrowingStream<UInt, Error>
//
// Aucun fork, aucun symbole interne.
//
// Le cadrage série `[0x05, taille, taille]` de la version USB N'A PAS SA
// PLACE ICI. Il appartient au transport RFC76 sur CDC-ACM ; en Bluetooth c'est
// le SDK qui s'en charge. On ne passe que l'en-tête PbPFtpOperation et les
// données brutes. Reporter le cadrage produirait une requête malformée c'est-
// à-dire précisément ce qui plante la montre.
//
// USAGE CÔTÉ JS
//
// await window.Capacitor.Plugins.CoachPolarBLE.isAvailable()
// { available: bool }
//
// await window.Capacitor.Plugins.CoachPolarBLE.send({
// mkdir: ['/U/0/20260831/TST/', '/U/0/20260831/TST/180000/'],
// dir: '/U/0/20260831/TST/180000/',
// files: [{ name: 'TST.BPB', b64: '...' }, { name: 'ID.BPB', b64: '...' }],
// dryRun: true, // par défaut FALSE, mais à utiliser au premier essai
// timeoutSec: 30,
// })
// { sent: bool, dryRun: bool, log: [string] }
//
// Le corps de `send` est exactement la réponse de `/api/plan/polar-target` :
// le JS n'a rien à recomposer.
import Foundation
import Capacitor
#if canImport(PolarBleSdk)
import PolarBleSdk
import CoreBluetooth
import Combine
#endif
@objc(CoachPolarBLEPlugin)
public class CoachPolarBLEPlugin: CAPPlugin, CAPBridgedPlugin {
// CAPBridgedPlugin : sans cette déclaration explicite, Capacitor 8
// n'expose pas les méthodes au bridge et `Capacitor.Plugins.CoachPolarBLE`
// reste undefined (même piège que CoachWorkoutKit).
public let identifier = "CoachPolarBLEPlugin"
public let jsName = "CoachPolarBLE"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "send", returnType: CAPPluginReturnPromise),
]
@objc func isAvailable(_ call: CAPPluginCall) {
#if canImport(PolarBleSdk)
call.resolve(["available": true])
#else
call.resolve(["available": false,
"reason": "PolarBleSdk absent de la cible — ajouter le paquet dans Xcode"])
#endif
}
@objc func send(_ call: CAPPluginCall) {
guard let etapes = Self.parseSteps(call) else {
call.reject("payload invalide : `dir`, `files[]` (name + b64) requis")
return
}
let dryRun = call.getBool("dryRun") ?? false
let timeout = call.getDouble("timeoutSec") ?? 30
// Les verrous d'abord, la radio ensuite. Une requête refusée ici est une
// montre qui ne plante pas.
do {
try etapes.forEach { try $0.validate() }
} catch {
call.reject("\(error)")
return
}
if dryRun {
call.resolve(["sent": false, "dryRun": true,
"log": etapes.map { $0.describe }])
return
}
#if canImport(PolarBleSdk)
Task {
do {
let issue = try await PolarPsFtpWriter().ecrire(etapes, timeout: timeout)
call.resolve(["sent": true, "dryRun": false,
"log": issue.journal,
// L'UI s'en sert pour prévenir d'un renvoi : la
// montre garde son index en cache, et l'objectif
// réécrit n'apparaît qu'après un redémarrage.
"alreadyExisted": issue.dossierPreexistant])
} catch {
call.reject("\(error)")
}
}
#else
call.reject("PolarBleSdk absent de la cible — ajouter le paquet dans Xcode")
#endif
}
/// Traduit le corps JS en étapes PFTP, dans l'ordre où elles doivent partir.
static func parseSteps(_ call: CAPPluginCall) -> [PftpStep]? {
guard let dir = call.getString("dir"), !dir.isEmpty else { return nil }
var etapes: [PftpStep] = []
// Les dossiers d'abord, du parent vers l'enfant : `/U/0/<date>/TST/` puis
// `<heure>/`. Aucun des deux n'existe d'avance pour une date neuve, et un
// mkdir dans le désordre échoue.
for chemin in call.getArray("mkdir", String.self) ?? [] {
etapes.append(PftpStep(path: chemin, data: Data()))
}
guard let fichiers = call.getArray("files") as? [[String: Any]], !fichiers.isEmpty else {
return nil
}
for fichier in fichiers {
guard let nom = fichier["name"] as? String,
let b64 = fichier["b64"] as? String,
let octets = Data(base64Encoded: b64) else { return nil }
etapes.append(PftpStep(path: dir + nom, data: octets))
}
return etapes
}
}
// MARK: - Le transport
#if canImport(PolarBleSdk)
/// Ouvre une session PsFTP sur la première montre qui répond, et écrit.
///
/// Le filtrage se fait sur « expose PsFTP », pas sur le nom ni l'identifiant
/// annoncé : ce que la V3 met dans son advertisement n'a pas été observé, et
/// s'appuyer dessus serait une supposition. À resserrer une fois qu'on l'aura vu
/// en attendant, un capteur Polar (H10) ne portant pas PsFTP est écarté de
/// lui-même.
final class PolarPsFtpWriter {
private let queue = DispatchQueue(label: "ch.hypnotruck.coach.polarble")
private var abonnements = Set<AnyCancellable>()
/// Le publisher de `search()` a-t-il émis quoi que ce soit valeur, fin ou
/// erreur ? S'il reste muet, `monitorBleState()` n'a jamais émis
/// `.poweredOn` et le scan n'a jamais démarré : ce n'est pas « aucun
/// appareil », c'est « on n'a jamais cherché ».
private var publisherAParle = false
private var derniereErreurSdk: String?
/// Écrit les étapes et rend le journal, plus un drapeau : le dossier de
/// destination existait-il déjà ? Ce drapeau remonte jusqu'à l'UI, qui doit
/// avertir un objectif réécrit n'apparaît qu'après redémarrage de la
/// montre.
func ecrire(_ etapes: [PftpStep], timeout: Double) async throws
-> (journal: [String], dossierPreexistant: Bool) {
// PRÉAMBULE INDISPENSABLE sans lui, rien ne se passe et rien ne le
// dit. `CBDeviceListenerImpl.search()` commence par
// `monitorBleState().filter { $0 == .poweredOn }` : tant que cet état
// n'arrive pas, le flux n'émet jamais, le scan ne démarre pas, et on
// conclut « aucun appareil » au bout du timeout.
//
// Or l'état ne peut arriver que si un `CBCentralManager` existe et
// c'est sa création qui déclenche l'alerte d'autorisation d'iOS.
// Mesuré le 2026-08-31 : le scan a tourné 30 s sans qu'iOS demande quoi
// que ce soit, et « Bluetooth » n'apparaissait même pas dans les
// réglages de l'app. L'autorisation n'avait jamais été sollicitée.
//
// On crée donc le manager nous-mêmes, on attend son premier état, et on
// traduit ce qu'il dit au lieu de laisser un silence passer pour une
// absence de montre.
let sonde = SondeBluetooth()
try await sonde.attendreEtatUtilisable(timeout: min(timeout, 15))
let listener = CBDeviceListenerImpl(
queue,
clients: [{ transport in BlePsFtpClient(gattServiceTransmitter: transport) }],
identifier: 1)
// NE PAS FILTRER À L'AVEUGLE on trie après avoir vu.
//
// Une version précédente posait `scanPreFilter` sur « polarDeviceId non
// vide OU nom contenant polar ». Si la Vantage ne s'annonce pas ainsi,
// elle était rejetée par NOUS, aucune session n'était créée, et le
// publisher de `search()` ne disait rien ce que le message
// interprétait à tort comme « le scan n'a pas démarré ».
//
// On laisse donc tout remonter et on trie dans la boucle : les appareils
// qui ressemblent à du Polar d'abord, et on n'ouvre de session que sur
// ceux-là. Si aucun ne ressemble, on RAPPORTE ce qu'on a vu au lieu de
// conclure.
// RÉVEILLER LA LAZY, PUIS ATTENDRE dans cet ordre, et avant tout
// abonnement.
//
// `CBDeviceListenerImpl.manager` est une `lazy var` : le
// CBCentralManager n'existe qu'au premier accès, et `search()` ne le
// touche jamais avant son filtre `$0 == .poweredOn`. Sans réveil, on
// attend un état que rien ne peut produire.
//
// Deux détails, tirés des sources, qui décident du succès :
//
// 1. `bleStateSubject` est un **CurrentValueSubject**, initialisé à
// `.unknown`. Il REJOUE sa valeur à chaque abonnement inutile donc
// d'être abonné au moment de l'émission. Une version précédente
// réveillait après l'abonnement « pour ne pas rater l'événement » :
// raisonnement de PassthroughSubject, faux ici, et qui exposait au
// piège suivant.
//
// 2. `centralManagerDidUpdateState` fait
// `BleState(rawValue: self.manager.state.rawValue)` il accède à la
// lazy 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. D'où : réveiller tôt, hors de tout abonnement, et laisser
// l'état se poser.
//
// ET SURTOUT : attendre le SUJET, pas l'état du manager.
//
// `blePowered()` lit `manager.state` l'état de CoreBluetooth. Mais
// `search()` filtre sur `bleStateSubject`, qui n'est alimenté que par
// `centralManagerDidUpdateState`. Le manager peut donc être allumé
// (`blePowered()` vrai) sans que le délégué ait encore publié quoi que
// ce soit : le sujet reste à `.unknown`, le filtre bloque, et le
// publisher se tait. C'est exactement ce qui a été mesuré le
// 2026-08-31, y compris après avoir attendu `blePowered()`.
//
// On attend donc sur `monitorBleState()`, qui est public et qui EST la
// source lue par `search()`. `blePowered()` ne sert plus qu'à une
// chose : instancier la lazy pour que le délégué puisse tourner.
_ = listener.blePowered()
let etat = await listener.premierEtatPret(timeout: min(timeout, 12))
if !etat {
throw PolarPftpError.bluetoothUnusable(
"le listener du SDK Polar n'a jamais publié l'état « prêt » "
+ "(son délégué n'a pas été appelé). Bluetooth actif côté "
+ "système, mais inutilisable par le SDK.")
}
let (client, session) = try await trouverClient(listener, timeout: timeout,
sonde: sonde)
defer { listener.closeSessionDirect(session) }
var journal: [String] = []
var dossierPreexistant = false
for etape in etapes {
// Revalidé juste avant l'émission : entre la validation d'entrée et
// ce point, rien ne doit avoir introduit un chemin de dossier avec
// du contenu.
try etape.validate()
do {
try await put(client, etape)
journal.append("" + etape.describe)
} catch BlePsFtpException.responseError(let code)
where etape.isDirectory && PftpCode.mkdirEstSatisfait(par: code) {
// `mkdir -p` : le dossier est là, c'est tout ce qu'on demandait.
//
// Rapporté par Sylvain le 01/09/2026 sous la forme
// « errorcode 104 » ce qui donnait à croire à un refus de la
// montre alors que la session BLE s'était parfaitement ouverte.
// Le commentaire de `parseSteps` disait « aucun des deux
// n'existe d'avance pour une date neuve » : c'est vrai d'une
// date NEUVE, et faux dès le second envoi vers la même date.
//
// Le renvoi est signalé plus haut, jamais avalé en silence :
// réécrire un objectif déjà présent n'est visible qu'après un
// redémarrage de la montre (index en cache, constaté le 31/08).
dossierPreexistant = true
journal.append("" + etape.describe + " — existait déjà")
}
}
return (journal, dossierPreexistant)
}
private func trouverClient(_ listener: CBDeviceListenerImpl,
timeout: Double,
sonde: SondeBluetooth) async throws
-> (BlePsFtpClient, BleDeviceSession) {
let debut = Date()
var vues = 0 // appareils BLE aperçus, tous confondus
var candidats = 0 // ceux qui ressemblent à un Polar
var noms: [String] = []
var sansPsFtp = 0 // aperçus mais ne portant pas le service FEEE
var muettes = 0 // portant FEEE mais dont waitPsFtpReady échoue
// UN SEUL ABONNEMENT, SUR TOUTE LA DURÉE pas des fenêtres.
//
// Non pas parce qu'une émission serait ratée `bleStateSubject` est un
// CurrentValueSubject, il rejoue sa valeur à chaque abonnement mais
// parce que chaque abonnement relance le cycle `addClient()` /
// `removeClient()` du scanner. Redémarrer un scan toutes les 4 s
// l'empêche de découvrir quoi que ce soit, et la découverte BLE demande
// plusieurs secondes ininterrompues.
//
// L'état, lui, est acquis avant d'arriver ici : `blePowered()` a été
// sondé jusqu'à devenir vrai.
do {
// `AllowDuplicates` est armé côté SDK : le même appareil revient à
// chaque advertisement (4020 émissions pour une poignée d'appareils
// réels). On déduplique avant de compter et d'ouvrir quoi que ce soit.
var dejaVues = Set<String>()
let toutes = try await sessionsVues(listener, fenetre: timeout)
.filter { dejaVues.insert(Self.etiquette($0)).inserted }
vues = toutes.count
noms = toutes.map { Self.etiquette($0) }
// Ouvrir une session coûte plusieurs secondes : on ne les tente que
// sur ce qui ressemble à un Polar, sinon 43 appareils feraient des
// minutes d'attente (mesuré, avec des erreurs sur l'Apple Watch).
let sessions = toutes.filter { Self.ressembleAPolar($0) }
candidats = sessions.count
for session in sessions {
// Marge au-delà du scan : ouvrir une session prend du temps, et
// couper ici ferait échouer la seule montre trouvée.
if Date().timeIntervalSince(debut) > timeout * 2 { break }
listener.openSessionDirect(session)
guard let client = session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE)
as? BlePsFtpClient else {
sansPsFtp += 1
listener.closeSessionDirect(session)
continue
}
do {
// `waitPsFtpReady` n'a AUCUNE limite de temps. Si la
// montre ne finit pas la négociation canal déjà pris,
// écran éteint, appairage en cours l'attente ne rend
// jamais la main et l'envoi paraît figé, sans message.
// Constaté le 2026-08-31 : « envoi » tournant sans fin.
// On lui donne donc une échéance, et un dépassement compte
// comme une montre muette : c'est ce qu'il est.
try await Self.avecEcheance(seconds: 12) {
try await client.waitPsFtpReady(true)
}
return (client, session)
} catch {
muettes += 1
listener.closeSessionDirect(session)
}
}
}
if vues > 0 && candidats == 0 {
// Le scan marche, mais rien ne ressemble à un Polar. Dire ce qu'on
// a vu : c'est la seule façon de savoir sous quel nom la montre
// s'annonce ou si elle ne s'annonce pas du tout.
throw PolarPftpError.aucunPolarParmi(vues: vues,
exemples: Array(noms.prefix(8)))
}
if vues == 0 {
// Le SDK n'a rien remonté : est-ce la radio, ou notre usage du SDK ?
// Un scan nu tranche, et son résultat part dans le message.
let bruts = await sonde.compterAppareils(pendant: 6)
throw PolarPftpError.sdkSilencieux(vusParCoreBluetooth: bruts,
publisherAParle: publisherAParle,
erreurSdk: derniereErreurSdk)
}
throw PolarPftpError.watchNotFound(timeout, vues: vues,
sansPsFtp: sansPsFtp, muettes: muettes)
}
/// Les appareils vus pendant une fenêtre de recherche, appairés compris.
private func sessionsVues(_ listener: CBDeviceListenerImpl,
fenetre: Double) async throws -> [BleDeviceSession] {
try await withCheckedThrowingContinuation { suite in
var vues: [BleDeviceSession] = []
var rendu = false
let rendre = {
guard !rendu else { return }
rendu = true
suite.resume(returning: vues)
}
// PASSER L'UUID DU SERVICE c'est ce qui trouve une montre DÉJÀ
// CONNECTÉE.
//
// Mesuré le 2026-08-31 : 4020 advertisements vus (HomePod, Furbo),
// aucune Polar. Normal **un appareil BLE connecté cesse
// d'émettre**. La Vantage est liée à l'iPhone par l'app Flow : elle
// est donc invisible au scan, par conception, et l'attendre était
// sans espoir.
//
// Avec un `uuids` non nil, `search()` emprunte une autre branche :
// `manager.retrieveConnectedPeripherals(withServices: uuids!)`, qui
// rend les périphériques déjà connectés au système exposant ce
// service, et leur fabrique une session. Passer `nil` ce que
// faisait ce code n'exécutait jamais cette branche.
listener.search([BlePsFtpClient.PSFTP_SERVICE],
identifiers: nil, fetchKnownDevices: true)
.sink(receiveCompletion: { [weak self] fin in
// Distinguer « le publisher a terminé » de « il n'a
// jamais rien dit » : dans le second cas, `flatMap`
// n'a pas été atteint, donc `monitorBleState()` n'a
// jamais émis `.poweredOn` le scan n'a pas démarré.
if case .failure(let e) = fin {
self?.derniereErreurSdk = String(describing: e)
}
self?.publisherAParle = true
rendre()
},
receiveValue: { [weak self] session in
self?.publisherAParle = true
vues.append(session)
})
.store(in: &abonnements)
// La recherche ne se termine pas d'elle-même : on lui donne une
// fenêtre, puis on travaille avec ce qu'on a vu.
queue.asyncAfter(deadline: .now() + fenetre) { rendre() }
}
}
/// Ce qu'on peut dire d'un appareil vu, pour l'afficher.
static func etiquette(_ session: BleDeviceSession) -> String {
let c = session.advertisementContent
let nom = c.name.isEmpty ? "(sans nom)" : c.name
return c.polarDeviceId.isEmpty ? nom : "\(nom) [\(c.polarDeviceId)]"
}
/// Vrai si l'appareil a une chance d'être une montre Polar.
///
/// Volontairement LARGE : mieux vaut tenter une session de trop que
/// d'exclure la montre et conclure qu'elle n'existe pas.
static func ressembleAPolar(_ session: BleDeviceSession) -> Bool {
let c = session.advertisementContent
if !c.polarDeviceId.isEmpty { return true }
if !c.polarDeviceType.isEmpty { return true }
let nom = c.name.lowercased()
return nom.contains("polar") || nom.contains("vantage") || nom.contains("grit")
}
/// Attend qu'une condition devienne vraie, en la sondant. Rend faux au bout
/// du délai.
///
/// Conservé pour d'autres usages, mais NE PAS s'en servir pour l'état
/// BLE : `blePowered()` lit le manager, pas le sujet que `search()` écoute.
///
/// Sonder plutôt qu'écouter : l'état du SDK se lit (`blePowered()`), et
/// c'est justement cette lecture qui instancie le CBCentralManager. Le
/// premier appel réveille, les suivants observent.
static func attendre(seconds: Double, _ condition: @escaping () -> Bool) async -> Bool {
let echeance = Date().addingTimeInterval(seconds)
while Date() < echeance {
if condition() { return true }
try? await Task.sleep(nanoseconds: 200_000_000)
}
return condition()
}
/// Court `operation` avec une échéance, et lève si elle est dépassée.
///
/// Le SDK Polar rend des `async` sans limite de temps : une montre qui ne
/// répond pas fige l'appel indéfiniment. Un envoi qui n'aboutit pas doit
/// dire pourquoi, pas tourner en silence.
static func avecEcheance<T: Sendable>(seconds: Double,
_ operation: @escaping @Sendable () async throws -> T)
async throws -> T {
try await withThrowingTaskGroup(of: T.self) { groupe in
groupe.addTask { try await operation() }
groupe.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw PolarPftpError.echeanceDepassee(seconds)
}
guard let premier = try await groupe.next() else {
throw PolarPftpError.echeanceDepassee(seconds)
}
groupe.cancelAll()
return premier
}
}
private func put(_ client: BlePsFtpClient, _ etape: PftpStep) async throws {
let entree = InputStream(data: etape.data)
// Même raison que pour waitPsFtpReady : une écriture qui n'aboutit pas
// doit échouer, pas figer l'app.
// `write` rend un flux de progression ; on le consomme jusqu'au bout,
// et l'absence d'erreur vaut acquittement c'est l'équivalent BLE du
// `05 00 00` observé en USB.
try await Self.avecEcheance(seconds: 20) {
for try await _ in client.write(etape.header() as NSData, data: entree) {}
}
}
}
#endif
/// Vérifie que le Bluetooth est utilisable, et dit pourquoi il ne l'est pas.
///
/// Ne scanne rien : elle instancie un `CBCentralManager` et lit son premier
/// état. C'est cette instanciation qui provoque la demande d'autorisation iOS
/// et donc l'apparition de la ligne « Bluetooth » dans les réglages de l'app.
private final class SondeBluetooth: NSObject, CBCentralManagerDelegate {
private var manager: CBCentralManager?
private var suite: CheckedContinuation<CBManagerState, Never>?
private var repondu = false
private var vus = Set<UUID>()
func attendreEtatUtilisable(timeout: Double) async throws {
let etat = await withCheckedContinuation { (c: CheckedContinuation<CBManagerState, Never>) in
suite = c
// `showPowerAlert: false` : c'est nous qui expliquons, pas une
// alerte système au milieu d'un envoi.
manager = CBCentralManager(delegate: self, queue: nil,
options: [CBCentralManagerOptionShowPowerAlertKey: false])
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in
self?.repondre(self?.manager?.state ?? .unknown)
}
}
switch etat {
case .poweredOn:
return
case .unauthorized:
throw PolarPftpError.bluetoothUnusable(
"coach n'a pas l'autorisation d'utiliser le Bluetooth. "
+ "Réglages → coach → activer Bluetooth.")
case .poweredOff:
throw PolarPftpError.bluetoothUnusable(
"le Bluetooth est désactivé sur l'iPhone.")
case .unsupported:
throw PolarPftpError.bluetoothUnusable(
"cet appareil ne prend pas en charge le Bluetooth LE.")
default:
throw PolarPftpError.bluetoothUnusable(
"le Bluetooth n'a pas répondu (état « \(etat.rawValue) »). "
+ "Réessayer ; si ça persiste, redémarrer l'app.")
}
}
/// Compte les appareils BLE qu'un scan CoreBluetooth NU voit, sans le SDK.
///
/// Sert à trancher un diagnostic, pas à travailler : si CoreBluetooth voit
/// des appareils et que le SDK n'en remonte aucun, le problème est dans
/// notre usage du SDK ; si les deux voient zéro, il est dans la radio ou
/// l'environnement. Sans cette mesure on ne peut que deviner ce qui a
/// coûté trois allers-retours le 2026-08-31.
func compterAppareils(pendant: Double) async -> Int {
vus.removeAll()
manager?.scanForPeripherals(withServices: nil,
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
try? await Task.sleep(nanoseconds: UInt64(pendant * 1_000_000_000))
manager?.stopScan()
return vus.count
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
vus.insert(peripheral.identifier)
}
private func repondre(_ etat: CBManagerState) {
guard !repondu else { return }
repondu = true
suite?.resume(returning: etat)
suite = nil
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
// `.unknown` est l'état transitoire du démarrage : ne pas conclure
// dessus, le vrai état suit.
if central.state != .unknown { repondre(central.state) }
}
}
extension CBDeviceListenerImpl {
/// Vrai dès que le listener PUBLIE l'état `.poweredOn` c'est-à-dire dès
/// que `search()` pourra franchir son filtre.
///
/// `monitorBleState()` rend un `CurrentValueSubject`, donc l'abonnement
/// reçoit immédiatement la valeur courante (`.unknown` au départ) puis les
/// suivantes. On attend la première qui vaut `.poweredOn`.
/// Porte son échéance elle-même plutôt que d'être enveloppée : le listener
/// n'est pas `Sendable`, et le faire traverser une TaskGroup se heurterait
/// à la concurrence stricte de Swift 6.
func premierEtatPret(timeout: Double) async -> Bool {
await withCheckedContinuation { suite in
let boite = BoiteJeton()
boite.jeton = monitorBleState()
.sink(receiveCompletion: { _ in boite.rendre(false, suite) },
receiveValue: { etat in
if etat == .poweredOn { boite.rendre(true, suite) }
})
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) {
boite.rendre(false, suite)
}
}
}
}
/// Garde l'abonnement en vie et garantit une reprise unique de la continuation
/// la reprendre deux fois est un crash, et il y a ici deux chemins de sortie
/// concurrents : l'état publié et l'échéance.
private final class BoiteJeton: @unchecked Sendable {
var jeton: AnyCancellable?
private var rendu = false
private let verrou = NSLock()
func rendre(_ valeur: Bool, _ suite: CheckedContinuation<Bool, Never>) {
verrou.lock()
defer { verrou.unlock() }
guard !rendu else { return }
rendu = true
suite.resume(returning: valeur)
jeton?.cancel()
jeton = nil
}
}

View File

@@ -40,13 +40,43 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
public let pluginMethods: [CAPPluginMethod] = [ public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "sendInterval", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "sendInterval", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "scheduledWorkouts", returnType: CAPPluginReturnPromise),
] ]
@objc func isAvailable(_ call: CAPPluginCall) { @objc func isAvailable(_ call: CAPPluginCall) {
if #available(iOS 17.0, *) { guard #available(iOS 17.0, *) else {
call.resolve(["available": true, "min_ios": "17.0"])
} else {
call.resolve(["available": false, "reason": "iOS 17+ required for WorkoutKit"]) call.resolve(["available": false, "reason": "iOS 17+ required for WorkoutKit"])
return
}
// `available` disait « iOS 17+ », rien de plus : une autorisation
// révoquée ou jamais accordée rendait exactement la même réponse.
Task {
let state = await WorkoutScheduler.shared.authorizationState
call.resolve([
"available": true,
"min_ios": "17.0",
"supported": WorkoutScheduler.isSupported,
"authorization": Self.describe(state),
"authorized": state == .authorized,
])
}
}
/// Ce que la montre a réellement en attente, interrogeable sans rien envoyer.
@objc func scheduledWorkouts(_ call: CAPPluginCall) {
guard #available(iOS 17.0, *) else {
call.reject("WorkoutKit requires iOS 17.0 or later")
return
}
Task {
let state = await WorkoutScheduler.shared.authorizationState
let scheduled = await Self.scheduledSummaries()
call.resolve([
"authorization": Self.describe(state),
"authorized": state == .authorized,
"count": scheduled.count,
"scheduled": scheduled,
])
} }
} }
@@ -131,7 +161,7 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
Task { Task {
do { do {
try await sendCustomWorkout( let scheduled = try await sendCustomWorkout(
activity: activity, activity: activity,
displayName: displayName, displayName: displayName,
warmupMin: warmupMin, warmupMin: warmupMin,
@@ -143,9 +173,12 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
"sent": true, "sent": true,
"activityUsed": Int(activity.rawValue), "activityUsed": Int(activity.rawValue),
"activityFallback": activity != requestedActivity, "activityFallback": activity != requestedActivity,
// Ce que le scheduler liste APRÈS l'envoi : c'est la preuve,
// le reste n'est qu'une intention.
"scheduled": scheduled,
]) ])
} catch { } catch {
call.reject("Failed to send workout: \(error.localizedDescription)") call.reject(error.localizedDescription)
} }
} }
} }
@@ -199,7 +232,7 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
stepsSpec: [(min: Double, hrZone: Int?, bpm: ClosedRange<Double>?, isWork: Bool, label: String?)], stepsSpec: [(min: Double, hrZone: Int?, bpm: ClosedRange<Double>?, isWork: Bool, label: String?)],
repeats: Int, repeats: Int,
cooldownMin: Double cooldownMin: Double
) async throws { ) async throws -> [[String: Any]] {
// 1. Warmup un WorkoutStep optionnel. Si warmupMin == 0, on passe // 1. Warmup un WorkoutStep optionnel. Si warmupMin == 0, on passe
// nil au CustomWorkout (la signature accepte WorkoutStep?). Passer // nil au CustomWorkout (la signature accepte WorkoutStep?). Passer
@@ -273,11 +306,24 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
// 8. Demande d'autorisation explicite (idempotent déclenche dialog // 8. Demande d'autorisation explicite (idempotent déclenche dialog
// iOS la 1re fois, return immédiat si déjà accordé). // iOS la 1re fois, return immédiat si déjà accordé).
//
// L'échec ne se ravale plus. Il l'était jusqu'au 03/09/2026 : un
// NSLog, puis on continuait comme si de rien n'était. Une
// réinstallation de l'app remet l'autorisation à `notDetermined`
// (celle du 31/08, chantier CoachPolarBLE, tombe dans la fenêtre du
// silence de la montre cf. GUIDE-MONTRE.md §5quater), et rien à
// l'écran n'aurait dit que plus aucune séance n'arrivait au poignet.
do { do {
try await WorkoutScheduler.shared.requestAuthorization() try await WorkoutScheduler.shared.requestAuthorization()
NSLog("[CoachWorkoutKit] requestAuthorization OK") NSLog("[CoachWorkoutKit] requestAuthorization OK")
} catch { } catch {
NSLog("[CoachWorkoutKit] requestAuthorization failed: \(error)") NSLog("[CoachWorkoutKit] requestAuthorization failed: \(error)")
throw CoachWorkoutKitError.authorization(error.localizedDescription)
}
let authState = await WorkoutScheduler.shared.authorizationState
guard authState == .authorized else {
NSLog("[CoachWorkoutKit] authorizationState = %@", String(describing: authState))
throw CoachWorkoutKitError.notAuthorized(Self.describe(authState))
} }
// 8bis. Efface les workouts déjà programmés par l'app avant d'en // 8bis. Efface les workouts déjà programmés par l'app avant d'en
@@ -299,5 +345,67 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
) )
try await WorkoutScheduler.shared.schedule(plan, at: comps) try await WorkoutScheduler.shared.schedule(plan, at: comps)
NSLog("[CoachWorkoutKit] schedule OK at \(scheduleDate)") NSLog("[CoachWorkoutKit] schedule OK at \(scheduleDate)")
// 10. Relecture : ce qui compte n'est pas que `schedule()` soit rentré
// sans erreur, c'est que la séance soit RÉELLEMENT dans la liste du
// scheduler. C'est la seule chose qui réponde, sans aller regarder
// la montre, à « est-ce que ma séance est programmée ? ».
// Une liste vide n'est PAS traitée comme un échec : le scheduler
// est asynchrone et rien chez Apple ne garantit qu'il ait publié la
// séance à l'instant où on le relit. Inventer une erreur à chaque
// envoi coûterait plus cher que le silence qu'on cherche. On rend
// ce qu'on voit, l'appelant le dit.
return await Self.scheduledSummaries()
}
/// Les séances programmées PAR CETTE APP, telles que le scheduler les rend.
@available(iOS 17.0, *)
private static func scheduledSummaries() async -> [[String: Any]] {
let plans = await WorkoutScheduler.shared.scheduledWorkouts
return plans.map { sw in
var out: [String: Any] = [
"date": ISO8601DateFormatter().string(
from: Calendar.current.date(from: sw.date) ?? Date()
),
"complete": sw.complete,
]
if case let .custom(custom) = sw.plan.workout {
// `displayName` est optionnel côté Apple : ne jamais poser un
// Optional dans le dictionnaire rendu au JS, il n'est pas
// sérialisable par le bridge Capacitor.
out["displayName"] = custom.displayName ?? ""
out["blocks"] = custom.blocks.count
out["steps"] = custom.blocks.reduce(0) { $0 + $1.steps.count }
out["iterations"] = custom.blocks.map { $0.iterations }
}
return out
}
}
@available(iOS 17.0, *)
private static func describe(_ state: WorkoutScheduler.AuthorizationState) -> String {
switch state {
case .authorized: return "authorized"
case .denied: return "denied"
case .notDetermined: return "notDetermined"
case .restricted: return "restricted"
@unknown default: return "unknown"
}
}
}
/// Erreurs qui doivent remonter jusqu'à l'écran, pas jusqu'au seul NSLog.
enum CoachWorkoutKitError: LocalizedError {
case authorization(String)
case notAuthorized(String)
var errorDescription: String? {
switch self {
case .authorization(let detail):
return "Autorisation « Séances programmées » refusée par iOS : \(detail)"
case .notAuthorized(let state):
return "Autorisation « Séances programmées » absente (\(state)) — "
+ "Réglages iPhone → coach → Séances, puis renvoyer la séance."
}
} }
} }

View File

@@ -25,6 +25,13 @@ import Foundation
import Capacitor import Capacitor
import HealthKit import HealthKit
import UserNotifications import UserNotifications
// Pour l'extension `HKWorkout.workoutPlan` (iOS 17+), qui rend la composition
// du plan dont une séance est issue ou nil si elle a été lancée à la main.
// Même garde que `CoachWorkoutKit.swift` : sur un SDK sans WorkoutKit, le
// fichier doit continuer à compiler, quitte à ne rien remonter.
#if canImport(WorkoutKit)
import WorkoutKit
#endif
@objc(CoachWorkoutObserverPlugin) @objc(CoachWorkoutObserverPlugin)
public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin { public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
@@ -375,8 +382,66 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
}.resume() }.resume()
} }
/// Dit au serveur si la séance vient d'une **séance programmée** ou d'un
/// démarrage à la main dans l'app Exercice.
///
/// C'est la seule chose qui distingue « la montre s'est tue alors qu'elle
/// devait parler » de « la séance n'a jamais été celle qu'on avait
/// poussée » : `tools/watch_alert_check.py` compte les alertes attendues à
/// partir du plan ENVOYÉ, mais rien, jusqu'ici, ne disait ce qui avait été
/// LANCÉ (cf. `coach_sportif/docs/GUIDE-MONTRE.md` §5bis, « angle mort »).
/// Le nom HealthKit ne le dit pas : il vaut « Course extérieure » dans les
/// deux cas.
///
/// Même parti pris que `reportToRoutineIfStrength` : on n'envoie que des
/// faits bruts, l'interprétation vit côté serveur donc sans rebuild iOS.
private func reportPlanOrigin(_ workout: HKWorkout) {
#if canImport(WorkoutKit)
guard #available(iOS 17.0, *) else { return }
Task {
var fromPlan = false
var planName = ""
do {
if let plan = try await workout.workoutPlan {
fromPlan = true
if case let .custom(custom) = plan.workout {
planName = custom.displayName ?? ""
}
}
} catch {
// Une lecture qui échoue n'est PAS un « lancé à la main » :
// sans réponse, on n'envoie rien plutôt qu'un faux négatif.
NSLog("[CoachWorkoutObserver] workoutPlan illisible: %@", error.localizedDescription)
return
}
guard let url = URL(string: "https://coach.hypnotruck.ch/api/workout/plan-origin") else { return }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie")
req.httpBody = try? JSONSerialization.data(withJSONObject: [
"uuid": workout.uuid.uuidString,
"start": ISO8601DateFormatter().string(from: workout.startDate),
"duration_min": workout.duration / 60,
"activity": Int(workout.workoutActivityType.rawValue),
"from_plan": fromPlan,
"plan_name": planName,
])
req.timeoutInterval = 15
URLSession.shared.dataTask(with: req) { _, _, error in
if let error = error {
NSLog("[CoachWorkoutObserver] plan-origin: %@", error.localizedDescription)
} else {
NSLog("[CoachWorkoutObserver] plan-origin envoyé (from_plan=%@)", fromPlan ? "true" : "false")
}
}.resume()
}
#endif
}
private func scheduleNotification(for workout: HKWorkout) { private func scheduleNotification(for workout: HKWorkout) {
reportToRoutineIfStrength(workout) reportToRoutineIfStrength(workout)
reportPlanOrigin(workout)
let durationMin = Int(workout.duration / 60) let durationMin = Int(workout.duration / 60)
var bodyParts: [String] = ["\(durationMin) min"] var bodyParts: [String] = ["\(durationMin) min"]
if let kcal = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()), kcal > 0 { if let kcal = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()), kcal > 0 {

View File

@@ -26,6 +26,8 @@
<false/> <false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Coach Hypnotruck se connecte en Bluetooth à votre montre Polar pour y déposer la séance du jour, avec ses phases et ses zones cardiaques. La connexion ne sert qu'à cet envoi et aucune donnée n'est lue sur la montre.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Coach Hypnotruck utilise l'appareil photo pour scanner les codes-barres des aliments, photographier vos assiettes et photographier les étiquettes nutritionnelles. Ces images servent à identifier l'aliment et à estimer ses valeurs nutritionnelles, puis à alimenter votre journal alimentaire.</string> <string>Coach Hypnotruck utilise l'appareil photo pour scanner les codes-barres des aliments, photographier vos assiettes et photographier les étiquettes nutritionnelles. Ces images servent à identifier l'aliment et à estimer ses valeurs nutritionnelles, puis à alimenter votre journal alimentaire.</string>
<key>NSHealthShareUsageDescription</key> <key>NSHealthShareUsageDescription</key>

View File

@@ -36,6 +36,11 @@ class MainViewController: CAPBridgeViewController {
// l'app watchOS, et relaie les coches faites sur la montre vers // l'app watchOS, et relaie les coches faites sur la montre vers
// /api/routine/day (cf. CoachRoutineBridge.swift). // /api/routine/day (cf. CoachRoutineBridge.swift).
bridge?.registerPluginInstance(CoachRoutineBridgePlugin()) bridge?.registerPluginInstance(CoachRoutineBridgePlugin())
// Écriture d'un objectif sur la Polar Vantage V3 en Bluetooth (PFTP).
// Ne transporte que des octets produits par le serveur, et refuse toute
// requête malformée avant d'ouvrir la radio le firmware plante au lieu
// de refuser (cf. CoachPolarBLE.swift et PolarPftpStep.swift).
bridge?.registerPluginInstance(CoachPolarBLEPlugin())
} }
/// Injecte le cookie d'auth dans le magasin de la WKWebView. /// Injecte le cookie d'auth dans le magasin de la WKWebView.

View File

@@ -0,0 +1,246 @@
// PolarPftpStep.swift
// Une opération d'écriture PFTP, et les garde-fous qui empêchent de planter la
// montre. N'importe QUE Foundation, délibérément.
//
// POURQUOI CE FICHIER EST SÉPARÉ DU PLUGIN
//
// `CoachPolarBLE.swift` importe Capacitor et le SDK Polar : il ne peut être
// compilé que sur un Mac, dans Xcode. Or ce qui est ici est exactement la
// partie qu'il faut pouvoir vérifier la sérialisation de l'en-tête, et les
// deux règles qui décident si une requête part ou non vers le firmware.
//
// Lié dans `tests-linux/Sources/CoachModel/` par un lien symbolique : les tests
// portent sur le fichier livré, pas sur une copie qui dériverait en silence.
//
// CE QUE CES GARDE-FOUS PROTÈGENT
//
// Le 2026-08-17, un PUT de 174 octets vers un chemin terminé par « / » a bloqué
// une Polar Vantage V3 : logo Polar puis écran noir, récupérée par un appui
// long sur OK (10-15 s). Le firmware ne renvoie pas d'erreur applicative sur
// une requête malformée il s'effondre. Le Bluetooth utilise le même protocole
// PFTP que l'USB : changer de transport n'enlève rien au risque.
//
// Les mêmes règles existent dans `tools/polar/polar_ftp.py::pftp_put()` du
// dépôt coach_sportif. Les deux doivent dire la même chose.
import Foundation
/// Codes d'erreur du protocole PFTP, tels que Polar les publie.
///
/// Source : `pftp_error.proto` du SDK officiel 100 `UNIDENTIFIED_HOST_ERROR`,
/// 101 `INVALID_COMMAND`, 102 `INVALID_PARAMETER`, 103 `NO_SUCH_FILE_OR_DIRECTORY`,
/// **104 `DIRECTORY_EXISTS`**, 105 `FILE_EXISTS`, 106 `OPERATION_NOT_PERMITTED`,
/// 107 `NO_SUCH_USER`, 108 `TIMEOUT`.
/// https://github.com/polarofficial/polar-ble-sdk/blob/master/sources/Android/android-communications/library/src/sdk/proto/pftp_error.proto
///
/// Le SDK iOS les remonte tels quels par
/// `BlePsFtpException.responseError(errorCode: Int)`.
public enum PftpCode {
/// Le dossier visé par un `mkdir` existe déjà.
///
/// **Ce n'est pas un échec d'envoi.** Rencontré le 01/09/2026 sur un
/// second envoi vers la même date : la session BLE s'était bien ouverte, et
/// c'est notre séquence de `mkdir` qui n'était pas idempotente. Le message
/// « errorcode 104 » donnait donc à croire à un refus de la montre alors
/// que le transport fonctionnait.
public static let directoryExists = 104
/// Codes sur lesquels un `mkdir` peut être considéré comme satisfait : le
/// dossier est là, c'est tout ce qu'on lui demandait. L'équivalent de
/// `mkdir -p`.
///
/// Strictement limité à la création de dossier. Un `put` de fichier ne
/// doit JAMAIS être avalé de la sorte 105 `FILE_EXISTS` signifierait que
/// l'objectif est déjà écrit, ce que l'appelant doit savoir.
public static func mkdirEstSatisfait(par code: Int) -> Bool {
code == directoryExists
}
}
/// Un PUT PFTP : un chemin, un contenu. Un contenu vide crée un dossier.
public struct PftpStep: Equatable {
public let path: String
public let data: Data
public init(path: String, data: Data) {
self.path = path
self.data = data
}
/// Un chemin terminé par « / » désigne un dossier, jamais un fichier.
public var isDirectory: Bool { path.hasSuffix("/") }
public var describe: String {
"\(isDirectory ? "mkdir" : "put ") \(path) (\(data.count) o)"
}
/// Refuse les formes qui ont fait, ou feraient, planter la montre.
///
/// Dernier rempart avant l'émission : à appeler à l'entrée du plugin ET
/// juste avant chaque écriture.
public func validate() throws {
if isDirectory && !data.isEmpty {
throw PolarPftpError.malformed(
"refus d'écrire \(data.count) octets vers « \(path) » : un chemin "
+ "terminé par « / » désigne un dossier. C'est cette requête exacte "
+ "qui a fait planter une Vantage V3 le 2026-08-17.")
}
if !isDirectory && data.isEmpty {
throw PolarPftpError.malformed(
"refus d'écrire un fichier vide vers « \(path) » : ajouter « / » "
+ "pour créer un dossier, ou fournir un contenu.")
}
if !path.hasPrefix("/U/0/") {
throw PolarPftpError.malformed(
"chemin « \(path) » hors de /U/0/ : refusé par précaution, rien "
+ "d'autre n'a jamais été écrit sur cette montre.")
}
}
/// `PbPFtpOperation { command = 1 (varint), path = 2 (string) }`.
///
/// Sérialisation identique à `encode_operation()` de `polar_ftp.py` :
/// `0x08` (champ 1, varint) + commande, puis `0x12` (champ 2, délimité) +
/// longueur + chemin. `PUT` vaut 1, d'après `pftp_request.proto` du SDK
/// (`enum Command { GET = 0; PUT = 1; MERGE = 2; REMOVE = 3; }`).
///
/// Pas de cadrage `[0x05, taille, taille]` ici : celui-là appartient au
/// transport série RFC76 de la version USB. En Bluetooth, le SDK cadre
/// lui-même l'ajouter produirait une requête malformée, c'est-à-dire
/// exactement ce qui plante la montre.
public func header() -> Data {
var bytes = Data([0x08, 0x01, 0x12])
let path = Array(self.path.utf8)
bytes.append(contentsOf: PftpStep.varint(path.count))
bytes.append(contentsOf: path)
return bytes
}
/// Varint protobuf, 7 bits par octet, bit de poids fort = continuation.
public static func varint(_ value: Int) -> [UInt8] {
var n = value, out: [UInt8] = []
repeat {
var b = UInt8(n & 0x7F)
n >>= 7
if n > 0 { b |= 0x80 }
out.append(b)
} while n > 0
return out
}
}
public enum PolarPftpError: Error, CustomStringConvertible, Equatable {
case malformed(String)
/// `vues` : appareils BLE aperçus · `sansPsFtp` : sans le service FEEE ·
/// `muettes` : avec FEEE mais qui n'ont pas répondu.
case watchNotFound(Double, vues: Int, sansPsFtp: Int, muettes: Int)
case psftpUnavailable
/// La radio elle-même est inutilisable : autorisation, Bluetooth éteint,
/// matériel. Distinct de `watchNotFound` ici on n'a même pas pu chercher.
case bluetoothUnusable(String)
/// Le SDK n'a remonté aucune session, mais CoreBluetooth, interrogé
/// directement, a vu `vusParCoreBluetooth` appareils. Deux diagnostics
/// opposés selon ce nombre.
/// Une opération du SDK n'a pas rendu la main dans le délai imparti.
case echeanceDepassee(Double)
/// Le scan a vu des appareils, mais aucun ne ressemble à un Polar. Les
/// exemples servent à voir sous quel nom la montre s'annonce réellement.
case aucunPolarParmi(vues: Int, exemples: [String])
case sdkSilencieux(vusParCoreBluetooth: Int,
publisherAParle: Bool = true,
erreurSdk: String? = nil)
/// Un échec de connexion a plusieurs causes OPPOSÉES, et un message
/// unique les confond c'est ce qui s'est produit au premier essai du
/// 2026-08-31. Ne jamais fusionner ces branches.
public var description: String {
switch self {
case .malformed(let why):
return why
case .psftpUnavailable:
return "session ouverte mais le service PsFTP (FEEE) n'a pas répondu"
case .bluetoothUnusable(let why):
return why
case .aucunPolarParmi(let vues, let exemples):
return "\(vues) appareil(s) Bluetooth vus, aucun ne ressemble à une "
+ "montre Polar. Vus : \(exemples.joined(separator: ", ")). "
+ "Si la montre est dans cette liste sous un autre nom, c'est le "
+ "tri qui est trop strict ; si elle n'y est pas, elle ne "
+ "s'annonce pas — probablement parce qu'elle est déjà liée à "
+ "Polar Flow."
case .echeanceDepassee(let seconds):
return "la montre n'a pas répondu en \(Int(seconds)) s. Réveiller son "
+ "écran et la rapprocher de l'iPhone ; vérifier que Polar Flow "
+ "est bien fermée."
case .sdkSilencieux(_, false, _):
// Ne PAS conclure « le scan n'a pas démarré » : le publisher de
// `search()` reste également muet quand le scan tourne mais ne
// découvre rien `scanSubject` n'émet que sur découverte, et le
// `prepend(knownSessions)` d'une liste vide n'émet pas. Une version
// précédente affirmait le contraire et a fait chercher au mauvais
// endroit pendant trois itérations.
return "le SDK Polar n'a remonté aucun appareil : soit son scan n'a "
+ "pas démarré, soit il tourne sans rien découvrir. L'état "
+ "Bluetooth, lui, a bien été atteint avant le scan."
case .sdkSilencieux(_, _, .some(let erreur)):
return "le SDK Polar a répondu par une erreur : \(erreur)"
case .sdkSilencieux(0, _, _):
return "aucun appareil Bluetooth alentour, même en scan direct. "
+ "La radio fonctionne mais ne voit rien : montre éteinte, hors "
+ "de portée, ou déjà connectée à un autre appareil de façon "
+ "exclusive."
case .sdkSilencieux(let bruts, _, _):
return "\(bruts) appareil(s) Bluetooth vus en scan direct, mais le SDK "
+ "Polar n'en remonte aucun. La radio va bien : le problème est "
+ "dans l'intégration du SDK, pas dans la montre ni dans l'iPhone."
case .watchNotFound(let seconds, 0, _, _):
// Rien du tout : le problème est en amont de la montre.
return "aucun appareil Bluetooth détecté en \(Int(seconds)) s. "
+ "Vérifier, dans l'ordre : le Bluetooth activé sur l'iPhone ; "
+ "l'autorisation Bluetooth accordée à coach (Réglages → coach) ; "
+ "la montre allumée et à portée."
case .watchNotFound(let seconds, let vues, _, let muettes) where muettes > 0:
// Le service est là mais ne répond pas : canal déjà pris.
//
// Ce message conseillait « fermer l'app Polar Flow ». C'était
// faux, et mesuré comme tel le 31/08/2026 : 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 fermer l'app ne rend pas
// le canal.
//
// Ce qui l'a rendu, le 01/09 : une montre fraîchement
// réinitialisée, donc pas encore liée à Flow. Redémarrer la montre
// libère aussi son canal, mais Flow le reprend.
return "\(vues) appareil(s) vu(s), \(muettes) portant PsFTP mais sans "
+ "réponse en \(Int(seconds)) s. Le canal PsFTP de la Vantage "
+ "n'accepte qu'une session, et Polar Flow la détient tant que "
+ "la montre lui est appairée — fermer l'app n'y change rien, "
+ "iOS maintient le lien. Redémarrer la montre (appui long sur "
+ "OK) libère le canal ; il faut écrire dans la foulée, avant "
+ "que Flow ne le reprenne. Chemin fiable : le câble."
case .watchNotFound(let seconds, let vues, _, _):
// Des appareils, mais aucun ne porte le service.
// Ce message parlait d'« inconnue restante ». Elle a été levée le
// 31/08/2026 : la montre EST atteignable et expose bien PsFTP
// (« 12 appareils vus, 1 portant PsFTP ») quand elle est
// connectée. N'en voir aucun est donc autre chose : montre éteinte,
// hors de portée, ou pas encore connectée à l'iPhone.
return "\(vues) appareil(s) Bluetooth vu(s) en \(Int(seconds)) s, aucun "
+ "n'expose PsFTP. La Vantage expose bien ce service quand elle "
+ "est connectée (vérifié le 31/08) : la chercher plutôt du côté "
+ "de la montre — allumée, à portée, et reliée à l'iPhone."
}
}
}

View File

@@ -15,9 +15,32 @@ import Foundation
* *
* - il se teste intégralement sur Linux (cf. `tests-linux/`), là où * - il se teste intégralement sur Linux (cf. `tests-linux/`), là où
* `WorkoutManager` ne le peut pas ; * `WorkoutManager` ne le peut pas ;
* - le temps de référence est `HKLiveWorkoutBuilder.elapsedTime`, qui * - le temps de référence est poussé par l'appelant ;
* **exclut déjà les pauses** : le moteur n'a donc rien à savoir de la *
* pause, ni de l'auto-pause de la montre ; * **CE COMMENTAIRE AFFIRMAIT LE CONTRAIRE DE SA SOURCE** (relevé le
* 2026-08-20, avant tout câblage). Il disait que
* `HKLiveWorkoutBuilder.elapsedTime` « exclut déjà les pauses », donc que
* le moteur n'avait rien à savoir de la pause. La doc Apple dit
* l'inverse, mot pour mot : « The elapsed time for the workout based on
* the builder's current contents, **including pauses**. »
* (developer.apple.com/documentation/healthkit/hkliveworkoutbuilder/elapsedtime)
*
* Conséquence si on câble le moteur sur cette propriété telle quelle :
* une pause de 5 min ferait avancer le déroulé de 5 min d'effort. Un
* fractionné mis en pause pour traverser une route se déroulerait tout
* seul, à l'arrêt.
*
* La propriété qui exclut réellement les pauses est
* `HKWorkoutBuilder.elapsedTime(at:)` « The duration of a workout
* doesn't include intervals between pause and resume events. » Ce n'est
* PAS la même API, et les deux textes d'Apple se contredisent
* frontalement sur ce point : à trancher avant le câblage.
*
* Le moteur, lui, reste correct : il ne fait qu'intégrer ce qu'on lui
* pousse. C'est l'appelant qui devra fournir un temps réellement actif ;
*
* - il ignore donc l'auto-pause de la montre, à condition que la source de
* temps ci-dessus soit correcte ;
* - il est rejouable : réinjecter la même suite de ticks redonne le même * - il est rejouable : réinjecter la même suite de ticks redonne le même
* déroulé, ce qui rend la reprise après crash triviale * déroulé, ce qui rend la reprise après crash triviale
* (`handleActiveWorkoutRecovery`). * (`handleActiveWorkoutRecovery`).

View File

@@ -165,21 +165,30 @@ final class LocationTracker: NSObject, ObservableObject {
/// pas l'appeler du tout fait perdre **toute** la trace le builder est /// pas l'appeler du tout fait perdre **toute** la trace le builder est
/// invalidé à sa libération. /// invalidé à sa libération.
/// ///
/// `workout` peut être `nil` : `finishWorkout()` réussit mais ne rend /// **Une trace ne peut PAS être sauvegardée sans workout.** Une version
/// pas l'objet **quand la montre est verrouillée** (confirmé par un /// antérieure de ce commentaire l'affirmait ; c'est faux, vérifié à la
/// ingénieur Apple). On sauvegarde alors la trace sans association plutôt /// source le 21/08 : la signature est
/// que de la jeter une route orpheline vaut mieux qu'une sortie sans /// `finishRoute(with workout: HKWorkout, metadata:)` non optionnelle
/// parcours. Une route ne peut être associée qu'une fois, et jamais après /// et Apple précise « You must have already saved this workout to the
/// coup. /// HealthKit store ». Il n'existe aucune API pour clore une route
/// orpheline. Le cas « montre verrouillée », où `finishWorkout()` rend
/// `nil` sans erreur, se traite donc **en amont** : `WorkoutManager` va
/// rechercher dans HealthKit le workout qui vient d'y être écrit. Ici, si
/// aucun workout n'arrive, il ne reste qu'à jeter la trace explicitement
/// (`discardRoute()`) un builder abandonné sans `discard()` laisse ses
/// données en suspens.
/// ///
/// Échoue aussi si aucune position n'a été insérée cas normal d'une /// Ne fait rien si aucune position n'a été insérée cas normal d'une
/// séance en salle, à ne pas remonter comme une anomalie. /// séance en salle, à ne pas remonter comme une anomalie.
func finishRoute(with workout: HKWorkout?) async { func finishRoute(with workout: HKWorkout) async {
await flushPendingRoute() await flushPendingRoute()
guard let builder = routeBuilder else { return } guard let builder = routeBuilder else { return }
self.routeBuilder = nil self.routeBuilder = nil
guard filter.acceptedCount > 0 else { guard filter.acceptedCount > 0 else {
// Séance en salle : rien à clore. Volontairement SANS `discard()`
// c'est le chemin le plus fréquent, et il fonctionnait tel quel ;
// on n'y introduit pas un appel qu'aucun build n'a validé.
locationLog.info("aucune position retenue : pas de trace a clore") locationLog.info("aucune position retenue : pas de trace a clore")
return return
} }
@@ -191,6 +200,19 @@ final class LocationTracker: NSObject, ObservableObject {
} }
} }
/// Abandonne la trace en cours.
///
/// Seul recours quand aucun workout n'a pu être associé : sans `discard()`,
/// le builder garde ses données côté HealthKit et « any further calls to
/// the builder raise an exception ». La sortie existera alors sans
/// parcours perte réelle, à tracer comme telle plutôt qu'à masquer.
func discardRoute() {
guard let builder = routeBuilder else { return }
self.routeBuilder = nil
builder.discard()
locationLog.error("trace abandonnee : aucun workout a associer (\(self.filter.acceptedCount) points perdus)")
}
// MARK: Écriture HealthKit // MARK: Écriture HealthKit
/// Écrit les points en attente. Par lots : une écriture par position /// Écrit les points en attente. Par lots : une écriture par position

View File

@@ -309,10 +309,19 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
LocationTracker.shared.stopTracking() LocationTracker.shared.stopTracking()
try? await builder.endCollection(at: date) try? await builder.endCollection(at: date)
// `finishWorkout()` rend `nil` SANS erreur quand la montre est // `finishWorkout()` rend `nil` SANS erreur quand la montre est
// verrouillée : ce n'est pas un échec. On poursuit avec `nil`, la // verrouillée : ce n'est pas un échec, la séance EST écrite dans
// trace sera sauvegardée sans association plutôt que perdue. // HealthKit seul l'objet manque. Comme `finishRoute(with:)`
// exige un `HKWorkout` non optionnel (vérifié dans la doc Apple),
// on va rechercher celle qui vient d'être sauvegardée.
let workout = try? await builder.finishWorkout() let workout = try? await builder.finishWorkout()
if let workout {
await LocationTracker.shared.finishRoute(with: workout) await LocationTracker.shared.finishRoute(with: workout)
} else if let recovered = await self.recentlySavedWorkout() {
workoutLog.info("workout recupere apres un finishWorkout() nil")
await LocationTracker.shared.finishRoute(with: recovered)
} else {
LocationTracker.shared.discardRoute()
}
} }
Task { @MainActor in Task { @MainActor in
self.isRunning = false self.isRunning = false
@@ -325,6 +334,46 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
} }
} }
/// La séance que HealthKit vient d'enregistrer, quand `finishWorkout()`
/// n'a rien rendu.
///
/// La fenêtre porte sur le **chevauchement**, pas sur la date de début :
/// sans `.strictStartDate`, un workout est retenu dès qu'il croise
/// l'intervalle. Filtrer sur son `startDate` raterait toute séance de plus
/// de quelques minutes c'est le piège qui avait rendu muettes les
/// notifications de fin de séance côté iPhone.
///
/// Restreinte à ce que **cette app** a écrit (`HKSource.default()`) : sans
/// ça, une séance enregistrée en parallèle par l'app Exercice d'Apple
/// pourrait récupérer notre trace.
private func recentlySavedWorkout() async -> HKWorkout? {
let recent = HKQuery.predicateForSamples(
withStart: Date(timeIntervalSinceNow: -Self.recoveryWindow), end: nil)
// Type écrit en toutes lettres : `predicateForObjects(from:)` a cinq
// surcharges (HKSource, Set<HKSource>, HKWorkout, Set<HKSourceRevision>,
// Set<HKDevice>) et un `.default()` abrégé s'y résout mal.
let mine = HKQuery.predicateForObjects(from: HKSource.default())
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [recent, mine])
let newestFirst = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
return await withCheckedContinuation { continuation in
let query = HKSampleQuery(sampleType: .workoutType(),
predicate: predicate,
limit: 1,
sortDescriptors: [newestFirst]) { _, samples, error in
if let error {
workoutLog.error("recuperation du workout impossible : \(error.localizedDescription)")
}
continuation.resume(returning: samples?.first as? HKWorkout)
}
healthStore.execute(query)
}
}
/// Fenêtre de recherche du workout de repli. Large assez pour couvrir une
/// sauvegarde lente, courte assez pour ne pas ramasser la séance d'avant.
private static let recoveryWindow: TimeInterval = 5 * 60
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
didFailWithError error: Error) { didFailWithError error: Error) {
let message = error.localizedDescription let message = error.localizedDescription

View File

@@ -0,0 +1 @@
../../../ios/App/App/PolarPftpStep.swift

View File

@@ -0,0 +1,339 @@
// Les garde-fous qui empêchent de bloquer une Polar Vantage V3.
//
// POURQUOI CES TESTS EXISTENT VRAIMENT
//
// Le 2026-08-17, une requête PFTP malformée 174 octets écrits vers un chemin
// terminé par « / », donc « du contenu dans un dossier » a bloqué la montre :
// logo Polar puis écran noir, récupérée par un appui long sur OK. Le firmware ne
// renvoie pas d'erreur applicative sur ce genre de requête, il s'effondre. Le
// Bluetooth utilise le même protocole que l'USB : changer de transport n'a rien
// enlevé au risque.
//
// Ces règles ne peuvent donc pas être vérifiées « à la relecture ». Elles sont
// la seule chose qui se dresse entre un bug de construction de chemin et une
// montre à plusieurs centaines de francs, et elles sont exécutées ici parce que
// le reste du plugin (Capacitor, PolarBleSdk) ne compile que sur un Mac.
//
// Les mêmes règles existent côté serveur dans
// `coach_sportif/tools/polar/polar_ftp.py::pftp_put()`. Si l'une des deux
// change, l'autre doit suivre.
import XCTest
@testable import CoachModel
final class PolarPftpStepTests: XCTestCase {
// MARK: - Les deux formes interdites
func testDossierAvecContenuEstRefuse() throws {
// La requête exacte qui a planté la montre le 2026-08-17.
let etape = PftpStep(path: "/U/0/20260831/TST/180000/TST.BPB/",
data: Data(repeating: 0x42, count: 174))
XCTAssertThrowsError(try etape.validate()) { erreur in
XCTAssertTrue("\(erreur)".contains("2026-08-17"),
"le message doit rappeler l'incident, pas seulement refuser")
}
}
func testFichierVideEstRefuse() {
// L'inverse : sans slash final la montre attend un fichier, et un
// fichier vide n'a aucun sens c'est un mkdir mal écrit.
let etape = PftpStep(path: "/U/0/20260831/TST/180000/TST.BPB", data: Data())
XCTAssertThrowsError(try etape.validate())
}
func testCheminHorsDeUZeroEstRefuse() {
// Rien d'autre que /U/0/ n'a jamais été écrit sur cette montre : tout
// le reste est une exploration en écriture, donc un risque de blocage.
let etape = PftpStep(path: "/SYS/quelquechose.BPB", data: Data([1, 2, 3]))
XCTAssertThrowsError(try etape.validate())
}
// MARK: - Les deux formes valides
func testDossierSansContenuEstAccepte() throws {
try PftpStep(path: "/U/0/20260831/TST/", data: Data()).validate()
try PftpStep(path: "/U/0/20260831/TST/180000/", data: Data()).validate()
}
func testFichierAvecContenuEstAccepte() throws {
try PftpStep(path: "/U/0/20260831/TST/180000/TST.BPB",
data: Data(repeating: 0x42, count: 240)).validate()
}
// MARK: - L'en-tête PbPFtpOperation
func testEnTeteReproduitLaSerialisationPython() {
// `encode_operation(PUT, path)` de polar_ftp.py :
// 0x08 (champ 1, varint) + 0x01 (PUT)
// 0x12 (champ 2, délimité) + longueur + chemin
let etape = PftpStep(path: "/U/0/", data: Data())
let attendu = Data([0x08, 0x01, 0x12, 0x05]) + Data("/U/0/".utf8)
XCTAssertEqual(etape.header(), attendu)
}
func testEnTeteAvecCheminReel() {
let chemin = "/U/0/20260831/TST/180000/TST.BPB"
let entete = PftpStep(path: chemin, data: Data([0x00])).header()
XCTAssertEqual(Array(entete.prefix(3)), [0x08, 0x01, 0x12])
XCTAssertEqual(entete[3], UInt8(chemin.utf8.count))
XCTAssertEqual(entete.suffix(chemin.utf8.count), Data(chemin.utf8))
}
func testEnTeteNePorteAucunCadrageSerie() {
// Le préfixe [0x05, taille, taille] appartient au transport RFC76 de la
// version USB. En Bluetooth, le SDK cadre lui-même : le reporter ici
// produirait une requête malformée c'est-à-dire ce qui plante la
// montre. Le premier octet doit être le tag protobuf, jamais 0x05.
let entete = PftpStep(path: "/U/0/test.BPB", data: Data([1])).header()
XCTAssertEqual(entete.first, 0x08)
}
// MARK: - Varint
func testVarintSurUnOctetEnDessousDe128() {
XCTAssertEqual(PftpStep.varint(0), [0x00])
XCTAssertEqual(PftpStep.varint(5), [0x05])
XCTAssertEqual(PftpStep.varint(127), [0x7F])
}
func testVarintPasseADeuxOctetsA128() {
// Un chemin de plus de 127 caractères existe : `/U/0/<date>/TST/<heure>/`
// plus un nom de fichier reste court, mais l'encodage doit être juste
// pour que la montre lise le bon nombre d'octets.
XCTAssertEqual(PftpStep.varint(128), [0x80, 0x01])
XCTAssertEqual(PftpStep.varint(300), [0xAC, 0x02])
}
func testEnTeteAvecCheminLongEncodeLaLongueurSurDeuxOctets() {
let chemin = "/U/0/" + String(repeating: "a", count: 200)
let entete = PftpStep(path: chemin, data: Data([1])).header()
XCTAssertEqual(Array(entete[3...4]), PftpStep.varint(chemin.utf8.count))
}
// MARK: - Description
func testLaDescriptionDistingueMkdirEtPut() {
XCTAssertTrue(PftpStep(path: "/U/0/x/", data: Data()).describe.hasPrefix("mkdir"))
XCTAssertTrue(PftpStep(path: "/U/0/x.BPB", data: Data([1])).describe.hasPrefix("put"))
}
}
// MARK: - Les trois causes d'échec ne doivent pas se confondre
//
// Au premier essai Bluetooth du 2026-08-31, un message unique disait « aucune
// montre exposant PsFTP » sans distinguer « rien vu du tout » (Bluetooth
// éteint, permission refusée) de « la montre est là mais ne répond pas » (canal
// occupé par Polar Flow). Ce sont des causes opposées et des gestes différents.
final class PolarPftpErrorTests: XCTestCase {
func testAucunAppareilVuPointeVersLIPhone() {
let e = PolarPftpError.watchNotFound(30, vues: 0, sansPsFtp: 0, muettes: 0)
XCTAssertTrue(e.description.contains("autorisation Bluetooth"))
XCTAssertFalse(e.description.contains("Polar Flow"),
"sans aucun appareil vu, accuser Flow envoie sur une fausse piste")
}
func testUneMontreMuettePointeVersFlow() {
let e = PolarPftpError.watchNotFound(30, vues: 3, sansPsFtp: 2, muettes: 1)
XCTAssertTrue(e.description.contains("Polar Flow"))
}
/// Le message conseillait « fermer complètement l'app Polar Flow ».
/// Mesuré faux le 31/08/2026 : 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.
func testLeMessageNeConseillePlusDeFermerLApp() {
let e = PolarPftpError.watchNotFound(30, vues: 3, sansPsFtp: 2, muettes: 1)
XCTAssertTrue(e.description.contains("fermer l'app n'y change rien"),
"le geste inutile doit être explicitement écarté")
XCTAssertTrue(e.description.contains("Redémarrer la montre"),
"le geste qui libère réellement le canal doit être donné")
XCTAssertTrue(e.description.contains("câble"),
"le chemin fiable doit être rappelé")
}
/// L'« inconnue restante » a été levée le 31/08 : la montre expose bien
/// PsFTP quand elle est connectée. Le message ne doit plus la présenter
/// comme une question ouverte.
func testDesAppareilsSansPsFtpNePresententPlusUneInconnue() {
let e = PolarPftpError.watchNotFound(30, vues: 4, sansPsFtp: 4, muettes: 0)
XCTAssertTrue(e.description.contains("n'expose PsFTP"))
XCTAssertTrue(e.description.contains("4 appareil"))
XCTAssertFalse(e.description.contains("inconnue"),
"la question a été tranchée, ne pas la rouvrir dans l'UI")
}
func testLesTroisMessagesSontDistincts() {
let messages = Set([
PolarPftpError.watchNotFound(30, vues: 0, sansPsFtp: 0, muettes: 0).description,
PolarPftpError.watchNotFound(30, vues: 3, sansPsFtp: 2, muettes: 1).description,
PolarPftpError.watchNotFound(30, vues: 4, sansPsFtp: 4, muettes: 0).description,
])
XCTAssertEqual(messages.count, 3)
}
}
// MARK: - « La radio ne marche pas » « la montre est introuvable »
//
// Le 2026-08-31, le scan a tourné 30 s et rendu « aucun appareil » alors que
// la vraie cause était en amont : iOS n'avait jamais demandé l'autorisation
// Bluetooth, et la ligne n'apparaissait même pas dans les réglages de l'app.
// Un message parlant de la montre envoyait chercher au mauvais endroit.
extension PolarPftpErrorTests {
func testRadioInutilisableNeParlePasDeLaMontre() {
let e = PolarPftpError.bluetoothUnusable(
"coach n'a pas l'autorisation d'utiliser le Bluetooth.")
XCTAssertFalse(e.description.contains("montre"))
XCTAssertTrue(e.description.contains("autorisation"))
}
func testRadioInutilisableEstDistinctDeMontreIntrouvable() {
let radio = PolarPftpError.bluetoothUnusable("Bluetooth désactivé").description
let montre = PolarPftpError.watchNotFound(30, vues: 0,
sansPsFtp: 0, muettes: 0).description
XCTAssertNotEqual(radio, montre)
}
}
// MARK: - Radio muette ou SDK muet : ce n'est pas le même diagnostic
//
// Le 2026-08-31, le SDK ne remontait aucune session et rien ne disait si
// CoreBluetooth lui-même voyait quelque chose. Sans cette mesure, impossible de
// savoir s'il fallait chercher du côté de la montre ou de notre code.
extension PolarPftpErrorTests {
func testAucunAppareilEnScanDirectAccuseLEnvironnement() {
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 0)
XCTAssertTrue(e.description.contains("hors\u{00A0}de portée")
|| e.description.contains("hors de portée"))
XCTAssertFalse(e.description.contains("intégration"))
}
func testDesAppareilsEnScanDirectAccusentLeSDK() {
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 12)
XCTAssertTrue(e.description.contains("intégration du SDK"))
XCTAssertTrue(e.description.contains("12"))
XCTAssertTrue(e.description.contains("La radio va bien"))
}
}
// MARK: - « On n'a jamais cherché » n'est pas « on n'a rien trouvé »
//
// `search()` du SDK commence par `monitorBleState().filter { $0 == .poweredOn }`.
// Si cet état n'arrive pas, le publisher ne dit RIEN : ni valeur, ni fin, ni
// erreur. Le timeout conclut alors « aucun appareil » alors que le scan n'a
// jamais démarré. Mesuré le 2026-08-31 : 43 appareils en scan direct, 0 par le
// SDK.
extension PolarPftpErrorTests {
func testPublisherMuetNAccusePasLaMontre() {
// Assoupli le 2026-08-31 : le message affirmait « le scan n'a pas
// démarré », ce qui était une conclusion de trop le publisher se tait
// aussi quand le scan tourne sans rien découvrir. Ce test vérifie
// désormais qu'on n'accuse ni la montre, ni une cause unique.
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
publisherAParle: false)
XCTAssertTrue(e.description.contains("aucun appareil"))
XCTAssertFalse(e.description.contains("Ce n'est pas la montre"),
"ne rien affirmer sur la montre : on n'en sait rien ici")
}
func testUneErreurDuSdkEstRapporteeTelleQuelle() {
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
publisherAParle: true,
erreurSdk: "bleNotReady")
XCTAssertTrue(e.description.contains("bleNotReady"))
}
func testPublisherActifSansSessionAccuseLIntegration() {
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
publisherAParle: true)
XCTAssertTrue(e.description.contains("intégration du SDK"))
}
}
// MARK: - Un envoi qui n'aboutit pas doit le dire
//
// Les `async` du SDK Polar n'ont aucune limite de temps. Le 2026-08-31, un envoi
// est resté figé sans message : `waitPsFtpReady` attendait une montre qui ne
// finissait pas la négociation. Une attente sans fin est pire qu'une erreur
// elle ne dit rien et n'apprend rien.
extension PolarPftpErrorTests {
func testLEcheanceDitQuoiFaire() {
let e = PolarPftpError.echeanceDepassee(12)
XCTAssertTrue(e.description.contains("12"))
XCTAssertTrue(e.description.contains("écran"))
XCTAssertTrue(e.description.contains("Polar Flow"))
}
func testLEcheanceNeSeConfondPasAvecUnScanVide() {
let echeance = PolarPftpError.echeanceDepassee(12).description
let vide = PolarPftpError.watchNotFound(30, vues: 0,
sansPsFtp: 0, muettes: 0).description
XCTAssertNotEqual(echeance, vide)
}
}
// MARK: - Ne pas conclure plus que ce qu'on a mesuré
//
// Le publisher de `search()` reste muet dans DEUX cas : le scan n'a pas démarré,
// ou il tourne sans rien découvrir (`scanSubject` n'émet que sur découverte).
// Un message affirmant le premier a fait chercher au mauvais endroit pendant
// trois itérations du 2026-08-31.
extension PolarPftpErrorTests {
func testLeSilenceDuSdkNAffirmePasQueLeScanEstArrete() {
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
publisherAParle: false)
XCTAssertTrue(e.description.contains("soit"),
"le message doit énoncer les deux possibilités")
XCTAssertFalse(e.description.contains("n'a jamais signalé"))
}
func testAucunPolarListeCeQuiAEteVu() {
let e = PolarPftpError.aucunPolarParmi(
vues: 43, exemples: ["Apple Watch de Sylvain", "(sans nom)"])
XCTAssertTrue(e.description.contains("43"))
XCTAssertTrue(e.description.contains("Apple Watch de Sylvain"))
XCTAssertTrue(e.description.contains("Polar Flow"),
"l'hypothèse du lien exclusif doit être énoncée")
}
}
// MARK: - Codes d'erreur PFTP (01/09/2026)
/// « errorcode 104 » rapporté par Sylvain sur un second envoi vers la même
/// date. Ce n'était pas un refus de la montre : la session BLE s'était ouverte,
/// et c'est notre séquence de `mkdir` qui n'était pas idempotente.
final class PftpCodeTests: XCTestCase {
func test104EstBienDirectoryExists() {
XCTAssertEqual(PftpCode.directoryExists, 104)
}
func testUnMkdirSurDossierExistantEstSatisfait() {
XCTAssertTrue(PftpCode.mkdirEstSatisfait(par: 104))
}
func testLesAutresCodesRestentDesEchecs() {
// 103 NO_SUCH_FILE_OR_DIRECTORY : le parent manque, l'ordre des mkdir
// est en cause surtout pas à avaler.
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 103))
// 105 FILE_EXISTS : l'objectif est déjà écrit. L'appelant DOIT le
// savoir le remplacer n'est visible qu'après redémarrage de la montre.
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 105))
// 106 OPERATION_NOT_PERMITTED, 108 TIMEOUT : de vrais refus.
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 106))
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 108))
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 0))
}
}