fletchtime 0.2.4__tar.gz → 0.2.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fletchtime-0.2.4/src/fletchtime.egg-info → fletchtime-0.2.5}/PKG-INFO +1 -1
- {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/architecture.md +81 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/roadmap.md +42 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/fletchtime.spec +7 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/_version.py +3 -3
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/gui.py +110 -4
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/runtime.py +12 -2
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/server/config_store.py +41 -4
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/server/http_static.py +66 -8
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/server/match_server.py +67 -44
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/display.html +10 -0
- fletchtime-0.2.5/src/fletchtime/web/logo.ico +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5/src/fletchtime.egg-info}/PKG-INFO +1 -1
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime.egg-info/SOURCES.txt +1 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime.egg-info/scm_file_list.json +1 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime.egg-info/scm_version.json +2 -2
- {fletchtime-0.2.4 → fletchtime-0.2.5}/tests/test_config_store.py +42 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/tests/test_match_server.py +36 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/.github/workflows/build.yml +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/.github/workflows/docs.yml +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/.github/workflows/test.yml +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/.gitignore +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/CONTRIBUTING.md +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/LICENSE +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/README.md +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/REMERCIEMENTS.md +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/config/app.toml +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/config/flint.toml +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/config/indoor.toml +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/demo.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/_static/logo.svg +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/api-reference.md +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/conf.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/dev-guide/index.md +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/index.md +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/premier-club.md +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/remerciements.md +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/requirements.txt +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/specifications.md +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/user-guide/index.md +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/pyproject.toml +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/run_server.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/run_tests.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/scripts/generate_classic_sounds.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/setup.cfg +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/__init__.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/__main__.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/__init__.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/engine.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/models.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/modes/__init__.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/modes/base.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/modes/flint.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/modes/indoor.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/sequence.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/turn_modes.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/logging_setup.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/server/__init__.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/server/ws_server.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/__init__.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/banners/README.md +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/club/README.md +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/README.md +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/countdown_tick.wav +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/emergency_end.wav +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/emergency_start.wav +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/end_of_match.wav +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/end_of_volee.wav +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/pause_end.wav +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/pause_start.wav +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/prep_start.wav +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/shoot_start.wav +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/warning_orange.wav +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/targets/flint_20cm_4spot.jpg +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/targets/flint_35cm_1spot.jpg +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/targets/indoor_compound.jpg +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/targets/indoor_recurve.jpg +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/config.html +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/control.html +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/i18n.js +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/index.html +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/logo.svg +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/manual.html +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/screenshots/config.png +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/screenshots/control.png +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/screenshots/display.png +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/screenshots/index.png +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/theme.js +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime.egg-info/dependency_links.txt +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime.egg-info/entry_points.txt +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime.egg-info/requires.txt +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime.egg-info/top_level.txt +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/tests/test_engine.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/tests/test_flint_mode.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/tests/test_indoor_mode.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/tests/test_main_cli.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/tests/test_runtime.py +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/web/assets/banners/README.md +0 -0
- {fletchtime-0.2.4 → fletchtime-0.2.5}/web/assets/sounds/packs/README.md +0 -0
|
@@ -343,6 +343,60 @@ confirmer le rendu et l'ergonomie tactile -- voir aussi le piège
|
|
|
343
343
|
PyInstaller/`customtkinter` documenté dans {doc}`dev-guide/index`.
|
|
344
344
|
```
|
|
345
345
|
|
|
346
|
+
## Résilience de la boucle de décompte
|
|
347
|
+
|
|
348
|
+
`MatchServer.tick_loop()` capture désormais toute exception imprévue en
|
|
349
|
+
son sein (journalisée avec la trace complète) plutôt que de laisser
|
|
350
|
+
mourir la boucle silencieusement -- filet de sécurité ajouté suite à un
|
|
351
|
+
symptôme signalé en pratique : le chrono se figeait indéfiniment, sans
|
|
352
|
+
aucune erreur visible, le reste du serveur (connexions, réponse aux
|
|
353
|
+
commandes) continuant de fonctionner normalement à côté.
|
|
354
|
+
|
|
355
|
+
```{important}
|
|
356
|
+
**Cause la plus probable identifiée** : sous Windows, remplacer un
|
|
357
|
+
fichier (`Path.replace`) peut échouer avec une "violation de partage"
|
|
358
|
+
si un autre processus a le fichier cible ouvert au même instant
|
|
359
|
+
(antivirus, surveillance de fichiers d'un IDE, Git Bash...) -- une
|
|
360
|
+
différence fondamentale avec la sémantique POSIX (Linux/macOS), où ceci
|
|
361
|
+
n'arrive jamais. Observé une fois en pratique, précisément sur
|
|
362
|
+
`config/match_state.json` -- écrit à chaque tick depuis la persistance
|
|
363
|
+
après plantage (voir plus bas). Sans gestion d'erreur, cette exception
|
|
364
|
+
tuait silencieusement `tick_loop` pour de bon : exactement le symptôme
|
|
365
|
+
rapporté (gel permanent, aucune déconnexion, se produisant aussi bien
|
|
366
|
+
en fenêtre graphique qu'en mode terminal -- sans lien réel avec le
|
|
367
|
+
focus d'une fenêtre, malgré la corrélation observée au départ).
|
|
368
|
+
|
|
369
|
+
Deux correctifs complémentaires : ce filet dans `tick_loop` (n'importe
|
|
370
|
+
quelle exception, pas seulement celle-ci), et
|
|
371
|
+
`config_store.save_match_snapshot` qui retente quelques fois avant
|
|
372
|
+
d'abandonner proprement (jamais d'exception qui remonterait perturber
|
|
373
|
+
la diffusion de l'état aux écrans pour ce tick). Testé concrètement :
|
|
374
|
+
un échec transitoire (une fois puis réussite) est absorbé et le
|
|
375
|
+
contenu final reste correct ; un échec permanent (toutes les tentatives
|
|
376
|
+
échouent) est abandonné proprement, sans jamais lever d'exception.
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
## Statut technique exposé via HTTP (`/api/status`)
|
|
380
|
+
|
|
381
|
+
Les mêmes données déjà affichées dans `control.html` (écrans connectés,
|
|
382
|
+
mode actif, phase en cours, pack de sons, mot de passe configuré ou non)
|
|
383
|
+
sont aussi exposées via un simple GET HTTP, lues directement depuis
|
|
384
|
+
l'instance `MatchServer` partagée avec le serveur WebSocket (voir
|
|
385
|
+
`ServerRuntime`, qui construit maintenant ce `MatchServer` une seule fois
|
|
386
|
+
et le fait circuler vers les deux serveurs plutôt que de le laisser
|
|
387
|
+
`run_ws_server` en créer un nouveau à chaque démarrage).
|
|
388
|
+
|
|
389
|
+
Utilisé par la fenêtre graphique pour afficher ce même statut sans
|
|
390
|
+
dupliquer la logique de rendu HTML -- interrogé par sondage périodique
|
|
391
|
+
(toutes les 2s) depuis un thread séparé plutôt qu'en temps réel via une
|
|
392
|
+
vraie connexion WebSocket, volontairement : pas besoin de la précision
|
|
393
|
+
temps réel d'une vraie connexion juste pour un affichage de statut, et
|
|
394
|
+
ça évite de dupliquer toute la logique de reconnexion/état déjà présente
|
|
395
|
+
côté web. La requête HTTP elle-même tourne toujours dans un thread à
|
|
396
|
+
part, jamais directement depuis le thread principal de la fenêtre (une
|
|
397
|
+
requête bloquante, même locale, gèlerait sinon l'interface le temps de
|
|
398
|
+
sa réponse).
|
|
399
|
+
|
|
346
400
|
## Journal applicatif persistant
|
|
347
401
|
|
|
348
402
|
En plus du journal affiché dans la fenêtre graphique (en mémoire,
|
|
@@ -370,6 +424,21 @@ volatile de la fenêtre.
|
|
|
370
424
|
dernières secondes de chaque volée -- noierait le journal sans valeur
|
|
371
425
|
diagnostique ajoutée).
|
|
372
426
|
|
|
427
|
+
```{important}
|
|
428
|
+
`configure_logging` prend un `console_level` séparé du niveau du fichier
|
|
429
|
+
(toujours INFO par défaut, lui) -- la fenêtre graphique doit l'appeler
|
|
430
|
+
avec `console_level=logging.INFO` explicitement, **pas** le défaut
|
|
431
|
+
(`WARNING`, pensé pour un terminal silencieux par défaut, voir
|
|
432
|
+
`fletchtime.__main__`, `-v`/`--verbose`). Un oubli de ce paramètre a
|
|
433
|
+
laissé le widget de journal de la fenêtre silencieux en usage normal
|
|
434
|
+
pendant un temps -- tous les journaux applicatifs ci-dessus sont à
|
|
435
|
+
INFO, donc filtrés par le `WARNING` par défaut, exactement l'inverse de
|
|
436
|
+
ce que ce widget est censé montrer. Confirmé par comparaison directe
|
|
437
|
+
avant/après correctif : file d'attente vide avant, message présent
|
|
438
|
+
après, avec un appel par ailleurs identique.
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
|
|
373
442
|
```{important}
|
|
374
443
|
Le mot de passe (action `authenticate`) n'est **jamais** journalisé -- le
|
|
375
444
|
code ne journalise jamais `data` tel quel, seulement le nom de l'action
|
|
@@ -565,3 +634,15 @@ La miniature d'aperçu de `control.html` est un vrai `display.html` chargé
|
|
|
565
634
|
dans une `<iframe>` (mise à l'échelle en CSS) plutôt qu'une logique de rendu
|
|
566
635
|
dupliquée -- elle s'enregistre avec la lane spéciale `"apercu"`, exclue du
|
|
567
636
|
comptage des écrans connectés.
|
|
637
|
+
|
|
638
|
+
```{note}
|
|
639
|
+
**Cette même lane `"apercu"` ne joue jamais de son** (voir `display.html`,
|
|
640
|
+
`soundEnabled`) : sans ça, ouvrir la page de contrôle et un vrai écran
|
|
641
|
+
d'affichage sur le même PC faisait entendre chaque son deux fois --
|
|
642
|
+
l'aperçu est une simple vue visuelle pour le responsable du
|
|
643
|
+
chronométrage, pas un écran destiné aux archers. Un paramètre d'URL
|
|
644
|
+
`?mute=1` permet en plus de couper le son sur n'importe quel autre onglet
|
|
645
|
+
ouvert en trop sur la même machine (ex. pour surveiller une autre lane
|
|
646
|
+
sans dupliquer le son de l'écran qui joue réellement pour les archers).
|
|
647
|
+
```
|
|
648
|
+
|
|
@@ -77,6 +77,48 @@ dépendent d'extensions compilées (Rust/C) peu fiables sur Pydroid 3, voir
|
|
|
77
77
|
|
|
78
78
|
## Backlog — à discuter / non encore programmé dans une étape précise
|
|
79
79
|
|
|
80
|
+
- ~~**Icône de l'exécutable**~~ -- fait : `web/logo.ico` (multi-résolution,
|
|
81
|
+
16 à 256px, généré depuis `web/logo.svg`), utilisé par `fletchtime.spec`
|
|
82
|
+
pour l'exécutable Windows. Sans effet sous Linux, qui n'a pas ce
|
|
83
|
+
concept de métadonnées d'icône pour un simple binaire (vérifié). Le
|
|
84
|
+
favicon des pages web était en fait déjà en place, rien à faire là.
|
|
85
|
+
- ~~**Son dupliqué sur plusieurs onglets**~~ -- fait : l'aperçu de
|
|
86
|
+
`control.html` (une vraie instance de `display.html` en iframe) ne
|
|
87
|
+
joue plus jamais de son -- c'est une simple vue visuelle, pas un écran
|
|
88
|
+
destiné aux archers. `?mute=1` permet en plus de couper le son sur
|
|
89
|
+
n'importe quel autre onglet ouvert en trop sur le même PC.
|
|
90
|
+
- ~~**Chrono figé (sous Windows, pas lié au focus en réalité)**~~ --
|
|
91
|
+
cause la plus probable identifiée : sous Windows, remplacer un fichier
|
|
92
|
+
peut échouer avec une "violation de partage" si un autre processus l'a
|
|
93
|
+
ouvert au même instant (antivirus, surveillance de fichiers d'un
|
|
94
|
+
IDE...) -- observé une fois en pratique, précisément sur
|
|
95
|
+
`config/match_state.json`, écrit à chaque tick depuis la persistance
|
|
96
|
+
après plantage. Sans gestion d'erreur, ça tuait silencieusement
|
|
97
|
+
`tick_loop` pour de bon -- exactement le symptôme rapporté (gel
|
|
98
|
+
permanent, pas de déconnexion, se produisant aussi bien en GUI qu'en
|
|
99
|
+
headless, sans lien réel avec le focus). Deux correctifs : `tick_loop`
|
|
100
|
+
capture désormais toute exception imprévue en son sein (journalisée
|
|
101
|
+
avec sa trace complète, ne meurt plus silencieusement) et
|
|
102
|
+
`save_match_snapshot` retente quelques fois avant d'abandonner
|
|
103
|
+
proprement (jamais d'exception qui remonterait perturber la diffusion
|
|
104
|
+
de l'état aux écrans). Testé concrètement : échec transitoire récupéré,
|
|
105
|
+
échec permanent absorbé sans lever.
|
|
106
|
+
- ~~**Journal absent du widget de la fenêtre**~~ -- vrai bug trouvé en
|
|
107
|
+
creusant le point précédent : `configure_logging` était appelé sans
|
|
108
|
+
préciser `console_level`, retombant sur le défaut `WARNING` (pensé
|
|
109
|
+
pour un terminal silencieux) -- qui filtrait justement tout ce que ce
|
|
110
|
+
widget est censé montrer (commandes, connexions, transitions, toutes
|
|
111
|
+
en `INFO`). Corrigé avec `console_level=INFO` explicite pour la
|
|
112
|
+
fenêtre. Confirmé par comparaison directe avant/après : file vide
|
|
113
|
+
avant, message présent après.
|
|
114
|
+
- ~~**Données techniques dans la fenêtre**~~ -- fait : nouvel endpoint
|
|
115
|
+
`/api/status` (les mêmes données déjà affichées dans `control.html`),
|
|
116
|
+
affiché dans la fenêtre via sondage périodique. `ServerRuntime`
|
|
117
|
+
construit maintenant le `MatchServer` une seule fois et le partage
|
|
118
|
+
entre les deux serveurs, plutôt que d'en laisser `run_ws_server` créer
|
|
119
|
+
un nouveau à chaque démarrage.
|
|
120
|
+
|
|
121
|
+
|
|
80
122
|
- ~~**Remerciements**~~ -- structure prête, à compléter : `REMERCIEMENTS.md`
|
|
81
123
|
(modèle à remplir avec les noms), lié depuis le README et intégré à la
|
|
82
124
|
doc Sphinx ({doc}`remerciements` -- même contenu, pas dupliqué, via une
|
|
@@ -71,6 +71,13 @@ exe = EXE(
|
|
|
71
71
|
# assets/ du bootstrap), d'où un serveur qui tourne mais ne sert rien
|
|
72
72
|
# d'utile. "." restaure explicitement l'ancien comportement.
|
|
73
73
|
contents_directory=".",
|
|
74
|
+
# Icône de l'exécutable (barre des tâches, explorateur de fichiers,
|
|
75
|
+
# raccourci) -- fichier .ico multi-résolution (16 à 256px), généré à
|
|
76
|
+
# partir de web/logo.svg. PyInstaller ignore ce paramètre sous Linux
|
|
77
|
+
# (les .ico n'y ont pas de sens -- l'icône affichée dépend du
|
|
78
|
+
# gestionnaire de fichiers/bureau, hors du contrôle de l'exécutable
|
|
79
|
+
# lui-même), donc rien à prévoir de spécial pour cette plateforme.
|
|
80
|
+
icon=str(project_root / "src" / "fletchtime" / "web" / "logo.ico"),
|
|
74
81
|
)
|
|
75
82
|
|
|
76
83
|
coll = COLLECT(
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.2.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
21
|
+
__version__ = version = '0.2.5'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 5)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'ga6e6ad564'
|
|
@@ -24,8 +24,13 @@ Pydroid reste nécessaire pour confirmer le rendu et l'ergonomie tactile.
|
|
|
24
24
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
27
29
|
import queue
|
|
28
30
|
import sys
|
|
31
|
+
import threading
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
29
34
|
import webbrowser
|
|
30
35
|
|
|
31
36
|
import customtkinter as ctk
|
|
@@ -64,6 +69,17 @@ _TRANSLATIONS = {
|
|
|
64
69
|
"networkErrorRange": "Les ports doivent être entre 1 et 65535",
|
|
65
70
|
"networkErrorSame": "Les deux ports doivent être différents",
|
|
66
71
|
"networkApplied": "Appliqué",
|
|
72
|
+
"techStatusUnavailable": "Statut technique : indisponible (serveur en cours de démarrage ?)",
|
|
73
|
+
"techStatusClients": "Clients connectés :",
|
|
74
|
+
"techStatusLanes": "Écrans :",
|
|
75
|
+
"techStatusNoLanes": "aucun",
|
|
76
|
+
"techStatusMode": "Mode :",
|
|
77
|
+
"techStatusNoMode": "aucun",
|
|
78
|
+
"techStatusPhase": "Phase :",
|
|
79
|
+
"techStatusSound": "Sons :",
|
|
80
|
+
"techStatusAuth": "Mot de passe :",
|
|
81
|
+
"yes": "oui",
|
|
82
|
+
"no": "non",
|
|
67
83
|
},
|
|
68
84
|
"en": {
|
|
69
85
|
"title": "FletchTime -- Server",
|
|
@@ -87,6 +103,17 @@ _TRANSLATIONS = {
|
|
|
87
103
|
"networkErrorRange": "Ports must be between 1 and 65535",
|
|
88
104
|
"networkErrorSame": "Both ports must be different",
|
|
89
105
|
"networkApplied": "Applied",
|
|
106
|
+
"techStatusUnavailable": "Technical status: unavailable (server starting?)",
|
|
107
|
+
"techStatusClients": "Connected clients:",
|
|
108
|
+
"techStatusLanes": "Screens:",
|
|
109
|
+
"techStatusNoLanes": "none",
|
|
110
|
+
"techStatusMode": "Mode:",
|
|
111
|
+
"techStatusNoMode": "none",
|
|
112
|
+
"techStatusPhase": "Phase:",
|
|
113
|
+
"techStatusSound": "Sounds:",
|
|
114
|
+
"techStatusAuth": "Password:",
|
|
115
|
+
"yes": "yes",
|
|
116
|
+
"no": "no",
|
|
90
117
|
},
|
|
91
118
|
}
|
|
92
119
|
|
|
@@ -209,6 +236,7 @@ class FletchTimeApp(ctk.CTk):
|
|
|
209
236
|
|
|
210
237
|
self.language = "fr"
|
|
211
238
|
self.log_queue: queue.Queue[str] = queue.Queue()
|
|
239
|
+
self.status_queue: queue.Queue = queue.Queue()
|
|
212
240
|
|
|
213
241
|
self.data_root = _data_root()
|
|
214
242
|
self.app_web_dir = _app_web_dir()
|
|
@@ -226,10 +254,17 @@ class FletchTimeApp(ctk.CTk):
|
|
|
226
254
|
# Appelé APRÈS la redirection ci-dessus, volontairement : le
|
|
227
255
|
# StreamHandler créé par configure_logging() se lie au sys.stderr
|
|
228
256
|
# courant au moment de sa création -- fait après le remplacement,
|
|
229
|
-
# les journaux applicatifs (commandes reçues, pertes de connexion
|
|
230
|
-
# apparaissent donc aussi dans le widget de
|
|
231
|
-
# pas seulement dans le fichier persistant.
|
|
232
|
-
|
|
257
|
+
# les journaux applicatifs (commandes reçues, pertes de connexion,
|
|
258
|
+
# transitions...) apparaissent donc aussi dans le widget de
|
|
259
|
+
# journal de la fenêtre, pas seulement dans le fichier persistant.
|
|
260
|
+
#
|
|
261
|
+
# console_level=INFO explicite, pas le défaut (WARNING, pensé
|
|
262
|
+
# pour un terminal silencieux par défaut -- voir
|
|
263
|
+
# fletchtime.__main__, -v/--verbose) : sans ce paramètre, le
|
|
264
|
+
# widget de journal de la fenêtre restait silencieux en usage
|
|
265
|
+
# normal, puisque tous les journaux applicatifs sont à INFO --
|
|
266
|
+
# exactement l'inverse de ce que ce widget est censé montrer.
|
|
267
|
+
self.log_file = configure_logging(self.data_root / "logs", console_level=logging.INFO)
|
|
233
268
|
|
|
234
269
|
self.title(self._t("title"))
|
|
235
270
|
self.geometry("820x600")
|
|
@@ -238,6 +273,8 @@ class FletchTimeApp(ctk.CTk):
|
|
|
238
273
|
|
|
239
274
|
self._build_ui()
|
|
240
275
|
self._poll_log_queue()
|
|
276
|
+
self._poll_technical_status()
|
|
277
|
+
self._drain_status_queue()
|
|
241
278
|
self._start_server()
|
|
242
279
|
|
|
243
280
|
# -- traductions ---------------------------------------------------
|
|
@@ -388,6 +425,20 @@ class FletchTimeApp(ctk.CTk):
|
|
|
388
425
|
self.network_status_label = ctk.CTkLabel(network_frame, text="", text_color="gray60")
|
|
389
426
|
self.network_status_label.pack(side="left", padx=(4, 12), pady=8, fill="x", expand=True)
|
|
390
427
|
|
|
428
|
+
# -- statut technique ---------------------------------------------
|
|
429
|
+
# Les mêmes données techniques que celles déjà affichées dans la
|
|
430
|
+
# page de contrôle (écrans connectés, mode actif...), lues via
|
|
431
|
+
# /api/status -- voir _poll_technical_status ci-dessous, et
|
|
432
|
+
# fletchtime.server.http_static._DualRootHandler._build_status_body
|
|
433
|
+
# côté serveur.
|
|
434
|
+
tech_frame = ctk.CTkFrame(self)
|
|
435
|
+
tech_frame.pack(fill="x", padx=16, pady=(0, 8))
|
|
436
|
+
|
|
437
|
+
self.tech_status_label = ctk.CTkLabel(
|
|
438
|
+
tech_frame, text=self._t("techStatusUnavailable"), anchor="w", justify="left"
|
|
439
|
+
)
|
|
440
|
+
self.tech_status_label.pack(fill="x", padx=12, pady=8)
|
|
441
|
+
|
|
391
442
|
# -- journal ----------------------------------------------------
|
|
392
443
|
self.log_label = ctk.CTkLabel(self, text=self._t("log_title"), anchor="w")
|
|
393
444
|
self.log_label.pack(fill="x", padx=20, pady=(8, 0))
|
|
@@ -557,6 +608,61 @@ class FletchTimeApp(ctk.CTk):
|
|
|
557
608
|
|
|
558
609
|
# -- journal (file thread-safe -> widget) ------------------------------
|
|
559
610
|
|
|
611
|
+
# -- statut technique (thread séparé pour la requête HTTP -> file) -----
|
|
612
|
+
|
|
613
|
+
def _poll_technical_status(self) -> None:
|
|
614
|
+
# Une requête HTTP, même locale, peut mettre du temps à répondre
|
|
615
|
+
# (serveur qui démarre, port qui change...) -- jamais faite
|
|
616
|
+
# directement depuis le thread principal (gèlerait la fenêtre le
|
|
617
|
+
# temps de la requête). Un thread par sondage, plutôt qu'un seul
|
|
618
|
+
# thread persistant : plus simple, et chaque requête se termine en
|
|
619
|
+
# quelques millisecondes en pratique (même machine).
|
|
620
|
+
threading.Thread(target=self._fetch_technical_status, daemon=True).start()
|
|
621
|
+
self.after(2000, self._poll_technical_status)
|
|
622
|
+
|
|
623
|
+
def _fetch_technical_status(self) -> None:
|
|
624
|
+
# Tourne dans un thread séparé -- ne touche JAMAIS un widget
|
|
625
|
+
# Tkinter directement ici (pas thread-safe) ; pousse seulement le
|
|
626
|
+
# résultat dans une file, lue depuis le thread principal via
|
|
627
|
+
# _drain_status_queue, exactement comme le journal (self.log_queue).
|
|
628
|
+
try:
|
|
629
|
+
url = f"http://127.0.0.1:{self.http_port}/api/status"
|
|
630
|
+
with urllib.request.urlopen(url, timeout=2) as res:
|
|
631
|
+
data = json.loads(res.read())
|
|
632
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
633
|
+
# Serveur pas encore démarré, port en cours de changement,
|
|
634
|
+
# réponse invalide... -- affiche juste "indisponible", jamais
|
|
635
|
+
# une exception qui remonterait jusqu'au thread principal.
|
|
636
|
+
data = None
|
|
637
|
+
self.status_queue.put(data)
|
|
638
|
+
|
|
639
|
+
def _drain_status_queue(self) -> None:
|
|
640
|
+
try:
|
|
641
|
+
while True:
|
|
642
|
+
data = self.status_queue.get_nowait()
|
|
643
|
+
self._render_technical_status(data)
|
|
644
|
+
except queue.Empty:
|
|
645
|
+
pass
|
|
646
|
+
self.after(200, self._drain_status_queue)
|
|
647
|
+
|
|
648
|
+
def _render_technical_status(self, data: dict | None) -> None:
|
|
649
|
+
if not data or not data.get("available"):
|
|
650
|
+
self.tech_status_label.configure(text=self._t("techStatusUnavailable"))
|
|
651
|
+
return
|
|
652
|
+
lanes = ", ".join(data["connected_lanes"]) or self._t("techStatusNoLanes")
|
|
653
|
+
mode = data["active_mode"] or self._t("techStatusNoMode")
|
|
654
|
+
phase = data["match_phase"] or "--"
|
|
655
|
+
text = (
|
|
656
|
+
f"{self._t('techStatusClients')} {data['connected_clients']} | "
|
|
657
|
+
f"{self._t('techStatusLanes')} {lanes} | "
|
|
658
|
+
f"{self._t('techStatusMode')} {mode} | "
|
|
659
|
+
f"{self._t('techStatusPhase')} {phase} | "
|
|
660
|
+
f"{self._t('techStatusSound')} {data['sound_pack']} | "
|
|
661
|
+
f"{self._t('techStatusAuth')} "
|
|
662
|
+
f"{self._t('yes') if data['password_configured'] else self._t('no')}"
|
|
663
|
+
)
|
|
664
|
+
self.tech_status_label.configure(text=text)
|
|
665
|
+
|
|
560
666
|
def _poll_log_queue(self) -> None:
|
|
561
667
|
try:
|
|
562
668
|
while True:
|
|
@@ -18,6 +18,7 @@ import threading
|
|
|
18
18
|
from http.server import ThreadingHTTPServer
|
|
19
19
|
|
|
20
20
|
from fletchtime.server.http_static import start_http_server
|
|
21
|
+
from fletchtime.server.match_server import MatchServer
|
|
21
22
|
from fletchtime.server.ws_server import run_ws_server
|
|
22
23
|
|
|
23
24
|
|
|
@@ -33,6 +34,13 @@ class ServerRuntime:
|
|
|
33
34
|
self.http_port = http_port
|
|
34
35
|
self.ws_port = ws_port
|
|
35
36
|
|
|
37
|
+
# Créé une seule fois ici (pas dans run_ws_server, qui en
|
|
38
|
+
# construirait un nouveau à chaque start()) -- partagé avec le
|
|
39
|
+
# serveur HTTP (voir start_http_server, paramètre match_server)
|
|
40
|
+
# pour exposer l'état du match en lecture via /api/status, sans
|
|
41
|
+
# dupliquer d'état entre les deux serveurs.
|
|
42
|
+
self.match_server = MatchServer()
|
|
43
|
+
|
|
36
44
|
self._httpd: ThreadingHTTPServer | None = None
|
|
37
45
|
self._http_thread: threading.Thread | None = None
|
|
38
46
|
self._ws_thread: threading.Thread | None = None
|
|
@@ -52,7 +60,7 @@ class ServerRuntime:
|
|
|
52
60
|
return
|
|
53
61
|
|
|
54
62
|
self._httpd = start_http_server(
|
|
55
|
-
self.app_web_dir, self.http_port, self.assets_dir, self.ws_port
|
|
63
|
+
self.app_web_dir, self.http_port, self.assets_dir, self.ws_port, self.match_server
|
|
56
64
|
)
|
|
57
65
|
self._http_thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
|
|
58
66
|
self._http_thread.start()
|
|
@@ -72,7 +80,9 @@ class ServerRuntime:
|
|
|
72
80
|
self._ws_stop_event = asyncio.Event()
|
|
73
81
|
self._ws_ready.set()
|
|
74
82
|
try:
|
|
75
|
-
loop.run_until_complete(
|
|
83
|
+
loop.run_until_complete(
|
|
84
|
+
run_ws_server(self.ws_port, self._ws_stop_event, self.match_server)
|
|
85
|
+
)
|
|
76
86
|
finally:
|
|
77
87
|
loop.close()
|
|
78
88
|
|
|
@@ -13,7 +13,9 @@ maison, largement suffisant pour les types simples qu'on manipule ici
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import json
|
|
16
|
+
import logging
|
|
16
17
|
import sys
|
|
18
|
+
import time
|
|
17
19
|
import tomllib
|
|
18
20
|
from dataclasses import fields
|
|
19
21
|
from pathlib import Path
|
|
@@ -21,6 +23,8 @@ from typing import Any
|
|
|
21
23
|
|
|
22
24
|
from fletchtime.engine import FlintConfig, IndoorConfig
|
|
23
25
|
|
|
26
|
+
logger = logging.getLogger("fletchtime.server")
|
|
27
|
+
|
|
24
28
|
# FletchTime opère sur le répertoire courant, comme la plupart des outils en
|
|
25
29
|
# ligne de commande (jekyll, hugo...) : `config/` vit à côté d'où la
|
|
26
30
|
# commande est lancée -- que ce soit un clone du dépôt (le dev lance
|
|
@@ -256,12 +260,45 @@ def _write_toml(path: Path, values: dict[str, Any], comments: dict[str, str]) ->
|
|
|
256
260
|
def save_match_snapshot(data: dict[str, Any]) -> None:
|
|
257
261
|
"""Écriture atomique (fichier temporaire puis renommage) : ne laisse
|
|
258
262
|
jamais un fichier à moitié écrit si le processus s'arrête précisément
|
|
259
|
-
pendant l'écriture -- ``Path.replace`` est atomique aussi bien
|
|
260
|
-
Linux/macOS que Windows depuis Python 3.3+.
|
|
263
|
+
pendant l'écriture -- ``Path.replace`` est garanti atomique aussi bien
|
|
264
|
+
sous Linux/macOS que Windows depuis Python 3.3+.
|
|
265
|
+
|
|
266
|
+
Ceci dit, "atomique" ne veut pas dire "ne peut jamais échouer" : sous
|
|
267
|
+
Windows, remplacer un fichier peut lever une erreur de "violation de
|
|
268
|
+
partage" si un autre processus l'a ouvert au même instant (antivirus,
|
|
269
|
+
surveillance de fichiers d'un IDE, Git Bash...) -- constaté en
|
|
270
|
+
pratique (voir docs/architecture.md, section sur la résilience de la
|
|
271
|
+
boucle de décompte, appelante de cette fonction à chaque tick).
|
|
272
|
+
Ce verrou est transitoire par nature, d'où quelques tentatives avant
|
|
273
|
+
d'abandonner. Échoue silencieusement (juste un avertissement dans le
|
|
274
|
+
journal) plutôt que de lever : un instantané manqué n'est jamais
|
|
275
|
+
grave (le prochain tick, ~200ms plus tard, retente), alors qu'une
|
|
276
|
+
exception qui remonterait perturberait aussi la diffusion de l'état
|
|
277
|
+
aux écrans pour ce même tick, pour une raison qui n'a rien à voir
|
|
278
|
+
avec eux."""
|
|
261
279
|
MATCH_STATE_JSON.parent.mkdir(parents=True, exist_ok=True)
|
|
262
280
|
tmp_path = MATCH_STATE_JSON.with_suffix(".json.tmp")
|
|
263
|
-
|
|
264
|
-
|
|
281
|
+
try:
|
|
282
|
+
tmp_path.write_text(json.dumps(data), encoding="utf-8")
|
|
283
|
+
except OSError:
|
|
284
|
+
logger.warning("Échec d'écriture de l'instantané de match (fichier temporaire)")
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
last_error: OSError | None = None
|
|
288
|
+
for attempt in range(3):
|
|
289
|
+
try:
|
|
290
|
+
tmp_path.replace(MATCH_STATE_JSON)
|
|
291
|
+
return
|
|
292
|
+
except OSError as exc:
|
|
293
|
+
last_error = exc
|
|
294
|
+
if attempt < 2:
|
|
295
|
+
time.sleep(0.05)
|
|
296
|
+
logger.warning(
|
|
297
|
+
"Échec de sauvegarde de l'instantané de match après plusieurs tentatives "
|
|
298
|
+
"(fichier probablement verrouillé par un autre processus, ex. antivirus, "
|
|
299
|
+
"IDE) : %s",
|
|
300
|
+
last_error,
|
|
301
|
+
)
|
|
265
302
|
|
|
266
303
|
|
|
267
304
|
def load_match_snapshot() -> dict[str, Any] | None:
|
|
@@ -33,9 +33,12 @@ class _DualRootHandler(SimpleHTTPRequestHandler):
|
|
|
33
33
|
behaviour as before this existed).
|
|
34
34
|
"""
|
|
35
35
|
|
|
36
|
-
def __init__(
|
|
36
|
+
def __init__(
|
|
37
|
+
self, *args, directory: str, assets_dir: str, ws_port: int, match_server=None, **kwargs
|
|
38
|
+
) -> None:
|
|
37
39
|
self._assets_dir = assets_dir
|
|
38
40
|
self._ws_port = ws_port
|
|
41
|
+
self._match_server = match_server
|
|
39
42
|
super().__init__(*args, directory=directory, **kwargs)
|
|
40
43
|
|
|
41
44
|
def do_GET(self) -> None:
|
|
@@ -49,15 +52,59 @@ class _DualRootHandler(SimpleHTTPRequestHandler):
|
|
|
49
52
|
# page elle-même, qui est garanti correct sans configuration
|
|
50
53
|
# supplémentaire côté client.
|
|
51
54
|
body = json.dumps({"version": __version__, "ws_port": self._ws_port}).encode("utf-8")
|
|
52
|
-
self.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
self.
|
|
56
|
-
self.end_headers()
|
|
57
|
-
self.wfile.write(body)
|
|
55
|
+
self._send_json(body)
|
|
56
|
+
return
|
|
57
|
+
if self.path == "/api/status":
|
|
58
|
+
self._send_json(self._build_status_body())
|
|
58
59
|
return
|
|
59
60
|
super().do_GET()
|
|
60
61
|
|
|
62
|
+
def _send_json(self, body: bytes) -> None:
|
|
63
|
+
self.send_response(200)
|
|
64
|
+
self.send_header("Content-Type", "application/json")
|
|
65
|
+
self.send_header("Content-Length", str(len(body)))
|
|
66
|
+
self.send_header("Cache-Control", "no-store")
|
|
67
|
+
self.end_headers()
|
|
68
|
+
self.wfile.write(body)
|
|
69
|
+
|
|
70
|
+
def _build_status_body(self) -> bytes:
|
|
71
|
+
"""Les mêmes données techniques que celles déjà affichées dans la
|
|
72
|
+
page de contrôle (écrans connectés, mode actif...), exposées ici
|
|
73
|
+
pour que la fenêtre graphique puisse les afficher aussi sans
|
|
74
|
+
dupliquer la logique de rendu HTML -- voir fletchtime.gui,
|
|
75
|
+
interrogé par sondage périodique plutôt qu'en temps réel (pas
|
|
76
|
+
besoin d'une vraie connexion WebSocket juste pour un affichage de
|
|
77
|
+
statut, voir sa docstring pour le détail de ce choix).
|
|
78
|
+
|
|
79
|
+
Lu directement depuis l'instance MatchServer partagée avec le
|
|
80
|
+
serveur WebSocket (voir fletchtime.runtime.ServerRuntime), depuis
|
|
81
|
+
un thread différent de celui qui la modifie -- en lecture seule,
|
|
82
|
+
sans le verrou asyncio de MatchServer (qui ne serait de toute
|
|
83
|
+
façon pas utilisable depuis ce thread synchrone). Le GIL rend
|
|
84
|
+
chaque lecture individuelle atomique ; au pire, une valeur d'un
|
|
85
|
+
sondage sur deux légèrement obsolète pour un simple affichage de
|
|
86
|
+
statut, jamais une valeur corrompue."""
|
|
87
|
+
if self._match_server is None:
|
|
88
|
+
return json.dumps({"available": False}).encode("utf-8")
|
|
89
|
+
|
|
90
|
+
server = self._match_server
|
|
91
|
+
engine = server.engine
|
|
92
|
+
state = engine.current_state if engine is not None else None
|
|
93
|
+
connected_lanes = sorted(
|
|
94
|
+
{lane for lane in server._display_lanes.values() if lane != "apercu"}
|
|
95
|
+
)
|
|
96
|
+
payload = {
|
|
97
|
+
"available": True,
|
|
98
|
+
"connected_clients": len(server.clients),
|
|
99
|
+
"connected_lanes": connected_lanes,
|
|
100
|
+
"active_mode": server._current_mode_kind,
|
|
101
|
+
"match_phase": state.phase.value if state is not None else None,
|
|
102
|
+
"match_finished": state.finished if state is not None else None,
|
|
103
|
+
"sound_pack": server._sound_pack,
|
|
104
|
+
"password_configured": bool(server._password),
|
|
105
|
+
}
|
|
106
|
+
return json.dumps(payload).encode("utf-8")
|
|
107
|
+
|
|
61
108
|
def translate_path(self, path: str) -> str:
|
|
62
109
|
if path == "/assets" or path.startswith("/assets/"):
|
|
63
110
|
rel = path[len("/assets") :].lstrip("/")
|
|
@@ -74,7 +121,11 @@ class _DualRootHandler(SimpleHTTPRequestHandler):
|
|
|
74
121
|
|
|
75
122
|
|
|
76
123
|
def start_http_server(
|
|
77
|
-
directory: str,
|
|
124
|
+
directory: str,
|
|
125
|
+
port: int,
|
|
126
|
+
assets_dir: str | None = None,
|
|
127
|
+
ws_port: int | None = None,
|
|
128
|
+
match_server=None,
|
|
78
129
|
) -> ThreadingHTTPServer:
|
|
79
130
|
"""``assets_dir`` defaults to ``<directory>/assets`` when not given,
|
|
80
131
|
matching the historical single-root behaviour (dev checkout, or a
|
|
@@ -85,6 +136,12 @@ def start_http_server(
|
|
|
85
136
|
fallback, callers should normally pass the real configured value
|
|
86
137
|
(see ``fletchtime.runtime.ServerRuntime``).
|
|
87
138
|
|
|
139
|
+
``match_server``, if given, is exposed read-only via ``/api/status``
|
|
140
|
+
(see ``_DualRootHandler._build_status_body``) -- ``None`` by default,
|
|
141
|
+
in which case that endpoint reports ``{"available": false}`` rather
|
|
142
|
+
than erroring, since not every caller needs/has one to share (e.g.
|
|
143
|
+
a bare ``start_http_server()`` call in isolation, as some tests do).
|
|
144
|
+
|
|
88
145
|
Returns the bound (but not yet serving) server instance -- the caller
|
|
89
146
|
is expected to run ``.serve_forever()`` on it (typically in a
|
|
90
147
|
background thread) and can call ``.shutdown()`` from any other thread
|
|
@@ -97,5 +154,6 @@ def start_http_server(
|
|
|
97
154
|
directory=directory,
|
|
98
155
|
assets_dir=resolved_assets_dir,
|
|
99
156
|
ws_port=resolved_ws_port,
|
|
157
|
+
match_server=match_server,
|
|
100
158
|
)
|
|
101
159
|
return ThreadingHTTPServer(("0.0.0.0", port), handler)
|
|
@@ -420,50 +420,73 @@ class MatchServer:
|
|
|
420
420
|
# ici puisqu'on mesure un intervalle, pas une heure absolue.
|
|
421
421
|
last_tick = time.monotonic()
|
|
422
422
|
while True:
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
self.engine
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
423
|
+
try:
|
|
424
|
+
await asyncio.sleep(TICK_INTERVAL)
|
|
425
|
+
now = time.monotonic()
|
|
426
|
+
elapsed = now - last_tick
|
|
427
|
+
last_tick = now
|
|
428
|
+
events = []
|
|
429
|
+
async with self._lock:
|
|
430
|
+
if self.engine is not None:
|
|
431
|
+
self.engine.tick(elapsed)
|
|
432
|
+
events = self.engine.pop_pending_events()
|
|
433
|
+
if events:
|
|
434
|
+
state = self.engine.current_state
|
|
435
|
+
for event in events:
|
|
436
|
+
# countdown_tick exclu volontairement : se
|
|
437
|
+
# déclenche une fois par seconde dans les
|
|
438
|
+
# dernières secondes de CHAQUE volée --
|
|
439
|
+
# noierait le journal sans valeur diagnostique
|
|
440
|
+
# ajoutée (le passage à l'orange, lui, est déjà
|
|
441
|
+
# journalisé séparément via warning_orange).
|
|
442
|
+
if event == "countdown_tick":
|
|
443
|
+
continue
|
|
444
|
+
logger.info(
|
|
445
|
+
"Transition : %s (unité %d, volée %d%s, phase=%s)",
|
|
446
|
+
event,
|
|
447
|
+
state.unit_number,
|
|
448
|
+
state.end_number,
|
|
449
|
+
f", {state.current_turn}" if state.current_turn else "",
|
|
450
|
+
state.phase.value,
|
|
451
|
+
)
|
|
452
|
+
# À chaque tick (~5x/seconde), pas seulement
|
|
453
|
+
# périodiquement : un instantané périodique trop
|
|
454
|
+
# espacé (auparavant toutes les 5s) peut être visible
|
|
455
|
+
# par les écrans -- en cas de redémarrage du serveur,
|
|
456
|
+
# la reprise se fait alors sur un temps plus ANCIEN
|
|
457
|
+
# que ce que l'écran affichait déjà (extrapolé
|
|
458
|
+
# localement pendant la coupure, voir display.html et
|
|
459
|
+
# docs/architecture.md), provoquant un retour en
|
|
460
|
+
# arrière visible du chrono affiché. Écrire un petit
|
|
461
|
+
# JSON à chaque tick reste bon marché (fichier local,
|
|
462
|
+
# quelques dizaines d'octets) -- réduit la fenêtre de
|
|
463
|
+
# péremption à ~200ms, imperceptible.
|
|
464
|
+
self._save_snapshot()
|
|
465
|
+
await self.broadcast_state()
|
|
466
|
+
if events:
|
|
467
|
+
await self._broadcast({"type": "events", "events": events})
|
|
468
|
+
except asyncio.CancelledError:
|
|
469
|
+
# Laisse la vraie annulation (arrêt propre du serveur, voir
|
|
470
|
+
# ServerRuntime.stop) se propager normalement -- ne doit
|
|
471
|
+
# surtout pas être avalée par le except Exception ci-dessous
|
|
472
|
+
# (CancelledError hérite de BaseException depuis Python
|
|
473
|
+
# 3.8, donc `except Exception` ne l'attraperait de toute
|
|
474
|
+
# façon pas, mais autant être explicite).
|
|
475
|
+
raise
|
|
476
|
+
except Exception:
|
|
477
|
+
# Ne doit JAMAIS arrêter la boucle silencieusement : sans
|
|
478
|
+
# ce filet, une exception imprévue ici tuait la boucle de
|
|
479
|
+
# décompte pour de bon, sans aucune erreur visible nulle
|
|
480
|
+
# part -- le reste du serveur (connexions, réponse aux
|
|
481
|
+
# commandes) continuait de fonctionner normalement à côté,
|
|
482
|
+
# donc rien ne semblait "en panne" du point de vue d'un
|
|
483
|
+
# client connecté, sinon un chrono figé indéfiniment.
|
|
484
|
+
# Cause précise jamais identifiée avec certitude (voir
|
|
485
|
+
# docs/architecture.md) -- ce filet capture la trace
|
|
486
|
+
# complète pour la prochaine occurrence, et continue au
|
|
487
|
+
# tick suivant plutôt que de laisser tout le décompte
|
|
488
|
+
# mourir en silence.
|
|
489
|
+
logger.exception("Erreur inattendue dans la boucle de décompte")
|
|
467
490
|
|
|
468
491
|
# -- broadcasting ---------------------------------------------------------
|
|
469
492
|
|
|
@@ -91,6 +91,15 @@
|
|
|
91
91
|
<script>
|
|
92
92
|
const params = new URLSearchParams(location.search);
|
|
93
93
|
const lane = params.get("lane") || "?";
|
|
94
|
+
// L'aperçu (dans la page de contrôle) ne doit jamais être une source de
|
|
95
|
+
// son -- c'est un simple coup d'œil visuel pour le responsable du
|
|
96
|
+
// chronométrage, pas un écran destiné aux archers. Sans ça, ouvrir la
|
|
97
|
+
// page de contrôle ET un vrai écran d'affichage sur le même PC fait
|
|
98
|
+
// entendre chaque son deux fois. `mute=1` permet en plus de couper le
|
|
99
|
+
// son sur n'importe quel autre onglet ouvert en trop sur la même
|
|
100
|
+
// machine (ex. pour jeter un œil à une autre lane sans dupliquer le
|
|
101
|
+
// son de l'écran qui joue réellement pour les archers).
|
|
102
|
+
const soundEnabled = lane !== "apercu" && params.get("mute") !== "1";
|
|
94
103
|
let currentLanguage = localStorage.getItem("fletchtime_lang") || "fr";
|
|
95
104
|
document.getElementById("lane").textContent = t(currentLanguage, "lane") + " " + lane;
|
|
96
105
|
|
|
@@ -301,6 +310,7 @@
|
|
|
301
310
|
}
|
|
302
311
|
|
|
303
312
|
function playEventSound(eventName) {
|
|
313
|
+
if (!soundEnabled) return;
|
|
304
314
|
if (eventName in resolvedSoundExtension) {
|
|
305
315
|
playResolvedSound(eventName, resolvedSoundExtension[eventName]);
|
|
306
316
|
} else {
|
|
Binary file
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import tempfile
|
|
2
2
|
import unittest
|
|
3
3
|
from pathlib import Path
|
|
4
|
+
from unittest.mock import patch
|
|
4
5
|
|
|
5
6
|
from fletchtime.server import config_store
|
|
6
7
|
|
|
@@ -14,16 +15,19 @@ class TestConfigStore(unittest.TestCase):
|
|
|
14
15
|
self._original_flint = config_store.FLINT_TOML
|
|
15
16
|
self._original_app = config_store.APP_TOML
|
|
16
17
|
self._original_auth = config_store.AUTH_TOML
|
|
18
|
+
self._original_match_state = config_store.MATCH_STATE_JSON
|
|
17
19
|
config_store.INDOOR_TOML = Path(self._tmpdir.name) / "indoor.toml"
|
|
18
20
|
config_store.FLINT_TOML = Path(self._tmpdir.name) / "flint.toml"
|
|
19
21
|
config_store.APP_TOML = Path(self._tmpdir.name) / "app.toml"
|
|
20
22
|
config_store.AUTH_TOML = Path(self._tmpdir.name) / "auth.toml"
|
|
23
|
+
config_store.MATCH_STATE_JSON = Path(self._tmpdir.name) / "match_state.json"
|
|
21
24
|
|
|
22
25
|
def tearDown(self) -> None:
|
|
23
26
|
config_store.INDOOR_TOML = self._original_indoor
|
|
24
27
|
config_store.FLINT_TOML = self._original_flint
|
|
25
28
|
config_store.APP_TOML = self._original_app
|
|
26
29
|
config_store.AUTH_TOML = self._original_auth
|
|
30
|
+
config_store.MATCH_STATE_JSON = self._original_match_state
|
|
27
31
|
self._tmpdir.cleanup()
|
|
28
32
|
|
|
29
33
|
def test_missing_file_falls_back_to_dataclass_defaults(self) -> None:
|
|
@@ -114,6 +118,44 @@ class TestConfigStore(unittest.TestCase):
|
|
|
114
118
|
app = config_store.load_app_config()
|
|
115
119
|
self.assertNotIn("bogus_field", app)
|
|
116
120
|
|
|
121
|
+
def test_save_match_snapshot_round_trips(self) -> None:
|
|
122
|
+
config_store.save_match_snapshot({"index": 1, "time_left": 100.0})
|
|
123
|
+
self.assertEqual(config_store.load_match_snapshot(), {"index": 1, "time_left": 100.0})
|
|
124
|
+
|
|
125
|
+
def test_save_match_snapshot_survives_a_transient_windows_sharing_violation(self) -> None:
|
|
126
|
+
"""Sous Windows, remplacer le fichier peut échouer avec une
|
|
127
|
+
"violation de partage" si un autre processus l'a ouvert au même
|
|
128
|
+
instant (antivirus, surveillance de fichiers d'un IDE...) --
|
|
129
|
+
constaté en pratique. Ce verrou est transitoire, donc quelques
|
|
130
|
+
tentatives suffisent généralement."""
|
|
131
|
+
original_replace = Path.replace
|
|
132
|
+
call_count = {"n": 0}
|
|
133
|
+
|
|
134
|
+
def flaky_replace(self, target):
|
|
135
|
+
call_count["n"] += 1
|
|
136
|
+
if call_count["n"] == 1:
|
|
137
|
+
raise OSError(32, "verrou transitoire simulé")
|
|
138
|
+
return original_replace(self, target)
|
|
139
|
+
|
|
140
|
+
with patch.object(Path, "replace", flaky_replace):
|
|
141
|
+
config_store.save_match_snapshot({"index": 2, "time_left": 50.0})
|
|
142
|
+
|
|
143
|
+
self.assertEqual(call_count["n"], 2)
|
|
144
|
+
self.assertEqual(config_store.load_match_snapshot(), {"index": 2, "time_left": 50.0})
|
|
145
|
+
|
|
146
|
+
def test_save_match_snapshot_never_raises_even_if_permanently_locked(self) -> None:
|
|
147
|
+
"""Ne doit JAMAIS remonter d'exception : appelé à chaque tick
|
|
148
|
+
(voir MatchServer.tick_loop), une exception ici perturberait
|
|
149
|
+
aussi la diffusion de l'état aux écrans pour ce tick, pour une
|
|
150
|
+
raison qui n'a rien à voir avec eux. Un instantané manqué n'est
|
|
151
|
+
jamais grave -- le tick suivant, ~200ms plus tard, retente."""
|
|
152
|
+
|
|
153
|
+
def always_fails(self, target):
|
|
154
|
+
raise OSError(32, "verrou permanent simulé")
|
|
155
|
+
|
|
156
|
+
with patch.object(Path, "replace", always_fails):
|
|
157
|
+
config_store.save_match_snapshot({"index": 3, "time_left": 25.0}) # ne doit pas lever
|
|
158
|
+
|
|
117
159
|
|
|
118
160
|
if __name__ == "__main__":
|
|
119
161
|
unittest.main()
|
|
@@ -1241,6 +1241,42 @@ class TestMatchServerLogging(unittest.IsolatedAsyncioTestCase):
|
|
|
1241
1241
|
joined = "\n".join(ctx.output)
|
|
1242
1242
|
self.assertNotIn("countdown_tick", joined)
|
|
1243
1243
|
|
|
1244
|
+
async def test_tick_loop_survives_an_unexpected_exception(self) -> None:
|
|
1245
|
+
"""Filet de sécurité contre un bug signalé en pratique : le
|
|
1246
|
+
chrono se figeait indéfiniment sans aucune erreur visible, le
|
|
1247
|
+
reste du serveur (connexions, commandes) continuant de
|
|
1248
|
+
fonctionner normalement à côté -- symptôme cohérent avec une
|
|
1249
|
+
exception imprévue tuant silencieusement la boucle. Ce test
|
|
1250
|
+
vérifie qu'une exception injectée dans tick() n'arrête plus la
|
|
1251
|
+
boucle : elle continue au tick suivant, et l'exception est
|
|
1252
|
+
journalisée avec sa trace complète plutôt que d'être avalée."""
|
|
1253
|
+
await self.server.handle_command(json.dumps({"action": "start_indoor"}), self.control)
|
|
1254
|
+
|
|
1255
|
+
original_tick = self.server.engine.tick
|
|
1256
|
+
call_count = {"n": 0}
|
|
1257
|
+
|
|
1258
|
+
def broken_tick(dt):
|
|
1259
|
+
call_count["n"] += 1
|
|
1260
|
+
if call_count["n"] == 2:
|
|
1261
|
+
raise RuntimeError("exception simulée")
|
|
1262
|
+
return original_tick(dt)
|
|
1263
|
+
|
|
1264
|
+
self.server.engine.tick = broken_tick
|
|
1265
|
+
|
|
1266
|
+
with self.assertLogs("fletchtime.server", level="ERROR") as ctx:
|
|
1267
|
+
task = asyncio.ensure_future(self.server.tick_loop())
|
|
1268
|
+
await asyncio.sleep(1.0)
|
|
1269
|
+
task.cancel()
|
|
1270
|
+
try:
|
|
1271
|
+
await task
|
|
1272
|
+
except asyncio.CancelledError:
|
|
1273
|
+
pass
|
|
1274
|
+
|
|
1275
|
+
self.assertGreater(call_count["n"], 2) # la boucle a continué après l'échec
|
|
1276
|
+
joined = "\n".join(ctx.output)
|
|
1277
|
+
self.assertIn("Erreur inattendue dans la boucle de décompte", joined)
|
|
1278
|
+
self.assertIn("RuntimeError", joined) # la trace complète est bien journalisée
|
|
1279
|
+
|
|
1244
1280
|
|
|
1245
1281
|
if __name__ == "__main__":
|
|
1246
1282
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/targets/flint_20cm_4spot.jpg
RENAMED
|
File without changes
|
{fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/targets/flint_35cm_1spot.jpg
RENAMED
|
File without changes
|
{fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/targets/indoor_compound.jpg
RENAMED
|
File without changes
|
{fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/targets/indoor_recurve.jpg
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|