From c8a8873ef629707dbab3ed3649b69ac7902d0767 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 4 Aug 2026 21:40:01 +0000
Subject: [PATCH 01/24] Release : les archives publiques ne portaient aucune
mention legale
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
docs/TIERS.md affirme deux fois etre « inclus dans chaque archive de
release » et « livre avec chaque archive ». Aucune des quatre archives
de release.yml ne le contenait, ni LICENSE.
Le correctif de la v3.4.1 avait ete pose sur ci.yml (artefact de
l'onglet Actions, avec le commentaire « obligation LGPL (GStreamer) et
point de decision GPL ») et jamais sur release.yml -- c'est-a-dire
partout SAUF sur les fichiers que les gens telechargent reellement,
puisque release.yml dit lui-meme servir la page « publique, sans compte
GitHub, contrairement aux artefacts de l'onglet Actions ».
Consequence concrete : la mention IJG, que la licence de jpeg-encoder
EXIGE de faire accompagner le logiciel, ne partait avec aucun binaire
publie. Et le pack le plus expose -- windows-x64-gstreamer, qui embarque
les DLL LGPL et les plugins GPL de gst-plugins-ugly -- partait lui aussi
sans rien.
Les quatre archives embarquent desormais docs/TIERS.md et LICENSE. Les
deux archives Windows recuperent au passage deploy/installer-windows.ps1,
que le README presente comme l'installateur a profils alors qu'il
n'etait livre nulle part.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
.github/workflows/release.yml | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3c1b534..ca6f978 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -53,6 +53,11 @@ jobs:
# installations permanentes : ./deploy/install.sh
cp deploy/install.sh deploy/smoke.sh staging/deploy/
cp -r deploy/systemd staging/deploy/
+ # Mentions légales : docs/TIERS.md dit lui-même être « inclus dans
+ # chaque archive de release » (mention IJG obligatoire, LGPL de
+ # GStreamer). Sans ces deux fichiers, l'archive publique ne tient
+ # pas cette promesse.
+ cp docs/TIERS.md LICENSE staging/
tar czf toolbox-node-linux-x64.tar.gz -C staging .
- uses: softprops/action-gh-release@v2
with:
@@ -70,7 +75,9 @@ jobs:
run: |
cargo build --release -p toolbox-node
New-Item -ItemType Directory staging | Out-Null
- Copy-Item target/release/toolbox-node.exe, deploy/run-portable.bat, deploy/install-autostart-windows.bat, node.toml.example staging/
+ # docs/TIERS.md + LICENSE : mentions légales que le fichier lui-même
+ # annonce comme livrées avec chaque archive.
+ Copy-Item target/release/toolbox-node.exe, deploy/run-portable.bat, deploy/install-autostart-windows.bat, deploy/installer-windows.ps1, node.toml.example, docs/TIERS.md, LICENSE staging/
Compress-Archive -Path staging/* -DestinationPath toolbox-node-windows-x64.zip
- uses: softprops/action-gh-release@v2
with:
@@ -100,7 +107,10 @@ jobs:
cargo build --release -p toolbox-node --features gstreamer
mkdir -p dist/lib
cp target/release/toolbox-node.exe dist/
- cp deploy/run-portable.bat deploy/install-autostart-windows.bat node.toml.example dist/
+ cp deploy/run-portable.bat deploy/install-autostart-windows.bat deploy/installer-windows.ps1 node.toml.example dist/
+ # Obligation la plus forte de toutes les archives : ce pack embarque
+ # les DLL et plugins GStreamer (LGPL, et GPL pour gst-plugins-ugly).
+ cp docs/TIERS.md LICENSE dist/
cp "$GST"/bin/*.dll dist/
cp -r "$GST"/lib/gstreamer-1.0 dist/lib/
rm -f dist/lib/gstreamer-1.0/*.pdb dist/lib/gstreamer-1.0/gstpython.dll 2>/dev/null || true
@@ -134,6 +144,7 @@ jobs:
# profil + réglages (./deploy/install.sh depuis l'archive).
cp deploy/install.sh deploy/smoke.sh staging/deploy/
cp -r deploy/systemd staging/deploy/
+ cp docs/TIERS.md LICENSE staging/
tar czf toolbox-node-raspberrypi-arm64.tar.gz -C staging .
- uses: softprops/action-gh-release@v2
with:
From 68d5677f6608598210793233406e8c3c68804334 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 4 Aug 2026 21:59:43 +0000
Subject: [PATCH 02/24] Dire la verite sur ce que les binaires publies savent
faire
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Premier lot de l'audit des zones jamais couvertes. Toutes ces corrections
repondent a la meme question : ce que Lanterne DIT correspond-il a ce qu'il
FAIT ?
1. LES BINAIRES LINUX ET PI NE LISENT AUCUNE VIDEO. `gstreamer` n'est pas
une feature par defaut : linux-x64 et windows-x64 sont compiles sans, et
raspberrypi-arm64 part en --no-default-features -- donc sans fenetre de
sortie, sans MIDI et sans GStreamer. Cette archive ne peut RIEN projeter.
Le manuel disait pourtant, pour Ubuntu comme pour le Pi, qu'il suffisait
d'installer les paquets gstreamer1.0-* : Pym aurait suivi la procedure,
branche le videoprojecteur et vu du noir. La carte Windows legere, elle,
annoncait honnetement « sans lecture video » depuis toujours.
Les cartes du manuel, la ligne de depannage et les notes de release
disent maintenant ce que chaque archive sait faire, et comment obtenir
la video (compiler avec --features gstreamer, mode KMS sur Pi OS Lite).
2. UN REGLAGE MORT DEPUIS TOUJOURS. `[paths] shaders` etait documente comme
reglable, cree par les trois installeurs... et lu par personne : les
shaders sont embarques par include_str!. Champ retire ; les installeurs
creent `luts/` a la place, qui lui est bien reel. Un `shaders = …` reste
dans un ancien node.toml est ignore sans bruit (pas de
deny_unknown_fields), et node.toml.example precise que `luts/` n'est PAS
deplacable.
3. REINSTALLER SOUS WINDOWS CASSAIT LE PACK VIDEO. `Copy-Item lib cible`
cree cible\lib quand cible existe deja : des la deuxieme installation,
les plugins GStreamer atterrissaient dans lib\lib\gstreamer-1.0, la ou
le binaire ne regarde jamais, pendant que les DLL de la racine etaient
bien mises a jour. Version melangee, panne difficile a comprendre. On
copie desormais le CONTENU, apres avoir vide lib\ (sinon les plugins
fantomes de l'ancienne version restaient).
4. UN PREFIXE RELATIF PRODUISAIT UNE UNITE SYSTEMD INVALIDE. systemd exige
un chemin absolu ; mkdir, install et chown s'accommodent tres bien d'un
chemin relatif. `./install.sh --prefix lanterne` se deroulait donc de
bout en bout sans un mot, et seul `systemctl start` echouait, plus tard.
Le prefixe est rendu absolu des l'analyse des options. Verifie en reel :
« Prefixe relatif converti en chemin absolu : /…/lanterne ».
5. METTRE A JOUR LAISSAIT TOURNER L'ANCIEN BINAIRE. install.sh remplacait
l'executable sans jamais toucher au service : le processus en cours
gardait l'ancien inode, et le conseil final `systemctl start` ne faisait
rien puisque le service tournait deja. La mise a jour semblait faite et
ne l'etait pas. Le service actif est desormais redemarre explicitement.
Le README decrivait par ailleurs un depot qui n'existe plus : dossier
webui/ fantome, « GStreamer a venir », et quatre crates bien reelles
absentes (render, gst, artnet, ndi).
Verifie : 254 tests, clippy -D warnings, fmt, shellcheck. Installation a
froid rejouee en reel (prefixe relatif, luts/ cree, node.toml genere).
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
.github/workflows/release.yml | 12 +++++++++---
README.md | 28 +++++++++++++++++++++------
crates/core/src/config.rs | 9 +++++++--
deploy/install.sh | 28 +++++++++++++++++++++++++--
deploy/installer-windows.ps1 | 13 +++++++++++--
deploy/run-portable.bat | 2 +-
deploy/run-portable.sh | 2 +-
docs/manuel.html | 36 +++++++++++++++++++++++------------
node.toml.example | 3 ++-
9 files changed, 103 insertions(+), 30 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ca6f978..1d2d2c9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -25,10 +25,16 @@ jobs:
**Quel fichier télécharger ?**
- Windows : `toolbox-node-windows-x64-gstreamer.zip` — pack
complet avec la vidéo, dézipper et lancer, rien à installer.
+ **C'est le seul binaire publié qui lit des vidéos.**
- Windows sans vidéo (léger) : `toolbox-node-windows-x64.zip`.
- - Ubuntu/Debian : `toolbox-node-linux-x64.tar.gz`
- (+ paquets gstreamer1.0-*, voir le manuel).
- - Raspberry Pi 4/5 : `toolbox-node-raspberrypi-arm64.tar.gz`.
+ - Ubuntu/Debian : `toolbox-node-linux-x64.tar.gz` — mires,
+ mapping, calibrage et OSC/MIDI, **sans lecture vidéo** (les
+ paquets `gstreamer1.0-*` n'y changent rien : pour lire des
+ vidéos, recompiler avec `--features gstreamer`).
+ - Raspberry Pi 4/5 : `toolbox-node-raspberrypi-arm64.tar.gz` —
+ **pilotage seul, cette archive ne projette rien** (compilée
+ sans fenêtre de sortie, sans MIDI et sans GStreamer). Pour
+ projeter depuis un Pi, compiler sur le Pi — voir le manuel.
Manuel : `docs/manuel.html` du dépôt. Journal des changements :
`CHANGELOG.md`.
diff --git a/README.md b/README.md
index 950cbdf..68dfd2f 100644
--- a/README.md
+++ b/README.md
@@ -98,22 +98,38 @@ tests) ; événements temps réel sur `GET /ws`.
```
crates/core/ bus de commandes, état validé, presets, médiathèque,
+ séquenceur, fondus, interrupteurs de fonctions,
ring buffer de logs, config [fait, testé]
crates/engine/ homographie (validée vs référence Python), paramètres
- de rendu (rotation/flip/crop/couleur), player + backend
- simulé ; GStreamer à venir [fait, testé]
-crates/control-http/ REST + WebSocket + web UI embarquée + monitoring
+ de rendu (rotation/flip/crop/couleur), LUT .cube,
+ compositeur, player + backend simulé [fait, testé]
+crates/render/ fenêtre de sortie (winit) : rendu GPU wgpu avec repli
+ automatique sur le peintre CPU [fait, testé]
+crates/gst/ backend vidéo GStreamer (playbin3), sortie RTSP,
+ sortie DRM/KMS — feature `gstreamer` [fait, testé]
+crates/artnet/ console lumières Art-Net (faders, scènes, chasers)
[fait, testé]
+crates/ndi/ entrée et sortie NDI (SDK chargé à l'exécution)
+ [fait, testé]
+crates/control-http/ REST + WebSocket + web UI embarquée + OSCQuery +
+ monitoring + mise à jour OTA [fait, testé]
crates/control-osc/ OSC UDP (Chataigne) [fait, testé]
crates/control-midi/ notes/CC → commandes (bindings TOML) [fait, testé*]
crates/node/ binaire : assemble les modules [fait]
-deploy/ installeur, systemd, portable [fait]
+deploy/ installeurs à profils, systemd, portable
+ [fait]
+docs/ manuel utilisateur, composants tiers [fait]
tools/bench/ bench décodage à lancer sur les Pi [fait]
-webui/ (réservé : UI Svelte phase suivante — l'UI V1 vanilla
- est embarquée dans control-http)
+tools/endurance/ harnais d'endurance (collecteurs + dépouillement)
+ [fait]
+tools/mapping/ référence Python de l'homographie [fait]
```
\* la traduction MIDI est testée ; l'ouverture du port reste à valider sur matériel.
+L'UI web est un **fichier unique** embarqué dans le binaire
+(`crates/control-http/assets/index.html`) — pas de build front, pas de
+dossier `webui/`.
+
## Développement
```bash
diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs
index d792423..350a2ec 100644
--- a/crates/core/src/config.rs
+++ b/crates/core/src/config.rs
@@ -430,12 +430,18 @@ impl Default for Limits {
}
/// Chemins des données. Relatifs au dossier de travail → portable par défaut.
+///
+/// Pas de champ `shaders` : les shaders sont embarqués dans le binaire par
+/// `include_str!`. Le réglage a existé, n'a jamais été lu par personne, et
+/// laissait croire qu'on pouvait déposer des shaders dans un dossier. Un
+/// `shaders = …` resté dans un ancien `node.toml` est simplement ignoré
+/// (aucun `deny_unknown_fields` ici). Le dossier `luts/`, lui, est bien réel
+/// mais vit toujours à côté du binaire — il n'est pas déplaçable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Paths {
pub media: PathBuf,
pub presets: PathBuf,
- pub shaders: PathBuf,
pub logs: PathBuf,
}
@@ -444,7 +450,6 @@ impl Default for Paths {
Self {
media: PathBuf::from("media"),
presets: PathBuf::from("presets"),
- shaders: PathBuf::from("shaders"),
logs: PathBuf::from("logs"),
}
}
diff --git a/deploy/install.sh b/deploy/install.sh
index ae5e414..90fa5ad 100755
--- a/deploy/install.sh
+++ b/deploy/install.sh
@@ -38,6 +38,19 @@ done
say() { printf '\033[1;36m>>\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31m!!\033[0m %s\n' "$*" >&2; exit 1; }
+# systemd EXIGE un chemin absolu dans WorkingDirectory= et ExecStart=. Or
+# mkdir, install et chown acceptent parfaitement un préfixe relatif : sans
+# cette normalisation, `--prefix lanterne` déroulait toute l'installation
+# sans le moindre message, et seul `systemctl start` échouait ensuite, sur
+# une unité que systemd refusait de charger.
+case "$PREFIX" in
+ /*) ;;
+ *) PREFIX="$(mkdir -p "$PREFIX" 2> /dev/null; cd "$PREFIX" 2> /dev/null && pwd)" \
+ || fail "préfixe inutilisable : $PREFIX"
+ [ -n "$PREFIX" ] || fail "impossible de rendre le préfixe absolu"
+ say "Préfixe relatif converti en chemin absolu : $PREFIX" ;;
+esac
+
# Échappe une valeur pour le REMPLACEMENT d'un sed dont le délimiteur est
# « | » : & (rappel du motif), | (délimiteur) et \ doivent être protégés,
# sinon un préfixe contenant l'un d'eux produirait une unité systemd cassée.
@@ -197,7 +210,7 @@ if { [ -d "$PREFIX" ] && [ ! -w "$PREFIX" ]; } || { [ ! -d "$PREFIX" ] && [ ! -w
say "Le préfixe demande les droits administrateur (sudo)."
fi
-run mkdir -p "$PREFIX" "$PREFIX/media" "$PREFIX/presets" "$PREFIX/logs" "$PREFIX/shaders"
+run mkdir -p "$PREFIX" "$PREFIX/media" "$PREFIX/presets" "$PREFIX/logs" "$PREFIX/luts"
run install -m 755 "$BINARY" "$PREFIX/toolbox-node"
TMP_CONF="$(mktemp)"
@@ -267,7 +280,18 @@ if command -v systemctl > /dev/null 2>&1; then
| sudo tee /etc/systemd/system/toolbox-node.service > /dev/null
sudo systemctl daemon-reload
sudo systemctl enable toolbox-node.service
- say "Service installé. Démarrage : sudo systemctl start toolbox-node"
+ # Réinstallation par-dessus un node qui tourne : `install` a remplacé
+ # le binaire, mais le processus en cours exécute toujours l'ANCIEN
+ # code (l'inode d'origine survit tant qu'il est ouvert). Sans ce
+ # redémarrage, le script concluait « Installation terminée » et le
+ # conseil `systemctl start` ne faisait rien — le service étant déjà
+ # actif. La mise à jour semblait faite et ne l'était pas.
+ if sudo systemctl is-active --quiet toolbox-node.service; then
+ say "Service déjà actif : redémarrage sur le nouveau binaire."
+ sudo systemctl restart toolbox-node.service
+ else
+ say "Service installé. Démarrage : sudo systemctl start toolbox-node"
+ fi
say "Logs : journalctl -u toolbox-node -f (ou la page Logs de la web UI)"
fi
fi
diff --git a/deploy/installer-windows.ps1 b/deploy/installer-windows.ps1
index f2e79a8..6f54981 100644
--- a/deploy/installer-windows.ps1
+++ b/deploy/installer-windows.ps1
@@ -61,7 +61,7 @@ foreach ($cand in @("$ici\toolbox-node.exe", "$ici\dist\toolbox-node.exe")) {
}
New-Item -ItemType Directory -Force $Dossier | Out-Null
-foreach ($sous in @("media", "presets", "logs", "shaders")) {
+foreach ($sous in @("media", "presets", "logs", "luts")) {
New-Item -ItemType Directory -Force (Join-Path $Dossier $sous) | Out-Null
}
@@ -73,7 +73,16 @@ if ($exeLocal) {
if (Test-Path $libLocal) {
Dire "Pack video detecte : copie des DLL GStreamer"
Copy-Item (Join-Path (Split-Path -Parent $exeLocal) "*.dll") $Dossier -Force
- Copy-Item $libLocal (Join-Path $Dossier "lib") -Recurse -Force
+ # Copier le CONTENU, pas le dossier : `Copy-Item dossier cible` cree
+ # cible\lib quand cible existe deja. A la deuxieme installation, les
+ # plugins atterrissaient donc dans lib\lib\gstreamer-1.0, ou le
+ # binaire ne regarde jamais -- DLL a jour a la racine, plugins
+ # perimes en dessous. On repart d'un lib\ propre pour ne pas garder
+ # les plugins fantomes de la version precedente.
+ $libCible = Join-Path $Dossier "lib"
+ if (Test-Path $libCible) { Remove-Item $libCible -Recurse -Force }
+ New-Item -ItemType Directory -Force $libCible | Out-Null
+ Copy-Item (Join-Path $libLocal "*") $libCible -Recurse -Force
}
} else {
$reponse = "o"
diff --git a/deploy/run-portable.bat b/deploy/run-portable.bat
index ca8a394..b508b63 100644
--- a/deploy/run-portable.bat
+++ b/deploy/run-portable.bat
@@ -5,6 +5,6 @@ cd /d "%~dp0"
if not exist media mkdir media
if not exist presets mkdir presets
if not exist logs mkdir logs
-if not exist shaders mkdir shaders
+if not exist luts mkdir luts
toolbox-node.exe %*
pause
diff --git a/deploy/run-portable.sh b/deploy/run-portable.sh
index 94b322d..96641b3 100755
--- a/deploy/run-portable.sh
+++ b/deploy/run-portable.sh
@@ -3,5 +3,5 @@
# Décompressez le binaire à côté de ce script et double-cliquez / lancez-le.
set -euo pipefail
cd "$(dirname "$0")"
-mkdir -p media presets logs shaders
+mkdir -p media presets logs luts
exec ./toolbox-node "$@"
diff --git a/docs/manuel.html b/docs/manuel.html
index a79d850..8d7f4a8 100644
--- a/docs/manuel.html
+++ b/docs/manuel.html
@@ -160,7 +160,7 @@
-
LanterneManuel · v3.3 · projet Toolbox
+
LanterneManuel · v3.5 · projet Toolbox
Vue d'ensemble
Toutes les fonctions
@@ -181,7 +181,7 @@
- Lanterne · version 3.3
+ Lanterne · version 3.5
Projeter une vidéo, exactement là où il faut.
Lanterne (projet Toolbox) transforme un ordinateur — ou un
Raspberry Pi — en node de projection : lecture vidéo, mapping 4 coins et mesh warp,
@@ -300,17 +300,26 @@
Windows — lé
Pour piloter ou tester sans projeter.
Sans lecture vidéo (mires, mapping, calibrage, OSC/MIDI) :
+ comme le pack Windows léger, ce binaire est compilé sans GStreamer —
+ installer les paquets gstreamer1.0-* n'y changera rien.
+ Pour lire des vidéos sur Linux, compiler sur place :
+ sudo apt install libgstreamer1.0-dev
+ libgstreamer-plugins-base1.0-dev libasound2-dev puis
+ cargo build --release -p toolbox-node --features gstreamer.
-
Raspberry Pi 4 / 5
+
Raspberry Pi 4 / 5
toolbox-node-raspberrypi-arm64
-
Mêmes paquets GStreamer que Ubuntu. L'installeur
- deploy/install.sh configure le service systemd
- (démarrage au boot, relance en cas de crash).
+
Pilotage seul — cette archive ne projette rien. Compilée en
+ croisé sans fenêtre de sortie, sans MIDI et sans GStreamer : l'UI, le
+ mapping, les presets et l'OSC répondent, mais aucune image ne sort sur
+ le vidéoprojecteur. Pour projeter depuis un Pi, compiler sur le Pi
+ (--features gstreamer, et [output] mode = "kms"
+ sur Raspberry Pi OS Lite). deploy/install.sh configure le
+ service systemd (démarrage au boot, relance en cas de crash).
@@ -532,6 +541,9 @@
Contrôle OSC
/preset/fade
nom, secondes
Fondu vers le preset : coins, couleur, effets et volume glissent — la lecture continue
/sync/arm
—
Arme : média prêt, pause à 0 (à envoyer à tous les nodes)
/sync/startAt
heure Unix (double)
Départ commun au timestamp — horloges NTP partagées
+
/cue/go
nom
Déclenche une cue du séquenceur
+
/dmx/scene
nom
Rappelle une scène de la console lumières
+
/dmx/chaser
nom (ou rien)
Lance un chaser ; sans argument, arrête celui en cours
Auto-découverte OSCQuery (Chataigne)
@@ -670,8 +682,8 @@
Dépannage
Fenêtre de sortie noire
Rien à projeter
Normal sans mire ni lecture en cours. Choisis une mire (onglet Mapping) ou lance un média.
-
La vidéo ne se lance pas
Binaire léger ou GStreamer absent
-
Prends le pack …-gstreamer (Windows) ou installe les paquets GStreamer (Linux/Pi). Les logs indiquent « backend simulé » dans ce cas.
+
La vidéo ne se lance pas
Binaire compilé sans GStreamer
+
Les logs indiquent « backend simulé » : la position avance, mais rien n'est décodé. Seul le pack Windows …-gstreamer embarque le décodage. Sur Linux/Pi, les paquets gstreamer1.0-* ne suffisent pas — il faut recompiler avec --features gstreamer (voir Installation).
Image saccadée
Rendu processeur actif
Vérifie « rendu GPU actif » dans la page Logs. Sinon : pilotes graphiques (Vulkan) à mettre à jour ; le badge img/s permet de comparer.
UI inaccessible depuis le téléphone
Pare-feu ou mauvaise IP
@@ -690,7 +702,7 @@
Dépannage
diff --git a/node.toml.example b/node.toml.example
index a9bd29a..2e898cd 100644
--- a/node.toml.example
+++ b/node.toml.example
@@ -38,8 +38,9 @@ oscquery = 8081 # auto-découverte des paramètres OSC (Chataigne : hôte + c
# Relatifs au dossier de travail → la version portable tient dans un dossier.
media = "media"
presets = "presets" # les presets de mapping seul vont dans presets/mapping/
-shaders = "shaders"
logs = "logs" # journal quotidien sur disque (toolbox.log.AAAA-MM-JJ, 14 jours gardés)
+# NB : le dossier des LUT .cube (`luts/`) n'est PAS réglable ici — il est
+# toujours créé à côté du binaire, dans le répertoire de travail.
[output]
# Fenêtre de sortie : mires de test et vidéo déformées en direct par le
From 98a6ed94622ffd28cbd18dba107be4411f7bba0e Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 4 Aug 2026 21:59:43 +0000
Subject: [PATCH 03/24] Chataigne ne voyait ni la vitesse, ni la regie, ni
l'etat du lecteur
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Deuxieme lot. Trois defauts qui se rejoignent : une fonction existe, elle
repond, mais l'operateur n'a aucun moyen de la trouver.
1. DES ADRESSES OSC QUI MARCHENT SANS ETRE PUBLIEES. Le namespace OSCQuery
est la SEULE source que Chataigne lit pour construire son interface. Il
annoncait 45 adresses et taisait /rate, /blending, /cue/go, /dmx/scene
et /dmx/chaser -- soit la vitesse de lecture, le fondu de bords, et la
TOTALITE du declenchement de spectacle (cues du sequenceur et scenes
lumieres). Toutes acceptees en entree OSC, aucune visible.
Verifie en reel sur un node : `/rate 0.5` en UDP fait bien passer la
lecture a 0,5x, alors que le module OSCQuery n'offre aucun curseur de
vitesse. Les cinq sont declarees.
2. UN RETOUR D'ETAT SANS DESTINATAIRE. event_to_osc emet /transport et
/media -- les deux voyants les plus elementaires d'un lecteur : ce qu'il
fait, ce qu'il a charge. Aucun des deux n'existait dans l'arbre :
Chataigne recevait les messages sans avoir de parametre ou les ranger.
Ajoutes en LECTURE SEULE (ACCESS 1), avec /playlist/position, via une
nouvelle feuille_lecture() -- leaf() ne savait produire que de
l'ecriture. /preset/loaded, /mapping/loaded et /sync/scheduled restent
volontairement absents : ce sont des notifications sans etat
correspondant dans NodeState, les publier imposerait une VALUE
perpetuellement vide qui se lirait « aucun preset ».
3. LE SELECTEUR DE MEDIAS LISAIT UN CHAMP QUI N'EXISTE PAS. /api/media
renvoie des MediaInfo { path, bytes } ; l'UI lisait `m.name` a trois
endroits, donc la chaine « undefined ». Le plus grave n'est pas la liste
de « undefined » affichee dans « Fichiers du parc » : c'est l'onglet
Sequences, ou l'action « Charger un media » enregistrait
{cmd:"load", path:"undefined"} dans la conduite. Rien ne le refusait --
« undefined » est un chemin relatif parfaitement licite -- et la panne
n'apparaissait que le soir, a l'heure de la cue. Le selecteur du parc
filtre en plus les sous-dossiers, que validate_upload_name refuse.
4. LES LUT ETAIENT PLAFONNEES A 2 Mo AU LIEU DE 64. lut_upload extrait le
corps avec `Bytes` : axum applique alors DefaultBodyLimit (2 Mio) tant
qu'on ne le releve pas, et aucun DefaultBodyLimit n'existait dans le
routeur. Le garde maison « 64 Mo max » etait donc du code mort, et une
LUT 64 points (~7 Mo, taille courante d'un pack d'etalonnage) repartait
en 413 sans corps JSON -- l'UI n'affichait qu'une erreur generique.
Contre-epreuve : le test echoue bien (413) quand on retire la couche.
Trois tests ajoutes (adresses pilotables, etat en lecture seule, plafond
de corps). 254 tests, clippy -D warnings, fmt.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
crates/control-http/assets/index.html | 14 ++-
crates/control-http/src/lib.rs | 42 ++++++++-
crates/control-http/src/oscquery.rs | 121 ++++++++++++++++++++++++++
3 files changed, 172 insertions(+), 5 deletions(-)
diff --git a/crates/control-http/assets/index.html b/crates/control-http/assets/index.html
index 4d3a6c0..0035cbb 100644
--- a/crates/control-http/assets/index.html
+++ b/crates/control-http/assets/index.html
@@ -1842,9 +1842,12 @@
Alimentation de la machine
sel.appendChild(o);
return;
}
- mediaList.forEach(m => {
+ // /api/media renvoie des MediaInfo { path, bytes } — jamais `name`.
+ // Et un envoi de parc exige un nom PLAT (validate_upload_name refuse les
+ // sous-dossiers) : inutile de proposer ce que le serveur rejettera.
+ mediaList.filter(m => !m.path.includes("/")).forEach(m => {
const o = document.createElement("option");
- o.value = o.textContent = m.name;
+ o.value = o.textContent = m.path;
sel.appendChild(o);
});
if ([...sel.options].some(o => o.value === old)) sel.value = old;
@@ -1870,7 +1873,7 @@
$("#cueActDmx").style.display = (type === "dmxscene" || type === "dmxchaser") ? "block" : "none";
if (type === "load") {
const sel = $("#cueActMedia"); sel.innerHTML = "";
- mediaList.forEach(m => { const o = document.createElement("option"); o.value = o.textContent = m.name; sel.appendChild(o); });
+ // `m.path` : MediaInfo n'a pas de champ `name`. Avec m.name, la cue
+ // enregistrait « load undefined » — accepté sans broncher, et découvert
+ // le soir venu, à l'heure du spectacle.
+ mediaList.forEach(m => { const o = document.createElement("option"); o.value = o.textContent = m.path; sel.appendChild(o); });
}
if (type === "preset" || type === "fade") {
const sel = $("#cueActPreset"); sel.innerHTML = "";
diff --git a/crates/control-http/src/lib.rs b/crates/control-http/src/lib.rs
index 4aa5756..209249f 100644
--- a/crates/control-http/src/lib.rs
+++ b/crates/control-http/src/lib.rs
@@ -336,7 +336,17 @@ pub fn router(app: AppState) -> Router {
.route("/api/chataigne", get(chataigne_get))
.route("/api/chataigne/lancer", post(chataigne_lancer))
.route("/api/luts", get(luts_list))
- .route("/api/luts/{name}", put(lut_upload).delete(lut_delete))
+ // axum plafonne le corps des extracteurs (`Bytes`) à 2 Mo par défaut :
+ // sans ce relèvement, le garde « 64 Mo max » de lut_upload était du
+ // code mort et une LUT 64 points (~7 Mo, taille courante d'un pack
+ // d'étalonnage) était refusée par un 413 sans corps JSON — l'UI
+ // n'affichait alors qu'une erreur générique.
+ .route(
+ "/api/luts/{name}",
+ put(lut_upload)
+ .delete(lut_delete)
+ .layer(axum::extract::DefaultBodyLimit::max(64 * 1024 * 1024)),
+ )
.route("/api/reglages", get(reglages_get).post(reglages_set))
.route("/api/ndi/sources", get(ndi_sources))
.route("/api/preview.png", get(preview_png))
@@ -2386,6 +2396,36 @@ mod tests {
}
}
+ /// Une LUT d'étalonnage réaliste (64 points ≈ 7 Mo) doit atteindre le
+ /// parseur. Sans `DefaultBodyLimit` relevé sur la route, axum coupait à
+ /// 2 Mo et répondait 413 SANS corps JSON : le garde « 64 Mo max » de
+ /// `lut_upload` ne servait à rien et l'UI n'affichait qu'une erreur
+ /// générique. On envoie 3 Mo de contenu invalide : le refus doit venir du
+ /// parseur (400), pas du cadre (413).
+ #[tokio::test]
+ async fn depot_de_lut_depasse_la_limite_de_corps_par_defaut() {
+ let bed = testbed();
+ let gros = "x".repeat(3 * 1024 * 1024);
+ let response = bed
+ .router
+ .clone()
+ .oneshot(
+ HttpRequest::put("/api/luts/etalon.cube")
+ .header("origin", "http://192.168.1.50:8080")
+ .header("host", "192.168.1.50:8080")
+ .body(Body::from(gros))
+ .expect("requête"),
+ )
+ .await
+ .expect("réponse");
+ assert_ne!(
+ response.status(),
+ StatusCode::PAYLOAD_TOO_LARGE,
+ "le corps a été coupé par axum avant d'atteindre lut_upload"
+ );
+ assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+ }
+
#[tokio::test]
async fn upload_too_large_is_refused_and_cleaned() {
let dir = tempfile::tempdir().expect("tempdir");
diff --git a/crates/control-http/src/oscquery.rs b/crates/control-http/src/oscquery.rs
index e24f189..5bb49f7 100644
--- a/crates/control-http/src/oscquery.rs
+++ b/crates/control-http/src/oscquery.rs
@@ -133,6 +133,21 @@ fn leaf(
node
}
+/// Feuille en LECTURE SEULE (`ACCESS` 1) : un état que le node publie en
+/// retour OSC mais qu'on ne lui envoie pas. Sans ces nœuds, `event_to_osc`
+/// émettait `/transport`, `/media`… vers des adresses que le namespace ne
+/// déclarait nulle part : Chataigne recevait les messages sans avoir de
+/// paramètre où les ranger, donc aucun voyant à l'écran.
+fn feuille_lecture(path: &str, types: &str, description: &str, value: Value) -> Value {
+ json!({
+ "FULL_PATH": path,
+ "TYPE": types,
+ "DESCRIPTION": description,
+ "ACCESS": 1,
+ "VALUE": value,
+ })
+}
+
fn container(path: &str, contents: Value) -> Value {
json!({ "FULL_PATH": path, "CONTENTS": contents })
}
@@ -208,6 +223,8 @@ pub fn namespace(state: &NodeState) -> Value {
"seek": leaf("/seek", "f", "Position (secondes)", None, None),
"load": leaf("/load", "s", "Charger une source (fichier, rtsp://, capture://N, ndi://Nom)", None, None),
"volume": leaf("/volume", "f", "Volume", Some(json!([state.player.volume])), Some(range01())),
+ "rate": leaf("/rate", "f", "Vitesse de lecture (1 = normale)",
+ Some(json!([state.player.rate])), Some(json!([{ "MIN": 0.25, "MAX": 4.0 }]))),
"lut": leaf("/lut", "s", "LUT d'étalonnage (.cube du dossier luts/, vide = retirer)",
Some(json!([state.lut.clone().unwrap_or_default()])), None),
"blackout": leaf("/blackout", "i", "Blackout de régie (0|1, + fondu ms optionnel)",
@@ -224,7 +241,31 @@ pub fn namespace(state: &NodeState) -> Value {
"go": leaf("/playlist/go", "i", "Saute à l'élément", None, None),
"next": leaf("/playlist/next", "", "Suivant", None, None),
"prev": leaf("/playlist/prev", "", "Précédent", None, None),
+ "position": feuille_lecture("/playlist/position", "is",
+ "Position courante dans la playlist (index, chemin)",
+ json!([
+ state.player.playlist_index.map_or(-1, |i| i as i64),
+ state.player.media.clone().unwrap_or_default(),
+ ])),
})),
+ // Les deux voyants les plus élémentaires d'un lecteur : ce qu'il
+ // fait, et ce qu'il a chargé. Le node les émettait déjà en
+ // feedback ; ils n'étaient simplement pas déclarés.
+ "transport": feuille_lecture("/transport", "s",
+ "Transport : stopped | playing | paused",
+ json!([match state.player.transport {
+ toolbox_core::Transport::Stopped => "stopped",
+ toolbox_core::Transport::Playing => "playing",
+ toolbox_core::Transport::Paused => "paused",
+ }])),
+ "media": feuille_lecture("/media", "s", "Média chargé",
+ json!([state.player.media.clone().unwrap_or_default()])),
+ // NON déclarés volontairement : /preset/loaded, /mapping/loaded et
+ // /sync/scheduled. Le feedback les émet, mais ce sont des
+ // notifications sans état correspondant dans NodeState (aucun
+ // champ ne retient le dernier preset chargé) : les publier
+ // imposerait une VALUE perpétuellement vide, qui se lirait comme
+ // « aucun preset » plutôt que « non mémorisé ».
"corner": container("/corner", Value::Object(corners)),
"rotation": leaf("/rotation", "i", "Rotation de la source",
Some(json!([state.mapping.rotation.degrees()])),
@@ -266,10 +307,32 @@ pub fn namespace(state: &NodeState) -> Value {
"load": leaf("/preset/load", "s", "Charge un preset", None, None),
"fade": leaf("/preset/fade", "sf", "Fondu vers un preset (nom, secondes)", None, None),
})),
+ "blending": leaf("/blending", "fffff",
+ "Fondu de bords : gauche droite haut bas (0..0,5) puis gamma (1..3)",
+ Some(json!([
+ state.blending.gauche, state.blending.droite,
+ state.blending.haut, state.blending.bas, state.blending.gamma,
+ ])),
+ Some(json!([
+ { "MIN": 0.0, "MAX": 0.5 }, { "MIN": 0.0, "MAX": 0.5 },
+ { "MIN": 0.0, "MAX": 0.5 }, { "MIN": 0.0, "MAX": 0.5 },
+ { "MIN": 1.0, "MAX": 3.0 },
+ ]))),
"sync": container("/sync", json!({
"arm": leaf("/sync/arm", "", "Arme : média prêt, pause à 0", None, None),
"startAt": leaf("/sync/startAt", "d", "Départ à l'heure Unix (secondes, double)", None, None),
})),
+ // Régie : le déclenchement d'un spectacle depuis Chataigne passe
+ // par ces trois adresses. Elles fonctionnaient en OSC mais
+ // n'étaient publiées nulle part — donc invisibles dans le module
+ // OSCQuery, seul endroit où l'opérateur va les chercher.
+ "cue": container("/cue", json!({
+ "go": leaf("/cue/go", "s", "Déclenche une cue du séquenceur (par nom)", None, None),
+ })),
+ "dmx": container("/dmx", json!({
+ "scene": leaf("/dmx/scene", "s", "Rappelle une scène de la console lumières", None, None),
+ "chaser": leaf("/dmx/chaser", "s", "Lance un chaser (nom) ; sans argument : arrêt", None, None),
+ })),
}
})
}
@@ -329,6 +392,64 @@ mod tests {
);
}
+ /// Chataigne ne connaît QUE ce que cet arbre déclare. Une adresse OSC
+ /// acceptée par `control-osc` mais absente d'ici est une fonction
+ /// injoignable en pratique : l'opérateur ne la voit nulle part.
+ /// `/rate`, `/blending`, `/cue/go`, `/dmx/scene` et `/dmx/chaser`
+ /// marchaient toutes en OSC sans figurer dans le namespace — vérifié en
+ /// réel : `/rate 0.5` changeait bien la vitesse d'un node dont le module
+ /// OSCQuery n'offrait aucun curseur de vitesse.
+ #[test]
+ fn namespace_publie_les_adresses_osc_pilotables() {
+ let tree = namespace(&NodeState::default());
+ let c = &tree["CONTENTS"];
+
+ // Vitesse de lecture : écrivable, bornée comme la commande.
+ assert_eq!(c["rate"]["FULL_PATH"], "/rate");
+ assert_eq!(c["rate"]["ACCESS"], 3);
+ assert_eq!(c["rate"]["RANGE"][0]["MIN"], 0.25);
+ assert_eq!(c["rate"]["RANGE"][0]["MAX"], 4.0);
+
+ // Fondu de bords : cinq floats, dans l'ordre exact de l'adresse OSC
+ // (gauche, droite, haut, bas, gamma) — pas celui de /crop.
+ assert_eq!(c["blending"]["TYPE"], "fffff");
+ // Gamma stocké en f32 : tolérance, pas d'égalité stricte.
+ let gamma = c["blending"]["VALUE"][4].as_f64().expect("gamma");
+ assert!((gamma - 2.2).abs() < 1e-6);
+
+ // Régie : cues et lumières, invisibles jusqu'ici.
+ assert_eq!(c["cue"]["CONTENTS"]["go"]["FULL_PATH"], "/cue/go");
+ assert_eq!(c["dmx"]["CONTENTS"]["scene"]["FULL_PATH"], "/dmx/scene");
+ assert_eq!(c["dmx"]["CONTENTS"]["chaser"]["FULL_PATH"], "/dmx/chaser");
+ }
+
+ /// Le retour d'état émet `/transport` et `/media` : sans nœud déclaré,
+ /// Chataigne reçoit les messages sans paramètre où les ranger.
+ #[test]
+ fn namespace_publie_letat_en_lecture_seule() {
+ let mut node_state = NodeState::default();
+ // `play` est refusé sans média : on charge d'abord, comme en vrai.
+ node_state
+ .apply(&toolbox_core::Command::Load {
+ path: "clip.mp4".into(),
+ })
+ .expect("load");
+ node_state
+ .apply(&toolbox_core::Command::Play)
+ .expect("play");
+ let tree = namespace(&node_state);
+ let c = &tree["CONTENTS"];
+
+ // ACCESS 1 = lecture seule : Chataigne ne doit pas proposer d'écrire
+ // dessus (le node n'accepte aucune commande sur ces adresses).
+ assert_eq!(c["transport"]["ACCESS"], 1);
+ assert_eq!(c["transport"]["VALUE"][0], "playing");
+ assert_eq!(c["media"]["ACCESS"], 1);
+ assert_eq!(c["playlist"]["CONTENTS"]["position"]["ACCESS"], 1);
+ // Hors playlist, l'index vaut -1 et non 0 : 0 est un rang valide.
+ assert_eq!(c["playlist"]["CONTENTS"]["position"]["VALUE"][0], -1);
+ }
+
#[tokio::test]
async fn http_serves_host_info_tree_and_attributes() {
use axum::body::Body;
From cd94dd71224905861d96df8a6647048ecfff9910 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 4 Aug 2026 22:02:07 +0000
Subject: [PATCH 04/24] CI : un depot tiers casse sur l'image du runner
arretait tous les jobs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`apt-get update` sortait en 100 sur les runners :
E: The repository 'https://packages.microsoft.com/ubuntu/24.04/prod
noble InRelease' is no longer signed.
Sous `set -o pipefail`, le step echouait et emportait le job « check »
entier -- fmt, clippy, tests, smoke -- alors qu'aucun de ces jobs n'a
besoin du depot Microsoft : ils veulent libasound2-dev, GStreamer,
shellcheck ou le linker ARM64, tous dans le depot Ubuntu.
La panne ne vient pas du code et se reproduit sur main : c'est la
signature d'un depot preinstalle sur l'image GitHub qui a expire de son
cote. Les huit steps apt du depot retirent desormais les listes
microsoft* avant `apt-get update`. On garde la strictitude : un vrai
echec d'installation fait toujours tomber le job.
Le job shellcheck gagne au passage le `apt-get update` qui lui manquait
(il installait shellcheck sur les listes preinstallees).
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
.github/workflows/ci.yml | 40 ++++++++++++++++++++++++++++++++---
.github/workflows/release.yml | 18 ++++++++++++++--
2 files changed, 53 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0da31e2..50caa7f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,6 +28,10 @@ jobs:
- name: Dépendances système (ALSA pour le MIDI)
run: |
set -o pipefail
+ # Les images GitHub embarquent des depots tiers (packages.microsoft.com)
+ # dont la signature expire : `apt-get update` sort alors en 100 et
+ # casse un job qui n'a besoin que du depot Ubuntu.
+ sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/microsoft*.sources
sudo apt-get update 2>&1 | tail -1
sudo apt-get install -y libasound2-dev 2>&1 | tail -1
- name: Format
@@ -146,6 +150,10 @@ jobs:
- name: Dépendances système (GStreamer dev + plugins + rtsp-server)
run: |
set -o pipefail
+ # Les images GitHub embarquent des depots tiers (packages.microsoft.com)
+ # dont la signature expire : `apt-get update` sort alors en 100 et
+ # casse un job qui n'a besoin que du depot Ubuntu.
+ sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/microsoft*.sources
sudo apt-get update 2>&1 | tail -1
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
@@ -185,7 +193,14 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Dépendances système (ALSA pour le MIDI)
- run: sudo apt-get update && sudo apt-get install -y libasound2-dev
+ run: |
+ set -o pipefail
+ # Les images GitHub embarquent des depots tiers (packages.microsoft.com)
+ # dont la signature expire : `apt-get update` sort alors en 100 et
+ # casse un job qui n'a besoin que du depot Ubuntu.
+ sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/microsoft*.sources
+ sudo apt-get update 2>&1 | tail -1
+ sudo apt-get install -y libasound2-dev 2>&1 | tail -1
- name: cargo-deny (licences des dépendances)
run: |
set -o pipefail
@@ -203,6 +218,11 @@ jobs:
- name: Shellcheck des scripts
run: |
set -o pipefail
+ # Les images GitHub embarquent des depots tiers (packages.microsoft.com)
+ # dont la signature expire : `apt-get update` sort alors en 100 et
+ # casse un job qui n'a besoin que du depot Ubuntu.
+ sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/microsoft*.sources
+ sudo apt-get update 2>&1 | tail -1
sudo apt-get install -y shellcheck 2>&1 | tee apt.log
shellcheck tools/bench/*.sh tools/endurance/*.sh deploy/*.sh 2>&1 | tee sc.log
- name: Publier les logs en cas d'échec
@@ -239,7 +259,14 @@ jobs:
- uses: Swatinem/rust-cache@v2
- name: Dépendances système (Linux)
if: runner.os == 'Linux'
- run: sudo apt-get update && sudo apt-get install -y libasound2-dev
+ run: |
+ set -o pipefail
+ # Les images GitHub embarquent des depots tiers (packages.microsoft.com)
+ # dont la signature expire : `apt-get update` sort alors en 100 et
+ # casse un job qui n'a besoin que du depot Ubuntu.
+ sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/microsoft*.sources
+ sudo apt-get update 2>&1 | tail -1
+ sudo apt-get install -y libasound2-dev 2>&1 | tail -1
- name: Build release
shell: bash
run: cargo build --release -p toolbox-node
@@ -334,7 +361,14 @@ jobs:
targets: aarch64-unknown-linux-gnu
- uses: Swatinem/rust-cache@v2
- name: Linker croisé ARM64
- run: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu
+ run: |
+ set -o pipefail
+ # Les images GitHub embarquent des depots tiers (packages.microsoft.com)
+ # dont la signature expire : `apt-get update` sort alors en 100 et
+ # casse un job qui n'a besoin que du depot Ubuntu.
+ sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/microsoft*.sources
+ sudo apt-get update 2>&1 | tail -1
+ sudo apt-get install -y gcc-aarch64-linux-gnu 2>&1 | tail -1
- name: Build release ARM64 (sans MIDI — ALSA croisé indisponible)
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1d2d2c9..8e77050 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -47,7 +47,14 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Dépendances système (ALSA)
- run: sudo apt-get update && sudo apt-get install -y libasound2-dev
+ run: |
+ set -o pipefail
+ # Les images GitHub embarquent des depots tiers (packages.microsoft.com)
+ # dont la signature expire : `apt-get update` sort alors en 100 et
+ # casse un job qui n'a besoin que du depot Ubuntu.
+ sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/microsoft*.sources
+ sudo apt-get update 2>&1 | tail -1
+ sudo apt-get install -y libasound2-dev 2>&1 | tail -1
- name: Build + archive
run: |
cargo build --release -p toolbox-node
@@ -137,7 +144,14 @@ jobs:
targets: aarch64-unknown-linux-gnu
- uses: Swatinem/rust-cache@v2
- name: Linker croisé ARM64
- run: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu
+ run: |
+ set -o pipefail
+ # Les images GitHub embarquent des depots tiers (packages.microsoft.com)
+ # dont la signature expire : `apt-get update` sort alors en 100 et
+ # casse un job qui n'a besoin que du depot Ubuntu.
+ sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/microsoft*.sources
+ sudo apt-get update 2>&1 | tail -1
+ sudo apt-get install -y gcc-aarch64-linux-gnu 2>&1 | tail -1
- name: Build + archive (sans MIDI/fenêtre — voir install.sh pour le Pi)
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
From 54005d915fad02873dd36180b232f9c0329a66e7 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 4 Aug 2026 22:02:07 +0000
Subject: [PATCH 05/24] systemd : l'unite kiosque ne pouvait pas ouvrir la
fenetre de sortie
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
L'unite est ordonnee sur multi-user.target et ne recoit aucune variable
d'affichage (DISPLAY, WAYLAND_DISPLAY, XDG_RUNTIME_DIR). Or le mode de
sortie par DEFAUT est `fenetre`, et install.sh ne propose jamais le mode
KMS. Un processus lance dans ce contexte n'a aucun serveur d'affichage a
contacter.
Le node ne plante pas pour autant -- il journalise « fenetre de sortie
indisponible » et continue sans projeter (comportement verifie en reel
sur une machine sans affichage). C'est justement ce qui rend la panne
sournoise : le service est actif, l'UI repond, tout parait normal, et le
videoprojecteur reste noir. L'en-tete promettait pourtant « un Pi branche
a un videoprojecteur reprend son show sans intervention ».
L'unite dit maintenant a quoi elle sert reellement -- pilotage seul, ou
sortie DRM/KMS -- et porte le bloc a decommenter pour un kiosque en mode
fenetre sur Pi OS Desktop, avec la marche a suivre pour trouver les
bonnes valeurs.
PAS active par defaut, et volontairement : basculer sur graphical.target
empecherait le service de demarrer sur une machine sans bureau (Pi OS
Lite), et l'UID comme le type de session dependent de l'installation.
Cela demande un Pi reel -- ce que CLAUDE.md reserve deja au retour de Pym.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
deploy/systemd/toolbox-node.service | 31 +++++++++++++++++++++++++++--
1 file changed, 29 insertions(+), 2 deletions(-)
diff --git a/deploy/systemd/toolbox-node.service b/deploy/systemd/toolbox-node.service
index 6f4f174..72825be 100644
--- a/deploy/systemd/toolbox-node.service
+++ b/deploy/systemd/toolbox-node.service
@@ -2,13 +2,36 @@
# (@PREFIX@ et @USER@ sont remplacés à l'installation).
#
# Mode kiosque : le node redémarre tout seul en cas de crash ou au boot ;
-# combiné à [startup] preset/autoplay dans node.toml, un Pi branché à un
-# vidéoprojecteur reprend son show sans intervention.
+# combiné à [startup] preset/autoplay dans node.toml, un Pi reprend son show
+# sans intervention.
+#
+# ATTENTION — CE QUE CETTE UNITÉ PROJETTE, ET CE QU'ELLE NE PROJETTE PAS.
+# Telle quelle, elle est ordonnée sur multi-user.target et ne reçoit aucune
+# variable d'affichage. Elle convient à deux usages :
+# - le PILOTAGE seul (web UI, mapping, OSC/MIDI, lumières, séquenceur) ;
+# - la sortie DRM/KMS (`[output] mode = "kms"` dans node.toml), qui écrit
+# directement sur l'écran sans serveur graphique — le chemin conseillé
+# sur Raspberry Pi OS Lite.
+# En mode fenêtre (le DÉFAUT), un service lancé dans ce contexte n'a aucun
+# serveur d'affichage à contacter : le node journalise « fenêtre de sortie
+# indisponible » et continue SANS projeter. Il ne plante pas — donc rien
+# n'attire l'attention, et le vidéoprojecteur reste noir.
+#
+# Pour un kiosque en mode fenêtre sur Raspberry Pi OS Desktop, décommenter
+# le bloc ci-dessous ET la ligne graphical.target, après avoir vérifié la
+# session réellement utilisée (X11 ou Wayland) et l'UID du compte :
+# loginctl show-user @USER@ -p RuntimeMax → XDG_RUNTIME_DIR=/run/user/
+# echo $DISPLAY / $WAYLAND_DISPLAY dans une session ouverte
+# Non activé par défaut : le passage à graphical.target empêcherait le
+# service de démarrer sur une machine sans bureau (Pi OS Lite), et ces
+# valeurs dépendent de l'installation. À valider sur un Pi réel.
[Unit]
Description=Toolbox node multimédia (player + mapping + contrôle réseau)
After=network-online.target
Wants=network-online.target
+# Kiosque en mode fenêtre uniquement :
+#After=graphical.target
[Service]
Type=simple
@@ -17,6 +40,10 @@ WorkingDirectory=@PREFIX@
ExecStart=@PREFIX@/toolbox-node
Restart=always
RestartSec=2
+# Kiosque en mode fenêtre uniquement (voir l'en-tête) :
+#Environment=DISPLAY=:0
+#Environment=WAYLAND_DISPLAY=wayland-0
+#Environment=XDG_RUNTIME_DIR=/run/user/1000
# Limite mémoire de sécurité (un node ne doit jamais étouffer le Pi).
MemoryMax=1G
# Journal : stdout/stderr vont dans journald ; la page de logs de la web UI
From cbc99db5ced4c6b1fc7111e55ac60c6c9513532b Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 4 Aug 2026 22:12:38 +0000
Subject: [PATCH 06/24] =?UTF-8?q?Un=20Pi=20qui=20projette=20parfaitement?=
=?UTF-8?q?=20etait=20declare=20=C2=AB=20sortie=20morte=20=C2=BB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Troisieme lot. La mesure de performance annoncait « une absence, pas un
faux zero rassurant » -- elle ne tenait cette promesse que sur la moitie
des chiffres.
1. `fps` RESTAIT UN ZERO DUR LA OU `rendu` DEVIENT null. Le drapeau
mesure_disponible (= feature `render` compilee ET mode != KMS) ne
protegeait QUE la cle `rendu`. La ligne juste en dessous publiait `fps`
sans condition -- immediatement apres le commentaire qui explique
pourtant que « des zeros se lisent comme tout va bien, une absence se
lit comme une absence ».
Or dans les deux cas ou le drapeau est faux -- mode KMS, et binaire
sans fenetre, c'est-a-dire l'artefact officiel raspberrypi-arm64 --
personne ne peuple le compteur. Un run d'endurance de 4 h sur un Pi qui
projette sans le moindre defaut ressortait donc « Sortie sans aucune
image : 100 % du run ». Exactement le defaut corrige en sens inverse
par 2cee3ea, et reintroduit par l'autre bout.
`fps` suit desormais la meme regle que `rendu`, aux QUATRE endroits qui
le publient (/api/system, ZIP de diagnostic, /api/outputs, evenement
position du WebSocket). Le depouillement ignore alors ces points au
lieu de les compter comme morts, et le badge img/s se masque au lieu
d'afficher « 0 img/s ».
2. Ctrl+C N'ARRETAIT PAS LE HARNAIS D'ENDURANCE. `trap nettoyer EXIT INT
TERM HUP` : un gestionnaire de signal qui ne se termine pas par `exit`
rend la main au script, qui REPREND ou il en etait. Ctrl+C eteignait
donc la charge et restaurait le mapping, puis la boucle continuait a
interroger le node pendant les heures restantes en empilant des lignes
dans le meme CSV -- et `nettoyer` repassait une seconde fois a la
sortie. Le meme SIGHUP d'une session SSH coupee produisait cela sur un
Pi distant.
Reproduit sur un cas minimal (le nettoyage s'affiche, la boucle va
jusqu'au bout, le nettoyage s'affiche encore), puis verifie corrige :
sortie immediate, nettoyage une seule fois, code 130.
3. LE README DU HARNAIS DISAIT ENCORE QUE LE p95 ATTRAPE L'A-COUP. Le
commit ba41112 avait corrigé cette idee partout (mesure.rs, UI, manuel,
CHANGELOG) SAUF dans le seul document que l'operateur lit pour
interpreter son CSV. Un figement de 200 ms par minute ne fait pas
bouger le p95 d'une microseconde -- c'est max_us qui le voit. Les deux
se lisent ensemble, et la colonne fps documente sa cellule vide.
Corriges au passage, meme famille : la table JSON/OSC de command.rs se
donne pour la reference du vocabulaire et ignorait 8 des 40 commandes
(dont blackout_set, cue_go et dmx_scene, qui ONT une adresse OSC) --
completee et desormais tenue par un test qui compare la table a l'enum ;
l'evenement DmxChaserDemande n'etait jamais miroite en retour OSC, si
bien qu'une scene rappelee allumait le voyant et un chaser lance
n'allumait rien ; trois cles [modules] (sequencer, sync, ndi) que rien ne
lisait, commentees « phase 2 » et « phase 3 » pour des fonctions livrees
en v2.0.0 ; la liste des ecrans, lue une seule fois au chargement de la
page, qui laissait une tablette de regie afficher « aucun ecran detecte »
apres le branchement du videoprojecteur ; docs/TIERS.md qui ouvrait sur
« logiciel proprietaire » alors que LICENSE et Cargo.toml sont MIT ; et
deploy/README qui annoncait en « phase 4 » l'OTA et le mot de passe UI,
livres depuis deux versions.
256 tests, clippy -D warnings, fmt, shellcheck.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
README.md | 14 ++++--
crates/control-http/assets/index.html | 15 +++++-
crates/control-http/src/lib.rs | 47 +++++++++++++++--
crates/control-osc/src/lib.rs | 26 ++++++++++
crates/core/src/command.rs | 72 +++++++++++++++++++++++++++
crates/core/src/config.rs | 33 +++++++++---
deploy/README.md | 20 ++++++--
docs/TIERS.md | 11 ++--
node.toml.example | 9 ++--
tools/endurance/README.md | 19 +++++--
tools/endurance/endurance.sh | 12 ++++-
11 files changed, 243 insertions(+), 35 deletions(-)
diff --git a/README.md b/README.md
index 68dfd2f..4760c91 100644
--- a/README.md
+++ b/README.md
@@ -13,9 +13,9 @@ préfixe historique `toolbox-` (aucun chemin ni contrat ne change).
> `Toolbox/docs/` du projet — ce repo ne contient que le code.
> Liste complète des fonctions : en tête de `docs/manuel.html`.
-## État — v3.0.0
+## État — v3.5.0
-La chaîne complète est fonctionnelle et testée (160+ tests, CI Linux +
+La chaîne complète est fonctionnelle et testée (250+ tests, CI Linux +
Windows + check ARM64) : **lecture vidéo réelle** (GStreamer, boucle sans
coupure), **fenêtre de sortie** avec warp/mires/couleur/effets calculés par
le **GPU** (wgpu/Vulkan, repli CPU automatique), sources externes (capture,
@@ -54,8 +54,14 @@ calibrage pas à pas, référence OSC/MIDI/config, dépannage.
**Binaires prêts** : page **[Releases](https://github.com/pymenvert/toolbox/releases)**
(sans compte) — `toolbox-node-windows-x64-gstreamer` (pack complet avec
-vidéo, rien à installer), `toolbox-node-windows-x64` (léger),
-`toolbox-node-linux-x64`, `toolbox-node-raspberrypi-arm64`.
+vidéo, rien à installer : **le seul binaire publié qui lit des vidéos**),
+`toolbox-node-windows-x64` et `toolbox-node-linux-x64` (mires, mapping,
+calibrage et OSC/MIDI, sans lecture vidéo), `toolbox-node-raspberrypi-arm64`
+(**pilotage seul — cette archive ne projette rien** : compilée sans fenêtre
+de sortie, sans MIDI et sans GStreamer).
+
+Pour lire des vidéos sur Linux ou projeter depuis un Pi, il faut compiler sur
+place avec `--features gstreamer` — voir le manuel.
```bash
# ou compilation locale (Linux : sudo apt install libasound2-dev)
diff --git a/crates/control-http/assets/index.html b/crates/control-http/assets/index.html
index 0035cbb..c28bde5 100644
--- a/crates/control-http/assets/index.html
+++ b/crates/control-http/assets/index.html
@@ -980,7 +980,11 @@
Alimentation de la machine
function renderFps(fps) {
const b = $("#fpsBadge");
const playing = state && state.player.transport === "playing";
- if (!playing || fps === undefined) { b.style.display = "none"; return; }
+ // null = il n'y a pas de fenetre pour compter (mode KMS, binaire sans la
+ // feature `render`). Afficher « 0 img/s » ferait croire a une sortie morte
+ // alors que la projection peut etre parfaite : on masque le badge, comme
+ // pour une valeur absente.
+ if (!playing || fps === undefined || fps === null) { b.style.display = "none"; return; }
b.style.display = "";
b.textContent = Math.round(fps) + " img/s";
}
@@ -1102,7 +1106,11 @@
Alimentation de la machine
const p = a.dataset.page;
$$(".page").forEach(s => s.classList.toggle("visible", s.id === "page-" + p));
$("#pagetitle").textContent = pageTitles[p];
- if (p === "mapping") { sizeStage(); renderBlending(); renderMasques(); renderMesh(); }
+ // loadOutputs() : la liste des ecrans n'etait lue qu'au chargement de la
+ // page. Une tablette de regie restee ouverte toute la journee affichait
+ // encore « aucun ecran detecte » apres le branchement du videoprojecteur,
+ // alors que le node l'avait bien detecte a chaud.
+ if (p === "mapping") { sizeStage(); renderBlending(); renderMasques(); renderMesh(); loadOutputs(); }
if (p === "features") loadFeatures();
if (p === "lights") loadDmx();
if (p === "cues") { loadCues(); loadMedia(); loadPresets(); }
@@ -2943,6 +2951,9 @@
Alimentation de la machine
} catch (e) { /* localStorage indisponible (navigation privée) : accueil masqué */ }
// Un fichier copié directement dans media/ apparaît sans recharger la page.
setInterval(() => { if (!document.hidden && $("#page-media").classList.contains("visible")) { loadMedia(); loadParc(); } }, 5000);
+ // Ecrans branches/debranches a chaud : le node republie la liste, encore
+ // faut-il la relire. Seulement quand la carte « Sortie » est visible.
+ setInterval(() => { if (!document.hidden && $("#page-mapping").classList.contains("visible")) loadOutputs(); }, 5000);
sizeStage();
paintSeek();
})();
diff --git a/crates/control-http/src/lib.rs b/crates/control-http/src/lib.rs
index 209249f..8a519b7 100644
--- a/crates/control-http/src/lib.rs
+++ b/crates/control-http/src/lib.rs
@@ -787,7 +787,20 @@ async fn system_stats(State(app): State) -> Json {
serde_json::Value::Null
};
objet.insert("rendu".into(), rendu);
- objet.insert("fps".into(), serde_json::json!(*app.output.fps.borrow()));
+ // `fps` suit EXACTEMENT la meme regle que `rendu`. Il restait un zero
+ // dur quand la mesure n'existe pas (mode KMS, ou binaire sans la
+ // feature `render` -- c'est-a-dire l'artefact officiel ARM64) : rien
+ // ne peuple le compteur, mais 0 img/s se lit « sortie morte ». Un run
+ // d'endurance parfaitement sain sur un Pi ressortait ainsi « sortie
+ // sans aucune image : 100 % du run ».
+ objet.insert(
+ "fps".into(),
+ if app.output.mesure_disponible {
+ serde_json::json!(*app.output.fps.borrow())
+ } else {
+ serde_json::Value::Null
+ },
+ );
}
Json(json)
}
@@ -1627,7 +1640,20 @@ async fn diagnostic_zip(State(app): State) -> Result) -> Json {
Json(serde_json::json!({
"monitors": *app.output.monitors.borrow(),
"settings": *app.output.settings.borrow(),
- "fps": *app.output.fps.borrow(),
+ // null (et non 0) quand aucune fenetre ne peut alimenter le compteur.
+ "fps": if app.output.mesure_disponible {
+ serde_json::json!(*app.output.fps.borrow())
+ } else {
+ serde_json::Value::Null
+ },
}))
}
@@ -1935,8 +1966,14 @@ async fn ws_events(mut socket: WebSocket, app: AppState) {
"event": "position",
"position": p.position,
"duration": p.duration,
- // Fluidité de la fenêtre de sortie (0 = pas de rendu).
- "fps": *app.output.fps.borrow(),
+ // Fluidite de la fenetre de sortie : 0 = la fenetre
+ // existe mais ne dessine pas ; null = il n'y a pas de
+ // fenetre du tout (mode KMS, binaire sans `render`).
+ "fps": if app.output.mesure_disponible {
+ serde_json::json!(*app.output.fps.borrow())
+ } else {
+ serde_json::Value::Null
+ },
});
if socket.send(Message::Text(message.to_string().into())).await.is_err() {
break;
diff --git a/crates/control-osc/src/lib.rs b/crates/control-osc/src/lib.rs
index 8fe80f3..78ba9eb 100644
--- a/crates/control-osc/src/lib.rs
+++ b/crates/control-osc/src/lib.rs
@@ -185,6 +185,7 @@ fn est_impulsion(addr: &str) -> bool {
addr,
"/cue/go"
| "/dmx/scene"
+ | "/dmx/chaser"
| "/preset/loaded"
| "/preset/fade"
| "/mapping/loaded"
@@ -312,6 +313,15 @@ pub fn event_to_osc(event: &toolbox_core::Event) -> Option {
Event::DmxSceneDemandee { name } => {
message("/dmx/scene", vec![OscType::String(name.clone())])
}
+ // Symétrique de /lut : chaîne vide = arrêt du chaser. Sans cette
+ // branche, le bras fourre-tout avalait l'événement en invoquant
+ // « Chataigne relira la valeur via OSCQuery » — ce qui était faux,
+ // /dmx/chaser n'y figurant pas non plus. Résultat : une scène
+ // rappelée allumait bien le voyant, un chaser lancé n'allumait rien.
+ Event::DmxChaserDemande { name } => message(
+ "/dmx/chaser",
+ vec![OscType::String(name.clone().unwrap_or_default())],
+ ),
Event::LutChanged { name } => message(
"/lut",
vec![OscType::String(name.clone().unwrap_or_default())],
@@ -1051,6 +1061,22 @@ mod tests {
vec![OscType::String("scene_02".into()), OscType::Float(2.5)]
);
+ // Un chaser qui démarre doit allumer le bouton de la surface, comme
+ // une scène. L'événement tombait auparavant dans le fourre-tout :
+ // /dmx/scene revenait, /dmx/chaser jamais.
+ let m = event_to_osc(&Event::DmxChaserDemande {
+ name: Some("poursuite".into()),
+ })
+ .expect("chaser");
+ assert_eq!(m.addr, "/dmx/chaser");
+ assert_eq!(m.args, vec![OscType::String("poursuite".into())]);
+ // Arrêt : chaîne vide, comme /lut.
+ let m = event_to_osc(&Event::DmxChaserDemande { name: None }).expect("stop chaser");
+ assert_eq!(m.args, vec![OscType::String(String::new())]);
+ // Et c'est un déclencheur : deux départs successifs du même chaser
+ // doivent produire deux messages, pas un seul.
+ assert!(est_impulsion("/dmx/chaser"));
+
// Pas de retour pour l'état complet remplacé.
assert!(event_to_osc(&Event::StateReplaced {
state: Box::default()
diff --git a/crates/core/src/command.rs b/crates/core/src/command.rs
index cbf474c..fbb1fbd 100644
--- a/crates/core/src/command.rs
+++ b/crates/core/src/command.rs
@@ -79,6 +79,14 @@ pub enum TestPattern {
/// | `preset_fade` | `/preset/fade ` |
/// | `sync_arm` | `/sync/arm` |
/// | `sync_start_at` | `/sync/startAt ` |
+/// | `lut_set` | `/lut ` (vide = retirer) |
+/// | `blackout_set` | `/blackout <0|1> [fondu ms]` |
+/// | `freeze_set` | `/freeze <0|1>` |
+/// | `cue_go` | `/cue/go ` |
+/// | `dmx_scene` | `/dmx/scene ` |
+/// | `dmx_chaser` | `/dmx/chaser [nom]` (rien = stop) |
+/// | `mesh_set` | — (UI/REST) |
+/// | `mesh_reset` | — (UI/REST) |
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum Command {
@@ -258,6 +266,70 @@ pub enum Command {
mod tests {
use super::*;
+ /// La table en tête de module s'annonce comme la référence du vocabulaire
+ /// (« Chaque commande a une représentation JSON canonique … et une adresse
+ /// OSC équivalente documentée ci-dessous »). Elle s'était arrêtée à la
+ /// v1.1 : huit commandes livrées depuis — dont `blackout_set`, `cue_go` et
+ /// `dmx_scene`, qui ONT une adresse OSC — n'y figuraient pas. Qui la
+ /// consultait en concluait qu'elles n'étaient pas pilotables.
+ #[test]
+ fn la_table_de_documentation_couvre_toutes_les_commandes() {
+ let source = include_str!("command.rs");
+
+ let documentees: std::collections::BTreeSet = source
+ .lines()
+ .filter_map(|l| l.trim_start().strip_prefix("/// | `"))
+ .filter_map(|reste| reste.split('`').next())
+ .map(str::to_string)
+ .collect();
+
+ // Variantes de l'enum : lignes indentées de 4 espaces commençant par
+ // une majuscule, entre `pub enum Command {` et sa fermeture.
+ let corps = source
+ .split_once("pub enum Command {")
+ .map(|(_, apres)| apres)
+ .unwrap_or_default();
+ let mut variantes = std::collections::BTreeSet::new();
+ for ligne in corps.lines() {
+ if ligne == "}" {
+ break;
+ }
+ let Some(nom) = ligne.strip_prefix(" ") else {
+ continue;
+ };
+ if !nom.starts_with(char::is_uppercase) {
+ continue;
+ }
+ let nom: String = nom
+ .chars()
+ .take_while(|c| c.is_alphanumeric())
+ .collect::();
+ if nom.is_empty() {
+ continue;
+ }
+ // CamelCase → snake_case, comme `rename_all = "snake_case"`.
+ let mut snake = String::new();
+ for (i, c) in nom.chars().enumerate() {
+ if c.is_uppercase() && i > 0 {
+ snake.push('_');
+ }
+ snake.extend(c.to_lowercase());
+ }
+ variantes.insert(snake);
+ }
+
+ assert!(
+ variantes.len() >= 40,
+ "analyse de l'enum ratée : {} variantes trouvées",
+ variantes.len()
+ );
+ let absentes: Vec<_> = variantes.difference(&documentees).collect();
+ assert!(
+ absentes.is_empty(),
+ "commandes absentes de la table de documentation : {absentes:?}"
+ );
+ }
+
/// Le format JSON est un contrat public (web UI, REST) : on le fige par test.
#[test]
fn json_format_is_stable() {
diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs
index 350a2ec..95dc5aa 100644
--- a/crates/core/src/config.rs
+++ b/crates/core/src/config.rs
@@ -30,6 +30,13 @@ impl Default for Resolution {
}
/// Modules activables — c'est ce qui rend l'installeur "à la carte" possible.
+///
+/// Uniquement les quatre interrupteurs RÉELLEMENT lus au démarrage. Il y en a
+/// eu trois autres (`sequencer`, `sync`, `ndi`) que rien ne consultait : les
+/// mettre à `true` n'allumait rien, les laisser à `false` n'éteignait rien.
+/// Le séquenceur démarre toujours (il s'arrête depuis l'onglet Fonctions), la
+/// synchro dépend de `[sync] role`, et la sortie NDI de `[ndi] sortie`. Une
+/// clé restée dans un ancien `node.toml` est ignorée sans bruit.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Modules {
@@ -37,9 +44,6 @@ pub struct Modules {
pub osc: bool,
pub midi: bool,
pub http: bool,
- pub sequencer: bool,
- pub sync: bool,
- pub ndi: bool,
}
impl Default for Modules {
@@ -49,9 +53,6 @@ impl Default for Modules {
osc: true,
midi: false,
http: true,
- sequencer: false,
- sync: false,
- ndi: false,
}
}
}
@@ -538,7 +539,25 @@ mod tests {
assert_eq!(cfg, NodeConfig::default());
assert_eq!(cfg.ports.http, 8080);
assert!(cfg.modules.player);
- assert!(!cfg.modules.ndi);
+ assert!(!cfg.modules.midi);
+ }
+
+ /// Les clés `[modules]` supprimées (sequencer, sync, ndi) ne doivent pas
+ /// faire échouer le chargement d'un `node.toml` écrit par une version
+ /// précédente : sinon une mise à jour perdrait TOUTE la configuration.
+ #[test]
+ fn anciennes_cles_modules_ignorees_sans_erreur() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let chemin = dir.path().join("node.toml");
+ std::fs::write(
+ &chemin,
+ "[modules]\nplayer = true\nsequencer = true\nsync = true\nndi = true\n\n\
+ [ports]\nhttp = 9999\n",
+ )
+ .expect("écriture");
+ let cfg = NodeConfig::load(&chemin).expect("un node.toml ancien doit rester lisible");
+ assert!(cfg.modules.player);
+ assert_eq!(cfg.ports.http, 9999);
}
#[test]
diff --git a/deploy/README.md b/deploy/README.md
index ab86850..672a34d 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -82,7 +82,19 @@ binaire compilé avec la feature `gstreamer` **et** le runtime GStreamer :
Sans GStreamer sur la machine, ce binaire se replie automatiquement sur le
backend simulé (visible dans les logs) : rien ne casse.
-## 7. Ce qui arrive ensuite
-
-- Image carte SD prête à flasher (pi-gen) — phase 4.
-- Mise à jour OTA, mot de passe UI, token API — phase 4.
+## 7. Déjà livré (ne pas refaire à la main)
+
+- **Mise à jour OTA** : onglet Système → Mise à jour. Le binaire précédent
+ est conservé, et un bouton « Revenir à la version précédente » permet de
+ faire marche arrière. Inutile de refaire un `scp` + `systemctl restart`.
+- **Mot de passe de l'UI** : `[security] password` dans `node.toml`
+ (protège la web UI et l'API ; l'OSC en UDP reste ouvert).
+- **Jeton de parc** : `[security] fleet_token`, à mettre IDENTIQUE sur tous
+ les nodes, pour les échanges de médias de machine à machine. Le mot de
+ passe de l'UI n'est jamais transmis à une machine annoncée sur le réseau.
+- **Installateurs à profils** : `install.sh` (Linux/Pi, avec détection du
+ modèle de Pi) et `installer-windows.ps1`.
+
+## 8. Ce qui arrive ensuite
+
+- Image carte SD prête à flasher (pi-gen).
diff --git a/docs/TIERS.md b/docs/TIERS.md
index 350f77f..a4a8fbd 100644
--- a/docs/TIERS.md
+++ b/docs/TIERS.md
@@ -1,11 +1,12 @@
# Composants tiers et mentions légales
-Lanterne est un logiciel propriétaire de Pym, distribué sous les termes du
-fichier `LICENSE`. Il s'appuie sur des composants tiers dont les licences
-imposent leurs propres mentions. Ce document doit accompagner **toute**
-distribution du logiciel (il est inclus dans chaque archive de release).
+Lanterne est un logiciel de Pym, distribué sous **licence MIT** (voir le
+fichier `LICENSE` à la racine, repris dans `Cargo.toml`). Il s'appuie sur des
+composants tiers dont les licences imposent leurs propres mentions. Ce
+document doit accompagner **toute** distribution du logiciel (il est inclus
+dans chaque archive de release).
-Dernière revue : 2026-07-29 (version 3.4.1).
+Dernière revue : 2026-08-04 (version 3.5.0).
---
diff --git a/node.toml.example b/node.toml.example
index 2e898cd..ce111e7 100644
--- a/node.toml.example
+++ b/node.toml.example
@@ -24,9 +24,12 @@ player = true # lecture : GStreamer si le binaire l'embarque (feature `gstr
http = true # web UI + API REST/WebSocket
osc = true # contrôle OSC (Chataigne…)
midi = false # contrôle MIDI (bindings ci-dessous)
-sequencer = false # phase 3
-sync = false # phase 2 (multi-device)
-ndi = false # jamais dans le core (plugin optionnel)
+# Il n'y a PAS de clé ici pour le séquenceur, la synchro ou le NDI :
+# séquenceur → toujours démarré, se coupe dans l'onglet « Fonctions » ;
+# synchro → section [sync] ci-dessous (c'est `role` qui décide) ;
+# sortie NDI → section [ndi] ci-dessous (`sortie = true`).
+# Ces trois clés ont existé sans jamais être lues : les écrire ne faisait
+# rien du tout. Elles sont désormais ignorées si elles traînent encore.
[ports]
bind = "0.0.0.0" # "127.0.0.1" pour restreindre à la machine locale
diff --git a/tools/endurance/README.md b/tools/endurance/README.md
index 29150cd..6fc72d3 100644
--- a/tools/endurance/README.md
+++ b/tools/endurance/README.md
@@ -17,12 +17,23 @@ Tout vient de `GET /api/system` :
| `rss_mb` | mémoire de **Lanterne lui-même** (pas de la machine) — c'est elle qui révèle une fuite |
| `p50_us` / `p95_us` / `max_us` | temps de production d'une image sur la dernière seconde |
| `sautees` | images perdues depuis le démarrage (cumul) |
-| `fps` | images réellement présentées |
+| `fps` | images réellement présentées (**cellule vide** = rien ne peut compter : mode KMS, ou binaire sans fenêtre — ce n'est pas zéro) |
| `erreurs` | erreurs dans le journal |
-Le `p95` est le chiffre qui compte : c'est l'à-coup qui fait saccader une
-projection, pas la moyenne. Au-delà de **16 ms**, une sortie 60 Hz commence à
-sauter des images.
+**`p95` et `max_us` se lisent ENSEMBLE — l'un ne remplace pas l'autre.**
+
+- Le `p95` décrit une gêne **installée** : au-delà de **16 ms**, une sortie
+ 60 Hz commence à sauter des images en permanence.
+- Le `max_us` est le seul qui attrape le **blocage isolé** — celui que le
+ spectateur voit. Un figement de 200 ms une fois par minute, à 60 img/s, ne
+ touche qu'une image sur 300 : le `p95` ne bouge alors pas d'une
+ microseconde, pendant que `max_us` monte à 200 000.
+
+Lire le `p95` seul revient donc à déclarer sain un node qui hoquette
+visiblement. C'est pour cette raison que la page Système affiche les deux.
+
+Attention aussi au **nombre d'échantillons** : sur une poignée d'images, le
+`p95` vaut simplement le maximum et n'a aucune valeur statistique.
## La charge
diff --git a/tools/endurance/endurance.sh b/tools/endurance/endurance.sh
index 0af2d96..a8e8a67 100755
--- a/tools/endurance/endurance.sh
+++ b/tools/endurance/endurance.sh
@@ -130,7 +130,17 @@ nettoyer() {
# HUP inclus : une session SSH coupee (le cas normal d'un test lance a
# distance sur un Pi) envoie SIGHUP, pas SIGTERM — sans lui, la mire de test
# restait allumee sur le videoprojecteur.
-trap nettoyer EXIT INT TERM HUP
+#
+# DEUX traps, et pas un seul : un gestionnaire de signal qui ne se termine
+# pas par `exit` rend la main au script, qui REPREND ou il en etait. Avec
+# `trap nettoyer EXIT INT TERM HUP`, un Ctrl+C eteignait la charge et
+# restaurait le mapping... puis la boucle continuait a interroger le node
+# pendant les heures restantes, en empilant des lignes dans le meme CSV --
+# et `nettoyer` repassait une seconde fois a la sortie. Reproduit puis
+# verifie corrige sur un cas minimal : sortie immediate, nettoyage une
+# seule fois, code 130.
+trap nettoyer EXIT
+trap 'nettoyer; trap - EXIT; exit 130' INT TERM HUP
# Une commande par tour, au rythme demande — comme une console OSC qui
# pilote le spectacle en continu. La version precedente n'envoyait que
From a708363b1ba7321371c820cc67b42f26c6e7f2cd Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 5 Aug 2026 05:11:01 +0000
Subject: [PATCH 07/24] Mettre a jour ne doit jamais retirer de fonctions ni
perdre la conduite
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Quatrieme lot : ce qui se passe quand une installation EXISTANTE change de
version. Le scenario n'avait jamais ete audite.
1. L'OTA REMPLACAIT LE BINAIRE PAR UNE VARIANTE PLUS PAUVRE. Le choix de
l'archive se faisait sur la seule plateforme, jamais sur la variante
reellement installee. Sous Windows il visait toujours
`toolbox-node-windows-x64.zip`, le binaire LEGER compile sans
`gstreamer` -- alors que l'installation recommandee, celle que
l'installateur pose et que la page Releases met en avant, est le pack
`…-gstreamer.zip`. Un clic sur « Mettre a jour » depuis l'onglet
Systeme retirait donc la lecture video a une machine de spectacle : les
DLL GStreamer toujours la, plus aucun code pour s'en servir.
Sur un Pi, pire : la seule facon de projeter est de compiler sur place
(l'archive officielle est en --no-default-features), et l'OTA proposait
precisement cette archive -- ni video, ni fenetre de sortie.
Le node declare desormais ses capacites reelles au demarrage (lui seul
connait ses features), et l'archive visee en decoule. Quand aucune
archive publiee ne fait autant que le binaire en place -- un Linux ou
un Pi compile sur place -- la mise a jour est REFUSEE avec la raison,
au lieu de degrader l'installation. Sans declaration, on suppose le
binaire le plus pauvre : jamais l'inverse.
2. « REVENIR A LA VERSION PRECEDENTE » ARRETAIT LE NODE SANS LE RELANCER,
sous Windows. `appliquer()` pose un .bat qui attend, remplace et
relance ; `revenir_en_arriere()` renommait les fichiers et rendait la
main, puis le handler tuait le process. Or sous Windows il n'y a ni
service ni Restart=always : le demarrage automatique n'est qu'un .bat
du dossier Demarrage, execute a l'ouverture de session. Le node ne
revenait donc pas, alors que le message promet « le node redemarre,
reconnexion automatique » -- et c'est le bouton qu'on utilise
justement quand une mise a jour se comporte mal EN PLEIN SPECTACLE.
Meme mecanique que `appliquer()`, et le script est nettoye au
demarrage suivant comme son homologue.
3. TROIS FICHIERS D'ETAT REJETAIENT LE PREMIER CHAMP AJOUTE. NodeConfig,
Reglages et FeatureFlags portent leur filet `#[serde(default)]` ;
EtatLumieres et OutputSettings ne l'avaient pas -- tous leurs champs
etaient obligatoires. Le jour ou une version ajoute un champ, le
`lumieres.json` du client part en `.corrompu` et la console repart
vide : scenes et chasers du spectacle perdus, et ceux-la ne se
reconstituent pas de memoire.
4. UNE CUE INCOMPRISE FAISAIT PERDRE TOUTE LA CONDUITE. Le filet
`actions_tolerantes` existait pour les ACTIONS d'une cue, pas pour les
cues elles-memes : `Declencheur` etant un enum etiquete sans repli, un
declencheur inconnu -- conduite ecrite par une version plus recente,
relue apres un retour arriere -- faisait echouer le fichier ENTIER.
Meme patron applique aux cues : on ne perd plus que celle qu'on ne
comprend pas.
Cinq tests ajoutes, dont la table de correspondance archive/capacites pour
Windows, Linux et Pi (logique rendue pure : testable pour toutes les
plateformes depuis n'importe quelle machine).
261 tests, clippy -D warnings, fmt.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
crates/artnet/src/lib.rs | 25 +++++
crates/control-http/src/ota.rs | 185 ++++++++++++++++++++++++++++++---
crates/core/src/output.rs | 17 +++
crates/core/src/sequenceur.rs | 45 ++++++++
crates/node/src/main.rs | 10 ++
5 files changed, 268 insertions(+), 14 deletions(-)
diff --git a/crates/artnet/src/lib.rs b/crates/artnet/src/lib.rs
index b36a615..afb33e9 100644
--- a/crates/artnet/src/lib.rs
+++ b/crates/artnet/src/lib.rs
@@ -88,7 +88,14 @@ pub struct Chaser {
}
/// L'état complet de la console, publié à l'UI et persisté.
+///
+/// `#[serde(default)]` : filet de MISE À JOUR. Sans lui, tous les champs
+/// étaient obligatoires — le jour où une version ajoute un champ, le
+/// `lumieres.json` du client devient illisible, part en `.corrompu` et la
+/// console repart vide (scènes et chasers du spectacle perdus). Le fichier
+/// est justement celui qu'on ne peut pas reconstituer de mémoire.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default)]
pub struct EtatLumieres {
/// Destination Art-Net (`"255.255.255.255"` = broadcast, ou IP du node
/// lumière). Le port standard 6454 est ajouté si absent.
@@ -542,6 +549,24 @@ pub async fn service(
mod tests {
use super::*;
+ /// Filet de MISE A JOUR : un `lumieres.json` ecrit par une version qui
+ /// ne connaissait pas encore tel champ doit rester lisible. Sans
+ /// `#[serde(default)]` sur le type, tous les champs etaient obligatoires
+ /// et le premier champ ajoute aurait envoye en `.corrompu` les scenes et
+ /// les chasers du client -- ce qu'on ne reconstitue pas de memoire.
+ #[test]
+ fn un_lumieres_json_ancien_reste_lisible() {
+ // Fichier d'une version anterieure : ni `chasers` ni `chaser_actif`.
+ let brut = r#"{"cible":"10.0.0.9","master":200,"faders":[],"scenes":{}}"#;
+ let etat: EtatLumieres =
+ serde_json::from_str(brut).expect("un fichier ancien doit se relire");
+ assert_eq!(etat.cible, "10.0.0.9");
+ assert_eq!(etat.master, 200);
+ // Champs absents : valeurs par defaut, pas d'echec.
+ assert!(etat.chasers.is_empty());
+ assert_eq!(etat.chaser_actif, None);
+ }
+
/// Un fader au canal hors bornes (0 ou > 512) venu d'un fichier
/// corrompu ne fait PAS paniquer l'émission — il est ignoré.
#[test]
diff --git a/crates/control-http/src/ota.rs b/crates/control-http/src/ota.rs
index 909b77c..05b2fc6 100644
--- a/crates/control-http/src/ota.rs
+++ b/crates/control-http/src/ota.rs
@@ -32,13 +32,68 @@ pub struct EtatMiseAJour {
pub asset: Option,
}
-fn nom_asset_plateforme() -> &'static str {
- if cfg!(target_os = "windows") {
- "toolbox-node-windows-x64.zip"
- } else if cfg!(target_arch = "aarch64") {
- "toolbox-node-raspberrypi-arm64.tar.gz"
+/// Ce que le binaire EN COURS d'exécution sait faire. Déclaré une fois au
+/// démarrage par le binaire lui-même (lui seul connaît ses features).
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct Capacites {
+ /// Décodage vidéo et sorties GStreamer (feature `gstreamer`).
+ pub video: bool,
+ /// Fenêtre de sortie (feature `render`).
+ pub fenetre: bool,
+}
+
+static CAPACITES: std::sync::OnceLock = std::sync::OnceLock::new();
+
+/// À appeler au démarrage du node. Sans appel, on suppose le binaire le plus
+/// pauvre — jamais l'inverse : mieux vaut refuser une mise à jour possible
+/// que d'en proposer une qui retire des fonctions.
+pub fn declarer_capacites(capacites: Capacites) {
+ let _ = CAPACITES.set(capacites);
+}
+
+fn capacites() -> Capacites {
+ CAPACITES.get().copied().unwrap_or_default()
+}
+
+/// L'archive de release qui correspond à CE binaire — `None` quand aucune
+/// archive publiée ne sait faire autant que lui.
+///
+/// Le choix se faisait sur la seule plateforme : sous Windows il visait
+/// toujours `…-windows-x64.zip`, le binaire LÉGER, y compris sur une machine
+/// installée avec le pack vidéo. « Mettre à jour » depuis l'onglet Système
+/// remplaçait donc un node qui lit des vidéos par un node qui n'en lit plus
+/// — les DLL GStreamer toujours là, mais plus aucun code pour s'en servir.
+/// Sur un Pi où l'on a compilé sur place (le seul moyen de projeter), il
+/// proposait l'archive `--no-default-features` : ni vidéo, ni fenêtre.
+fn nom_asset_plateforme() -> Option<&'static str> {
+ asset_pour(
+ capacites(),
+ cfg!(target_os = "windows"),
+ cfg!(target_arch = "aarch64"),
+ )
+}
+
+/// Logique pure (plateforme passée en paramètre) : testable pour Windows
+/// comme pour le Pi depuis n'importe quelle machine.
+fn asset_pour(capacites: Capacites, windows: bool, aarch64: bool) -> Option<&'static str> {
+ if windows {
+ // Seule plateforme à publier les deux variantes.
+ Some(if capacites.video {
+ "toolbox-node-windows-x64-gstreamer.zip"
+ } else {
+ "toolbox-node-windows-x64.zip"
+ })
+ } else if capacites.video {
+ // Aucune archive Linux ou Pi ne contient GStreamer : la seule façon
+ // d'avoir la vidéo y est de compiler sur place. Se mettre à jour
+ // depuis la page Releases reviendrait à la perdre.
+ None
+ } else if aarch64 {
+ // L'archive ARM64 officielle est compilée --no-default-features :
+ // elle n'a même pas de fenêtre de sortie.
+ (!capacites.fenetre).then_some("toolbox-node-raspberrypi-arm64.tar.gz")
} else {
- "toolbox-node-linux-x64.tar.gz"
+ Some("toolbox-node-linux-x64.tar.gz")
}
}
@@ -79,9 +134,10 @@ pub fn verifier(version_courante: &str) -> Result {
.get("assets")
.and_then(|a| a.as_array())
.and_then(|assets| {
+ let attendu = nom_asset_plateforme()?;
assets.iter().find_map(|a| {
let nom = a.get("name")?.as_str()?;
- (nom == nom_asset_plateforme()).then(|| nom.to_string())
+ (nom == attendu).then(|| nom.to_string())
})
});
Ok(EtatMiseAJour {
@@ -123,10 +179,16 @@ pub fn telecharger() -> Result {
if !etat.plus_recente {
return Err(format!("déjà en {version} : rien à télécharger"));
}
- let asset = etat
- .asset
- .as_deref()
- .ok_or("pas d'archive pour cette plateforme dans la release")?;
+ let asset = etat.asset.as_deref().ok_or_else(|| {
+ if nom_asset_plateforme().is_none() {
+ "ce binaire a été compilé sur place (vidéo et/ou fenêtre de sortie) : \
+ aucune archive publiée ne fait autant. Une mise à jour automatique \
+ vous ferait PERDRE la lecture vidéo. Recompilez plutôt sur la machine."
+ .to_string()
+ } else {
+ "pas d'archive pour cette plateforme dans la release".to_string()
+ }
+ })?;
let url = format!("https://github.com/{DEPOT}/releases/latest/download/{asset}");
let dossier = dossier_du_binaire()?;
let archive = dossier.join(asset);
@@ -302,9 +364,13 @@ pub fn nettoyer_apres_demarrage() {
warn!("dossier du binaire inconnu : pas de nettoyage OTA");
return;
};
- let script = dossier.join("mise-a-jour.bat");
- if script.exists() && std::fs::remove_file(&script).is_ok() {
- info!(fichier = %script.display(), "script de bascule nettoyé");
+ // `relance.bat` autant que `mise-a-jour.bat` : le retour arrière en pose
+ // un, il ne doit pas rester dans le dossier d'installation.
+ for nom in ["mise-a-jour.bat", "relance.bat"] {
+ let script = dossier.join(nom);
+ if script.exists() && std::fs::remove_file(&script).is_ok() {
+ info!(fichier = %script.display(), "script de bascule nettoyé");
+ }
}
if binaire_precedent().is_some() {
info!("version précédente conservée : retour arrière possible depuis l'onglet Système");
@@ -341,6 +407,34 @@ pub fn revenir_en_arriere() -> Result {
let _ = std::fs::rename(&ecarte, &courant); // rien n'a changé
return Err(format!("retour arrière échoué (annulé) : {err}"));
}
+ #[cfg(windows)]
+ {
+ // Sous Windows il n'y a NI service NI Restart=always : le démarrage
+ // automatique n'est qu'un .bat du dossier Démarrage, exécuté à
+ // l'ouverture de session. Sans ce script, le retour arrière arrêtait
+ // le node et personne ne le relançait — alors que le message promet
+ // « le node redémarre, reconnexion automatique ». Même mécanique que
+ // `appliquer()`, à ceci près que les renommages sont déjà faits :
+ // il ne reste qu'à attendre la fin du process et à relancer.
+ let script = dossier.join("relance.bat");
+ let contenu = format!(
+ "@echo off\r\n\
+ timeout /t 2 /nobreak > NUL\r\n\
+ start \"\" \"{courant}\"\r\n",
+ courant = courant.display(),
+ );
+ match std::fs::write(&script, contenu) {
+ Ok(()) => {
+ if let Err(err) = std::process::Command::new("cmd")
+ .args(["/C", "start", "/min", "", &script.to_string_lossy()])
+ .spawn()
+ {
+ warn!(%err, "relance Windows non programmée : relancer le node à la main");
+ }
+ }
+ Err(err) => warn!(%err, "script de relance non écrit : relancer le node à la main"),
+ }
+ }
info!("retour à la version précédente effectué : le node redémarre");
Ok("Version précédente restaurée : le node redémarre.".into())
}
@@ -349,6 +443,69 @@ pub fn revenir_en_arriere() -> Result {
mod tests {
use super::*;
+ /// Une mise à jour ne doit JAMAIS retirer de fonctions. Le choix se
+ /// faisait sur la seule plateforme : sous Windows il visait toujours le
+ /// binaire léger, y compris sur une machine installée avec le pack
+ /// vidéo — « Mettre à jour » retirait alors la lecture vidéo.
+ #[test]
+ fn l_archive_visee_ne_retire_jamais_de_fonctions() {
+ let leger = Capacites {
+ video: false,
+ fenetre: true,
+ };
+ let pack_video = Capacites {
+ video: true,
+ fenetre: true,
+ };
+ let arm64_officiel = Capacites {
+ video: false,
+ fenetre: false,
+ };
+
+ // Windows : la seule plateforme qui publie les deux variantes.
+ assert_eq!(
+ asset_pour(pack_video, true, false),
+ Some("toolbox-node-windows-x64-gstreamer.zip")
+ );
+ assert_eq!(
+ asset_pour(leger, true, false),
+ Some("toolbox-node-windows-x64.zip")
+ );
+
+ // Linux/Pi : aucune archive ne contient GStreamer. Un binaire
+ // compilé sur place pour avoir la vidéo ne doit rien se voir
+ // proposer, plutôt que de la perdre.
+ assert_eq!(asset_pour(pack_video, false, false), None);
+ assert_eq!(asset_pour(pack_video, false, true), None);
+
+ // L'archive ARM64 officielle n'a même pas de fenêtre de sortie :
+ // elle ne convient qu'à un binaire qui n'en a pas non plus.
+ assert_eq!(asset_pour(leger, false, true), None);
+ assert_eq!(
+ asset_pour(arm64_officiel, false, true),
+ Some("toolbox-node-raspberrypi-arm64.tar.gz")
+ );
+
+ // Linux x64 : l'archive officielle a bien la fenêtre.
+ assert_eq!(
+ asset_pour(leger, false, false),
+ Some("toolbox-node-linux-x64.tar.gz")
+ );
+ }
+
+ /// Sans déclaration explicite, on suppose le binaire le plus pauvre —
+ /// jamais l'inverse.
+ #[test]
+ fn capacites_par_defaut_prudentes() {
+ assert_eq!(
+ Capacites::default(),
+ Capacites {
+ video: false,
+ fenetre: false
+ }
+ );
+ }
+
/// Le coeur du garde-fou : ne JAMAIS proposer une version antérieure.
/// Avant, une simple inégalité proposait « 3.3.0 » à un node en 3.4.0
/// (binaire de CI plus récent que la dernière release) — et accepter
diff --git a/crates/core/src/output.rs b/crates/core/src/output.rs
index d0fd88f..0df02b3 100644
--- a/crates/core/src/output.rs
+++ b/crates/core/src/output.rs
@@ -21,7 +21,13 @@ pub struct MonitorInfo {
}
/// Réglages appliqués par la fenêtre de sortie (modifiables à chaud).
+///
+/// `#[serde(default)]` : un `sortie.json` écrit par une version antérieure
+/// (ou amputé d'un champ ajouté depuis) reste lisible, les champs absents
+/// prenant leur valeur par défaut — au lieu de faire repartir la sortie sur
+/// le mauvais écran après une mise à jour.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(default)]
pub struct OutputSettings {
/// Écran cible, par index dans la liste détectée.
pub monitor: usize,
@@ -50,6 +56,17 @@ impl OutputSettings {
mod tests {
use super::*;
+ /// Un `sortie.json` amputé d'un champ (version antérieure, ou champ
+ /// ajouté depuis) reste lisible : sinon la sortie repartait sur le
+ /// mauvais écran après une mise à jour.
+ #[test]
+ fn un_sortie_json_ancien_reste_lisible() {
+ let settings: OutputSettings =
+ serde_json::from_str(r#"{"monitor":2}"#).expect("un fichier ancien doit se relire");
+ assert_eq!(settings.monitor, 2);
+ assert!(!settings.fullscreen);
+ }
+
#[test]
fn settings_persist_and_survive_corruption() {
let dir = tempfile::tempdir().expect("tempdir");
diff --git a/crates/core/src/sequenceur.rs b/crates/core/src/sequenceur.rs
index c67435c..01444cd 100644
--- a/crates/core/src/sequenceur.rs
+++ b/crates/core/src/sequenceur.rs
@@ -120,9 +120,35 @@ where
Ok(actions)
}
+/// Désérialise les cues une par une, sur le modèle d'[`actions_tolerantes`].
+///
+/// Le filet existait pour les ACTIONS mais pas pour les cues elles-mêmes :
+/// `Declencheur` est un enum étiqueté sans repli, si bien qu'un déclencheur
+/// inconnu — une conduite écrite par une version plus récente, relue après
+/// un « Revenir à la version précédente » — faisait échouer le fichier
+/// ENTIER. Perdre la cue qu'on ne comprend pas vaut infiniment mieux que
+/// perdre tout le spectacle.
+fn cues_tolerantes<'de, D>(deserializer: D) -> Result, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ let brutes = Vec::::deserialize(deserializer)?;
+ let mut cues = Vec::with_capacity(brutes.len());
+ for brute in brutes {
+ match serde_json::from_value::(brute.clone()) {
+ Ok(cue) => cues.push(cue),
+ Err(err) => {
+ tracing::error!(%err, cue = %brute, "cue illisible — ignorée (le reste de la conduite est conservé)");
+ }
+ }
+ }
+ Ok(cues)
+}
+
/// L'état du séquenceur, publié à l'UI et persisté (sans le transitoire).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct EtatSequenceur {
+ #[serde(default, deserialize_with = "cues_tolerantes")]
pub cues: Vec,
/// Cue en attente d'enchaînement (nom, échéance en ms) — transitoire,
/// publié à l'UI, purgé au chargement.
@@ -499,6 +525,25 @@ mod tests {
use super::*;
use crate::Bus;
+ /// Scénario réel : une version plus récente a écrit une conduite avec un
+ /// déclencheur qu'on ne connaît pas (retour arrière après mise à jour).
+ /// Avant, `Declencheur` étant un enum étiqueté sans repli, TOUTE la
+ /// conduite devenait illisible et partait en `.corrompu` — le spectacle
+ /// du soir avec. On ne perd plus que la cue incomprise.
+ #[test]
+ fn une_cue_au_declencheur_inconnu_ne_fait_pas_perdre_la_conduite() {
+ let brut = r#"{
+ "cues": [
+ {"nom": "ouverture", "declencheur": {"type": "manuel"}, "actions": []},
+ {"nom": "venue_du_futur", "declencheur": {"type": "au_lever_du_jour"}, "actions": []},
+ {"nom": "final", "declencheur": {"type": "manuel"}, "actions": []}
+ ]
+ }"#;
+ let etat: EtatSequenceur = serde_json::from_str(brut).expect("la conduite doit survivre");
+ let noms: Vec<&str> = etat.cues.iter().map(|c| c.nom.as_str()).collect();
+ assert_eq!(noms, vec!["ouverture", "final"]);
+ }
+
#[test]
fn les_jours_de_semaine_filtrent_le_declencheur() {
// Vide = tous les jours.
diff --git a/crates/node/src/main.rs b/crates/node/src/main.rs
index 86f6e59..be0f686 100644
--- a/crates/node/src/main.rs
+++ b/crates/node/src/main.rs
@@ -194,6 +194,16 @@ async fn run(mut config: NodeConfig, logs: LogBuffer) -> Result<(), Box
Date: Wed, 5 Aug 2026 05:16:01 +0000
Subject: [PATCH 08/24] Version 3.5.0 : CHANGELOG, bump, et la portee reelle
des reglages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
La section « Non publie » devient la 3.5.0 et couvre les DEUX chantiers :
la mesure de performance (deja ecrite) et l'audit des zones jamais
couvertes. Le manuel etant deja passe en v3.5, Cargo.toml devait suivre --
sinon l'UI affichait une version et le manuel une autre.
Corrige au passage, meme famille que le reste de l'audit :
LES REGLAGES DE PERFORMANCE ANNONCAIENT UNE RESOLUTION QU'ILS
N'APPLIQUAIENT PAS. `config.resolution` n'est lu qu'a UN endroit du depot :
la construction de la sortie DRM/KMS. En mode fenetre -- le defaut, et le
seul que pose install.sh -- la fenetre rend a la taille de sa surface, et
la valeur est purement decorative. Or l'installateur detecte un Pi 3,
ecrit 960×540 et annonce « Reglages de performance pi3 ecrits », le
journal confirmait « reglages appliques (largeur=960 hauteur=540 ) », et
la carte Systeme proposait les deux champs sans reserve. Personne ne
pouvait deviner que rien ne changeait.
Plutot que de faire semblant : le journal ne mentionne la resolution que
lorsqu'elle sert (mode KMS) et emet sinon un warn! explicite ; les deux
champs de l'UI portent la mention « (KMS) » et l'infobulle correspondante ;
le conseil Pi 3 de l'UI, celui de install.sh et celui du manuel disent
« 960×540 en sortie sans bureau » au lieu de le promettre partout.
Implementer vraiment la resolution de rendu en mode fenetre serait un
changement du chemin de rendu, a valider sur un Pi : c'est note en limite
assumee dans le CHANGELOG, pas fait en aveugle.
Restent dits ailleurs : node.toml.example previent que [output] et [midi]
sont sans effet sur l'archive ARM64 officielle, et deploy/README pointe
enfin la page Releases (publique) plutot que les seuls artefacts de
l'onglet Actions -- en disant ce que chaque archive sait faire.
Le nombre de tests du README est desormais le chiffre reel (261), pas une
estimation.
261 tests, clippy -D warnings, fmt, shellcheck, smoke.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
CHANGELOG.md | 70 +++++++++++++++++++++++++--
Cargo.lock | 20 ++++----
Cargo.toml | 2 +-
README.md | 2 +-
crates/control-http/assets/index.html | 16 +++---
crates/node/src/main.rs | 34 ++++++++++---
deploy/README.md | 17 +++++--
deploy/install.sh | 2 +-
docs/manuel.html | 2 +-
node.toml.example | 5 ++
10 files changed, 134 insertions(+), 36 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2671453..a3be33c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,65 @@
Évolutions notables du node Toolbox. Format inspiré de
[Keep a Changelog](https://keepachangelog.com/fr/), versionnage SemVer.
-## [Non publié]
+## [3.5.0] — 2026-08-04
+
+Deux chantiers : la **mesure de performance** (ce qu'on peut enfin voir), et
+un **audit des zones qu'aucune passe précédente n'avait couvertes** — la
+cohérence entre ce que Lanterne *dit* et ce qu'il *fait*.
+
+### L'audit : dire la vérité sur ce que les binaires savent faire
+
+Six dimensions jamais auditées (dérive doc/réalité, complétude des contrats
+de contrôle, cohérence UI ↔ serveur, démarrage à froid, troisième passe sur
+la mesure, mise à jour d'une installation existante), chaque lot repassé par
+une relecture adversariale.
+
+- **Les binaires publiés ne lisent pas tous des vidéos**, et le manuel disait
+ le contraire. `gstreamer` n'est pas une feature par défaut : seuls les packs
+ Windows `…-gstreamer` décodent. L'archive **Raspberry Pi officielle ne
+ projette rien du tout** (compilée `--no-default-features` : ni fenêtre de
+ sortie, ni MIDI, ni GStreamer) alors que le manuel invitait à installer des
+ paquets `gstreamer1.0-*` et à brancher le vidéoprojecteur. Manuel, README et
+ notes de release disent maintenant ce que chaque archive sait faire.
+- **Aucune archive publique ne portait les mentions légales.** `docs/TIERS.md`
+ affirme deux fois être « inclus dans chaque archive de release » ; le
+ correctif de la 3.4.1 n'avait été posé que sur les artefacts de l'onglet
+ Actions. La mention IJG — que la licence de `jpeg-encoder` exige de faire
+ accompagner le logiciel — ne partait avec aucun binaire publié, pas même le
+ pack qui embarque les DLL LGPL et les plugins GPL de GStreamer.
+- **Mettre à jour retirait des fonctions.** L'OTA choisissait l'archive sur la
+ seule plateforme : une machine Windows installée avec le pack vidéo se
+ voyait proposer le binaire léger, et « Mettre à jour » lui retirait la
+ lecture vidéo. Le node déclare désormais ses capacités réelles, et refuse la
+ mise à jour quand aucune archive publiée ne fait autant que lui.
+- **Chataigne ne voyait ni la vitesse, ni la régie.** `/rate`, `/blending`,
+ `/cue/go`, `/dmx/scene` et `/dmx/chaser` fonctionnaient en OSC sans être
+ publiées dans OSCQuery — donc introuvables. `/transport` et `/media` sont
+ désormais déclarés en lecture seule : le node les émettait déjà en retour,
+ sans que rien ne les recueille.
+- **La conduite du spectacle pouvait enregistrer `load "undefined"`.** L'UI
+ lisait un champ inexistant sur les médias : la cue partait sans broncher, et
+ la panne n'apparaissait que le soir, à l'heure dite.
+- **Un Pi qui projette parfaitement était déclaré « sortie morte ».** Le
+ garde-fou « une absence, pas un faux zéro » ne protégeait que la moitié des
+ chiffres : `fps` restait un zéro dur là où `rendu` devenait `null`.
+- Démarrage à froid et mise à jour : préfixe relatif produisant une unité
+ systemd invalide, ancien binaire laissé en marche après réinstallation,
+ `lib\lib` imbriqué à la deuxième installation Windows, trois fichiers d'état
+ qui auraient rejeté le premier champ ajouté, une cue au déclencheur inconnu
+ qui faisait perdre toute la conduite, `Ctrl+C` qui n'arrêtait pas le harnais
+ d'endurance, réglages morts (`[paths] shaders`, trois clés `[modules]`).
+
+### Limites assumées de l'audit
+
+- L'unité systemd **ne projette toujours pas en mode fenêtre** (ni `DISPLAY`,
+ ni ordonnancement graphique). Le fait est désormais écrit dans l'unité, avec
+ le bloc à décommenter — mais l'activer par défaut casserait les
+ installations sans bureau, et cela demande un Pi réel.
+- Le réglage de **résolution de rendu ne s'applique qu'à la sortie KMS** ; en
+ mode fenêtre il est ignoré (voir ci-dessous).
+
+### La mesure de performance : ce qu'on peut enfin voir
Lanterne savait dire combien d'images par seconde il présentait, jamais
combien de temps chacune coûtait ni combien de mémoire il occupait. Deux
@@ -11,7 +69,7 @@ machines très différentes — l'une à l'aise, l'autre au bord du décrochage
affichaient donc le même « 60 img/s ». Cette section comble ce trou, et
rend compte des deux relectures adversariales qui ont suivi.
-### Ce qu'on peut enfin voir
+#### Ce que la mesure apporte
- **Temps par image** (p50 / p95 / pire image) et **images perdues**, mesurés
sans allocation ni verrou sur le chemin chaud. Le p95 révèle une gêne
@@ -27,7 +85,7 @@ rend compte des deux relectures adversariales qui ont suivi.
- `tools/endurance/` : deux collecteurs (Windows, Linux/Pi) au même format,
un dépouillement commun, et une charge continue réaliste.
-### Ce que les relectures ont corrigé
+#### Ce que les relectures de la mesure ont corrigé
Deux passes multi-agents, la seconde portant sur les correctifs de la
première. Elles ont trouvé 29 puis 41 défauts. Les plus instructifs :
@@ -47,11 +105,13 @@ première. Elles ont trouvé 29 puis 41 défauts. Les plus instructifs :
(`core.fileMode` était à `true` sur le dépôt) : après un clone sur le Pi,
`./install.sh` répondait « Permission denied ».
-### Limites assumées
+#### Limites assumées de la mesure
- La mesure de rendu **n'existe pas en mode KMS ni dans le binaire ARM64**
officiel (compilé sans fenêtre). L'API renvoie `"rendu": null` et l'UI
- affiche « n/d » — une absence, pas un faux zéro rassurant.
+ affiche « n/d » — une absence, pas un faux zéro rassurant. `fps` suit la
+ même règle depuis l'audit ci-dessus : il restait un zéro dur, et faisait
+ passer un Pi parfaitement sain pour une sortie morte.
- Le **p95 n'a de sens que sur assez d'images**. Au repos, une seconde n'en
contient que deux ou trois et il vaut alors le maximum. Le nombre
d'échantillons est publié pour qu'on ne s'y trompe pas.
diff --git a/Cargo.lock b/Cargo.lock
index fb878a9..ed11722 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3385,7 +3385,7 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "toolbox-artnet"
-version = "3.4.1"
+version = "3.5.0"
dependencies = [
"serde",
"serde_json",
@@ -3398,7 +3398,7 @@ dependencies = [
[[package]]
name = "toolbox-control-http"
-version = "3.4.1"
+version = "3.5.0"
dependencies = [
"axum",
"base64",
@@ -3424,7 +3424,7 @@ dependencies = [
[[package]]
name = "toolbox-control-midi"
-version = "3.4.1"
+version = "3.5.0"
dependencies = [
"midir",
"thiserror 2.0.18",
@@ -3434,7 +3434,7 @@ dependencies = [
[[package]]
name = "toolbox-control-osc"
-version = "3.4.1"
+version = "3.5.0"
dependencies = [
"rosc",
"serde_json",
@@ -3446,7 +3446,7 @@ dependencies = [
[[package]]
name = "toolbox-core"
-version = "3.4.1"
+version = "3.5.0"
dependencies = [
"serde",
"serde_json",
@@ -3460,7 +3460,7 @@ dependencies = [
[[package]]
name = "toolbox-engine"
-version = "3.4.1"
+version = "3.5.0"
dependencies = [
"rayon",
"serde",
@@ -3472,7 +3472,7 @@ dependencies = [
[[package]]
name = "toolbox-gst"
-version = "3.4.1"
+version = "3.5.0"
dependencies = [
"gstreamer",
"gstreamer-app",
@@ -3486,7 +3486,7 @@ dependencies = [
[[package]]
name = "toolbox-ndi"
-version = "3.4.1"
+version = "3.5.0"
dependencies = [
"libloading",
"tokio",
@@ -3497,7 +3497,7 @@ dependencies = [
[[package]]
name = "toolbox-node"
-version = "3.4.1"
+version = "3.5.0"
dependencies = [
"mdns-sd",
"serde",
@@ -3521,7 +3521,7 @@ dependencies = [
[[package]]
name = "toolbox-render"
-version = "3.4.1"
+version = "3.5.0"
dependencies = [
"bytemuck",
"naga",
diff --git a/Cargo.toml b/Cargo.toml
index 6f217c3..2c126f1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,7 +14,7 @@ members = [
]
[workspace.package]
-version = "3.4.1"
+version = "3.5.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/pymenvert/toolbox"
diff --git a/README.md b/README.md
index 4760c91..a28648d 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ préfixe historique `toolbox-` (aucun chemin ni contrat ne change).
## État — v3.5.0
-La chaîne complète est fonctionnelle et testée (250+ tests, CI Linux +
+La chaîne complète est fonctionnelle et testée (261 tests, CI Linux +
Windows + check ARM64) : **lecture vidéo réelle** (GStreamer, boucle sans
coupure), **fenêtre de sortie** avec warp/mires/couleur/effets calculés par
le **GPU** (wgpu/Vulkan, repli CPU automatique), sources externes (capture,
diff --git a/crates/control-http/assets/index.html b/crates/control-http/assets/index.html
index c28bde5..111b2ac 100644
--- a/crates/control-http/assets/index.html
+++ b/crates/control-http/assets/index.html
@@ -861,10 +861,10 @@
Réglages de performance
-
-
+
+
@@ -873,7 +873,11 @@
Réglages de performance
Enregistré dans reglages.json et appliqué au
prochain lancement du logiciel (la résolution de rendu et le GPU ne
- se changent pas à chaud).
+ se changent pas à chaud).
+ Largeur et hauteur ne servent qu'à la sortie sans bureau
+ ([output] mode = "kms", Raspberry Pi OS Lite). En mode
+ fenêtre — le défaut — le rendu suit la taille de la fenêtre et ces
+ deux valeurs sont ignorées ; le journal le dit au démarrage.
Chataigne
@@ -2711,7 +2715,7 @@
Alimentation de la machine
pi4: { largeur: 1920, hauteur: 1080, gpu: true, kms_fps: 30,
conseil: "Pi 4 : 1080p OK (décodage H.264 matériel jusqu'en 1080p). Si ça rame avec beaucoup d'effets, passe en 1280×720." },
pi3: { largeur: 960, hauteur: 540, gpu: false, kms_fps: 20,
- conseil: "Pi 3 (allégé) : lecture + mapping en 960×540, rendu processeur (la puce graphique est trop ancienne pour le rendu GPU). Conseillé : couper l'aperçu et le parc dans Fonctions ; éviter la sortie RTSP." },
+ conseil: "Pi 3 (allégé) : rendu processeur (la puce graphique est trop ancienne pour le rendu GPU), et 960×540 SI la sortie sans bureau (KMS) est utilisée. Conseillé : couper l'aperçu et le parc dans Fonctions ; éviter la sortie RTSP." },
perso: { conseil: "Personnalisé : règle les champs à la main." },
};
function renderReglages(r) {
diff --git a/crates/node/src/main.rs b/crates/node/src/main.rs
index be0f686..51dc3ce 100644
--- a/crates/node/src/main.rs
+++ b/crates/node/src/main.rs
@@ -147,13 +147,33 @@ async fn run(mut config: NodeConfig, logs: LogBuffer) -> Result<(), BoxInstallation par profils
Sur Raspberry Pi, l'installateur reconnaît le modèle
(Pi 3 / 4 / 5) et affiche ce qui est conseillé ou déconseillé pour cette
machine, propose le bon profil et écrit les réglages de performance
- adaptés (Pi 3 : version allégée — lecture + mapping en 960×540, rendu
+ adaptés (Pi 3 : version allégée — 960×540 en sortie sans bureau, rendu
processeur). Tout reste modifiable ensuite dans Système → Réglages de
performance.
Version portable
diff --git a/node.toml.example b/node.toml.example
index ce111e7..549ba49 100644
--- a/node.toml.example
+++ b/node.toml.example
@@ -50,6 +50,10 @@ logs = "logs" # journal quotidien sur disque (toolbox.log.AAAA-MM-JJ
# mapping. Sans mire ni lecture, la sortie est noire. F11 : plein écran,
# Échap : le quitte. NB : l'écran et le plein écran choisis dans l'UI web
# sont persistés dans sortie.json et priment sur ces valeurs au démarrage.
+# NB : l'archive officielle `toolbox-node-raspberrypi-arm64` est compilée
+# SANS fenêtre de sortie (et sans MIDI) : toute cette section y est sans
+# effet, et le mode "kms" ci-dessous exige lui aussi une compilation avec
+# `--features gstreamer`. Pour projeter depuis un Pi, compiler sur le Pi.
enabled = true
monitor = 0 # index de l'écran cible ; la liste détectée est dans les logs
fullscreen = false # true = plein écran sans bordure sur l'écran cible
@@ -143,6 +147,7 @@ log_buffer = 1000 # entrées gardées par la page de logs
#url = "https://exemple.fr/rapports-toolbox"
[midi]
+# NB : absent de l'archive officielle ARM64 (compilée sans la feature `midi`).
# Sous-chaîne du nom du port MIDI à ouvrir (absent = premier port trouvé).
#port = "APC"
From 8bd5c438d574f8bfaf6855aa6f223381af709132 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 5 Aug 2026 05:20:30 +0000
Subject: [PATCH 09/24] =?UTF-8?q?Le=20bouton=20=C2=AB=20Identifier=20?=
=?UTF-8?q?=C2=BB=20du=20parc=20n'a=20jamais=20marche=20entre=20deux=20mac?=
=?UTF-8?q?hines?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
L'UI visait le node distant directement, en `mode: "no-cors"`. Depuis la
v3.4.0, toute requete mutatrice dont l'Origin ne correspond pas au Host est
refusee -- et le navigateur JOINT toujours Origin sur un POST
inter-origines. La cible repondait donc 403, trace en WARN dans SON journal
(que personne ne regarde). Comme la reponse d'un appel no-cors est opaque,
le `.catch` ne se declenchait jamais : l'UI affichait « Mire coins envoyee
a » a tous les coups. Le geste sert justement a savoir quel
videoprojecteur correspond a quelle machine -- il repondait toujours oui,
sans rien faire.
Relais serveur-a-serveur, sur le modele de `fleet_media` : la cible doit
etre un node connu du parc, et c'est le jeton de parc -- jamais le mot de
passe de l'UI -- qui authentifie. L'anti-CSRF n'est pas affaibli.
`/api/identify` n'est VOLONTAIREMENT pas ajoute aux routes ouvertes par le
jeton de parc : ce jeton est commun a toutes les machines et les cibles
viennent d'un mDNS non authentifie, alors que la mire « coins » couvre la
sortie pendant 4 secondes -- donc en plein spectacle. Identifier un node
protege par mot de passe reste refuse, mais le message le DIT desormais, au
lieu d'annoncer un succes ; et il ne conseille pas de poser un fleet_token,
qui n'y changerait rien.
Verifie en reel sur le binaire 3.5.0 :
- OSCQuery publie 53 adresses contre 45 auparavant ; /rate, /blending,
/cue/go, /dmx/scene, /dmx/chaser, /transport, /media et
/playlist/position sont tous presents, /transport avec ACCESS 1.
- En mode KMS, /api/system et /api/outputs renvoient bien `fps: null` (et
non plus 0.0, qui faisait passer la sortie pour morte) en meme temps que
`rendu: null`.
- En mode fenetre avec un reglages.json pi3, le journal n'annonce plus
« largeur=960 hauteur=540 appliques » mais un WARN « resolution de rendu
IGNOREE : elle ne s'applique qu'a la sortie KMS » ; en mode KMS, aucun
warn -- elle sert vraiment.
261 tests, clippy -D warnings, fmt.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
crates/control-http/assets/index.html | 24 +++++++++---
crates/control-http/src/lib.rs | 54 +++++++++++++++++++++++++++
2 files changed, 73 insertions(+), 5 deletions(-)
diff --git a/crates/control-http/assets/index.html b/crates/control-http/assets/index.html
index 111b2ac..6d722da 100644
--- a/crates/control-http/assets/index.html
+++ b/crates/control-http/assets/index.html
@@ -2794,11 +2794,25 @@
Alimentation de la machine
li.appendChild(open);
}
const ident = document.createElement("button"); ident.className = "mini"; ident.textContent = "Identifier";
- ident.addEventListener("click", () => {
- // no-cors : requête envoyée au node cible sans lire la réponse
- // (suffisant pour un déclencheur, et licite entre origines).
- fetch(n.url + "api/identify", { method: "POST", mode: "no-cors" }).catch(() => {});
- toast("Mire coins envoyée à " + n.name);
+ ident.addEventListener("click", async () => {
+ // Le node cible est vise par un RELAIS serveur-a-serveur, pas
+ // directement : un POST inter-origines porte toujours un en-tete
+ // Origin, que l'anti-CSRF de la cible refuse (403). En no-cors la
+ // reponse etait opaque, donc le .catch ne se declenchait jamais et
+ // l'UI annoncait « Mire envoyee » alors que rien n'etait parti.
+ // api() affiche DEJA le message d'erreur : ne pas en remettre un
+ // second ici (les doubles toasts ont ete traques en v3.3.0).
+ ident.disabled = true;
+ try {
+ if (n.soi_meme) {
+ await api("/api/identify", { method: "POST" });
+ toast("Mire coins affichée ici");
+ } else {
+ await api("/api/fleet/identify?url=" + encodeURIComponent(n.url), { method: "POST" });
+ toast("Mire coins envoyée à " + n.name);
+ }
+ } catch (e) { /* message deja affiche par api() */ }
+ finally { ident.disabled = false; }
});
li.appendChild(ident);
ul.appendChild(li);
diff --git a/crates/control-http/src/lib.rs b/crates/control-http/src/lib.rs
index 8a519b7..4f2d73d 100644
--- a/crates/control-http/src/lib.rs
+++ b/crates/control-http/src/lib.rs
@@ -354,6 +354,7 @@ pub fn router(app: AppState) -> Router {
.route("/api/diagnostic.zip", get(diagnostic_zip))
.route("/api/features", get(features_get).post(features_set))
.route("/api/fleet/media", get(fleet_media))
+ .route("/api/fleet/identify", post(fleet_identify))
.route("/api/fleet/push", post(fleet_push))
.route("/api/dmx", get(dmx_get).post(dmx_commande))
.route("/api/cues", get(cues_get).post(cues_commande))
@@ -1406,6 +1407,59 @@ async fn fleet_media(
Ok(Json(json))
}
+/// Fait clignoter la mire « coins » sur un AUTRE node du parc.
+///
+/// L'UI visait la cible directement, en `mode: "no-cors"`. Depuis la v3.4.0,
+/// toute requête mutatrice dont l'`Origin` ne correspond pas au `Host` est
+/// refusée — et le navigateur JOINT toujours `Origin` sur un POST
+/// inter-origines. La cible répondait donc 403, tracé dans SON journal (que
+/// personne ne regarde), pendant que l'UI annonçait « Mire envoyée » : le
+/// bouton n'a jamais fonctionné entre deux machines. Un relais
+/// serveur-à-serveur contourne le problème sans affaiblir l'anti-CSRF, sur
+/// le modèle de `fleet_media` : la cible doit être un node connu du parc, et
+/// c'est le jeton de parc — jamais le mot de passe de l'UI — qui authentifie.
+async fn fleet_identify(
+ State(app): State,
+ axum::extract::Query(params): axum::extract::Query>,
+) -> Result {
+ let url = params
+ .get("url")
+ .ok_or_else(|| CoreError::InvalidCommand("paramètre url manquant".into()))?;
+ if !urls_du_parc(&app).iter().any(|u| u == url) {
+ return Err(CoreError::InvalidCommand(format!("node inconnu du parc : {url}")).into());
+ }
+ let mut requete = client_http()?.post(format!("{url}api/identify"));
+ if let Some(jeton) = &app.fleet_token {
+ requete = requete.header(ENTETE_JETON_PARC, jeton.clone());
+ }
+ let reponse = requete
+ .send()
+ .await
+ .map_err(|e| CoreError::InvalidCommand(format!("node injoignable : {e}")))?;
+ if !reponse.status().is_success() {
+ let statut = reponse.status();
+ // Message SPÉCIFIQUE : le jeton de parc n'ouvre volontairement que
+ // les échanges de médias. Renvoyer ici le conseil « posez le même
+ // fleet_token » enverrait l'utilisateur régler un paramètre qui ne
+ // changerait rien — l'identification restera refusée par un node
+ // protégé par mot de passe, et c'est voulu : la mire « coins »
+ // couvre la sortie pendant 4 s, donc en plein spectacle.
+ let message = if statut == reqwest::StatusCode::UNAUTHORIZED
+ || statut == reqwest::StatusCode::FORBIDDEN
+ {
+ "node refusé (HTTP 401/403) : il est protégé par un mot de passe d'interface. \
+ L'identification à distance n'est pas couverte par le jeton de parc (elle \
+ couvrirait la sortie d'une mire pendant 4 s). Ouvrez l'UI de ce node pour \
+ l'identifier."
+ .to_string()
+ } else {
+ format!("refusé par le node : HTTP {statut}")
+ };
+ return Err(CoreError::InvalidCommand(message).into());
+ }
+ Ok(StatusCode::NO_CONTENT)
+}
+
#[derive(Deserialize)]
struct PushRequest {
/// Média LOCAL à envoyer (nom plat, déjà dans `media/`).
From 0c5e3e34a4696294dd563c83fa3ec4989fee666d Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 5 Aug 2026 05:24:27 +0000
Subject: [PATCH 10/24] Les faders MIDI ne pouvaient piloter ni un effet ni la
vitesse
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Le manuel promet depuis la v1, section Effets : « une intensite 0..1 par
effet — un fader MIDI ou OSC suffit a l'activer et le doser ». En OSC,
oui. En MIDI, non : `ScaleTarget` ne comptait que 9 cibles (le volume et
les 8 reglages couleur). Ni les cinq effets, ni la vitesse de lecture.
Et il n'y avait pas de contournement : un binding CC sans `scale` ne peut
renvoyer qu'une commande FIXE, donc une valeur CONSTANTE -- tourner le
fader rejouait la meme valeur. Un operateur qui suivait le manuel ecrivait
`scale = "pixelate"`... et le node REFUSAIT DE DEMARRER, sur une erreur de
desserialisation d'un enum, pour une simple cible inconnue.
Deux corrections, donc :
1. Six cibles ajoutees -- pixelate, posterize, noise, sharpen, mirror
(bornees 0..1, la valeur du CC passe telle quelle) et rate (mise a
l'echelle sur les bornes de SetRate, 0,25×–4×, pour que le bus n'ait
aucune raison de refuser la commande a fond de course ; verifie par
contre-epreuve en appliquant les deux extremes a l'etat).
2. `scale` recoit le meme filet que `command` : une cible mal
orthographiee est ignoree avec un avertissement nommant la faute et la
liste des cibles possibles, au lieu d'empecher le demarrage. C'est
exactement la panne que la tolerance sur `command` avait ete ecrite
pour eviter -- elle n'avait simplement jamais ete etendue au champ
d'a cote.
Les cibles sont enfin ecrites la ou on les cherche : node.toml.example et
la section MIDI du manuel les listent toutes les quinze.
263 tests, clippy -D warnings, fmt.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
crates/control-midi/src/lib.rs | 55 ++++++++++++++++++++++++++
crates/core/src/config.rs | 71 +++++++++++++++++++++++++++++++++-
docs/manuel.html | 5 ++-
node.toml.example | 6 +++
4 files changed, 135 insertions(+), 2 deletions(-)
diff --git a/crates/control-midi/src/lib.rs b/crates/control-midi/src/lib.rs
index a2f333f..0531b67 100644
--- a/crates/control-midi/src/lib.rs
+++ b/crates/control-midi/src/lib.rs
@@ -130,6 +130,8 @@ fn scaled_command(target: ScaleTarget, value: u8) -> Command {
value: min + t * (max - min),
}
};
+ // Les effets sont tous bornés 0..1, comme `t` : pas de mise à l'échelle.
+ let effet = |param: toolbox_core::state::EffectParam| Command::EffectSet { param, value: t };
match target {
ScaleTarget::Volume => Command::SetVolume { volume: t },
ScaleTarget::Brightness => color(ColorParam::Brightness),
@@ -140,6 +142,16 @@ fn scaled_command(target: ScaleTarget, value: u8) -> Command {
ScaleTarget::GainR => color(ColorParam::GainR),
ScaleTarget::GainG => color(ColorParam::GainG),
ScaleTarget::GainB => color(ColorParam::GainB),
+ ScaleTarget::Pixelate => effet(toolbox_core::state::EffectParam::Pixelate),
+ ScaleTarget::Posterize => effet(toolbox_core::state::EffectParam::Posterize),
+ ScaleTarget::Noise => effet(toolbox_core::state::EffectParam::Noise),
+ ScaleTarget::Sharpen => effet(toolbox_core::state::EffectParam::Sharpen),
+ ScaleTarget::Mirror => effet(toolbox_core::state::EffectParam::Mirror),
+ // Bornes de `Command::SetRate` (0.25..=4.0) : les respecter ici évite
+ // que le bus refuse la commande à fond de course.
+ ScaleTarget::Rate => Command::SetRate {
+ rate: 0.25 + t * (4.0 - 0.25),
+ },
}
}
@@ -255,6 +267,49 @@ mod tests {
use super::*;
use toolbox_core::LoopMode;
+ /// Le manuel promet depuis la v1 qu'« un fader MIDI ou OSC suffit à
+ /// activer et doser » un effet. Aucune cible n'existait : un binding CC
+ /// sans `scale` ne peut envoyer qu'une valeur CONSTANTE, donc le fader
+ /// était inutilisable pour ça.
+ #[test]
+ fn un_fader_pilote_un_effet_et_la_vitesse() {
+ use toolbox_core::state::EffectParam;
+
+ // Effets : bornés 0..1, donc la valeur du CC passe telle quelle.
+ assert_eq!(
+ scaled_command(ScaleTarget::Pixelate, 127),
+ Command::EffectSet {
+ param: EffectParam::Pixelate,
+ value: 1.0
+ }
+ );
+ assert_eq!(
+ scaled_command(ScaleTarget::Noise, 0),
+ Command::EffectSet {
+ param: EffectParam::Noise,
+ value: 0.0
+ }
+ );
+
+ // Vitesse : à l'échelle des bornes de SetRate, pour que le bus
+ // n'ait aucune raison de refuser la commande à fond de course.
+ let Command::SetRate { rate } = scaled_command(ScaleTarget::Rate, 0) else {
+ panic!("attendu SetRate");
+ };
+ assert!((rate - 0.25).abs() < 1e-6);
+ let Command::SetRate { rate } = scaled_command(ScaleTarget::Rate, 127) else {
+ panic!("attendu SetRate");
+ };
+ assert!((rate - 4.0).abs() < 1e-6);
+
+ // Contre-épreuve : les bornes doivent être acceptées par l'état.
+ let mut etat = toolbox_core::NodeState::default();
+ etat.apply(&scaled_command(ScaleTarget::Rate, 127))
+ .expect("la vitesse maximale doit être acceptée");
+ etat.apply(&scaled_command(ScaleTarget::Rate, 0))
+ .expect("la vitesse minimale doit être acceptée");
+ }
+
fn note_binding(note: u8, command: Command) -> MidiBinding {
MidiBinding {
note: Some(note),
diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs
index 95dc5aa..1cdd901 100644
--- a/crates/core/src/config.rs
+++ b/crates/core/src/config.rs
@@ -122,6 +122,18 @@ pub enum ScaleTarget {
GainR,
GainG,
GainB,
+ // Les cinq effets et la vitesse : le manuel promet depuis la v1 qu'« un
+ // fader MIDI ou OSC suffit à activer et doser » un effet, mais aucune
+ // cible n'existait — et un binding CC sans `scale` ne peut envoyer
+ // qu'une valeur CONSTANTE. Le fader était donc inutilisable pour ça.
+ Pixelate,
+ Posterize,
+ Noise,
+ Sharpen,
+ Mirror,
+ /// Vitesse de lecture, 0.25× à 4× (1× à mi-course n'est pas garanti :
+ /// l'échelle est linéaire sur les bornes de la commande).
+ Rate,
}
/// Un binding MIDI : note ou CC → commande fixe ou paramètre continu.
@@ -149,7 +161,12 @@ pub struct MidiBinding {
/// est ignorée avec un ERROR au lieu de faire échouer tout le node.toml.
#[serde(default, deserialize_with = "commande_tolerante")]
pub command: Option,
- /// Paramètre continu piloté par la valeur du CC.
+ /// Paramètre continu piloté par la valeur du CC. Désérialisation
+ /// TOLÉRANTE, comme `command` : une cible inconnue — une faute de frappe
+ /// dans un nom qu'on vient d'écrire à la main — est ignorée avec un
+ /// avertissement, au lieu d'empêcher le node de démarrer. C'est la panne
+ /// que la tolérance sur `command` avait justement été écrite pour éviter.
+ #[serde(default, deserialize_with = "cible_tolerante")]
pub scale: Option,
}
@@ -175,6 +192,25 @@ where
}
}
+/// Même filet que [`commande_tolerante`], pour la cible d'un CC.
+fn cible_tolerante<'de, D>(deserializer: D) -> Result
Auto-découverte OSCQuery (Chataigne)
diff --git a/node.toml.example b/node.toml.example
index 0b7a874..051db96 100644
--- a/node.toml.example
+++ b/node.toml.example
@@ -173,6 +173,7 @@ log_buffer = 1000 # entrées gardées par la page de logs
# volume · rate (vitesse 0,25×–4×)
# brightness · contrast · gamma · saturation · hue · gain_r · gain_g · gain_b
# pixelate · posterize · noise · sharpen · mirror (les 5 effets, 0..1)
+# dmx_master (grand master lumières, 0..255)
# Une cible mal orthographiée est ignorée avec un avertissement au démarrage :
# elle n'empêche pas le node de partir.
From 329d85f50d7d068b801642ddfe864bcb41c76c6d Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 5 Aug 2026 05:50:10 +0000
Subject: [PATCH 15/24] CHANGELOG et CLAUDE.md : etat de la 3.5.0
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
La 3.5.0 gagne une section « Ce qui devient pilotable » : corriger ce qui
etait DIT a mis au jour des fonctions reellement injoignables (faders MIDI
sur les effets et la vitesse, master et faders lumieres, /transport et
/media en lecture seule dans OSCQuery, export diagnostic devenu une vraie
sauvegarde).
CLAUDE.md porte l'etat de la version, ses deux limites assumees (unite
systemd qui ne projette pas en mode fenetre ; resolution de reglages.json
limitee a la sortie KMS) et le point laisse a Pym sur la formulation de
deny.toml -- la politique elle-meme n'a pas ete touchee, elle touche a la
decision sur gst-plugins-ugly qu'on ne rouvre pas.
Les prochaines etapes gagnent deux points precis pour le retour au
materiel : valider le bloc DISPLAY/graphical.target du service systemd sur
Pi OS Desktop, et decider si l'on publie une archive Linux/Pi AVEC
GStreamer.
Le chiffre « 45 a 55 adresses OSCQuery » est mesure sur un node reel, pas
estime.
264 tests, clippy -D warnings, fmt.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
CHANGELOG.md | 23 +++++++++++++++++++++++
CLAUDE.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 71 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a3be33c..68147a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -52,6 +52,29 @@ une relecture adversariale.
qui faisait perdre toute la conduite, `Ctrl+C` qui n'arrêtait pas le harnais
d'endurance, réglages morts (`[paths] shaders`, trois clés `[modules]`).
+### Ce qui devient pilotable (trous fonctionnels comblés)
+
+Corriger « ce qui est dit » a mis au jour des fonctions réellement
+injoignables. Elles le sont maintenant :
+
+- **Faders MIDI sur les effets et la vitesse** — le manuel le promettait
+ depuis la v1, mais `ScaleTarget` n'avait que le volume et les 8 réglages
+ couleur. Pire : écrire `scale = "pixelate"` empêchait le node de
+ **démarrer**. Six cibles ajoutées, et une cible mal orthographiée est
+ désormais ignorée avec un avertissement au lieu de bloquer le boot.
+- **Grand master et faders lumières** — ils n'existaient que via
+ `POST /api/dmx`, donc uniquement depuis la web UI : impossible de poser un
+ fader de surface MIDI sur le grand master, ni de le piloter depuis
+ Chataigne, ni d'en faire une action de cue. Nouvelles commandes
+ `dmx_master` / `dmx_fader`, adresses OSC `/dmx/master` et `/dmx/fader`
+ (un flottant 0..1 est accepté : les surfaces envoient souvent des faders
+ normalisés), feuilles OSCQuery, et cible de fader MIDI `dmx_master`.
+- **`/transport`, `/media` et `/playlist/position` en lecture seule** dans
+ OSCQuery : le node les émettait déjà en retour d'état, sans que rien ne les
+ recueille côté Chataigne.
+- **L'export diagnostic est devenu une vraie sauvegarde** : il contient le
+ contenu des presets et les fichiers d'état, plus seulement une liste de noms.
+
### Limites assumées de l'audit
- L'unité systemd **ne projette toujours pas en mode fenêtre** (ni `DISPLAY`,
diff --git a/CLAUDE.md b/CLAUDE.md
index 0ff4a27..6271b13 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -240,11 +240,58 @@ Sous Windows, aucune dépendance système (midir utilise WinMM).
le basculement ne demande aucun code, `rtsp.rs` retombe seul sur MJPEG
si x264enc est absent). Ne pas re-litiger la question autrement.
+- v3.5.0 (2026-08-04/05) : publication de la mesure de performance
+ (section déjà écrite) + AUDIT DES ZONES JAMAIS COUVERTES — la cohérence
+ entre ce que Lanterne DIT et ce qu'il FAIT. 6 dimensions inédites
+ (dérive doc/réalité, complétude des contrats de contrôle, UI ↔ serveur,
+ démarrage à froid, 3e passe sur la mesure, mise à jour d'une
+ installation existante), chaque lot repassé en relecture adversariale,
+ et vérifications EMPIRIQUES sur un vrai node à chaque fois que possible.
+ 264 tests. Faits notables :
+ **l'archive Pi officielle ne projette rien** (`--no-default-features` :
+ ni fenêtre, ni MIDI, ni GStreamer) alors que le manuel invitait à
+ installer des paquets `gstreamer1.0-*` — seul le pack Windows
+ `…-gstreamer` lit des vidéos, c'est écrit partout maintenant ;
+ **aucune archive publique ne portait `docs/TIERS.md` ni `LICENSE`** (le
+ correctif 3.4.1 n'avait été posé que sur ci.yml, jamais sur release.yml
+ — donc partout sauf sur ce que les gens téléchargent) ; **l'OTA
+ remplaçait le binaire par une variante plus pauvre** (le node déclare
+ désormais ses capacités et REFUSE la mise à jour quand aucune archive ne
+ fait autant que lui) ; `fps` restait un zéro dur là où `rendu` devient
+ null, donc un Pi sain ressortait « sortie morte » ; l'UI enregistrait
+ `load "undefined"` dans la conduite ; F11 n'était jamais republié ;
+ OSCQuery passe de 45 à 55 adresses (`/rate`, `/blending`, la régie, et
+ `/transport`+`/media` en lecture seule) ; faders MIDI sur les effets, la
+ vitesse et le master lumières ; `dmx_master`/`dmx_fader` (le master
+ n'était joignable que depuis la web UI) ; l'export diagnostic contient
+ enfin le CONTENU des presets et les fichiers d'état — c'est la
+ sauvegarde d'avant mise à jour ; CI : `apt-get update` cassé par un
+ dépôt tiers du runner (touchait main aussi), et les `.ps1` n'étaient
+ lintés NULLE PART (job check-windows).
+ LIMITES ASSUMÉES, écrites dans le CHANGELOG : l'unité systemd ne
+ projette toujours pas en mode fenêtre (ni DISPLAY ni ordonnancement
+ graphique — bloc à décommenter fourni, l'activer par défaut casserait
+ les installs sans bureau, et cela demande un Pi réel) ; la résolution de
+ `reglages.json` ne s'applique QU'À la sortie KMS (le journal et l'UI le
+ disent au lieu de faire semblant).
+ POINT LAISSÉ À PYM : `deny.toml` justifie sa politique par « Lanterne
+ est destiné à être VENDU » et parle de « distribution propriétaire »,
+ alors que LICENSE et Cargo.toml sont MIT. La politique (refuser le GPL
+ dans le binaire) reste juste et n'a PAS été touchée — la formulation
+ touche à la décision sur gst-plugins-ugly, qu'on ne rouvre pas.
+ `docs/TIERS.md`, lui, disait « logiciel propriétaire » en citant le
+ fichier LICENSE qui dit MIT : corrigé.
+
## Prochaines étapes
1. Au retour de Pym : tests matériels (Pi, capture HDMI, Chataigne réel,
multi-machine, `systemctl stop`) — liste dans
- `../RAPPORT_V1_2026-07-10.md`.
+ `../RAPPORT_V1_2026-07-10.md`. **La v3.5.0 y ajoute deux points
+ précis** : (a) le kiosque en mode fenêtre sur Pi OS Desktop demande de
+ décommenter le bloc `DISPLAY`/`graphical.target` de
+ `deploy/systemd/toolbox-node.service` et de le valider ; (b) décider si
+ l'on publie une archive Linux/Pi AVEC GStreamer (aujourd'hui il faut
+ compiler sur place pour projeter depuis un Pi).
2. Petites améliorations UX de l'UI signalées par des TODO éventuels.
3. Les grosses suites (chaîne vidéo Pi, sync multi-device, séquenceur)
attendent le matériel et les retours de Pym — **ne pas les entamer**.
From 91cbabd54ce6d80c29afd69b2c2bb600d9c17b7f Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 5 Aug 2026 05:55:38 +0000
Subject: [PATCH 16/24] Un fader OSC pousse a fond rendait la sortie quasi
noire
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Defaut introduit par le commit precedent, trouve en relisant mes propres
correctifs -- exactement le genre que la v3.4.1 avait ete creee pour
attraper.
`octet_arg` distinguait « niveau DMX » et « fader normalise » sur
`fract() != 0.0`. Or 1.0 n'a pas de partie fractionnaire : un fader
normalise pousse A FOND repartait sur le chemin entier et donnait 1, pas
255. La discontinuite tombait donc precisement la ou personne ne la
cherche :
Float(0.5) -> 128
Float(0.999) -> 255
Float(1.0) -> 1 <-- quasi noir
Mesure sur le parseur reel avant correction, pas deduite.
C'est desormais le TYPE qui decide, jamais la valeur : un entier est un
niveau DMX 0..255 (ce que declare OSCQuery, donc ce que Chataigne envoie),
un flottant est un fader normalise 0..1 (la convention des surfaces OSC).
Regle simple, sans cas particulier, et enoncee telle quelle dans le
manuel.
Le test couvre maintenant les extremes des deux conventions -- 0.0, 1.0,
et les refus hors bornes dans les deux sens (Int(-1), Int(300),
Float(1.5)) -- au lieu du seul 0.5 qui passait des deux facons.
264 tests, clippy -D warnings, fmt.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
crates/control-osc/src/lib.rs | 43 +++++++++++++++++++++++++----------
docs/manuel.html | 2 +-
2 files changed, 32 insertions(+), 13 deletions(-)
diff --git a/crates/control-osc/src/lib.rs b/crates/control-osc/src/lib.rs
index 4df6e57..f7fbb01 100644
--- a/crates/control-osc/src/lib.rs
+++ b/crates/control-osc/src/lib.rs
@@ -677,20 +677,25 @@ fn int_arg(args: &[OscType], index: usize) -> Option {
/// Niveau lumière 0..=255, tolérant comme le reste du parseur.
///
-/// Accepte un entier (0..255, la convention de la console) ET un flottant
-/// NON entier interprété comme 0..1 — beaucoup de surfaces OSC envoient
-/// leurs faders normalisés. Un `0.5` deviendrait sinon `0`, c'est-à-dire un
-/// noir au lieu d'un demi-niveau. Hors bornes : refusé, jamais tronqué en
-/// silence.
+/// C'est le TYPE de l'argument qui décide, jamais sa valeur :
+/// - **entier** (Int/Long) = niveau DMX brut, 0..=255 — ce que déclare
+/// OSCQuery, donc ce que Chataigne envoie ;
+/// - **flottant** (Float/Double) = fader NORMALISÉ 0..1 — la convention de
+/// la plupart des surfaces OSC.
+///
+/// La première version distinguait les deux sur `fract() != 0.0`, ce qui
+/// créait une discontinuité absurde exactement là où on l'attend le moins :
+/// `0.999` donnait 255 mais `1.0` donnait **1**, parce que 1.0 n'a pas de
+/// partie fractionnaire et repartait sur le chemin entier. Un fader normalisé
+/// poussé À FOND rendait donc la sortie quasi noire. Vérifié sur le parseur
+/// réel avant correction.
+///
+/// Hors bornes : refusé, jamais tronqué en silence.
fn octet_arg(args: &[OscType], index: usize) -> Option {
+ let normalise = |v: f64| (0.0..=1.0).contains(&v).then(|| (v * 255.0).round() as u8);
match args.get(index)? {
- // Flottant fractionnaire = fader normalisé 0..1.
- OscType::Float(f) if f.fract() != 0.0 => {
- (*f >= 0.0 && *f <= 1.0).then(|| (f * 255.0).round() as u8)
- }
- OscType::Double(d) if d.fract() != 0.0 => {
- (*d >= 0.0 && *d <= 1.0).then(|| (d * 255.0).round() as u8)
- }
+ OscType::Float(f) => normalise(f64::from(*f)),
+ OscType::Double(d) => normalise(*d),
_ => u8::try_from(int_arg(args, index)?).ok(),
}
}
@@ -1110,12 +1115,26 @@ mod tests {
Ok(Command::DmxMaster { valeur: 200 })
);
// Fader normalisé 0..1 d'une surface OSC : converti, pas tronqué à 0.
+ // C'est le TYPE qui décide, pas la partie fractionnaire : une première
+ // version testait `fract() != 0.0` et rendait 255 pour 0.999 mais 1
+ // pour 1.0 — un fader poussé À FOND donnait une sortie quasi noire.
assert_eq!(
map_message("/dmx/master", &[OscType::Float(0.5)]),
Ok(Command::DmxMaster { valeur: 128 })
);
+ assert_eq!(
+ map_message("/dmx/master", &[OscType::Float(1.0)]),
+ Ok(Command::DmxMaster { valeur: 255 }),
+ "un fader normalisé à fond doit donner le niveau maximum"
+ );
+ assert_eq!(
+ map_message("/dmx/master", &[OscType::Float(0.0)]),
+ Ok(Command::DmxMaster { valeur: 0 })
+ );
// Hors bornes : refusé, jamais tronqué en silence.
assert!(map_message("/dmx/master", &[OscType::Int(300)]).is_err());
+ assert!(map_message("/dmx/master", &[OscType::Int(-1)]).is_err());
+ assert!(map_message("/dmx/master", &[OscType::Float(1.5)]).is_err());
assert_eq!(
map_message(
"/dmx/fader",
diff --git a/docs/manuel.html b/docs/manuel.html
index 9edfe6b..a37f3f0 100644
--- a/docs/manuel.html
+++ b/docs/manuel.html
@@ -544,7 +544,7 @@
Contrôle OSC
/cue/go
nom
Déclenche une cue du séquenceur
/dmx/scene
nom
Rappelle une scène de la console lumières
/dmx/chaser
nom (ou rien)
Lance un chaser ; sans argument, arrête celui en cours
-
/dmx/master
0..255 (ou 0..1)
Grand master lumières — un flottant entre 0 et 1 est accepté (fader de surface normalisé)
+
/dmx/master
entier 0..255 ou flottant 0..1
Grand master lumières. C'est le type qui décide : un entier est un niveau DMX, un flottant est un fader normalisé (0.5 = mi-course, 1.0 = plein)
/dmx/fader
id, 0..255
Niveau d'un fader lumières, par identifiant
From 97213ecdb8585ef503febad8a2336dbfb2dc839e Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 5 Aug 2026 05:57:29 +0000
Subject: [PATCH 17/24] Fenetre : publier le plein ecran sans ecraser un
changement d'ecran
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Suite de la relecture de mes propres correctifs. `publier_plein_ecran`
lisait les reglages courants, modifiait sa copie, puis la publiait avec
`send_replace` : une lecture-modification-ecriture NON atomique.
Course possible, etroite mais reelle : F11 presse sur la machine pendant
qu'un changement d'ecran cible arrive de la tablette. La copie lue avant
le changement etait republiee apres, et ramenait la sortie sur l'ancien
ecran -- le symptome meme que ce correctif etait cense faire disparaitre.
`send_modify` fait la modification SOUS le verrou du canal : le champ
`fullscreen` est le seul touche, les autres gardent la valeur qu'ils ont
au moment de l'ecriture. Le marquage « deja applique » est pris APRES,
depuis la valeur reellement retenue, pour que l'echo renvoye par le relais
reste reconnu comme le notre.
264 tests, clippy -D warnings, fmt.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
crates/render/src/window.rs | 18 ++++++++++++------
1 file changed, 12 insertions(+), 6 deletions(-)
diff --git a/crates/render/src/window.rs b/crates/render/src/window.rs
index 48b4a61..8655b8b 100644
--- a/crates/render/src/window.rs
+++ b/crates/render/src/window.rs
@@ -421,13 +421,19 @@ impl OutputApp {
/// la sortie en plein écran avec F11 sur la machine, puis changer d'écran
/// depuis une tablette, la faisait sortir du plein écran toute seule.
fn publier_plein_ecran(&mut self, plein: bool) {
- let Some(nouveaux) = publication_plein_ecran(*self.settings.borrow(), plein) else {
+ if publication_plein_ecran(*self.settings.borrow(), plein).is_none() {
return;
- };
- // Marqué comme appliqué AVANT la publication : l'événement qui nous
- // reviendra par le relais sera alors reconnu comme notre propre écho.
- self.applique = Some(nouveaux);
- self.settings_tx.send_replace(nouveaux);
+ }
+ // `send_modify` et non `send_replace` : la lecture-modification-
+ // écriture est ATOMIQUE sous le verrou du canal. Publier une copie
+ // lue plus tôt écraserait un changement d'écran cible venu de l'UI
+ // entre-temps — F11 sur la machine pendant qu'on change d'écran
+ // depuis la tablette ramènerait la sortie sur l'ancien écran.
+ self.settings_tx.send_modify(|s| s.fullscreen = plein);
+ // Marqué appliqué APRÈS la publication, à partir de la valeur
+ // RÉELLEMENT retenue : l'écho qui nous revient par le relais est
+ // alors reconnu comme le nôtre, et n'est pas rejoué.
+ self.applique = Some(*self.settings.borrow());
}
fn toggle_fullscreen(&mut self) {
From bfcf80752c4aa5b49bf0028e9f24653481b3a2ca Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 5 Aug 2026 06:04:26 +0000
Subject: [PATCH 18/24] Sequenceur : un test qui pariait sur le temps au lieu
de l'attendre
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`l_enchainement_suit_le_nom_pas_l_index` a echoue sur check-windows
(97213ec) apres avoir passe cinq executions de suite sur cette meme
branche : aucun code en cause, une marge de temps trop courte.
Le test dormait 400 ms pour un enchainement programme a 300 ms, soit
100 ms de marge -- sur un runner Windows charge, ou chaque enregistrement
de cue passe par un fsync de sequences.json, la marge est parfois
depassee.
Il ATTEND desormais l'evenement au lieu de le parier : sonde toutes les
20 ms, jusqu'a 5 s, qu'une cue ENCHAINEE ait joue. Le detail qui compte :
la condition porte sur « `derniere` n'est plus A », pas sur « une cue a
joue » -- A est la cue lancee a la main, elle devient `derniere`
immediatement, et une premiere version de ce correctif sortait donc de la
boucle avant meme l'enchainement (le test l'a dit tout de suite :
left: Some("A")).
La garantie du test est intacte, et c'est verifie et non suppose :
contre-epreuve en remettant volontairement l'enchainement par INDEX --
le test echoue bien, en nommant le coupable (left: Some("C")). Puis
20 executions consecutives du test corrige : 20 ok, 0 echec.
Les deux assertions sont aussi inversees pour que la plus parlante tombe
en premier : savoir QUELLE cue a joue explique la panne, un volume
inattendu la fait seulement soupconner.
264 tests, clippy -D warnings, fmt.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
crates/core/src/sequenceur.rs | 30 +++++++++++++++++++++++++++---
1 file changed, 27 insertions(+), 3 deletions(-)
diff --git a/crates/core/src/sequenceur.rs b/crates/core/src/sequenceur.rs
index 01444cd..82466ff 100644
--- a/crates/core/src/sequenceur.rs
+++ b/crates/core/src/sequenceur.rs
@@ -680,13 +680,37 @@ mod tests {
.send(CommandeSequenceur::CueSupprime { nom: "A".into() })
.await
.expect("suppr");
- tokio::time::sleep(std::time::Duration::from_millis(400)).await;
+ // ATTENDRE l'enchaînement, ne pas le PARIER. Une temporisation fixe de
+ // 400 ms pour un enchaînement à 300 ms ne laisse que 100 ms de marge :
+ // sur un runner Windows chargé, où chaque enregistrement de cue passe
+ // par un fsync de sequences.json, la marge est parfois dépassée et le
+ // test échouait sans qu'aucun code soit en cause (observé une fois sur
+ // six exécutions de cette branche).
+ //
+ // On sonde jusqu'à ce qu'une cue se soit enchaînée, avec une limite
+ // large. La garantie du test est INTACTE : si l'enchaînement suivait
+ // l'index au lieu du nom, ce serait C qui jouerait, et l'assertion
+ // ci-dessous le verrait immédiatement.
+ // On attend qu'une cue ENCHAÎNÉE ait joué, c'est-à-dire que `derniere`
+ // ne soit plus « A » : A est la cue lancée à la main, elle devient
+ // `derniere` immédiatement. Attendre simplement « une cue a joué »
+ // sortirait de la boucle sur A, avant même l'enchaînement.
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while etat_rx.borrow().derniere.as_deref() == Some("A")
+ && std::time::Instant::now() < deadline
+ {
+ tokio::time::sleep(std::time::Duration::from_millis(20)).await;
+ }
// B (0.5) doit avoir joué, PAS C (0.9).
+ assert_eq!(
+ etat_rx.borrow().derniere.as_deref(),
+ Some("B"),
+ "c'est B qui doit s'enchaîner, pas la cue à l'ancien index"
+ );
assert!(
(handle.snapshot().player.volume - 0.5).abs() < 1e-6,
- "c'est B qui doit s'enchaîner, pas la cue à l'ancien index"
+ "le volume doit être celui de B (0.5), pas celui de C (0.9)"
);
- assert_eq!(etat_rx.borrow().derniere.as_deref(), Some("B"));
}
/// Une cue dont l'action est cue_go vers elle-même via le bus ne boucle
From 78810c3a50dcc093ddb6ec8ef5bf8ce84e7a82b0 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 5 Aug 2026 06:12:02 +0000
Subject: [PATCH 19/24] Relecture de mes correctifs : quatre qui ne tenaient
pas leur promesse
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Relecture adversariale des correctifs de cette branche -- la demarche meme
qui avait justifie la v3.4.1. Quatre defauts confirmes DANS mes propres
corrections, dont deux regressions que j'avais introduites.
1. LE MASTER POSE PAR OSC/MIDI/CUE N'ETAIT JAMAIS PERSISTE. Le commit
f188a89 annoncait « la chaine complete est posee ». Elle s'arretait une
ligne trop tot : le bras `commandes` (web UI) fait
`if appliquer(...) { a_sauver = true }`, le bras `evenements` -- celui
que j'avais etendu -- jetait la valeur de retour. Deux entrees vers la
meme console, deux comportements : un master pose depuis Chataigne ou
un fader MIDI vivait en memoire et disparaissait au redemarrage,
pendant que le meme geste depuis la tablette survivait.
Verifie en reel : `/dmx/master 77` en UDP, puis arret du node ->
lumieres.json porte bien master 77, et le node relance le relit.
2. `/dmx/chaser ""` N'ARRETAIT AUCUN CHASER. Le retour d'etat emet la
chaine vide pour un arret, « symetrique de /lut » disait le
commentaire ; l'entree, elle, gardait `Some("")` et cherchait un chaser
nomme « ». Or vider le champ est le SEUL geste d'arret possible depuis
Chataigne, qui ne sait pas envoyer un message sans argument sur un
parametre type « s ». La symetrie n'existait que dans un sens.
3. octet_arg REFUSAIT `90.0`. Ma correction precedente decidait sur le
seul TYPE : entier = niveau DMX, flottant = fader normalise. Mais
`int_arg`, dix lignes plus haut, documente que « Chataigne envoie
volontiers 90.0 pour 90 » -- l'emetteur le plus courant du projet se
voyait donc refuser ses messages. Troisieme version, et la bonne : un
flottant FRACTIONNAIRE est normalise, un flottant a valeur entiere est
un niveau DMX, et la seule ambiguite irreductible (1.0 : niveau 1, ou
fader a fond ?) est tranchee en faveur du fader a fond -- un master a
1/255 ne se vise pas a la main, et reste atteignable en entier. Le
choix est ecrit dans le code, pas laisse implicite.
4. LE FADER MIDI « rate » NE POUVAIT PAS RENDRE LA VITESSE NORMALE. Mise a
l'echelle lineaire sur [0,25 ; 4] : 1x tombait a CC 25,4, une position
qu'aucun controleur ne peut emettre. Les deux crans encadrants donnaient
0,988x et 1,018x -- assez pour faire deriver une boucle video d'une
bande son, et sans retour possible depuis la surface. Echelle
GEOMETRIQUE desormais : 0,25x / 0,5x / 1x / 2x / 4x aux quarts, comme
se pense un fader de vitesse.
Corrige aussi : la liste des cibles de fader affichee au demarrage
oubliait `dmx_master`, ajoutee la veille -- elle est maintenant unique
(ScaleTarget::NOMS) et tenue par un test, au lieu d'etre recopiee a la
main ; et la regle « entier ou flottant » n'etait ecrite que dans le
manuel : elle l'est aux quatre points de contrat (table de command.rs,
deux feuilles OSCQuery, manuel).
265 tests, clippy -D warnings, fmt.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
crates/artnet/src/lib.rs | 9 +++-
crates/control-http/src/oscquery.rs | 4 +-
crates/control-midi/src/lib.rs | 34 ++++++++++--
crates/control-osc/src/lib.rs | 83 +++++++++++++++++++++++------
crates/core/src/command.rs | 4 +-
crates/core/src/config.rs | 70 ++++++++++++++++++++++--
docs/manuel.html | 2 +-
7 files changed, 178 insertions(+), 28 deletions(-)
diff --git a/crates/artnet/src/lib.rs b/crates/artnet/src/lib.rs
index 5cfe2a5..494bf10 100644
--- a/crates/artnet/src/lib.rs
+++ b/crates/artnet/src/lib.rs
@@ -493,7 +493,14 @@ pub async fn service(
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
};
if let Some(commande) = commande {
- appliquer(&mut etat, commande);
+ // MEME traitement que le bras `commandes` : sans le
+ // `a_sauver`, un master pose par OSC, par un fader MIDI
+ // ou par une cue vivait en memoire et disparaissait au
+ // redemarrage -- alors que le meme geste depuis la web UI
+ // etait bien persiste. Deux entrees, deux comportements.
+ if appliquer(&mut etat, commande) {
+ a_sauver = true;
+ }
etat_tx.send_replace(etat.clone());
}
}
diff --git a/crates/control-http/src/oscquery.rs b/crates/control-http/src/oscquery.rs
index bd77997..4deb23a 100644
--- a/crates/control-http/src/oscquery.rs
+++ b/crates/control-http/src/oscquery.rs
@@ -332,9 +332,9 @@ pub fn namespace(state: &NodeState) -> Value {
"dmx": container("/dmx", json!({
"scene": leaf("/dmx/scene", "s", "Rappelle une scène de la console lumières", None, None),
"chaser": leaf("/dmx/chaser", "s", "Lance un chaser (nom) ; sans argument : arrêt", None, None),
- "master": leaf("/dmx/master", "i", "Grand master lumières (0..255)",
+ "master": leaf("/dmx/master", "i", "Grand master lumières : entier 0..255, ou flottant 0..1 (fader normalisé)",
None, Some(json!([{ "MIN": 0, "MAX": 255 }]))),
- "fader": leaf("/dmx/fader", "si", "Niveau d'un fader (identifiant, 0..255)", None, None),
+ "fader": leaf("/dmx/fader", "si", "Niveau d'un fader : identifiant, puis entier 0..255 ou flottant 0..1", None, None),
})),
}
})
diff --git a/crates/control-midi/src/lib.rs b/crates/control-midi/src/lib.rs
index b5ddc25..1493086 100644
--- a/crates/control-midi/src/lib.rs
+++ b/crates/control-midi/src/lib.rs
@@ -147,10 +147,16 @@ fn scaled_command(target: ScaleTarget, value: u8) -> Command {
ScaleTarget::Noise => effet(toolbox_core::state::EffectParam::Noise),
ScaleTarget::Sharpen => effet(toolbox_core::state::EffectParam::Sharpen),
ScaleTarget::Mirror => effet(toolbox_core::state::EffectParam::Mirror),
- // Bornes de `Command::SetRate` (0.25..=4.0) : les respecter ici évite
- // que le bus refuse la commande à fond de course.
+ // Échelle GÉOMÉTRIQUE, pas linéaire : 0,25× à t=0, 1× PILE à
+ // mi-course, 4× à fond. En linéaire sur [0,25 ; 4], la vitesse
+ // normale tombait à t = 0,2, soit CC 25,4 — une position qu'aucun
+ // contrôleur ne peut émettre : les deux crans encadrants donnaient
+ // 0,988× et 1,018×, et le régisseur ne pouvait plus revenir à la
+ // vitesse nominale depuis sa surface. Un fader de vitesse se pense
+ // d'ailleurs en octaves (moitié / normal / double), pas en pas
+ // constants.
ScaleTarget::Rate => Command::SetRate {
- rate: 0.25 + t * (4.0 - 0.25),
+ rate: 4.0f32.powf(2.0 * t - 1.0),
},
// 0..127 → 0..255 : la course entière du fader couvre celle du
// master, et 127 donne bien 255 (pas 254).
@@ -307,6 +313,28 @@ mod tests {
};
assert!((rate - 4.0).abs() < 1e-6);
+ // La vitesse NORMALE doit être atteignable depuis la surface : en
+ // échelle linéaire elle tombait à CC 25,4, une position qu'aucun
+ // contrôleur ne peut émettre — le régisseur ne pouvait plus revenir
+ // à 1× après avoir ralenti. Échelle géométrique : 1× pile à
+ // mi-course (CC 64 sur 127 n'est pas exactement le milieu, mais
+ // 63,5 l'est, donc on vise la valeur exacte à t = 0,5).
+ let Command::SetRate { rate } = scaled_command(ScaleTarget::Rate, 127 / 2) else {
+ panic!("attendu SetRate");
+ };
+ assert!(
+ (rate - 1.0).abs() < 0.02,
+ "à mi-course le fader doit rendre la vitesse normale, pas {rate}"
+ );
+ // Et les octaves tombent juste : moitié au quart, double aux trois quarts.
+ let Command::SetRate { rate } = scaled_command(ScaleTarget::Rate, 127 / 4) else {
+ panic!("attendu SetRate");
+ };
+ assert!(
+ (rate - 0.5).abs() < 0.02,
+ "au quart : 0,5× attendu, eu {rate}"
+ );
+
// Contre-épreuve : les bornes doivent être acceptées par l'état.
let mut etat = toolbox_core::NodeState::default();
etat.apply(&scaled_command(ScaleTarget::Rate, 127))
diff --git a/crates/control-osc/src/lib.rs b/crates/control-osc/src/lib.rs
index f7fbb01..832ae83 100644
--- a/crates/control-osc/src/lib.rs
+++ b/crates/control-osc/src/lib.rs
@@ -538,9 +538,13 @@ pub fn map_message(addr: &str, args: &[OscType]) -> Result {
"/dmx/scene" => string_arg(args, 0)
.map(|name| Command::DmxScene { name })
.ok_or_else(|| bad("attendu : nom de scène (string)")),
- // Sans argument : stop du chaser en cours.
+ // Sans argument OU chaine VIDE : stop du chaser en cours. La chaine
+ // vide compte, exactement comme pour /lut : le retour d'etat emet ""
+ // pour un arret, la feuille OSCQuery est typee "s", et vider le champ
+ // est le seul geste d'arret possible depuis Chataigne -- qui ne sait
+ // pas envoyer un message sans argument sur un parametre typé.
"/dmx/chaser" => Ok(Command::DmxChaser {
- name: string_arg(args, 0),
+ name: string_arg(args, 0).filter(|n| !n.is_empty()),
}),
// Grand master et faders : 0..255, comme la console. Un float est
// accepte (les surfaces OSC envoient souvent 0..1 mis a l'echelle
@@ -677,25 +681,43 @@ fn int_arg(args: &[OscType], index: usize) -> Option {
/// Niveau lumière 0..=255, tolérant comme le reste du parseur.
///
-/// C'est le TYPE de l'argument qui décide, jamais sa valeur :
-/// - **entier** (Int/Long) = niveau DMX brut, 0..=255 — ce que déclare
-/// OSCQuery, donc ce que Chataigne envoie ;
-/// - **flottant** (Float/Double) = fader NORMALISÉ 0..1 — la convention de
-/// la plupart des surfaces OSC.
+/// Deux conventions coexistent chez les émetteurs, il faut vivre avec :
+/// - un **entier** 0..=255 est un niveau DMX brut (ce que déclare OSCQuery) ;
+/// - un flottant **fractionnaire** de 0 à 1 est un fader NORMALISÉ, la
+/// convention de la plupart des surfaces OSC (`0.5` → 128) ;
+/// - un flottant à **valeur entière** est un niveau DMX : `int_arg`, dix
+/// lignes plus haut, documente que « Chataigne envoie volontiers 90.0
+/// pour 90 » — refuser `90.0` casserait l'émetteur le plus courant du
+/// projet.
///
-/// La première version distinguait les deux sur `fract() != 0.0`, ce qui
-/// créait une discontinuité absurde exactement là où on l'attend le moins :
-/// `0.999` donnait 255 mais `1.0` donnait **1**, parce que 1.0 n'a pas de
-/// partie fractionnaire et repartait sur le chemin entier. Un fader normalisé
-/// poussé À FOND rendait donc la sortie quasi noire. Vérifié sur le parseur
-/// réel avant correction.
+/// Reste UNE ambiguïté irréductible, `1.0` : niveau DMX 1, ou fader poussé à
+/// fond ? Tranchée en faveur du **fader à fond (255)**. Un master à 1/255
+/// est un noir que personne ne vise à la main, et qui reste atteignable en
+/// envoyant un entier ; un fader normalisé poussé à fond, lui, est un geste
+/// permanent en régie.
+///
+/// Deux versions se sont trompées ici avant celle-ci. La première décidait
+/// sur `fract() != 0.0` : `0.999` donnait 255 mais `1.0` donnait 1, un fader
+/// à fond rendant la sortie quasi noire. La seconde décidait sur le seul
+/// TYPE : elle refusait alors `90.0`, que Chataigne envoie pour 90. Les deux
+/// ont été mesurées sur le parseur réel, pas déduites.
///
/// Hors bornes : refusé, jamais tronqué en silence.
fn octet_arg(args: &[OscType], index: usize) -> Option {
- let normalise = |v: f64| (0.0..=1.0).contains(&v).then(|| (v * 255.0).round() as u8);
+ let flottant = |v: f64| {
+ if !v.is_finite() || !(0.0..=255.0).contains(&v) {
+ return None;
+ }
+ // Fader à fond, ou valeur fractionnaire : convention normalisée.
+ if v == 1.0 || v.fract() != 0.0 {
+ return (v <= 1.0).then(|| (v * 255.0).round() as u8);
+ }
+ // Valeur entière (0.0, 2.0, 90.0…) : niveau DMX.
+ Some(v as u8)
+ };
match args.get(index)? {
- OscType::Float(f) => normalise(f64::from(*f)),
- OscType::Double(d) => normalise(*d),
+ OscType::Float(f) => flottant(f64::from(*f)),
+ OscType::Double(d) => flottant(*d),
_ => u8::try_from(int_arg(args, index)?).ok(),
}
}
@@ -1131,8 +1153,20 @@ mod tests {
map_message("/dmx/master", &[OscType::Float(0.0)]),
Ok(Command::DmxMaster { valeur: 0 })
);
+ // Chataigne « envoie volontiers 90.0 pour 90 » (cf. int_arg) : un
+ // flottant À VALEUR ENTIÈRE reste un niveau DMX. Une version de ce
+ // parseur décidait sur le seul TYPE et refusait ce message.
+ assert_eq!(
+ map_message("/dmx/master", &[OscType::Float(90.0)]),
+ Ok(Command::DmxMaster { valeur: 90 })
+ );
+ assert_eq!(
+ map_message("/dmx/master", &[OscType::Float(200.0)]),
+ Ok(Command::DmxMaster { valeur: 200 })
+ );
// Hors bornes : refusé, jamais tronqué en silence.
assert!(map_message("/dmx/master", &[OscType::Int(300)]).is_err());
+ assert!(map_message("/dmx/master", &[OscType::Float(300.0)]).is_err());
assert!(map_message("/dmx/master", &[OscType::Int(-1)]).is_err());
assert!(map_message("/dmx/master", &[OscType::Float(1.5)]).is_err());
assert_eq!(
@@ -1161,6 +1195,23 @@ mod tests {
// Et c'est un déclencheur : deux départs successifs du même chaser
// doivent produire deux messages, pas un seul.
assert!(est_impulsion("/dmx/chaser"));
+ // La chaîne VIDE arrête le chaser, comme pour /lut. C'est le seul
+ // geste d'arrêt possible depuis Chataigne, qui ne sait pas envoyer
+ // un message sans argument sur un paramètre typé « s ».
+ assert_eq!(
+ map_message("/dmx/chaser", &[OscType::String(String::new())]),
+ Ok(Command::DmxChaser { name: None })
+ );
+ assert_eq!(
+ map_message("/dmx/chaser", &[]),
+ Ok(Command::DmxChaser { name: None })
+ );
+ assert_eq!(
+ map_message("/dmx/chaser", &[OscType::String("poursuite".into())]),
+ Ok(Command::DmxChaser {
+ name: Some("poursuite".into())
+ })
+ );
// Pas de retour pour l'état complet remplacé.
assert!(event_to_osc(&Event::StateReplaced {
diff --git a/crates/core/src/command.rs b/crates/core/src/command.rs
index 87c0eb8..8529694 100644
--- a/crates/core/src/command.rs
+++ b/crates/core/src/command.rs
@@ -85,8 +85,8 @@ pub enum TestPattern {
/// | `cue_go` | `/cue/go ` |
/// | `dmx_scene` | `/dmx/scene ` |
/// | `dmx_chaser` | `/dmx/chaser [nom]` (rien = stop) |
-/// | `dmx_master` | `/dmx/master <0..255>` |
-/// | `dmx_fader` | `/dmx/fader <0..255>` |
+/// | `dmx_master` | `/dmx/master <0..255 ou 0..1>` |
+/// | `dmx_fader` | `/dmx/fader <0..255 ou 0..1>` |
/// | `mesh_set` | — (UI/REST) |
/// | `mesh_reset` | — (UI/REST) |
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs
index 16343de..55e33c6 100644
--- a/crates/core/src/config.rs
+++ b/crates/core/src/config.rs
@@ -140,6 +140,32 @@ pub enum ScaleTarget {
DmxMaster,
}
+impl ScaleTarget {
+ /// Les cibles, telles qu'on les écrit dans `node.toml`. Recopier cette
+ /// liste à la main dans le message d'erreur l'avait déjà laissée en
+ /// arrière d'une version : `dmx_master` existait et fonctionnait, mais
+ /// la seule liste que l'opérateur voit au démarrage l'ignorait. Un test
+ /// vérifie qu'elle reste alignée sur l'énumération.
+ pub const NOMS: &'static [&'static str] = &[
+ "volume",
+ "brightness",
+ "contrast",
+ "gamma",
+ "saturation",
+ "hue",
+ "gain_r",
+ "gain_g",
+ "gain_b",
+ "pixelate",
+ "posterize",
+ "noise",
+ "sharpen",
+ "mirror",
+ "rate",
+ "dmx_master",
+ ];
+}
+
/// Un binding MIDI : note ou CC → commande fixe ou paramètre continu.
///
/// ```toml
@@ -206,9 +232,8 @@ where
Ok(cible) => Ok(Some(cible)),
Err(err) => {
noter(format!(
- "binding MIDI ignoré : cible « {brut} » inconnue ({err}). Cibles possibles : \
- volume, brightness, contrast, gamma, saturation, hue, gain_r, gain_g, gain_b, \
- pixelate, posterize, noise, sharpen, mirror, rate"
+ "binding MIDI ignoré : cible « {brut} » inconnue ({err}). Cibles possibles : {}",
+ ScaleTarget::NOMS.join(", ")
));
Ok(None)
}
@@ -628,6 +653,45 @@ mod tests {
assert_eq!(cfg.ports.osc, 9000);
}
+ #[test]
+ fn la_liste_des_cibles_reste_alignee_sur_l_enum() {
+ // Recopier la liste à la main l'avait déjà laissée en arrière :
+ // `dmx_master` fonctionnait mais n'était nulle part annoncé.
+ for nom in ScaleTarget::NOMS {
+ let valeur: ScaleTarget = toml::Value::String((*nom).to_string())
+ .try_into()
+ .unwrap_or_else(|e| panic!("cible « {nom} » annoncée mais illisible : {e}"));
+ // Aller-retour : le nom annoncé est bien celui que serde produit.
+ let round = toml::Value::try_from(valeur).expect("sérialisation");
+ assert_eq!(round.as_str(), Some(*nom));
+ }
+ // Et l'inverse : aucune variante ne doit manquer à l'appel. On compte
+ // les variantes via le nombre de cibles distinctes acceptées.
+ let variantes = [
+ ScaleTarget::Volume,
+ ScaleTarget::Brightness,
+ ScaleTarget::Contrast,
+ ScaleTarget::Gamma,
+ ScaleTarget::Saturation,
+ ScaleTarget::Hue,
+ ScaleTarget::GainR,
+ ScaleTarget::GainG,
+ ScaleTarget::GainB,
+ ScaleTarget::Pixelate,
+ ScaleTarget::Posterize,
+ ScaleTarget::Noise,
+ ScaleTarget::Sharpen,
+ ScaleTarget::Mirror,
+ ScaleTarget::Rate,
+ ScaleTarget::DmxMaster,
+ ];
+ assert_eq!(
+ variantes.len(),
+ ScaleTarget::NOMS.len(),
+ "une cible a été ajoutée à l'enum sans rejoindre NOMS (ou l'inverse)"
+ );
+ }
+
#[test]
fn une_cible_de_fader_inconnue_ne_bloque_pas_le_node() {
// Même filet que pour `command`, appliqué à `scale` : une coquille
diff --git a/docs/manuel.html b/docs/manuel.html
index a37f3f0..52f297d 100644
--- a/docs/manuel.html
+++ b/docs/manuel.html
@@ -545,7 +545,7 @@
Contrôle OSC
/dmx/scene
nom
Rappelle une scène de la console lumières
/dmx/chaser
nom (ou rien)
Lance un chaser ; sans argument, arrête celui en cours
/dmx/master
entier 0..255 ou flottant 0..1
Grand master lumières. C'est le type qui décide : un entier est un niveau DMX, un flottant est un fader normalisé (0.5 = mi-course, 1.0 = plein)
-
/dmx/fader
id, 0..255
Niveau d'un fader lumières, par identifiant
+
/dmx/fader
id, puis entier 0..255 ou flottant 0..1
Niveau d'un fader lumières, par identifiant. Même règle que /dmx/master
Auto-découverte OSCQuery (Chataigne)
From 257f0e0bc39f65af76e302e53ba9604f72d98d4d Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 5 Aug 2026 06:17:53 +0000
Subject: [PATCH 20/24] Relecture (suite) : trois correctifs qui se trompaient
de cible
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Deuxieme lot de la relecture adversariale de mes propres correctifs.
1. L'OTA ANNONCAIT « A JOUR » ALORS QU'UNE VERSION PLUS RECENTE EXISTAIT.
En apprenant a refuser une mise a jour degradante, j'ai fait rendre
`asset: None` dans des cas REELS (tout Linux/Pi compile avec GStreamer,
tout aarch64 compile sur place). Or l'UI, que je n'avais pas touchee,
teste `if (etat.plus_recente && etat.asset)` et tombe sinon dans la
branche « A jour (vX) ». Le refus prudent se transformait donc en
mensonge tranquille : le node savait qu'une v3.6.0 existait et affirmait
etre a jour.
`EtatMiseAJour` porte desormais une `raison`, et l'UI l'affiche :
« v3.6.0 existe, mais : ce binaire a ete compile sur place… ».
2. LE MESSAGE DE REFUS PARLAIT DE LA VIDEO A QUI N'EN A PAS. Le meme texte
servait aux trois cas de refus. Sur un Pi compile sur place SANS
gstreamer -- le cas que decrit install.sh -- il annoncait « vous ferait
perdre la lecture video » a quelqu'un qui n'en a jamais eu, et taisait
ce qu'il perdrait vraiment : la fenetre de sortie, donc toute
projection. Le message est construit depuis les capacites reelles.
3. L'EXPORT DIAGNOSTIC SAUTAIT LE PRESET QU'IL FALLAIT SAUVER. J'avais
applique la lecture BRUTE aux cinq fichiers d'etat, et laisse les
presets passer par `load()` -- donc par la validation ET une
re-serialisation. Trois consequences : un preset au JSON abime etait
silencieusement absent de l'archive (c'est-a-dire exactement celui
qu'on exporte pour le recuperer), un preset refuse par la validation
aussi, et tout champ ecrit par une version plus recente disparaissait
a la re-serialisation.
`PresetStore::octets` lit le fichier tel qu'il est. Verifie en reel :
avec un `presets/casse.json` volontairement tronque, l'archive le
contient desormais octet pour octet.
4. LE FILTRE D'ECHO DE LA FENETRE POUVAIT AVALER UN REGLAGE LEGITIME.
`send_modify` avait supprime l'ecrasement DANS le canal, mais le
marqueur « deja applique » recopiait ensuite le canal entier. Si un
reglage venu de l'UI y etait deja sans que l'event loop l'ait traite
(il peint une frame), un F11 le faisait passer pour notre propre echo :
l'ecran cible choisi depuis la tablette n'etait jamais applique. Le
marqueur part maintenant de ce que la fenetre a REELLEMENT applique.
5. Le rapport.txt EMBARQUE dans l'archive -- le premier fichier qu'on
ouvre, et le seul qui voyage avec elle -- enumerait encore l'ancien
contenu. Il annonce les dossiers presets/ et etat/, et dit que
l'archive sert de sauvegarde avant mise a jour.
265 tests, clippy -D warnings, fmt.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
crates/control-http/assets/index.html | 6 ++++
crates/control-http/src/lib.rs | 25 +++++++++++------
crates/control-http/src/ota.rs | 40 ++++++++++++++++++++-------
crates/core/src/preset.rs | 11 ++++++++
crates/render/src/window.rs | 13 ++++++---
5 files changed, 72 insertions(+), 23 deletions(-)
diff --git a/crates/control-http/assets/index.html b/crates/control-http/assets/index.html
index 6d722da..5de513a 100644
--- a/crates/control-http/assets/index.html
+++ b/crates/control-http/assets/index.html
@@ -2660,6 +2660,12 @@
Alimentation de la machine
if (etat.plus_recente && etat.asset) {
$("#majEtat").textContent = "v" + etat.version_disponible + " disponible (actuelle : v" + etat.version_courante + ")";
$("#majTelecharge").style.display = "inline-block";
+ } else if (etat.plus_recente && etat.raison) {
+ // Une version PLUS RECENTE existe, mais aucune archive ne convient a ce
+ // binaire. Dire « A jour » ici serait un mensonge : on donne la raison.
+ $("#majEtat").textContent = "v" + etat.version_disponible + " existe, mais : " + etat.raison;
+ $("#majTelecharge").style.display = "none";
+ $("#majApplique").style.display = "none";
} else {
$("#majEtat").textContent = "À jour (v" + etat.version_courante + ")";
$("#majTelecharge").style.display = "none";
diff --git a/crates/control-http/src/lib.rs b/crates/control-http/src/lib.rs
index 2718f73..a0ea2c2 100644
--- a/crates/control-http/src/lib.rs
+++ b/crates/control-http/src/lib.rs
@@ -1667,7 +1667,13 @@ async fn diagnostic_zip(State(app): State) -> Result.json et presets/mapping/.json (le CONTENU\n\
+ de chaque preset, octets bruts), etat/*.json (fichiers d'etat bruts :\n\
+ console lumieres, conduite du sequenceur, fonctions, reglages,\n\
+ demarrage).\n\n\
+ Cette archive sert AUSSI de sauvegarde avant une mise a jour : les\n\
+ dossiers presets/ et etat/ suffisent a remonter un node.\n",
app.node_name,
app.version,
std::env::consts::OS,
@@ -1728,18 +1734,19 @@ async fn diagnostic_zip(State(app): State) -> Result,
+ /// Pourquoi aucune archive ne convient, le cas échéant. Sans ce champ,
+ /// l'UI affichait « À jour » dès que `asset` était absent — donc un node
+ /// compilé sur place se voyait annoncer « à jour » alors qu'une version
+ /// PLUS RÉCENTE existait bel et bien. Un silence qui ment.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub raison: Option,
}
/// Ce que le binaire EN COURS d'exécution sait faire. Déclaré une fois au
@@ -140,6 +146,7 @@ pub fn verifier(version_courante: &str) -> Result {
(nom == attendu).then(|| nom.to_string())
})
});
+ let raison = (asset.is_none()).then(raison_aucune_archive);
Ok(EtatMiseAJour {
// STRICTEMENT supérieure. Une simple inégalité annonçait une « mise
// à jour » vers une version ANTÉRIEURE dès qu'un node tournait sur
@@ -149,9 +156,28 @@ pub fn verifier(version_courante: &str) -> Result {
version_courante: version_courante.to_string(),
version_disponible: Some(version_dispo),
asset,
+ raison,
})
}
+/// Pourquoi aucune archive publiée ne convient à CE binaire. Le message cite
+/// ce qu'il perdrait vraiment : annoncer « la lecture vidéo » à un Pi compilé
+/// sans GStreamer — qui, lui, perdrait sa fenêtre de sortie — enverrait
+/// chercher au mauvais endroit.
+fn raison_aucune_archive() -> String {
+ let c = capacites();
+ let perdu = match (c.video, c.fenetre) {
+ (true, true) => "la lecture vidéo et la fenêtre de sortie",
+ (true, false) => "la lecture vidéo",
+ (false, true) => "la fenêtre de sortie (donc toute projection)",
+ (false, false) => return "aucune archive publiée ne correspond à cette plateforme".into(),
+ };
+ format!(
+ "ce binaire a été compilé sur place : aucune archive publiée ne fait autant. \
+ Une mise à jour automatique vous ferait perdre {perdu}. Recompilez sur la machine."
+ )
+}
+
/// `dispo` est-elle strictement postérieure à `courante` ? Comparaison
/// numérique champ par champ (majeur.mineur.correctif) ; un champ non
/// numérique (pré-version « 3.5.0-rc1 ») compte pour 0, ce qui rend une
@@ -179,16 +205,10 @@ pub fn telecharger() -> Result {
if !etat.plus_recente {
return Err(format!("déjà en {version} : rien à télécharger"));
}
- let asset = etat.asset.as_deref().ok_or_else(|| {
- if nom_asset_plateforme().is_none() {
- "ce binaire a été compilé sur place (vidéo et/ou fenêtre de sortie) : \
- aucune archive publiée ne fait autant. Une mise à jour automatique \
- vous ferait PERDRE la lecture vidéo. Recompilez plutôt sur la machine."
- .to_string()
- } else {
- "pas d'archive pour cette plateforme dans la release".to_string()
- }
- })?;
+ let asset = etat
+ .asset
+ .as_deref()
+ .ok_or_else(|| etat.raison.clone().unwrap_or_else(raison_aucune_archive))?;
let url = format!("https://github.com/{DEPOT}/releases/latest/download/{asset}");
let dossier = dossier_du_binaire()?;
let archive = dossier.join(asset);
diff --git a/crates/core/src/preset.rs b/crates/core/src/preset.rs
index d25ca88..f9a48fc 100644
--- a/crates/core/src/preset.rs
+++ b/crates/core/src/preset.rs
@@ -110,6 +110,17 @@ impl Store {
/// Charge le preset `name`. Le document est **validé** après lecture : un
/// fichier corrompu ou édité à la main avec des valeurs hors bornes est
/// refusé plutôt que de devenir l'état du node.
+ /// Les octets du preset TELS QU'ILS SONT sur le disque, sans
+ /// désérialisation ni validation. Pour l'export diagnostic : un preset
+ /// abîmé est précisément celui qu'on veut récupérer, et `load` le
+ /// rejetterait. Une re-sérialisation, elle, effacerait silencieusement
+ /// tout champ que la version courante ne connaît pas.
+ pub fn octets(&self, name: &str) -> Result, CoreError> {
+ validate_name(name)?;
+ let path = self.path_of(name);
+ fs::read(&path).map_err(|e| CoreError::io(path.display().to_string(), e))
+ }
+
pub fn load(&self, name: &str) -> Result {
validate_name(name)?;
let path = self.path_of(name);
diff --git a/crates/render/src/window.rs b/crates/render/src/window.rs
index 8655b8b..92d0764 100644
--- a/crates/render/src/window.rs
+++ b/crates/render/src/window.rs
@@ -429,11 +429,16 @@ impl OutputApp {
// lue plus tôt écraserait un changement d'écran cible venu de l'UI
// entre-temps — F11 sur la machine pendant qu'on change d'écran
// depuis la tablette ramènerait la sortie sur l'ancien écran.
+ // Le marqueur d'écho part de ce que la fenêtre a RÉELLEMENT appliqué,
+ // pas de ce que le canal contient : un réglage venu de l'UI peut
+ // déjà y être sans que l'event loop l'ait traité (il peint une
+ // frame). Recopier le canal ferait passer ce réglage-là pour notre
+ // propre écho, et il serait ignoré pour toujours — l'écran cible
+ // choisi depuis la tablette ne serait jamais appliqué.
+ let mut cible = self.applique.unwrap_or(*self.settings.borrow());
+ cible.fullscreen = plein;
self.settings_tx.send_modify(|s| s.fullscreen = plein);
- // Marqué appliqué APRÈS la publication, à partir de la valeur
- // RÉELLEMENT retenue : l'écho qui nous revient par le relais est
- // alors reconnu comme le nôtre, et n'est pas rejoué.
- self.applique = Some(*self.settings.borrow());
+ self.applique = Some(cible);
}
fn toggle_fullscreen(&mut self) {
From f38aaa7fe028f7ebbc8d720e5f8f4a4c42c2eac3 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 5 Aug 2026 06:24:09 +0000
Subject: [PATCH 21/24] Sequenceur : les tests attendent l'evenement, tous, pas
seulement un
`go_joue_et_enchaine` a echoue sur check-windows apres que
`l_enchainement_suit_le_nom_pas_l_index` a echoue au run precedent : deux
tests differents du MEME module, tour a tour, sans qu'aucun code soit en
cause. J'avais corrige le premier en concluant qu'il n'y avait pas lieu de
toucher aux autres. C'etait la mauvaise lecture : ce n'est pas UN test
fragile, c'est une famille -- les tests du sequenceur dorment une duree
fixe pour attendre un enchainement dont l'echeance est du meme ordre, sur
un service qui fsync sequences.json a chaque enregistrement de cue.
Une aide commune `attendre(etat, condition)` remplace les paris : elle
sonde l'etat publie toutes les 20 ms, jusqu'a 5 s. Les trois attentes des
deux tests concernes y passent.
Le cas interessant est le dernier bloc de `go_joue_et_enchaine`, qui
affirme qu'il ne se passe RIEN apres un Stop. Une attente ne peut pas y
remplacer le delai -- mais elle garantit l'etat de DEPART, et c'est la que
le bat blessait : dormir 100 ms en esperant que l'enchainement soit arme,
c'etait risquer d'envoyer Stop avant qu'il y ait quoi que ce soit a
annuler. Le test aurait alors reussi sans rien prouver, ce qui est pire
qu'un echec. Il attend maintenant que `en_attente` soit rempli, l'affirme,
puis annule et verifie l'annulation avant de laisser passer l'echeance.
Verifie : 25 executions consecutives de tous les tests du sequenceur, puis
4 suites en parallele pour reproduire la charge d'un runner -- 0 echec.
Les autres `sleep` fixes du depot (player, bascules, sync, http) sont
courts et sans echeance concurrente ; ils restent en l'etat plutot que
d'etre reecrits en aveugle.
265 tests, clippy -D warnings, fmt.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
.github/workflows/ci.yml | 22 +++++++++++++
crates/core/src/sequenceur.rs | 50 ++++++++++++++++++++++-------
deploy/install.sh | 33 +++++++++++++++----
deploy/systemd/toolbox-node.service | 3 +-
tools/endurance/endurance.ps1 | 16 ++++++---
5 files changed, 102 insertions(+), 22 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 430e934..b77e5df 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -94,6 +94,28 @@ jobs:
}
}
if ($fautifs.Count -gt 0) { throw "$($fautifs.Count) script(s) PowerShell invalides" }
+ # ET avec Windows PowerShell 5.1 : c'est LUI qu'obtient un double-clic
+ # ou la ligne `powershell -ExecutionPolicy Bypass -File …` imprimee en
+ # tete de l'installateur. Une syntaxe acceptee par pwsh 7 mais inconnue
+ # de 5.1 (l'operateur `??`, par exemple) passerait sinon la CI et
+ # echouerait sur le poste de l'utilisateur.
+ - name: Analyse des scripts PowerShell (Windows PowerShell 5.1)
+ shell: powershell
+ run: |
+ $fautifs = @()
+ Get-ChildItem -Recurse -Filter *.ps1 | ForEach-Object {
+ $erreurs = $null
+ [System.Management.Automation.Language.Parser]::ParseFile(
+ $_.FullName, [ref]$null, [ref]$erreurs) | Out-Null
+ if ($erreurs -and $erreurs.Count -gt 0) {
+ $fautifs += $_.FullName
+ Write-Host "== $($_.FullName)"
+ $erreurs | ForEach-Object { Write-Host " $($_.Extent.StartLineNumber): $($_.Message)" }
+ } else {
+ Write-Host "ok $($_.Name)"
+ }
+ }
+ if ($fautifs.Count -gt 0) { throw "$($fautifs.Count) script(s) invalides en PowerShell 5.1" }
- name: Tests (Windows)
shell: bash
run: |
diff --git a/crates/core/src/sequenceur.rs b/crates/core/src/sequenceur.rs
index 82466ff..c751cb9 100644
--- a/crates/core/src/sequenceur.rs
+++ b/crates/core/src/sequenceur.rs
@@ -640,6 +640,28 @@ mod tests {
.is_ok());
}
+ /// Attend qu'une condition sur l'état publié devienne vraie, ou abandonne.
+ ///
+ /// Les tests de ce module dormaient une durée FIXE, calculée avec une
+ /// marge de quelques dizaines de ms sur l'échéance réelle. Sur un runner
+ /// Windows chargé — où chaque enregistrement de cue passe par un fsync de
+ /// sequences.json — la marge est parfois dépassée, et deux tests
+ /// différents ont échoué tour à tour sans qu'aucun code soit en cause.
+ /// On attend donc l'événement au lieu de le parier ; la limite reste
+ /// large, et un vrai défaut la dépasse de toute façon.
+ async fn attendre(etat: &watch::Receiver, mut pret: F)
+ where
+ F: FnMut(&EtatSequenceur) -> bool,
+ {
+ let limite = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while std::time::Instant::now() < limite {
+ if pret(&etat.borrow()) {
+ return;
+ }
+ tokio::time::sleep(std::time::Duration::from_millis(20)).await;
+ }
+ }
+
/// Supprimer la cue A pendant que B(après) attend ne doit PAS faire
/// jouer C à la place : l'enchaînement suit le NOM, pas l'index.
#[tokio::test]
@@ -695,12 +717,7 @@ mod tests {
// ne soit plus « A » : A est la cue lancée à la main, elle devient
// `derniere` immédiatement. Attendre simplement « une cue a joué »
// sortirait de la boucle sur A, avant même l'enchaînement.
- let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
- while etat_rx.borrow().derniere.as_deref() == Some("A")
- && std::time::Instant::now() < deadline
- {
- tokio::time::sleep(std::time::Duration::from_millis(20)).await;
- }
+ attendre(&etat_rx, |e| e.derniere.as_deref() != Some("A")).await;
// B (0.5) doit avoir joué, PAS C (0.9).
assert_eq!(
etat_rx.borrow().derniere.as_deref(),
@@ -834,7 +851,7 @@ mod tests {
.expect("go");
// La cue « un » est jouée tout de suite…
- tokio::time::sleep(std::time::Duration::from_millis(120)).await;
+ attendre(&etat_rx, |e| e.derniere.as_deref() == Some("un")).await;
assert!((handle.snapshot().player.volume - 0.25).abs() < 1e-6);
assert_eq!(
etat_rx.borrow().en_attente.as_ref().map(|(n, _)| n.clone()),
@@ -842,18 +859,29 @@ mod tests {
"l'enchaînement doit être annoncé"
);
// … et « deux » s'enchaîne ~300 ms plus tard.
- tokio::time::sleep(std::time::Duration::from_millis(500)).await;
+ attendre(&etat_rx, |e| e.derniere.as_deref() == Some("deux")).await;
assert!((handle.snapshot().player.volume - 0.75).abs() < 1e-6);
assert_eq!(etat_rx.borrow().derniere.as_deref(), Some("deux"));
- // Stop annule un enchaînement en attente.
+ // Stop annule un enchaînement en attente. Ici on affirme qu'il ne se
+ // passe RIEN : une attente ne peut pas remplacer le délai, mais elle
+ // peut garantir l'état de DÉPART. Dormir 100 ms en espérant que
+ // l'enchaînement soit armé, c'était risquer d'envoyer Stop avant même
+ // qu'il y ait quelque chose à annuler — le test aurait alors réussi
+ // sans rien prouver.
cmd_tx
.send(CommandeSequenceur::Go { nom: "un".into() })
.await
.expect("re-go");
- tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+ attendre(&etat_rx, |e| e.en_attente.is_some()).await;
+ assert!(
+ etat_rx.borrow().en_attente.is_some(),
+ "l'enchaînement doit être armé AVANT d'être annulé, sinon le test ne prouve rien"
+ );
cmd_tx.send(CommandeSequenceur::Stop).await.expect("stop");
- tokio::time::sleep(std::time::Duration::from_millis(450)).await;
+ attendre(&etat_rx, |e| e.en_attente.is_none()).await;
+ // Au-delà de l'échéance de « deux » (300 ms) : elle ne doit pas jouer.
+ tokio::time::sleep(std::time::Duration::from_millis(600)).await;
assert!(
(handle.snapshot().player.volume - 0.25).abs() < 1e-6,
"la cue deux ne doit PAS avoir été jouée après Stop"
diff --git a/deploy/install.sh b/deploy/install.sh
index ad60d39..b781088 100755
--- a/deploy/install.sh
+++ b/deploy/install.sh
@@ -43,11 +43,20 @@ fail() { printf '\033[1;31m!!\033[0m %s\n' "$*" >&2; exit 1; }
# cette normalisation, `--prefix lanterne` déroulait toute l'installation
# sans le moindre message, et seul `systemctl start` échouait ensuite, sur
# une unité que systemd refusait de charger.
+# Un préfixe VIDE ne correspond pas à `/*` : il tomberait dans la conversion,
+# où `cd ""` réussit sans changer de répertoire — l'installation se déroulait
+# alors dans le répertoire courant, sans un mot. Le cas arrive pour de bon :
+# `--prefix "$DEST"` avec DEST oublié dans un script de provisionnement.
+[ -n "$PREFIX" ] || fail "préfixe vide : passez un chemin à --prefix"
case "$PREFIX" in
/*) ;;
- *) PREFIX="$(mkdir -p "$PREFIX" 2> /dev/null; cd "$PREFIX" 2> /dev/null && pwd)" \
- || fail "préfixe inutilisable : $PREFIX"
- [ -n "$PREFIX" ] || fail "impossible de rendre le préfixe absolu"
+ *) # Simple préfixage par le répertoire courant : on ne CRÉE rien ici
+ # (`mkdir` poserait le dossier avant de savoir sous quelle identité,
+ # et fausserait le chown de fin d'installation) et on n'exige pas que
+ # le parent existe — `mkdir -p`, plus bas, sait le créer, et l'exiger
+ # refuserait `--prefix sous/lanterne`, que l'ancienne version
+ # acceptait.
+ PREFIX="$(pwd)/${PREFIX#./}"
say "Préfixe relatif converti en chemin absolu : $PREFIX" ;;
esac
@@ -124,8 +133,13 @@ case "$MATERIEL" in
echo " Déconseillé : sortie RTSP au-delà de 720p (encodage au CPU)." ;;
pi3)
say "Matériel détecté : Raspberry Pi 3 / Zero 2 — VERSION ALLÉGÉE conseillée"
- echo " Conseillé : profil « lecteur » (lecture + mapping), rendu CPU en"
- echo " 960×540 en sortie sans bureau (KMS), aperçu web coupé."
+ echo " Conseillé : profil « lecteur » (lecture + mapping), rendu CPU,"
+ echo " aperçu web coupé."
+ echo " À FAIRE À LA MAIN : sur Pi OS Lite (sans bureau), ajouter"
+ echo " [output] mode = \"kms\" dans node.toml — ce script ne"
+ echo " l'écrit pas, et le mode KMS exige un binaire compilé"
+ echo " avec --features gstreamer. C'est là que la résolution"
+ echo " 960×540 des réglages de performance s'applique."
echo " Déconseillé : rendu GPU (puce GLES 2.0 trop ancienne), sortie RTSP,"
echo " flux MJPEG au-delà de 480p, effets lourds." ;;
pi_ancien)
@@ -288,7 +302,14 @@ if command -v systemctl > /dev/null 2>&1; then
# actif. La mise à jour semblait faite et ne l'était pas.
if sudo systemctl is-active --quiet toolbox-node.service; then
say "Service déjà actif : redémarrage sur le nouveau binaire."
- sudo systemctl restart toolbox-node.service
+ # NON fatal : sous `set -e`, un restart refusé (binaire de la
+ # mauvaise architecture, archive tronquée…) tuait le script AVANT
+ # le chown du préfixe — l'installation repartait donc avec des
+ # fichiers appartenant à root, et le node ne pouvait plus rien
+ # enregistrer. Exactement la panne que le chown existe pour éviter.
+ if ! sudo systemctl restart toolbox-node.service; then
+ say "ATTENTION : redémarrage refusé — voir journalctl -u toolbox-node"
+ fi
else
say "Service installé. Démarrage : sudo systemctl start toolbox-node"
fi
diff --git a/deploy/systemd/toolbox-node.service b/deploy/systemd/toolbox-node.service
index 72825be..585140b 100644
--- a/deploy/systemd/toolbox-node.service
+++ b/deploy/systemd/toolbox-node.service
@@ -20,7 +20,8 @@
# Pour un kiosque en mode fenêtre sur Raspberry Pi OS Desktop, décommenter
# le bloc ci-dessous ET la ligne graphical.target, après avoir vérifié la
# session réellement utilisée (X11 ou Wayland) et l'UID du compte :
-# loginctl show-user @USER@ -p RuntimeMax → XDG_RUNTIME_DIR=/run/user/
+# loginctl show-user @USER@ -p RuntimePath → XDG_RUNTIME_DIR=
+# (ou simplement : id -u @USER@ → /run/user/)
# echo $DISPLAY / $WAYLAND_DISPLAY dans une session ouverte
# Non activé par défaut : le passage à graphical.target empêcherait le
# service de démarrer sur une machine sans bureau (Pi OS Lite), et ces
diff --git a/tools/endurance/endurance.ps1 b/tools/endurance/endurance.ps1
index f41bab5..9e57c82 100644
--- a/tools/endurance/endurance.ps1
+++ b/tools/endurance/endurance.ps1
@@ -66,6 +66,10 @@ function ConvertTo-Nombre($texte) {
# pilote le spectacle en continu -- chacune traverse le bus, republie
# l'etat et declenche un redessin ;
# - un client MJPEG permanent, qui fait tourner le compositeur partage.
+# Etat du client MJPEG, renseigne par Start-Charge et lu par Start-Collecte
+# pour annoncer la charge REELLE.
+$script:MjpegEtat = "sans client MJPEG"
+
function Start-Charge($base, $cadence) {
$jobs = @()
$jobs += Start-Job -ArgumentList $base, $cadence -ScriptBlock {
@@ -125,12 +129,16 @@ function Start-Charge($base, $cadence) {
$p = Start-Process -FilePath "curl.exe" -PassThru -WindowStyle Hidden `
-ArgumentList "-s", "-o", $nul, "$base/flux.mjpg?fps=15"
$jobs += $p
+ $script:MjpegEtat = "+ client MJPEG"
} else {
- Write-Host " (flux MJPEG indisponible - HTTP $code : charge reduite,"
- Write-Host " le compositeur partage n'est pas sollicite)"
+ # Drapeau de portee script : l'annonce de la charge est imprimee
+ # par Start-Collecte, ailleurs. Sans cela, la sonde etait bien
+ # faite mais le message « + client MJPEG » restait inconditionnel
+ # -- le collecteur shell disait la verite, celui-ci non.
+ $script:MjpegEtat = "SANS client MJPEG (HTTP $code) - charge reduite"
}
} else {
- Write-Host " (curl.exe absent : pas de client MJPEG dans la charge)"
+ $script:MjpegEtat = "SANS client MJPEG (curl.exe absent)"
}
return $jobs
}
@@ -213,7 +221,7 @@ function Start-Collecte {
if (-not $sansCharge) {
$sauvegarde = Save-Etat $base
$charge = Start-Charge $base $cadence
- Write-Host "Charge : ~$cadence commandes/s + client MJPEG."
+ Write-Host "Charge : ~$cadence commandes/s $script:MjpegEtat."
}
while ((Get-Date) -lt $fin) {
From 7e9e07556d3813946cefb1dcccb4e3144a95e04a Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 5 Aug 2026 06:26:00 +0000
Subject: [PATCH 22/24] CI : un paquet chocolatey disparu emportait GStreamer
avec lui
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`build-windows-gstreamer` echoue depuis le 2026-08-05 sur « dossier
GStreamer : INTROUVABLE ». La cause est ailleurs, et elle accuse le
mauvais coupable :
Unable to find package 'pkgconfiglite'.
Chocolatey installed 0/0 packages.
`pkgconfiglite` a disparu du depot communautaire. Or chocolatey traite une
commande comme UNE transaction : le paquet introuvable a annule les deux
autres, et GStreamer -- parfaitement disponible, lui -- n'a jamais ete
installe. Le message d'erreur pointait donc GStreamer alors que seul un
utilitaire annexe manquait.
Les deux workflows installent desormais les paquets separement, et le
message dit ce qui manque vraiment. GStreamer sera bien installe ; il
restera a trouver un pkg-config si le paquet ne revient pas.
CE N'EST PAS UNE PANNE DE CETTE BRANCHE : elle se reproduit sur main, et
le job est en continue-on-error, donc la PR n'est pas bloquee. Mais elle
merite d'etre signalee : ce job produit
`toolbox-node-windows-x64-gstreamer`, c'est-a-dire le SEUL binaire publie
qui lit des videos. Tant que pkg-config manque au runner, le pack video ne
peut pas etre reconstruit -- point a trancher avec Pym (paquet de
remplacement, ou pkg-config telecharge directement).
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
.github/workflows/ci.yml | 21 ++++++++++++++++++---
.github/workflows/release.yml | 11 ++++++++++-
2 files changed, 28 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b77e5df..436ad9d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -339,9 +339,24 @@ jobs:
shell: bash
run: |
set -o pipefail
- # pkgconfiglite : les paquets GStreamer MSVC n'embarquent pas
- # pkg-config.exe, requis par les crates *-sys.
- choco install -y gstreamer gstreamer-devel pkgconfiglite --no-progress 2>&1 | tail -5 | tee gstwin.log
+ # DEUX installations SEPAREES, et c'est important : chocolatey
+ # traite une commande comme UNE transaction, donc un seul paquet
+ # introuvable annule TOUS les autres. Le 2026-08-05, pkgconfiglite
+ # a disparu du depot communautaire (« Unable to find package ») et
+ # a emporte gstreamer avec lui : « Chocolatey installed 0/0
+ # packages », puis « dossier GStreamer : INTROUVABLE » -- alors que
+ # GStreamer, lui, etait parfaitement disponible.
+ choco install -y gstreamer gstreamer-devel --no-progress 2>&1 | tail -5 | tee gstwin.log
+ # pkg-config.exe : absent des paquets GStreamer MSVC, requis par les
+ # crates *-sys. Non bloquant ici pour que le diagnostic soit lisible
+ # (l'etape suivante dira precisement ce qui manque) au lieu d'un
+ # « INTROUVABLE » qui accuse GStreamer a tort.
+ if ! choco install -y pkgconfiglite --no-progress 2>&1 | tail -5 | tee -a gstwin.log; then
+ echo "pkgconfiglite indisponible : voir l'etape suivante" | tee -a gstwin.log
+ fi
+ if ! command -v pkg-config > /dev/null 2>&1; then
+ echo "ATTENTION : pkg-config absent du runner -- le pack video ne peut pas etre construit." | tee -a gstwin.log
+ fi
- name: Build release (feature gstreamer)
shell: bash
run: |
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8e77050..8a9a229 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -105,7 +105,16 @@ jobs:
- uses: Swatinem/rust-cache@v2
- name: GStreamer MSVC (dev + runtime) + pkg-config
shell: bash
- run: choco install -y gstreamer gstreamer-devel pkgconfiglite --no-progress 2>&1 | tail -3
+ # Installations SEPAREES : chocolatey annule toute la transaction si
+ # UN paquet est introuvable. Le 2026-08-05, pkgconfiglite a disparu du
+ # depot communautaire et a emporte GStreamer avec lui -- le pack video
+ # public, seul binaire publie qui lit des videos, cessait d'etre
+ # construit sans que la cause soit lisible.
+ run: |
+ set -o pipefail
+ choco install -y gstreamer gstreamer-devel --no-progress 2>&1 | tail -3
+ choco install -y pkgconfiglite --no-progress 2>&1 | tail -3 || \
+ echo "pkgconfiglite indisponible : build video impossible sur ce runner"
- name: Build + pack autonome
shell: bash
run: |
From fcc409f262956fbace6508d928cad8f016ff0c1c Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 5 Aug 2026 06:32:25 +0000
Subject: [PATCH 23/24] Relecture (fin) : l'UI, et une doc qui redisait ce
qu'on venait de corriger
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Troisieme et dernier lot de la relecture adversariale de mes correctifs.
CE QUI NE MARCHAIT PAS, DANS MES PROPRES CORRECTIFS :
1. LE SELECTEUR DE MEDIA DES CUES RESTAIT VIDE. J'avais corrige la valeur
qu'il ecrit (m.path au lieu de m.name) sans voir qu'il n'etait rempli
qu'a l'evaluation du script -- donc AVANT l'arrivee de la mediatheque.
Et « Charger un media » est le type d'action par DEFAUT : en ouvrant
l'onglet Sequences on trouvait un menu vide, et il fallait changer de
type puis revenir pour le peupler. Le correctif etait juste et
inoperant. `loadMedia` rappelle desormais `majChampsAction`.
2. LE FILTRE DU PARC N'APPLIQUAIT QU'UNE DES DEUX REGLES du serveur.
`validate_upload_name` exige un nom plat ET un jeu de caracteres
restreint : « ete.mp4 » avec un accent restait propose, pour etre
refuse ensuite par un message technique. Le filtre est maintenant le
miroir de la regle, et le cas « aucun media eligible » est dit au lieu
d'un menu vide sans explication.
3. LE SONDAGE DES ECRANS RECONSTRUISAIT LE MENU TOUTES LES 5 SECONDES.
En ajoutant le rafraichissement (pour voir un videoprojecteur branche
a chaud) j'ai rendu la carte « Sortie » penible a la souris : le menu
se refermait sous le doigt. Il n'est reconstruit que si la liste a
VRAIMENT change, et la selection n'est pas ecrasee quand l'operateur a
le champ sous le curseur.
4. « RESOLUTION IGNOREE » REGARDAIT LE MODE, PAS LA FEATURE.
`config.resolution` n'est lu que par la sortie KMS, elle-meme sous
`#[cfg(feature = "gstreamer")]`. Un binaire sans GStreamer configure en
mode « kms » annoncait donc la resolution comme appliquee alors que
rien ne la lit. Le terme manquant est ajoute.
LA DOC REDISAIT CE QU'ON VENAIT DE CORRIGER -- le pire resultat possible
pour une branche dont le sujet est justement la derive doc/realite :
5. La ligne /dmx/master du manuel affirmait encore, EN GRAS, la regle
« c'est le type qui decide » -- celle que le commit precedent avait
supprimee comme un defaut. Je n'avais reecrit que la ligne /dmx/fader.
6. La carte « Ubuntu / Debian » avait perdu les paquets d'EXECUTION en
gagnant ceux de compilation : quelqu'un qui recompile avec
--features gstreamer se serait retrouve sans decodeurs, donc toujours
sans video. Les deux temps sont retablis.
7. L'arret d'un chaser par chaine vide -- implemente la veille -- n'etait
documente nulle part, ni dans le manuel ni dans OSCQuery.
8. La liste des cibles de fader du manuel oubliait `dmx_master` : la faute
EXACTE que le commit precedent pretendait avoir eradiquee, sur une
troisieme copie manuscrite que je n'avais pas vue.
9. Trois documents annoncaient trois nombres de tests differents, dont
aucun n'etait le bon. Ils annoncent le chiffre mesure : 267.
10. Le doc-comment de ScaleTarget::Rate decrivait encore l'echelle
lineaire remplacee par le meme commit.
Deux tests ajoutes la ou RIEN ne retenait le correctif : `fps` null quand
la mesure n'existe pas (contre-epreuve faite : le test echoue bien si on
remet le zero dur), et le refus d'un node inconnu du parc par le relais
d'identification.
267 tests, clippy -D warnings, fmt.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
CLAUDE.md | 2 +-
README.md | 2 +-
crates/control-http/assets/index.html | 63 ++++++++++++++++++++++-----
crates/control-http/src/lib.rs | 63 +++++++++++++++++++++++++++
crates/control-http/src/oscquery.rs | 2 +-
crates/core/src/config.rs | 7 ++-
crates/node/src/main.rs | 7 ++-
docs/manuel.html | 26 ++++++-----
8 files changed, 144 insertions(+), 28 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 6271b13..1187896 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -247,7 +247,7 @@ Sous Windows, aucune dépendance système (midir utilise WinMM).
démarrage à froid, 3e passe sur la mesure, mise à jour d'une
installation existante), chaque lot repassé en relecture adversariale,
et vérifications EMPIRIQUES sur un vrai node à chaque fois que possible.
- 264 tests. Faits notables :
+ 267 tests. Faits notables :
**l'archive Pi officielle ne projette rien** (`--no-default-features` :
ni fenêtre, ni MIDI, ni GStreamer) alors que le manuel invitait à
installer des paquets `gstreamer1.0-*` — seul le pack Windows
diff --git a/README.md b/README.md
index a28648d..e74fa52 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ préfixe historique `toolbox-` (aucun chemin ni contrat ne change).
## État — v3.5.0
-La chaîne complète est fonctionnelle et testée (261 tests, CI Linux +
+La chaîne complète est fonctionnelle et testée (267 tests, CI Linux +
Windows + check ARM64) : **lecture vidéo réelle** (GStreamer, boucle sans
coupure), **fenêtre de sortie** avec warp/mires/couleur/effets calculés par
le **GPU** (wgpu/Vulkan, repli CPU automatique), sources externes (capture,
diff --git a/crates/control-http/assets/index.html b/crates/control-http/assets/index.html
index 5de513a..5573f5e 100644
--- a/crates/control-http/assets/index.html
+++ b/crates/control-http/assets/index.html
@@ -1687,20 +1687,37 @@
Alimentation de la machine
});
/* Sortie : écran cible + plein écran, appliqués à chaud. */
+/* Derniere liste d'ecrans affichee : evite de reconstruire le menu (et donc
+ de le refermer sous la souris) a chaque sondage. */
+let ecransSignature = null;
async function loadOutputs() {
let data;
try { data = await api("/api/outputs"); } catch (e) { return; }
const monitors = data.monitors || [], st = data.settings || { monitor: 0, fullscreen: false };
const sel = $("#outMonitor");
- sel.innerHTML = monitors.length ? "" : '
(aucun écran détecté)
';
- monitors.forEach(m => {
- const o = document.createElement("option");
- o.value = m.index;
- o.textContent = "Écran " + (m.index + 1) + " · " + m.name + " (" + m.width + "×" + m.height + ")";
- sel.appendChild(o);
- });
- if (monitors.length) sel.value = st.monitor;
- $("#outFullscreen").checked = !!st.fullscreen;
+ // La liste n'est RECONSTRUITE que si elle a change. Depuis qu'un sondage
+ // rappelle loadOutputs toutes les 5 s, la reconstruire a chaque passage
+ // fermerait le menu deroulant ouvert par l'operateur et ecraserait sa
+ // selection en cours -- une carte « Sortie » inutilisable a la souris.
+ const signature = JSON.stringify(monitors);
+ if (signature !== ecransSignature) {
+ ecransSignature = signature;
+ sel.innerHTML = monitors.length ? "" : '
(aucun écran détecté)
';
+ monitors.forEach(m => {
+ const o = document.createElement("option");
+ o.value = m.index;
+ o.textContent = "Écran " + (m.index + 1) + " · " + m.name + " (" + m.width + "×" + m.height + ")";
+ sel.appendChild(o);
+ });
+ if (monitors.length) sel.value = st.monitor;
+ } else if (monitors.length && sel !== document.activeElement) {
+ // Liste inchangee : on suit quand meme l'ecran courant, sauf si
+ // l'operateur a le menu sous le doigt.
+ sel.value = st.monitor;
+ }
+ if ($("#outFullscreen") !== document.activeElement) {
+ $("#outFullscreen").checked = !!st.fullscreen;
+ }
}
async function pushOutput() {
const body = { monitor: +$("#outMonitor").value || 0, fullscreen: $("#outFullscreen").checked };
@@ -1835,9 +1852,18 @@
Alimentation de la machine
async function loadMedia() {
try { mediaList = await api("/api/media") || []; } catch (e) { return; }
renderMediaTable(); renderPlAdd(); renderParcMedia();
+ // majChampsAction : « Charger un media » est le type d'action par DEFAUT,
+ // et son selecteur n'etait rempli qu'a l'evaluation du script -- donc avant
+ // que la mediatheque n'arrive. En ouvrant l'onglet Sequences, on trouvait
+ // un menu vide et il fallait changer de type puis revenir pour le peupler.
+ majChampsAction();
}
/* ===================== Fichiers du parc ===================== */
+/* Miroir de validate_upload_name (crates/core/src/media.rs) : nom PLAT, jeu
+ de caracteres restreint, et pas de point en tete. Proposer autre chose
+ revient a promettre un envoi que le serveur refusera. */
+const NOM_PARC = /^(?!\.)[A-Za-z0-9._ ()-]+$/;
let parcNodes = [];
async function loadParc() {
try { parcNodes = (await api("/api/fleet") || []).filter(n => !n.soi_meme); } catch (e) { return; }
@@ -1855,9 +1881,19 @@
Alimentation de la machine
return;
}
// /api/media renvoie des MediaInfo { path, bytes } — jamais `name`.
- // Et un envoi de parc exige un nom PLAT (validate_upload_name refuse les
- // sous-dossiers) : inutile de proposer ce que le serveur rejettera.
- mediaList.filter(m => !m.path.includes("/")).forEach(m => {
+ // Et un envoi de parc exige un nom que validate_upload_name accepte
+ // (crates/core/src/media.rs) : plat ET restreint a un jeu de caracteres.
+ // Ne filtrer que les sous-dossiers laissait proposer « été.mp4 », que le
+ // serveur refuse ensuite avec un message technique.
+ const plats = mediaList.filter(m => NOM_PARC.test(m.path));
+ if (!plats.length) {
+ const o = document.createElement("option");
+ o.disabled = true;
+ o.textContent = "— aucun media eligible (sous-dossier ou nom accentue) —";
+ sel.appendChild(o);
+ return;
+ }
+ plats.forEach(m => {
const o = document.createElement("option");
o.value = o.textContent = m.path;
sel.appendChild(o);
@@ -2978,6 +3014,9 @@
Alimentation de la machine
// Ecrans branches/debranches a chaud : le node republie la liste, encore
// faut-il la relire. Seulement quand la carte « Sortie » est visible.
setInterval(() => { if (!document.hidden && $("#page-mapping").classList.contains("visible")) loadOutputs(); }, 5000);
+ // (loadOutputs ne reconstruit le menu que si la liste a change : voir
+ // ecransSignature — sinon un sondage toutes les 5 s ecraserait la
+ // selection en cours de l'operateur.)
sizeStage();
paintSeek();
})();
diff --git a/crates/control-http/src/lib.rs b/crates/control-http/src/lib.rs
index a0ea2c2..969679d 100644
--- a/crates/control-http/src/lib.rs
+++ b/crates/control-http/src/lib.rs
@@ -2532,6 +2532,69 @@ mod tests {
}
}
+ /// `fps` doit suivre EXACTEMENT la règle de `rendu` : `null` quand rien
+ /// ne peut le peupler. Il restait un zéro dur, et un run d'endurance sur
+ /// un Pi parfaitement sain ressortait « sortie sans aucune image ». Rien
+ /// ne retenait ce correctif : le seul test qui regarde ce champ force le
+ /// drapeau à vrai.
+ #[tokio::test]
+ async fn fps_est_nul_quand_la_mesure_n_existe_pas() {
+ let bed = testbed(); // mesure_disponible = false par défaut
+ for route in ["/api/system", "/api/outputs"] {
+ let response = bed
+ .router
+ .clone()
+ .oneshot(
+ HttpRequest::get(route)
+ .body(Body::empty())
+ .expect("requête"),
+ )
+ .await
+ .expect("réponse");
+ let json = body_json(response).await;
+ assert!(
+ json["fps"].is_null(),
+ "{route} : fps doit être null quand la mesure n'existe pas, pas 0 — un zéro se lit « sortie morte »"
+ );
+ }
+ // Et `rendu` suit la même règle, sur la route qui le porte.
+ let response = bed
+ .router
+ .clone()
+ .oneshot(
+ HttpRequest::get("/api/system")
+ .body(Body::empty())
+ .expect("requête"),
+ )
+ .await
+ .expect("réponse");
+ assert!(body_json(response).await["rendu"].is_null());
+ }
+
+ /// Le relais d'identification n'accepte que des nodes CONNUS du parc :
+ /// l'URL vient du client, et la suivre sans contrôle serait un SSRF.
+ #[tokio::test]
+ async fn le_relais_d_identification_refuse_un_node_inconnu() {
+ let bed = testbed(); // parc vide
+ let response = bed
+ .router
+ .clone()
+ .oneshot(
+ HttpRequest::post("/api/fleet/identify?url=http://192.168.1.99:8080/")
+ .header("origin", "http://192.168.1.50:8080")
+ .header("host", "192.168.1.50:8080")
+ .body(Body::empty())
+ .expect("requête"),
+ )
+ .await
+ .expect("réponse");
+ assert_ne!(
+ response.status(),
+ StatusCode::NO_CONTENT,
+ "un node absent du parc ne doit pas être contacté"
+ );
+ }
+
/// Une LUT d'étalonnage réaliste (64 points ≈ 7 Mo) doit atteindre le
/// parseur. Sans `DefaultBodyLimit` relevé sur la route, axum coupait à
/// 2 Mo et répondait 413 SANS corps JSON : le garde « 64 Mo max » de
diff --git a/crates/control-http/src/oscquery.rs b/crates/control-http/src/oscquery.rs
index 4deb23a..a68125a 100644
--- a/crates/control-http/src/oscquery.rs
+++ b/crates/control-http/src/oscquery.rs
@@ -331,7 +331,7 @@ pub fn namespace(state: &NodeState) -> Value {
})),
"dmx": container("/dmx", json!({
"scene": leaf("/dmx/scene", "s", "Rappelle une scène de la console lumières", None, None),
- "chaser": leaf("/dmx/chaser", "s", "Lance un chaser (nom) ; sans argument : arrêt", None, None),
+ "chaser": leaf("/dmx/chaser", "s", "Lance un chaser (nom) ; chaîne VIDE : arrêt du chaser en cours", None, None),
"master": leaf("/dmx/master", "i", "Grand master lumières : entier 0..255, ou flottant 0..1 (fader normalisé)",
None, Some(json!([{ "MIN": 0, "MAX": 255 }]))),
"fader": leaf("/dmx/fader", "si", "Niveau d'un fader : identifiant, puis entier 0..255 ou flottant 0..1", None, None),
diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs
index 55e33c6..4a864bd 100644
--- a/crates/core/src/config.rs
+++ b/crates/core/src/config.rs
@@ -131,8 +131,11 @@ pub enum ScaleTarget {
Noise,
Sharpen,
Mirror,
- /// Vitesse de lecture, 0.25× à 4× (1× à mi-course n'est pas garanti :
- /// l'échelle est linéaire sur les bornes de la commande).
+ /// Vitesse de lecture, 0,25× à 4×, sur une échelle GÉOMÉTRIQUE :
+ /// 0,25× en bas, 0,5× au quart, 1× PILE à mi-course, 2× aux trois
+ /// quarts, 4× à fond. Un fader de vitesse se pense en octaves, et la
+ /// vitesse normale doit être atteignable — en linéaire elle tombait
+ /// entre deux crans du contrôleur.
Rate,
/// Grand master de la console lumières (0..255). Poser un fader de
/// surface dessus est le geste le plus canonique d'une console ; il
diff --git a/crates/node/src/main.rs b/crates/node/src/main.rs
index cc6ac88..ced2be9 100644
--- a/crates/node/src/main.rs
+++ b/crates/node/src/main.rs
@@ -152,7 +152,12 @@ async fn run(mut config: NodeConfig, logs: LogBuffer) -> Result<(), BoxWindows — lé
Ubuntu / Debian
toolbox-node-linux-x64
Sans lecture vidéo (mires, mapping, calibrage, OSC/MIDI) :
- comme le pack Windows léger, ce binaire est compilé sans GStreamer —
- installer les paquets gstreamer1.0-* n'y changera rien.
- Pour lire des vidéos sur Linux, compiler sur place :
- sudo apt install libgstreamer1.0-dev
- libgstreamer-plugins-base1.0-dev libasound2-dev puis
- cargo build --release -p toolbox-node --features gstreamer.
+ ce binaire est compilé sans GStreamer, comme le pack Windows léger.
+ Sur cette archive, installer les paquets gstreamer1.0-*
+ ne change donc rien.
+
Pour lire des vidéos sur Linux, il faut compiler sur place — et les
+ deux temps comptent : les paquets -dev pour compiler, les
+ plugins pour lire ensuite.
Départ commun au timestamp — horloges NTP partagées
/cue/go
nom
Déclenche une cue du séquenceur
/dmx/scene
nom
Rappelle une scène de la console lumières
-
/dmx/chaser
nom (ou rien)
Lance un chaser ; sans argument, arrête celui en cours
-
/dmx/master
entier 0..255 ou flottant 0..1
Grand master lumières. C'est le type qui décide : un entier est un niveau DMX, un flottant est un fader normalisé (0.5 = mi-course, 1.0 = plein)
-
/dmx/fader
id, puis entier 0..255 ou flottant 0..1
Niveau d'un fader lumières, par identifiant. Même règle que /dmx/master
+
/dmx/chaser
nom, ou chaîne vide
Un nom lance le chaser ; une chaîne vide — vider le champ dans Chataigne — arrête celui en cours. Un message sans argument marche aussi, mais Chataigne ne sait pas en envoyer sur un paramètre texte
+
/dmx/master
entier 0..255 ou flottant 0..1
Grand master lumières. Un entier est un niveau DMX (0..255). Un flottant fractionnaire — ou exactement 1.0 — est un fader normalisé (0.5 = mi-course, 1.0 = plein). Un flottant à valeur entière (90.0) reste un niveau DMX : Chataigne envoie volontiers 90.0 pour 90
+
/dmx/fader
id, puis entier 0..255 ou flottant 0..1
Niveau d'un fader lumières, par identifiant. Même règle de valeur que /dmx/master
Auto-découverte OSCQuery (Chataigne)
@@ -651,7 +656,8 @@
Contrôle MIDI
scale = "volume" # 0..127 → 0..1# Cibles : volume, rate (0,25×–4×), les 8 réglages couleur# (brightness, contrast, gamma, saturation, hue, gain_r/g/b)
-# et les 5 effets (pixelate, posterize, noise, sharpen, mirror).
+# les 5 effets (pixelate, posterize, noise, sharpen, mirror)
+# et dmx_master (grand master lumieres, 0..255).
[[midi.bindings]]
note = 62
From 18002d5c6a47f62470c46271566913c85dba76ea Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 5 Aug 2026 06:38:23 +0000
Subject: [PATCH 24/24] Les trois derniers defauts de la relecture, dont un
dans un correctif
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fin de la relecture adversariale. Trois defauts restaient ouverts ; l'un
d'eux etait dans le correctif que je venais d'ecrire.
1. LE SELECTEUR DE CUE AURAIT EFFACE LA SELECTION DE L'OPERATEUR. Mon
correctif precedent faisait rappeler `majChampsAction` par `loadMedia`,
pour que le menu « Charger un media » ne soit plus vide. Mais loadMedia
est SONDE toutes les 5 s tant que la page Medias est visible : le menu
aurait ete reconstruit sous le doigt, et le media deja choisi dans
l'onglet Sequences remplace en silence par le premier de la liste --
l'operateur aurait enregistre une cue qui ne charge pas ce qu'il croit.
La selection est desormais preservee (meme precaution que
renderParcMedia), pour les medias comme pour les presets, et le rappel
est limite au type d'action concerne (sinon un GET /api/dmx toutes les
5 s pour rien).
2. UN TEST QUI ASSURAIT UNE PROPRIETE JETEE A L'EXECUTION.
`publication_plein_ecran` construisait un OutputSettings dont la
production ne gardait plus que `.is_none()` : l'assertion centrale du
test -- « l'ecran cible ne doit pas bouger » -- ne protegeait donc plus
rien. Une refonte aurait pu ramener le defaut d'origine avec une suite
verte. La fonction rend maintenant la valeur que la production RETIENT
vraiment, et prend les deux entrees qui comptent : le canal decide s'il
y a quelque chose a dire, ce que la fenetre a applique sert de base. Un
test couvre le cas qui compte -- un reglage de l'UI encore en attente ne
doit pas etre absorbe par un F11.
3. UNE CUE INCOMPRISE DISPARAISSAIT DEFINITIVEMENT. En rendant la lecture
tolerante, j'avais supprime sans le voir le filet qui existait : avant,
un fichier illisible partait en `.corrompu` et restait recuperable EN
ENTIER. Maintenant qu'on le lit avec succes, la premiere reecriture de
la conduite l'amputait pour de bon. Le cas vise est precisement le
retour arriere apres une mise a jour, ou les cues perdues sont
parfaitement valides pour la version qui les a ecrites.
`sequences.json.incomplet` conserve l'original quand au moins une cue a
ete ecartee, avec un WARN qui le dit. Deux tests : la copie porte bien
la conduite entiere, et une conduite saine n'en laisse aucune.
269 tests, clippy -D warnings, fmt.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01We2sFzNwRT8Juwd8Ai1LDZ
---
CLAUDE.md | 2 +-
README.md | 2 +-
crates/control-http/assets/index.html | 27 ++++++---
crates/core/src/sequenceur.rs | 80 +++++++++++++++++++++++++++
crates/render/src/window.rs | 50 +++++++++++++----
5 files changed, 141 insertions(+), 20 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 1187896..7b9a16a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -247,7 +247,7 @@ Sous Windows, aucune dépendance système (midir utilise WinMM).
démarrage à froid, 3e passe sur la mesure, mise à jour d'une
installation existante), chaque lot repassé en relecture adversariale,
et vérifications EMPIRIQUES sur un vrai node à chaque fois que possible.
- 267 tests. Faits notables :
+ 269 tests. Faits notables :
**l'archive Pi officielle ne projette rien** (`--no-default-features` :
ni fenêtre, ni MIDI, ni GStreamer) alors que le manuel invitait à
installer des paquets `gstreamer1.0-*` — seul le pack Windows
diff --git a/README.md b/README.md
index e74fa52..0ddd398 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ préfixe historique `toolbox-` (aucun chemin ni contrat ne change).
## État — v3.5.0
-La chaîne complète est fonctionnelle et testée (267 tests, CI Linux +
+La chaîne complète est fonctionnelle et testée (269 tests, CI Linux +
Windows + check ARM64) : **lecture vidéo réelle** (GStreamer, boucle sans
coupure), **fenêtre de sortie** avec warp/mires/couleur/effets calculés par
le **GPU** (wgpu/Vulkan, repli CPU automatique), sources externes (capture,
diff --git a/crates/control-http/assets/index.html b/crates/control-http/assets/index.html
index 5573f5e..10e0552 100644
--- a/crates/control-http/assets/index.html
+++ b/crates/control-http/assets/index.html
@@ -1852,11 +1852,13 @@
Alimentation de la machine
async function loadMedia() {
try { mediaList = await api("/api/media") || []; } catch (e) { return; }
renderMediaTable(); renderPlAdd(); renderParcMedia();
- // majChampsAction : « Charger un media » est le type d'action par DEFAUT,
- // et son selecteur n'etait rempli qu'a l'evaluation du script -- donc avant
- // que la mediatheque n'arrive. En ouvrant l'onglet Sequences, on trouvait
- // un menu vide et il fallait changer de type puis revenir pour le peupler.
- majChampsAction();
+ // « Charger un media » est le type d'action par DEFAUT, et son selecteur
+ // n'etait rempli qu'a l'evaluation du script -- donc avant que la
+ // mediatheque n'arrive. En ouvrant l'onglet Sequences on trouvait un menu
+ // vide, et il fallait changer de type puis revenir pour le peupler.
+ // Uniquement si le type courant en depend : majChampsAction lance sinon un
+ // GET /api/dmx, qui n'a rien a faire dans un sondage a 5 s.
+ if ($("#cueActType") && $("#cueActType").value === "load") majChampsAction();
}
/* ===================== Fichiers du parc ===================== */
@@ -2279,15 +2281,26 @@
Alimentation de la machine
$("#cueActPattern").style.display = type === "pattern" ? "block" : "none";
$("#cueActDmx").style.display = (type === "dmxscene" || type === "dmxchaser") ? "block" : "none";
if (type === "load") {
- const sel = $("#cueActMedia"); sel.innerHTML = "";
+ const sel = $("#cueActMedia");
+ // La SELECTION EN COURS est preservee : loadMedia rappelle cette
+ // fonction, et il est sonde toutes les 5 s tant que la page Medias est
+ // visible. Reconstruire sans precaution effacerait en silence le media
+ // choisi par l'operateur, qui enregistrerait alors le premier de la
+ // liste sans s'en apercevoir. Meme precaution que renderParcMedia.
+ const choisi = sel.value;
+ sel.innerHTML = "";
// `m.path` : MediaInfo n'a pas de champ `name`. Avec m.name, la cue
// enregistrait « load undefined » — accepté sans broncher, et découvert
// le soir venu, à l'heure du spectacle.
mediaList.forEach(m => { const o = document.createElement("option"); o.value = o.textContent = m.path; sel.appendChild(o); });
+ if ([...sel.options].some(o => o.value === choisi)) sel.value = choisi;
}
if (type === "preset" || type === "fade") {
- const sel = $("#cueActPreset"); sel.innerHTML = "";
+ const sel = $("#cueActPreset");
+ const choisi = sel.value;
+ sel.innerHTML = "";
presets.forEach(n => { const o = document.createElement("option"); o.value = o.textContent = n; sel.appendChild(o); });
+ if ([...sel.options].some(o => o.value === choisi)) sel.value = choisi;
}
if (type === "dmxscene" || type === "dmxchaser") {
const sel = $("#cueActDmx"); sel.innerHTML = "";
diff --git a/crates/core/src/sequenceur.rs b/crates/core/src/sequenceur.rs
index c751cb9..ad1505b 100644
--- a/crates/core/src/sequenceur.rs
+++ b/crates/core/src/sequenceur.rs
@@ -145,6 +145,17 @@ where
Ok(cues)
}
+/// Combien de cues le fichier contenait-il de plus que ce qu'on a relu ?
+/// `None` si le compte est le même (cas nominal) ou si le fichier ne se
+/// laisse pas inspecter — on ne veut surtout pas transformer une lecture
+/// réussie en échec pour un dénombrement.
+fn cues_ecartees(path: &std::path::Path, relues: usize) -> Option {
+ let octets = std::fs::read(path).ok()?;
+ let brut: serde_json::Value = serde_json::from_slice(&octets).ok()?;
+ let ecrites = brut.get("cues")?.as_array()?.len();
+ ecrites.checked_sub(relues).filter(|n| *n > 0)
+}
+
/// L'état du séquenceur, publié à l'UI et persisté (sans le transitoire).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct EtatSequenceur {
@@ -162,6 +173,25 @@ pub struct EtatSequenceur {
impl EtatSequenceur {
pub fn load(path: &std::path::Path) -> Option {
let mut etat: Self = crate::charger_ou_mettre_de_cote(path, "séquences")?;
+ // Une cue écartée par `cues_tolerantes` disparaîtrait DÉFINITIVEMENT
+ // à la première réécriture du fichier. Le filet qui existait avant
+ // (échec de lecture → `sequences.json.corrompu`, tout le fichier
+ // conservé) ne joue plus, puisqu'on lit désormais avec succès. On
+ // garde donc une copie de l'original AVANT que la conduite ne soit
+ // réenregistrée amputée — le cas visé est le retour arrière après
+ // une mise à jour, où les cues perdues sont parfaitement valides
+ // pour la version qui les a écrites.
+ if let Some(ecartees) = cues_ecartees(path, etat.cues.len()) {
+ let copie = path.with_extension("json.incomplet");
+ match std::fs::copy(path, &copie) {
+ Ok(_) => tracing::warn!(
+ ecartees,
+ copie = %copie.display(),
+ "cues illisibles écartées — conduite d'origine conservée"
+ ),
+ Err(err) => tracing::error!(%err, ecartees, "cues écartées ET copie impossible"),
+ }
+ }
// Le transitoire ne survit pas au redémarrage.
etat.en_attente = None;
etat.derniere = None;
@@ -525,6 +555,56 @@ mod tests {
use super::*;
use crate::Bus;
+ /// La cue écartée ne doit pas disparaître SANS TRACE : avant le filet
+ /// tolérant, un fichier illisible partait en `.corrompu` et restait
+ /// récupérable en entier. Maintenant qu'on le lit avec succès, la
+ /// première réécriture l'amputerait définitivement — on en garde donc
+ /// une copie.
+ #[test]
+ fn une_cue_ecartee_laisse_l_original_recuperable() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let chemin = dir.path().join("sequences.json");
+ std::fs::write(
+ &chemin,
+ r#"{"cues":[
+ {"nom":"ouverture","declencheur":{"type":"manuel"},"actions":[]},
+ {"nom":"venue_du_futur","declencheur":{"type":"au_lever_du_jour"},"actions":[]}
+ ]}"#,
+ )
+ .expect("écriture");
+
+ let etat = EtatSequenceur::load(&chemin).expect("la conduite doit se relire");
+ assert_eq!(etat.cues.len(), 1, "la cue incomprise est écartée");
+
+ // L'original est conservé À CÔTÉ, avec ses DEUX cues.
+ let copie = chemin.with_extension("json.incomplet");
+ assert!(copie.exists(), "l'original doit être conservé : {copie:?}");
+ let garde: serde_json::Value =
+ serde_json::from_slice(&std::fs::read(&copie).expect("copie")).expect("json");
+ assert_eq!(
+ garde["cues"].as_array().expect("cues").len(),
+ 2,
+ "la copie doit porter la conduite ENTIÈRE, cue incomprise incluse"
+ );
+ }
+
+ /// Cas nominal : aucune cue écartée, donc aucune copie parasite.
+ #[test]
+ fn une_conduite_saine_ne_laisse_pas_de_copie() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let chemin = dir.path().join("sequences.json");
+ std::fs::write(
+ &chemin,
+ r#"{"cues":[{"nom":"ouverture","declencheur":{"type":"manuel"},"actions":[]}]}"#,
+ )
+ .expect("écriture");
+ EtatSequenceur::load(&chemin).expect("relecture");
+ assert!(
+ !chemin.with_extension("json.incomplet").exists(),
+ "pas de copie quand rien n'a été écarté"
+ );
+ }
+
/// Scénario réel : une version plus récente a écrit une conduite avec un
/// déclencheur qu'on ne connaît pas (retour arrière après mise à jour).
/// Avant, `Declencheur` étant un enum étiqueté sans repli, TOUTE la
diff --git a/crates/render/src/window.rs b/crates/render/src/window.rs
index 92d0764..75eb224 100644
--- a/crates/render/src/window.rs
+++ b/crates/render/src/window.rs
@@ -193,13 +193,21 @@ fn run_event_loop(config: WindowConfig, channels: OutputChannels) {
///
/// Séparé de la fenêtre pour être testable : le reste demande un event loop
/// winit, donc un serveur graphique.
-fn publication_plein_ecran(courant: OutputSettings, plein: bool) -> Option {
- if courant.fullscreen == plein {
+fn publication_plein_ecran(
+ publie: OutputSettings,
+ applique: Option,
+ plein: bool,
+) -> Option {
+ if publie.fullscreen == plein {
return None;
}
+ // La base est ce que la FENÊTRE a appliqué, pas le contenu du canal : un
+ // réglage de l'UI peut y être sans que l'event loop l'ait traité, et le
+ // recopier le ferait passer pour notre propre écho — donc ignorer à
+ // jamais l'écran cible choisi depuis la tablette.
Some(OutputSettings {
fullscreen: plein,
- ..courant
+ ..applique.unwrap_or(publie)
})
}
@@ -421,9 +429,10 @@ impl OutputApp {
/// la sortie en plein écran avec F11 sur la machine, puis changer d'écran
/// depuis une tablette, la faisait sortir du plein écran toute seule.
fn publier_plein_ecran(&mut self, plein: bool) {
- if publication_plein_ecran(*self.settings.borrow(), plein).is_none() {
+ let Some(cible) = publication_plein_ecran(*self.settings.borrow(), self.applique, plein)
+ else {
return;
- }
+ };
// `send_modify` et non `send_replace` : la lecture-modification-
// écriture est ATOMIQUE sous le verrou du canal. Publier une copie
// lue plus tôt écraserait un changement d'écran cible venu de l'UI
@@ -435,8 +444,6 @@ impl OutputApp {
// frame). Recopier le canal ferait passer ce réglage-là pour notre
// propre écho, et il serait ignoré pour toujours — l'écran cible
// choisi depuis la tablette ne serait jamais appliqué.
- let mut cible = self.applique.unwrap_or(*self.settings.borrow());
- cible.fullscreen = plein;
self.settings_tx.send_modify(|s| s.fullscreen = plein);
self.applique = Some(cible);
}
@@ -998,22 +1005,43 @@ mod tests {
fullscreen: false,
};
// F11 depuis l'état fenêtré : on publie, en gardant l'écran cible.
- let publie = publication_plein_ecran(fenetre, true).expect("doit publier");
+ let publie = publication_plein_ecran(fenetre, None, true).expect("doit publier");
assert!(publie.fullscreen);
assert_eq!(publie.monitor, 1, "l'écran cible ne doit pas bouger");
// Échap depuis le plein écran : on publie le retour en fenêtré.
- let publie = publication_plein_ecran(publie, false).expect("doit publier");
+ let publie = publication_plein_ecran(publie, None, false).expect("doit publier");
assert!(!publie.fullscreen);
// Déjà dans l'état demandé : rien à publier (sinon on réveille
// l'event loop pour rien, et on relance un cycle d'écho).
- assert_eq!(publication_plein_ecran(fenetre, false), None);
+ assert_eq!(publication_plein_ecran(fenetre, None, false), None);
let plein = OutputSettings {
monitor: 0,
fullscreen: true,
};
- assert_eq!(publication_plein_ecran(plein, true), None);
+ assert_eq!(publication_plein_ecran(plein, None, true), None);
+
+ // LE CAS QUI COMPTE, et que la production retient vraiment : un
+ // réglage de l'UI est déjà dans le canal (écran 1) mais la fenêtre
+ // n'a pas encore eu le temps de l'appliquer (elle en est à l'écran
+ // 0). Un F11 ne doit PAS s'attribuer l'écran 1, sinon le changement
+ // venu de la tablette passerait pour notre écho et serait ignoré.
+ let en_attente = OutputSettings {
+ monitor: 1,
+ fullscreen: false,
+ };
+ let deja_applique = OutputSettings {
+ monitor: 0,
+ fullscreen: false,
+ };
+ let retenu =
+ publication_plein_ecran(en_attente, Some(deja_applique), true).expect("doit publier");
+ assert!(retenu.fullscreen);
+ assert_eq!(
+ retenu.monitor, 0,
+ "le marqueur d'écho part de ce que la FENÊTRE a appliqué, pas du canal"
+ );
}
#[test]