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 @@

Alimentation de la machine

try { const liste = await api("/api/fleet/media?url=" + encodeURIComponent(n.url)) || []; detail.textContent = liste.length - ? liste.map(m => m.name).join(" · ") + ? liste.map(m => m.path).join(" · ") : "(médiathèque vide)"; } catch (e) { detail.textContent = "Node injoignable."; } }); @@ -2228,12 +2281,26 @@

Alimentation de la machine

$("#cueActPattern").style.display = type === "pattern" ? "block" : "none"; $("#cueActDmx").style.display = (type === "dmxscene" || type === "dmxchaser") ? "block" : "none"; if (type === "load") { - const sel = $("#cueActMedia"); sel.innerHTML = ""; - 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, D::Error> +where + D: serde::Deserializer<'de>, +{ + let brut = toml::Value::deserialize(deserializer)?; + match brut.clone().try_into::() { + Ok(cible) => Ok(Some(cible)), + Err(err) => { + noter(format!( + "binding MIDI ignoré : cible « {brut} » inconnue ({err}). Cibles possibles : {}", + ScaleTarget::NOMS.join(", ") + )); + Ok(None) + } + } +} + thread_local! { /// Anomalies relevées pendant la désérialisation. On ne peut pas les /// journaliser sur place (aucun collecteur `tracing` n'existe encore à @@ -430,12 +499,18 @@ impl Default for Limits { } /// Chemins des données. Relatifs au dossier de travail → portable par défaut. +/// +/// Pas de champ `shaders` : les shaders sont embarqués dans le binaire par +/// `include_str!`. Le réglage a existé, n'a jamais été lu par personne, et +/// laissait croire qu'on pouvait déposer des shaders dans un dossier. Un +/// `shaders = …` resté dans un ancien `node.toml` est simplement ignoré +/// (aucun `deny_unknown_fields` ici). Le dossier `luts/`, lui, est bien réel +/// mais vit toujours à côté du binaire — il n'est pas déplaçable. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct Paths { pub media: PathBuf, pub presets: PathBuf, - pub shaders: PathBuf, pub logs: PathBuf, } @@ -444,7 +519,6 @@ impl Default for Paths { Self { media: PathBuf::from("media"), presets: PathBuf::from("presets"), - shaders: PathBuf::from("shaders"), logs: PathBuf::from("logs"), } } @@ -533,7 +607,25 @@ mod tests { assert_eq!(cfg, NodeConfig::default()); assert_eq!(cfg.ports.http, 8080); assert!(cfg.modules.player); - assert!(!cfg.modules.ndi); + assert!(!cfg.modules.midi); + } + + /// Les clés `[modules]` supprimées (sequencer, sync, ndi) ne doivent pas + /// faire échouer le chargement d'un `node.toml` écrit par une version + /// précédente : sinon une mise à jour perdrait TOUTE la configuration. + #[test] + fn anciennes_cles_modules_ignorees_sans_erreur() { + let dir = tempfile::tempdir().expect("tempdir"); + let chemin = dir.path().join("node.toml"); + std::fs::write( + &chemin, + "[modules]\nplayer = true\nsequencer = true\nsync = true\nndi = true\n\n\ + [ports]\nhttp = 9999\n", + ) + .expect("écriture"); + let cfg = NodeConfig::load(&chemin).expect("un node.toml ancien doit rester lisible"); + assert!(cfg.modules.player); + assert_eq!(cfg.ports.http, 9999); } #[test] @@ -564,6 +656,78 @@ mod tests { assert_eq!(cfg.ports.osc, 9000); } + #[test] + fn la_liste_des_cibles_reste_alignee_sur_l_enum() { + // Recopier la liste à la main l'avait déjà laissée en arrière : + // `dmx_master` fonctionnait mais n'était nulle part annoncé. + for nom in ScaleTarget::NOMS { + let valeur: ScaleTarget = toml::Value::String((*nom).to_string()) + .try_into() + .unwrap_or_else(|e| panic!("cible « {nom} » annoncée mais illisible : {e}")); + // Aller-retour : le nom annoncé est bien celui que serde produit. + let round = toml::Value::try_from(valeur).expect("sérialisation"); + assert_eq!(round.as_str(), Some(*nom)); + } + // Et l'inverse : aucune variante ne doit manquer à l'appel. On compte + // les variantes via le nombre de cibles distinctes acceptées. + let variantes = [ + ScaleTarget::Volume, + ScaleTarget::Brightness, + ScaleTarget::Contrast, + ScaleTarget::Gamma, + ScaleTarget::Saturation, + ScaleTarget::Hue, + ScaleTarget::GainR, + ScaleTarget::GainG, + ScaleTarget::GainB, + ScaleTarget::Pixelate, + ScaleTarget::Posterize, + ScaleTarget::Noise, + ScaleTarget::Sharpen, + ScaleTarget::Mirror, + ScaleTarget::Rate, + ScaleTarget::DmxMaster, + ]; + assert_eq!( + variantes.len(), + ScaleTarget::NOMS.len(), + "une cible a été ajoutée à l'enum sans rejoindre NOMS (ou l'inverse)" + ); + } + + #[test] + fn une_cible_de_fader_inconnue_ne_bloque_pas_le_node() { + // Même filet que pour `command`, appliqué à `scale` : une coquille + // dans un nom de cible — le genre qu'on fait en suivant le manuel — + // ne doit pas empêcher le node de démarrer. C'est exactement la + // panne que la tolérance sur `command` évitait déjà. + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("node.toml"); + std::fs::write( + &path, + r#" + [[midi.bindings]] + cc = 21 + scale = "pixelatte" + + [[midi.bindings]] + cc = 7 + scale = "volume" + "#, + ) + .expect("write"); + let cfg = NodeConfig::load(&path).expect("le node démarre malgré la coquille"); + assert_eq!(cfg.midi.bindings.len(), 2); + assert!(cfg.midi.bindings[0].scale.is_none(), "coquille ignorée"); + assert_eq!(cfg.midi.bindings[1].scale, Some(ScaleTarget::Volume)); + // L'anomalie est remontée, pas avalée en silence. + assert!( + cfg.avertissements.iter().any(|a| a.contains("pixelatte")), + "l'avertissement doit nommer la cible fautive : {:?}", + cfg.avertissements + ); + } + #[test] fn une_commande_de_binding_inconnue_ne_bloque_pas_le_node() { // Une faute de frappe dans la commande d'un binding MIDI (« paly ») diff --git a/crates/core/src/output.rs b/crates/core/src/output.rs index d0fd88f..0df02b3 100644 --- a/crates/core/src/output.rs +++ b/crates/core/src/output.rs @@ -21,7 +21,13 @@ pub struct MonitorInfo { } /// Réglages appliqués par la fenêtre de sortie (modifiables à chaud). +/// +/// `#[serde(default)]` : un `sortie.json` écrit par une version antérieure +/// (ou amputé d'un champ ajouté depuis) reste lisible, les champs absents +/// prenant leur valeur par défaut — au lieu de faire repartir la sortie sur +/// le mauvais écran après une mise à jour. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(default)] pub struct OutputSettings { /// Écran cible, par index dans la liste détectée. pub monitor: usize, @@ -50,6 +56,17 @@ impl OutputSettings { mod tests { use super::*; + /// Un `sortie.json` amputé d'un champ (version antérieure, ou champ + /// ajouté depuis) reste lisible : sinon la sortie repartait sur le + /// mauvais écran après une mise à jour. + #[test] + fn un_sortie_json_ancien_reste_lisible() { + let settings: OutputSettings = + serde_json::from_str(r#"{"monitor":2}"#).expect("un fichier ancien doit se relire"); + assert_eq!(settings.monitor, 2); + assert!(!settings.fullscreen); + } + #[test] fn settings_persist_and_survive_corruption() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/core/src/preset.rs b/crates/core/src/preset.rs index d25ca88..f9a48fc 100644 --- a/crates/core/src/preset.rs +++ b/crates/core/src/preset.rs @@ -110,6 +110,17 @@ impl Store { /// Charge le preset `name`. Le document est **validé** après lecture : un /// fichier corrompu ou édité à la main avec des valeurs hors bornes est /// refusé plutôt que de devenir l'état du node. + /// Les octets du preset TELS QU'ILS SONT sur le disque, sans + /// désérialisation ni validation. Pour l'export diagnostic : un preset + /// abîmé est précisément celui qu'on veut récupérer, et `load` le + /// rejetterait. Une re-sérialisation, elle, effacerait silencieusement + /// tout champ que la version courante ne connaît pas. + pub fn octets(&self, name: &str) -> Result, CoreError> { + validate_name(name)?; + let path = self.path_of(name); + fs::read(&path).map_err(|e| CoreError::io(path.display().to_string(), e)) + } + pub fn load(&self, name: &str) -> Result { validate_name(name)?; let path = self.path_of(name); diff --git a/crates/core/src/sequenceur.rs b/crates/core/src/sequenceur.rs index c67435c..ad1505b 100644 --- a/crates/core/src/sequenceur.rs +++ b/crates/core/src/sequenceur.rs @@ -120,9 +120,46 @@ where Ok(actions) } +/// Désérialise les cues une par une, sur le modèle d'[`actions_tolerantes`]. +/// +/// Le filet existait pour les ACTIONS mais pas pour les cues elles-mêmes : +/// `Declencheur` est un enum étiqueté sans repli, si bien qu'un déclencheur +/// inconnu — une conduite écrite par une version plus récente, relue après +/// un « Revenir à la version précédente » — faisait échouer le fichier +/// ENTIER. Perdre la cue qu'on ne comprend pas vaut infiniment mieux que +/// perdre tout le spectacle. +fn cues_tolerantes<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let brutes = Vec::::deserialize(deserializer)?; + let mut cues = Vec::with_capacity(brutes.len()); + for brute in brutes { + match serde_json::from_value::(brute.clone()) { + Ok(cue) => cues.push(cue), + Err(err) => { + tracing::error!(%err, cue = %brute, "cue illisible — ignorée (le reste de la conduite est conservé)"); + } + } + } + Ok(cues) +} + +/// Combien de cues le fichier contenait-il de plus que ce qu'on a relu ? +/// `None` si le compte est le même (cas nominal) ou si le fichier ne se +/// laisse pas inspecter — on ne veut surtout pas transformer une lecture +/// réussie en échec pour un dénombrement. +fn cues_ecartees(path: &std::path::Path, relues: usize) -> Option { + let octets = std::fs::read(path).ok()?; + let brut: serde_json::Value = serde_json::from_slice(&octets).ok()?; + let ecrites = brut.get("cues")?.as_array()?.len(); + ecrites.checked_sub(relues).filter(|n| *n > 0) +} + /// L'état du séquenceur, publié à l'UI et persisté (sans le transitoire). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct EtatSequenceur { + #[serde(default, deserialize_with = "cues_tolerantes")] pub cues: Vec, /// Cue en attente d'enchaînement (nom, échéance en ms) — transitoire, /// publié à l'UI, purgé au chargement. @@ -136,6 +173,25 @@ pub struct EtatSequenceur { impl EtatSequenceur { pub fn load(path: &std::path::Path) -> Option { let mut etat: Self = crate::charger_ou_mettre_de_cote(path, "séquences")?; + // Une cue écartée par `cues_tolerantes` disparaîtrait DÉFINITIVEMENT + // à la première réécriture du fichier. Le filet qui existait avant + // (échec de lecture → `sequences.json.corrompu`, tout le fichier + // conservé) ne joue plus, puisqu'on lit désormais avec succès. On + // garde donc une copie de l'original AVANT que la conduite ne soit + // réenregistrée amputée — le cas visé est le retour arrière après + // une mise à jour, où les cues perdues sont parfaitement valides + // pour la version qui les a écrites. + if let Some(ecartees) = cues_ecartees(path, etat.cues.len()) { + let copie = path.with_extension("json.incomplet"); + match std::fs::copy(path, &copie) { + Ok(_) => tracing::warn!( + ecartees, + copie = %copie.display(), + "cues illisibles écartées — conduite d'origine conservée" + ), + Err(err) => tracing::error!(%err, ecartees, "cues écartées ET copie impossible"), + } + } // Le transitoire ne survit pas au redémarrage. etat.en_attente = None; etat.derniere = None; @@ -499,6 +555,75 @@ mod tests { use super::*; use crate::Bus; + /// La cue écartée ne doit pas disparaître SANS TRACE : avant le filet + /// tolérant, un fichier illisible partait en `.corrompu` et restait + /// récupérable en entier. Maintenant qu'on le lit avec succès, la + /// première réécriture l'amputerait définitivement — on en garde donc + /// une copie. + #[test] + fn une_cue_ecartee_laisse_l_original_recuperable() { + let dir = tempfile::tempdir().expect("tempdir"); + let chemin = dir.path().join("sequences.json"); + std::fs::write( + &chemin, + r#"{"cues":[ + {"nom":"ouverture","declencheur":{"type":"manuel"},"actions":[]}, + {"nom":"venue_du_futur","declencheur":{"type":"au_lever_du_jour"},"actions":[]} + ]}"#, + ) + .expect("écriture"); + + let etat = EtatSequenceur::load(&chemin).expect("la conduite doit se relire"); + assert_eq!(etat.cues.len(), 1, "la cue incomprise est écartée"); + + // L'original est conservé À CÔTÉ, avec ses DEUX cues. + let copie = chemin.with_extension("json.incomplet"); + assert!(copie.exists(), "l'original doit être conservé : {copie:?}"); + let garde: serde_json::Value = + serde_json::from_slice(&std::fs::read(&copie).expect("copie")).expect("json"); + assert_eq!( + garde["cues"].as_array().expect("cues").len(), + 2, + "la copie doit porter la conduite ENTIÈRE, cue incomprise incluse" + ); + } + + /// Cas nominal : aucune cue écartée, donc aucune copie parasite. + #[test] + fn une_conduite_saine_ne_laisse_pas_de_copie() { + let dir = tempfile::tempdir().expect("tempdir"); + let chemin = dir.path().join("sequences.json"); + std::fs::write( + &chemin, + r#"{"cues":[{"nom":"ouverture","declencheur":{"type":"manuel"},"actions":[]}]}"#, + ) + .expect("écriture"); + EtatSequenceur::load(&chemin).expect("relecture"); + assert!( + !chemin.with_extension("json.incomplet").exists(), + "pas de copie quand rien n'a été écarté" + ); + } + + /// Scénario réel : une version plus récente a écrit une conduite avec un + /// déclencheur qu'on ne connaît pas (retour arrière après mise à jour). + /// Avant, `Declencheur` étant un enum étiqueté sans repli, TOUTE la + /// conduite devenait illisible et partait en `.corrompu` — le spectacle + /// du soir avec. On ne perd plus que la cue incomprise. + #[test] + fn une_cue_au_declencheur_inconnu_ne_fait_pas_perdre_la_conduite() { + let brut = r#"{ + "cues": [ + {"nom": "ouverture", "declencheur": {"type": "manuel"}, "actions": []}, + {"nom": "venue_du_futur", "declencheur": {"type": "au_lever_du_jour"}, "actions": []}, + {"nom": "final", "declencheur": {"type": "manuel"}, "actions": []} + ] + }"#; + let etat: EtatSequenceur = serde_json::from_str(brut).expect("la conduite doit survivre"); + let noms: Vec<&str> = etat.cues.iter().map(|c| c.nom.as_str()).collect(); + assert_eq!(noms, vec!["ouverture", "final"]); + } + #[test] fn les_jours_de_semaine_filtrent_le_declencheur() { // Vide = tous les jours. @@ -595,6 +720,28 @@ mod tests { .is_ok()); } + /// Attend qu'une condition sur l'état publié devienne vraie, ou abandonne. + /// + /// Les tests de ce module dormaient une durée FIXE, calculée avec une + /// marge de quelques dizaines de ms sur l'échéance réelle. Sur un runner + /// Windows chargé — où chaque enregistrement de cue passe par un fsync de + /// sequences.json — la marge est parfois dépassée, et deux tests + /// différents ont échoué tour à tour sans qu'aucun code soit en cause. + /// On attend donc l'événement au lieu de le parier ; la limite reste + /// large, et un vrai défaut la dépasse de toute façon. + async fn attendre(etat: &watch::Receiver, mut pret: F) + where + F: FnMut(&EtatSequenceur) -> bool, + { + let limite = std::time::Instant::now() + std::time::Duration::from_secs(5); + while std::time::Instant::now() < limite { + if pret(&etat.borrow()) { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + } + /// Supprimer la cue A pendant que B(après) attend ne doit PAS faire /// jouer C à la place : l'enchaînement suit le NOM, pas l'index. #[tokio::test] @@ -635,13 +782,32 @@ mod tests { .send(CommandeSequenceur::CueSupprime { nom: "A".into() }) .await .expect("suppr"); - tokio::time::sleep(std::time::Duration::from_millis(400)).await; + // ATTENDRE l'enchaînement, ne pas le PARIER. Une temporisation fixe de + // 400 ms pour un enchaînement à 300 ms ne laisse que 100 ms de marge : + // sur un runner Windows chargé, où chaque enregistrement de cue passe + // par un fsync de sequences.json, la marge est parfois dépassée et le + // test échouait sans qu'aucun code soit en cause (observé une fois sur + // six exécutions de cette branche). + // + // On sonde jusqu'à ce qu'une cue se soit enchaînée, avec une limite + // large. La garantie du test est INTACTE : si l'enchaînement suivait + // l'index au lieu du nom, ce serait C qui jouerait, et l'assertion + // ci-dessous le verrait immédiatement. + // On attend qu'une cue ENCHAÎNÉE ait joué, c'est-à-dire que `derniere` + // ne soit plus « A » : A est la cue lancée à la main, elle devient + // `derniere` immédiatement. Attendre simplement « une cue a joué » + // sortirait de la boucle sur A, avant même l'enchaînement. + attendre(&etat_rx, |e| e.derniere.as_deref() != Some("A")).await; // B (0.5) doit avoir joué, PAS C (0.9). + assert_eq!( + etat_rx.borrow().derniere.as_deref(), + Some("B"), + "c'est B qui doit s'enchaîner, pas la cue à l'ancien index" + ); assert!( (handle.snapshot().player.volume - 0.5).abs() < 1e-6, - "c'est B qui doit s'enchaîner, pas la cue à l'ancien index" + "le volume doit être celui de B (0.5), pas celui de C (0.9)" ); - assert_eq!(etat_rx.borrow().derniere.as_deref(), Some("B")); } /// Une cue dont l'action est cue_go vers elle-même via le bus ne boucle @@ -765,7 +931,7 @@ mod tests { .expect("go"); // La cue « un » est jouée tout de suite… - tokio::time::sleep(std::time::Duration::from_millis(120)).await; + attendre(&etat_rx, |e| e.derniere.as_deref() == Some("un")).await; assert!((handle.snapshot().player.volume - 0.25).abs() < 1e-6); assert_eq!( etat_rx.borrow().en_attente.as_ref().map(|(n, _)| n.clone()), @@ -773,18 +939,29 @@ mod tests { "l'enchaînement doit être annoncé" ); // … et « deux » s'enchaîne ~300 ms plus tard. - tokio::time::sleep(std::time::Duration::from_millis(500)).await; + attendre(&etat_rx, |e| e.derniere.as_deref() == Some("deux")).await; assert!((handle.snapshot().player.volume - 0.75).abs() < 1e-6); assert_eq!(etat_rx.borrow().derniere.as_deref(), Some("deux")); - // Stop annule un enchaînement en attente. + // Stop annule un enchaînement en attente. Ici on affirme qu'il ne se + // passe RIEN : une attente ne peut pas remplacer le délai, mais elle + // peut garantir l'état de DÉPART. Dormir 100 ms en espérant que + // l'enchaînement soit armé, c'était risquer d'envoyer Stop avant même + // qu'il y ait quelque chose à annuler — le test aurait alors réussi + // sans rien prouver. cmd_tx .send(CommandeSequenceur::Go { nom: "un".into() }) .await .expect("re-go"); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; + attendre(&etat_rx, |e| e.en_attente.is_some()).await; + assert!( + etat_rx.borrow().en_attente.is_some(), + "l'enchaînement doit être armé AVANT d'être annulé, sinon le test ne prouve rien" + ); cmd_tx.send(CommandeSequenceur::Stop).await.expect("stop"); - tokio::time::sleep(std::time::Duration::from_millis(450)).await; + attendre(&etat_rx, |e| e.en_attente.is_none()).await; + // Au-delà de l'échéance de « deux » (300 ms) : elle ne doit pas jouer. + tokio::time::sleep(std::time::Duration::from_millis(600)).await; assert!( (handle.snapshot().player.volume - 0.25).abs() < 1e-6, "la cue deux ne doit PAS avoir été jouée après Stop" diff --git a/crates/core/src/state.rs b/crates/core/src/state.rs index fcb1044..78060d5 100644 --- a/crates/core/src/state.rs +++ b/crates/core/src/state.rs @@ -544,6 +544,15 @@ pub enum Event { DmxChaserDemande { name: Option, }, + /// Grand master lumières demandé : la console applique. + DmxMasterDemande { + valeur: u8, + }, + /// Niveau de fader lumières demandé : la console applique. + DmxFaderDemande { + id: String, + valeur: u8, + }, /// Départ synchronisé programmé : le player lancera la lecture à `at` /// (heure Unix en secondes). SyncScheduled { @@ -922,6 +931,11 @@ impl NodeState { Command::CueGo { name } => Ok(vec![Event::CueDemandee { name: name.clone() }]), Command::DmxScene { name } => Ok(vec![Event::DmxSceneDemandee { name: name.clone() }]), Command::DmxChaser { name } => Ok(vec![Event::DmxChaserDemande { name: name.clone() }]), + Command::DmxMaster { valeur } => Ok(vec![Event::DmxMasterDemande { valeur: *valeur }]), + Command::DmxFader { id, valeur } => Ok(vec![Event::DmxFaderDemande { + id: id.clone(), + valeur: *valeur, + }]), Command::SyncArm => { if self.player.media.is_none() { return Err(CoreError::InvalidCommand( diff --git a/crates/node/src/main.rs b/crates/node/src/main.rs index 86f6e59..ced2be9 100644 --- a/crates/node/src/main.rs +++ b/crates/node/src/main.rs @@ -147,13 +147,38 @@ async fn run(mut config: NodeConfig, logs: LogBuffer) -> Result<(), Box Result<(), Box Result<(), Box>, /// Réglages de sortie appliqués à chaud (écran cible, plein écran). pub settings: watch::Receiver, + /// Le MÊME canal en écriture : F11 et Échap changent le plein écran + /// directement sur la fenêtre, sans passer par l'API. Sans ce Sender, + /// personne ne republiait l'état réel — `/api/outputs` continuait + /// d'annoncer l'ancienne valeur, la case « Plein écran » de l'UI restait + /// désynchronisée, et le réglage suivant (changer d'écran, par exemple) + /// réappliquait le `fullscreen` périmé : la sortie sortait du plein + /// écran toute seule, en plein spectacle. + pub settings_tx: std::sync::Arc>, /// Liste des écrans détectés, publiée pour l'API `/api/outputs`. pub monitors: watch::Sender>, /// Frames réellement présentées par seconde, publiées pour l'UI @@ -103,6 +111,7 @@ fn run_event_loop(config: WindowConfig, channels: OutputChannels) { state, video, settings, + settings_tx, monitors, fps, mesures, @@ -149,6 +158,8 @@ fn run_event_loop(config: WindowConfig, channels: OutputChannels) { snapshot, video, settings, + settings_tx, + applique: None, monitors, fps, mesures, @@ -176,6 +187,30 @@ fn run_event_loop(config: WindowConfig, channels: OutputChannels) { info!("fenêtre de sortie fermée"); } +/// Ce qu'il faut publier quand la fenêtre bascule elle-même le plein écran +/// (F11 / Échap). `None` = l'état publié est déjà le bon, ne rien envoyer — +/// une publication inutile réveillerait l'event loop pour rien. +/// +/// Séparé de la fenêtre pour être testable : le reste demande un event loop +/// winit, donc un serveur graphique. +fn publication_plein_ecran( + publie: OutputSettings, + applique: Option, + plein: bool, +) -> Option { + if publie.fullscreen == plein { + return None; + } + // La base est ce que la FENÊTRE a appliqué, pas le contenu du canal : un + // réglage de l'UI peut y être sans que l'event loop l'ait traité, et le + // recopier le ferait passer pour notre propre écho — donc ignorer à + // jamais l'écran cible choisi depuis la tablette. + Some(OutputSettings { + fullscreen: plein, + ..applique.unwrap_or(publie) + }) +} + /// Forwarde chaque signal async vers l'event loop. Runtime minimal dédié. fn spawn_wake_relay( proxy: EventLoopProxy, @@ -250,6 +285,13 @@ struct OutputApp { snapshot: NodeState, video: watch::Receiver>, settings: watch::Receiver, + settings_tx: std::sync::Arc>, + /// Dernier réglage RÉELLEMENT appliqué à la fenêtre. Sert à ignorer + /// l'écho de nos propres publications (F11/Échap) : sans lui, + /// `apply_settings` rejouerait le `set_outer_position` de la branche + /// « fenêtré » et la fenêtre sauterait à l'origine de l'écran juste + /// après un Échap. + applique: Option, monitors: watch::Sender>, fps: watch::Sender, /// Publication du coût des frames (voir [`OutputChannels::mesures`]). @@ -357,8 +399,15 @@ impl OutputApp { } /// Applique les réglages courants : écran cible + plein écran. - fn apply_settings(&self, event_loop: &ActiveEventLoop) { + fn apply_settings(&mut self, event_loop: &ActiveEventLoop) { let settings = *self.settings.borrow(); + // Écho de notre propre publication (F11/Échap) : la fenêtre est déjà + // dans cet état. Refaire le travail rejouerait le + // `set_outer_position` ci-dessous, et la fenêtre sauterait à + // l'origine de l'écran juste après un Échap. + if self.applique == Some(settings) { + return; + } let monitor = self.refresh_monitors(event_loop, settings.monitor); let Some(window) = &self.window else { return }; if settings.fullscreen { @@ -370,16 +419,54 @@ impl OutputApp { } } window.request_redraw(); + self.applique = Some(settings); } - fn toggle_fullscreen(&self) { - if let Some(window) = &self.window { - if window.fullscreen().is_some() { - window.set_fullscreen(None); - } else { - window.set_fullscreen(Some(Fullscreen::Borderless(window.current_monitor()))); - } + /// Publie l'état de plein écran RÉEL de la fenêtre (F11, Échap). + /// + /// Sans cela, `/api/outputs` continuait d'annoncer l'ancienne valeur et + /// l'UI renvoyait ce `fullscreen` périmé au moindre autre réglage : mettre + /// la sortie en plein écran avec F11 sur la machine, puis changer d'écran + /// depuis une tablette, la faisait sortir du plein écran toute seule. + fn publier_plein_ecran(&mut self, plein: bool) { + let Some(cible) = publication_plein_ecran(*self.settings.borrow(), self.applique, plein) + else { + return; + }; + // `send_modify` et non `send_replace` : la lecture-modification- + // écriture est ATOMIQUE sous le verrou du canal. Publier une copie + // lue plus tôt écraserait un changement d'écran cible venu de l'UI + // entre-temps — F11 sur la machine pendant qu'on change d'écran + // depuis la tablette ramènerait la sortie sur l'ancien écran. + // Le marqueur d'écho part de ce que la fenêtre a RÉELLEMENT appliqué, + // pas de ce que le canal contient : un réglage venu de l'UI peut + // déjà y être sans que l'event loop l'ait traité (il peint une + // frame). Recopier le canal ferait passer ce réglage-là pour notre + // propre écho, et il serait ignoré pour toujours — l'écran cible + // choisi depuis la tablette ne serait jamais appliqué. + self.settings_tx.send_modify(|s| s.fullscreen = plein); + self.applique = Some(cible); + } + + fn toggle_fullscreen(&mut self) { + let Some(window) = &self.window else { return }; + let plein = window.fullscreen().is_none(); + if plein { + window.set_fullscreen(Some(Fullscreen::Borderless(window.current_monitor()))); + } else { + window.set_fullscreen(None); } + self.publier_plein_ecran(plein); + } + + /// Échap : quitte le plein écran, jamais la fenêtre. + fn quitter_plein_ecran(&mut self) { + let Some(window) = &self.window else { return }; + if window.fullscreen().is_none() { + return; + } + window.set_fullscreen(None); + self.publier_plein_ecran(false); } /// Fabrique le peintre (GPU si demandé, CPU en secours). `None` si aucun @@ -876,11 +963,7 @@ impl ApplicationHandler for OutputApp { Key::Named(NamedKey::F11) => self.toggle_fullscreen(), // Échap quitte SEULEMENT le plein écran (jamais la // fenêtre : un show ne se ferme pas sur une fausse touche). - Key::Named(NamedKey::Escape) => { - if let Some(window) = &self.window { - window.set_fullscreen(None); - } - } + Key::Named(NamedKey::Escape) => self.quitter_plein_ecran(), _ => {} } } @@ -909,6 +992,58 @@ impl ApplicationHandler for OutputApp { mod tests { use super::*; + /// F11 et Échap changeaient le plein écran sur la fenêtre sans jamais le + /// republier : `/api/outputs` gardait l'ancienne valeur, et comme l'UI + /// renvoie TOUJOURS les deux champs, le réglage suivant réappliquait le + /// `fullscreen` périmé. Mettre la sortie en plein écran avec F11 sur la + /// machine, puis changer d'écran depuis une tablette, la faisait sortir + /// du plein écran toute seule. + #[test] + fn le_plein_ecran_de_la_fenetre_est_republie() { + let fenetre = OutputSettings { + monitor: 1, + fullscreen: false, + }; + // F11 depuis l'état fenêtré : on publie, en gardant l'écran cible. + let publie = publication_plein_ecran(fenetre, None, true).expect("doit publier"); + assert!(publie.fullscreen); + assert_eq!(publie.monitor, 1, "l'écran cible ne doit pas bouger"); + + // Échap depuis le plein écran : on publie le retour en fenêtré. + let publie = publication_plein_ecran(publie, None, false).expect("doit publier"); + assert!(!publie.fullscreen); + + // Déjà dans l'état demandé : rien à publier (sinon on réveille + // l'event loop pour rien, et on relance un cycle d'écho). + assert_eq!(publication_plein_ecran(fenetre, None, false), None); + let plein = OutputSettings { + monitor: 0, + fullscreen: true, + }; + assert_eq!(publication_plein_ecran(plein, None, true), None); + + // LE CAS QUI COMPTE, et que la production retient vraiment : un + // réglage de l'UI est déjà dans le canal (écran 1) mais la fenêtre + // n'a pas encore eu le temps de l'appliquer (elle en est à l'écran + // 0). Un F11 ne doit PAS s'attribuer l'écran 1, sinon le changement + // venu de la tablette passerait pour notre écho et serait ignoré. + let en_attente = OutputSettings { + monitor: 1, + fullscreen: false, + }; + let deja_applique = OutputSettings { + monitor: 0, + fullscreen: false, + }; + let retenu = + publication_plein_ecran(en_attente, Some(deja_applique), true).expect("doit publier"); + assert!(retenu.fullscreen); + assert_eq!( + retenu.monitor, 0, + "le marqueur d'écho part de ce que la FENÊTRE a appliqué, pas du canal" + ); + } + #[test] fn publier_transmet_les_percentiles_mesures() { let (tx, rx) = watch::channel(RenduMesures::default()); diff --git a/deploy/README.md b/deploy/README.md index ab86850..989ef3b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -2,11 +2,20 @@ ## 1. Récupérer un binaire -- **Sans compiler** : GitHub → onglet *Actions* → dernier run vert → - *Artifacts* : `toolbox-node-linux-x64`, `toolbox-node-windows-x64`, - `toolbox-node-raspberrypi-arm64` (login GitHub requis). +- **Binaires publics, sans compte GitHub** : page + [Releases](https://github.com/pymenvert/toolbox/releases). C'est la voie + normale. Attention à ce que contient chaque archive : + `toolbox-node-windows-x64-gstreamer` est **le seul binaire publié qui lit + des vidéos** ; `…-windows-x64` et `…-linux-x64` font mires, mapping, + calibrage et OSC/MIDI sans lecture vidéo ; `…-raspberrypi-arm64` est + **du pilotage seul — il ne projette rien** (compilé sans fenêtre de + sortie, sans MIDI et sans GStreamer). +- **Builds de développement** : GitHub → onglet *Actions* → dernier run vert + → *Artifacts* (login GitHub requis). - **En compilant** : `cargo build --release -p toolbox-node` - (Linux : `sudo apt install libasound2-dev` pour le MIDI). + (Linux : `sudo apt install libasound2-dev` pour le MIDI). **C'est le seul + moyen d'avoir la vidéo sur Linux, et de projeter depuis un Pi** : ajouter + `--features gstreamer` (voir §6). ## 2. Version portable (P1.10) @@ -82,7 +91,19 @@ binaire compilé avec la feature `gstreamer` **et** le runtime GStreamer : Sans GStreamer sur la machine, ce binaire se replie automatiquement sur le backend simulé (visible dans les logs) : rien ne casse. -## 7. Ce qui arrive ensuite +## 7. Déjà livré (ne pas refaire à la main) -- Image carte SD prête à flasher (pi-gen) — phase 4. -- Mise à jour OTA, mot de passe UI, token API — phase 4. +- **Mise à jour OTA** : onglet Système → Mise à jour. Le binaire précédent + est conservé, et un bouton « Revenir à la version précédente » permet de + faire marche arrière. Inutile de refaire un `scp` + `systemctl restart`. +- **Mot de passe de l'UI** : `[security] password` dans `node.toml` + (protège la web UI et l'API ; l'OSC en UDP reste ouvert). +- **Jeton de parc** : `[security] fleet_token`, à mettre IDENTIQUE sur tous + les nodes, pour les échanges de médias de machine à machine. Le mot de + passe de l'UI n'est jamais transmis à une machine annoncée sur le réseau. +- **Installateurs à profils** : `install.sh` (Linux/Pi, avec détection du + modèle de Pi) et `installer-windows.ps1`. + +## 8. Ce qui arrive ensuite + +- Image carte SD prête à flasher (pi-gen). diff --git a/deploy/install.sh b/deploy/install.sh index ae5e414..b781088 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -38,6 +38,28 @@ done say() { printf '\033[1;36m>>\033[0m %s\n' "$*"; } fail() { printf '\033[1;31m!!\033[0m %s\n' "$*" >&2; exit 1; } +# systemd EXIGE un chemin absolu dans WorkingDirectory= et ExecStart=. Or +# mkdir, install et chown acceptent parfaitement un préfixe relatif : sans +# cette normalisation, `--prefix lanterne` déroulait toute l'installation +# sans le moindre message, et seul `systemctl start` échouait ensuite, sur +# une unité que systemd refusait de charger. +# Un préfixe VIDE ne correspond pas à `/*` : il tomberait dans la conversion, +# où `cd ""` réussit sans changer de répertoire — l'installation se déroulait +# alors dans le répertoire courant, sans un mot. Le cas arrive pour de bon : +# `--prefix "$DEST"` avec DEST oublié dans un script de provisionnement. +[ -n "$PREFIX" ] || fail "préfixe vide : passez un chemin à --prefix" +case "$PREFIX" in + /*) ;; + *) # Simple préfixage par le répertoire courant : on ne CRÉE rien ici + # (`mkdir` poserait le dossier avant de savoir sous quelle identité, + # et fausserait le chown de fin d'installation) et on n'exige pas que + # le parent existe — `mkdir -p`, plus bas, sait le créer, et l'exiger + # refuserait `--prefix sous/lanterne`, que l'ancienne version + # acceptait. + PREFIX="$(pwd)/${PREFIX#./}" + say "Préfixe relatif converti en chemin absolu : $PREFIX" ;; +esac + # Échappe une valeur pour le REMPLACEMENT d'un sed dont le délimiteur est # « | » : & (rappel du motif), | (délimiteur) et \ doivent être protégés, # sinon un préfixe contenant l'un d'eux produirait une unité systemd cassée. @@ -111,8 +133,13 @@ case "$MATERIEL" in echo " Déconseillé : sortie RTSP au-delà de 720p (encodage au CPU)." ;; pi3) say "Matériel détecté : Raspberry Pi 3 / Zero 2 — VERSION ALLÉGÉE conseillée" - echo " Conseillé : profil « lecteur » (lecture + mapping), rendu CPU en" - echo " 960×540, aperçu web coupé (onglet Fonctions)." + echo " Conseillé : profil « lecteur » (lecture + mapping), rendu CPU," + echo " aperçu web coupé." + echo " À FAIRE À LA MAIN : sur Pi OS Lite (sans bureau), ajouter" + echo " [output] mode = \"kms\" dans node.toml — ce script ne" + echo " l'écrit pas, et le mode KMS exige un binaire compilé" + echo " avec --features gstreamer. C'est là que la résolution" + echo " 960×540 des réglages de performance s'applique." echo " Déconseillé : rendu GPU (puce GLES 2.0 trop ancienne), sortie RTSP," echo " flux MJPEG au-delà de 480p, effets lourds." ;; pi_ancien) @@ -197,7 +224,7 @@ if { [ -d "$PREFIX" ] && [ ! -w "$PREFIX" ]; } || { [ ! -d "$PREFIX" ] && [ ! -w say "Le préfixe demande les droits administrateur (sudo)." fi -run mkdir -p "$PREFIX" "$PREFIX/media" "$PREFIX/presets" "$PREFIX/logs" "$PREFIX/shaders" +run mkdir -p "$PREFIX" "$PREFIX/media" "$PREFIX/presets" "$PREFIX/logs" "$PREFIX/luts" run install -m 755 "$BINARY" "$PREFIX/toolbox-node" TMP_CONF="$(mktemp)" @@ -267,7 +294,25 @@ if command -v systemctl > /dev/null 2>&1; then | sudo tee /etc/systemd/system/toolbox-node.service > /dev/null sudo systemctl daemon-reload sudo systemctl enable toolbox-node.service - say "Service installé. Démarrage : sudo systemctl start toolbox-node" + # Réinstallation par-dessus un node qui tourne : `install` a remplacé + # le binaire, mais le processus en cours exécute toujours l'ANCIEN + # code (l'inode d'origine survit tant qu'il est ouvert). Sans ce + # redémarrage, le script concluait « Installation terminée » et le + # conseil `systemctl start` ne faisait rien — le service étant déjà + # actif. La mise à jour semblait faite et ne l'était pas. + if sudo systemctl is-active --quiet toolbox-node.service; then + say "Service déjà actif : redémarrage sur le nouveau binaire." + # NON fatal : sous `set -e`, un restart refusé (binaire de la + # mauvaise architecture, archive tronquée…) tuait le script AVANT + # le chown du préfixe — l'installation repartait donc avec des + # fichiers appartenant à root, et le node ne pouvait plus rien + # enregistrer. Exactement la panne que le chown existe pour éviter. + if ! sudo systemctl restart toolbox-node.service; then + say "ATTENTION : redémarrage refusé — voir journalctl -u toolbox-node" + fi + else + say "Service installé. Démarrage : sudo systemctl start toolbox-node" + fi say "Logs : journalctl -u toolbox-node -f (ou la page Logs de la web UI)" fi fi diff --git a/deploy/installer-windows.ps1 b/deploy/installer-windows.ps1 index f2e79a8..6f54981 100644 --- a/deploy/installer-windows.ps1 +++ b/deploy/installer-windows.ps1 @@ -61,7 +61,7 @@ foreach ($cand in @("$ici\toolbox-node.exe", "$ici\dist\toolbox-node.exe")) { } New-Item -ItemType Directory -Force $Dossier | Out-Null -foreach ($sous in @("media", "presets", "logs", "shaders")) { +foreach ($sous in @("media", "presets", "logs", "luts")) { New-Item -ItemType Directory -Force (Join-Path $Dossier $sous) | Out-Null } @@ -73,7 +73,16 @@ if ($exeLocal) { if (Test-Path $libLocal) { Dire "Pack video detecte : copie des DLL GStreamer" Copy-Item (Join-Path (Split-Path -Parent $exeLocal) "*.dll") $Dossier -Force - Copy-Item $libLocal (Join-Path $Dossier "lib") -Recurse -Force + # Copier le CONTENU, pas le dossier : `Copy-Item dossier cible` cree + # cible\lib quand cible existe deja. A la deuxieme installation, les + # plugins atterrissaient donc dans lib\lib\gstreamer-1.0, ou le + # binaire ne regarde jamais -- DLL a jour a la racine, plugins + # perimes en dessous. On repart d'un lib\ propre pour ne pas garder + # les plugins fantomes de la version precedente. + $libCible = Join-Path $Dossier "lib" + if (Test-Path $libCible) { Remove-Item $libCible -Recurse -Force } + New-Item -ItemType Directory -Force $libCible | Out-Null + Copy-Item (Join-Path $libLocal "*") $libCible -Recurse -Force } } else { $reponse = "o" diff --git a/deploy/run-portable.bat b/deploy/run-portable.bat index ca8a394..b508b63 100644 --- a/deploy/run-portable.bat +++ b/deploy/run-portable.bat @@ -5,6 +5,6 @@ cd /d "%~dp0" if not exist media mkdir media if not exist presets mkdir presets if not exist logs mkdir logs -if not exist shaders mkdir shaders +if not exist luts mkdir luts toolbox-node.exe %* pause diff --git a/deploy/run-portable.sh b/deploy/run-portable.sh index 94b322d..96641b3 100755 --- a/deploy/run-portable.sh +++ b/deploy/run-portable.sh @@ -3,5 +3,5 @@ # Décompressez le binaire à côté de ce script et double-cliquez / lancez-le. set -euo pipefail cd "$(dirname "$0")" -mkdir -p media presets logs shaders +mkdir -p media presets logs luts exec ./toolbox-node "$@" diff --git a/deploy/systemd/toolbox-node.service b/deploy/systemd/toolbox-node.service index 6f4f174..585140b 100644 --- a/deploy/systemd/toolbox-node.service +++ b/deploy/systemd/toolbox-node.service @@ -2,13 +2,37 @@ # (@PREFIX@ et @USER@ sont remplacés à l'installation). # # Mode kiosque : le node redémarre tout seul en cas de crash ou au boot ; -# combiné à [startup] preset/autoplay dans node.toml, un Pi branché à un -# vidéoprojecteur reprend son show sans intervention. +# combiné à [startup] preset/autoplay dans node.toml, un Pi reprend son show +# sans intervention. +# +# ATTENTION — CE QUE CETTE UNITÉ PROJETTE, ET CE QU'ELLE NE PROJETTE PAS. +# Telle quelle, elle est ordonnée sur multi-user.target et ne reçoit aucune +# variable d'affichage. Elle convient à deux usages : +# - le PILOTAGE seul (web UI, mapping, OSC/MIDI, lumières, séquenceur) ; +# - la sortie DRM/KMS (`[output] mode = "kms"` dans node.toml), qui écrit +# directement sur l'écran sans serveur graphique — le chemin conseillé +# sur Raspberry Pi OS Lite. +# En mode fenêtre (le DÉFAUT), un service lancé dans ce contexte n'a aucun +# serveur d'affichage à contacter : le node journalise « fenêtre de sortie +# indisponible » et continue SANS projeter. Il ne plante pas — donc rien +# n'attire l'attention, et le vidéoprojecteur reste noir. +# +# Pour un kiosque en mode fenêtre sur Raspberry Pi OS Desktop, décommenter +# le bloc ci-dessous ET la ligne graphical.target, après avoir vérifié la +# session réellement utilisée (X11 ou Wayland) et l'UID du compte : +# loginctl show-user @USER@ -p RuntimePath → XDG_RUNTIME_DIR= +# (ou simplement : id -u @USER@ → /run/user/) +# echo $DISPLAY / $WAYLAND_DISPLAY dans une session ouverte +# Non activé par défaut : le passage à graphical.target empêcherait le +# service de démarrer sur une machine sans bureau (Pi OS Lite), et ces +# valeurs dépendent de l'installation. À valider sur un Pi réel. [Unit] Description=Toolbox node multimédia (player + mapping + contrôle réseau) After=network-online.target Wants=network-online.target +# Kiosque en mode fenêtre uniquement : +#After=graphical.target [Service] Type=simple @@ -17,6 +41,10 @@ WorkingDirectory=@PREFIX@ ExecStart=@PREFIX@/toolbox-node Restart=always RestartSec=2 +# Kiosque en mode fenêtre uniquement (voir l'en-tête) : +#Environment=DISPLAY=:0 +#Environment=WAYLAND_DISPLAY=wayland-0 +#Environment=XDG_RUNTIME_DIR=/run/user/1000 # Limite mémoire de sécurité (un node ne doit jamais étouffer le Pi). MemoryMax=1G # Journal : stdout/stderr vont dans journald ; la page de logs de la web UI diff --git a/docs/TIERS.md b/docs/TIERS.md index 350f77f..a4a8fbd 100644 --- a/docs/TIERS.md +++ b/docs/TIERS.md @@ -1,11 +1,12 @@ # Composants tiers et mentions légales -Lanterne est un logiciel propriétaire de Pym, distribué sous les termes du -fichier `LICENSE`. Il s'appuie sur des composants tiers dont les licences -imposent leurs propres mentions. Ce document doit accompagner **toute** -distribution du logiciel (il est inclus dans chaque archive de release). +Lanterne est un logiciel de Pym, distribué sous **licence MIT** (voir le +fichier `LICENSE` à la racine, repris dans `Cargo.toml`). Il s'appuie sur des +composants tiers dont les licences imposent leurs propres mentions. Ce +document doit accompagner **toute** distribution du logiciel (il est inclus +dans chaque archive de release). -Dernière revue : 2026-07-29 (version 3.4.1). +Dernière revue : 2026-08-04 (version 3.5.0). --- diff --git a/docs/manuel.html b/docs/manuel.html index a79d850..1e50912 100644 --- a/docs/manuel.html +++ b/docs/manuel.html @@ -160,7 +160,7 @@ -
LanterneManuel · v3.3 · projet Toolbox
+
LanterneManuel · v3.5 · projet Toolbox
Vue d'ensemble
Toutes les fonctions @@ -181,7 +181,7 @@
- Lanterne · version 3.3 + Lanterne · version 3.5

Projeter une vidéo, exactement là où il faut.

Lanterne (projet Toolbox) transforme un ordinateur — ou un Raspberry Pi — en node de projection : lecture vidéo, mapping 4 coins et mesh warp, @@ -300,17 +300,31 @@

Windows — lé Pour piloter ou tester sans projeter.

-

Ubuntu / Debian

+

Ubuntu / Debian

toolbox-node-linux-x64

-

Vidéo : sudo apt install gstreamer1.0-plugins-base - gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-libav

+

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.

+

sudo apt install libgstreamer1.0-dev + libgstreamer-plugins-base1.0-dev libasound2-dev
+ cargo build --release -p toolbox-node --features gstreamer
+ sudo apt install gstreamer1.0-plugins-base gstreamer1.0-plugins-good + gstreamer1.0-plugins-bad gstreamer1.0-libav

-

Raspberry Pi 4 / 5

+

Raspberry Pi 4 / 5

toolbox-node-raspberrypi-arm64

-

Mêmes paquets GStreamer que Ubuntu. L'installeur - deploy/install.sh configure le service systemd - (démarrage au boot, relance en cas de crash).

+

Pilotage seul — cette archive ne projette rien. Compilée en + croisé sans fenêtre de sortie, sans MIDI et sans GStreamer : l'UI, le + mapping, les presets et l'OSC répondent, mais aucune image ne sort sur + le vidéoprojecteur. Pour projeter depuis un Pi, compiler sur le Pi + (--features gstreamer, et [output] mode = "kms" + sur Raspberry Pi OS Lite). deploy/install.sh configure le + service systemd (démarrage au boot, relance en cas de crash).

@@ -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/fadenom, secondesFondu 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/startAtheure Unix (double)Départ commun au timestamp — horloges NTP partagées + /cue/gonomDéclenche une cue du séquenceur + /dmx/scenenomRappelle une scène de la console lumières + /dmx/chasernom, ou chaîne videUn 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/masterentier 0..255 ou flottant 0..1Grand 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/faderid, puis entier 0..255 ou flottant 0..1Niveau 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 @@

Contrôle MIDI

[[midi.bindings]] cc = 7 # le fader 7 pilote le volume -scale = "volume" # 0..127 → 0..1 (aussi : brightness, gamma, hue…) +scale = "volume" # 0..127 → 0..1 +# Cibles : volume, rate (0,25×–4×), les 8 réglages couleur +# (brightness, contrast, gamma, saturation, hue, gain_r/g/b) +# les 5 effets (pixelate, posterize, noise, sharpen, mirror) +# et dmx_master (grand master lumieres, 0..255). [[midi.bindings]] note = 62 @@ -670,8 +702,8 @@

Dépannage

Fenêtre de sortie noireRien à projeter Normal sans mire ni lecture en cours. Choisis une mire (onglet Mapping) ou lance un média. - La vidéo ne se lance pasBinaire 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 pasBinaire 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éeRendu 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éphonePare-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