Compare commits

...

57 Commits

Author SHA1 Message Date
Sylvain Bettinelli
6aa7265147 Les macros arrivent au poignet — affichage, pas saisie
Sylvain, après avoir reçu le widget Hydratation : « il manque juste le suivi
des repas calories et lipides etc ».

⚠️ Sa demande est plus fine que ce que j'avais écarté. J'avais dit non à la
SAISIE des repas sur la montre — pas de caméra, pas de scanner, pas de saisie
de texte : un bouton n'aurait fait que renvoyer vers l'iPhone. Mais VOIR où
l'on en est n'a aucune de ces limites.

Ce qui manquait, et où
-----------------------
Le snapshot portait `kcalToday`/`kcalGoal` et rien d'autre. Les macros
existaient pourtant côté serveur, agrégées et servies :

  /api/meals            -> kcal, protein_g, carbs_g, fat_g, fiber_g
  /api/nutrition/goals  -> cibles {target, min, max} pour chacune

Il manquait uniquement le transport. Trois maillons ajoutés : `widget-bridge.js`
les pousse, `CoachWidgetSnapshot` les porte, `CoachWatchNutritionWidget` les
affiche.

⛔ Rien n'est calculé côté natif. Les totaux viennent du journal, les cibles de
`nutrition_goals.py`. Le seul calcul est un reste (cible − consommé), la même
soustraction que /meals affiche déjà.

Le piège évité, parce qu'il est documenté dans ce fichier
---------------------------------------------------------
`asOf(_:)` remet les totaux du jour à zéro quand le snapshot date d'hier. Les
macros y sont ajoutées : les oublier aurait affiché les grammes de la veille
sous la date du jour — le défaut même que cette fonction existe pour empêcher,
et qui avait survécu un temps sur les totaux de boisson.

⚠️ Les CIBLES ne se périment pas, elles : une cible n'est pas une mesure. Sans
ça, la jauge disparaîtrait chaque nuit jusqu'à la prochaine ouverture de l'app.
Deux tests Linux le verrouillent (`testMacrosResetLikeEnergy`,
`testMacroGoalsSurviveTheNight`) — 93 au vert.

⚠️ Une cible de type « min » (les protéines) se lit comme un plancher atteint,
pas comme un plafond à ne pas dépasser. `widget-bridge.js` ne pousse que
`target` ; le widget n'en sait pas plus et ne prétend pas le contraire.

Le widget compile-t-il ? Inconnu, comme les précédents : WidgetKit n'existe pas
sur Linux. Vérifié en revanche que chaque champ appelé existe, et qu'aucun
symbole n'est déclaré deux fois.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 07:46:32 +00:00
Sylvain Bettinelli
d41e8a457b Premier build : deux dialogues Siri refusés, la faute à une concaténation
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>
2026-09-25 07:15:37 +00:00
Sylvain Bettinelli
0d34908f4f Runbook : la cible watchOS s'appelle CoachWatchWidgetsExtension, pas CoachWatchWidgets
Vérifié dans project.pbxproj avant d'envoyer Sylvain dans Xcode : les quatre
cibles réelles sont App, CoachLiveActivity, CoachWatch et
CoachWatchWidgetsExtension. Le runbook donnait le nom du DOSSIER
(CoachWatchWidgets) pour celui de la cible — il aurait cherché un intitulé
inexistant dans l'inspecteur de Target Membership, à l'étape précisément
décrite comme celle qui décide de tout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 07:02:29 +00:00
Sylvain Bettinelli
98b1cd0c89 Siri connaît enfin l'app, et la montre sait noter un verre
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>
2026-09-25 06:58:11 +00:00
Sylvain Bettinelli
3b5b61268c Les quatre constats restants, corrigés — dont deux sur le chemin qu'il fallait éviter
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>
2026-09-24 08:27:17 +00:00
Sylvain Bettinelli
a794323314 Le snapshot arrive à la montre, et on peut enfin le dire
Vérifié sur l'iPhone de Sylvain le 24/09, après build :

    Montre : snapshot reçu
    Zones envoyées : version frozen du 2026-08-12

Les quatre causes d'échec silencieux du § 1.2 sont écartées — session activée,
montre appairée, app Watch installée, updateApplicationContext sans erreur — et
la date de version arrive au poignet, elle qui se perdait au transport. C'est la
première fois que ce chemin peut être affirmé plutôt que supposé : il était muet
depuis son écriture.

⚠️ Ce que ça ne prouve pas, et c'est écrit dans le document :
`updateApplicationContext` est asynchrone. iOS a ACCEPTÉ le contexte, il le
livre quand il peut. Ce qui est établi, c'est que l'iPhone a réussi à le
transmettre au système — pas que la montre affiche les bonnes bornes. Seul le
poignet le dira, pendant une séance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 08:20:52 +00:00
Sylvain Bettinelli
7e7be4a1d1 Les bornes cardiaques partaient vers la montre sans que rien ne dise si elles arrivaient
§ 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>
2026-09-24 08:08:02 +00:00
Sylvain Bettinelli
8e0c2738ce Les cinq constats natifs de l'audit, vérifiés ligne à ligne — sans rien corriger
L'audit du 22/09 marquait cinq constats **[rapporté]** : issus de revues
automatisées, jamais reconfrontés au code, et il le disait lui-même — « les
confirmer avant d'agir ». Sylvain a autorisé la lecture, pas la modification :
aucune ligne de Swift ne bouge ici.

**Les cinq sont confirmés, aucun faux positif.** Trois lectures s'écartent
toutefois de ce que l'audit décrivait :

§ 1.2 est **plus grave**. L'audit ne voyait que le `try?` ; `pushToWatch` a
quatre sorties silencieuses sur sept lignes — WCSession non supportée, session
non activée, encodage échoué, envoi refusé. Et l'aggravant se vérifie :
`HeartRateZones` ne porte aucune date, alors que le backend sert une version
figée et datée (règle 9 du CLAUDE.md web). La date existe côté serveur et se
perd au transport. `asOf()` périme l'eau, les kcal, la routine et le score de
forme, mais pas les zones — délibérément, et c'est défendable pour une
péremption journalière ; ça laisse ouvert le cas d'une révision des bornes.

§ 2.3 est **plus nuancé**. Le code sait déjà que `finishWorkout()` rend nil sans
erreur quand la montre est verrouillée, et que la séance est alors bien écrite —
d'où `recentlySavedWorkout()`. Le défaut n'est donc pas « la séance est perdue »
mais que le double `try?` rend indiscernables deux situations opposées : montre
verrouillée (séance sauvée) et `endCollection` qui a levé (rien de sauvé). Les
deux tombent dans le même `else`.

§ 2.4 est **plus grave** aussi : `self.builder = nil` détruit la seule référence
au builder. La perte devient définitive dans l'instruction même qui l'ignore, et
le GPS continue de tourner après la fin apparente de la séance.

§ 1.3 et § 2.6 sont exacts tels que décrits. Pour § 2.6, un fait qui manquait :
le même fichier utilise `NSLock` pour ce motif à quatre autres endroits — c'est
une omission ponctuelle, pas une convention absente.

⚠️ Les numéros de ligne de l'audit ne valent plus : les correctifs du 22/09 au
soir ont décalé `WorkoutManager.swift` et `CoachHealthRoute.swift`. Le document
porte les références à jour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 08:00:30 +00:00
Sylvain Bettinelli
6031be8c1c CLAUDE.md : Capgo ne déplie pas les séries condensées, getStepSamples si
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:00:11 +00:00
Sylvain Bettinelli
8973a26e47 Les pas d'une séance sont lus en dépliant les séries condensées par HealthKit
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>
2026-09-22 19:55:48 +00:00
Sylvain Bettinelli
b15867273c Le détail natif expose les activités HealthKit de la séance, en lecture seule
À 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>
2026-09-22 19:32:40 +00:00
Sylvain Bettinelli
31467851d9 Séances perdues en silence, et succès journalisés sur des requêtes refusées
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>
2026-09-22 09:20:43 +00:00
Sylvain Bettinelli
2c9af6c684 Chaque pause effaçait jusqu'à 24 points de trace déjà parcourus
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>
2026-09-22 09:20:30 +00:00
Sylvain Bettinelli
a5cbb70210 Les deux extensions étaient restées au build 22, l'app était passée à 23
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>
2026-09-22 09:18:45 +00:00
Sylvain Bettinelli
adb8177f2f Écran noir au lancement : la fenêtre n'était jamais rendue visible
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>
2026-09-22 09:16:08 +00:00
Sylvain
157250b3eb Package.resolved régénéré pour capacitor-swift-pm 8.5.2
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é.
2026-09-22 11:09:53 +02:00
Sylvain Bettinelli
bb1261dfd8 Le Package.swift généré était invalide : la faute à une variable Xcode
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>
2026-09-22 08:53:46 +00:00
Sylvain
316a35f33b Merge branch 'feat/watch-outdoor' of https://git.nexus.hypnotruck.ch/sylvain/coach-ios into feat/watch-outdoor 2026-09-22 10:33:27 +02:00
Sylvain
446b611afd Intégration Xcode du chantier Polar BLE : SDK SPM et sources au target
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.
2026-09-22 10:31:52 +02:00
Sylvain Bettinelli
e5c8967ea9 SceneDelegate.swift enregistré dans le target App
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>
2026-09-22 08:15:41 +00:00
Sylvain Bettinelli
3bd90c8bb7 CLAUDE.md : la règle du cookie ne suffit plus, le SceneDelegate en porte la moitié
La règle n°2 protégeait AppDelegate.swift. Depuis l'adoption d'UIScene, trois
callbacks ont déménagé et UIKit ne les appelle plus sur l'AppDelegate même si
on les y remet — un futur correctif posé au mauvais endroit compilerait sans
broncher et casserait l'OAuth ou le flush des saisies widget en silence.

Précisé au passage que didFinishLaunchingWithOptions reste appelé avant la
connexion de la scène : c'est ce qui garantit l'ordre cookie -> WebView, et
c'est documenté par Apple, pas supposé.

