# Architecture technique ## Vue d'ensemble ```{mermaid} flowchart TB subgraph srv["Serveur Python (asyncio)
tourne sur le poste du responsable
du chronométrage, testé sur Pydroid 3"] MS["MatchServer
état du match, source de vérité unique"] ME["MatchEngine
Indoor / Flint"] HTTP["HTTP statique (stdlib)"] WS["WebSocket (paquet websockets)"] MS --- ME MS --- HTTP MS --- WS end srv -->|réseau local WiFi| C["control.html
poste de contrôle"] srv -->|réseau local WiFi| D1["display.html
pas de tir 1"] srv -->|réseau local WiFi| D2["display.html
pas de tir 2"] ``` Un seul service Python centralise l'état de la compétition. Tous les clients (contrôle et écrans) sont de simples pages web connectées en WebSocket : aucune installation logicielle sur les tablettes d'affichage. ## Choix techniques | Composant | Choix | Justification | |---|---|---| | Backend WebSocket | Paquet `websockets` (asyncio) | Pas de FastAPI/Pydantic (dépendance Rust `pydantic-core` à risque sur Android/Pydroid) ni d'uvicorn `[standard]` (extensions C `uvloop`/`httptools`) ; `websockets` a un fallback pur Python si son extension C optionnelle ne compile pas — installable de façon fiable sur Pydroid 3. | | Serveur HTTP statique | `http.server` (stdlib), port **8000** par défaut | Sert les pages et assets sur un port séparé du WebSocket (port **8765** par défaut) -- évite l'API instable de combinaison HTTP+WS selon les versions de `websockets` ; utilisé aussi pour la découverte de fichiers (bannières, packs de sons) via le listing de répertoire natif. Les deux ports sont modifiables (fenêtre graphique, `config/gui.toml`, ou `--http-port`/`--ws-port` en ligne de commande -- voir {doc}`dev-guide/index`), notamment pour plusieurs salles de compétition sur un même PC. **Les deux ports doivent être accessibles** depuis les écrans/postes clients (pare-feu, redirection de port si le serveur tourne dans un conteneur/VM -- ex. Docker, WSL2) : rediriger seulement le port HTTP charge les pages mais laisse la synchronisation temps réel bloquée ("en attente de connexion" indéfiniment). | | Communication temps réel | WebSocket | Évite la dérive du polling, tous les écrans restent synchronisés à la seconde près. | | Frontend | HTML/CSS/JS vanilla | Pas de build, une tablette ouvre juste une URL. | | Config des modes (Indoor/Flint) | Fichiers **TOML** (`config/*.toml`) | Lu via `tomllib`, stdlib depuis Python 3.11 (donc Pydroid) -- zéro dépendance. Écriture via un petit sérialiseur maison (pas de support d'écriture en stdlib). | | Assets (logo, bannières, cibles, sons) | Dépôt de fichiers dans `web/assets/...`, découverte par listing de répertoire HTTP | Pas de formulaire d'upload à construire ni maintenir. | | Paquetage | Un seul paquet `fletchtime` (PyPI), sous-paquets `fletchtime.engine`/`fletchtime.server`, pages web incluses comme *package data* dans `fletchtime.web` | `pip install fletchtime` + `python -m fletchtime` (ou juste `fletchtime`) suffit partout -- Pydroid compris. Les données propres au club (logo, sons, config) restent hors du paquet installé, bootstrapées dans le répertoire courant au premier lancement. | | Déploiement | Pydroid 3 (Android), pip/PyPI, ou exécutable autoporteur (PyInstaller) | Contrainte du club à l'origine : développement mobile-only, pas de PC -- le paquet PyPI et l'exécutable ont été ajoutés ensuite pour élargir l'usage à d'autres clubs. | ## Modèle d'état Le serveur maintient un objet d'état, source de vérité unique, recalculé à chaque tick du moteur de séquencement (`MatchEngine.tick()`, ~5 fois/seconde) et diffusé à tous les écrans connectés. Chaque connexion reçoit un payload individualisé (pas un broadcast identique pour tous), car un message peut être ciblé sur une seule lane. ```{important} `MatchServer.tick_loop()` mesure le temps **réellement écoulé** entre deux ticks via `time.monotonic()`, plutôt que de supposer que l'intervalle visé (`TICK_INTERVAL`, 0.2s) s'est écoulé pile. `asyncio.sleep()` ne dort jamais exactement la durée demandée -- l'écart, minime à chaque tick, dérivait de façon perceptible sur une volée longue (plusieurs secondes sur les 45s d'un walk-up Flint, remonté sous Windows spécifiquement, dont la granularité de l'ordonnanceur est plus grossière que sous Linux). Voir `tests/test_match_server.py::test_tick_loop_uses_actual_elapsed_time_not_fixed_interval`. ``` ```{code-block} json :caption: Payload diffusé à un client donné (exemple) { "type": "state", "state": { "phase": "green", "time_left": 87.0, "current_turn": "A-B", "end_number": 3, "total_ends": 6, "unit_number": 1, "arrow_in_end": 0, "total_arrows_in_end": 0, "distance_label": "20 yards", "target_image": "assets/targets/indoor_recurve.svg", "target_image_2": "assets/targets/indoor_compound.svg", "finished": false }, "message": null, "language": "fr", "event_title": "Concours FFTL Indoor -- Février 2026", "connected_lanes": ["1", "2", "3"], "active_mode": "indoor", "sound_pack": "classic" } ``` `state` vaut `null` quand aucun match n'est en cours (écran neutre). Chaque écran ne fait qu'interpréter ce payload ; toute la logique de décision (quand passer à l'orange, quand enchaîner la volée suivante, quand mettre en pause entre deux relais, etc.) vit côté serveur, dans le moteur de séquencement. ## Moteur de séquencement Contrairement à l'approche legacy observée dans ArcheryClock (dispatch par chaînes de caractères `if archerysystem = 'fita' then ...` dispersées dans un fichier de 12 000 lignes), le moteur est structuré en deux couches indépendantes : - **`ShootingMode`** (`IndoorMode`, `FlintMode`) : ne fait qu'une chose, produire à l'avance la liste ordonnée et complète des `Step` d'un match (mise en place, tir, pause...) à partir d'une config (`IndoorConfig`, `FlintConfig`). Aucun état, aucune notion de temps qui s'écoule -- juste une fonction pure `build_sequence() -> List[Step]`. C'est ce qui permet de tester chaque mode en asserting directement sur la liste de steps produite, sans faire tourner de minuteur. - **`MatchEngine`** : rejoue cette liste. C'est la seule classe avec de l'état (index courant, temps restant, pause, urgence). Expose les commandes du responsable du chronométrage : `tick(dt)`, `next()`, `stop()`, `restart()`, `goto(unit, end, arrow, turn)`, `emergency()`/`resume()`, `pause()`/`play()`. Les phases visuelles (`Phase`, voir {doc}`api-reference`) suivent ces transitions : ```{mermaid} stateDiagram-v2 [*] --> RED: mise en place RED --> GREEN: fin de la mise en place GREEN --> ORANGE: seuil d alerte franchi, même décompte GREEN --> PAUSE: fin de volée standard ORANGE --> PAUSE: fin de volée PAUSE --> RED: next, relais ou volée suivante RED --> EMERGENCY: emergency GREEN --> EMERGENCY: emergency ORANGE --> EMERGENCY: emergency EMERGENCY --> RED: resume EMERGENCY --> GREEN: resume EMERGENCY --> ORANGE: resume GREEN --> [*]: fin de match ORANGE --> [*]: fin de match ``` ```{code-block} python :caption: Step -- un segment temporisé (extrait réel) @dataclass(frozen=True) class Step: phase: Phase # RED, GREEN, ORANGE, PAUSE, EMERGENCY... duration: Optional[float] # None = attente indéfinie (fin de volée) current_turn: str = "" # "A-B", "C-D" end_number: int = 0 total_ends: int = 0 unit_number: int = 1 # série (Indoor) / unité standard (Flint) arrow_in_end: int = 0 # flèche du walk-up distance_label: str = "" target_image: str = "" target_image_2: str = "" # Indoor seulement (2 blasons) sound_event: Optional[str] = None orange_threshold: Optional[float] = None # seuil de passage à l'orange orange_sound_event: Optional[str] = None ``` Le décompte est **continu** : `orange_threshold` ne crée pas un deuxième step séparé, c'est juste un seuil vérifié à chaque tick sur le même step -- le temps affiché ne redémarre jamais. - `IndoorMode` : construit un bloc de steps par relais (A-B, C-D) et par volée ; insère un step `PAUSE` (`duration=None`) entre chaque volée. Le relais suivant démarre directement par sa propre mise en place (RED), pas besoin de pause entre A-B et C-D puisqu'ils partagent le même blason. - `FlintMode` : idem pour les volées standards, mais chaque relais tire l'**unité entière** (7 volées) avant que l'autre ne la reprenne -- pas de `PAUSE` au sein d'une volée, seulement entre volées et entre relais/unités. La volée walk-up est un bloc de 4 flèches contiguës (pas de `PAUSE` entre elles, conformément à la règle "tout le monde avance ensemble"). Ce découpage permet d'ajouter un nouveau mode sans toucher aux modes existants (voir {doc}`dev-guide/index`). ## Configuration (TOML) `src/fletchtime/server/config_store.py` charge/sauvegarde trois fichiers : - `config/indoor.toml`, `config/flint.toml` : tous les réglages de chaque mode (temps, distances, nombre de volées/flèches, images de cible, ordre des relais par défaut). Chargés à chaque démarrage de match (pas mis en cache), donc un changement via `config.html` prend effet dès le prochain match sans redémarrer le serveur. - `config/app.toml` : réglages globaux non liés à un mode (actuellement, le pack de sons actif). Pris en compte **immédiatement** sur tous les écrans déjà connectés (contrairement à `indoor.toml`/`flint.toml`). Un champ non reconnu dans un fichier TOML est ignoré silencieusement (pas d'erreur bloquante) ; un fichier absent retombe sur les valeurs par défaut du dataclass Python correspondant. Toute tentative de sauvegarde invalide (ex. seuil orange supérieur au temps de tir total) est rejetée **avant** écriture, avec le message d'erreur renvoyé à `config.html`. **Garde-fou** : `save_config` refuse de modifier la configuration d'un mode (Indoor ou Flint) tant qu'un match de ce même mode est en cours (actif, en pause, ou en urgence) -- vérifié côté serveur (autoritaire), avec un signal `active_mode` diffusé en continu pour que `config.html` grise le bouton correspondant de façon proactive. ## Données du club (assets bootstrapés) Les fichiers réellement fournis avec FletchTime vivent dans le paquet lui-même (`src/fletchtime/web/_defaults/`), pas dans `web/assets/` à la racine du dépôt -- ce dernier n'est qu'une sortie générée au premier lancement (voir `fletchtime.__main__.ensure_directories`), jamais committée, et identique que FletchTime tourne en paquet pip ou en exécutable PyInstaller (même logique de bootstrap dans les deux cas) : ```{code-block} text src/fletchtime/web/_defaults/ # fourni avec le paquet, committé club/README.md # explique comment ajouter le logo du club banners/README.md # explique comment ajouter des bannières sponsors targets/ # images de blasons par défaut (Indoor + Flint) sounds/packs/ classic/ # généré par synthèse (scripts/generate_classic_sounds.py), prep_start.wav # libre de droits, inclus dans le paquet shoot_start.wav warning_orange.wav countdown_tick.wav emergency_start.wav emergency_end.wav end_of_volee.wav pause_start.wav pause_end.wav end_of_match.wav README.md # explique comment créer un pack personnalisé web/assets/ # généré au 1er lancement, jamais committé club/README.md # copié depuis _defaults/ une seule fois banners/README.md # idem targets/ # idem sounds/packs/ classic/ # idem README.md # idem / # n'importe quel autre nom -- jamais copié ni versionné ``` Cette copie ne se fait qu'**une seule fois par fichier/dossier** (si la destination n'existe pas déjà) : une personnalisation du club (logo ajouté, packs ajoutés, fichiers du pack `classic` modifiés ou supprimés) est donc toujours préservée, même après une mise à jour du paquet FletchTime. Le serveur diffuse un **identifiant d'événement** (10 au total, voir `docs/specifications.md`) plus le nom du pack actif (`sound_pack`) ; chaque écran résout localement le fichier audio correspondant (`assets/sounds/packs//<événement>.{wav,mp3,ogg}`, testés dans cet ordre) et met en cache l'extension qui a fonctionné pour ne pas re-sonder à chaque déclenchement. Un événement sans fichier retombe sur un bip Web Audio synthétisé. Aucun flux audio ne transite par le serveur -- chaque écran joue son propre fichier localement. ```{important} Seul `src/fletchtime/web/_defaults/` (et son contenu : README, images de blasons, pack "classic") est suivi par Git. Le dossier `web/assets/` à la racine du dépôt n'est qu'une sortie générée au premier lancement, entièrement ignorée par `.gitignore` -- y compris ses copies de `_defaults/`. ``` ## Authentification (optionnelle) Un mot de passe optionnel (`config/auth.toml`, vide par défaut -- jamais versionné, contrairement à `app.toml`/`indoor.toml`/`flint.toml` qui sont des réglages partageables sans risque) protège les actions qui changent l'état du match ou la configuration (`PROTECTED_ACTIONS` dans `match_server.py`). `display.html` (lecture seule) n'est jamais concerné. Mécanisme par **session de connexion WebSocket**, pas par mot de passe persistant sur l'appareil : une action `authenticate` marque la connexion en cours comme authentifiée (`self._authenticated_connections`, un ensemble de connexions, remis à zéro à la déconnexion) ; rouvrir la page (nouvelle connexion) redemande le mot de passe. Le mot de passe lui-même n'est jamais renvoyé en clair au client -- seul un booléen `password_set` circule. Tant qu'aucun mot de passe n'est défini, `_auth_required()` retourne toujours `False` : comportement strictement identique à avant l'ajout de ce mécanisme, y compris pour définir le tout premier mot de passe (pas de poule-et-l'œuf). Une fois un mot de passe actif, le changer ou le supprimer nécessite d'être déjà authentifié avec l'ancien. ```{important} Reste du HTTP simple, non chiffré : quelqu'un capturant le trafic réseau sur le même WiFi pourrait intercepter le mot de passe. Adapté à un réseau de concours dédié/fermé, pas à un réseau partagé avec le grand public (voir `docs/roadmap.md`, backlog sécurité). ``` ## Fenêtre graphique et cycle de vie des serveurs Depuis l'introduction de la fenêtre graphique (`fletchtime.gui`, basée sur `customtkinter`), point d'entrée par défaut sur toutes les plateformes (`fletchtime.__main__.main`), les deux serveurs (HTTP statique et WebSocket) doivent pouvoir démarrer et s'arrêter **proprement**, pas seulement tourner jusqu'à un Ctrl+C -- boutons Démarrer/Arrêter obligent. Cette logique vit dans `fletchtime.runtime.ServerRuntime`, partagée entre le mode graphique et le mode terminal (`--headless`), pour ne jamais la dupliquer. ```{mermaid} flowchart LR subgraph main["Thread principal"] GUI["Fenêtre customtkinter
mainloop -- doit posséder ce thread"] end subgraph httpT["Thread HTTP"] HTTPD["ThreadingHTTPServer
serve_forever / shutdown"] end subgraph wsT["Thread WebSocket"] LOOP["Boucle asyncio dédiée
run_ws_server -- attend un stop_event"] end GUI -->|start / stop| HTTPD GUI -->|start / stop| LOOP HTTPD -.->|file thread-safe| GUI LOOP -.->|file thread-safe| GUI ``` Points clés : - Une fenêtre graphique (tkinter et ses surcouches, dont `customtkinter`) doit posséder le thread principal -- contrairement à l'ancien `fletchtime.__main__.main`, qui y faisait tourner directement `asyncio.run(run_ws_server(...))`. Le serveur WebSocket tourne donc maintenant dans son **propre thread**, avec sa propre boucle asyncio (`asyncio.new_event_loop()`), exactement comme le serveur HTTP l'a toujours fait. - Arrêt propre du serveur HTTP : `ThreadingHTTPServer.shutdown()` (offert par la stdlib, `socketserver.BaseServer`) débloque `serve_forever()` depuis n'importe quel autre thread -- rien à construire à la main. - Arrêt propre du serveur WebSocket : `run_ws_server` attend maintenant un `asyncio.Event` (`stop_event`) plutôt qu'un `await asyncio.Future()` qui ne se résout jamais. Le déclencher depuis un autre thread (le thread graphique) passe par `loop.call_soon_threadsafe(stop_event.set)` -- seule façon sûre d'interagir avec une boucle asyncio depuis l'extérieur de son propre thread. - Le journal affiché dans la fenêtre est une redirection de `sys.stdout`/ `sys.stderr` vers une `queue.Queue` (thread-safe par construction), lue et affichée via un `after()` périodique de tkinter -- capte le journal d'accès HTTP (`http.server` écrit sur stderr) sans avoir à instrumenter chaque site d'appel. ```{warning} Le rendu de la fenêtre elle-même n'a pas pu être testé visuellement lors de son écriture initiale (pas d'affichage graphique disponible dans l'environnement de développement utilisé). La logique de cycle de vie qu'elle pilote (`ServerRuntime`) est testée pour de vrai (voir `tests/test_runtime.py` : démarrage, requête HTTP réelle, arrêt, vérifi- cation que le port est bien libéré, redémarrage sur le même port). Un premier lancement réel sur PC et sur Pydroid reste nécessaire pour confirmer le rendu et l'ergonomie tactile -- voir aussi le piège PyInstaller/`customtkinter` documenté dans {doc}`dev-guide/index`. ``` ## Résilience de la boucle de décompte `MatchServer.tick_loop()` capture désormais toute exception imprévue en son sein (journalisée avec la trace complète) plutôt que de laisser mourir la boucle silencieusement -- filet de sécurité ajouté suite à un symptôme signalé en pratique : le chrono se figeait indéfiniment, sans aucune erreur visible, le reste du serveur (connexions, réponse aux commandes) continuant de fonctionner normalement à côté. ```{important} **Cause la plus probable identifiée** : sous Windows, remplacer un fichier (`Path.replace`) peut échouer avec une "violation de partage" si un autre processus a le fichier cible ouvert au même instant (antivirus, surveillance de fichiers d'un IDE, Git Bash...) -- une différence fondamentale avec la sémantique POSIX (Linux/macOS), où ceci n'arrive jamais. Observé une fois en pratique, précisément sur `config/match_state.json` -- écrit à chaque tick depuis la persistance après plantage (voir plus bas). Sans gestion d'erreur, cette exception tuait silencieusement `tick_loop` pour de bon : exactement le symptôme rapporté (gel permanent, aucune déconnexion, se produisant aussi bien en fenêtre graphique qu'en mode terminal -- sans lien réel avec le focus d'une fenêtre, malgré la corrélation observée au départ). Deux correctifs complémentaires : ce filet dans `tick_loop` (n'importe quelle exception, pas seulement celle-ci), et `config_store.save_match_snapshot` qui retente quelques fois avant d'abandonner proprement (jamais d'exception qui remonterait perturber la diffusion de l'état aux écrans pour ce tick). Testé concrètement : un échec transitoire (une fois puis réussite) est absorbé et le contenu final reste correct ; un échec permanent (toutes les tentatives échouent) est abandonné proprement, sans jamais lever d'exception. ``` ## Statut technique exposé via HTTP (`/api/status`) Les mêmes données déjà affichées dans `control.html` (écrans connectés, mode actif, phase en cours, pack de sons, mot de passe configuré ou non) sont aussi exposées via un simple GET HTTP, lues directement depuis l'instance `MatchServer` partagée avec le serveur WebSocket (voir `ServerRuntime`, qui construit maintenant ce `MatchServer` une seule fois et le fait circuler vers les deux serveurs plutôt que de le laisser `run_ws_server` en créer un nouveau à chaque démarrage). Utilisé par la fenêtre graphique pour afficher ce même statut sans dupliquer la logique de rendu HTML -- interrogé par sondage périodique (toutes les 2s) depuis un thread séparé plutôt qu'en temps réel via une vraie connexion WebSocket, volontairement : pas besoin de la précision temps réel d'une vraie connexion juste pour un affichage de statut, et ça évite de dupliquer toute la logique de reconnexion/état déjà présente côté web. La requête HTTP elle-même tourne toujours dans un thread à part, jamais directement depuis le thread principal de la fenêtre (une requête bloquante, même locale, gèlerait sinon l'interface le temps de sa réponse). ## Journal applicatif persistant En plus du journal affiché dans la fenêtre graphique (en mémoire, perdu à la fermeture -- voir `fletchtime.gui._QueueWriter`), `fletchtime.logging_setup.configure_logging` écrit un fichier de journal persistant (`/logs/fletchtime.log`, avec rotation -- 1 Mo par fichier, 5 fichiers conservés) : utile pour comprendre après coup ce qui s'est passé pendant un concours, sans dépendre de la mémoire volatile de la fenêtre. `MatchServer` y journalise : - chaque commande reçue (`next`, `stop`, `start_indoor`...), avec la lane d'origine si connue ; - chaque connexion/déconnexion, avec le nombre de clients restants ; - les pertes de connexion réseau détectées (WiFi, mise en veille...), distinguées d'une fermeture normale ; - les messages malformés reçus (JSON invalide) ; - les transitions d'état significatives (`prep_start`, `shoot_start`, `warning_orange`, `end_of_volee`, `pause_start`/`pause_end`, `emergency_start`/`emergency_end`, `end_of_match`), avec le contexte (unité, volée, tour, phase) -- qu'elles soient déclenchées naturellement par le décompte ou manuellement (`next`, `goto`...), puisque les deux passent par le même mécanisme de collecte d'événements. `countdown_tick` est volontairement exclu (se déclenche une fois par seconde dans les dernières secondes de chaque volée -- noierait le journal sans valeur diagnostique ajoutée). ```{important} `configure_logging` prend un `console_level` séparé du niveau du fichier (toujours INFO par défaut, lui) -- la fenêtre graphique doit l'appeler avec `console_level=logging.INFO` explicitement, **pas** le défaut (`WARNING`, pensé pour un terminal silencieux par défaut, voir `fletchtime.__main__`, `-v`/`--verbose`). Un oubli de ce paramètre a laissé le widget de journal de la fenêtre silencieux en usage normal pendant un temps -- tous les journaux applicatifs ci-dessus sont à INFO, donc filtrés par le `WARNING` par défaut, exactement l'inverse de ce que ce widget est censé montrer. Confirmé par comparaison directe avant/après correctif : file d'attente vide avant, message présent après, avec un appel par ailleurs identique. ``` ```{important} Le mot de passe (action `authenticate`) n'est **jamais** journalisé -- le code ne journalise jamais `data` tel quel, seulement le nom de l'action et la lane, précisément pour éviter qu'un secret ne traîne en clair dans un fichier qui persiste sur disque. Vérifié par un test dédié (`test_password_never_appears_in_logs`). ``` ## Récupération après un plantage ou redémarrage du serveur Avant ce mécanisme, un plantage ou un redémarrage du serveur en plein match perdait toute la progression (série, volée, temps écoulé) : le `MatchServer` repartait d'un `self.engine = None`, sans aucun moyen de savoir où en était le concours -- il fallait alors un `goto()` manuel du responsable du chronométrage pour s'y retrouver. `MatchServer` persiste maintenant un instantané JSON de l'état du match (`config/match_state.json`, jamais versionné -- voir `.gitignore`) : - **Écriture** : à chaque commande qui change l'état (`next`, `stop`, `restart`, `goto`, `emergency`, `resume`, `pause`, `play`, `start_indoor`, `start_flint`) et à chaque tick (~5x/seconde) pendant le décompte. Écriture atomique (fichier temporaire puis renommage, voir `config_store.save_match_snapshot`) : jamais de fichier à moitié écrit si le plantage survient pendant l'écriture elle-même. - **Effacement** : dès que le match se termine (fin naturelle ou `stop()` explicite) -- rien à reprendre dans ce cas, un instantané qui traînerait redémarrerait un match déjà fini. - **Lecture** : à la construction de `MatchServer`, tentative de restauration -- reconstruit la même séquence d'étapes à partir de la config Indoor/Flint actuelle, puis restaure la position exacte (`MatchEngine.snapshot`/`restore=`, voir {doc}`api-reference`). Une reprise ratée (config changée depuis rendant l'index hors bornes, fichier corrompu) efface l'instantané et se rabat silencieusement sur un démarrage normal -- ne bloque jamais le lancement du serveur. - **Silencieuse** : contrairement à un vrai démarrage (`prep_start`), une reprise ne rejoue aucun son de transition -- les écrans reprennent juste où ça en était, sans redéclencher les sons du début de l'étape. ```{important} **Le temps restauré est recalculé à partir d'une échéance en horloge murale (`time.time()`), pas de la valeur `time_left` telle qu'enregistrée.** Chaque instantané inclut `wallclock_deadline` : l'instant exact (horloge murale) où le décompte en cours atteindra zéro. À la restauration, `time_left = wallclock_deadline - time.time()` -- recalculé à l'instant présent, pas relu tel quel. Résultat : peu importe combien de temps réel s'est écoulé entre la sauvegarde et la restauration (y compris le temps où le serveur était concrètement indisponible pendant son redémarrage), le temps restauré reste exact -- sans ça, la reprise affichait un temps figé à l'instant de la dernière sauvegarde, revenant visiblement en arrière par rapport à ce que l'écran avait déjà montré en extrapolant localement pendant la coupure (voir plus bas, "Fonctionnement dégradé en cas de coupure réseau"). Volontairement `time.time()` et jamais `time.monotonic()` pour cette échéance : `time.monotonic()` redémarre à une référence arbitraire à chaque nouveau processus, une valeur sauvegardée avant un plantage n'aurait donc plus aucun sens après redémarrage -- seule l'horloge murale reste comparable à travers un redémarrage du processus. `wallclock_deadline` vaut `None` pendant une pause/urgence (le décompte est gelé, aucune échéance à calculer) : `time_left` reste alors la valeur figée telle quelle, sans recalcul. ``` ```{important} **Si l'échéance est déjà dépassée à la restauration** (le temps réellement écoulé pendant l'indisponibilité dépasse ce qui restait sur l'étape en cours), le match est marqué **terminé** plutôt que de figer `time_left` à 0. Sans ça, le tick normal suivant (`MatchEngine.tick()`, voir plus haut) aurait déclenché son rattrapage automatique -- pensé pour un simple ralentissement passager du serveur, pas une coupure de plusieurs minutes -- et avancé à l'étape suivante avec une durée fraîche : le chrono semblait alors "redémarrer" du point de vue des archers, alors qu'en réalité bien plus de temps s'était écoulé que ce que l'étape en cours pouvait raisonnablement absorber. Une coupure trop longue pour être absorbée dans l'étape en cours demande donc une intervention explicite du responsable du chronométrage (`restart`/ `goto`) plutôt qu'une reprise automatique qui devinerait une position dans une coupure d'une durée arbitraire. ``` La sauvegarde à chaque tick (plutôt que périodiquement, ex. toutes les 5s) garde malgré tout sa valeur avec ce mécanisme : elle limite la fenêtre pendant laquelle un changement d'étape aurait pu se produire juste avant un plantage sans jamais être capturé dans l'instantané. ## Fonctionnement dégradé en cas de coupure réseau `display.html` continue de décompter **localement** pendant une coupure WebSocket brève, plutôt que d'afficher un écran figé ou neutre : dès la perte de connexion, le temps restant est extrapolé à partir du temps réellement écoulé (`performance.now()`, même principe que `MatchServer.tick_loop()` côté serveur -- voir plus haut), uniquement pendant une phase de décompte actif (rouge/vert/orange). Deux champs sont diffusés spécifiquement pour permettre cette extrapolation de reproduire fidèlement le comportement normal, sans deviner quoi que ce soit : - `MatchState.orange_threshold` (seuil de l'étape en cours) permet de reproduire localement le passage à l'orange, exactement comme `MatchEngine.current_state` le calcule côté serveur. - `countdown_tick_seconds` (réglage global, voir `config.html` section Son) permet de reproduire localement les bips des dernières secondes, exactement comme `MatchEngine._maybe_emit_countdown_ticks`. Chacun de ces deux événements sonores (`warning_orange`, `countdown_tick`) ne se déclenche qu'**une seule fois** pendant l'extrapolation, avec le même suivi que côté serveur (pas de re-déclenchement si on était déjà orange au moment de la coupure, pas de bip répété pour la même seconde). Volontairement limité à ce qui peut se dériver de l'étape **en cours** sans ambiguïté : deviner la transition vers l'étape **suivante** (fin de volée, walk-up...) sans confirmation du serveur demanderait de dupliquer toute la logique de séquencement côté client, ce qu'on évite -- le chrono se fige simplement à zéro en attendant la reconnexion plutôt que de risquer d'afficher une transition incorrecte (et donc de jouer les sons associés au mauvais moment). Les sons liés à une transition d'étape (`shoot_start`, `end_of_volee`, `emergency_start`...) ne jouent donc jamais pendant une coupure -- seuls `warning_orange` et `countdown_tick`, qui restent des seuils au sein de la même étape, sont reproduits. Dès qu'un état est de nouveau reçu du serveur, il fait autorité immédiatement et corrige toute dérive locale (visuelle et sonore) accumulée pendant la coupure. Un petit indicateur discret (⏱, coin supérieur droit) signale ce mode sans alarmer les archers. Le poste de contrôle, à l'inverse, affiche une bannière large et alarmante (pas discrète) en cas de coupure -- c'est le responsable du chronométrage qui doit être alerté clairement, pas les archers. ## Synchronisation du diaporama de l'écran neutre L'écran neutre (hors concours, ou après sa fin) alterne logo/horloge et bannières sponsors -- voir `display.html`, `showSlideshowStep`. Aucune coordination serveur pour ça : chaque écran calcule sa slide actuelle en divisant l'horloge murale (`Date.now()`) par la durée d'une slide, plutôt que d'incrémenter un compteur local à partir de 0 à son propre démarrage. ```{important} Une première version utilisait un compteur local (`slideshowStep`, incrémenté par `setInterval`) -- deux écrans qui chargeaient ou se reconnectaient à des instants différents affichaient alors des slides différentes au même moment, chacun étant reparti de 0 à son propre démarrage. Corrigé en dérivant la slide actuelle de l'horloge murale (`Math.floor(Date.now() / SLIDE_DURATION_MS) % totalSlides`) : deux écrans avec des horloges système raisonnablement synchronisées (le cas normal sur un même réseau local) calculent alors la même slide, indépendamment de quand chacun a démarré. Vérifié avec un vrai navigateur (Chromium via Playwright) : deux pages chargées à 3 secondes d'écart affichent bien la même slide. ``` ## Découverte du port WebSocket côté client `display.html` et `control.html` ne connaissent pas à l'avance le port WebSocket à utiliser : depuis que les ports sont devenus modifiables (voir la fenêtre graphique et `config/gui.toml`, pensé pour plusieurs salles de compétition sur un même PC), le coder en dur côté client casserait silencieusement toute page si le port avait été changé. Chaque page interroge `/api/version` (servi par le même serveur HTTP qui vient de la servir, donc forcément sur le bon port) avant d'ouvrir sa connexion WebSocket -- la réponse inclut `ws_port`, le port réellement configuré (`ServerRuntime.ws_port`, plombé jusqu'à `http_static.start_http_server`). Un échec de cette requête (réseau, serveur non démarré) se rabat silencieusement sur `8765` -- l'ancien port fixe, qui reste une valeur par défaut raisonnable, jamais une erreur bloquante pour l'utilisateur. ```{note} Vérifié avec un vrai navigateur (Chromium via Playwright), pas seulement en théorie : les deux pages utilisent bien le port récupéré dynamiquement, et se rabattent proprement sur 8765 sans planter quand `/api/version` échoue. ``` ## Multi-écrans et ciblage Chaque écran se connecte au WebSocket et s'enregistre avec son numéro de lane (`register_display`, extrait de `?lane=` dans l'URL). Le serveur garde une table connexion → lane, ce qui permet : - d'afficher sur `control.html` la liste des lanes effectivement connectées ; - d'envoyer un message à une seule lane (`message` avec un champ `lane`) sans affecter les autres écrans, avec repli sur le message global si aucun message ciblé n'est actif pour cette lane ; - un message global envoyé après un message ciblé **remplace** ce dernier (pas de message ciblé qui reste bloqué indéfiniment sur un écran). La miniature d'aperçu de `control.html` est un vrai `display.html` chargé dans une `