diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0da31e2..436ad9d 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
@@ -69,6 +73,49 @@ jobs:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
+ # Les scripts PowerShell n'étaient vérifiés NULLE PART : shellcheck ne
+ # couvre que les .sh, et l'installateur Windows comme le collecteur
+ # d'endurance ne tournent jamais en CI. Une coquille de syntaxe n'y
+ # était découverte que par l'utilisateur, sur sa machine.
+ - name: Analyse des scripts PowerShell
+ shell: pwsh
+ 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) 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: |
@@ -146,6 +193,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 +236,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 +261,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 +302,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
@@ -269,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: |
@@ -334,7 +419,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 3c1b534..8a9a229 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`.
@@ -41,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
@@ -53,6 +66,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 +88,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:
@@ -85,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: |
@@ -100,7 +129,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
@@ -121,7 +153,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
@@ -134,6 +173,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:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2671453..68147a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,88 @@
É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]`).
+
+### 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`,
+ 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 +92,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 +108,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 +128,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/CLAUDE.md b/CLAUDE.md
index 0ff4a27..7b9a16a 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.
+ 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
+ `…-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**.
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 950cbdf..0ddd398 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 (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,
@@ -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)
@@ -98,22 +104,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/artnet/src/lib.rs b/crates/artnet/src/lib.rs
index b36a615..494bf10 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.
@@ -476,11 +483,24 @@ pub async fn service(
Ok(toolbox_core::Event::DmxChaserDemande { name: None }) => {
Some(CommandeLumieres::ChaserArrete)
}
+ Ok(toolbox_core::Event::DmxMasterDemande { valeur }) => {
+ Some(CommandeLumieres::Master { valeur })
+ }
+ Ok(toolbox_core::Event::DmxFaderDemande { id, valeur }) => {
+ Some(CommandeLumieres::FaderValeur { id, valeur })
+ }
Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => None,
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());
}
}
@@ -542,6 +562,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/assets/index.html b/crates/control-http/assets/index.html
index 4d3a6c0..10e0552 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
@@ -980,7 +984,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 +1110,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(); }
@@ -1675,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 ? "" : '';
- 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 ? "" : '';
+ 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 };
@@ -1823,9 +1852,20 @@
Alimentation de la machine
async function loadMedia() {
try { mediaList = await api("/api/media") || []; } catch (e) { return; }
renderMediaTable(); renderPlAdd(); renderParcMedia();
+ // « 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 ===================== */
+/* 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; }
@@ -1842,9 +1882,22 @@
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 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.name;
+ o.value = o.textContent = m.path;
sel.appendChild(o);
});
if ([...sel.options].some(o => o.value === old)) sel.value = old;
@@ -1870,7 +1923,7 @@
$("#cueActPattern").style.display = type === "pattern" ? "block" : "none";
$("#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); });
+ 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 = "";
@@ -2642,6 +2709,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";
@@ -2697,7 +2770,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) {
@@ -2776,11 +2849,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);
@@ -2937,6 +3024,12 @@
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);
+ // (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 4aa5756..969679d 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))
@@ -344,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))
@@ -777,7 +788,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)
}
@@ -1383,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/`).
@@ -1590,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,
@@ -1617,7 +1700,20 @@ async fn diagnostic_zip(State(app): State) -> Result) -> 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
+ },
}))
}
@@ -1925,8 +2065,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;
@@ -2386,6 +2532,99 @@ 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
+ /// `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..a68125a 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,35 @@ 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) ; 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),
+ })),
}
})
}
@@ -329,6 +395,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;
diff --git a/crates/control-http/src/ota.rs b/crates/control-http/src/ota.rs
index 909b77c..6479d40 100644
--- a/crates/control-http/src/ota.rs
+++ b/crates/control-http/src/ota.rs
@@ -30,15 +30,76 @@ pub struct EtatMiseAJour {
pub plus_recente: bool,
/// Nom de l'archive adaptée à cette plateforme, si trouvée.
pub asset: Option,
+ /// 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,
}
-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,11 +140,13 @@ 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())
})
});
+ 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
@@ -93,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
@@ -126,7 +208,7 @@ pub fn telecharger() -> Result {
let asset = etat
.asset
.as_deref()
- .ok_or("pas d'archive pour cette plateforme dans la release")?;
+ .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);
@@ -302,9 +384,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 +427,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 +463,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/control-midi/src/lib.rs b/crates/control-midi/src/lib.rs
index a2f333f..1493086 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,27 @@ 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),
+ // É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: 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).
+ ScaleTarget::DmxMaster => Command::DmxMaster {
+ valeur: (t * 255.0).round() as u8,
+ },
}
}
@@ -255,6 +278,71 @@ 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);
+
+ // 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))
+ .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/control-osc/src/lib.rs b/crates/control-osc/src/lib.rs
index 8fe80f3..832ae83 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,25 @@ 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::DmxMasterDemande { valeur } => {
+ message("/dmx/master", vec![OscType::Int(i32::from(*valeur))])
+ }
+ Event::DmxFaderDemande { id, valeur } => message(
+ "/dmx/fader",
+ vec![
+ OscType::String(id.clone()),
+ OscType::Int(i32::from(*valeur)),
+ ],
+ ),
Event::LutChanged { name } => message(
"/lut",
vec![OscType::String(name.clone().unwrap_or_default())],
@@ -518,10 +538,24 @@ 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
+ // par l'operateur) mais borne, jamais tronque en silence.
+ "/dmx/master" => octet_arg(args, 0)
+ .map(|valeur| Command::DmxMaster { valeur })
+ .ok_or_else(|| bad("attendu : niveau 0..255")),
+ "/dmx/fader" => match (string_arg(args, 0), octet_arg(args, 1)) {
+ (Some(id), Some(valeur)) => Ok(Command::DmxFader { id, valeur }),
+ _ => Err(bad("attendu : identifiant (string) puis niveau 0..255")),
+ },
"/preset/fade" => match (string_arg(args, 0), float_arg(args, 1)) {
(Some(name), Some(seconds)) => Ok(Command::PresetFade { name, seconds }),
_ => Err(bad("attendu : nom (string) puis durée en secondes (float)")),
@@ -645,6 +679,49 @@ fn int_arg(args: &[OscType], index: usize) -> Option {
}
}
+/// Niveau lumière 0..=255, tolérant comme le reste du parseur.
+///
+/// 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.
+///
+/// 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 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) => flottant(f64::from(*f)),
+ OscType::Double(d) => flottant(*d),
+ _ => u8::try_from(int_arg(args, index)?).ok(),
+ }
+}
+
/// Booléen tolérant : Bool, ou entier/float 0/1.
fn bool_arg(args: &[OscType], index: usize) -> Option {
match args.get(index)? {
@@ -1051,6 +1128,91 @@ mod tests {
vec![OscType::String("scene_02".into()), OscType::Float(2.5)]
);
+ // Master et faders lumières : ils n'existaient QUE via POST /api/dmx,
+ // donc uniquement depuis la web UI. Poser un fader de surface sur le
+ // grand master — le geste le plus canonique d'une console — était
+ // impossible, comme de le piloter depuis Chataigne.
+ assert_eq!(
+ map_message("/dmx/master", &[OscType::Int(200)]),
+ 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 })
+ );
+ // 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!(
+ map_message(
+ "/dmx/fader",
+ &[OscType::String("f1".into()), OscType::Int(64)]
+ ),
+ Ok(Command::DmxFader {
+ id: "f1".into(),
+ valeur: 64
+ })
+ );
+
+ // 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"));
+ // 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 {
state: Box::default()
diff --git a/crates/core/src/command.rs b/crates/core/src/command.rs
index cbf474c..8529694 100644
--- a/crates/core/src/command.rs
+++ b/crates/core/src/command.rs
@@ -79,6 +79,16 @@ 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) |
+/// | `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)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum Command {
@@ -244,6 +254,21 @@ pub enum Command {
DmxChaser {
name: Option,
},
+ /// Grand master lumières, 0..=255 (OSC `/dmx/master`, fader MIDI).
+ /// Le master et les niveaux de fader n'existaient QUE via POST
+ /// /api/dmx, donc uniquement depuis la web UI : poser un fader de
+ /// surface MIDI sur le grand master — le geste le plus canonique
+ /// d'une console — était impossible, comme de le piloter depuis
+ /// Chataigne.
+ DmxMaster {
+ valeur: u8,
+ },
+ /// Niveau d'un fader lumières par identifiant, 0..=255
+ /// (OSC `/dmx/fader `).
+ DmxFader {
+ id: String,
+ valeur: u8,
+ },
/// Arme la synchro multi-node : média prêt, position 0, en pause.
/// (`/sync/arm` — envoyé à tous les nodes avant un départ commun.)
SyncArm,
@@ -258,6 +283,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 d792423..4a864bd 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,
}
}
}
@@ -121,6 +122,51 @@ 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×, 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
+ /// n'existait aucun chemin pour le faire.
+ 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.
@@ -148,7 +194,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,
}
@@ -174,6 +225,24 @@ where
}
}
+/// Même filet que [`commande_tolerante`], pour la cible d'un CC.
+fn cible_tolerante<'de, D>(deserializer: D) -> Result
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,31 @@
Windows — lé
Pour piloter ou tester sans projeter.
Sans lecture vidéo (mires, mapping, calibrage, OSC/MIDI) :
+ 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.
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).
@@ -335,7 +349,7 @@
Installation 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
@@ -532,6 +546,11 @@
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 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)
@@ -554,8 +573,17 @@
Fichiers du parc
Demander de l'aide : l'export diagnostic
Système → « Exporter le diagnostic (ZIP) » (ou
GET /api/diagnostic.zip) : une archive avec l'état complet, le
- journal, les infos système, les écrans, les médias et les presets — tout ce
- qu'il faut pour comprendre un node à distance, sans aucun secret dedans.
+ journal, les infos système (dont le temps par image et la mémoire), les
+ écrans, les médias, le contenu de chaque preset
+ (presets/) et les fichiers d'état du node
+ (etat/ : conduite du séquenceur, console lumières, réglages,
+ bascules de fonctions) — tout ce qu'il faut pour comprendre un node à
+ distance, sans aucun secret dedans.
+
Sauvegarde
+ Comme l'archive contient le contenu des presets et les fichiers d'état,
+ c'est aussi la sauvegarde à faire avant une mise à jour : scènes
+ lumières, conduite et calibrages y sont, et ce sont eux qu'on ne
+ reconstitue pas de mémoire.
Après un crash, le node écrit aussi logs/crash.txt
(panic, version, nom du node). En option — et seulement si
[telemetrie] url est configurée dans node.toml —
@@ -625,7 +653,11 @@
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 +722,7 @@
Dépannage
diff --git a/node.toml.example b/node.toml.example
index a9bd29a..051db96 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
@@ -38,14 +41,19 @@ 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
# 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
@@ -139,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"
@@ -160,6 +169,13 @@ log_buffer = 1000 # entrées gardées par la page de logs
#[[midi.bindings]]
#cc = 1
#scale = "brightness" # 0..127 → 0..2
+# Cibles possibles pour `scale` :
+# 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.
#[[midi.bindings]]
#note = 62
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.ps1 b/tools/endurance/endurance.ps1
index 33f7314..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 {
@@ -104,11 +108,37 @@ function Start-Charge($base, $cadence) {
# Client MJPEG permanent : curl.exe est livre avec Windows 10+.
if (Get-Command curl.exe -ErrorAction SilentlyContinue) {
$nul = if ($IsLinux -or $IsMacOS) { "/dev/null" } else { "NUL" }
- $p = Start-Process -FilePath "curl.exe" -PassThru -WindowStyle Hidden `
- -ArgumentList "-s", "-o", $nul, "$base/flux.mjpg?fps=15"
- $jobs += $p
+ # Sonder AVANT d'annoncer : /flux.mjpg repond 404 si la fonction
+ # « Apercu » est coupee (le profil Pi 3 conseille de la couper),
+ # 503 au-dela de 4 clients, 401 si un mot de passe est pose. Sans
+ # cette verification, curl mourait aussitot, le compositeur partage
+ # n'etait pas sollicite de tout le run, et le script annoncait
+ # quand meme un client MJPEG.
+ # Un flux MJPEG ne se termine JAMAIS : la sonde sort forcement en
+ # timeout (curl code 28) apres avoir ecrit « 200 ». Or ce script
+ # tourne sous $ErrorActionPreference = "Stop", et depuis PowerShell
+ # 7.4 un code de retour non nul d'une commande native est une erreur
+ # TERMINANTE : la sonde tuerait le run qu'elle est censee preparer.
+ # On neutralise ce comportement le temps de l'appel (la variable
+ # n'existe pas en 5.1, l'affectation y est sans effet).
+ $ancienNatif = $PSNativeCommandUseErrorActionPreference
+ $PSNativeCommandUseErrorActionPreference = $false
+ $code = & curl.exe -s -m 3 -o $nul -w "%{http_code}" "$base/flux.mjpg?fps=15" 2>$null
+ $PSNativeCommandUseErrorActionPreference = $ancienNatif
+ if ("$code" -match "^2") {
+ $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 {
+ # 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
}
@@ -191,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) {
@@ -347,7 +377,17 @@ function Show-Analyse($fichier) {
# ne rend rien tout de suite). Extrapoler ces 6 Mo-la donnerait
# "110 Mo/h" et un faux cri a la fuite. Il faut au moins 30 min.
$DUREE_MINIMALE_H = 0.5
- if ($duree -lt $DUREE_MINIMALE_H) {
+ if ($redemarrages -gt 0) {
+ # Un redemarrage remet la RSS a zero : la regression traverse
+ # alors DEUX vies du process et sort negative, ce qui imprimait
+ # « VERDICT : stable. » sur un run pourtant traverse par un
+ # plantage. Le bandeau du haut annoncait deja que les tendances
+ # sont sans valeur -- seules les images perdues en tenaient
+ # compte, quarante lignes plus bas la memoire l'ignorait.
+ Write-Host " VERDICT : non calculable ($redemarrages redemarrage(s) pendant le run)."
+ Write-Host " La memoire repart de zero a chaque redemarrage : la pente melangerait"
+ Write-Host " deux vies du process. Relancer un run sans plantage pour conclure."
+ } elseif ($duree -lt $DUREE_MINIMALE_H) {
Write-Host " run trop court ($([math]::Round($duree * 60)) min) pour conclure :"
Write-Host " les premieres minutes sont de la montee en regime. Relancer sur 1 h au moins."
} elseif ($null -ne $pente) {
diff --git a/tools/endurance/endurance.sh b/tools/endurance/endurance.sh
index 0af2d96..6aac424 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
@@ -186,9 +196,27 @@ if [ "$CHARGE" = "1" ]; then
fi
charge_continue &
boucle_pid=$!
- curl -s -o /dev/null "$URL/flux.mjpg?fps=15" &
- mjpeg_pid=$!
- echo "Charge : ~$CADENCE commandes/s + client MJPEG."
+ # Le client MJPEG etait lance sans jamais verifier qu'il tenait : or
+ # /flux.mjpg repond 404 si la fonction « Apercu » est coupee (le profil
+ # Pi 3 conseille justement de la couper), 503 au-dela de 4 clients, et
+ # 401 si un mot de passe est pose. curl rendait alors la main en
+ # quelques millisecondes, le compositeur partage n'etait sollicite de
+ # tout le run... et le script annoncait « + client MJPEG » quand meme.
+ # `|| true` SEUL (pas de `|| echo 000`) : un flux MJPEG ne se termine
+ # jamais, donc la sonde sort forcement en timeout (code 28) tout en ayant
+ # deja ecrit « 200 ». Ajouter un echo de repli concatenait « 200 » et
+ # « 000 » en « 200000 ». Un node injoignable donne bien « 000 ».
+ code_mjpeg=$(curl -s -m 3 -o /dev/null -w '%{http_code}' "$URL/flux.mjpg?fps=15" || true)
+ case "$code_mjpeg" in
+ 2*)
+ curl -s -o /dev/null "$URL/flux.mjpg?fps=15" &
+ mjpeg_pid=$!
+ MJPEG_ETAT="+ client MJPEG" ;;
+ *)
+ MJPEG_ETAT="SANS client MJPEG (HTTP $code_mjpeg) - charge reduite,"
+ MJPEG_ETAT="$MJPEG_ETAT le compositeur partage n'est pas sollicite" ;;
+ esac
+ echo "Charge : ~$CADENCE commandes/s $MJPEG_ETAT."
fi
while [ "$(maintenant)" -lt "$fin" ]; do