Architecture technique¶
Vue d’ensemble¶
flowchart TB
subgraph srv["Serveur Python (asyncio)<br/>tourne sur le poste du responsable<br/>du chronométrage, testé sur Pydroid 3"]
MS["MatchServer<br/><i>état du match, source de vérité unique</i>"]
ME["MatchEngine<br/><i>Indoor / Flint</i>"]
HTTP["HTTP statique (stdlib)"]
WS["WebSocket (paquet websockets)"]
MS --- ME
MS --- HTTP
MS --- WS
end
srv -->|réseau local WiFi| C["control.html<br/><i>poste de contrôle</i>"]
srv -->|réseau local WiFi| D1["display.html<br/><i>pas de tir 1</i>"]
srv -->|réseau local WiFi| D2["display.html<br/><i>pas de tir 2</i>"]
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 |
Pas de FastAPI/Pydantic (dépendance Rust |
Serveur HTTP statique |
|
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 |
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 ( |
Lu via |
Assets (logo, bannières, cibles, sons) |
Dépôt de fichiers dans |
Pas de formulaire d’upload à construire ni maintenir. |
Paquetage |
Un seul paquet |
|
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.
{
"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 desStepd’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 purebuild_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 Référence de l’API Python) suivent ces
transitions :
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
@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 stepPAUSE(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 dePAUSEau 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 dePAUSEentre 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 Guide développeur).
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 viaconfig.htmlprend 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) :
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
<pack_du_club>/ # 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/<pack>/<é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.
flowchart LR
subgraph main["Thread principal"]
GUI["Fenêtre customtkinter<br/><i>mainloop -- doit posséder ce thread</i>"]
end
subgraph httpT["Thread HTTP"]
HTTPD["ThreadingHTTPServer<br/><i>serve_forever / shutdown</i>"]
end
subgraph wsT["Thread WebSocket"]
LOOP["Boucle asyncio dédiée<br/><i>run_ws_server -- attend un stop_event</i>"]
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’ancienfletchtime.__main__.main, qui y faisait tourner directementasyncio.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ébloqueserve_forever()depuis n’importe quel autre thread – rien à construire à la main.Arrêt propre du serveur WebSocket :
run_ws_serverattend maintenant unasyncio.Event(stop_event) plutôt qu’unawait asyncio.Future()qui ne se résout jamais. Le déclencher depuis un autre thread (le thread graphique) passe parloop.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.stderrvers unequeue.Queue(thread-safe par construction), lue et affichée via unafter()périodique de tkinter – capte le journal d’accès HTTP (http.serverécrit sur stderr) sans avoir à instrumenter chaque site d’appel.
Avertissement
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 Guide développeur.
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 (<données du club>/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_tickest 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, voirconfig_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 Référence de l’API Python). 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 commeMatchEngine.current_statele calcule côté serveur.countdown_tick_seconds(réglage global, voirconfig.htmlsection Son) permet de reproduire localement les bips des dernières secondes, exactement commeMatchEngine._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=<n> dans l’URL). Le serveur garde une
table connexion → lane, ce qui permet :
d’afficher sur
control.htmlla liste des lanes effectivement connectées ;d’envoyer un message à une seule lane (
messageavec un champlane) 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 <iframe> (mise à l’échelle en CSS) plutôt qu’une logique de rendu
dupliquée – elle s’enregistre avec la lane spéciale "apercu", exclue du
comptage des écrans connectés.
Note
Cette même lane "apercu" ne joue jamais de son (voir display.html,
soundEnabled) : sans ça, ouvrir la page de contrôle et un vrai écran
d’affichage sur le même PC faisait entendre chaque son deux fois –
l’aperçu est une simple vue visuelle pour le responsable du
chronométrage, pas un écran destiné aux archers. Un paramètre d’URL
?mute=1 permet en plus de couper le son sur n’importe quel autre onglet
ouvert en trop sur la même machine (ex. pour surveiller une autre lane
sans dupliquer le son de l’écran qui joue réellement pour les archers).