Deux commandes ajoutées à la liste de ce qu'on ne lance pas sur ce projet :
npx cap migrate (le guide officiel exclut les AppDelegate à gestion d'URL
personnalisée) et npm audit fix --force (il redescend le CLI en v7, ce qui
supprimerait le support UIScene qu'on vient d'ajouter).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:05:25 +00:00
Sylvain Bettinelli
3755cafa1f Adoption du cycle de vie UIScene : sans lui, l'app ne démarre plus
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>
2026-09-22 08:04:14 +00:00
Sylvain Bettinelli
d5330b8f1a La FC ne reste plus affichée quand le capteur a décroché
`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>
2026-09-22 07:48:07 +00:00
Sylvain Bettinelli
c16b92ce29 Correction : les rappels SONT natifs, j'avais cherché le mauvais plugin
La version précédente de § 5.1bis affirmait que « la chaîne de notifications
natives n'existe pas de bout en bout ». C'est faux. web/static/local-
notifications.js programme bien, via Capacitor.Plugins.LocalNotifications, le
rappel de séance non faite, le résumé du jour, l'hydratation à 10/13/16 h et le
coucher à 23 h. Le README a donc raison sur le fond ; il attribue seulement la
fonction au mauvais plugin (local-notifications, pas push-notifications).

La méthode qui a produit l'erreur vaut d'être notée, et elle est notée dans le
document : j'avais grepé PushNotifications sans greper LocalNotifications.
Conclure d'un grep négatif qu'une fonctionnalité est absente suppose d'avoir
cherché le bon terme — c'est la même faute que celle qui fait dire à un cron
« rien de neuf ».

Ce qui reste : le chemin ntfy (push_notify.py, api_notifications.py,
tools/notify_morning.py) est résiduel et double une fonction déjà rendue en
natif ; plus aucune notification n'est censée y passer. Et le plugin
@capacitor/push-notifications reste installé avec son entitlement aps-
environment alors que rien ne l'appelle — 2.3.1(a).

⚠️ Ne rien supprimer avant d'avoir vérifié le cron notify_morning sur nexus :
le retirer sous un cron vivant le casserait, et un cron cassé a déjà tenu
quatre mois ici sans être vu. La vérif n'a pas pu être faite depuis le
conteneur de dev (pas de résolution DNS pour nexus) ; la commande est dans le
document.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 07:00:49 +00:00
Sylvain Bettinelli
a29569c05c Les push natives ne sont branchées nulle part, et le README affirme l'inverse
Vérifié dans les deux dépôts : web/push_notify.py envoie via ntfy, en se
servant du token du device comme nom de topic. Aucun enregistrement de token
APNs côté serveur (recherche registerToken / /api/devices / device_token :
zéro résultat), et le web n'appelle jamais PushNotifications de Capacitor
(zéro résultat). La moitié native est prête — plugin installé, aps-environment
déclaré, remote-notification dans UIBackgroundModes — et personne ne la
branche. Pour recevoir un rappel, il faut installer l'app ntfy et s'abonner à
un topic.

Deux conséquences. Le README liste « ✅ Push notifications natives » comme
PREMIER argument de sa section anti-rejet Guideline 4.2 : l'argument ne tient
pas si un examinateur teste les rappels, et 4.2 est justement le risque de
cette architecture. Et une capability déclarée mais inutilisée tombe sous
2.3.1(a) (« hidden, dormant, or undocumented features »).

En contrepartie, le risque APNs que l'audit supposait en § 5.1 (token
production envoyé au sandbox, notification jetée silencieusement) est sans
objet : il n'y a pas d'APNs. Un item de moins, un plus sérieux à la place.

La décision est produit avant d'être technique : brancher la chaîne APNs, ou
assumer ntfy et retirer plugin + entitlement + la ligne du README. L'état
actuel est le seul qui cumule les inconvénients des deux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 06:58:17 +00:00
Sylvain Bettinelli
b1e7cf6d47 Audit complet de l'app mobile : natif, conformité Apple, ergonomie
Trois axes, chaque constat marqué [vérifié] / [rapporté] / [doc] — la
distinction n'est pas cosmétique : un constat non revérifié relayé comme un
fait est la dérive que la règle 3 interdit.

Le plus grave (§1.1, vérifié ligne à ligne) : WorkoutManager.swift:199 garde la
dernière FC connue quand le capteur décroche, et :218 la réémet avec un
horodatage NEUF. Le watchdog de LiveStore ne peut donc jamais basculer en
.lost — il reçoit des samples frais. Au poignet, une FC morte s'affiche stable,
avec sa zone colorée, indéfiniment. Pour un post-infarctus sous bêtabloquants
qui lit ce chiffre pour décider d'une pente, ce n'est pas un bug d'affichage.

Quatre affirmations d'audits antérieurs corrigées, toutes vérifiées :
- la politique de confidentialité EXISTE (HTTP 200) — le TODO du README est
  périmé, et « missing privacy policy » circulait encore ;
- la suppression de compte EXISTE (5.1.1(v), motif de rejet fréquent) ;
- 88 tests Swift verts, pas 57 ;
- npm audit réellement propre, et aucun secret jamais commité (git log vide).

aps-environment = development n'est pas le problème qu'on croyait : Apple
documente que la valeur vient du provisioning profile et que TestFlight utilise
production. Le vrai risque est côté backend, sur l'endpoint APNs.

Côté produit : le mot « Démarrer » n'existe nulle part pour une séance
sportive, et widgets/today-session.html:6 documente un CTA que le markup ne
contient pas. L'app ne permet pas de démarrer une séance.

Ce qui n'a pas été audité est dit en § 7 — aucun build Xcode possible ici,
CoachPolarBLE non revu, backend non audité au-delà de l'ergonomie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 06:55:20 +00:00
Sylvain Bettinelli
e6cdc67451 Revert "iOS 27 compat: VMA test — rendre startKmh éditable au lieu de figé à 8"
This reverts commit 263a8f694f.
2026-09-20 15:54:35 +00:00
Sylvain Bettinelli
263a8f694f iOS 27 compat: VMA test — rendre startKmh éditable au lieu de figé à 8
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>
2026-09-20 15:46:57 +00:00
Sylvain Bettinelli
150e78efa0 Checklist pré-build Mac : feat/watch-outdoor prêt, 4 vérifications device 2026-09-20 15:35:23 +00:00
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
40 changed files with 4790 additions and 166 deletions

View File

@@ -15,7 +15,8 @@ Instructions pour Claude Code sur ce projet. Lis ce fichier en début de session
## 🔒 Règles non négociables ## 🔒 Règles non négociables
1. **Le code web n'est PAS ici** — pour modifier l'UI/UX, c'est `coach_sportif/web/`. Ce repo ne touche QUE le natif (Swift, Kotlin, plugins Capacitor, config). 1. **Le code web n'est PAS ici** — pour modifier l'UI/UX, c'est `coach_sportif/web/`. Ce repo ne touche QUE le natif (Swift, Kotlin, plugins Capacitor, config).
2. **Cookie inject AppDelegate** (iOS) : mécanisme critique pour l'auth WKWebView. Toute modif `AppDelegate.swift` doit préserver ce flux. 2. **Cookie inject AppDelegate** (iOS) : mécanisme critique pour l'auth WKWebView. Toute modif `AppDelegate.swift` doit préserver ce flux. Il vit dans `didFinishLaunchingWithOptions`, qui **reste appelé** en cycle de vie par scènes et **avant** la connexion de la scène — Apple documente la séquence, l'ordre cookie → WebView est donc garanti.
2bis. **⚠️ Cycle de vie UIScene depuis le 22/09/2026** (obligatoire iOS 27, Capacitor ≥ 8.5.2). Trois callbacks ont quitté `AppDelegate.swift` pour `SceneDelegate.swift`, et UIKit **ne les appelle plus sur l'AppDelegate même si on les y remet** : retour OAuth Google (`scene(_:openURLContexts:)`), Universal Links (`scene(_:continue:)`), flush des saisies widget (`sceneDidBecomeActive`). Aucun ne produit d'erreur de compilation s'il est mal placé — la panne est silencieuse. Le `SceneDelegate` instancie **`MainViewController`**, jamais un `CAPBridgeViewController` nu comme le template Capacitor : la sous-classe porte l'enregistrement des plugins et la config WKWebView. ⛔ Ne pas lancer `npx cap migrate` sur ce projet (le guide officiel exclut les AppDelegate à gestion d'URL personnalisée), ni `npm audit fix --force` (il redescend le CLI en v7 et supprime le support UIScene).
3. **Health Connect Android** : nécessite la privacy policy URL dans le manifest (validation Play Console). 3. **Health Connect Android** : nécessite la privacy policy URL dans le manifest (validation Play Console).
4. **Pas d'extrapolation** : root cause vérifiable avant tout fix natif. iOS+Android = doubles surfaces de bug. 4. **Pas d'extrapolation** : root cause vérifiable avant tout fix natif. iOS+Android = doubles surfaces de bug.
5. **Default yes** : décider et exécuter (sauf actions destructives). 5. **Default yes** : décider et exécuter (sauf actions destructives).
@@ -125,5 +126,6 @@ l'emplacement de la toolchain.
## ⚠️ Limites connues sync ## ⚠️ Limites connues sync
- **Apple Watch sync** : impossible via API tierce — le wrapper HealthKit dans cette app est obligatoire pour récupérer les données Watch. - **Apple Watch sync** : impossible via API tierce — le wrapper HealthKit dans cette app est obligatoire pour récupérer les données Watch.
- **⚠️ `@capgo/capacitor-health` ne déplie pas les séries condensées** (`HKQuantitySample.count > 1`, `Health.swift::readSamples` lit `quantity.doubleValue` seul). HealthKit condense les séances Apple — et depuis iOS/watchOS 27 en quelques heures : les pas d'une course sont devenus 4 tranches de 12 min (22/09/2026). `CoachHealthRoute.getStepSamples` déplie avec `HKQuantitySeriesSampleQuery` ; tout autre type lu par Capgo (FC en tête) peut subir le même sort — même remède si ça arrive. `getWorkoutDetails` expose aussi `activities` (une `HKWorkoutActivity` par étape d'un exercice personnalisé, constaté le même jour).
- **Polar AccessLink** : pas d'historique pré-enregistrement (limitation API). - **Polar AccessLink** : pas d'historique pré-enregistrement (limitation API).
- **Push training-targets vers Polar** : nécessiterait migration AccessLink v4 (backlog). - **Push training-targets vers Polar** : nécessiterait migration AccessLink v4 (backlog).

126
COWORK.md
View File

@@ -129,12 +129,130 @@ 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.
## 📋 Pré-requis `feat/watch-outdoor` — checklist dev (2026-09-20)
✅ 34 commits accumulés depuis `main`. Code **complet & testé** (57 tests verts Linux).
- `IntervalEngine.swift` + `RouteFilter.swift` + `LocationTracker.swift` — déclarés dans `CoachWatch` (3 fichiers × 2 déclarations = 6 attendues)
- `CoachWorkoutKit.swift` et `CoachWorkoutObserver.swift` — modifiés pour route (enregistrement, finition, sauvegarde)
- `Info.plist` (CoachWatch) — `UIBackgroundModes = [location]` + `NSLocationWhenInUseUsageDescription` présents
- Toutes les corrections BLE/Polar revertées (chantier jeté, n'impacte pas feat/watch-outdoor)
- Branche stable, dernière corr le 20/08 soir
**À tester côté Mac avant toute release** (4 points critiques) :
1. **Permission localisation** — une sortie extérieure demande "Localisation lorsque l'app est active". Si rien, pas de trace.
2. **Trace dans Santé** — à la fin du workout, vérifier `Santé > Données > Athlétisme > Trajets` : la sortie doit apparaître.
3. **FC remonte sur `/live`** — `WorkoutManager` modifié, **test de régression prioritaire**.
4. **Écran verrouillé** — positions continuent d'arriver (test suivi GPS en background). S'arrêtent = boucle d'affichage trop rapide (CPU), pas permission.
---
## 👉 À 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 `feat/watch-outdoor` — prêt Mac** : **builder + tester 4 points critiques** (voir checklist ci-dessus). Workflow exact ligne 20.
- ⚠️ **Corrigé le 2026-08-20** : cette ligne disait `open ios/App/App.xcworkspace`. **Ce fichier n'existe pas** (vérifié : le dépôt ne contient que `App.xcodeproj`). Depuis **Capacitor 8, les dépendances passent par SPM** et il n'y a plus de workspace CocoaPods. La commande échouait donc telle quelle. - Corrigé le 2026-08-20 : `open ios/App/App.xcworkspace` **n'existe pas**. Depuis Capacitor 8 : **`open ios/App/App.xcodeproj`** (SPM, pas CocoaPods).
- ⚠️ **Fixes robustesse 2026-07-01 (dev, non compilés)** à valider au prochain build : `CoachWidgetBridge` (score lu en `NSNumber.intValue` → corrige un score float perdu ; `save` renvoie Bool → `call.reject` si App Group KO), `CoachAppleAuth` (double-tap : rejette l'ancien `pendingCall` ; erreur testée par domaine `ASAuthorizationError`). Swift pur, aucun nouveau fichier ni capability. - Fixes robustesse 2026-07-01 (non compilés avant) à valider : `CoachWidgetBridge` (score Float ↔ NSNumber.intValue ; KO App Group → reject), `CoachAppleAuth` (double-tap → rejette ancien call).
- ⚠️ **Checklist pré-App-Store (audit sécu 2026-07-01)** : `ios/App/App/App.entitlements` a `aps-environment = development`. Pour la soumission **App Store/TestFlight**, l'APNs prod exige `production`. NON changé côté dev (casserait le push en dev device — arbitrage de signing à faire sur Mac : soit basculer `production` avant l'archive de distribution, soit laisser Xcode le gérer via le profil de distribution auto). À trancher/tester au moment du build release. - **Checklist pré-App-Store** : `ios/App/App/App.entitlements` a `aps-environment = development`. **TestFlight réclame `production`** — à basculer avant archive (Xcode peut le gérer via profil dist auto, à trancher).
- **watchOS — bloqué Mac** : test device + TestFlight de la cible `CoachWatch` (Phases 1-3 réalisées, build vert sim). Cf. `HANDOFF-WATCHOS.md`. - **watchOS — bloqué Mac** : test device + TestFlight de la cible `CoachWatch` (Phases 1-3 réalisées, build vert sim). Cf. `HANDOFF-WATCHOS.md`.
- **Android — bloqué externe** : Play Console ($25 validé), upload AAB + Internal Testing depuis machine avec Android SDK. - **Android — bloqué externe** : Play Console ($25 validé), upload AAB + Internal Testing depuis machine avec Android SDK.
- Backend/web : voir `coach_sportif/COWORK.md`. - Backend/web : voir `coach_sportif/COWORK.md`.

543
docs/AUDIT-2026-09-22.md Normal file
View File

@@ -0,0 +1,543 @@
# Audit complet — app mobile Coach Hypnotruck
**Date** : 2026-09-22 · **Portée** : `coach-ios` (natif iOS/watchOS) + `coach_sportif/web`
(l'UI réelle, servie dans la WKWebView) · **Branche auditée** : `feat/watch-outdoor` @ `e6cdc67`
## Comment lire ce document
Chaque constat porte son statut de vérification. La distinction n'est pas
cosmétique : c'est une app de santé, et un constat non vérifié relayé comme un
fait est exactement la dérive que la règle 3 interdit.
| Marque | Signification |
|---|---|
| **[vérifié]** | Lu ligne à ligne dans le fichier cité, ou calculé, pendant cet audit |
| **[rapporté]** | Remonté par une revue automatisée, **non revérifié** — à confirmer avant d'agir |
| **[doc]** | Adossé à une source Apple citée verbatim, URL donnée |
⚠️ Ce document date du jour de son écriture. Il constate, il ne prescrit pas un
état. `git log` fait foi pour ce qui a bougé depuis.
---
## 1. Ce qui va bien, et qu'il ne faut pas casser
Le socle est sain, et plusieurs points sont au-dessus de la moyenne du domaine.
- **[vérifié] Aucun secret n'a jamais été commité.** `git log --all` sur
`coach-ios.local.json` et `ios/App/App/CoachAuth.swift` est vide. Le
`.gitignore` les couvre (lignes 23-24).
- **[vérifié] `npm audit` : 0 vulnérabilité.** Capacitor 8.3.1 homogène sur
tous les paquets.
- **[vérifié] 88 tests Swift verts sur Linux** (10 DayRollover + 7 FormeBand +
17 IntervalEngine + 31 PolarPftpStep + 8 QuickDrink + 15 RouteFilter). Le
chiffre de 57 qui circulait est faux.
- **[vérifié] La politique de confidentialité existe** — `HTTP 200` sur
`/privacy`, liée depuis `settings.html:796` et `landing.html:207`. Le TODO du
README (« à héberger ») est **périmé**, de même que la ligne « missing
privacy policy » de tout audit antérieur.
- **[vérifié] La suppression de compte existe** (`settings.html:797` →
`POST /account/delete`, `routers/auth.py:313`). C'est l'exigence 5.1.1(v),
motif de rejet fréquent : elle est déjà satisfaite.
- **[vérifié] Universal Links opérationnels** — AASA servi en
`application/json`, Team ID `TZ2PVTRKNY`, routes `/live` `/routine` `/forme`
`/calendar`.
- **[vérifié] Purpose strings HealthKit précises et en français**, nommant les
types de données et leur finalité. Conforme à la lettre de 5.1.1(ii).
- **[rapporté] `CoachWorkoutKit.swift` est le fichier le plus sain du chemin
santé** : autorisation vérifiée deux fois, échec remonté jusqu'à l'écran,
relecture post-envoi qui rend ce qui est constaté et non ce qui est supposé.
- **[rapporté] Aucune confusion kcal/kJ, m/s ↔ km/h, ni mètres/miles** dans le
code Swift. Aucun `@MainActor` manquant, aucun cycle de rétention.
- **[vérifié] La barre d'onglets web est le seul composant d'UI protégé par un
test** (`tests/test_bottom_tabs.py`) — et c'est le seul qui n'a pas dérivé.
Le lien de cause à effet mérite d'être noté.
---
## 2. Gravité 1 — sécurité des données de santé
### 1.1 [vérifié] La FC au poignet se fige sur la dernière valeur connue, sans le dire
`ios/App/CoachWatch/WorkoutManager.swift:199` et `:217-224`
```swift
if let hr { heartRate = hr } // 199 — hr nil → l'ancienne valeur reste
...
ConnectivityManager.shared.sendSample([
"heartRate": heartRate, // 218 — la propriété stockée, PAS `hr`
...
"ts": now.timeIntervalSince1970 // 223 — horodatage NEUF
])
```
`didCollectDataOf` se déclenche dès qu'*un* type est collecté. Quand le capteur
optique décroche — bracelet desserré, froid, arythmie : **la FA thermoablée est
précisément le terrain à risque** — les calories continuent d'arriver, `hr` vaut
`nil`, et la ligne 218 réémet la dernière FC connue avec un horodatage frais.
Conséquence en trois étages :
1. Au poignet, l'écran affiche une FC stable et sa zone colorée, indéfiniment.
2. Sur l'iPhone, `LiveStore.ingest` pose `lastSampleAt = Date()` et
`status = .live`. Le watchdog **ne basculera jamais en `.lost`** : il reçoit
des samples frais.
3. Aucun log, aucun compteur.
**C'est le défaut le plus grave de l'audit.** Un utilisateur post-infarctus sous
bêtabloquants lit 138 bpm en Z3, conclut qu'il peut tenir la pente, et sa FC
réelle est inconnue. Ce n'est pas une erreur de logique : c'est une information
absente du modèle. `heartRate` a besoin d'un `heartRateAt: Date` posé uniquement
quand `hr != nil`, d'un seuil d'âge au-delà duquel la vue affiche « — » (elle
sait déjà le faire, `LiveStore.heartRateText:152`), et `sendSample` d'un champ
d'âge pour que l'iPhone applique la même règle.
### 1.2 [rapporté] Les zones cardiaques peuvent ne jamais atteindre la montre
`ios/App/App/CoachWidgetBridge.swift:183` — `try? session.updateApplicationContext(...)`
`try?` avale l'échec (payload trop gros, session non activée, montre non
appairée). Ce snapshot transporte les bornes de zones affichées pendant
l'effort. Deux fichiers plus loin, `CoachRoutineBridge.pushToWatch:92-101`
appelle **la même API** avec `do/catch`, log et `call.reject`.
Le chemin instrumenté est celui de la routine de gainage ; le chemin muet est
celui qui porte les bornes cardiaques d'un patient post-infarctus. Aggravant :
`HeartRateZones` ne porte aucune date de validité, donc après une révision des
bornes la montre peut afficher les anciennes indéfiniment. C'est le motif
« zones datées » déjà tranché côté serveur (`app.current_zones()`), non
répercuté au poignet.
### 1.3 [rapporté] « Accès refusé » et « aucune donnée » sont rendus identiques
`ios/App/App/CoachSleep.swift:92-96` — l'en-tête du fichier (ligne 30) promet
`available = false` si non autorisé ; le code ne teste jamais l'autorisation et
rend `{available: true, samples: []}`.
Si l'utilisateur retire « Sommeil » dans Réglages → Santé, le backend enregistre
des nuits vides et `sleep_score.py` calcule une régularité sur des trous. Rien
ne distingue « il n'a pas dormi » de « on ne nous laisse plus regarder ».
HealthKit ne distingue effectivement pas refusé de vide en lecture, mais
`authorizationStatus(for:)` couvre `.notDetermined`, qui est le cas fréquent
(réinstallation, permission jamais accordée). Même famille :
`CoachHealthRoute.swift:173` et `:277`.
---
## 3. Gravité 2 — perte silencieuse de données
Le projet a déjà vécu ce scénario côté backend : un cron a privé quatre mois de
séances de leur charge cardiaque en répondant « rien de neuf » — ce qui était
vrai de son point de vue. Les trois défauts ci-dessous ont la même forme.
### 2.1 [rapporté] Séances multiples : toutes sauf une sont perdues définitivement
`ios/App/App/CoachWorkoutObserver.swift:297-319`. L'ancre est sauvegardée
(l. 298) **avant** que seul `latest` (l. 315) soit traité. Or
`scheduleNotification` est le seul appelant de `reportToRoutineIfStrength` et
`reportPlanOrigin`. Renfo le matin + vélo le soir, iPhone resté à la maison :
au premier fire, le vélo est notifié, le renfo n'est jamais POSTé, et l'ancre
ayant avancé, il ne reviendra jamais. Le log dit « 2 new workout(s), notifying
latest » — exact, et parfaitement muet sur ce qui vient d'être jeté.
**Correction** : boucler sur `workouts` pour les rapports serveur ; garder
`latest` uniquement pour la notification locale.
### 2.2 [rapporté] Les POST natifs partent, puis l'app se déclare suspendable
Même fichier, `:266-268`, `:352-382`, `:398-438`. Le `defer { completion() }`
s'exécute dès que le corps rend la main, alors que deux requêtes sont en vol sur
`URLSession.shared`, sans `beginBackgroundTask`. iOS est libre de suspendre.
**Aggravant, indépendamment vérifiable** : le statut HTTP n'est jamais lu
(l. 368 et 431). Un 401 sur token rotaté, un 500, un 404 ne produisent pas
d'`error` — et `plan-origin envoyé` est journalisé comme un succès. Le bon
pattern existe déjà à deux fichiers de là (`CoachRoutineBridge.postDay:154-159`
vérifie `(200..<300).contains(http.statusCode)`).
### 2.3 [rapporté] Une séance entière peut disparaître sans un seul log
`ios/App/CoachWatch/WorkoutManager.swift:310-324`. `try? await
builder.endCollection(...)` puis `try? await builder.finishWorkout()` : si le
premier lève, le second lève aussi, la récupération ne trouve rien, et le chemin
finit sur `discardRoute()` qui logue la perte de la *trace* mais pas de la
*séance*. `statusMessage` n'est pas touché : l'utilisateur voit l'écran de fin
normal après 2 h de sortie.
### 2.4 [rapporté] `didFailWithError` ne clôt rien et laisse le GPS allumé
Même fichier, `:377-389`. Comparé au chemin `.ended`, il manque
`stopTracking()`, `endCollection`, `finishWorkout`, `finishRoute`/`discardRoute`.
Tout ce que le builder a collecté est perdu sans tentative de sauvegarde, et le
GPS continue de tourner après la fin apparente de la séance.
### 2.5 [vérifié] Chaque pause efface jusqu'à 24 points de trace *antérieurs*
`ios/App/CoachWatch/LocationTracker.swift:147` — `pendingRoute.removeAll()`.
Le commentaire vise les points *postérieurs* à la pause. Mais `pendingRoute` ne
contient que des points **déjà acceptés par le filtre et déjà comptés dans la
distance affichée**, en attente d'écriture (seuil de flush : 25). À 1 Hz, c'est
une douzaine de points en moyenne, jusqu'à 24. L'écart est donc visible : la
distance les inclut, la trace HealthKit non. Six arrêts sur une sortie VTTAE →
jusqu'à 144 points disparus, et un D+ recalculé qui diverge de celui affiché.
**Correction** : `await flushPendingRoute()` avant de vider, ou ne pas vider —
le point de reprise est de toute façon filtré par `implausibleSpeed`.
### 2.6 [rapporté] Accès concurrent non protégé sur le tampon GPS
`ios/App/App/CoachHealthRoute.swift:182-203` : `all` et `pluginErr` sont mutés
depuis les callbacks de N requêtes livrées sur des files HealthKit
indépendantes, sans verrou — alors que `getEffortScores:282` et
`getWorkoutDetails:379` du **même fichier** utilisent un `NSLock` pour ce motif
exact. Déclencheur : un workout portant plusieurs `HKWorkoutRoute` (multisport,
reprise après interruption).
---
## 4. Gravité 3 — justesse et cohérence
| # | Constat | Emplacement |
|---|---|---|
| 3.1 | **[rapporté]** `humidity_pct` vaut 0 ou 1, jamais 65 — `HKUnit.percent()` est une fraction (1.0 = 100 %), le `× 100` manque | `CoachHealthRoute.swift:415-417` |
| 3.2 | **[rapporté]** Une séance `traditionalStrengthTraining` est POSTée en `functionalStrengthTraining` (valeur en dur l. 363 alors que le garde accepte deux types). Le payload n'a ni UUID ni horodatage de début → serveur incapable de dédupliquer | `CoachWorkoutObserver.swift:352-364` |
| 3.3 | **[rapporté]** `value > 0` confond un zéro mesuré avec une absence de mesure, alors que le commentaire l. 399 promet l'inverse. L'erreur de la requête est jetée | `CoachHealthRoute.swift:449-462` |
| 3.4 | **[rapporté]** `startObserving` répond `observing: true` même sur refus (le `success` est ignoré, et Apple le met à `true` dès l'affichage de la feuille). `getStatus` montre trois voyants verts et zéro donnée | `CoachWorkoutObserver.swift:218-228` |
| 3.5 | **[rapporté]** La garde anti-doublon `lastNotifiedKey` est écrite (l. 317) et **jamais lue**. Le commentaire affirme une protection absente | `CoachWorkoutObserver.swift:60-63` |
| 3.6 | **[rapporté]** Un cookie d'auth périmé ne peut jamais être remplacé : les deux injections testent le *nom* du cookie, jamais sa valeur ni son expiration. Les appels natifs continuent de partir avec la constante compilée | `AppDelegate.swift:31-34`, `MainViewController.swift:63-65` |
| 3.7 | **[vérifié]** `STYLE_GUIDE.md:233` annonce `--text-secondary` à 9,7:1 ; le calcul donne **6,36:1** sur noir, 5,95:1 sur `.ap-card` | `docs/STYLE_GUIDE.md` |
Le flux d'injection du cookie lui-même (3.6) est correct et bien documenté — la
règle non négociable n° 2 du CLAUDE.md est respectée. Le défaut est le critère
de remplacement, pas le mécanisme.
---
## 5. Conformité App Store — sources Apple
Méthode : `developer.apple.com` uniquement, verbatim + URL. La distinction
exige / recommande / **ne dit pas** est le cœur de cette section.
### 5.1 [doc] `aps-environment = development` — non-problème, sous réserve d'une vérification
`App.entitlements` porte `development`. La
[doc Apple](https://developer.apple.com/documentation/bundleresources/entitlements/aps-environment)
dit verbatim :
> « **Xcode sets the value of the entitlement based on your app's current
> provisioning profile.** […] Production provisioning profile and [prerelease
> versions and beta testers] use `production`. »
TestFlight utilise donc `production`, et [TN2265](https://developer.apple.com/library/archive/technotes/tn2265/_index.html)
ajoute que Xcode **re-signe** à la soumission avec le profil de distribution.
**Ce que la doc ne dit pas** : si Xcode réécrit le *fichier source*
`.entitlements`. N'éditez pas le fichier sur la foi d'un blog. Apple publie une
procédure de vérification, et c'est la seule qui conclut :
> Organizer → archive → Distribute/Validate → feuille Summary → dépliant
> **Binary and Entitlements** → lire la valeur réelle dans le binaire signé.
**Le risque APNs supposé est sans objet [vérifié le 2026-09-22]** : il n'y a
pas d'APNs. `web/push_notify.py` envoie via **ntfy**, en utilisant le `token`
du device comme *nom de topic*. L'en-tête du fichier annonce « V2+ : remplacer
par APNs (iOS) + FCM (Android) » — ce n'est pas fait. Voir §5.1bis, qui est le
vrai sujet.
### 5.1bis [vérifié] Les rappels sont natifs et fonctionnent ; c'est le chemin ntfy qui est résiduel
⚠️ **Correction d'une première version de cette section, qui affirmait que « la
chaîne de notifications natives n'existe pas ». C'était faux**, et la méthode
qui a produit l'erreur mérite d'être notée : j'avais cherché
`PushNotifications` (le plugin *distant*) sans chercher `LocalNotifications`.
Conclure d'un grep négatif qu'une fonctionnalité est absente suppose d'avoir
cherché le bon terme.
**Ce qui marche, en natif** — `web/static/local-notifications.js` programme via
`Capacitor.Plugins.LocalNotifications` :
- rappel « Séance pas encore faite » (l. 97) ;
- résumé du jour : sport + durée, « Jour OFF », « Peu récupéré » (l. 150-171) ;
- hydratation à 10 h / 13 h / 16 h (l. 184) ;
- « Heure de te coucher ? » à 23 h (l. 197).
Ce sont de vraies notifications locales iOS, planifiées sur l'appareil, sans
serveur. **Le README a donc raison sur le fond** (les rappels de séance sont
natifs), mais il attribue la fonction au mauvais plugin : c'est
`@capacitor/local-notifications` qui la rend, pas
`@capacitor/push-notifications`.
**Ce qui reste du chemin ntfy** — `web/push_notify.py` envoie via ntfy (le
`token` du device sert de *nom de topic*), et il a deux appelants vivants :
| Appelant | Ce qu'il expose |
|---|---|
| `web/routers/api_notifications.py:16` | `POST /register-device`, `GET /devices`, `DELETE /devices/{id}`, `POST /test` |
| `tools/notify_morning.py:24` | notification matinale, déclenchée par cron |
L'intention est que **plus rien ne passe par ntfy** (confirmé par Sylvain le
2026-09-22). Ce chemin est donc du code résiduel qui double une fonction déjà
assurée en natif.
⚠️ **Ne pas supprimer avant d'avoir vérifié le cron sur nexus.** Si
`notify_morning.py` y est encore planifié, retirer le module casserait le cron
— et un cron cassé dans ce projet a déjà tenu quatre mois sans être vu. La
vérification n'a **pas** pu être faite depuis le conteneur de dev (pas de
résolution DNS pour `nexus`) :
```
ssh nexus "crontab -l | grep -i notify; systemctl list-timers --all | grep -i notify"
```
**Ce qui reste vrai de la remarque App Store** : `@capacitor/push-notifications`
est installé et `aps-environment` déclaré, alors que **rien n'appelle ce
plugin** (aucun enregistrement de token APNs côté serveur, aucun appel
`PushNotifications` côté web). Une capability déclarée sans usage tombe sous
2.3.1(a) (« hidden, dormant, or undocumented features »). Les rappels étant
couverts par les notifications locales, le plus simple est de **retirer le
plugin distant et l'entitlement** — sauf si des push serveur sont prévues.
### 5.2 [doc] Ce qu'Apple exige, et où on en est
| Exigence | Source | État |
|---|---|---|
| Privacy policy URL dans App Store Connect | 5.1.1(i) + ASC Help, « required for all apps » | à renseigner à la soumission |
| Privacy policy accessible **dans** l'app | 5.1.1(i), « in an easily accessible manner » | **[vérifié] fait** — `settings.html:796` |
| Privacy policy pour toute app HealthKit | [doc HealthKit](https://developer.apple.com/documentation/healthkit/protecting-user-privacy) | **[vérifié] fait** |
| Contenu : collecte + usages + tiers + **rétention/suppression et révocation** | 5.1.1(i) | à relire — c'est le point que les policies génériques oublient |
| Suppression de compte dans l'app | 5.1.1(v) | **[vérifié] fait** — `POST /account/delete` |
| Purpose strings précises | 5.1.1(ii) + doc HealthKit | **[vérifié] fait** |
| Nutrition labels renseignés | « required to submit » | **à faire** : Health, Fitness, Precise Location, identifiants, photos — tous « Linked to You » (il y a un compte) |
| Aucune donnée HealthKit en pub/marketing | 5.1.3(i), 5.1.2(vi), 2.5.18 | conforme |
| Aucune donnée de santé dans iCloud | 5.1.3(ii) | **[vérifié]** aucun entitlement iCloud |
| Notes for Review spécifiques + compte de démo **avec backend allumé** | 2.3.1(a), 2.1(a) | à préparer |
⚠️ **Piège propre à l'architecture webview**, [User Privacy and Data Use](https://developer.apple.com/app-store/user-privacy-and-data-use/) :
> « **If you are using a webview for app functionality, it should be treated the
> same way as native functionality in your app** »
Tout ce que fait `coach.hypnotruck.ch` dans la WKWebView compte comme du natif
pour les nutrition labels **et** pour l'ATT. Si le site embarque le moindre
analytics tiers, il doit être déclaré.
### 5.3 [doc] Ce que la doc Apple **ne tranche pas** — à connaître, pas à corriger
- **Guideline 4.2 n'a aucun critère mesurable.** « Useful, unique, app-like »
n'est pas quantifié. Personne ne peut certifier que HealthKit + widgets +
Live Activity + watchOS + BLE « suffit ». Le levier réel est 2.3.1(a) :
**décrire nommément les fonctions natives dans les Notes for Review**.
- **2.5.2** (« may not download […] code which introduces or changes features »)
n'a pas d'exception publiée pour une UI web distante chargée via `server.url`.
Zone non tranchée — c'est le risque documentaire réel de l'architecture, et il
est structurel, pas corrigeable.
- **« Third party »** n'est pas défini : la doc ne dit pas si le backend du
développeur en est un. Aucune exigence technique publiée sur le stockage
(chiffrement, juridiction, rétention).
- **`limitsNavigationsToAppBoundDomains: false`** : la page Apple ne décrit
**aucune** conséquence, et `WKAppBoundDomains` est absente de la référence
Info.plist. Aucun lien publié entre app-bound domains et données de santé.
La config actuelle est cohérente (pas de `WKAppBoundDomains` + flag `false` =
comportement pré-iOS 14, sans restriction), et passer à `true` casserait
`evaluateJavaScript` et `messageHandlers`, dont Capacitor a besoin.
**Ne pas y toucher pour des raisons de conformité.**
- **Aucune clé Info.plist de privacy policy n'existe.** Si une source en
réclame une, c'est inventé.
### 5.4 [doc] Signal de positionnement
5.1.1(ix) vise les « highly regulated fields (such as […] healthcare) » avec un
« should » (pas « must »). Mais 1.4.1 prévient :
> « **Medical apps that could provide inaccurate data or information […] may be
> reviewed with greater scrutiny.** »
Le vocabulaire de la fiche App Store a donc un effet documenté sur le niveau de
scrutin : « coaching sportif » et « suivi cardiaque post-infarctus » ne
déclenchent pas la même lecture.
---
## 6. Ergonomie — l'app est-elle utilisable **par un sportif** ?
C'est l'exigence produit : simple, ergonomique, pour des sportifs. Rappel
d'architecture — l'UI réelle vit dans `coach_sportif/web/`, le wrapper ne fait
que l'afficher. Les constats ci-dessous portent donc sur le web.
### 6.1 [vérifié] Le geste central du sportif n'existe pas
Le mot « Démarrer » n'apparaît que **deux fois** dans tout `web/templates/`, et
les deux fois pour la routine anti-douleurs (`routine.html:27,42`). La troisième
occurrence est un commentaire qui décrit une fonctionnalité absente :
`widgets/today-session.html:6` annonce un CTA « Démarrer » / « Voir détails »
que le markup ne contient pas.
| Action | Taps |
|---|---|
| Voir la séance du jour | 0 (mais ~420 px sous le haut de page) |
| Voir ses étapes et sa zone cardiaque cible | 2 |
| Envoyer la séance à la Watch | 3 + scroll dans la modale |
| **Démarrer la séance** | **impossible** — `/live` est passif, et hors onglets par défaut |
Avant l'effort : 3 taps pour préparer. Pendant l'effort : l'app ne sert à rien
sans la montre. Pour une app « prévue pour les sportifs », c'est le trou central.
### 6.2 [vérifié] La typographie descend à 8 px sur l'information la plus sensible
17 règles CSS descendent à ≤ 0,62 rem, la plupart en `--text-tertiary` :
| Sélecteur | Taille | Ce que ça porte |
|---|---|---|
| `.ap-tile__pill` (`style.css:7053`) | **0,5 rem = 8 px** | **l'intensité de la séance**, « ADAPTÉ IA », « FAIT ✓ » |
| `.ap-tile__meta` (`:7068`) | 0,62 rem ≈ 9,9 px | durée de la séance, km, FC |
| `.entry-src`, `.entry-ecart` | 0,58 rem ≈ 9,3 px | provenance / écart d'une mesure |
**L'intensité d'effort d'un post-infarctus est affichée en majuscules de 8 px,
en gris, sur noir.** Au total, 127 règles combinent `--text-tertiary` avec une
police < 0,78 rem.
**Ratios réels [vérifié par calcul]**, fonction validée sur le témoin WCAG
officiel (`#767676`/blanc = 4,54) :
| Token | sur `#000` | sur `.ap-card` | sur `--bg-tertiary` |
|---|---|---|---|
| `--text-secondary` | **6,36** (le guide annonce 9,7 — faux) | 5,95 | 5,27 |
| `--text-tertiary` | 5,42 | 5,22 | **4,68** (limite AA) |
Les tokens passent AA, mais avec deux fois moins de marge que le guide ne le
croit. Et le problème n'est pas le ratio : c'est la taille. **Aucune
échappatoire** : pas de mode clair (`_layout.html:20` force
`background:#000 !important`, et les tokens clairs de `critical.css:153-166`
sont du code mort), pas de Dynamic Type (`-webkit-text-size-adjust: 100%` +
`rem` sur racine fixe), aucun réglage de taille dans `/settings`.
Un thème sombre est le pire cas en extérieur : c'est le fond — la plus grande
surface — qui est noyé par la lumière réfléchie.
### 6.3 [rapporté] Les cibles tactiles fautives sont exactement les actions fréquentes
Les composants génériques respectent le 44×44 du style guide (`.ap-icon-btn`,
`.tab-item`, `.more-item`, `.coach-fab`). Mais :
| Élément | Taille | Rôle |
|---|---|---|
| `.qr-action` | ~**29 × 35** | ajout rapide d'un favori — le geste le plus fréquent |
| `.hero-tab` | ~**23 px** | sélecteur primaire de `/meals` |
| `.ap-home-hero__cta-mini` | **32 × 32** | **le CTA du hero d'accueil** |
| `.meal-section__add-btn` | **34 × 34** | porte d'entrée de la saisie de repas |
| `.ap-icon-btn.--sm` | **36 × 36** | navigation jour de `/meals` — usage **primaire**, ce que §2 du guide interdit |
Saisir un repas connu = 3 taps, **dont 2 sous le seuil**. Pire : dans
`.quick-row`, le bouton favori (étoile) et le bouton ajouter (+) sont adjacents,
même taille ~29 px : avec des doigts moites ou des gants, on met en favori au
lieu d'ajouter, **sans retour d'erreur** puisque les deux « marchent ».
Aucun test ne verrouille le 44×44 ni le contraste — alors que le CLAUDE.md pose
« un garde-fou contre la récidive → un test, pas une phrase ». Les règles
d'ergonomie sont les seules du projet à n'être qu'une phrase. D'où la dérive.
### 6.4 [rapporté] Densité et navigation
- **Accueil : 28 chiffres, dont un seul concerne la séance à faire** (sa durée,
en 9,9 px gris). Les 27 autres sont du constat sur le passé.
- **`/workout` : 20 tuiles toutes visibles par défaut**, 100-130 valeurs avec
les tableaux. `/sleep` : 8 tuiles.
- **19 sections de navigation, 4 visibles**, avec des doublons sémantiques
réels : `/mesures` + `/body` + `/profile` pour le corps ; `/calendar` +
`/week` + `/dashboards` + `/reviews` pour la planification.
- **La place la plus accessible au pouce** — le FAB 56 px, fixe, sur toutes les
pages — **est occupée par le chat IA**, pas par « logger » ou « démarrer ».
### 6.5 Le meilleur travail d'ergonomie de l'app est éteint
`drinks-quick.html` et `meal-capture.html` ont des boutons `min-height: 44px`,
un geste unique, et un commentaire qui pose le bon raisonnement (« boire se note
vingt fois par jour ; un geste qui coûte deux navigations ne se fait pas »).
**Ils ne sont ni dans le layout par défaut, ni en production.** Les allumer dans
`_default_home_layout()` est un gain immédiat pour un coût nul.
### 6.6 La tension honnêteté / ergonomie, à trancher
Le hero affiche TSB signé, HRV en ms, FC repos + écart % — quatre grandeurs à
interpréter, dont aucune ne dit quoi faire. La seule phrase actionnable est
suivie d'un encadré de 40 mots à 11,2 px expliquant qu'aucune publication ne la
fonde.
Ce disclaimer est **juste** : c'est la règle 3ter, et sur une app de santé il
n'est pas négociable. Mais son coût est payé au pire endroit. La question de
conception est : *comment rester honnête sans occuper le premier écran ?* Le
composant existe déjà — `<details class="settings-fold">`, dont §3c du guide
décrit les deux règles.
---
## 7. Ce qui n'a pas été audité
Par honnêteté sur la couverture :
- **Aucun build Xcode** — impossible depuis Linux. WidgetKit, SwiftUI, HealthKit
et WatchKit ne compilent pas ici. Rien de ce qui touche à l'UI native, à la
signature ou au comportement sur device n'a été vérifié autrement que par
lecture.
- **`CoachPolarBLE.swift`** (644 L, 86 commits de diagnostic entre le 11/08 et
le 20/09) n'a pas été revu. C'est le chantier le plus instable du dépôt.
- **Android** : seul le manifest a été lu. Health Connect n'est pas intégré, et
la validation Play Console reste en attente.
- **Le backend** (`coach_sportif/web`) n'a été audité que sur l'ergonomie et
deux points de conformité. Ni sécurité serveur, ni justesse des formules.
- **Le contenu de la politique de confidentialité** n'a pas été relu contre les
trois exigences de 5.1.1(i) — son existence a été vérifiée, pas son contenu.
*(`web/push_notify.py` a été lu après coup : voir §5.1bis, qui remplace le
risque APNs initialement supposé.)*
---
## 8. Ordre de traitement proposé
**D'abord, parce que la conséquence est une décision d'intensité prise sur un
chiffre mort :**
1. **§1.1 — horodater la FC** et afficher « — » au-delà d'un seuil d'âge, des
deux côtés du pont. Correctif petit et local.
2. **§6.2 — remonter le plancher typographique** : interdire < 0,7 rem, bannir
`--text-tertiary` sous 0,78 rem. En priorité `.ap-tile__pill` (l'intensité de
séance en 8 px).
**Ensuite, la perte de données :**
3. **§2.1 + §2.2 ensemble** — même scénario, même correctif structurel : boucler
sur le lot, tenir un `beginBackgroundTask`, lire le statut HTTP comme
`CoachRoutineBridge` le fait déjà.
4. **§2.3 + §2.4** — deux faces de la même omission dans le cycle de vie de la
séance watchOS.
5. **§2.5** — une ligne.
**Puis le produit :**
6. **§6.1 — un bouton « Démarrer »** ≥ 44 px sur la tuile du jour, avec la zone
cible lisible à côté.
7. **§6.5 — allumer `drinks-quick` et `meal-capture`.** Coût nul.
8. **§6.3 — porter les cibles primaires à 44 px**, et écarter l'étoile du `+`.
**Avant soumission :**
9. **Nettoyer le résiduel de notifications (§5.1bis)** — vérifier le cron
`notify_morning` sur nexus, puis retirer le chemin ntfy et le plugin
`@capacitor/push-notifications` + son entitlement, que rien n'appelle.
Corriger la ligne du README qui attribue les rappels au mauvais plugin.
Puis vérifier `aps-environment` dans le Summary de l'archive (§5.1).
10. Nutrition labels, Notes for Review nommant les fonctions natives, compte de
démo avec backend allumé (§5.2).
**Garde-fous, parce que « un test, pas une phrase » :**
11. Deux tests web : 44×44 sur les classes primaires, ratio de contraste des
tokens. Ce sont les seules règles du projet non verrouillées, et ce sont
celles qui ont dérivé.
---
*Audit mené par lecture du code et des sources Apple. Les constats **[rapporté]**
proviennent de revues automatisées et n'ont pas été revérifiés ligne à ligne :
les confirmer avant d'agir.*

