Erreurs remontées par Xcode sur le Mac :
CoachIntents.swift:126 Cannot convert value of type 'String' to expected
argument type 'IntentDialog'
CoachIntents.swift:152 idem
`IntentDialog` est `ExpressibleByStringInterpolation` : un littéral interpolé
passe — et les sept autres dialogues du fichier passent pour cette raison —
mais l'opérateur `+` produit une `String`, que le compilateur refuse. Mes deux
seules phrases écrites sur plusieurs lignes avec `+` étaient donc les deux
seules à casser.
Corrigé : un littéral d'un seul tenant pour la phrase courte, et
`IntentDialog(stringLiteral:)` pour celle qui se construit à partir de trois
valeurs. Le commentaire dit pourquoi, pour que la prochaine phrase longue ne
reparte pas sur un `+`.
⚠️ Exactement le genre d'erreur annoncée au commit précédent : rien de ce code
ne compile sur Linux, la vérification croisée des symboles ne voit pas les
types. Il fallait le build pour la trouver.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Demandé par Sylvain : des widgets, puis « comment lier Siri à mon appli ».
Constat avant de commencer :
App Intents déclarés : 1 (LogDrinkIntent)
AppShortcutsProvider : 0 -> Siri n'avait AUCUNE phrase à écouter
Saisie depuis la montre : 0 -> complications d'affichage seulement
Et la cause commune : `LogDrinkIntent` vivait dans `CoachQuickWidget.swift`,
qui n'appartient qu'à l'extension widget iPhone. Ni l'app (d'où Siri découvre
les raccourcis), ni la cible Watch ne pouvaient le voir.
Ce qui est ajouté
------------------
- `CoachIntents.swift` — les intents, partagés entre les TROIS cibles.
`LogDrinkIntent` y est déplacé (pas dupliqué : deux définitions du même
symbole dans une cible qui compile les deux fichiers casseraient le build),
plus `HydrationStatusIntent` et `TodaySessionIntent`.
- `CoachAppShortcuts.swift` — 5 raccourcis, 16 phrases françaises. Cible App
UNIQUEMENT : Apple attend un seul AppShortcutsProvider par application.
- `CoachWatchHydration.swift` — widget Smart Stack de la montre : total du
jour, jauge, et deux boutons +cl. Le geste se fait au poignet.
Choix qui méritent d'être dits
-------------------------------
⛔ Pas de saisie des repas sur la montre. Pas de caméra, pas de scanner, pas de
saisie de texte confortable : le bouton ne ferait que renvoyer vers l'iPhone.
Tranché avec Sylvain.
⛔ Pas de volume libre dans les phrases Siri. Un intent paramétré oblige Siri à
poser une question — plus long que de sortir le téléphone, et le raccourci perd
sa raison d'être. Volumes fixés : 250 ml, 500 ml, café 60 ml.
⛔ Aucune borne en bpm dans les réponses vocales. `TodaySession` n'en porte pas
(vérifié : sport, title, subtitle, done) et il ne faut pas en ajouter — une
fourchette cardiaque annoncée par haut-parleur, hors de la recommandation du
jour et de ses réserves, reviendrait à prescrire une intensité à un
post-infarctus. Siri annonce QUOI ; l'app dit COMMENT.
Deux boutons et pas trois sur la montre : sur 160 pt, un troisième descend les
cibles sous le seuil tactile, pour un geste qu'on fait en marchant.
⚠️ RIEN DE CE CODE N'EST COMPILÉ
---------------------------------
AppIntents, WidgetKit et SwiftUI sont absents de Swift pour Linux. Ce qui a été
vérifié à la place, par lecture croisée des sources : que chaque symbole appelé
existe, et qu'aucun n'est déclaré deux fois. Deux erreurs ont d'ailleurs été
trouvées ainsi avant commit — `asOf()` ne rend pas un optionnel, et
`TodaySession` n'a pas de `durationMin`. Les erreurs de type, de concurrence et
d'API n'apparaîtront qu'au build.
`docs/siri-et-widget-watch-runbook-mac.md` porte les étapes Xcode, le tableau
de Target Membership, les cinq vérifications à faire sur l'appareil, et les
trois questions que seul le build tranchera — dont celle-ci : une complication
de cadran accepte-t-elle un bouton interactif, ou est-ce réservé à la Smart
Stack ? Par prudence, le cadran n'est ici qu'un affichage.
91 tests Swift Linux au vert (non-régression CoachQuickLog / snapshot).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sur demande explicite de Sylvain (« pour le reste vas-y »), après le § 1.2 buildé
et vérifié le matin. Les cinq constats de l'audit sont désormais traités.
⚠️ § 2.3 et § 2.4 touchent `WorkoutManager.swift` — la fin de séance du live
workout, que la consigne du 22/09 demandait de ne pas approcher. Fait en ne
changeant AUCUNE logique : les `try?` deviennent des `do/catch` qui journalisent,
et le chemin d'échec gagne la clôture qui lui manquait. La récupération par
`recentlySavedWorkout()` est intacte.
§ 1.3 — `CoachSleep` teste `authorizationStatus` : `.notDetermined` ne se
confond plus avec une nuit sans sommeil, et le serveur cessera d'enregistrer des
nuits vides sur lesquelles `sleep_score.py` calculait une régularité. Un refus
EXPLICITE reste indiscernable d'une absence de données — HealthKit ne le révèle
pas en lecture, par conception. L'en-tête du fichier dit maintenant ce que la
promesse couvre au lieu de promettre plus.
§ 2.3 — le `else` final distinguait deux situations opposées qui y tombaient
ensemble : montre verrouillée (la séance EST écrite) et `endCollection` qui a
levé (rien n'est écrit). Seule la seconde est une perte, et elle seule renseigne
`statusMessage` — annoncer une perte sur une montre verrouillée serait une
fausse alerte.
§ 2.4 — `stopTracking()` d'abord : c'est le seul geste qui réussit à coup sûr et
le seul dont l'omission se paie en batterie pendant des heures. Puis une
tentative de sauvegarde en best-effort, avec `recentlySavedWorkout()` en second
recours. `builder = nil` n'intervient plus qu'après les tentatives — il
détruisait jusqu'ici la seule référence, rendant la perte définitive dans
l'instruction qui l'ignorait.
§ 2.6 — `NSLock` sur le tampon GPS, comme aux quatre autres endroits du même
fichier. ⚠️ Invérifiable sans son déclencheur : un workout à plusieurs
HKWorkoutRoute. Sa correction ne se constatera pas plus que le défaut ne se
manifestait.
⛔ NON COMPILÉS. Le test qui comptera pour § 2.3 et § 2.4 n'est pas un log :
c'est une séance réelle menée jusqu'au bout, puis retrouvée dans Santé et dans
coach. Tant qu'elle n'a pas eu lieu, ce sont des intentions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§ 1.2 de l'audit, vérifié le matin même puis corrigé sur demande explicite de
Sylvain — le gel du 22/09 est levé pour ce point seul, les quatre autres
constats restent intouchés.
Pourquoi celui-là d'abord : c'est le seul dont la conséquence est un chiffre
faux affiché PENDANT l'effort, chez quelqu'un qui règle son intensité dessus.
Les autres perdent de la donnée ou de la batterie, ça se rattrape. Une borne de
zone périmée au poignet a déjà servi à décider.
**L'envoi ne se tait plus.** `pushToWatch` avait quatre sorties silencieuses sur
sept lignes ; il en a maintenant six, toutes nommées et journalisées —
WatchConnectivity non supportée, session non activée, aucune montre appairée,
app Watch non installée, encodage impossible, envoi refusé. Les deux gardes du
milieu viennent de `CoachRoutineBridge`, qui les avait déjà : sans elles,
l'absence de montre se confond avec un échec d'envoi. Le log de succès nomme ce
qui compte — `zones=oui depuis=2026-08-12` — pour savoir quelles bornes sont au
poignet sans ouvrir la montre.
⚠️ Un échec ne fait PAS échouer `setSnapshot`, contrairement à
`CoachRoutineBridge` où l'envoi EST l'opération. Ici l'App Group est déjà écrit
et les widgets iPhone rafraîchis : une montre verrouillée n'invalide rien. D'où
un `resolve` enrichi (`watchPushed`, `watchReason`) plutôt qu'un `reject`, et
`ok` conservé — aucun appelant JS ne casse.
**La date de version voyage enfin.** `HeartRateZones` ne portait aucune date,
alors que le backend sert une version figée et datée (règle 9 du CLAUDE.md web) :
elle se perdait au transport, et après une révision rien au poignet ne
distinguait les nouvelles bornes des anciennes. `zonesSince` et `zonesFrom`
rejoignent le modèle en optionnels. Le serveur les servait déjà — c'est le JS du
dépôt web qui ne les faisait pas monter.
Trois tests au banc Linux : la date survit à `asOf()`, elle survit à
l'aller-retour JSON (ce qui n'est pas encodé n'arrive jamais), et un snapshot
antérieur à ces champs se décode toujours — un décodage qui lèverait laisserait
la montre sans zones du tout, pire que des zones sans date. 91 verts (88 avant).
⛔ NON COMPILÉ. Syntaxe vérifiée (`swiftc -parse`), mais Capacitor,
WatchConnectivity et SwiftUI n'existent pas sur Linux. Au prochain build :
ouvrir la Console sur la catégorie `widget` et vérifier qu'une ligne
« snapshot poussé vers la Watch · zones=oui depuis=… » apparaît au lancement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Apple, « Accessing condensed workout samples » : HealthKit fusionne les
échantillons d'une séance en séries (count > 1), supprime les originaux,
« while still preserving data resolution », et le détail se relit avec
HKQuantitySeriesSampleQuery. Depuis watchOS 27 (21/09/2026), les pas
d'une course arrivent en 4 à 7 tranches de 5 à 12 min ; le plugin Capgo
lit quantity.doubleValue sans regarder count.
getStepSamples(startDate, endDate) lit les pas de la fenêtre, déplie
chaque série et rend aussi les comptes (raw / series / expanded) : c'est
la preuve que le détail existe encore — ou qu'il n'existe plus. Lecture
seule. Syntaxe vérifiée (swiftc -parse), build Xcode à faire.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
À la demande explicite de Sylvain (22/09), après la perte des pas fins
côté montre. Apple documente que les HKWorkoutActivity peuvent
« represent the active and rest periods during interval training », mais
ne dit pas que l'app Exercice en écrit une par étape d'un exercice
personnalisé. Ce champ `activities` (début, fin, durée, type, nombre
d'événements, métadonnées en clair) permet de le constater dans un brut.
Le serveur ne s'en sert pas : rien n'est stocké ni affiché tant que la
donnée n'a pas été vue. Syntaxe vérifiée (swiftc -parse), build Xcode à
faire sur le Mac mini.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Trois défauts du même chemin, tous vérifiés ligne à ligne avant correction.
1. Toutes les séances d'un lot sauf une étaient perdues DÉFINITIVEMENT.
L'ancre est sauvegardée ligne 298, puis seul `latest` était traité ligne 319 —
or scheduleNotification était le seul appelant de reportToRoutineIfStrength et
reportPlanOrigin. Renfo le matin, vélo le soir, iPhone resté à la maison : au
premier fire la requête ancrée rend les deux, le vélo est notifié, le renfo
n'est jamais POSTé, et l'ancre ayant avancé il ne reviendra jamais. Le log
disait « 2 new workout(s), notifying latest » : exact, et parfaitement muet sur
ce qu'il venait de jeter. Les rapports serveur bouclent désormais sur tout le
lot ; la notification reste unique, un « 🎉 » par séance serait du bruit.
2. Le statut HTTP n'était jamais lu. Les deux POST faisaient
`{ data, _, error in }` et ne regardaient que `error`. Un 401 sur token rotaté,
un 404 ou un 500 n'en produisent pas : reportPlanOrigin journalisait donc
« plan-origin envoyé » sur une requête refusée, et auto-complete sortait en
silence au guard de parsing. Un helper `envoyer(_:libelle:traiter:)` vérifie
maintenant (200..<300) et journalise le code — le pattern existait déjà dans
CoachRoutineBridge.postDay, à deux fichiers de là.
3. Une séance de musculation classique était étiquetée fonctionnelle.
L'activité était écrite en dur alors que le garde accepte aussi
traditionalStrengthTraining. Si la règle de correspondance côté serveur
discrimine sur ce champ, l'appariement était faussé. L'UUID et l'heure de début
sont ajoutés au passage : sans eux le serveur ne peut pas dédupliquer et doit
évaluer sa fenêtre de 12 h depuis l'heure de réception — une séance remontée
avec six heures de retard était datée de son arrivée.
⚠️ PAS corrigé, et assumé : le `defer { completion() }` dit à iOS que la
livraison est traitée alors que les requêtes sont encore en vol, sans
beginBackgroundTask. Le faire proprement demande un import UIKit, une bascule
sur le fil principal et un essai sur appareil — à traiter à part plutôt qu'en
marge. Le statut HTTP étant désormais lu, un échec laissera au moins une trace.
Relevé § 2.1, § 2.2 et § 3.2 de docs/AUDIT-2026-09-22.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
setPaused vidait `pendingRoute` pour « ne pas tracer la pause ». Mais ce tampon
ne contient que des points DÉJÀ acceptés par le filtre et DÉJÀ comptés dans
`distanceMeters` : ils attendent seulement leur écriture, qui ne part qu'au
seuil de routeFlushThreshold (25). À 1 Hz, c'est une douzaine de points en
moyenne, jusqu'à 24.
L'écart était donc visible : la distance affichée les incluait, la
HKWorkoutRoute non. Six arrêts sur une sortie VTTAE — feux, photos, ravito — et
jusqu'à 144 points manquaient à la trace, avec un D+ recalculé depuis elle qui
divergeait d'autant de celui affiché pendant la séance. Aucun log ne
mentionnait l'effacement.
Ils sont désormais écrits, via le même `Task { await flushPendingRoute() }` que
le flush au seuil. Le raisonnement du commentaire d'origine visait le point qui
SUIT la reprise, à des centaines de mètres de là — mais celui-là n'est pas
encore dans le tampon quand la pause commence, et c'est `implausibleSpeed` qui
l'écarte. Nettoyer la pause est l'affaire du filtre, pas du tampon d'écriture.
Pas de course possible : `isPaused` est posé avant, et `ingest` sort dès qu'il
est vrai — aucun point neuf ne peut s'ajouter pendant que le flush s'exécute.
Relevé § 2.5 de docs/AUDIT-2026-09-22.md, seul constat de la section vérifié
ligne à ligne avant correction. 88 tests Linux verts (RouteFilter compris), mais
ils ne couvrent pas ce fichier : CoreLocation n'existe pas sur Linux.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Xcode refuse l'installation quand une extension ne porte pas le même
CFBundleVersion que l'app qui la contient : « The CFBundleVersion of an app
extension ('22') must match that of its containing parent app ('23') ».
CoachWatchWidgetsExtension et CoachLiveActivity étaient à 22 ; App et
CoachWatch à 23. Les quatre configurations concernées passent à 23, ce qui
aligne les huit.
⚠️ Ce décalage reviendra à chaque incrémentation faite target par target. La
parade durable serait de porter CURRENT_PROJECT_VERSION au niveau du projet et
de laisser les targets en hériter, au lieu de le redéfinir huit fois — à faire
un jour où l'on ne court pas après un build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La première version conditionnait la création de la fenêtre à `window == nil`,
par prudence : le manifeste déclarant UISceneStoryboardFile = Main, je
supposais qu'UIKit avait déjà monté la fenêtre depuis le storyboard et qu'il
ne fallait pas l'écraser.
Sur appareil, le résultat est un écran noir permanent, sans erreur, sans log,
sans crash. UIKit fournit bien un objet fenêtre, mais ne le rend pas visible à
notre place : la garde sautait donc makeKeyAndVisible() et rien ne s'affichait
jamais. Le fond noir de capacitor.config.ts rendait le symptôme indiscernable
d'un écran d'attente.
Le template de Capacitor crée la fenêtre sans condition. C'est le chemin
éprouvé, et s'en écarter « par sécurité » aura coûté un aller-retour de build.
Le seul écart qui reste nécessaire est MainViewController au lieu d'un
CAPBridgeViewController nu : notre sous-classe enregistre les neuf plugins
in-app dans capacitorDidLoad(), et ne dépend de rien du storyboard.
⚠️ À retenir pour la suite : sur ce chemin, une garde défensive ne protège de
rien et masque le défaut qu'elle prétend éviter. La condition de succès était
observable (une fenêtre visible), pas déductible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le fichier épinglait encore 8.3.1, ce qui empêchait Xcode de résoudre le paquet (croix rouge dans Package Dependencies) alors que xcodebuild y parvenait en ligne de commande. Supprimé et régénéré.
Le build échouait sur « unterminated string literal » dans
CapApp-SPM/Package.swift ligne 7, et en cascade sur trois « Missing package
product » (CapApp-SPM, PolarBleSdk, GoogleSignIn). Rien de tout cela ne venait
des paquets : SPM ne pouvait simplement pas lire un manifeste qui contenait
platforms: [.iOS(.v"$)].
Cause racine, lue dans le code du CLI (util/spm.js -> ios/common.js) :
getMajoriOSVersion cherche la chaîne « IPHONEOS_DEPLOYMENT_TARGET = » dans le
pbxproj et prend les DEUX caractères qui suivent, sans vérifier que c'est un
nombre. Xcode ayant rempli ce réglage avec "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_
TARGET)" en ouvrant le projet, le CLI récupérait littéralement les caractères
« "$ » et les recopiait dans le manifeste.
Les quatre occurrences de cette variable étaient toutes sur CoachWatch et
CoachWatchWidgetsExtension, où IPHONEOS_DEPLOYMENT_TARGET est INOPÉRANT : un
target watchOS se règle par WATCHOS_DEPLOYMENT_TARGET. Les remplacer par 15.0
— la valeur du projet — ne change donc aucun comportement, et rend au CLI une
valeur qu'il sait lire. Vérifié : la première occurrence rend désormais « 15 »,
le projet se reparse et les quatre targets sont intacts.
⚠️ Constaté au passage, non corrigé parce que c'est une décision produit : le
target App est à IPHONEOS_DEPLOYMENT_TARGET = 26.0. L'app ne s'installera donc
que sur iOS 26 ou plus récent. Si ce n'est pas délibéré, c'est Xcode qui l'a
monté en appliquant ses réglages recommandés, et il faut le redescendre.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CoachPolarBLE.swift et PolarPftpStep.swift étaient versionnés mais absents du projet Xcode : rien ne les compilait, et la dépendance PolarBleSdk n'était déclarée nulle part. L'intégration ne vivait que sur le Mac. Inclut aussi ce qu'Xcode a réécrit en ouvrant le projet : renommage Embed App Extensions vers Embed Foundation Extensions, résolution SPM et les deux schémas.
Sans cette entrée, Xcode ne compile pas le fichier et SceneDelegate.self reste
introuvable : l'app ne démarrerait pas davantage qu'avant la migration.
Fait avec la bibliothèque `xcode` que Capacitor utilise lui-même pour cette
opération (addSwiftFileToAppTarget), pas à la main. Essai à blanc d'abord pour
confirmer la cible : getFirstTarget() rend bien « App » et non l'un des trois
autres targets (CoachWatch, CoachWatchWidgetsExtension, CoachLiveActivity).
Deux retouches après coup :
- la bibliothèque écrivait `explicitFileType = undefined` sur la référence.
explicitFileType prime sur la détection automatique, et cette valeur aurait
pu empêcher Xcode de reconnaître le fichier comme du Swift. Normalisé au
format exact d'AppDelegate.swift, ligne pour ligne ;
- elle a reformaté le groupe synchronisé CoachWatchWidgets de sa forme compacte
vers le format multi-lignes. Vérifié champ par champ : même isa, même UUID
d'exception, mêmes explicitFileTypes et explicitFolders vides, même path.
Reformatage pur, aucune perte.
Intégrité vérifiée en comparant les deux fichiers parsés, objet par objet :
hors nos deux ajouts, aucune autre différence. Le groupe App passe de 28 à 29
enfants et la phase Sources de 20 à 21 fichiers, avec zéro retrait de part et
d'autre. Le projet se reparse et les quatre targets sont intacts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le build échouait au lancement sur _UIApplicationEvaluateRuntimeIssueForNo
SceneLifecycleAdoption. Apple est explicite : « Beginning in iOS 27 […] apps
built with the latest SDK must adopt the scene-based life cycle or they fail to
launch » (Transitioning to the UIKit scene-based life cycle). Ce n'est plus un
avertissement comme en iOS 26, c'est un assert. Aucune dérogation n'est
documentée : ni clé Info.plist, ni entitlement, ni date de tolérance — le seul
report possible serait de ne pas construire avec le dernier SDK, ce qui ne fait
que déplacer l'échéance sous contrainte de release.
Capacitor a adopté UIScene en 8.5.0 (PR #8536, « required in iOS 27 »), et
8.5.2 corrige une régression qui faisait échouer chaque démarrage à froid sur
une JS Eval error (#8595). On passe donc de 8.3.1 à 8.5.2, pas à 8.5.0.
⚠️ npx cap migrate N'A PAS été utilisé, et c'est volontaire : le guide officiel
exclut les projets à gestion d'URL personnalisée dans l'AppDelegate — « Projects
with hand-rolled scene delegates or custom AppDelegate URL handling should
follow the manual steps instead ». Notre branche GoogleSignIn en est la
définition. Le SceneDelegate suit le template livré dans le paquet 8.5.2, avec
deux écarts nécessaires décrits ci-dessous.
⚠️ CE QUI CASSE EN SILENCE, et qui est repris dans SceneDelegate.swift. Une
fois le manifeste posé, UIKit cesse d'appeler trois méthodes de l'AppDelegate
« even if you keep them implemented » (Apple) :
- application(_:open:options:) -> le retour OAuth Google partirait vers Safari
sans jamais revenir. Déprécié iOS 26, remplacé par scene(_:openURLContexts:).
La signature change : une URL devient un Set, donc on filtre ce que Google
consomme avant de passer le reste à Capacitor.
- applicationDidBecomeActive -> les saisies du widget (eau, café) resteraient
bloquées dans l'App Group. Reprise dans sceneDidBecomeActive.
- application(_:continue:restorationHandler:) -> les Universal Links des
widgets et complications ouvriraient Safari, où le cookie d'auth n'existe pas.
Aucune des trois ne produit d'erreur de compilation. Elles ne sont pas laissées
en doublon dans l'AppDelegate : une méthode morte qui a l'air vivante fait
chercher la panne au mauvais endroit.
✅ CE QUI NE BOUGE PAS, vérifié dans la doc avant d'écrire une ligne :
didFinishLaunchingWithOptions reste appelé, et Apple documente la séquence
(launch -> création des scènes -> scene(_:willConnectTo:)). L'injection du
cookie garde donc son ordre : elle s'exécute avant que la WKWebView charge. La
règle non négociable n°2 du CLAUDE.md est tenue. Les callbacks de notification
distante restent aussi sur l'AppDelegate — UISceneDelegate n'a aucun équivalent
et ils ne sont pas dépréciés.
Écart au template Capacitor : il pose un CAPBridgeViewController() nu comme
rootViewController. L'appliquer tel quel remplacerait MainViewController, qui
porte l'enregistrement des 9 plugins natifs et la config WKWebView — sans
erreur visible. Le manifeste gardant UISceneStoryboardFile = Main, UIKit charge
déjà le storyboard : on ne crée la fenêtre que s'il ne l'a pas fait, et avec
notre sous-classe.
npm audit : 0 vulnérabilité en production. 13 dans l'outillage de build, qui ne
part pas dans l'app. ⛔ Ne PAS lancer npm audit fix --force : il propose de
redescendre @capacitor/cli en 7.6.9, ce qui supprimerait le support UIScene.
⚠️ RESTE À FAIRE SUR LE MAC, et rien ne compilera sans : ajouter
SceneDelegate.swift au target App dans Xcode. Le dossier App/ utilise des
références explicites dans le pbxproj (les groupes synchronisés ne couvrent que
les targets Watch/Widgets), et je n'édite pas un pbxproj à l'aveugle.
Vérifié ici : plist valide et relu par plistlib, syntaxe des deux fichiers
Swift validée par swiftc -parse. Aucune compilation possible sur Linux.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`didCollectDataOf` se déclenche dès qu'UN type est collecté. Quand le capteur
optique décroche — bracelet desserré, froid, arythmie, et la FA thermoablée est
précisément le terrain à risque — les calories continuent d'arriver et `hr`
vaut nil. La ligne `if let hr { heartRate = hr }` gardait alors la dernière
valeur connue, et `sendSample` la renvoyait à l'iPhone avec un `ts` NEUF.
Conséquence en trois étages : au poignet, une FC morte s'affichait stable avec
sa zone colorée, indéfiniment ; côté iPhone, `LiveStore` posait
`lastSampleAt = Date()` et `status = .live`, donc son watchdog — qui ne regarde
que l'arrivée de samples — ne pouvait JAMAIS la déclarer perdue ; et aucun log
ne le signalait. De quoi décider de tenir une pente sur un chiffre qui
n'existait plus.
La FC porte désormais la date de sa dernière mesure réelle. `freshHeartRate`
rend 0 au-delà de `heartRateStaleAfter`, et c'est lui qui est affiché et émis.
0 est la convention « inconnu » déjà partagée par toute la chaîne :
`HeartRateMetric` cesse d'afficher une zone, `heartRateText` rend « — » côté
iPhone. Rien à changer dans LiveStore, qui reste à `.live` à juste titre — la
connexion est vivante, c'est la mesure qui manque, et les deux méritent d'être
distingués.
Le seuil de 10 s est un choix d'ingénierie, pas une donnée physiologique :
Apple ne publie aucune cadence garantie d'échantillonnage en séance. Il est
posé au-dessus du `lostThreshold` de 8 s de LiveStore pour que la FC ne se
périme jamais AVANT que la connexion soit déclarée perdue — deux signaux
contradictoires seraient illisibles au poignet. Commenté comme tel, avec la
consigne de l'augmenter si « — » clignote plutôt que de retirer la péremption.
Trouvé au passage : `WorkoutManager` est un singleton et ne remettait RIEN à
zéro entre deux séances. La FC, les calories et la distance de la séance
précédente restaient affichées jusqu'au premier échantillon de la suivante.
`resetMetrics()` est appelé au démarrage, séance réelle comme simulation.
Limite assumée et commentée : la péremption est évaluée dans `ingest`, donc
elle suppose qu'un échantillon d'un type quelconque continue d'arriver — le cas
visé. Si plus rien n'arrive, l'écran entier se fige, chronomètre compris, ce
qui se voit ; un timer dédié coûterait de la batterie pour un cas déjà visible.
⚠️ Vérifié ici par `swiftc -parse` (syntaxe) et les 88 tests Linux, qui ne
couvrent PAS ce fichier : WatchKit et HealthKit n'existent pas sur Linux. Reste
à compiler et à essayer sur la montre — notamment que « — » n'apparaisse pas en
usage normal.
Relevé § 1.1 de docs/AUDIT-2026-09-22.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La vitesse de départ du test à paliers (VAMEVAL, Léger 1988) est désormais
saisie par l'utilisateur au lieu d'être figée à 8,0 km/h.
Le web a retiré ce défaut le 2026-08-25 (C9b) parce qu'il ne correspond
à aucun protocole publié et sous-estime la VMA de 0,5 km/h. La montre
suivait une valeur fausse sans permettre à l'utilisateur de la modifier —
tout VAMEVAL lancé au poignet partait donc faux.
Changements :
- startKmh : let constant → @State variable (source de vérité partagée)
- setupView : ajout TextField pour saisir la vitesse (visible si proto == .stages)
- Bouton Démarrer : désactivé tant que startKmh <= 0 en mode paliers
Le changement @State/let ne crée pas d'incompatibilité iOS 27 : @State est
utilisé partout dans la vue. iOS 27 n'en refuse que l'initialisation double
(valeur initiale + assignation en initializer), cas que nous n'avons pas.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
« 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>
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>
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>
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>
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>
Deux sessions ont ajouté le même fichier à la cible CoachWatch en parallèle —
l'une depuis Xcode sur le Mac (be1ffec), l'autre en éditant le pbxproj sous
Linux (a04dfc4). Le rebase a fusionné les deux sans conflit, puisqu'elles
touchaient des lignes différentes : le fichier se retrouvait déclaré deux fois
et Xcode aurait refusé de compiler pour redéclaration.
Les entrées Xcode sont conservées, les miennes retirées. RouteFilter et
LocationTracker n'étaient pas concernés.
Repère de contrôle, à refaire après toute fusion touchant le pbxproj : chaque
fichier doit rendre 2 pour `grep -c '<Nom>.swift in Sources'` — une seule
cible. 4 signale un doublon.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La cible watchOS ne contenait pas une ligne de localisation : ni
CLLocationManager, ni HKWorkoutRouteBuilder, et aucune clé NSLocation dans son
Info.plist. Tant que les séances partaient de l'app Exercice d'Apple, c'est
elle qui écrivait la trace et CoachHealthRoute la relisait après coup. Dès
qu'une séance démarre depuis CoachWatch, plus personne ne l'écrit : la sortie
n'aurait ni carte, ni parcours dans Santé.
Le filtrage vit dans `RouteFilter`, en Foundation pur, donc testable ici :
rejet des points imprécis (> 50 m, seuil de l'exemple Apple), des sauts
impossibles, des doublons et des points arrivés dans le désordre, puis cumul de
la distance (haversine) et du dénivelé.
Le dénivelé a demandé deux passes. L'hystérésis seule laissait passer 297 m de
D+ sur un parcours PLAT : une oscillation d'amplitude égale au seuil est
comptée à chaque alternance, ce qui est inhérent à tout seuil. D'où un lissage
préalable, qui annule le bruit alternant. Contrepartie assumée et testée : une
pointe franchie en quelques points est écrêtée, donc sous-comptée — sans
conséquence, le D+ qui fait foi restant celui, barométrique, qu'Apple écrit
dans les métadonnées de la séance.
Quatre pièges documentés, tous traités dans le code :
- `allowsBackgroundLocationUpdates` sans `UIBackgroundModes` = `location`
TERMINE l'app. Un garde-fou vérifie le plist avant d'armer le drapeau.
- Ne jamais demander « Always » sur watchOS : Apple décrit ce prompt comme
« mostly a placeholder » et le parcours comme un comportement indéfini.
- Interdit de relancer la localisation depuis l'arrière-plan : la pause garde
le flux ouvert et ignore les points, au lieu de couper le manager.
- Le CPU tue le GPS — d'où l'insertion des points par lots et la trace
d'affichage bornée à 1500 points.
Corrigé après lecture de la doc : `finishRoute` s'appelle APRÈS `finishWorkout`
et reçoit le workout pour s'y associer. J'avais écrit l'inverse, avec nil, ce
qui aurait perdu l'association à chaque sortie. Et `finishWorkout()` rend nil
sans erreur quand la montre est verrouillée : on sauvegarde alors la trace sans
association plutôt que de la jeter.
15 tests, dont 4 vérifiés rouges en désactivant le lissage. Les trois fichiers
sont déclarés dans la cible CoachWatch (pbxproj sauvegardé en .bak-outdoor) :
aucun « Add Files » à faire sur le Mac.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WorkoutKit ne sait pas exécuter une séance structurée dans une app tierce :
son seul point d'exécution public est `WorkoutPlan.openInWorkoutApp()`, qui
ouvre l'app Exercice d'Apple. Guider les intervalles du plan coach au poignet
impose donc d'écrire la machine à états nous-mêmes.
`IntervalEngine` est volontairement pur — aucun HealthKit, aucun timer, aucune
horloge interne. Il répond à « où en sommes-nous ? » à partir du temps et de la
distance qu'on lui pousse. Le temps de référence est `elapsedTime` du builder,
qui exclut déjà les pauses : le moteur n'a rien à savoir de la pause ni de
l'auto-pause. Et comme il est une fonction de la suite des ticks, rejouer
celle-ci redonne le même déroulé — ce qui rend la reprise après crash triviale.
Le point délicat est la dérive. Quand un tick arrive en retard (app suspendue
poignet baissé, collecte irrégulière), l'étape suivante démarre à la frontière
THÉORIQUE de la précédente, jamais à l'instant du tick. Sinon chaque
répétition offre quelques secondes de rab et l'erreur s'accumule : sur un
9×(1'/1'), le décalage final se compte en dizaines de secondes. Pour la même
raison `update` boucle au lieu de tester une fois — un tick manqué peut
franchir plusieurs étapes courtes d'un coup.
17 tests, dont 5 vérifiés rouges en neutralisant les frontières théoriques.
Rien n'est encore câblé : le fichier n'est pas membre de la cible CoachWatch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le snapshot poussé par l'iPhone (séance prévue, forme, hydratation, énergie,
routine, séance de demain) arrive sur la montre depuis WatchConnectivity et
vit dans son App Group. Les complications le lisent ; l'app, elle, ouvrait
sur le lanceur d'activité et n'en affichait pas une ligne.
Aucun nouveau pont : `ConnectivityManager` publie le snapshot qu'il persistait
déjà, l'accueil en montre un résumé, et une feuille « Aujourd'hui » donne le
détail. Même `asOf(_:)` que les complications, pour qu'un snapshot d'hier se
vide au lieu de mentir.
Les vues vivent dans ContentView.swift, déjà membre de la cible CoachWatch :
pas de Target Membership à régler dans Xcode.
⚠️ Non compilé — SwiftUI et WatchKit n'existent pas sur Linux. Seuls
`swiftc -parse` et les tests Linux existants sont passés. À builder sur le Mac.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
La couleur du score de forme était décidée ici, avec un barème périmé
(vert >= 75, jaune >= 50). Le serveur avait recalé le sien sur la
distribution réelle (65/55/40) parce que le score est borné à [25, 75] :
une bande haute à 75 est inatteignable. Mesuré sur 115 jours de
production, le vert n'est jamais sorti et le rouge couvrait 68 jours.
La correction n'avait pas atteint le binaire.
`FormeScore` porte désormais la bande servie par le serveur, et
`resolvedBand` ne sert que de repli — aligné sur les seuils serveur, pas
sur les anciens.
Le widget de saisie rapide enregistrait un café à 100 ml, volume qui ne
correspond à aucun préréglage : le serveur avait séparé l'expresso (60)
du mug (250) parce qu'un volume unique faussait le journal, et ces
préréglages sont personnalisables. Les boutons viennent maintenant du
snapshot ; `fallbackQuickDrinks` reprend les préréglages par défaut du
serveur.
15 tests ajoutés au banc d'essai Linux, qui compile réellement ces deux
fichiers : 25 tests verts. Le reste (SwiftUI, WidgetKit) n'est pas
compilable ici et n'a été que relu.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
L'interface HealthSample de @capgo/capacitor-health n'expose aucun champ
metadata — seul Workout en a un. Le fuseau d'une nuit
(HKMetadataKeyTimeZone), qu'Apple recommande pourtant de stocker avec le
sommeil, n'atteignait donc jamais le JavaScript.
Sans lui, douze nuits d'avril passées au Pérou s'affichaient 06:17 ->
14:25 au lieu de 23:17 -> 07:25, et leurs couchers étaient écartés du
calcul de régularité faute de pouvoir les situer.
Le plugin lit les échantillons de sommeil avec leur fuseau, leur source
et leur stade, sur le modèle de CoachHealthRoute. Le stade passe par
l'énumération HKCategoryValueSleepAnalysis et non par les entiers bruts,
qu'Apple ne publie pas. Le champ timeZone peut rester nul : Apple ne
garantit pas que la Watch renseigne la métadonnée, et c'est un cas
normal côté web.
Ajouté au projet Xcode manuellement : les sources de la cible App sont
référencées une par une dans project.pbxproj (seul CoachWatchWidgets est
un groupe synchronisé), donc un fichier posé sur le disque ne serait pas
compilé.
CLAUDE.md : le Mac mini est à jour depuis un moment, les builds iOS ne
sont plus bloqués. La mention contraire a fait déconseiller à tort des
chantiers natifs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signale par Sylvain le 2026-08-18 : passe minuit, l'hydratation, les calories
et la seance de la veille restaient affichees comme celles du jour. Il fallait
ouvrir l'app pour les voir retomber a zero.
Le widget n'a AUCUN acces reseau : il rend ce que l'app lui a pousse la
derniere fois qu'elle a tourne. Le snapshot portait deja `updatedAt` — rien
ne le lisait. CoachQuickWidget programmait meme un reveil a minuit, avec le
bon commentaire, mais relisait ensuite les memes chiffres sans regarder leur
date : le rechargement avait lieu, la remise a zero non.
- `CoachWidgetSnapshot.asOf(_:)` : vide ce qui decrit UNE journee (eau, cafes,
kcal, routine, activite faite, score de forme) quand le snapshot date d'un
autre jour. Conserve ce qui traverse les jours — objectifs et zones
cardiaques. Les compteurs repartent a 0 et non a nil : au matin, « rien bu »
est vrai, « je ne sais pas » ne l'est pas.
- Les trois providers emettent une 2e entree datee de minuit. WidgetKit
bascule seul, sans reveiller l'app.
- `tomorrow` : la seance du lendemain, poussee par le pont web. Sans elle,
vider la seance ferait annoncer JOUR OFF chaque nuit — l'agacement d'un
incident precedent, repete tous les jours.
⚠️ Deuxieme occurrence trouvee en verifiant, et pire : CoachQuickSync.credit()
ajoutait au total EXISTANT puis rehorodatait a maintenant. Sur un snapshot de
la veille, 200 ml bus le matin donnaient « 2 200 ml » estampilles du jour — un
total faux, que la peremption ne pouvait plus rattraper.
⚠️ NON COMPILE : pas de toolchain Swift ici. Build Xcode requis sur le Mac
mini pour que le correctif arrive sur l'iPhone et la Watch.
Le widget affichait un total sans jauge, et c'était une décision documentée
et juste : « aucun objectif de boisson n'est fixé, et en inventer un
donnerait un pourcentage qui n'a pas de sens ». Une cible existe maintenant
côté serveur (`/api/drinks` renvoie `water_target_ml`), donc la jauge peut
apparaître — sans cible, on retombe exactement sur l'ancien comportement.
⚠️ Cette cible n'a **aucune source** : c'est un objectif personnel, pas une
recommandation médicale. D'où le libellé « objectif » et non « besoin » ou
« recommandé ».
⚠️ Les deux côtés du pont bougent ensemble : `widget-bridge.js` pose
`waterGoalMl`, `CoachWidgetBridge` la lit sous le même nom. Le widget
« JOUR OFF » a menti pendant des jours parce que le pont lisait une clé
absente de la réponse serveur.
Ajoute aussi un `widgetURL` vers /hydratation : un tap hors bouton ouvrait
l'app sur sa dernière page consultée, au hasard.
⚠️ **NON COMPILÉ** — pas de Mac ici. À vérifier au prochain build Xcode,
avec les zones cardiaques Watch et `getSnapshot` déjà en attente.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- `HeartRateZones` porte `clinicalTargetBpm` et `isUseful(bpm:)`. Sous
bêtabloquant, le repère utile n'est PAS le bas de Z4 mais HRKarv0,60
(Díaz-Buschmann 2014, ESC : la Karvonen standard sous-évalue le seuil chez le
patient traité). Un sceau vert l'indique sous la plage, et VoiceOver le dit —
une icône seule ne suffit pas.
- Z1 passe de `.blue` à `.cyan` : `CoachLiveView` (iPhone) utilise déjà cyan.
Le même effort doit avoir la même teinte sur les deux écrans de la même app ;
j'avais introduit la divergence en écrivant le modèle sans regarder l'existant.
- Deux `#Preview` avec les zones RÉELLES de production (Z1 100-109 … Z5
134-143) : les cinq états d'un coup, plus le cas « zones inconnues ». 109 bpm
y figure exprès — c'est le plancher de Z2, il vérifie la règle du
chevauchement d'un battement.
⚠️ `CoachLiveView.CardiacZones` reste une SECONDE implémentation de la même
règle, avec sa propre table de zones. Non fusionnée ici pour ne pas mélanger
deux chantiers ; c'est le même motif que les deux traductions WorkoutKit
divergentes corrigées ce matin.
⚠️ NON COMPILÉ — build Xcode requis.
L'app Coach de la montre n'affichait que la FC brute — aucune notion de zone
dans `ContentView.swift`. C'est pourtant au poignet que « 142 bpm » a besoin
d'être traduit ; l'écran iPhone portait déjà l'information, mais on ne le
regarde pas en courant.
- `HeartRateZones.swift` : modèle partagé (App + CoachLiveActivity + les deux
cibles Watch), câblé dans les 4 phases Sources du pbxproj comme
`CoachWidgetSnapshot.swift`. ⚠️ Les bornes viennent de `/api/cardiac-zones`,
version FIGÉE et datée — jamais recalculées côté natif, ce serait rouvrir les
jeux de zones concurrents supprimés le 12/08.
- `zone(for:)` retient la zone la plus HAUTE qui contient la valeur : les
bornes se chevauchent d'un battement (Z1 100-109, Z2 109-118), et 109 bpm
doit se lire Z2, pas Z1. Au-delà du haut de Z5, on reste en Z5 — un effort
maximal n'est pas une absence de zone.
- `ConnectivityManager` publie les zones reçues avec le snapshot et repart du
dernier connu au lancement (la montre a pu redémarrer). Un snapshot partiel
n'efface pas celles en place.
- `HeartRateMetric` : pastille « Z3 », valeur colorée, et la plage sous le
chiffre. ⚠️ La couleur ne porte jamais l'information seule — illisible pour
un daltonien, et le contraste d'un écran de montre en plein soleil ne se
prête pas aux nuances. Libellé VoiceOver explicite.
`switch` en `return` explicites plutôt qu'en expression : la syntaxe courte
exige Swift 5.9 pile, et je ne peux pas compiler ici.
⚠️ NON COMPILÉ — build Xcode requis, avec `getSnapshot` (`8a25f36`) en attente.
Sans lecture de l'App Group, un widget qui n'affiche pas la bonne chose laisse
trois hypotheses ouvertes — rien d'ecrit, ecrit sans le champ, ou ecrit mais
pas reaffiche — et le JS ne peut en departager aucune : l'App Group lui est
invisible.
Vecu ce soir : tout etait correct jusqu'au stockage, seul le rafraichissement
de la timeline manquait. getSnapshot rend hasDoneToday, hasToday, l'horodatage
et le contenu, ce qui designe directement le maillon fautif.
⚠️ NON COMPILE — a embarquer au prochain build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>