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.
Files changed (99) hide show
  1. {fletchtime-0.2.4/src/fletchtime.egg-info → fletchtime-0.2.5}/PKG-INFO +1 -1
  2. {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/architecture.md +81 -0
  3. {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/roadmap.md +42 -0
  4. {fletchtime-0.2.4 → fletchtime-0.2.5}/fletchtime.spec +7 -0
  5. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/_version.py +3 -3
  6. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/gui.py +110 -4
  7. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/runtime.py +12 -2
  8. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/server/config_store.py +41 -4
  9. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/server/http_static.py +66 -8
  10. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/server/match_server.py +67 -44
  11. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/display.html +10 -0
  12. fletchtime-0.2.5/src/fletchtime/web/logo.ico +0 -0
  13. {fletchtime-0.2.4 → fletchtime-0.2.5/src/fletchtime.egg-info}/PKG-INFO +1 -1
  14. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime.egg-info/SOURCES.txt +1 -0
  15. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime.egg-info/scm_file_list.json +1 -0
  16. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime.egg-info/scm_version.json +2 -2
  17. {fletchtime-0.2.4 → fletchtime-0.2.5}/tests/test_config_store.py +42 -0
  18. {fletchtime-0.2.4 → fletchtime-0.2.5}/tests/test_match_server.py +36 -0
  19. {fletchtime-0.2.4 → fletchtime-0.2.5}/.github/workflows/build.yml +0 -0
  20. {fletchtime-0.2.4 → fletchtime-0.2.5}/.github/workflows/docs.yml +0 -0
  21. {fletchtime-0.2.4 → fletchtime-0.2.5}/.github/workflows/test.yml +0 -0
  22. {fletchtime-0.2.4 → fletchtime-0.2.5}/.gitignore +0 -0
  23. {fletchtime-0.2.4 → fletchtime-0.2.5}/CONTRIBUTING.md +0 -0
  24. {fletchtime-0.2.4 → fletchtime-0.2.5}/LICENSE +0 -0
  25. {fletchtime-0.2.4 → fletchtime-0.2.5}/README.md +0 -0
  26. {fletchtime-0.2.4 → fletchtime-0.2.5}/REMERCIEMENTS.md +0 -0
  27. {fletchtime-0.2.4 → fletchtime-0.2.5}/config/app.toml +0 -0
  28. {fletchtime-0.2.4 → fletchtime-0.2.5}/config/flint.toml +0 -0
  29. {fletchtime-0.2.4 → fletchtime-0.2.5}/config/indoor.toml +0 -0
  30. {fletchtime-0.2.4 → fletchtime-0.2.5}/demo.py +0 -0
  31. {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/_static/logo.svg +0 -0
  32. {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/api-reference.md +0 -0
  33. {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/conf.py +0 -0
  34. {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/dev-guide/index.md +0 -0
  35. {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/index.md +0 -0
  36. {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/premier-club.md +0 -0
  37. {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/remerciements.md +0 -0
  38. {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/requirements.txt +0 -0
  39. {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/specifications.md +0 -0
  40. {fletchtime-0.2.4 → fletchtime-0.2.5}/docs/user-guide/index.md +0 -0
  41. {fletchtime-0.2.4 → fletchtime-0.2.5}/pyproject.toml +0 -0
  42. {fletchtime-0.2.4 → fletchtime-0.2.5}/run_server.py +0 -0
  43. {fletchtime-0.2.4 → fletchtime-0.2.5}/run_tests.py +0 -0
  44. {fletchtime-0.2.4 → fletchtime-0.2.5}/scripts/generate_classic_sounds.py +0 -0
  45. {fletchtime-0.2.4 → fletchtime-0.2.5}/setup.cfg +0 -0
  46. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/__init__.py +0 -0
  47. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/__main__.py +0 -0
  48. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/__init__.py +0 -0
  49. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/engine.py +0 -0
  50. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/models.py +0 -0
  51. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/modes/__init__.py +0 -0
  52. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/modes/base.py +0 -0
  53. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/modes/flint.py +0 -0
  54. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/modes/indoor.py +0 -0
  55. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/sequence.py +0 -0
  56. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/engine/turn_modes.py +0 -0
  57. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/logging_setup.py +0 -0
  58. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/server/__init__.py +0 -0
  59. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/server/ws_server.py +0 -0
  60. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/__init__.py +0 -0
  61. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/banners/README.md +0 -0
  62. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/club/README.md +0 -0
  63. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/README.md +0 -0
  64. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/countdown_tick.wav +0 -0
  65. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/emergency_end.wav +0 -0
  66. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/emergency_start.wav +0 -0
  67. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/end_of_match.wav +0 -0
  68. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/end_of_volee.wav +0 -0
  69. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/pause_end.wav +0 -0
  70. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/pause_start.wav +0 -0
  71. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/prep_start.wav +0 -0
  72. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/shoot_start.wav +0 -0
  73. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/sounds/packs/classic/warning_orange.wav +0 -0
  74. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/targets/flint_20cm_4spot.jpg +0 -0
  75. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/targets/flint_35cm_1spot.jpg +0 -0
  76. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/targets/indoor_compound.jpg +0 -0
  77. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/_defaults/targets/indoor_recurve.jpg +0 -0
  78. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/config.html +0 -0
  79. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/control.html +0 -0
  80. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/i18n.js +0 -0
  81. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/index.html +0 -0
  82. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/logo.svg +0 -0
  83. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/manual.html +0 -0
  84. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/screenshots/config.png +0 -0
  85. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/screenshots/control.png +0 -0
  86. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/screenshots/display.png +0 -0
  87. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/screenshots/index.png +0 -0
  88. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime/web/theme.js +0 -0
  89. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime.egg-info/dependency_links.txt +0 -0
  90. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime.egg-info/entry_points.txt +0 -0
  91. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime.egg-info/requires.txt +0 -0
  92. {fletchtime-0.2.4 → fletchtime-0.2.5}/src/fletchtime.egg-info/top_level.txt +0 -0
  93. {fletchtime-0.2.4 → fletchtime-0.2.5}/tests/test_engine.py +0 -0
  94. {fletchtime-0.2.4 → fletchtime-0.2.5}/tests/test_flint_mode.py +0 -0
  95. {fletchtime-0.2.4 → fletchtime-0.2.5}/tests/test_indoor_mode.py +0 -0
  96. {fletchtime-0.2.4 → fletchtime-0.2.5}/tests/test_main_cli.py +0 -0
  97. {fletchtime-0.2.4 → fletchtime-0.2.5}/tests/test_runtime.py +0 -0
  98. {fletchtime-0.2.4 → fletchtime-0.2.5}/web/assets/banners/README.md +0 -0
  99. {fletchtime-0.2.4 → fletchtime-0.2.5}/web/assets/sounds/packs/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fletchtime
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: Chronométrage de compétitions d'archerie FFTL (Indoor, Flint) -- serveur + interfaces web incluses
5
5
  License: GPL-3.0-or-later
6
6
  Requires-Python: >=3.11
@@ -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.4'
22
- __version_tuple__ = version_tuple = (0, 2, 4)
21
+ __version__ = version = '0.2.5'
22
+ __version_tuple__ = version_tuple = (0, 2, 5)
23
23
 
24
- __commit_id__ = commit_id = 'g5efc3a10b'
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 journal de la fenêtre,
231
- # pas seulement dans le fichier persistant.
232
- self.log_file = configure_logging(self.data_root / "logs")
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(run_ws_server(self.ws_port, self._ws_stop_event))
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 sous
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
- tmp_path.write_text(json.dumps(data), encoding="utf-8")
264
- tmp_path.replace(MATCH_STATE_JSON)
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__(self, *args, directory: str, assets_dir: str, ws_port: int, **kwargs) -> None:
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.send_response(200)
53
- self.send_header("Content-Type", "application/json")
54
- self.send_header("Content-Length", str(len(body)))
55
- self.send_header("Cache-Control", "no-store")
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, port: int, assets_dir: str | None = None, ws_port: int | None = None
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
- await asyncio.sleep(TICK_INTERVAL)
424
- now = time.monotonic()
425
- elapsed = now - last_tick
426
- last_tick = now
427
- events = []
428
- async with self._lock:
429
- if self.engine is not None:
430
- self.engine.tick(elapsed)
431
- events = self.engine.pop_pending_events()
432
- if events:
433
- state = self.engine.current_state
434
- for event in events:
435
- # countdown_tick exclu volontairement : se
436
- # déclenche une fois par seconde dans les
437
- # dernières secondes de CHAQUE volée --
438
- # noierait le journal sans valeur diagnostique
439
- # ajoutée (le passage à l'orange, lui, est déjà
440
- # journalisé séparément via warning_orange).
441
- if event == "countdown_tick":
442
- continue
443
- logger.info(
444
- "Transition : %s (unité %d, volée %d%s, phase=%s)",
445
- event,
446
- state.unit_number,
447
- state.end_number,
448
- f", {state.current_turn}" if state.current_turn else "",
449
- state.phase.value,
450
- )
451
- # À chaque tick (~5x/seconde), pas seulement
452
- # périodiquement : un instantané périodique trop
453
- # espacé (auparavant toutes les 5s) peut être visible
454
- # par les écrans -- en cas de redémarrage du serveur,
455
- # la reprise se fait alors sur un temps plus ANCIEN
456
- # que ce que l'écran affichait déjà (extrapolé
457
- # localement pendant la coupure, voir display.html et
458
- # docs/architecture.md), provoquant un retour en
459
- # arrière visible du chrono affiché. Écrire un petit
460
- # JSON à chaque tick reste bon marché (fichier local,
461
- # quelques dizaines d'octets) -- réduit la fenêtre de
462
- # péremption à ~200ms, imperceptible.
463
- self._save_snapshot()
464
- await self.broadcast_state()
465
- if events:
466
- await self._broadcast({"type": "events", "events": events})
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 {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fletchtime
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: Chronométrage de compétitions d'archerie FFTL (Indoor, Flint) -- serveur + interfaces web incluses
5
5
  License: GPL-3.0-or-later
6
6
  Requires-Python: >=3.11
@@ -61,6 +61,7 @@ src/fletchtime/web/control.html
61
61
  src/fletchtime/web/display.html
62
62
  src/fletchtime/web/i18n.js
63
63
  src/fletchtime/web/index.html
64
+ src/fletchtime/web/logo.ico
64
65
  src/fletchtime/web/logo.svg
65
66
  src/fletchtime/web/manual.html
66
67
  src/fletchtime/web/theme.js
@@ -22,6 +22,7 @@
22
22
  "src/fletchtime/web/manual.html",
23
23
  "src/fletchtime/web/config.html",
24
24
  "src/fletchtime/web/i18n.js",
25
+ "src/fletchtime/web/logo.ico",
25
26
  "src/fletchtime/web/logo.svg",
26
27
  "src/fletchtime/web/__init__.py",
27
28
  "src/fletchtime/web/screenshots/config.png",
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "0.2.4",
2
+ "tag": "0.2.5",
3
3
  "distance": 0,
4
- "node": "g5efc3a10b889b080631cf37c9c761d09919b643a",
4
+ "node": "ga6e6ad5647b6d656d79af17dd66fa88787867232",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-07-20"
@@ -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