View File

@@ -0,0 +1,404 @@
# Diagnostic natif — les cinq constats ouverts de l'audit, vérifiés
> ## ⚠️ § 1.2 A ÉTÉ CORRIGÉ le 24/09, après ce diagnostic
>
> Sylvain a levé le gel sur ce point précis (« tu attends quoi ? »), puis sur
> **les quatre autres** (« pour le reste vas-y »). Les cinq sont donc corrigés :
> § 1.2 buildé et vérifié sur l'appareil, § 1.3 / 2.3 / 2.4 / 2.6 écrits et
> vérifiés en syntaxe, **pas encore compilés**.
>
> ⚠️ **§ 2.3 et § 2.4 touchent `WorkoutManager.swift`**, c'est-à-dire le chemin
> de fin de séance du live workout — ce que la consigne du 22/09 demandait de ne
> pas approcher. Fait sur demande explicite, 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. **À éprouver en séance réelle avant de
> considérer que ça tient.**
>
> ✅ **BUILD FAIT ET VÉRIFIÉ SUR L'APPAREIL, le 24/09.** Le diagnostic de
> `/settings` (« Diagnostiquer et pousser ») affiche sur l'iPhone de Sylvain :
>
> ```
> Montre : snapshot reçu
> Zones envoyées : version frozen du 2026-08-12
> ```
>
> Les quatre causes d'échec silencieux sont donc écartées : session activée,
> montre appairée, app Watch installée, `updateApplicationContext` sans erreur.
> Et la date de version arrive au poignet — elle se perdait au transport.
>
> ⚠️ **Ce que cela ne prouve pas** : `updateApplicationContext` est asynchrone.
> iOS a *accepté* le contexte, il le livre quand il peut. Ce qui est établi,
> c'est que l'iPhone a réussi à le transmettre au système — pas que la montre
> affiche les bonnes bornes. Seul le poignet le dira, pendant une séance.
**24/09/2026. Diagnostic mené en lecture seule** — aucune ligne de Swift n'avait
été modifiée à sa rédaction.
Demandé par Sylvain après l'audit du 22/09, dont cinq constats étaient marqués
**[rapporté]** — issus de revues automatisées, jamais reconfrontés au code.
L'audit lui-même le disait : « les confirmer avant d'agir ».
Ce document dit ce que le code fait **aujourd'hui**, ce que l'audit décrivait
mal, et ce qu'il faudra corriger le jour où le gel sur le natif sera levé. Il ne
propose aucun geste : la correction demande un build Mac, et rien n'est réel
tant que ce build n'a pas tourné.
⚠️ **Les numéros de ligne de l'audit ne valent plus.** Les correctifs du 22/09
au soir ont décalé `WorkoutManager.swift` et `CoachHealthRoute.swift`. Les
références ci-dessous sont celles du dépôt à `6031be8`.
---
## Verdict d'ensemble
**Les cinq sont confirmés.** Aucun faux positif. Deux sont plus graves que
l'audit ne le disait, un est plus nuancé.
| # | Constat | Vérifié | Écart avec l'audit |
|---|---|---|---|
| 1.2 | Zones cardiaques muettes vers la montre | ✅ | **Aggravé** : 4 sorties muettes, pas 1 |
| 1.3 | « Refusé » ≡ « aucune donnée » (sommeil) | ✅ | Exact |
| 2.3 | Séance perdue sans log | ✅ | **Nuancé** : `nil` n'est pas toujours une perte |
| 2.4 | `didFailWithError` laisse le GPS allumé | ✅ | **Aggravé** : `builder = nil` rend la récupération impossible |
| 2.6 | Tampon GPS sans verrou | ✅ | Exact |
---
## 1.2 — Les zones cardiaques peuvent ne jamais atteindre la montre
**`ios/App/App/CoachWidgetBridge.swift:175-183`** (`pushToWatch`)
L'audit ne signalait que le `try?`. Il y a **quatre** sorties silencieuses sur
sept lignes :
```swift
guard WCSession.isSupported() else { return } // 1. muet
let session = WCSession.default
guard session.activationState == .activated, // 2. muet
let data = try? JSONEncoder().encode(snapshot) else { return } // 3. muet
try? session.updateApplicationContext(["widgetSnapshot": data]) // 4. avalé
```
Aucun log, aucune remontée au JS. Le snapshot porte `zones: HeartRateZones?`
(`CoachWidgetSnapshot.swift:96`) — les bornes Z1…Z5 et `clinicalTargetBpm`, la
cible sous bêtabloquant.
**Ce que fait le chemin voisin, dans le même dépôt**
(`CoachRoutineBridge.swift:87-101`) pour la routine de gainage : il vérifie
`isPaired` **et** `isWatchAppInstalled` avec une raison explicite, entoure
l'appel d'un `do/catch`, journalise l'échec et appelle `call.reject`.
Le chemin instrumenté porte des exercices de mobilité. Le chemin muet porte les
bornes cardiaques d'un patient post-infarctus.
### L'aggravant est réel, et il se compose avec le premier
`HeartRateZones` (`ios/App/App/HeartRateZones.swift`) ne porte **aucune date** :
ni `version`, ni `validFrom`, ni `computedAt`. Or le backend sert une version
**figée et datée** (`data/hr_zones_history.json`, via `app.current_zones()`) —
c'est même une règle non négociable du projet web (règle 9 du CLAUDE.md).
**La date existe côté serveur et se perd au transport.**
Et `CoachWidgetSnapshot.asOf(_:)` périme l'eau, le café, les kcal, la routine,
l'activité du jour et le score de forme — mais **pas `zones`**, délibérément
(« version figée, valable des semaines »). Le raisonnement est bon pour une
péremption journalière ; il laisse ouvert le cas d'une **révision** des bornes.
Conséquence composée : après un `POST /api/cardiac-zones/confirm`, si le push
échoue silencieusement, la montre affiche les anciennes bornes **sans limite de
temps et sans que rien ne le signale**, ni au poignet ni à l'iPhone.
**À corriger, le jour venu** : aligner `pushToWatch` sur `CoachRoutineBridge`
(do/catch, log, `isPaired`/`isWatchAppInstalled`), et faire voyager la date de
version des zones pour que la montre puisse dire « bornes du 12/08 » plutôt que
d'afficher un chiffre sans âge.
---
## 1.3 — « Accès refusé » et « aucune donnée » sont rendus identiques
**`ios/App/App/CoachSleep.swift`** — en-tête ligne 30 :
> `// r.available = false si HealthKit est indisponible ou non autorisé`
Le code teste `HKHealthStore.isHealthDataAvailable()` (l. 59, 63) et la
disponibilité du type (l. 68). **`authorizationStatus(for:)` n'apparaît nulle
part dans le fichier.** La requête aboutit et rend :
```swift
call.resolve(["available": true, "samples": out, ...]) // l. 92-96
```
La documentation du fichier promet donc quelque chose que le code ne fait pas.
**La nuance de l'audit est juste** : HealthKit ne distingue pas volontairement
« refusé » de « vide » **en lecture** — c'est une protection de vie privée
documentée par Apple. Mais `authorizationStatus(for:)` renvoie `.notDetermined`
de façon fiable, et c'est le cas fréquent : réinstallation de l'app,
autorisation jamais accordée. C'est celui-là qu'on peut distinguer.
**Conséquence côté serveur** : `sleep_score.py` calcule une régularité de
coucher sur des nuits absentes, sans savoir qu'elles sont absentes parce qu'on
ne nous laisse plus regarder. Une valeur fausse, pas une valeur manquante.
Même famille dans `CoachHealthRoute.swift` (l. 173 et 277 selon l'audit — à
revérifier, les lignes ont bougé).
---
## 2.3 — Une séance peut disparaître sans un seul log
**`ios/App/CoachWatch/WorkoutManager.swift:383-398`**
```swift
LocationTracker.shared.stopTracking()
try? await builder.endCollection(at: date)
let workout = try? await builder.finishWorkout()
if let workout { … } else if let recovered = await self.recentlySavedWorkout() { … }
else { LocationTracker.shared.discardRoute() }
```
**Ce que l'audit ne disait pas, et qui change la lecture** : le commentaire du
code explique que `finishWorkout()` rend `nil` **sans erreur** quand la montre
est verrouillée, et que dans ce cas la séance **est** écrite dans HealthKit —
seul l'objet manque. C'est pour ça que `recentlySavedWorkout()` existe. Un `nil`
n'est donc pas une perte en soi, et le code le sait.
**Le défaut est plus précis que « la séance est perdue » :** le double `try?`
efface la différence entre deux situations opposées —
| Ce qui s'est passé | `workout` | La séance est-elle sauvée ? |
|---|---|---|
| Montre verrouillée à l'arrêt | `nil` | **Oui**, `recentlySavedWorkout()` la retrouve |
| `endCollection` a levé | `nil` | **Non**, il n'y a rien à retrouver |
Les deux chemins convergent vers le même `else`, et `discardRoute()` ne
journalise que la perte de la **trace**. `statusMessage` n'est pas touché :
après deux heures de sortie, l'utilisateur voit l'écran de fin habituel.
**À corriger** : remplacer les deux `try?` par un `do/catch` qui journalise
l'erreur — sans changer la logique de récupération, qui est bonne.
---
## 2.4 — `didFailWithError` ne clôt rien et laisse le GPS allumé
**`ios/App/CoachWatch/WorkoutManager.swift:451-464`**
```swift
workoutLog.error("session didFailWithError domain=… code=…: \(message)")
Task { @MainActor in
self.statusMessage = "Erreur séance : \(message)"
self.isRunning = false
self.session = nil
self.builder = nil
ConnectivityManager.shared.sendEnded(reason: "session-failed")
}
```
Comparé au chemin `.ended` (l. 383-398), il manque **tout** : `stopTracking()`,
`endCollection`, `finishWorkout`, `finishRoute`/`discardRoute`.
**Plus grave que l'audit ne le dit** : `self.builder = nil` détruit la seule
référence au builder. Après cette ligne, plus aucun code ne peut tenter de
sauver ce qu'il avait collecté — la perte devient définitive dans la même
instruction qui l'ignore.
Deux conséquences distinctes :
1. **Le GPS continue de tourner.** `LocationTracker` n'est jamais arrêté, alors
que la séance est finie du point de vue de l'interface. Sur une montre, ça se
paie en batterie, et rien ne l'indique.
2. **Les données collectées sont jetées sans tentative de sauvegarde.**
⚠️ À la différence de § 2.3, ici `statusMessage` **est** renseigné : l'utilisateur
voit « Erreur séance ». Il sait qu'il s'est passé quelque chose ; il ne sait pas
qu'il a perdu la séance, et le GPS tourne toujours.
---
## 2.6 — Accès concurrent non protégé sur le tampon GPS
**`ios/App/App/CoachHealthRoute.swift:183-205`** (`collectLocations`)
```swift
var all: [CLLocation] = []
let group = DispatchGroup()
var pluginErr: Error?
for route in routes {
group.enter()
let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in
if let err = err { pluginErr = err; group.leave(); return }
if let locs = locations { all.append(contentsOf: locs) } // ⚠️ sans verrou
…
```
`all` et `pluginErr` sont mutés depuis les callbacks de N requêtes, livrées sur
des files HealthKit indépendantes. **Aucun verrou.**
**Le même fichier utilise `NSLock` pour ce motif exact à quatre endroits** :
lignes 283, 380, 442 et 565. Ce n'est donc pas une convention absente du
projet — c'est une omission à un seul endroit.
**Déclencheur** : un workout portant plusieurs `HKWorkoutRoute` (multisport, ou
reprise après interruption). Avec une seule route, la boucle n'a qu'une
itération et le défaut est inobservable.
⚠️ **On ne sait pas s'il s'est déjà produit** : les routes vivent dans
HealthKit, pas sur le serveur, et rien ne journalise `routes.count`. Une
corruption de tableau en Swift ne lève pas forcément — elle peut donner une
trace tronquée ou un crash, sans rien qui désigne la cause.
---
## Ce que ce diagnostic ne dit pas
- **Aucune de ces corrections n'a été écrite.** Le gel du 22/09 sur le natif
tient ; Sylvain a autorisé la lecture, pas la modification.
- **Rien n'est vérifiable sans build Mac.** Ces cinq constats sont établis par
lecture du code, pas par exécution. Une lecture peut manquer un chemin.
- **La dette assumée du 22/09 reste ouverte** : le `defer { completion() }` de
`CoachWorkoutObserver` déclare la livraison traitée alors que les requêtes
sont encore en vol, sans `beginBackgroundTask`. Elle n'est pas revue ici, elle
était déjà documentée et assumée.
- **Le § 4 de l'audit (gravité 3, justesse et cohérence) n'a pas été revérifié.**
## Si un seul devait être corrigé
**§ 1.2**, et pas parce qu'il est le plus spectaculaire : parce que 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 quatre autres perdent de la donnée
ou de la batterie — c'est réparable après coup. Une borne de zone périmée au
poignet ne se rattrape pas : elle a déjà servi à décider.
---
## Ce qui a été corrigé (§ 1.2, le 24/09 après-midi)
Trois couches, parce que le défaut en traversait trois.
### 1. L'envoi ne se tait plus — `CoachWidgetBridge.swift`
`pushToWatch` rend désormais la **raison** de son échec, ou `nil` s'il a réussi,
et journalise dans `Logger(subsystem: "ch.hypnotruck.coach", category: "widget")`.
Les quatre sorties muettes deviennent **six sorties nommé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 en gras viennent de `CoachRoutineBridge` : sans elles, l'absence
de montre se confondait avec un échec d'envoi.
Le log de succès nomme ce qui compte : `zones=oui depuis=2026-08-12`. Savoir
quelles bornes sont au poignet, et depuis quand, 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`) et non un `reject` — `ok`
est conservé, aucun appelant JS existant ne casse.
### 2. La date de version voyage — `HeartRateZones.swift` + `widget-bridge.js`
`zonesSince` (date de la version figée) et `zonesFrom` (`"frozen"` ou `"live"`)
rejoignent le modèle, en optionnels — un snapshot écrit avant reste décodable.
**Le serveur les servait déjà** : `/api/cardiac-zones` rend `zones_since` et
`zones_from` depuis `hr_zones_history.effective()`. Rien à changer côté backend ;
c'est `widget-bridge.js` qui ne les faisait pas monter dans le snapshot. Deux
lignes.
La montre peut donc maintenant distinguer des bornes **validées** de bornes
**provisoires**, et dire leur âge. Ce qu'elle en affiche reste à décider — c'est
de l'UI watchOS, hors de ce correctif.
### 3. Le garde-fou — `tests-linux/`
Trois tests dans `DayRolloverTests` : la date survit au passage de minuit
(`asOf`), elle survit à l'aller-retour JSON (ce qui n'est pas encodé n'arrive
pas au poignet), 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. La doublure `HeartRateZonesStub.swift` est tenue à l'identique.
91 tests verts (88 avant).
### Ce que ce correctif ne fait pas
- **Il ne prouve pas que le push fonctionne.** Il rend son échec *visible*. Si
l'envoi échouait pour une raison qu'on ignore, on le saura au prochain build —
c'était impossible jusqu'ici.
- **Il ne périme pas les zones.** Après une révision, la montre affichera
toujours les anciennes bornes tant qu'un push n'a pas réussi ; elle saura
seulement les dater. Décider qu'une borne de plus de N jours n'est plus
affichable est un choix produit, pas une correction de défaut.
- **Il ne touche pas au flux course à pied** (WorkoutKit, live workout FC, app
Exercice), conformément à la consigne du 22/09.
- ✅ **Compilé et vérifié le 24/09** — sans Console : le diagnostic de
`/settings` rend le statut en clair (`Montre : snapshot reçu` · `Zones
envoyées : version frozen du 2026-08-12`), parce que le plugin remonte
désormais `watchPushed` / `watchReason` jusqu'au JS.
⏳ **Reste à voir au poignet** : que la montre affiche ces bornes pendant une
séance. L'envoi est accepté par iOS ; la livraison est asynchrone.
---
## Les quatre autres, corrigés le 24/09 (« pour le reste vas-y »)
⛔ **Aucun n'est compilé.** Syntaxe vérifiée (`swiftc -parse`), 91 tests du banc
Linux verts — mais ces fichiers importent HealthKit, WatchKit et Capacitor,
absents de Swift pour Linux. Le banc ne les couvre pas et ne peut pas les
couvrir.
### § 1.3 — `.notDetermined` n'est plus confondu avec une nuit vide
`CoachSleep.readSamples` teste désormais `authorizationStatus(for: sleepType)`
et rend `{available: false, reason: "authorization not determined"}`.
⚠️ **Ce qui n'est PAS corrigé, et ne peut pas l'être** : un refus *explicite*
reste indiscernable d'une absence de données. HealthKit ne le révèle pas en
lecture, par conception — c'est une protection de vie privée, pas un oubli
d'Apple. Seul `.notDetermined` est fiable, et c'est le cas fréquent
(réinstallation, permission jamais demandée). L'en-tête du fichier dit
maintenant exactement ce que la promesse couvre, au lieu de promettre plus.
### § 2.3 — « séance sauvée » et « rien de sauvé » ne se confondent plus
Les deux `try?` deviennent des `do/catch`. **La logique de récupération n'a pas
changé** — `recentlySavedWorkout()` reste le repli quand `finishWorkout()` rend
`nil` sur montre verrouillée. Ce qui change : un `endCollection` qui lève est
journalisé, et le `else` final distingue les deux cas, dont un seul est une
perte. Dans ce cas seulement, `statusMessage` le dit à l'écran — une montre
verrouillée n'est pas une perte, et l'annoncer serait une fausse alerte.
### § 2.4 — le GPS s'arrête, et la séance est sauvée si elle peut l'être
`didFailWithError` commence par `stopTracking()` : 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 clôture en best-effort (`endCollection`,
`finishWorkout`), avec `recentlySavedWorkout()` en second recours — une session
qui a échoué refusera souvent de clore, mais ne pas essayer garantissait la
perte. `builder = nil` n'intervient plus qu'à la fin, après les tentatives.
Trois issues, trois messages distincts : « Séance interrompue, mais
enregistrée », « Séance perdue : … », ou le message d'erreur d'origine.
### § 2.6 — le tampon GPS est verrouillé
`collectLocations` protège `all` et `pluginErr` par un `NSLock`, comme les
quatre autres endroits du même fichier le faisaient déjà.
⚠️ **Invérifiable sans le déclencheur** : il faut un workout portant plusieurs
`HKWorkoutRoute` (multisport, reprise après interruption). Avec une seule route,
la boucle n'a qu'une itération — c'est pourquoi le défaut a pu vivre longtemps
sans se manifester, et pourquoi sa correction ne se constatera pas non plus.
### Ce qu'il faut regarder au prochain build
| Quoi | Où | Ce qui prouve |
|---|---|---|
| § 1.3 | `/settings` → synchro santé | Retirer « Sommeil » dans Réglages → Santé, réinstaller : la synchro doit dire « non autorisé », pas enregistrer des nuits vides |
| § 2.3 | Console, catégorie `workout` | Une séance normale ne doit RIEN journaliser de neuf. Les nouveaux logs ne sortent qu'en cas d'échec |
| § 2.4 | Batterie, après une séance interrompue | Le GPS ne doit plus tourner une fois l'écran de fin affiché |
| § 2.6 | — | Rien à voir : le correctif supprime une corruption qui ne se manifestait pas de façon lisible |
⚠️ **§ 2.3 et § 2.4 sont sur le chemin de fin de séance du live workout.** Le
test qui compte 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, ces
deux correctifs sont des intentions.

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,107 @@
# Runbook Mac — Siri + widget Hydratation de la montre
**Écrit le 25/09/2026, depuis Linux.** Le code Swift est committé ; il reste les
étapes Xcode, qui ne peuvent pas se faire d'ici.
> ⚠️ **Nom des cibles, vérifié le 25/09** : la cible watchOS s'appelle
> **`CoachWatchWidgetsExtension`** — le DOSSIER, lui, s'appelle
> `CoachWatchWidgets`. Ce runbook disait d'abord le nom du dossier ; corrigé.
> Les quatre cibles réelles : `App`, `CoachLiveActivity`, `CoachWatch`,
> `CoachWatchWidgetsExtension`.
> ⛔ **Rien de ce code n'a été compilé.** `AppIntents`, `WidgetKit` et `SwiftUI`
> sont des frameworks Apple, absents de Swift pour Linux — le CLAUDE.md de ce
> dépôt le dit. Ce qui a été vérifié à la place, par lecture croisée des
> sources : que chaque symbole appelé existe (`CoachQuickLog.append`,
> `todayTotal`, `pending`, `CoachWidgetStore.load`, `nextMidnight`,
> `fallbackQuickDrinks`, `resolvedQuickDrinks`, `waterMlToday`, `waterGoalMl`,
> `asOf`), et qu'aucun symbole n'est déclaré deux fois. **Les erreurs de type,
> de concurrence et d'API n'apparaîtront qu'au build.**
## Ce que ça apporte
| | Avant | Après |
|---|---|---|
| Siri | rien — aucun `AppShortcutsProvider` | 5 raccourcis, 16 phrases |
| Montre | complications d'affichage | widget Smart Stack qui **note un verre** |
| Intents | 1, enfermé dans l'extension iPhone | 3, partagés entre 3 cibles |
## Fichiers ajoutés / modifiés
- **`ios/App/App/CoachIntents.swift`** *(nouveau)* — `LogDrinkIntent` (déplacé),
`HydrationStatusIntent`, `TodaySessionIntent`, `CoachQuickError`.
- **`ios/App/App/CoachAppShortcuts.swift`** *(nouveau)* — les phrases Siri.
- **`ios/App/CoachWatchWidgets/CoachWatchHydration.swift`** *(nouveau)* — widget
Smart Stack de la montre.
- `ios/App/CoachLiveActivity/CoachQuickWidget.swift` — l'intent en a été
**retiré** (il vivait là, invisible des deux autres cibles).
- `ios/App/CoachWatchWidgets/CoachWatchComplications.swift` — le widget rejoint
le `WidgetBundle`.
## Étapes Xcode
⚠️ **Quitter Xcode puis `git pull` d'abord** (règle COWORK : jamais de pull
Xcode ouvert).
### 1. Target Membership — c'est l'étape qui décide de tout
| Fichier | App | CoachLiveActivity | CoachWatchWidgetsExtension |
|---|:--:|:--:|:--:|
| `CoachIntents.swift` | ✅ | ✅ | ✅ |
| `CoachAppShortcuts.swift` | ✅ | ❌ | ❌ |
| `CoachWatchHydration.swift` | ❌ | ❌ | ✅ |
| `CoachQuickLog.swift` | ✅ | ✅ | ✅ *(à vérifier)* |
| `CoachWidgetSnapshot.swift` | ✅ | ✅ | ✅ *(déjà le cas)* |
⚠️ **`CoachAppShortcuts.swift` dans la cible App UNIQUEMENT.** Apple attend un
seul `AppShortcutsProvider` par application ; un second dans une extension
produirait au mieux un doublon.
⚠️ **`CoachQuickLog.swift` doit être dans `CoachWatchWidgetsExtension`**, sinon le widget
de la montre compile mais n'écrit nulle part. Son en-tête le réclamait déjà.
### 2. Vérifier l'App Group sur la cible Watch
`group.ch.hypnotruck.coach` sur **CoachWatch** ET **CoachWatchWidgetsExtension**.
Vérifié depuis Linux le 25/09 : les quatre `.entitlements` le portent et les
`CODE_SIGN_ENTITLEMENTS` sont câblés. À reconfirmer dans Signing & Capabilities.
### 3. Build, puis les vérifications qui comptent
1. **Siri** — dire « Note un verre d'eau dans Coach ». Attendu : « Noté.
X,XX L d'eau aujourd'hui », **sans que l'app s'ouvre**.
2. **Raccourcis** — l'app *Raccourcis* doit lister les 5 actions sous « Coach ».
3. **Smart Stack** — ajouter le widget Hydratation, toucher « +25 cl », vérifier
que le total monte **et** que le badge des saisies en attente s'incrémente.
4. **Synchro** — ouvrir l'app iPhone, vérifier que la saisie faite au poignet
remonte sur `coach.hypnotruck.ch`.
5. **Minuit** — le total doit repartir de zéro (c'est `asOf(_:)` qui l'assure,
pas le réveil de la timeline : le piège a déjà été payé côté iPhone).
## Les trois questions que seul le build tranchera
1. **Une complication de CADRAN accepte-t-elle un bouton d'App Intent, ou est-ce
réservé à la Smart Stack ?** La doc Apple n'était pas récupérable depuis
Linux. Par prudence, `.accessoryCircular` n'est ici qu'un **affichage** ; si
le cadran accepte l'interactivité, on pourra l'enrichir.
2. **Les phrases Siri passent-elles la validation ?** Chaque phrase contient
`\(.applicationName)`, comme Apple l'exige, et le nom supposé est « Coach »
(`CFBundleDisplayName`). Si le nom affiché diffère, les phrases ne se
déclencheront pas — c'est silencieux.
3. **`ProvidesDialog` sur watchOS.** Les intents rendent
`some IntentResult & ProvidesDialog` pour que Siri réponde à voix haute. À
confirmer que la cible watchOS l'accepte sans ajustement.
## Ce qui a été volontairement écarté
- ⛔ **Saisie des repas depuis la montre.** Pas de caméra, pas de scanner de
code-barres, pas de saisie de texte confortable : un bouton « repas » au
poignet ne ferait que renvoyer vers l'iPhone. Décidé avec Sylvain le 25/09.
- ⛔ **Un paramètre libre de volume dans les phrases Siri.** Un intent
paramétré oblige Siri à poser une question — ce qui coûte plus de temps que
de sortir le téléphone, et vide le raccourci de son intérêt. Les volumes sont
donc fixés (250 ml, 500 ml, café 60 ml).
- ⛔ **Toute borne en bpm dans les réponses de Siri.** `TodaySession` n'en porte
pas, 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.

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

@@ -18,8 +18,6 @@
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */; }; 2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */; };
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
3E6740C521EF14892687BCF7 /* CoachWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2517374227A4072A4D6AFC15 /* CoachWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3E6740C521EF14892687BCF7 /* CoachWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2517374227A4072A4D6AFC15 /* CoachWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */; };
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */; };
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */; }; 436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */; };
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
@@ -31,9 +29,13 @@
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */; }; 543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */; };
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; }; 548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; };
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60061E60061E60061E060002 /* CoachGoogleAuth.swift */; }; 60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60061E60061E60061E060002 /* CoachGoogleAuth.swift */; };
60BB59C4096F4C8EB22DE183 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */; };
6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */; }; 6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */; };
694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */; }; 694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */; };
79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276928E86E785EEA434057C /* CoachWidgets.swift */; }; 79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276928E86E785EEA434057C /* CoachWidgets.swift */; };
8807F51A3045719400A4D4E8 /* PolarPftpStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8807F5193045719400A4D4E8 /* PolarPftpStep.swift */; };
8807F51B3045719400A4D4E8 /* CoachPolarBLE.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8807F5183045719400A4D4E8 /* CoachPolarBLE.swift */; };
8807F51D3045721100A4D4E8 /* PolarBleSdk in Frameworks */ = {isa = PBXBuildFile; productRef = 8807F51C3045721100A4D4E8 /* PolarBleSdk */; };
880A76D1302B601000F582D5 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D0302B601000F582D5 /* WidgetKit.framework */; }; 880A76D1302B601000F582D5 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D0302B601000F582D5 /* WidgetKit.framework */; };
880A76D3302B601000F582D5 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D2302B601000F582D5 /* SwiftUI.framework */; }; 880A76D3302B601000F582D5 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D2302B601000F582D5 /* SwiftUI.framework */; };
880A76DE302B601100F582D5 /* CoachWatchWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 880A76DE302B601100F582D5 /* CoachWatchWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -50,7 +52,7 @@
95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; }; 95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; };
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; }; 97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; };
97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; }; 97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */; }; 9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */; };
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */; }; A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */; };
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; }; A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; };
@@ -61,6 +63,8 @@
C0AC4E1Z0003HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; }; C0AC4E1Z0003HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
C0AC4E1Z0004HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; }; C0AC4E1Z0004HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
C0AC4E1Z0005HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; }; C0AC4E1Z0005HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */; };
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */; };
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; }; D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; }; D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61D7B2F5AFEF834423E07AB /* Foundation.framework */; }; E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61D7B2F5AFEF834423E07AB /* Foundation.framework */; };
@@ -106,15 +110,15 @@
name = "Embed Foundation Extensions"; name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
C4916F81E61FABAAD62EE4F9 /* Embed App Extensions */ = { C4916F81E61FABAAD62EE4F9 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase; isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
dstPath = ""; dstPath = "";
dstSubfolderSpec = 13; dstSubfolderSpec = 13;
files = ( files = (
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */, 9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed Foundation Extensions */,
); );
name = "Embed App Extensions"; name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */ = { F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */ = {
@@ -151,6 +155,8 @@
60061E60061E60061E060002 /* CoachGoogleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachGoogleAuth.swift; sourceTree = "<group>"; }; 60061E60061E60061E060002 /* CoachGoogleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachGoogleAuth.swift; sourceTree = "<group>"; };
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveStore.swift; sourceTree = "<group>"; }; 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveStore.swift; sourceTree = "<group>"; };
82122ACF7E5A608AD1D55D24 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 82122ACF7E5A608AD1D55D24 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
8807F5183045719400A4D4E8 /* CoachPolarBLE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachPolarBLE.swift; sourceTree = "<group>"; };
8807F5193045719400A4D4E8 /* PolarPftpStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolarPftpStep.swift; sourceTree = "<group>"; };
880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachWatchWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachWatchWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
880A76D0302B601000F582D5 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 880A76D0302B601000F582D5 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
880A76D2302B601000F582D5 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 880A76D2302B601000F582D5 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
@@ -164,8 +170,6 @@
88D1DD5D302B46B000B9BA42 /* CoachAuth.swift.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = CoachAuth.swift.example; sourceTree = "<group>"; }; 88D1DD5D302B46B000B9BA42 /* CoachAuth.swift.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = CoachAuth.swift.example; sourceTree = "<group>"; };
88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = "<group>"; }; 88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = "<group>"; };
8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; 8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RouteFilter.swift; sourceTree = "<group>"; };
C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocationTracker.swift; sourceTree = "<group>"; };
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; }; 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetSnapshot.swift; sourceTree = "<group>"; }; 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetSnapshot.swift; sourceTree = "<group>"; };
@@ -177,9 +181,12 @@
A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAppleAuth.swift; sourceTree = "<group>"; }; A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAppleAuth.swift; sourceTree = "<group>"; };
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = "<group>"; }; A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = "<group>"; };
AA5C5902EFB7F60563678E46 /* RoutineView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineView.swift; sourceTree = "<group>"; }; AA5C5902EFB7F60563678E46 /* RoutineView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineView.swift; sourceTree = "<group>"; };
B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; }; B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; }; C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; };
C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HeartRateZones.swift; sourceTree = "<group>"; }; C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HeartRateZones.swift; sourceTree = "<group>"; };
C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RouteFilter.swift; sourceTree = "<group>"; };
C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocationTracker.swift; sourceTree = "<group>"; };
C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineGuidedView.swift; sourceTree = "<group>"; }; C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineGuidedView.swift; sourceTree = "<group>"; };
D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = CoachWatch.entitlements; sourceTree = "<group>"; }; D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = CoachWatch.entitlements; sourceTree = "<group>"; };
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; }; D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; };
@@ -212,6 +219,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */, 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
8807F51D3045721100A4D4E8 /* PolarBleSdk in Frameworks */,
88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */, 88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -294,6 +302,8 @@
504EC3061FED79650016851F /* App */ = { 504EC3061FED79650016851F /* App */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
8807F5183045719400A4D4E8 /* CoachPolarBLE.swift */,
8807F5193045719400A4D4E8 /* PolarPftpStep.swift */,
88ABE0023024C45A002636FD /* CoachQuickLog.swift */, 88ABE0023024C45A002636FD /* CoachQuickLog.swift */,
88ABE0033024C45A002636FD /* CoachQuickSync.swift */, 88ABE0033024C45A002636FD /* CoachQuickSync.swift */,
88A837392FAA741C00DDD144 /* App.entitlements */, 88A837392FAA741C00DDD144 /* App.entitlements */,
@@ -322,6 +332,7 @@
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */, 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */,
340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */, 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */,
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */, B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */,
B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */,
); );
path = App; path = App;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -394,7 +405,7 @@
504EC3011FED79650016851F /* Frameworks */, 504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */, 504EC3021FED79650016851F /* Resources */,
F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */, F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */,
C4916F81E61FABAAD62EE4F9 /* Embed App Extensions */, C4916F81E61FABAAD62EE4F9 /* Embed Foundation Extensions */,
); );
buildRules = ( buildRules = (
); );
@@ -406,6 +417,7 @@
packageProductDependencies = ( packageProductDependencies = (
4D22ABE82AF431CB00220026 /* CapApp-SPM */, 4D22ABE82AF431CB00220026 /* CapApp-SPM */,
88924CCD2FD2A733002F3A80 /* GoogleSignIn */, 88924CCD2FD2A733002F3A80 /* GoogleSignIn */,
8807F51C3045721100A4D4E8 /* PolarBleSdk */,
); );
productName = App; productName = App;
productReference = 504EC3041FED79650016851F /* App.app */; productReference = 504EC3041FED79650016851F /* App.app */;
@@ -460,7 +472,7 @@
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES; BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 2660; LastSwiftUpdateCheck = 2660;
LastUpgradeCheck = 2650; LastUpgradeCheck = 2700;
TargetAttributes = { TargetAttributes = {
504EC3031FED79650016851F = { 504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2; CreatedOnToolsVersion = 9.2;
@@ -484,6 +496,7 @@
packageReferences = ( packageReferences = (
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, 88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */,
8807F51730456ED400A4D4E8 /* XCRemoteSwiftPackageReference "polar-ble-sdk" */,
); );
productRefGroup = 504EC3051FED79650016851F /* Products */; productRefGroup = 504EC3051FED79650016851F /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -570,6 +583,8 @@
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */, A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */,
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */, 60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */,
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */, 88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */,
8807F51A3045719400A4D4E8 /* PolarPftpStep.swift in Sources */,
8807F51B3045719400A4D4E8 /* CoachPolarBLE.swift in Sources */,
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */, E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */,
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */, 21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */,
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */, 2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */,
@@ -577,6 +592,7 @@
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */, 548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */,
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */, D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */,
E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */, E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */,
60BB59C4096F4C8EB22DE183 /* SceneDelegate.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -658,10 +674,11 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = ""; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "";
CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements; CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = CoachWatch/Info.plist; INFOPLIST_FILE = CoachWatch/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -676,7 +693,7 @@
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4; TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 11.0; WATCHOS_DEPLOYMENT_TARGET = 26.0;
}; };
name = Debug; name = Debug;
}; };
@@ -685,6 +702,7 @@
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@@ -748,6 +766,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@@ -809,10 +828,10 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -834,10 +853,10 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -858,10 +877,11 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = ""; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "";
CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements; CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = CoachWatch/Info.plist; INFOPLIST_FILE = CoachWatch/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -877,7 +897,7 @@
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4; TARGETED_DEVICE_FAMILY = 4;
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 11.0; WATCHOS_DEPLOYMENT_TARGET = 26.0;
}; };
name = Release; name = Release;
}; };
@@ -891,12 +911,13 @@
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements; CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = CoachWatchWidgets/Info.plist; INFOPLIST_FILE = CoachWatchWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets; INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -932,12 +953,13 @@
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements; CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = CoachWatchWidgets/Info.plist; INFOPLIST_FILE = CoachWatchWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets; INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -968,7 +990,7 @@
CLANG_ENABLE_OBJC_WEAK = NO; CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements; CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = TZ2PVTRKNY; DEVELOPMENT_TEAM = TZ2PVTRKNY;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
@@ -997,7 +1019,7 @@
CLANG_ENABLE_OBJC_WEAK = NO; CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements; CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = TZ2PVTRKNY; DEVELOPMENT_TEAM = TZ2PVTRKNY;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
@@ -1078,6 +1100,14 @@
/* End XCLocalSwiftPackageReference section */ /* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */
8807F51730456ED400A4D4E8 /* XCRemoteSwiftPackageReference "polar-ble-sdk" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/polarofficial/polar-ble-sdk";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 8.2.0;
};
};
88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = { 88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/google/GoogleSignIn-iOS"; repositoryURL = "https://github.com/google/GoogleSignIn-iOS";
@@ -1094,6 +1124,11 @@
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */; package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
productName = "CapApp-SPM"; productName = "CapApp-SPM";
}; };
8807F51C3045721100A4D4E8 /* PolarBleSdk */ = {
isa = XCSwiftPackageProductDependency;
package = 8807F51730456ED400A4D4E8 /* XCRemoteSwiftPackageReference "polar-ble-sdk" */;
productName = PolarBleSdk;
};
88924CCD2FD2A733002F3A80 /* GoogleSignIn */ = { 88924CCD2FD2A733002F3A80 /* GoogleSignIn */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; package = 88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */;

View File

@@ -1,5 +1,5 @@
{ {
"originHash" : "a07fb3cab82bafe1a29362b82b6680a574f65c3772bc11737e36d9b15949d1dd", "originHash" : "71d869ed5304dbc1f922c9e16fb695db4fb02f0cacefa12d4a10572958e936b8",
"pins" : [ "pins" : [
{ {
"identity" : "app-check", "identity" : "app-check",
@@ -24,8 +24,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/ionic-team/capacitor-swift-pm.git", "location" : "https://github.com/ionic-team/capacitor-swift-pm.git",
"state" : { "state" : {
"revision" : "f1a8fadf1437c23b825c818fb6509c9dbbae2f61", "revision" : "0b6882e9a3288342aacf36348e5a94e4f1dd7b13",
"version" : "8.3.1" "version" : "8.5.2"
} }
}, },
{ {
@@ -73,6 +73,15 @@
"version" : "2.1.1" "version" : "2.1.1"
} }
}, },
{
"identity" : "polar-ble-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/polarofficial/polar-ble-sdk",
"state" : {
"revision" : "3d15da61dd0c63e6be2582d03fd80f6c7ba02e11",
"version" : "8.2.0"
}
},
{ {
"identity" : "promises", "identity" : "promises",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -81,6 +90,24 @@
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0" "version" : "2.4.0"
} }
},
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8",
"version" : "1.38.1"
}
},
{
"identity" : "zip",
"kind" : "remoteSourceControl",
"location" : "https://github.com/marmelroy/Zip.git",
"state" : {
"revision" : "67fa55813b9e7b3b9acee9c0ae501def28746d76",
"version" : "2.1.2"
}
} }
], ],
"version" : 3 "version" : 3

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "2650" LastUpgradeVersion = "2700"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "2650" LastUpgradeVersion = "2700"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@@ -55,7 +55,6 @@
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "D5BF41648D334DC24770B9D2" BlueprintIdentifier = "D5BF41648D334DC24770B9D2"
BuildableName = "CoachWatch.app" BuildableName = "CoachWatch.app"
BlueprintName = "CoachWatch"
ReferencedContainer = "container:App.xcodeproj"> ReferencedContainer = "container:App.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
@@ -72,7 +71,6 @@
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "D5BF41648D334DC24770B9D2" BlueprintIdentifier = "D5BF41648D334DC24770B9D2"
BuildableName = "CoachWatch.app" BuildableName = "CoachWatch.app"
BlueprintName = "CoachWatch"
ReferencedContainer = "container:App.xcodeproj"> ReferencedContainer = "container:App.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>

View File

@@ -1,12 +1,11 @@
import UIKit import UIKit
import Capacitor import Capacitor
#if canImport(GoogleSignIn)
import GoogleSignIn
#endif
@UIApplicationMain @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
// Conservée comme dans le template Capacitor 8.5. En cycle par scènes,
// c'est le SceneDelegate qui possède la fenêtre affichée.
var window: UIWindow? var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
@@ -47,28 +46,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
HTTPCookieStorage.shared.setCookie(cookie) HTTPCookieStorage.shared.setCookie(cookie)
} }
func applicationWillResignActive(_ application: UIApplication) {} // ─── Cycle de vie par scènes (obligatoire iOS 27) ───────────────────
func applicationDidEnterBackground(_ application: UIApplication) {} //
func applicationWillEnterForeground(_ application: UIApplication) {} // ⚠️ Depuis l'adoption du `UIApplicationSceneManifest`, UIKit n'appelle
func applicationDidBecomeActive(_ application: UIApplication) { // PLUS `applicationDidBecomeActive`, `application(_:open:options:)` ni
// Les saisies faites depuis le widget (eau, café) attendent dans l'App // `application(_:continue:restorationHandler:)` — « even if you keep them
// Group : une extension n'a ni session ni droit de tenir une requête. // implemented » (Apple, *Transitioning to the UIKit scene-based life
// On les transmet ici, à chaque retour au premier plan. // cycle*). Les trois ont déménagé dans `SceneDelegate.swift` :
Task { await CoachQuickSync.flush() } //
// applicationDidBecomeActive -> sceneDidBecomeActive (CoachQuickSync)
// application(_:open:options:) -> scene(_:openURLContexts:) (OAuth Google)
// application(_:continue:…) -> scene(_:continue:) (Universal Links)
//
// Elles ne sont pas conservées ici en doublon : une méthode morte qui a
// l'air vivante est exactement ce qui fait chercher la panne au mauvais
// endroit. Les callbacks de notification distante, eux, restent sur
// l'AppDelegate — `UISceneDelegate` n'en a aucun équivalent.
func application(_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
let config = UISceneConfiguration(name: "Default Configuration",
sessionRole: connectingSceneSession.role)
config.delegateClass = SceneDelegate.self
return config
} }
func applicationWillTerminate(_ application: UIApplication) {} func applicationWillTerminate(_ application: UIApplication) {}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
#if canImport(GoogleSignIn)
// Retour du flux OAuth Google (reversed client id URL scheme).
if GIDSignIn.sharedInstance.handle(url) {
return true
}
#endif
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
} }

View File

@@ -0,0 +1,89 @@
// CoachAppShortcuts.swift
// Les phrases que Siri écoute. C'est ce qui manquait pour « lier Siri à
// l'app » : les App Intents existaient (`CoachIntents.swift`), mais sans
// `AppShortcutsProvider` aucune phrase ne les déclenche — Siri ne sait pas
// quoi écouter, et les actions ne remontent que dans l'app Raccourcis.
//
// ⚠️ **Cible App UNIQUEMENT**, pas les extensions.
// Apple attend un seul `AppShortcutsProvider` par application ; en déclarer un
// second dans une extension widget produirait au mieux un doublon.
// Les INTENTS, eux, sont partagés (cf. `CoachIntents.swift`).
//
// ⚠️ **Chaque phrase doit contenir `\(.applicationName)`** — Apple le refuse
// autrement, et le raccourci n'apparaît simplement pas. Le nom vient de
// `CFBundleDisplayName` ; les variantes ci-dessous supposent « Coach ».
//
// ⚠️ **Dix phrases maximum par intent**, et les premières comptent le plus :
// ce sont celles que Siri propose spontanément.
import AppIntents
@available(iOS 17.0, watchOS 10.0, *)
struct CoachAppShortcuts: AppShortcutsProvider {
/// Couleur de la tuile dans l'app Raccourcis.
static var shortcutTileColor: ShortcutTileColor = .teal
static var appShortcuts: [AppShortcut] {
// ── Noter un verre d'eau ────────────────────────────────────────
// Le geste à une vingtaine de fois par jour : c'est lui qui justifie
// Siri. Les paramètres sont FIXÉS ici (250 ml) — un intent avec
// paramètre libre oblige Siri à poser une question, ce qui coûte plus
// de temps que de sortir le téléphone.
AppShortcut(
intent: LogDrinkIntent(ref: "water", amountMl: 250),
phrases: [
"Note un verre d'eau dans \(.applicationName)",
"J'ai bu un verre d'eau dans \(.applicationName)",
"Ajoute un verre d'eau dans \(.applicationName)",
"Un verre d'eau dans \(.applicationName)",
],
shortTitle: "Un verre d'eau",
systemImageName: "drop.fill"
)
AppShortcut(
intent: LogDrinkIntent(ref: "water", amountMl: 500),
phrases: [
"Note une bouteille d'eau dans \(.applicationName)",
"J'ai bu une bouteille dans \(.applicationName)",
"Ajoute une grande eau dans \(.applicationName)",
],
shortTitle: "Une bouteille d'eau",
systemImageName: "waterbottle.fill"
)
AppShortcut(
intent: LogDrinkIntent(ref: "coffee", amountMl: 60),
phrases: [
"Note un café dans \(.applicationName)",
"J'ai bu un café dans \(.applicationName)",
"Ajoute un café dans \(.applicationName)",
],
shortTitle: "Un café",
systemImageName: "cup.and.saucer.fill"
)
// ── Consulter ───────────────────────────────────────────────────
AppShortcut(
intent: HydrationStatusIntent(),
phrases: [
"Combien j'ai bu dans \(.applicationName)",
"Où j'en suis côté hydratation dans \(.applicationName)",
"Mon hydratation dans \(.applicationName)",
],
shortTitle: "Mon hydratation",
systemImageName: "drop.circle"
)
AppShortcut(
intent: TodaySessionIntent(),
phrases: [
"Quelle est ma séance du jour dans \(.applicationName)",
"Ma séance dans \(.applicationName)",
"Qu'est-ce que je fais aujourd'hui dans \(.applicationName)",
],
shortTitle: "Ma séance du jour",
systemImageName: "figure.run"
)
}
}

View File

@@ -28,6 +28,7 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
CAPPluginMethod(name: "deleteWorkout", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "deleteWorkout", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getEffortScores", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getEffortScores", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getWorkoutDetails", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getWorkoutDetails", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getStepSamples", returnType: CAPPluginReturnPromise),
] ]
private lazy var store = HKHealthStore() private lazy var store = HKHealthStore()
@@ -183,17 +184,36 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
var all: [CLLocation] = [] var all: [CLLocation] = []
let group = DispatchGroup() let group = DispatchGroup()
var pluginErr: Error? var pluginErr: Error?
/* ⚠️ `all` et `pluginErr` sont mutés depuis les callbacks de N requêtes,
livrées sur des files HealthKit indépendantes. Sans verrou, deux
`append` concurrents corrompent le tableau — silencieusement, ou par
un crash sans rapport apparent avec la cause.
Ce fichier verrouillait déjà ce motif à quatre autres endroits
(`getEffortScores`, `getWorkoutDetails`, et deux fois plus bas) :
c'était une omission ponctuelle, pas une convention absente.
§ 2.6 du diagnostic du 24/09/2026.
Déclencheur : un workout portant PLUSIEURS `HKWorkoutRoute`
(multisport, reprise après interruption). Avec une seule route la
boucle n'a qu'une itération, et le défaut est inobservable — c'est
pourquoi il a pu vivre longtemps sans se manifester. */
let lock = NSLock()
for route in routes { for route in routes {
group.enter() group.enter()
let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in
if let err = err { if let err = err {
lock.lock()
pluginErr = err pluginErr = err
lock.unlock()
group.leave() group.leave()
return return
} }
if let locs = locations { if let locs = locations {
lock.lock()
all.append(contentsOf: locs) all.append(contentsOf: locs)
lock.unlock()
} }
if done { if done {
group.leave() group.leave()
@@ -396,6 +416,93 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
store.execute(wq) store.execute(wq)
} }
/// Pas d'une fenêtre de temps, séries condensées **dépliées**.
///
/// Apple, *Accessing condensed workout samples* : « HealthKit enumerates the
/// data associated with a workout and adds that data to one or more
/// quantity series samples. After saving all the data to series objects,
/// HealthKit deletes the originals » — « while still preserving data
/// resolution ». Un échantillon condensé a `count > 1`, et son détail se
/// lit avec `HKQuantitySeriesSampleQuery`. Depuis watchOS 27 (constaté le
/// 21/09/2026), les pas d'une course arrivent en 4 à 7 tranches de 5 à
/// 12 min au lieu de ~850 échantillons de 2,5 s ; le plugin Capgo lit
/// `quantity.doubleValue` sans regarder `count`. Cette méthode dit si le
/// détail existe encore, et le rend s'il existe. Lecture seule.
@objc func getStepSamples(_ call: CAPPluginCall) {
guard HKHealthStore.isHealthDataAvailable(),
let stepType = HKObjectType.quantityType(forIdentifier: .stepCount) else {
call.resolve(["samples": [], "reason": "HealthKit unavailable"])
return
}
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let isoPlain = ISO8601DateFormatter()
func date(_ s: String?) -> Date? {
guard let s = s else { return nil }
return iso.date(from: s) ?? isoPlain.date(from: s)
}
guard let start = date(call.getString("startDate")),
let end = date(call.getString("endDate")), end > start else {
call.reject("startDate et endDate requis (ISO 8601), fin après début")
return
}
let window = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
let query = HKSampleQuery(sampleType: stepType, predicate: window,
limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { [weak self] _, results, error in
guard let self = self else { return }
if let error = error {
call.reject("Step fetch failed: \(error.localizedDescription)")
return
}
let samples = (results as? [HKQuantitySample]) ?? []
let group = DispatchGroup()
let lock = NSLock()
var rows: [[String: Any]] = []
var nSeries = 0
var nExpanded = 0
for s in samples {
let src = s.sourceRevision.source
let base: [String: Any] = ["source": src.name, "sourceId": src.bundleIdentifier]
if s.count <= 1 {
var row = base
row["startDate"] = iso.string(from: s.startDate)
row["endDate"] = iso.string(from: s.endDate)
row["qty"] = s.quantity.doubleValue(for: .count())
lock.lock(); rows.append(row); lock.unlock()
continue
}
nSeries += 1
group.enter()
// Une série : ses quantités individuelles, chacune avec son
// intervalle. `done` marque le dernier appel du handler.
let inSeries = HKQuery.predicateForObject(with: s.uuid)
let detail = HKQuantitySeriesSampleQuery(quantityType: stepType,
predicate: inSeries) { _, quantity, interval, _, done, err in
if let quantity = quantity, let interval = interval {
var row = base
row["startDate"] = iso.string(from: interval.start)
row["endDate"] = iso.string(from: interval.end)
row["qty"] = quantity.doubleValue(for: .count())
row["fromSeries"] = true
lock.lock(); rows.append(row); nExpanded += 1; lock.unlock()
}
if done || err != nil { group.leave() }
}
self.store.execute(detail)
}
group.notify(queue: .global()) {
rows.sort { ($0["startDate"] as? String ?? "") < ($1["startDate"] as? String ?? "") }
NSLog("[CoachHealthRoute] pas : %d échantillons, %d séries, %d dépliés", samples.count, nSeries, nExpanded)
call.resolve(["samples": rows, "raw": samples.count, "series": nSeries, "expanded": nExpanded])
}
}
store.execute(query)
}
/// Assemble métadonnées + statistiques d'une séance. Les champs absents ne /// Assemble métadonnées + statistiques d'une séance. Les champs absents ne
/// sont pas émis : le backend distingue « pas mesuré » de « zéro ». /// sont pas émis : le backend distingue « pas mesuré » de « zéro ».
private func details(for workout: HKWorkout, completion: @escaping ([String: Any]) -> Void) { private func details(for workout: HKWorkout, completion: @escaping ([String: Any]) -> Void) {
@@ -423,6 +530,38 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
out["average_mets"] = (mets.doubleValue(for: unit) * 10).rounded() / 10 out["average_mets"] = (mets.doubleValue(for: unit) * 10).rounded() / 10
} }
// Activités de la séance — lecture seule, pour VOIR ce qu'Exercice écrit.
// Apple : « you can use workout activities to […] represent the active
// and rest periods during interval training. All HKWorkout instances
// have at least one associated HKWorkoutActivity » (doc HKWorkoutActivity,
// lue le 22/09/2026). Apple ne dit PAS que l'app Exercice en écrit une
// par étape d'un exercice personnalisé : c'est ce que ce champ permet
// de constater. Le serveur ne s'en sert pas tant que ce n'est pas vu.
if #available(iOS 16.0, *) {
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var activities: [[String: Any]] = []
for a in workout.workoutActivities {
var row: [String: Any] = [
"start": iso.string(from: a.startDate),
"duration_s": (a.duration * 10).rounded() / 10,
"activity_type": Int(a.workoutConfiguration.activityType.rawValue),
"location": Int(a.workoutConfiguration.locationType.rawValue),
"events": a.workoutEvents.count,
]
if let end = a.endDate { row["end"] = iso.string(from: end) }
// Métadonnées en clair uniquement (String/Number/Bool) : une
// HKQuantity ne se sérialise pas, et on ne veut rien deviner.
var plain: [String: Any] = [:]
for (k, v) in a.metadata ?? [:] {
if v is String || v is NSNumber { plain[k] = v }
}
if !plain.isEmpty { row["metadata"] = plain }
activities.append(row)
}
out["activities"] = activities
}
// Statistiques agrégées sur la fenêtre exacte de la séance. // Statistiques agrégées sur la fenêtre exacte de la séance.
let samplePredicate = HKQuery.predicateForObjects(from: workout) let samplePredicate = HKQuery.predicateForObjects(from: workout)
var requests: [(key: String, id: HKQuantityTypeIdentifier, unit: HKUnit, option: HKStatisticsOptions, scale: Double)] = [] var requests: [(key: String, id: HKQuantityTypeIdentifier, unit: HKUnit, option: HKStatisticsOptions, scale: Double)] = []

View File

@@ -0,0 +1,182 @@
// CoachIntents.swift
// App Intents partagés : app iPhone, widget iPhone, widget Watch, Siri.
//
// ⚠️ **Ce fichier doit appartenir à TOUTES les cibles qui s'en servent** :
// App + CoachLiveActivity + CoachWatchWidgets
// (cf. docs/widgets-runbook-mac.md, étape « Target Membership »).
//
// Pourquoi il existe
// ------------------
// `LogDrinkIntent` vivait dans `CoachQuickWidget.swift`, qui n'appartient qu'à
// l'extension widget iPhone. Deux conséquences, constatées le 25/09/2026 :
//
// - **Siri ne pouvait pas y accéder.** Les raccourcis (`AppShortcutsProvider`)
// se déclarent dans l'application principale, qui ne voyait pas cet intent.
// - **La montre non plus.** La cible `CoachWatchWidgets` ne portait que des
// complications d'affichage : aucune saisie n'était possible au poignet,
// alors que c'est le geste que Sylvain fait une vingtaine de fois par jour.
//
// L'extraire ici, plutôt que le dupliquer, évite deux implémentations qui
// divergeraient — le défaut déjà payé sur l'envoi vers la Watch.
//
// ⛔ Aucun appel réseau ici, et c'est structurel : une extension n'a ni session
// authentifiée ni droit de tenir une requête. L'intent écrit dans la file de
// `CoachQuickLog` (App Group), et l'application la transmet au serveur à sa
// prochaine ouverture. Rien n'est déclaré synchronisé tant qu'il ne l'est pas.
import AppIntents
import Foundation
#if canImport(WidgetKit)
import WidgetKit
#endif
// MARK: - Erreurs
enum CoachQuickError: Error, CustomLocalizedStringResourceConvertible {
case unavailable
var localizedStringResource: LocalizedStringResource {
"Stockage partagé indisponible — ouvrez l'app une fois."
}
}
// MARK: - Noter une boisson
/// Ajoute une boisson sans quitter l'écran d'accueil, le cadran, ou en dictant
/// à Siri.
@available(iOS 17.0, watchOS 10.0, *)
struct LogDrinkIntent: AppIntent {
static var title: LocalizedStringResource = "Noter une boisson"
static var description = IntentDescription(
"Ajoute un verre d'eau ou un café au journal, sans ouvrir l'application."
)
/// `false` : tout l'intérêt est de NE PAS ouvrir l'app.
static var openAppWhenRun: Bool = false
@Parameter(title: "Type") var ref: String
@Parameter(title: "Volume (ml)") var amountMl: Double
init() {}
init(ref: String, amountMl: Double) {
self.ref = ref
self.amountMl = amountMl
}
func perform() async throws -> some IntentResult & ProvidesDialog {
let ok = CoachQuickLog.append(
CoachQuickEntry(kind: .drink, ref: ref, amount: amountMl)
)
#if canImport(WidgetKit)
WidgetCenter.shared.reloadAllTimelines()
#endif
// Un échec d'App Group doit remonter : afficher « ajouté » sur une
// saisie perdue est pire que ne rien afficher.
guard ok else { throw CoachQuickError.unavailable }
// ⚠️ Le dialogue annonce ce qui a été NOTÉ, pas ce qui a été envoyé au
// serveur — la file part à la prochaine ouverture de l'app. Dire
// « enregistré sur ton compte » serait faux pendant plusieurs heures.
let total = CoachQuickLog.todayTotal(
kind: "water",
syncedMl: CoachWidgetStore.load()?.asOf(Date()).waterMlToday ?? 0
)
if ref == "water" {
return .result(dialog: "Noté. \(Self.enLitres(total)) d'eau aujourd'hui.")
}
return .result(dialog: "Noté.")
}
/// « 1,25 L » plutôt que « 1250 ml » : Siri le lit à voix haute.
static func enLitres(_ ml: Double) -> String {
let litres = ml / 1000
let f = NumberFormatter()
f.numberStyle = .decimal
f.maximumFractionDigits = litres < 1 ? 2 : 1
return (f.string(from: NSNumber(value: litres)) ?? "\(litres)") + " L"
}
}
// MARK: - Où j'en suis
/// Répond « combien j'ai bu aujourd'hui », sans ouvrir l'app.
///
/// ⚠️ Cet intent ne fait que LIRE ce que l'appareil sait déjà : le snapshot
/// poussé par l'app plus la file locale. Il n'interroge pas le serveur — une
/// extension n'en a pas le droit, et Siri doit répondre tout de suite.
@available(iOS 17.0, watchOS 10.0, *)
struct HydrationStatusIntent: AppIntent {
static var title: LocalizedStringResource = "Où j'en suis côté hydratation"
static var description = IntentDescription(
"Dit combien d'eau a été notée aujourd'hui, et la cible du jour."
)
static var openAppWhenRun: Bool = false
func perform() async throws -> some IntentResult & ProvidesDialog {
let snapshot = CoachWidgetStore.load()?.asOf(Date())
let total = CoachQuickLog.todayTotal(
kind: "water", syncedMl: snapshot?.waterMlToday ?? 0)
guard let objectif = snapshot?.waterGoalMl, objectif > 0 else {
// Pas de cible connue : on donne le total, sans inventer de reste.
return .result(dialog: "\(LogDrinkIntent.enLitres(total)) d'eau aujourd'hui.")
}
let reste = max(0, objectif - total)
if reste <= 0 {
return .result(dialog: "\(LogDrinkIntent.enLitres(total)) : objectif atteint.")
}
// ⚠️ UN SEUL littéral interpolé, jamais de concaténation.
// `IntentDialog` est `ExpressibleByStringInterpolation` : un littéral
// passe, mais l'opérateur `+` produit une `String`, que le compilateur
// refuse — « Cannot convert value of type 'String' to expected argument
// type 'IntentDialog' ». Erreur rencontrée au premier build, 25/09.
let phrase = "\(LogDrinkIntent.enLitres(total)) sur \(LogDrinkIntent.enLitres(objectif)). Il reste \(LogDrinkIntent.enLitres(reste))."
return .result(dialog: IntentDialog(stringLiteral: phrase))
}
}
// MARK: - La séance du jour
/// Dit la séance prévue aujourd'hui, telle que l'app l'a poussée.
///
/// ⛔ **Aucune borne en bpm n'est prononcée.** `TodaySession` n'en porte pas —
/// vérifié, elle a `sport`, `title`, `subtitle`, `done` — et il ne faut pas en
/// ajouter ici : une fourchette cardiaque dite 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.
/// `subtitle` (« 1h30 · Z2 ») est répété tel que l'app l'affiche, pas
/// reformulé : une zone nommée n'est pas une cible en battements.
@available(iOS 17.0, watchOS 10.0, *)
struct TodaySessionIntent: AppIntent {
static var title: LocalizedStringResource = "Ma séance du jour"
static var description = IntentDescription(
"Annonce le sport et la durée prévus aujourd'hui."
)
static var openAppWhenRun: Bool = false
func perform() async throws -> some IntentResult & ProvidesDialog {
guard let today = CoachWidgetStore.load()?.asOf(Date()).today else {
// Idem : pas de `+` dans un dialogue (cf. HydrationStatusIntent).
return .result(dialog: "Je n'ai pas encore ta séance du jour. Ouvre l'app une fois.")
}
let sport = Self.sportEnFrancais(today.sport)
if today.done {
return .result(dialog: "\(sport) : \(today.title). Déjà faite aujourd'hui.")
}
// `subtitle` porte l'objectif court tel que l'app l'affiche
// (« 1h30 · Z2 ») : on le répète, on ne le reformule pas.
if let objectif = today.subtitle, !objectif.isEmpty {
return .result(dialog: "\(sport) : \(today.title), \(objectif).")
}
return .result(dialog: "\(sport) : \(today.title).")
}
static func sportEnFrancais(_ sport: String) -> String {
switch sport {
case "running": return "Course"
case "cycling": return "Vélo"
case "walking": return "Marche"
case "hiking": return "Randonnée"
case "strength": return "Renforcement"
case "mobility": return "Mobilité"
case "rest": return "Repos"
default: return sport.capitalized
}
}
}

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

@@ -27,7 +27,10 @@
// startDate: '2026-08-01T00:00:00Z', endDate: '2026-08-19T00:00:00Z', // startDate: '2026-08-01T00:00:00Z', endDate: '2026-08-19T00:00:00Z',
// }); // });
// // r.samples = [{startDate, endDate, sleepState, sourceName, timeZone, tzSource}, ...] // // r.samples = [{startDate, endDate, sleepState, sourceName, timeZone, tzSource}, ...]
// // r.available = false si HealthKit est indisponible ou non autorisé // // r.available = false si HealthKit est indisponible, si le type manque,
// // ou si l'autorisation n'a jamais été demandée (`.notDetermined`).
// // ⚠️ Un refus EXPLICITE reste indiscernable d'une absence de données :
// // HealthKit ne le révèle pas en lecture, par conception (cf. readSamples).
import Foundation import Foundation
import UIKit import UIKit
@@ -68,6 +71,28 @@ public class CoachSleepPlugin: CAPPlugin, CAPBridgedPlugin {
call.resolve(["available": false, "samples": [], "reason": "sleepAnalysis unavailable"]) call.resolve(["available": false, "samples": [], "reason": "sleepAnalysis unavailable"])
return return
} }
/* ⚠️ Sans ce test, « on ne nous laisse plus regarder » et « il n'a pas
dormi » rendaient la MÊME réponse : `{available: true, samples: []}`.
Le serveur enregistrait alors des nuits vides, et `sleep_score.py`
calculait une régularité de coucher sur des trous — une valeur fausse,
pas une valeur manquante. L'en-tête de ce fichier promettait pourtant
`available = false` si non autorisé. § 1.3 du diagnostic du 24/09/2026.
Ce que HealthKit permet, et ce qu'il ne permet pas : Apple ne
distingue PAS volontairement « refusé » de « vide » en LECTURE, pour
ne pas révéler qu'une donnée existe. `.sharingDenied` n'est donc pas
fiable pour un type lu. En revanche `.notDetermined` l'est, et c'est
le cas fréquent : réinstallation de l'app, permission jamais demandée.
On ne traite que celui-là — le reste serait une affirmation qu'Apple
ne garantit pas. */
if store.authorizationStatus(for: sleepType) == .notDetermined {
call.resolve([
"available": false,
"samples": [],
"reason": "authorization not determined",
])
return
}
guard let start = Self.parseISO(call.getString("startDate")), guard let start = Self.parseISO(call.getString("startDate")),
let end = Self.parseISO(call.getString("endDate")) else { let end = Self.parseISO(call.getString("endDate")) else {
call.reject("startDate et endDate requis (ISO 8601)") call.reject("startDate et endDate requis (ISO 8601)")

View File

@@ -13,10 +13,18 @@
// forme: { score: 72, label:"Prêt" } // forme: { score: 72, label:"Prêt" }
// }); // });
import Foundation import Foundation
import os
import Capacitor import Capacitor
import WidgetKit import WidgetKit
import WatchConnectivity import WatchConnectivity
/// ⚠️ Ce pont transporte les bornes de zones cardiaques vers la montre. Il était
/// le seul chemin WatchConnectivity du projet à ne rien journaliser — quatre
/// sorties silencieuses sur sept lignes — alors que `CoachRoutineBridge`, qui
/// porte des exercices de mobilité, journalise et remonte ses échecs.
/// Diagnostic du 24/09/2026 : `docs/DIAGNOSTIC-NATIF-2026-09-24.md` § 1.2.
private let widgetLog = Logger(subsystem: "ch.hypnotruck.coach", category: "widget")
@objc(CoachWidgetBridgePlugin) @objc(CoachWidgetBridgePlugin)
public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin { public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "CoachWidgetBridgePlugin" public let identifier = "CoachWidgetBridgePlugin"
@@ -164,8 +172,15 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
return return
} }
if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() } if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() }
pushToWatch(snapshot) // Le statut d'envoi remonte au JS : jusqu'ici, la page n'avait aucun
call.resolve(["ok": true]) // moyen de savoir si les bornes cardiaques étaient arrivées au poignet.
// Champs AJOUTÉS, `ok` conservé : aucun appelant existant ne casse.
let raison = pushToWatch(snapshot)
call.resolve([
"ok": true,
"watchPushed": raison == nil,
"watchReason": raison ?? "",
])
} }
/// Envoie le snapshot à l'Apple Watch pour ses complications. L'App Group /// Envoie le snapshot à l'Apple Watch pour ses complications. L'App Group
@@ -173,14 +188,50 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
/// de SA montre, alimenté ici via WatchConnectivity. /// de SA montre, alimenté ici via WatchConnectivity.
/// On n'est PAS le delegate WCSession (c'est CoachLiveBridge) : on se contente /// On n'est PAS le delegate WCSession (c'est CoachLiveBridge) : on se contente
/// d'émettre, ce qui ne requiert pas d'être delegate, juste une session activée. /// d'émettre, ce qui ne requiert pas d'être delegate, juste une session activée.
private func pushToWatch(_ snapshot: CoachWidgetSnapshot) { /// Rend la raison de l'échec, ou `nil` si le snapshot est parti.
guard WCSession.isSupported() else { return } ///
/// ⚠️ Un échec ici ne fait PAS échouer `setSnapshot` : l'App Group est déjà
/// écrit et les widgets iPhone rafraîchis. Une montre absente ou verrouillée
/// n'invalide pas l'opération — d'où un `resolve` enrichi plutôt qu'un
/// `reject`, contrairement à `CoachRoutineBridge` où l'envoi EST l'opération.
@discardableResult
private func pushToWatch(_ snapshot: CoachWidgetSnapshot) -> String? {
guard WCSession.isSupported() else {
widgetLog.info("snapshot non poussé : WatchConnectivity non supporté")
return "WatchConnectivity non supporté"
}
let session = WCSession.default let session = WCSession.default
guard session.activationState == .activated, guard session.activationState == .activated else {
let data = try? JSONEncoder().encode(snapshot) else { return } widgetLog.error("snapshot non poussé : session non activée (état \(session.activationState.rawValue, privacy: .public))")
return "session WatchConnectivity non activée"
}
// `isPaired` / `isWatchAppInstalled` comme dans CoachRoutineBridge : sans
// eux, l'absence de montre se confond avec un échec d'envoi.
guard session.isPaired else {
widgetLog.info("snapshot non poussé : aucune montre appairée")
return "aucune Watch appairée"
}
guard session.isWatchAppInstalled else {
widgetLog.info("snapshot non poussé : app Watch non installée")
return "app Watch non installée"
}
guard let data = try? JSONEncoder().encode(snapshot) else {
widgetLog.error("snapshot non poussé : encodage JSON impossible")
return "encodage du snapshot impossible"
}
do {
// updateApplicationContext : dernier état, sans budget (≠ transferCurrent // updateApplicationContext : dernier état, sans budget (≠ transferCurrent
// ComplicationUserInfo qui réveille la complication mais est rationné ~50/j). // ComplicationUserInfo qui réveille la complication mais est rationné ~50/j).
try? session.updateApplicationContext(["widgetSnapshot": data]) try session.updateApplicationContext(["widgetSnapshot": data])
// Les zones sont la raison d'être de ce log : savoir QUELLES bornes
// sont au poignet, et depuis quand, sans avoir à ouvrir la montre.
let z = snapshot.zones
widgetLog.info("snapshot poussé vers la Watch · zones=\(z != nil ? "oui" : "non", privacy: .public) depuis=\(z?.zonesSince ?? "?", privacy: .public)")
return nil
} catch {
widgetLog.error("updateApplicationContext a échoué : \(error.localizedDescription, privacy: .public)")
return error.localizedDescription
}
} }
/// Relit le snapshot RÉELLEMENT stocké dans l'App Group. /// Relit le snapshot RÉELLEMENT stocké dans l'App Group.

View File

@@ -69,6 +69,21 @@ struct CoachWidgetSnapshot: Codable {
var kcalToday: Double? = nil var kcalToday: Double? = nil
var kcalGoal: Double? = nil var kcalGoal: Double? = nil
/// Macros du jour et leurs cibles (25/09/2026, pour le suivi au poignet).
/// Mêmes règles que l'énergie : les totaux viennent du journal, les cibles
/// de `nutrition_goals.py` — rien n'est écrit en dur, pour qu'un changement
/// d'objectif suive.
/// ⚠️ Les cibles portent côté serveur une forme `{target, min, max}` :
/// `widget-bridge.js` n'en pousse que `target`. Une jauge sur une cible de
/// type « min » (les protéines) se lit donc comme un plancher atteint, pas
/// comme un plafond à ne pas dépasser.
var proteinToday: Double? = nil
var proteinGoal: Double? = nil
var fatToday: Double? = nil
var fatGoal: Double? = nil
var carbsToday: Double? = nil
var carbsGoal: Double? = nil
/// Boutons de saisie rapide, poussés depuis les préréglages de boisson. /// Boutons de saisie rapide, poussés depuis les préréglages de boisson.
/// `nil` ⇒ repli aligné sur les préréglages par défaut du serveur. /// `nil` ⇒ repli aligné sur les préréglages par défaut du serveur.
var quickDrinks: [QuickDrink]? = nil var quickDrinks: [QuickDrink]? = nil
@@ -237,6 +252,13 @@ extension CoachWidgetSnapshot {
s.waterMlToday = 0 s.waterMlToday = 0
s.coffeeCountToday = 0 s.coffeeCountToday = 0
s.kcalToday = 0 s.kcalToday = 0
// ⚠️ Les macros se périment avec l'énergie. Les oublier ici afficherait
// les grammes d'hier sous la date du jour — exactement le défaut que
// `asOf` existe pour éviter, et qui avait survécu un temps sur les
// totaux de boisson.
s.proteinToday = 0
s.fatToday = 0
s.carbsToday = 0
s.routine = nil s.routine = nil
// L'activité de la veille n'est pas celle du jour, et le score de forme // L'activité de la veille n'est pas celle du jour, et le score de forme
// du jour n'est calculé qu'après la nuit : afficher celui d'hier comme // du jour n'est calculé qu'après la nuit : afficher celui d'hier comme

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 {
@@ -303,12 +310,29 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
NSLog("[CoachWorkoutObserver] anchored fired but 0 new workouts") NSLog("[CoachWorkoutObserver] anchored fired but 0 new workouts")
return return
} }
// Notifier le plus récent (par endDate) — généralement il y // ⚠️ Les rapports serveur partent pour TOUTES les séances du lot.
// en a 1 mais Apple peut grouper plusieurs samples. // Ils n'étaient envoyés que pour `latest`, alors que l'ancre venait
// d'être avancée quelques lignes plus haut : les autres séances du
// lot n'étaient donc jamais rapportées, et ne reviendraient jamais.
//
// Le cas se produit dès que l'iPhone a manqué un réveil : renfo le
// matin, vélo le soir, téléphone resté à la maison — au premier
// fire, la requête ancrée rend les deux, le vélo est notifié, et la
// routine du matin n'est jamais cochée. Le log disait « 2 new
// workout(s), notifying latest » : exact, et parfaitement muet sur
// ce qu'il venait de jeter.
for seance in workouts {
self.reportToRoutineIfStrength(seance)
self.reportPlanOrigin(seance)
}
// Une seule notification en revanche : un « 🎉 » par séance d'un
// même lot serait du bruit. On garde la plus récente.
let latest = workouts.max(by: { $0.endDate < $1.endDate })! let latest = workouts.max(by: { $0.endDate < $1.endDate })!
let uuid = latest.uuid.uuidString let uuid = latest.uuid.uuidString
UserDefaults.standard.set(uuid, forKey: self.lastNotifiedKey) UserDefaults.standard.set(uuid, forKey: self.lastNotifiedKey)
NSLog("[CoachWorkoutObserver] %d new workout(s), notifying latest %@", workouts.count, uuid) NSLog("[CoachWorkoutObserver] %d nouvelle(s) séance(s) rapportée(s), notification pour %@",
workouts.count, uuid)
self.scheduleNotification(for: latest) self.scheduleNotification(for: latest)
} }
healthStore.execute(query) healthStore.execute(query)
@@ -335,6 +359,37 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
} }
} }
/// Envoie une requête et **lit le statut HTTP**.
///
/// ⚠️ Les deux POST de ce fichier jetaient la réponse (`{ data, _, error in }`)
/// et ne regardaient que `error`. Or un 401 sur token rotaté, un 404 ou un
/// 500 ne produisent PAS d'`error` : `reportPlanOrigin` journalisait donc
/// « plan-origin envoyé » sur une requête refusée, et `auto-complete`
/// sortait en silence au `guard` de parsing. Le bon pattern existait déjà
/// deux fichiers plus loin — `CoachRoutineBridge.postDay` vérifie
/// `(200..<300)` et journalise le code.
///
/// `traiter` ne reçoit le corps que sur un statut de succès.
private func envoyer(_ req: URLRequest, libelle: String,
traiter: ((Data) -> Void)? = nil) {
URLSession.shared.dataTask(with: req) { data, reponse, error in
if let error = error {
NSLog("[CoachWorkoutObserver] %@ : %@", libelle, error.localizedDescription)
return
}
guard let http = reponse as? HTTPURLResponse else {
NSLog("[CoachWorkoutObserver] %@ : réponse sans statut HTTP", libelle)
return
}
guard (200..<300).contains(http.statusCode) else {
NSLog("[CoachWorkoutObserver] %@ : HTTP %d — non pris en compte",
libelle, http.statusCode)
return
}
traiter?(data ?? Data())
}.resume()
}
/// Remonte au serveur la fin d'une séance de renfo, qui décide seul si elle /// Remonte au serveur la fin d'une séance de renfo, qui décide seul si elle
/// correspond à une routine poussée dans l'app Exercice. /// correspond à une routine poussée dans l'app Exercice.
/// ///
@@ -352,19 +407,31 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
req.httpMethod = "POST" req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie") req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie")
// ⚠️ L'activité était écrite en dur à « functionalStrengthTraining »
// alors que le garde ci-dessus accepte AUSSI traditionalStrengthTraining :
// une séance de musculation classique arrivait au serveur étiquetée
// fonctionnelle. Si la règle de correspondance discrimine sur ce champ
// — et le commentaire ci-dessus dit que les règles vivent là-bas —,
// l'appariement était faussé.
//
// L'UUID et l'heure de début manquaient aussi : sans eux le serveur ne
// peut pas dédupliquer, et il doit évaluer sa fenêtre de 12 h depuis
// l'heure de RÉCEPTION. Une séance remontée avec six heures de retard
// (iPhone resté à la maison) était donc datée de son arrivée.
req.httpBody = try? JSONSerialization.data(withJSONObject: [ req.httpBody = try? JSONSerialization.data(withJSONObject: [
"activity": "functionalStrengthTraining", "activity": activity == .traditionalStrengthTraining
? "traditionalStrengthTraining" : "functionalStrengthTraining",
"duration_min": workout.duration / 60, "duration_min": workout.duration / 60,
"uuid": workout.uuid.uuidString,
"start": ISO8601DateFormatter().string(from: workout.startDate),
]) ])
req.timeoutInterval = 15 req.timeoutInterval = 15
URLSession.shared.dataTask(with: req) { data, _, error in envoyer(req, libelle: "auto-complete routine") { data in
if let error = error { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
NSLog("[CoachWorkoutObserver] auto-complete routine: %@", error.localizedDescription) NSLog("[CoachWorkoutObserver] auto-complete : réponse illisible")
return return
} }
guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
if (json["matched"] as? Bool) == true { if (json["matched"] as? Bool) == true {
let n = (json["done"] as? [String])?.count ?? 0 let n = (json["done"] as? [String])?.count ?? 0
NSLog("[CoachWorkoutObserver] routine '%@' cochée automatiquement (%d exercices)", NSLog("[CoachWorkoutObserver] routine '%@' cochée automatiquement (%d exercices)",
@@ -372,11 +439,68 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
} else { } else {
NSLog("[CoachWorkoutObserver] pas une routine (%@)", (json["reason"] as? String) ?? "?") NSLog("[CoachWorkoutObserver] pas une routine (%@)", (json["reason"] as? String) ?? "?")
} }
}.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
// Le log de succès ne part plus que sur un 2xx : il s'écrivait
// auparavant sur un 401 ou un 500, puisque seul `error` était lu.
self.envoyer(req, libelle: "plan-origin") { _ in
NSLog("[CoachWorkoutObserver] plan-origin accepté (from_plan=%@)",
fromPlan ? "true" : "false")
}
}
#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

@@ -32,6 +32,23 @@ struct HeartRateZones: Codable, Equatable, Sendable {
/// vers Z2-Z3. C'est le repère clinique, celui qui compte ici. /// vers Z2-Z3. C'est le repère clinique, celui qui compte ici.
var clinicalTargetBpm: Int? var clinicalTargetBpm: Int?
/// Date de la version FIGÉE dont viennent ces bornes (`YYYY-MM-DD`), telle
/// que servie par `/api/cardiac-zones` (`zones_since`).
///
/// ⚠️ Sans elle, la montre affichait des bornes sans âge. Le backend tient
/// une version datée (`data/hr_zones_history.json`, règle 9 du CLAUDE.md
/// web) parce qu'une référence qui bouge avec son propre bruit ne permet
/// pas de juger une séance ; **cette date se perdait au transport**. Après
/// une révision des bornes, rien au poignet ne distinguait les nouvelles
/// des anciennes — et si le push échouait en silence (cf. § 1.2 du
/// diagnostic du 24/09/2026), les anciennes restaient indéfiniment.
var zonesSince: String? = nil
/// `"frozen"` (version confirmée) ou `"live"` (calcul glissant, avant la
/// première version figée). Distingue « bornes validées » de « bornes
/// provisoires » — la montre ne pouvait pas faire la différence.
var zonesFrom: String? = nil
/// L'effort est-il dans la zone d'entraînement utile ? /// L'effort est-il dans la zone d'entraînement utile ?
func isUseful(bpm: Int) -> Bool { func isUseful(bpm: Int) -> Bool {
guard let target = clinicalTargetBpm else { return false } guard let target = clinicalTargetBpm else { return false }

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>
@@ -48,6 +50,25 @@
<string>location</string> <string>location</string>
<string>fetch</string> <string>fetch</string>
</array> </array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</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

@@ -0,0 +1,84 @@
import UIKit
import Capacitor
#if canImport(GoogleSignIn)
import GoogleSignIn
#endif
/// Cycle de vie par scènes, **obligatoire** depuis iOS 27 : « apps built with
/// the latest SDK must adopt the scene-based life cycle or they fail to launch »
/// (Apple, *Transitioning to the UIKit scene-based life cycle*). Sans ce
/// fichier et le `UIApplicationSceneManifest` de l'Info.plist, l'app ne démarre
/// pas du tout — c'est un assert au lancement, pas un avertissement.
///
/// ⚠️ **Ce qui arrive ici arrive en SILENCE.** Une fois le manifeste en place,
/// UIKit cesse d'appeler `application(_:open:options:)`,
/// `application(_:continue:restorationHandler:)` et `applicationDidBecomeActive`
/// sur l'AppDelegate — « even if you keep them implemented » (Apple). Rien ne
/// casse à la compilation : le retour OAuth Google part vers Safari et ne
/// revient jamais, et les saisies du widget restent bloquées dans l'App Group.
/// Les trois sont donc repris ci-dessous.
///
/// Ce que la migration NE change PAS, et c'est ce qui la rendait acceptable :
/// `application(_:didFinishLaunchingWithOptions:)` reste appelé, et toujours
/// **avant** que la scène ne se connecte — donc avant que la WKWebView charge.
/// L'injection du cookie d'auth garde son ordre (Apple documente la séquence :
/// launch → création des scènes → `scene(_:willConnectTo:)`). Les callbacks de
/// notification distante ne bougent pas non plus : `UISceneDelegate` n'a aucun
/// équivalent, et ils ne sont pas dépréciés.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
// ⚠️ Ne PAS conditionner cette création à `window == nil`.
//
// C'était ma première version, par prudence : le manifeste déclarant
// `UISceneStoryboardFile = Main`, je supposais qu'UIKit avait déjà
// monté la fenêtre et qu'il ne fallait pas l'écraser. Résultat sur
// appareil : **écran noir permanent, sans la moindre erreur**. UIKit
// fournit bien une fenêtre, mais ne la rend pas visible à notre place ;
// la garde sautait donc `makeKeyAndVisible()` et rien ne s'affichait.
//
// Le template de Capacitor crée la fenêtre sans condition, et c'est le
// chemin éprouvé. Le seul écart nécessaire est `MainViewController` au
// lieu d'un `CAPBridgeViewController` nu : notre sous-classe enregistre
// les neuf plugins in-app dans `capacitorDidLoad()`. Elle ne dépend de
// rien du storyboard, donc l'instancier ici est sans perte.
let fenetre = UIWindow(windowScene: windowScene)
fenetre.rootViewController = MainViewController()
window = fenetre
fenetre.makeKeyAndVisible()
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
// Retour du flux OAuth Google. `handle(url)` prend UNE url alors que la
// scène en livre un ensemble : on laisse à Capacitor ce que Google n'a
// pas consommé, plutôt que de tout lui passer ou de tout lui cacher.
var restants = URLContexts
#if canImport(GoogleSignIn)
restants = URLContexts.filter { !GIDSignIn.sharedInstance.handle($0.url) }
#endif
guard !restants.isEmpty else { return }
SceneDelegateProxy.shared.scene(scene, openURLContexts: restants)
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
// Universal Links : les .widgetURL des widgets, de la Live Activity et
// des complications Watch passent par là. Sans ce relais, ils
// ouvriraient Safari, où le cookie injecté dans la WKWebView n'existe
// pas.
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
}
func sceneDidBecomeActive(_ scene: UIScene) {
// Reprend `applicationDidBecomeActive`, qu'UIKit n'appelle plus. Les
// saisies faites depuis le widget (eau, café) attendent dans l'App
// Group : une extension n'a ni session ni droit de tenir une requête.
Task { await CoachQuickSync.flush() }
}
}

View File

@@ -4,7 +4,7 @@ import PackageDescription
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands // DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
let package = Package( let package = Package(
name: "CapApp-SPM", name: "CapApp-SPM",
platforms: [.iOS(.v15)], platforms: [.iOS(.v"$)],
products: [ products: [
.library( .library(
name: "CapApp-SPM", name: "CapApp-SPM",

View File

@@ -17,41 +17,14 @@ import SwiftUI
import WidgetKit import WidgetKit
// MARK: - Intents // MARK: - Intents
//
/// Ajoute une boisson sans quitter l'écran d'accueil. // ⚠️ `LogDrinkIntent` et `CoachQuickError` ont DÉMÉNAGÉ dans
@available(iOS 17.0, watchOS 10.0, *) // `ios/App/App/CoachIntents.swift` le 25/09/2026, pour être partagés entre
struct LogDrinkIntent: AppIntent { // trois cibles : ce widget, l'app (d'où Siri les découvre) et le widget Watch.
static var title: LocalizedStringResource = "Noter une boisson" // Les laisser ici les rendait invisibles aux deux autres — Siri n'avait aucun
/// `false` : tout l'intérêt est de NE PAS ouvrir l'app. // intent à écouter, et la montre ne savait pas noter un verre.
static var openAppWhenRun: Bool = false // ⛔ Ne pas les redéclarer ici : deux définitions du même symbole dans une
// cible qui compile les deux fichiers, et le build casse.
@Parameter(title: "Type") var ref: String
@Parameter(title: "Volume (ml)") var amountMl: Double
init() {}
init(ref: String, amountMl: Double) {
self.ref = ref
self.amountMl = amountMl
}
func perform() async throws -> some IntentResult {
let ok = CoachQuickLog.append(
CoachQuickEntry(kind: .drink, ref: ref, amount: amountMl)
)
WidgetCenter.shared.reloadAllTimelines()
// Un échec d'App Group doit remonter : afficher « ajouté » sur une
// saisie perdue est pire que ne rien afficher.
guard ok else { throw CoachQuickError.unavailable }
return .result()
}
}
enum CoachQuickError: Error, CustomLocalizedStringResourceConvertible {
case unavailable
var localizedStringResource: LocalizedStringResource {
"Stockage partagé indisponible — ouvrez l'app une fois."
}
}
// MARK: - Timeline // MARK: - Timeline

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

@@ -142,9 +142,21 @@ final class LocationTracker: NSObject, ObservableObject {
guard isTracking else { return } guard isTracking else { return }
isPaused = paused isPaused = paused
if paused { if paused {
// Le point qui suivra la reprise serait à des centaines de mètres : // ⚠️ Ici se jouait une perte silencieuse. `pendingRoute` ne contient
// on repart d'une origine neuve pour ne pas tracer la pause. // que des points DÉJÀ acceptés par le filtre et DÉJÀ comptés dans
pendingRoute.removeAll() // `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. Les jeter creusait un
// écart visible entre la distance affichée et la trace enregistrée :
// six arrêts sur une sortie, et jusqu'à 144 points manquaient à la
// HKWorkoutRoute, avec un D+ recalculé qui divergeait d'autant.
//
// On les écrit donc au lieu de les perdre. 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, et c'est `implausibleSpeed` qui l'écarte. Nettoyer la
// pause est l'affaire du filtre, pas du tampon d'écriture.
Task { await flushPendingRoute() }
} }
} }
@@ -165,21 +177,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 +212,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

