--- issueRef: "#124" version: 3 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedAt: 1785137390360 --- ## #124 — Architecture collecte capteurs montre → séance (Architect, 2026-07-26) Statut : **cadrage technique, ouvre la voie à l'implémentation** mais reste combiné à #123 comme décidé par Main sur #106 — aucun lot d'implémentation ne doit s'ouvrir sans relecture croisée de ce cadrage et du cadrage UX #123 (déjà "cadrage terminé côté UX", en attente de ce document). ### Constat sur l'existant (fait, pas une supposition) Recherche exhaustive sur `android/`, `watch_app/android/` et tous les Gradle : **aucune infrastructure capteur/santé n'existe aujourd'hui**. Ni `BODY_SENSORS`, ni dépendance Health Services/Health Connect, ni Horologist. La seule dépendance Wear présente (`com.google.android.gms:play-services-wearable:19.0.0`) est l'API Wearable Data Layer classique (MessageClient/DataClient), utilisée uniquement pour le bridge commandes/projection existant — **sans rapport avec les capteurs**. C'est donc une **capacité entièrement nouvelle**, pas une extension d'existant. ### Faisabilité et choix d'API - API recommandée : **Wear OS Health Services** (`androidx.health:health-services-client`), l'API moderne recommandée par Google pour la fréquence cardiaque sur Wear OS 3+ (l'ancienne lecture directe `Sensor.TYPE_HEART_RATE` est déconseillée/restreinte). - **Choix structurant : `MeasureClient` (mesure passive), pas `ExerciseClient`.** `ExerciseClient` déclare un "Exercise" au niveau système (peut entrer en conflit avec d'autres apps fitness, affiche sa propre UI/notification système d'exercice en cours). GameTime a déjà son propre concept de séance ; on ne veut pas d'un second concept de "session" concurrent au niveau OS. `MeasureClient.registerMeasureCallback(DataType.HEART_RATE_BPM, ...)` suffit pour une mesure passive sans ce couplage. - Dépendance à ajouter : `watch_app/android/app/build.gradle.kts` uniquement (la fréquence cardiaque se mesure sur la montre, jamais sur le téléphone). - Permission `android.permission.BODY_SENSORS` (dangereuse, runtime) dans `watch_app/android/app/src/main/AndroidManifest.xml` ; évaluer `BODY_SENSORS_BACKGROUND` si la mesure doit continuer écran éteint au poignet (probable en usage réel de séance). ### Modèle de permission (répond à la question ouverte du carnet UX #123) Demande **une seule fois, paresseuse**, déclenchée par le premier passage en "séance active" côté montre après déploiement de la feature — jamais un écran d'onboarding dédié (évite une surface supplémentaire non demandée). Refus ou ignorance → la montre ne mesure jamais, le téléphone ne reçoit jamais de résumé, l'UI reste silencieuse — exactement le contrat UX déjà posé par #123 (silence total, jamais de blocage du parcours principal). ### Décision structurante : agrégation locale montre, résumé unique en fin de séance Pas de flux temps réel synchronisé en continu (inutile : UX #123 exclut explicitement tout affichage live). La montre : 1. Accumule localement (min/max/somme/count — arithmétique triviale, pas un "modèle de calcul maison" au sens où l'UX l'exclut pour les calories) pendant toute la séance, **pause-aware** : suspend l'agrégation quand la séance est en pause ou que le repos court sans effort (aligné sur le comportement déjà existant des autres chronos pause-aware, cf. [[gametime-session-execution-timer-refactor]]). 2. Envoie **un seul message résumé** en fin de séance (terminer/abandonner), pas un flux continu — élimine la complexité de synchronisation/idempotence en continu, cohérent avec le fait que rien ne consomme la donnée en direct. 3. Le canal Data Layer (`MessageClient`) gère nativement la fiabilité/retry si la montre est temporairement injoignable au moment de l'envoi ; si le message n'arrive jamais (montre débranchée, désinstallée...), la séance reste simplement sans ce bloc — silencieux pour toujours, aucune donnée en attente à représenter, aucune migration rétroactive à inventer (conforme à la demande explicite d'UX #123). ### Nouveau contrat (séparé du contrat commandes existant) Ne pas réutiliser `WatchCommandEnvelope` (sémantique commande utilisateur + ack) pour de la télémétrie sans accusé de réception. Nouveau type dans `packages/watch_bridge_contract` : ```dart class WatchSensorSummary { final int schemaVersion; final String sessionId; final int sampleCount; final double? averageHeartRateBpm; final int? maxHeartRateBpm; } ``` - `sampleCount` permet au téléphone de décider "donnée insuffisante" (UX #123 : "données insuffisantes pour un calcul fiable" → bloc absent). Seuil recommandé : `sampleCount < 3` ⇒ traiter comme absent (pas de moyenne calculée sur un bruit d'une ou deux mesures). - Pas d'ack requis (fire-and-forget, perte tolérée par design). ### Persistance téléphone - Champs nullables **au niveau racine de l'entité d'historique de séance** (celle qui porte déjà la durée totale), pas par série/exercice — UX #123 demande une fréquence cardiaque **de la séance**, une seule paire moyenne/max par séance, pas un état vivant par set : ``` averageHeartRateBpm: double? maxHeartRateBpm: int? ``` (nom exact de l'entité à confirmer par DevBackend selon le modèle d'historique réel — non exploré en détail dans ce cadrage.) - **Écriture non bloquante (point important)** : ne jamais faire attendre la finalisation de "Terminer la séance" sur l'arrivée du résumé capteur. La séance se termine et s'enregistre immédiatement, `averageHeartRateBpm`/`maxHeartRateBpm` restent `null`. Si le résumé arrive ensuite (avant ou après la fin de séance, l'ordre n'est pas garanti), un use case dédié `updateWorkoutHistoryHeartRateSummary(historySessionId, ...)` applique un **patch idempotent** a posteriori si les champs sont encore `null` sur l'enregistrement d'historique correspondant, silencieusement si l'enregistrement n'existe plus/a été supprimé. - Justification : cohérent par analogie avec [[gametime-online-layer-philosophy]] — ne jamais bloquer une action locale critique (ici, terminer une séance) en attendant une confirmation externe, même si ce n'est pas strictement un flux réseau serveur. ### Calories (hors MVP, second temps déjà indiqué par UX) Même pipeline (`MeasureClient`, `DataType.CALORIES_TOTAL` si disponible nativement) — champ additif `estimatedCalories: double?` sur le même résumé, à ajouter seulement si demandé explicitement plus tard. Ne pas l'inclure au lot 1. ### Découpage en lots proposé (permet d'ouvrir l'implémentation sans ambiguïté) 1. **Watch (natif)** : dépendance Gradle + permission + `HeartRateSampler` (`MeasureClient`) + accumulateur local pause-aware + envoi résumé fin de séance. 2. **Contrat** : `WatchSensorSummary` dans `watch_bridge_contract` + tests `dart test`. 3. **Backend/domaine téléphone** : champs nullables sur l'entité d'historique + migration + `updateWorkoutHistoryHeartRateSummary` + écoute du message natif (extension du service d'écoute bridge déjà existant) + logique de patch tardif idempotent. 4. **Frontend téléphone** : bloc "Fréquence cardiaque" optionnel sur écran de fin de séance + détail historique (absent si `null`, aucun placeholder — conforme #123). 5. **QA** : permission refusée, montre sans capteur cardio, déconnexion mi-séance, arrivée tardive du résumé après clôture, seuil `sampleCount` insuffisant. Chaque lot est testable isolément : accumulateur watch (calcul pur), DTO contrat (dart test existant), use case patch (test avec repo fake), rendu UI (widget test null vs présent). ### Ce qui reste explicitement hors MVP (repris d'UX #123, confirmé faisable/non-faisable ici) VO2 max, zones d'intensité, SpO2, ECG — Health Services les expose en partie mais hors périmètre produit GameTime, pas seulement hors scope technique. Pas d'affichage temps réel (choix d'architecture confirmé ci-dessus, pas seulement une préférence UX : la mesure continue en flux aurait needlessly recompliqué idempotence/sync pour zéro valeur d'usage dans ce MVP).