@@ -31,8 +31,59 @@ final class WorkoutManager: NSObject, ObservableObject {
private let minSendInterval: TimeInterval = 0.5 private let minSendInterval: TimeInterval = 0.5
private var simTask: Task<Void, Never>? private var simTask: Task<Void, Never>?
/// Date du dernier échantillon de FC **réellement mesuré**. Posée nulle
/// part ailleurs que là où HealthKit nous donne une valeur.
private var heartRateAt: Date?
/// Au-delà de ce délai sans nouvel échantillon, on cesse d'afficher et
/// d'émettre la dernière FC connue.
///
/// ⚠️ Cette valeur est un **choix d'ingénierie, pas une donnée
/// physiologique** : Apple ne publie pas de cadence garantie pour
/// l'échantillonnage de la FC en séance. Elle est posée au-dessus du
/// `lostThreshold` de 8 s de `LiveStore` côté iPhone, pour que la FC ne se
/// périme jamais *avant* que la connexion elle-même soit déclarée perdue —
/// deux signaux contradictoires seraient illisibles au poignet.
///
/// Si « — » clignote en usage normal, c'est cette valeur qu'il faut
/// augmenter, pas la péremption qu'il faut retirer.
///
/// ⚠️ Limite assumée : la péremption est évaluée dans `ingest`, donc elle
/// suppose qu'un échantillon — d'un type quelconque — continue d'arriver.
/// C'est le cas du scénario visé (le capteur FC décroche pendant que
/// calories et distance continuent). 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 couvrir un cas déjà visible autrement.
private let heartRateStaleAfter: TimeInterval = 10
/// La FC connue si elle est encore fraîche, 0 sinon.
///
/// 0 est la convention « inconnu » partagée par toute la chaîne :
/// `HeartRateMetric` n'affiche alors plus de zone, et `heartRateText` côté
/// iPhone rend « — ».
private var freshHeartRate: Double {
guard let at = heartRateAt,
Date().timeIntervalSince(at) <= heartRateStaleAfter else { return 0 }
return heartRate
}
private override init() { super.init() } private override init() { super.init() }
/// Remet les mesures à « inconnu » avant une nouvelle séance.
///
/// `WorkoutManager` est un singleton : sans ça, les valeurs de la séance
/// précédente restaient affichées jusqu'au premier échantillon de la
/// nouvelle — une FC d'hier présentée comme celle de maintenant.
private func resetMetrics() {
heartRate = 0
heartRateAt = nil
activeEnergyKcal = 0
distanceMeters = 0
speedKmh = 0
elapsedSec = 0
lastSentAt = .distantPast
}
func startWorkout(activityType: HKWorkoutActivityType, func startWorkout(activityType: HKWorkoutActivityType,
locationType: HKWorkoutSessionLocationType) async { locationType: HKWorkoutSessionLocationType) async {
guard !isRunning, session == nil else { return } guard !isRunning, session == nil else { return }
@@ -40,6 +91,7 @@ final class WorkoutManager: NSObject, ObservableObject {
statusMessage = "HealthKit indisponible sur cet appareil" statusMessage = "HealthKit indisponible sur cet appareil"
return return
} }
resetMetrics()
let distanceType = Self.distanceType(for: activityType) let distanceType = Self.distanceType(for: activityType)
let hr = HKQuantityType(.heartRate) let hr = HKQuantityType(.heartRate)
let energy = HKQuantityType(.activeEnergyBurned) let energy = HKQuantityType(.activeEnergyBurned)
@@ -164,6 +216,7 @@ final class WorkoutManager: NSObject, ObservableObject {
/// Mode test : FC simulée, sans HealthKit, pour valider le flux watch -> iPhone -> /live. /// Mode test : FC simulée, sans HealthKit, pour valider le flux watch -> iPhone -> /live.
func startSimulation() { func startSimulation() {
guard !isRunning else { return } guard !isRunning else { return }
resetMetrics()
isRunning = true isRunning = true
statusMessage = "Mode test — FC simulée" statusMessage = "Mode test — FC simulée"
let startedAt = Date() let startedAt = Date()
@@ -175,6 +228,7 @@ final class WorkoutManager: NSObject, ObservableObject {
let kcal = elapsed / 6 let kcal = elapsed / 6
let dist = elapsed * 2.6 let dist = elapsed * 2.6
self.heartRate = hr self.heartRate = hr
self.heartRateAt = Date() // sinon la FC simulée se périme
self.activeEnergyKcal = kcal self.activeEnergyKcal = kcal
self.distanceMeters = dist self.distanceMeters = dist
self.elapsedSec = elapsed self.elapsedSec = elapsed
@@ -196,7 +250,24 @@ final class WorkoutManager: NSObject, ObservableObject {
/// Appelé sur le main depuis le callback de collecte (hors-main). /// Appelé sur le main depuis le callback de collecte (hors-main).
private func ingest(hr: Double?, kcal: Double?, dist: Double?, speedMs: Double?, private func ingest(hr: Double?, kcal: Double?, dist: Double?, speedMs: Double?,
elapsed: TimeInterval) { elapsed: TimeInterval) {
if let hr { heartRate = hr } // ⚠️ `didCollectDataOf` se déclenche dès qu'UN type est collecté. Quand
// le capteur optique décroche — bracelet desserré, froid, arythmie —
// les calories continuent d'arriver et `hr` vaut nil. Sans la date
// ci-dessous, la dernière FC connue restait affichée, et repartait vers
// l'iPhone avec un horodatage NEUF : le watchdog de `LiveStore`, qui
// ne regarde que l'arrivée de samples, ne pouvait alors jamais la
// déclarer perdue. Une FC morte s'affichait stable, avec sa zone
// colorée, indéfiniment — de quoi valider un effort sur un chiffre qui
// n'existe plus.
if let hr {
heartRate = hr
heartRateAt = Date()
} else if freshHeartRate == 0 {
// Périmée : on retombe sur la convention « inconnu » plutôt que de
// laisser la vue afficher une valeur que plus rien ne soutient.
heartRate = 0
heartRateAt = nil
}
if let kcal { activeEnergyKcal = kcal } if let kcal { activeEnergyKcal = kcal }
if let dist { distanceMeters = dist } if let dist { distanceMeters = dist }
elapsedSec = elapsed elapsedSec = elapsed
@@ -215,7 +286,10 @@ final class WorkoutManager: NSObject, ObservableObject {
guard heartRateUpdated || now.timeIntervalSince(lastSentAt) >= minSendInterval else { return } guard heartRateUpdated || now.timeIntervalSince(lastSentAt) >= minSendInterval else { return }
lastSentAt = now lastSentAt = now
ConnectivityManager.shared.sendSample([ ConnectivityManager.shared.sendSample([
"heartRate": heartRate, // `freshHeartRate`, pas `heartRate` : ne jamais envoyer une valeur
// périmée sous un horodatage neuf. L'iPhone ne peut pas deviner
// l'âge d'un chiffre qu'on lui présente comme actuel.
"heartRate": freshHeartRate,
"activeEnergyKcal": activeEnergyKcal, "activeEnergyKcal": activeEnergyKcal,
"distanceMeters": distanceMeters, "distanceMeters": distanceMeters,
"speedKmh": speedKmh, "speedKmh": speedKmh,
@@ -307,12 +381,53 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
// sauvegarder la séance, et SEULEMENT ensuite clore la trace en // sauvegarder la séance, et SEULEMENT ensuite clore la trace en
// l'associant au workout obtenu. // l'associant au workout obtenu.
LocationTracker.shared.stopTracking() LocationTracker.shared.stopTracking()
try? await builder.endCollection(at: date) /* ⚠️ Les deux `try?` d'avant rendaient indiscernables deux
situations OPPOSÉES, toutes deux menant à `workout == nil` :
· montre verrouillée à l'arrêt → la séance EST écrite ;
· `endCollection` a levé → il n'y a rien à retrouver.
Elles tombaient dans le même `else`, et seul le sort de la TRACE
était journalisé — pas celui de la séance. Après deux heures de
sortie, l'écran de fin restait le même.
§ 2.3 du diagnostic du 24/09/2026. La logique de récupération
ci-dessous n'a pas changé : seul l'échec devient visible. */
var collecteClose = true
do {
try await builder.endCollection(at: date)
} catch {
collecteClose = false
workoutLog.error("endCollection a echoue : \(error.localizedDescription, privacy: .public)")
}
// ⚠️ `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:)`
let workout = try? await builder.finishWorkout() // exige un `HKWorkout` non optionnel (vérifié dans la doc Apple),
// on va rechercher celle qui vient d'être sauvegardée.
var workout: HKWorkout?
do {
workout = try await builder.finishWorkout()
} catch {
workoutLog.error("finishWorkout a echoue : \(error.localizedDescription, privacy: .public)")
}
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()
if collecteClose {
// La collecte s'est close proprement mais rien n'est
// retrouvable : anormal, et jamais journalisé jusqu'ici.
workoutLog.error("seance introuvable apres un finishWorkout() nil : rien a associer a la trace")
} else {
workoutLog.error("SEANCE PERDUE : endCollection a leve, rien n'a ete ecrit dans HealthKit")
/* Le dire à l'écran. C'est le cœur du constat : l'utilisateur
voyait l'écran de fin habituel après une séance perdue.
Uniquement dans ce cas — une montre verrouillée n'est pas
une perte, et l'annoncer serait une fausse alerte. */
self.statusMessage = "Séance non enregistrée — rien n'a pu être écrit dans Santé"
}
}
} }
Task { @MainActor in Task { @MainActor in
self.isRunning = false self.isRunning = false
@@ -325,14 +440,88 @@ 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
let ns = error as NSError let ns = error as NSError
workoutLog.error("session didFailWithError domain=\(ns.domain) code=\(ns.code): \(message)") workoutLog.error("session didFailWithError domain=\(ns.domain) code=\(ns.code): \(message)")
let builder = workoutSession.associatedWorkoutBuilder()
Task { @MainActor in Task { @MainActor in
self.statusMessage = "Erreur séance : \(message)" /* ⚠️ Ce chemin ne clôturait RIEN. Comparé à `.ended`, il manquait
`stopTracking()`, `endCollection`, `finishWorkout` et
`finishRoute`/`discardRoute` — et `self.builder = nil` détruisait
la seule référence au builder : la perte devenait définitive dans
l'instruction même qui l'ignorait. Le GPS, lui, continuait de
tourner après la fin apparente de la séance, sans que rien ne
l'indique. § 2.4 du diagnostic du 24/09/2026.
Le GPS 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.
La sauvegarde ensuite, en best-effort — une session qui a échoué
refusera souvent de clore, mais ne pas essayer garantit la perte. */
LocationTracker.shared.stopTracking()
var sauvee: HKWorkout?
do {
try await builder.endCollection(at: Date())
sauvee = try await builder.finishWorkout()
} catch {
workoutLog.error("cloture apres echec impossible : \(error.localizedDescription, privacy: .public)")
}
if let sauvee {
workoutLog.info("seance sauvee malgre l'echec de session")
await LocationTracker.shared.finishRoute(with: sauvee)
self.statusMessage = "Séance interrompue, mais enregistrée"
} else if let recovered = await self.recentlySavedWorkout() {
workoutLog.info("seance retrouvee dans HealthKit apres l'echec de session")
await LocationTracker.shared.finishRoute(with: recovered)
self.statusMessage = "Séance interrompue, mais enregistrée"
} else {
LocationTracker.shared.discardRoute()
workoutLog.error("SEANCE PERDUE apres didFailWithError : rien n'a pu etre sauve")
self.statusMessage = "Séance perdue : \(message)"
}
self.isRunning = false self.isRunning = false
self.isPaused = false
self.session = nil self.session = nil
self.builder = nil self.builder = nil
ConnectivityManager.shared.sendEnded(reason: "session-failed") ConnectivityManager.shared.sendEnded(reason: "session-failed")

View File

@@ -300,5 +300,11 @@ struct CoachWatchWidgetsBundle: WidgetBundle {
CoachWatchFormeWidget() CoachWatchFormeWidget()
CoachWatchSessionWidget() CoachWatchSessionWidget()
CoachWatchRoutineWidget() CoachWatchRoutineWidget()
// Smart Stack : le seul widget de la montre qui fasse AGIR — noter un
// verre sans sortir le téléphone. Ajouté le 25/09/2026.
if #available(watchOS 10.0, *) {
CoachWatchHydrationWidget()
CoachWatchNutritionWidget()
}
} }
} }

View File

@@ -0,0 +1,327 @@
// CoachWatchHydration.swift
// Widget « Hydratation » pour la Smart Stack de l'Apple Watch : voir où l'on
// en est, et **noter un verre sans sortir le téléphone**.
//
// ⚠️ Cible : CoachWatchWidgets. Dépend de `CoachIntents.swift`,
// `CoachQuickLog.swift` et `CoachWidgetSnapshot.swift`, qui doivent appartenir
// à CETTE cible aussi (cf. docs/widgets-runbook-mac.md).
//
// Pourquoi ce widget, et pas un de plus pour les repas
// -----------------------------------------------------
// Tranché avec Sylvain le 25/09/2026. Boire se note une vingtaine de fois par
// jour : c'est le seul geste où la montre bat vraiment le téléphone — le
// poignet est déjà levé. Les repas, non : pas de caméra, pas de scanner de
// code-barres, pas de saisie de texte confortable. Un bouton « repas » au
// poignet ne ferait que renvoyer vers l'iPhone, pour du code à maintenir.
//
// ⚠️ **Rien ne part sur le réseau ici.** Comme sur l'iPhone, l'intent écrit
// dans la file de `CoachQuickLog` (App Group de la montre) et l'application la
// transmet à sa prochaine ouverture. Le widget affiche donc son propre total —
// snapshot serveur + file locale — sinon il afficherait un chiffre périmé juste
// après une saisie, et on douterait de ce qu'on vient de faire.
//
// ⚠️ **Interactivité : Smart Stack, pas cadran.** Les boutons d'App Intent
// demandent watchOS 10+. Reste à confirmer AU BUILD si une complication de
// cadran les accepte : dans le doute, la famille `.accessoryCircular` ci-dessous
// n'est qu'un AFFICHAGE (un tap ouvre l'app), et seule `.accessoryRectangular`
// porte les boutons.
import AppIntents
import SwiftUI
import WidgetKit
// MARK: - Timeline
struct CoachWatchHydrationEntry: TimelineEntry {
let date: Date
let waterMl: Double
let goalMl: Double?
/// Saisies encore en attente d'envoi, tous jours confondus.
let pending: Int
let quickDrinks: [CoachWidgetSnapshot.QuickDrink]
/// 0…1, borné. Sans cible connue, pas de jauge inventée.
var fraction: Double? {
guard let goal = goalMl, goal > 0 else { return nil }
return min(1, max(0, waterMl / goal))
}
}
struct CoachWatchHydrationProvider: TimelineProvider {
func placeholder(in context: Context) -> CoachWatchHydrationEntry {
CoachWatchHydrationEntry(
date: Date(), waterMl: 1250, goalMl: 2750, pending: 0,
quickDrinks: CoachWidgetSnapshot.fallbackQuickDrinks)
}
func getSnapshot(in context: Context,
completion: @escaping (CoachWatchHydrationEntry) -> Void) {
completion(current())
}
/// Réveil à minuit, comme le widget iPhone : les totaux du jour doivent
/// repartir de zéro. ⚠️ `asOf(_:)` est ce qui le rend effectif — sans lui
/// le rechargement a lieu mais réaffiche les chiffres de la veille, défaut
/// déjà payé côté iPhone.
func getTimeline(in context: Context,
completion: @escaping (Timeline<CoachWatchHydrationEntry>) -> Void) {
let now = Date()
let minuit = CoachWidgetSnapshot.nextMidnight(after: now)
completion(Timeline(entries: [current(at: now), current(at: minuit)],
policy: .after(minuit.addingTimeInterval(60))))
}
private func current(at day: Date = Date()) -> CoachWatchHydrationEntry {
let snapshot = CoachWidgetStore.load()?.asOf(day)
return CoachWatchHydrationEntry(
date: day,
waterMl: CoachQuickLog.todayTotal(
kind: "water", syncedMl: snapshot?.waterMlToday ?? 0, day: day),
goalMl: snapshot?.waterGoalMl,
pending: CoachQuickLog.pending().count,
quickDrinks: snapshot?.resolvedQuickDrinks
?? CoachWidgetSnapshot.fallbackQuickDrinks)
}
}
// MARK: - Vues
/// Les deux premières boissons à base d'eau, sinon le repli. Deux et pas trois :
/// sur 160 pt de large, un troisième bouton descend les cibles sous le seuil
/// tactile, et c'est un geste qu'on fait en marchant.
private func deuxBoutonsEau(_ drinks: [CoachWidgetSnapshot.QuickDrink])
-> [CoachWidgetSnapshot.QuickDrink] {
let eau = drinks.filter { $0.ref == "water" }
let retenus = eau.isEmpty ? CoachWidgetSnapshot.fallbackQuickDrinks
.filter({ $0.ref == "water" }) : eau
return Array(retenus.prefix(2))
}
private func enLitres(_ ml: Double) -> String {
let l = ml / 1000
return String(format: l < 1 ? "%.2f L" : "%.1f L", l)
}
@available(watchOS 10.0, *)
struct CoachWatchHydrationView: View {
@Environment(\.widgetFamily) private var family
let entry: CoachWatchHydrationEntry
var body: some View {
switch family {
case .accessoryRectangular:
rectangulaire
case .accessoryInline:
Text("Eau \(enLitres(entry.waterMl))")
default:
circulaire
}
}
/// La seule famille qui porte les boutons : il y faut la place.
private var rectangulaire: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 4) {
Image(systemName: "drop.fill")
Text(enLitres(entry.waterMl))
.font(.headline)
if let goal = entry.goalMl {
Text("/ \(enLitres(goal))")
.font(.caption2)
.foregroundStyle(.secondary)
}
// Ce qui attend d'être envoyé se dit, plutôt que de laisser
// croire que tout est synchronisé.
if entry.pending > 0 {
Text("·\(entry.pending)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
if let f = entry.fraction {
ProgressView(value: f).progressViewStyle(.linear)
}
HStack(spacing: 6) {
ForEach(deuxBoutonsEau(entry.quickDrinks), id: \.volumeMl) { d in
Button(intent: LogDrinkIntent(ref: d.ref, amountMl: d.volumeMl)) {
Text("+\(Int(d.volumeMl / 10)) cl")
.font(.caption2)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
}
.containerBackground(.clear, for: .widget)
}
/// Cadran : affichage seul. Un tap ouvre l'app — voir l'avertissement en
/// tête de fichier sur l'interactivité hors Smart Stack.
private var circulaire: some View {
Gauge(value: entry.fraction ?? 0) {
Image(systemName: "drop.fill")
} currentValueLabel: {
Text(enLitres(entry.waterMl)).font(.system(size: 12))
}
.gaugeStyle(.accessoryCircular)
.containerBackground(.clear, for: .widget)
}
}
// MARK: - Widget
@available(watchOS 10.0, *)
struct CoachWatchHydrationWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "CoachWatchHydration",
provider: CoachWatchHydrationProvider()) { entry in
CoachWatchHydrationView(entry: entry)
}
.configurationDisplayName("Hydratation")
.description("Où j'en suis, et noter un verre sans ouvrir l'app.")
.supportedFamilies([.accessoryRectangular, .accessoryCircular, .accessoryInline])
}
}
// MARK: - Nutrition
//
// ⚠️ **Affichage seulement, et c'est délibéré.** La saisie des repas au poignet
// a été écartée le 25/09 : pas de caméra, pas de scanner de code-barres, pas de
// saisie de texte confortable — un bouton n'aurait fait que renvoyer vers
// l'iPhone. Mais VOIR où l'on en est n'a aucune de ces limites, et c'est ce que
// Sylvain a demandé ensuite.
//
// ⛔ Rien n'est calculé ici. Les totaux viennent du journal et les cibles de
// `nutrition_goals.py`, tous deux poussés par l'app. Le seul calcul est un
// reste (cible − consommé), la même soustraction que la page /meals affiche.
struct CoachWatchNutritionEntry: TimelineEntry {
let date: Date
let kcal: Double?
let kcalGoal: Double?
let protein: Double?
let proteinGoal: Double?
let fat: Double?
let fatGoal: Double?
let carbs: Double?
let carbsGoal: Double?
/// Reste d'énergie. `nil` sans cible — une jauge sans objectif n'a pas de
/// sens, et un « reste » inventé encore moins.
var kcalRestantes: Double? {
guard let g = kcalGoal, let c = kcal else { return nil }
return max(0, g - c)
}
var fraction: Double? {
guard let g = kcalGoal, g > 0, let c = kcal else { return nil }
return min(1, max(0, c / g))
}
}
struct CoachWatchNutritionProvider: TimelineProvider {
func placeholder(in context: Context) -> CoachWatchNutritionEntry {
CoachWatchNutritionEntry(date: Date(), kcal: 1186, kcalGoal: 1908,
protein: 74, proteinGoal: 118,
fat: 41, fatGoal: 59,
carbs: 158, carbsGoal: 225)
}
func getSnapshot(in context: Context,
completion: @escaping (CoachWatchNutritionEntry) -> Void) {
completion(current())
}
func getTimeline(in context: Context,
completion: @escaping (Timeline<CoachWatchNutritionEntry>) -> Void) {
let now = Date()
let minuit = CoachWidgetSnapshot.nextMidnight(after: now)
completion(Timeline(entries: [current(at: now), current(at: minuit)],
policy: .after(minuit.addingTimeInterval(60))))
}
/// `asOf(_:)` remet les totaux du jour à zéro quand le snapshot date
/// d'hier — macros comprises depuis le 25/09. Sans lui, le réveil de
/// minuit réafficherait les grammes de la veille.
private func current(at day: Date = Date()) -> CoachWatchNutritionEntry {
let s = CoachWidgetStore.load()?.asOf(day)
return CoachWatchNutritionEntry(
date: day,
kcal: s?.kcalToday, kcalGoal: s?.kcalGoal,
protein: s?.proteinToday, proteinGoal: s?.proteinGoal,
fat: s?.fatToday, fatGoal: s?.fatGoal,
carbs: s?.carbsToday, carbsGoal: s?.carbsGoal)
}
}
@available(watchOS 10.0, *)
struct CoachWatchNutritionView: View {
@Environment(\.widgetFamily) private var family
let entry: CoachWatchNutritionEntry
/// « 74/118 g » ; « 74 g » sans cible. Jamais de cible inventée.
private func macro(_ nom: String, _ valeur: Double?, _ cible: Double?) -> String {
guard let v = valeur else { return "\(nom) —" }
if let c = cible { return "\(nom) \(Int(v))/\(Int(c))" }
return "\(nom) \(Int(v))"
}
var body: some View {
switch family {
case .accessoryRectangular:
rectangulaire
case .accessoryInline:
Text(entry.kcalRestantes.map { "\(Int($0)) kcal restantes" }
?? "\(Int(entry.kcal ?? 0)) kcal")
default:
circulaire
}
}
private var rectangulaire: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: "fork.knife")
if let reste = entry.kcalRestantes {
Text("\(Int(reste))").font(.headline)
Text("kcal restantes").font(.caption2).foregroundStyle(.secondary)
} else {
Text("\(Int(entry.kcal ?? 0)) kcal").font(.headline)
}
}
if let f = entry.fraction {
ProgressView(value: f).progressViewStyle(.linear)
}
// P / L / G sur une ligne : trois valeurs tiennent, pas davantage.
Text([macro("P", entry.protein, entry.proteinGoal),
macro("L", entry.fat, entry.fatGoal),
macro("G", entry.carbs, entry.carbsGoal)].joined(separator: " "))
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.containerBackground(.clear, for: .widget)
}
private var circulaire: some View {
Gauge(value: entry.fraction ?? 0) {
Image(systemName: "fork.knife")
} currentValueLabel: {
Text("\(Int(entry.kcalRestantes ?? entry.kcal ?? 0))")
.font(.system(size: 12))
}
.gaugeStyle(.accessoryCircular)
.containerBackground(.clear, for: .widget)
}
}
@available(watchOS 10.0, *)
struct CoachWatchNutritionWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "CoachWatchNutrition",
provider: CoachWatchNutritionProvider()) { entry in
CoachWatchNutritionView(entry: entry)
}
.configurationDisplayName("Nutrition")
.description("Calories restantes et macros du jour.")
.supportedFamilies([.accessoryRectangular, .accessoryCircular, .accessoryInline])
}
}

40
package-lock.json generated
View File

@@ -9,12 +9,12 @@
"version": "0.1.0", "version": "0.1.0",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@capacitor/android": "^8.3.1", "@capacitor/android": "^8.5.2",
"@capacitor/app": "^8.1.0", "@capacitor/app": "^8.1.0",
"@capacitor/core": "^8.3.1", "@capacitor/core": "^8.5.2",
"@capacitor/geolocation": "^8.2.0", "@capacitor/geolocation": "^8.2.0",
"@capacitor/haptics": "^8.0.2", "@capacitor/haptics": "^8.0.2",
"@capacitor/ios": "^8.3.1", "@capacitor/ios": "^8.5.2",
"@capacitor/local-notifications": "^8.0.2", "@capacitor/local-notifications": "^8.0.2",
"@capacitor/push-notifications": "^8.0.3", "@capacitor/push-notifications": "^8.0.3",
"@capacitor/splash-screen": "^8.0.1", "@capacitor/splash-screen": "^8.0.1",
@@ -24,7 +24,7 @@
}, },
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^8.3.1" "@capacitor/cli": "^8.5.2"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -51,12 +51,12 @@
} }
}, },
"node_modules/@capacitor/android": { "node_modules/@capacitor/android": {
"version": "8.3.1", "version": "8.5.2",
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.3.1.tgz", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.5.2.tgz",
"integrity": "sha512-hjskIG8YcBEh3X4yaTXvE9gcqpdcxunTgFruSKnuPxtMxAUzEK4Oq25x0Z1g3cz+MQPc+lRG09R7Ovc+ydKsNw==", "integrity": "sha512-rtiaz78egCcS+vs29wkWCL3C+RhazKMSygXcjutwWP8NmO6r5UBxOvGf3Wjz5+ahobyQ723tn9gCePwRwcNDZQ==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@capacitor/core": "^8.3.0" "@capacitor/core": "^8.5.0"
} }
}, },
"node_modules/@capacitor/app": { "node_modules/@capacitor/app": {
@@ -452,10 +452,11 @@
"dev": true "dev": true
}, },
"node_modules/@capacitor/cli": { "node_modules/@capacitor/cli": {
"version": "8.3.1", "version": "8.5.2",
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.1.tgz", "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.2.tgz",
"integrity": "sha512-1sPGW4THTDfR6YjXwZ0jM7oAfAtciPOHN00qs/3sNAQx1kKrrEYSfDPwCm1/xlAgi0OeL69SiRfw314Ans+1sw==", "integrity": "sha512-O2kpA5ZPJUAmliDCcmpMf8xnJwMezxlFPUrlyOAjbL3U9MFNY3l4ZpPI/UXwvyPmR8ETcVF9pFr+VdWq61rqTQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@ionic/cli-framework-output": "^2.2.8", "@ionic/cli-framework-output": "^2.2.8",
"@ionic/utils-subprocess": "^3.0.1", "@ionic/utils-subprocess": "^3.0.1",
@@ -473,6 +474,7 @@
"semver": "^7.6.3", "semver": "^7.6.3",
"tar": "^7.5.3", "tar": "^7.5.3",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"xcode": "^3.0.1",
"xml2js": "^0.6.2" "xml2js": "^0.6.2"
}, },
"bin": { "bin": {
@@ -484,9 +486,10 @@
} }
}, },
"node_modules/@capacitor/core": { "node_modules/@capacitor/core": {
"version": "8.3.1", "version": "8.5.2",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.1.tgz", "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.2.tgz",
"integrity": "sha512-UF8ItlHguU1Z6GXfPTeT2gakf+ctNI8pAS1kwSBQlsJMlfD4OPoto/SmKnOxKCQvnF4WRcdWeg6C0zREUNaAQg==", "integrity": "sha512-agnQbGdhGw/KZFuLf7H9SY2o+oE7upkr/WPQsYaokXP6f9chaE8iVmbKkfN6SVl4w3pmz7oZ3Ur1hbkmy1hx+w==",
"license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
@@ -511,11 +514,12 @@
} }
}, },
"node_modules/@capacitor/ios": { "node_modules/@capacitor/ios": {
"version": "8.3.1", "version": "8.5.2",
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.3.1.tgz", "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.5.2.tgz",
"integrity": "sha512-BEhLyYYHWJLib4mpaPMaaylbC8meqgxbNYwQJH2svsSLW7yo/hFie+Zoo66a44XnqcMd2tvmAuzimWunXZi/xA==", "integrity": "sha512-eOT2S67AfTFG7KN0T3oxwAajrJz4rSye+ODfvChiwrk0F/+kWufFM1qU6cDjGJUOfwdAoeuEpZyWHSwZlu4GFg==",
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@capacitor/core": "^8.3.0" "@capacitor/core": "^8.5.0"
} }
}, },
"node_modules/@capacitor/local-notifications": { "node_modules/@capacitor/local-notifications": {

View File

@@ -15,12 +15,12 @@
"author": "Sylvain Bettinelli", "author": "Sylvain Bettinelli",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@capacitor/android": "^8.3.1", "@capacitor/android": "^8.5.2",
"@capacitor/app": "^8.1.0", "@capacitor/app": "^8.1.0",
"@capacitor/core": "^8.3.1", "@capacitor/core": "^8.5.2",
"@capacitor/geolocation": "^8.2.0", "@capacitor/geolocation": "^8.2.0",
"@capacitor/haptics": "^8.0.2", "@capacitor/haptics": "^8.0.2",
"@capacitor/ios": "^8.3.1", "@capacitor/ios": "^8.5.2",
"@capacitor/local-notifications": "^8.0.2", "@capacitor/local-notifications": "^8.0.2",
"@capacitor/push-notifications": "^8.0.3", "@capacitor/push-notifications": "^8.0.3",
"@capacitor/splash-screen": "^8.0.1", "@capacitor/splash-screen": "^8.0.1",
@@ -30,6 +30,6 @@
}, },
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^8.3.1" "@capacitor/cli": "^8.5.2"
} }
} }

View File

@@ -18,4 +18,6 @@ struct HeartRateZones: Codable, Equatable, Sendable {
var z5: [Int]? var z5: [Int]?
var fcmaxUsed: Int? var fcmaxUsed: Int?
var clinicalTargetBpm: Int? var clinicalTargetBpm: Int?
var zonesSince: String?
var zonesFrom: String?
} }

View File

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

View File

@@ -41,9 +41,16 @@ final class DayRolloverTests: XCTestCase {
s.waterMlToday = 1660 s.waterMlToday = 1660
s.coffeeCountToday = 3 s.coffeeCountToday = 3
s.kcalToday = 1186 s.kcalToday = 1186
s.proteinToday = 74
s.fatToday = 41
s.carbsToday = 158
s.waterGoalMl = 2750 s.waterGoalMl = 2750
s.kcalGoal = 1950 s.kcalGoal = 1950
s.zones = HeartRateZones(z2: [109, 118], fcmaxUsed: 152, clinicalTargetBpm: 112) s.proteinGoal = 118
s.fatGoal = 59
s.carbsGoal = 225
s.zones = HeartRateZones(z2: [109, 118], fcmaxUsed: 152, clinicalTargetBpm: 112,
zonesSince: "2026-08-12", zonesFrom: "frozen")
return s return s
} }
@@ -68,6 +75,28 @@ final class DayRolloverTests: XCTestCase {
XCTAssertEqual(vu.kcalToday, 0) XCTAssertEqual(vu.kcalToday, 0)
} }
func testMacrosResetLikeEnergy() {
// ⚠️ Ajoutées le 25/09/2026 pour le suivi au poignet. Les oublier dans
// `asOf` afficherait les grammes d'hier sous la date du jour — le
// défaut même que cette fonction existe pour empêcher, et qui avait
// survécu un temps sur les totaux de boisson.
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
XCTAssertEqual(vu.proteinToday, 0)
XCTAssertEqual(vu.fatToday, 0)
XCTAssertEqual(vu.carbsToday, 0)
}
func testMacroGoalsSurviveTheNight() {
// Une CIBLE n'est pas une mesure : elle ne se périme pas à minuit.
// Sans elle, la jauge disparaîtrait chaque nuit jusqu'à la prochaine
// ouverture de l'app.
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
XCTAssertEqual(vu.proteinGoal, 118)
XCTAssertEqual(vu.fatGoal, 59)
XCTAssertEqual(vu.carbsGoal, 225)
XCTAssertEqual(vu.kcalGoal, 1950)
}
func testYesterdaysDayScopedFieldsAreDropped() { func testYesterdaysDayScopedFieldsAreDropped() {
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich) let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
XCTAssertNil(vu.routine, "la routine se recoche chaque jour") XCTAssertNil(vu.routine, "la routine se recoche chaque jour")
@@ -94,6 +123,42 @@ final class DayRolloverTests: XCTestCase {
"les zones sont une version figée, valable des semaines") "les zones sont une version figée, valable des semaines")
} }
/// La date de version des zones doit survivre au transport et au passage de
/// minuit — sans elle, la montre affiche des bornes sans âge, et après une
/// révision rien ne distingue les nouvelles des anciennes.
/// Cf. `docs/DIAGNOSTIC-NATIF-2026-09-24.md` § 1.2.
func testZonesCarryTheirVersionDate() {
let vu = snapshot(at: "2026-08-10 12:00").asOf(date("2026-08-18 09:00"), calendar: zurich)
XCTAssertEqual(vu.zones?.zonesSince, "2026-08-12",
"la date de la version figée se perdait au transport")
XCTAssertEqual(vu.zones?.zonesFrom, "frozen",
"« figée » et « provisoire » ne doivent pas se confondre au poignet")
}
/// Le snapshot voyage en JSON jusqu'à la montre : ce qui n'est pas encodé
/// n'arrive pas. Le test précédent vérifie la recopie en mémoire, celui-ci
/// le passage par le fil.
func testVersionDateSurvivesJsonRoundTrip() throws {
let original = snapshot(at: "2026-08-10 12:00")
let data = try JSONEncoder().encode(original)
let relu = try JSONDecoder().decode(CoachWidgetSnapshot.self, from: data)
XCTAssertEqual(relu.zones?.zonesSince, "2026-08-12")
XCTAssertEqual(relu.zones?.zonesFrom, "frozen")
}
/// ⚠️ Un snapshot écrit AVANT l'ajout de ces champs doit rester décodable :
/// la montre en garde un en cache, et un décodage qui lève la laisserait
/// sans zones du tout — pire que des zones sans date.
func testOlderSnapshotWithoutVersionDateStillDecodes() throws {
// `updatedAt` est un `Date` : l'encodeur par défaut l'écrit en secondes
// depuis la date de référence, pas en ISO — comme les autres tests de
// compatibilité ascendante de ce banc (cf. QuickDrinkTests).
let json = #"{"updatedAt": 1755000000, "zones": {"z2": [109, 118], "fcmaxUsed": 152}}"#
let relu = try JSONDecoder().decode(CoachWidgetSnapshot.self, from: Data(json.utf8))
XCTAssertEqual(relu.zones?.fcmaxUsed, 152)
XCTAssertNil(relu.zones?.zonesSince, "absente, pas inventée")
}
// MARK: - Au-delà du lendemain : ne rien inventer // MARK: - Au-delà du lendemain : ne rien inventer
func testAfterTwoDaysTheSessionIsUnknown() { func testAfterTwoDaysTheSessionIsUnknown() {

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))
}
}