diff --git a/etc/systemd/system/multi-user.target.wants/pincabos-inputs-installer.service b/etc/systemd/system/multi-user.target.wants/pincabos-inputs-installer.service
new file mode 120000
index 00000000..0ad4115a
--- /dev/null
+++ b/etc/systemd/system/multi-user.target.wants/pincabos-inputs-installer.service
@@ -0,0 +1 @@
+../pincabos-inputs-installer.service
\ No newline at end of file
diff --git a/etc/systemd/system/pincabos-inputs-installer.service b/etc/systemd/system/pincabos-inputs-installer.service
new file mode 100644
index 00000000..78a719c7
--- /dev/null
+++ b/etc/systemd/system/pincabos-inputs-installer.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=PinCabOS - apply the buttons captured in the installer
+Documentation=https://pincabos.cc/
+# PINCABOS_INSTALLEUR_BOUTONS_V1 : ne tourne que si l'installateur a laissé un drapeau.
+ConditionPathExists=/opt/pincabos/flags/inputs-installer.pending
+ConditionKernelCommandLine=!pincabos.installer
+After=local-fs.target
+Before=pincabos-vpinfe.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/local/sbin/pincabos-inputs-installer-apply
+
+[Install]
+WantedBy=multi-user.target
diff --git a/opt/pincabos/installer-gui/app.py b/opt/pincabos/installer-gui/app.py
index 877fe9cd..f41dc784 100755
--- a/opt/pincabos/installer-gui/app.py
+++ b/opt/pincabos/installer-gui/app.py
@@ -19,6 +19,7 @@
import screens as pco_screens # PINCABOS_INSTALLEUR_ECRANS_V1
import dmd as pco_dmd # PINCABOS_INSTALLEUR_DMD_V1
import disks as pco_disks # PINCABOS_INSTALLEUR_DISQUE_V1
+import inputs as pco_inputs # PINCABOS_INSTALLEUR_BOUTONS_V1
# PINCABOS_INSTALLEUR_RESEAU_V1 : le moteur réseau du cab (nmcli) sert aussi à
# l'assistant. Absent de la session (ISO au modèle classique) : l'étape se
@@ -406,6 +407,52 @@ def toys_vers_fichiers(a):
return {"toys_file": str(f)}
+# PINCABOS_INSTALLEUR_BOUTONS_V1 : le cabinet est branche pendant l installation,
+# c est le bon moment pour appuyer sur ses boutons. On capture ici ; l ecriture
+# dans le VPinballX.ini et le vpinfe.ini de la cible est rejouee au premier
+# demarrage, ou les peripheriques sont ceux du cabinet installe.
+BOUTONS_DEMO = {"actions": [{"id": "LeftFlipper", "label": "Flipper gauche", "defaut": "Key;225", "essentielle": True},
+ {"id": "RightFlipper", "label": "Flipper droit", "defaut": "Key;229", "essentielle": True},
+ {"id": "Start", "label": "Start", "defaut": "Key;30", "essentielle": True}],
+ "peripheriques": [{"name": "L'atelier d'Arnoz DudesCab", "id": "0001", "boutons": 15, "axes": 6}]}
+
+
+@app.route("/api/inputs")
+def inputs_status():
+ if DEMO:
+ return jsonify(dict(BOUTONS_DEMO, disponible=True))
+ try:
+ return jsonify({"disponible": pco_inputs.disponible(),
+ "actions": pco_inputs.actions(),
+ "peripheriques": pco_inputs.peripheriques()})
+ except Exception as exc:
+ return jsonify({"disponible": False, "error": str(exc), "actions": [], "peripheriques": []})
+
+
+@app.route("/api/inputs/capture", methods=["POST"])
+def inputs_capture():
+ a = request.get_json(force=True, silent=True) or {}
+ if DEMO:
+ return jsonify({"binding": "Key;225", "label": "LSHIFT", "device": "clavier (démo)",
+ "device_id": "Key", "raw": "démo"})
+ return jsonify(pco_inputs.capturer(a.get("delai", 8.0)))
+
+
+def boutons_vers_fichiers(a):
+ """Le mappage capture devient gui-inputs.json, rejoue au premier demarrage."""
+ if not isinstance(a.get("inputs"), dict):
+ return {}
+ erreurs, ok = pco_inputs.valider(a["inputs"])
+ if erreurs:
+ return {"error": "bad-inputs", "detail": erreurs}
+ if not ok.get("mappings"):
+ return {} # rien de capture : l etape a ete passee, c est permis
+ RUN_DIR.mkdir(parents=True, exist_ok=True)
+ f = RUN_DIR / "gui-inputs.json"
+ f.write_text(json.dumps(pco_inputs.config_json(ok), indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+ return {"inputs_file": str(f)}
+
+
RESEAU_DEMO = {
"interfaces": [
{"device": "eno1", "type": "ethernet", "state": "100 (connected)", "method": "auto", "address": "172.18.40.80/24",
@@ -686,6 +733,13 @@ def install():
return jsonify(res), 400
reponses.update(res)
+ # PINCABOS_INSTALLEUR_BOUTONS_V1 : les boutons appuyés pendant l'installation
+ if isinstance(a.get("inputs"), dict):
+ res = boutons_vers_fichiers(a)
+ if "error" in res:
+ return jsonify(res), 400
+ reponses.update(res)
+
# PINCABOS_INSTALLEUR_RESEAU_V1 : ce que la session a configuré part sur la cible
if a.get("network") is not False:
try:
diff --git a/opt/pincabos/installer-gui/i18n.json b/opt/pincabos/installer-gui/i18n.json
index e59b80b8..76d2e56e 100755
--- a/opt/pincabos/installer-gui/i18n.json
+++ b/opt/pincabos/installer-gui/i18n.json
@@ -202,7 +202,19 @@
"hp_pos_SL": "fond du meuble gauche",
"hp_pos_SR": "fond du meuble droit",
"hp_pos_C": "centre du fronton",
- "hp_pos_LFE": "caisson de graves"
+ "hp_pos_LFE": "caisson de graves",
+ "inputs_title": "Boutons",
+ "inputs_hint": "Votre cabinet est branché : appuyez sur chaque bouton, PinCabOS le reconnaît. Étape facultative — sans elle, seul le clavier répondra, et vous ferez le mappage plus tard dans Map Commander.",
+ "inputs_devices": "Cartes vues",
+ "inputs_devices_note": "Une carte de boutons (DudesCab, Pinscape, encodeur clavier…) apparaît ici dès qu'elle est branchée.",
+ "inputs_devices_none": "aucune carte de boutons vue (clavier seul)",
+ "inputs_press": "Appuyer",
+ "inputs_waiting": "appuyez maintenant…",
+ "inputs_timeout": "rien reçu — réessayez",
+ "inputs_more": "Voir toutes les actions",
+ "inputs_less": "Ne voir que l'essentiel",
+ "inputs_count": "%n bouton(s) reconnu(s) — ils seront posés à l'installation.",
+ "inputs_skip_ok": "Aucun bouton appuyé : vous pourrez le faire dans Map Commander."
},
"en": {
"choose_lang": "Choose your language",
@@ -407,7 +419,19 @@
"hp_pos_SL": "cabinet back left",
"hp_pos_SR": "cabinet back right",
"hp_pos_C": "backbox center",
- "hp_pos_LFE": "subwoofer"
+ "hp_pos_LFE": "subwoofer",
+ "inputs_title": "Buttons",
+ "inputs_hint": "Your cabinet is plugged in: press each button and PinCabOS recognises it. Optional step — without it only the keyboard will answer, and you will map later in Map Commander.",
+ "inputs_devices": "Boards seen",
+ "inputs_devices_note": "A button board (DudesCab, Pinscape, keyboard encoder…) shows up here as soon as it is plugged in.",
+ "inputs_devices_none": "no button board seen (keyboard only)",
+ "inputs_press": "Press",
+ "inputs_waiting": "press now…",
+ "inputs_timeout": "nothing received — try again",
+ "inputs_more": "Show every action",
+ "inputs_less": "Show the essentials only",
+ "inputs_count": "%n button(s) recognised — they will be applied at install time.",
+ "inputs_skip_ok": "No button pressed: you will be able to do it in Map Commander."
},
"de": {
"choose_lang": "Sprache auswählen",
@@ -612,7 +636,19 @@
"hp_pos_SL": "Gehäuserückseite links",
"hp_pos_SR": "Gehäuserückseite rechts",
"hp_pos_C": "Backbox Mitte",
- "hp_pos_LFE": "Subwoofer"
+ "hp_pos_LFE": "Subwoofer",
+ "inputs_title": "Tasten",
+ "inputs_hint": "Ihr Cabinet ist angeschlossen: drücken Sie jede Taste, PinCabOS erkennt sie. Optionaler Schritt — ohne ihn reagiert nur die Tastatur, und Sie mappen später in Map Commander.",
+ "inputs_devices": "Erkannte Karten",
+ "inputs_devices_note": "Eine Tastenkarte (DudesCab, Pinscape, Tastatur-Encoder…) erscheint hier, sobald sie angeschlossen ist.",
+ "inputs_devices_none": "keine Tastenkarte erkannt (nur Tastatur)",
+ "inputs_press": "Drücken",
+ "inputs_waiting": "jetzt drücken…",
+ "inputs_timeout": "nichts empfangen — erneut versuchen",
+ "inputs_more": "Alle Aktionen anzeigen",
+ "inputs_less": "Nur das Wesentliche",
+ "inputs_count": "%n Taste(n) erkannt — sie werden bei der Installation übernommen.",
+ "inputs_skip_ok": "Keine Taste gedrückt: Sie können es in Map Commander nachholen."
},
"it": {
"choose_lang": "Scegli la tua lingua",
@@ -817,7 +853,19 @@
"hp_pos_SL": "fondo del mobile sinistra",
"hp_pos_SR": "fondo del mobile destra",
"hp_pos_C": "centro backbox",
- "hp_pos_LFE": "subwoofer"
+ "hp_pos_LFE": "subwoofer",
+ "inputs_title": "Pulsanti",
+ "inputs_hint": "Il tuo cabinato è collegato: premi ogni pulsante, PinCabOS lo riconosce. Passo facoltativo — senza, risponderà solo la tastiera e mapperai più tardi in Map Commander.",
+ "inputs_devices": "Schede viste",
+ "inputs_devices_note": "Una scheda pulsanti (DudesCab, Pinscape, encoder tastiera…) appare qui appena collegata.",
+ "inputs_devices_none": "nessuna scheda pulsanti vista (solo tastiera)",
+ "inputs_press": "Premi",
+ "inputs_waiting": "premi ora…",
+ "inputs_timeout": "nulla ricevuto — riprova",
+ "inputs_more": "Mostra tutte le azioni",
+ "inputs_less": "Solo l'essenziale",
+ "inputs_count": "%n pulsante/i riconosciuto/i — verranno applicati all'installazione.",
+ "inputs_skip_ok": "Nessun pulsante premuto: potrai farlo in Map Commander."
},
"es": {
"choose_lang": "Elige tu idioma",
@@ -1022,6 +1070,18 @@
"hp_pos_SL": "fondo del mueble izquierda",
"hp_pos_SR": "fondo del mueble derecha",
"hp_pos_C": "centro backbox",
- "hp_pos_LFE": "subwoofer"
+ "hp_pos_LFE": "subwoofer",
+ "inputs_title": "Botones",
+ "inputs_hint": "Tu mueble está conectado: pulsa cada botón y PinCabOS lo reconoce. Paso opcional — sin él sólo responderá el teclado, y mapearás más tarde en Map Commander.",
+ "inputs_devices": "Tarjetas vistas",
+ "inputs_devices_note": "Una tarjeta de botones (DudesCab, Pinscape, codificador de teclado…) aparece aquí al conectarla.",
+ "inputs_devices_none": "ninguna tarjeta de botones vista (sólo teclado)",
+ "inputs_press": "Pulsar",
+ "inputs_waiting": "pulsa ahora…",
+ "inputs_timeout": "nada recibido — inténtalo de nuevo",
+ "inputs_more": "Ver todas las acciones",
+ "inputs_less": "Sólo lo esencial",
+ "inputs_count": "%n botón(es) reconocido(s) — se aplicarán en la instalación.",
+ "inputs_skip_ok": "Ningún botón pulsado: podrás hacerlo en Map Commander."
}
}
diff --git a/opt/pincabos/installer-gui/inputs.py b/opt/pincabos/installer-gui/inputs.py
new file mode 100755
index 00000000..f8ffee86
--- /dev/null
+++ b/opt/pincabos/installer-gui/inputs.py
@@ -0,0 +1,116 @@
+"""Étape Boutons de l'assistant d'installation (PINCABOS_INSTALLEUR_BOUTONS_V1).
+
+Remonté par Patrick le 09/09/2026 : arrivé sur le frontend après installation,
+aucun bouton ne répondait et rien ne disait quoi faire. Le mappage n'existait
+qu'après coup, dans Map Commander — qu'il fallait deviner.
+
+Le cabinet est branché pendant l'installation : c'est le bon moment pour
+appuyer sur ses boutons. On réutilise le moteur de Map Commander tel quel
+(`pincabos_vpx_input`) : mêmes actions, mêmes jetons de liaison, même écriture.
+L'assistant ne fait que CAPTURER ; l'écriture dans le VPinballX.ini et le
+vpinfe.ini de la cible est rejouée au premier démarrage, là où les
+périphériques et les chemins sont ceux du cabinet installé — la leçon du ZeDMD
+appliqué dans le chroot (PINCABOS_ZEDMD_AU_PREMIER_DEMARRAGE_V1).
+"""
+from __future__ import annotations
+
+import datetime
+
+try:
+ import pincabos_vpx_input as vi
+except Exception: # pragma: no cover - hors cab
+ vi = None
+
+# Ce qu'un cabinet a forcément, et qu'il faut pour jouer dès le premier
+# démarrage. Le reste des actions de Map Commander reste accessible, replié.
+ESSENTIELLES = ("LeftFlipper", "RightFlipper", "Start", "Credit1",
+ "LaunchBall", "Lockbar", "ExitGame")
+
+DELAI_MAX = 30.0
+
+
+def disponible() -> bool:
+ return vi is not None
+
+
+def actions() -> list:
+ """Les actions mappables, l'essentiel d'abord."""
+ if vi is None:
+ return []
+ rang = {a: i for i, a in enumerate(ESSENTIELLES)}
+ tout = [{"id": a, "label": label, "defaut": defaut,
+ "essentielle": a in rang}
+ for a, label, defaut in vi.ACTIONS]
+ tout.sort(key=lambda d: (0, rang[d["id"]]) if d["essentielle"] else (1, 0))
+ return tout
+
+
+def peripheriques() -> list:
+ """Cartes de boutons vues par le noyau, pour dire à l'utilisateur ce qu'on voit."""
+ if vi is None:
+ return []
+ try:
+ devs = vi.list_devices()
+ except Exception:
+ return []
+ ids = vi.joystick_setting_ids(devs)
+ return [{"name": d.name, "id": ids.get(d.path, ("", ""))[0],
+ "boutons": len(d.button_order()), "axes": len(d.axis_order())}
+ for d in devs]
+
+
+def capturer(delai: float = 8.0) -> dict:
+ """Attend un appui et renvoie la liaison, ou dit pourquoi elle n'est pas venue."""
+ if vi is None:
+ return {"error": "moteur d'entrées indisponible"}
+ try:
+ delai = max(1.0, min(float(delai), DELAI_MAX))
+ except (TypeError, ValueError):
+ delai = 8.0
+ try:
+ r = vi.detect_once(delai)
+ except Exception as exc:
+ return {"error": str(exc)}
+ if r is None:
+ return {"timeout": True}
+ return r
+
+
+def valider(choix) -> tuple[list, dict]:
+ """Un mappage vide est valide : cette étape n'est pas obligatoire."""
+ if vi is None:
+ return [], {"mappings": {}}
+ if not isinstance(choix, dict):
+ return ["choix boutons invalide"], {}
+ brut = choix.get("mappings")
+ if brut is None:
+ brut = {}
+ if not isinstance(brut, dict):
+ return ["mappings invalide"], {}
+ erreurs, ok = [], {}
+ for action, texte in brut.items():
+ if action not in vi.ACTION_LABELS:
+ erreurs.append("action inconnue : %s" % action)
+ continue
+ texte = str(texte or "").strip()
+ if not texte:
+ continue
+ try:
+ normalise = vi.normalize_mapping(texte)
+ except Exception:
+ erreurs.append("liaison illisible pour %s" % action)
+ continue
+ if not normalise:
+ continue
+ ok[action] = normalise
+ return erreurs, {"mappings": ok}
+
+
+def config_json(ok: dict) -> dict:
+ """Ce que l'installateur pose sur la cible, rejoué au premier démarrage."""
+ return {
+ "version": 1,
+ "source": "PinCabOS installer",
+ "date": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ "mappings": dict(ok.get("mappings") or {}),
+ }
diff --git a/opt/pincabos/installer-gui/templates/wizard.html b/opt/pincabos/installer-gui/templates/wizard.html
index 1f7c4dc4..ffee3b7f 100755
--- a/opt/pincabos/installer-gui/templates/wizard.html
+++ b/opt/pincabos/installer-gui/templates/wizard.html
@@ -290,7 +290,23 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -577,6 +593,45 @@
// (mapping au premier démarrage), cartes de sortie AutoConfig, contrôleurs de
// rubans à déclarer (matrice backboard ou rubans, LEDs par sortie).
S.toys={controllers:[]};
+// PINCABOS_INSTALLEUR_BOUTONS_V1 — l'étape Boutons.
+// On ne fait que CAPTURER : le moteur (pincabos_vpx_input) est le même que
+// Map Commander, et l'écriture sur la cible est rejouée au premier démarrage.
+S.inputs={mappings:{}};
+let inputsDet=null,inputsTout=false,inputsEnCours=false;
+function loadInputs(){fetch("/api/inputs").then(r=>r.json()).then(d=>{inputsDet=d;renderInputs()})
+ .catch(()=>{inputsDet={disponible:false,actions:[],peripheriques:[]};renderInputs()})}
+function inputsMore(){inputsTout=!inputsTout;renderInputs()}
+function inputsLabel(id){const v=S.inputs.mappings[id];return v?(S.inputs.labels&&S.inputs.labels[id]||v):""}
+function renderInputs(){
+ const d=inputsDet||{actions:[],peripheriques:[]};
+ const dv=document.getElementById("inputs-devices");
+ dv.textContent=(d.peripheriques||[]).length
+ ? d.peripheriques.map(p=>p.name+" ("+p.boutons+" "+t("toys_buttons")+")").join(" · ")
+ : t("inputs_devices_none");
+ const actions=(d.actions||[]).filter(a=>inputsTout||a.essentielle);
+ const l=document.getElementById("inputs-list");
+ l.innerHTML='
';
+ document.getElementById("inputs-more").textContent=t(inputsTout?"inputs_less":"inputs_more");
+ const n=Object.keys(S.inputs.mappings).length;
+ document.getElementById("inputs-status").textContent=n?t("inputs_count").replace("%n",n):t("inputs_skip_ok")}
+function clearAction(id){delete S.inputs.mappings[id];if(S.inputs.labels)delete S.inputs.labels[id];renderInputs()}
+function captureAction(id){
+ if(inputsEnCours)return; inputsEnCours=true;
+ const c=document.getElementById("in-v-"+id); if(c)c.textContent=t("inputs_waiting");
+ fetch("/api/inputs/capture",{method:"POST",headers:{"Content-Type":"application/json"},
+ body:JSON.stringify({delai:8})}).then(r=>r.json()).then(d=>{
+ inputsEnCours=false;
+ if(d&&d.binding){S.inputs.mappings[id]=d.binding;
+ S.inputs.labels=S.inputs.labels||{};S.inputs.labels[id]=d.label||d.binding}
+ else if(c)c.textContent=d&&d.timeout?t("inputs_timeout"):(d&&d.error||"—");
+ renderInputs()}).catch(()=>{inputsEnCours=false;renderInputs()})}
let toysDet=null;
function loadToys(){fetch("/api/toys").then(r=>r.json()).then(d=>{toysDet=d;
if(!S.toys.touched){S.toys={controllers:(d.proposition&&d.proposition.controllers)||[]}}
@@ -780,7 +835,7 @@
// PINCABOS_INSTALLEUR_EGERIE_V3 (Yann) : Miss Tilt sur CHAQUE page, toujours a la meme place
// (marge droite, pied de page), derriere le contenu, une pose differente par page.
const EGERIE_PAGES={"st-lang":"pose-07-salut-futuriste","st-regional":"pose-03-debout-contrapposto","st-screens":"pose-extra-04",
- "st-sound":"pose-06-marche-elegante","st-toys":"pose-10-trois-billes","st-network":"pose-extra-01","st-disk":"pose-02-assise-de-cote",
+ "st-sound":"pose-06-marche-elegante","st-toys":"pose-10-trois-billes","st-inputs":"pose-09-penchee-sur-bille","st-network":"pose-extra-01","st-disk":"pose-02-assise-de-cote",
"st-confirm":"pose-08-un-genou-au-sol","st-progress":"pose-04-course-dynamique","st-done":"pose-05-heroique-bras-croises"};
document.body.insertAdjacentHTML("beforeend",'');
function egeriePour(id){const im=document.getElementById("egerie"),p=EGERIE_PAGES[id];if(!im||!p)return;
diff --git a/opt/pincabos/script/installer/pincabos-live-installer b/opt/pincabos/script/installer/pincabos-live-installer
index 136bdf4d..7e272f58 100755
--- a/opt/pincabos/script/installer/pincabos-live-installer
+++ b/opt/pincabos/script/installer/pincabos-live-installer
@@ -1222,6 +1222,26 @@ PY
fi
}
+apply_target_inputs() {
+ # PINCABOS_INSTALLEUR_BOUTONS_V1 : le mappage capture pendant l installation
+ # est POSE ici et rejoue au premier demarrage — jamais ecrit depuis le
+ # chroot. Les peripheriques et les chemins n y sont pas ceux du cabinet
+ # installe : c est la lecon du ZeDMD (PINCABOS_ZEDMD_AU_PREMIER_DEMARRAGE_V1).
+ local src="${PCO_ANS_INPUTS_FILE:-}"
+ [ -n "$src" ] || return 0
+ echo
+ pco_step "Applying the buttons captured in the installer to the target"
+ if [ ! -s "$src" ]; then
+ pco_warn "inputs file missing or empty: $src (Map Commander on the cab will do it)"
+ return 0
+ fi
+ install -d -o 1000 -g 1000 -m 0775 "$TARGET/opt/pincabos/config"
+ install -o 1000 -g 1000 -m 0664 "$src" "$TARGET/opt/pincabos/config/inputs-installer.json"
+ install -d -m 0755 "$TARGET/opt/pincabos/flags"
+ date -Is > "$TARGET/opt/pincabos/flags/inputs-installer.pending"
+ pco_go "inputs-installer.json installed ($(python3 -c 'import json,sys;print(len(json.load(open(sys.argv[1])).get("mappings",{})))' "$src" 2>/dev/null || echo '?') action(s))"
+}
+
apply_target_ownership() {
# PINCABOS_CIBLE_DROITS_V1
# La WebApp tourne en User=pinball. Les fichiers de /opt/pincabos/config
@@ -1986,6 +2006,7 @@ install_payload() {
apply_target_audio
apply_target_dof
apply_target_toys
+ apply_target_inputs
apply_target_ownership
purge_live_only_from_target
refresh_target_initrd_for_orientation
diff --git a/opt/pincabos/tests/test_installer_network.py b/opt/pincabos/tests/test_installer_network.py
index 65bdcc51..58aa95c6 100755
--- a/opt/pincabos/tests/test_installer_network.py
+++ b/opt/pincabos/tests/test_installer_network.py
@@ -109,7 +109,7 @@ class Integration(unittest.TestCase):
def test_iso_sh(self):
s = texte_installateur()
self.assertIn("apply_target_network() {", s)
- self.assertIn(" apply_target_screens\n apply_target_network\n apply_target_dmd\n apply_target_audio\n apply_target_dof\n apply_target_toys\n apply_target_ownership\n purge_live_only_from_target\n refresh_target_initrd_for_orientation\n", s)
+ self.assertIn(" apply_target_screens\n apply_target_network\n apply_target_dmd\n apply_target_audio\n apply_target_dof\n apply_target_toys\n apply_target_inputs\n apply_target_ownership\n purge_live_only_from_target\n refresh_target_initrd_for_orientation\n", s)
self.assertIn('netplan-takeover "$iface" --root "$TARGET"', s)
self.assertIn("network-installer.done", s)
# PINCABOS_INSTALLEUR_RESEAU_V2 : a la mise a jour, le choix reseau est rejoue apres la restauration
diff --git a/opt/pincabos/tests/test_installeur_boutons.py b/opt/pincabos/tests/test_installeur_boutons.py
new file mode 100755
index 00000000..75831cb2
--- /dev/null
+++ b/opt/pincabos/tests/test_installeur_boutons.py
@@ -0,0 +1,168 @@
+"""L'assistant capture les boutons du cabinet (PINCABOS_INSTALLEUR_BOUTONS_V1).
+
+Remonté par Patrick le 09/09/2026 : arrivé sur le frontend après installation,
+aucun bouton ne répondait et rien ne disait quoi faire. Le mappage n'existait
+qu'après coup, dans Map Commander — qu'il fallait deviner. Son tire-bille, lui,
+répondait très bien dès qu'il a trouvé la page : le matériel allait, il ne
+manquait que le moment de le déclarer.
+
+Le cabinet est branché PENDANT l'installation. On réutilise le moteur de Map
+Commander tel quel (pincabos_vpx_input) : mêmes actions, mêmes jetons. On ne
+fait que capturer ; l'écriture dans le VPinballX.ini et le vpinfe.ini de la
+cible est rejouée au premier démarrage, là où les périphériques et les chemins
+sont ceux du cabinet installé (leçon du ZeDMD appliqué dans le chroot).
+"""
+import json
+import sys
+import unittest
+from pathlib import Path
+
+from _charge import RACINE
+
+R = Path(RACINE)
+GUI = R / "opt/pincabos/installer-gui"
+MODULE = GUI / "inputs.py"
+APP = GUI / "app.py"
+WIZARD = GUI / "templates/wizard.html"
+I18N = GUI / "i18n.json"
+INSTALLEUR = R / "opt/pincabos/script/installer/pincabos-live-installer"
+UNITE = R / "etc/systemd/system/pincabos-inputs-installer.service"
+LIEN = R / "etc/systemd/system/multi-user.target.wants/pincabos-inputs-installer.service"
+REJEU = R / "usr/local/sbin/pincabos-inputs-installer-apply"
+LANGUES = ("fr", "en", "de", "it", "es")
+
+
+def _mod():
+ sys.path.insert(0, str(R / "opt/pincabos/tools"))
+ sys.path.insert(0, str(GUI))
+ import importlib
+ import inputs as m
+ importlib.reload(m)
+ return m
+
+
+class LeModuleReutiliseMapCommander(unittest.TestCase):
+ def setUp(self):
+ self.m = _mod()
+
+ def test_les_actions_viennent_du_moteur(self):
+ # surtout pas une seconde liste d actions qui divergerait
+ self.assertIn("import pincabos_vpx_input", MODULE.read_text(encoding="utf-8"))
+ ids = [a["id"] for a in self.m.actions()]
+ for attendu in ("LeftFlipper", "RightFlipper", "Start", "LaunchBall", "ExitGame"):
+ self.assertIn(attendu, ids)
+
+ def test_l_essentiel_vient_en_premier(self):
+ acts = self.m.actions()
+ essentielles = [a for a in acts if a["essentielle"]]
+ self.assertEqual([a["id"] for a in acts[:len(essentielles)]],
+ [a["id"] for a in essentielles],
+ "l essentiel doit ouvrir la liste")
+ self.assertIn("LeftFlipper", [a["id"] for a in essentielles])
+
+ def test_un_mappage_vide_reste_valide(self):
+ # l etape est facultative : la passer ne doit rien casser
+ self.assertEqual(self.m.valider({"mappings": {}}), ([], {"mappings": {}}))
+ self.assertEqual(self.m.valider({})[0], [])
+
+ def test_une_action_inconnue_est_refusee(self):
+ erreurs, _ = self.m.valider({"mappings": {"PasUneAction": "Key;225"}})
+ self.assertTrue(any("inconnue" in e for e in erreurs))
+
+ def test_les_liaisons_sont_normalisees(self):
+ _, ok = self.m.valider({"mappings": {"LeftFlipper": "Key;225"}})
+ self.assertEqual(ok["mappings"]["LeftFlipper"], "Key;225")
+
+ def test_le_delai_de_capture_est_borne(self):
+ # une capture sans borne fige l assistant
+ self.assertIn("DELAI_MAX", MODULE.read_text(encoding="utf-8"))
+
+ def test_le_fichier_de_reponses_porte_sa_provenance(self):
+ c = self.m.config_json({"mappings": {"Start": "Key;30"}})
+ self.assertEqual(c["source"], "PinCabOS installer")
+ self.assertEqual(c["mappings"], {"Start": "Key;30"})
+
+
+class LAssistantExposeLEtape(unittest.TestCase):
+ def test_les_routes_existent(self):
+ s = APP.read_text(encoding="utf-8")
+ self.assertIn('@app.route("/api/inputs")', s)
+ self.assertIn('@app.route("/api/inputs/capture", methods=["POST"])', s)
+
+ def test_le_choix_part_dans_les_reponses(self):
+ s = APP.read_text(encoding="utf-8")
+ self.assertIn("def boutons_vers_fichiers", s)
+ self.assertIn('"inputs_file"', s) # -> PCO_ANS_INPUTS_FILE
+ self.assertIn('if isinstance(a.get("inputs"), dict):', s)
+
+ def test_la_page_a_l_etape(self):
+ h = WIZARD.read_text(encoding="utf-8")
+ for m in ('id="st-inputs"', "loadInputs()", "captureAction(", "S.inputs"):
+ self.assertIn(m, h)
+
+ def test_l_etape_s_insere_entre_toys_et_reseau(self):
+ h = WIZARD.read_text(encoding="utf-8")
+ self.assertIn("loadInputs();go('st-inputs')", h, "l etape Toys doit y mener")
+ bloc = h.split('id="st-inputs"', 1)[1].split("", 1)[0]
+ self.assertIn("go('st-toys')", bloc, "le retour va vers Toys")
+ self.assertIn("go('st-network')", bloc, "la suite va vers Reseau")
+
+
+class LeMappageEstRejoueAuPremierDemarrage(unittest.TestCase):
+ """Jamais d'écriture depuis le chroot : ni les périphériques ni les chemins
+ n'y sont ceux du cabinet installé."""
+
+ def test_l_installateur_pose_le_fichier_et_le_drapeau(self):
+ corps = INSTALLEUR.read_text(encoding="utf-8") \
+ .split("apply_target_inputs() {", 1)[1].split("\n}", 1)[0]
+ self.assertIn("inputs-installer.json", corps)
+ self.assertIn("flags/inputs-installer.pending", corps)
+
+ def test_rien_n_est_ecrit_depuis_le_chroot(self):
+ corps = INSTALLEUR.read_text(encoding="utf-8") \
+ .split("apply_target_inputs() {", 1)[1].split("\n}", 1)[0]
+ # On ne regarde que le CODE : le commentaire, lui, doit justement parler
+ # du chroot pour expliquer pourquoi on n'y écrit pas. Piège déjà pris en
+ # 4.63, où un test échouait sur son propre commentaire d'explication.
+ code = "\n".join(l for l in corps.splitlines() if not l.strip().startswith("#"))
+ self.assertNotIn("chroot", code,
+ "le mappage ne doit pas etre applique depuis le media")
+
+ def test_l_etape_est_appelee(self):
+ corps = INSTALLEUR.read_text(encoding="utf-8") \
+ .split("install_payload() {", 1)[1].split("\nfinal_boot_refresh", 1)[0]
+ self.assertIn("apply_target_inputs", corps)
+
+ def test_l_unite_de_rejeu_est_activee(self):
+ self.assertTrue(UNITE.is_file())
+ u = UNITE.read_text(encoding="utf-8")
+ self.assertIn("ConditionPathExists=/opt/pincabos/flags/inputs-installer.pending", u)
+ self.assertIn("Before=pincabos-vpinfe.service", u)
+ self.assertRegex(u, r"(?m)^WantedBy=multi-user\.target$")
+ self.assertTrue(LIEN.is_symlink(), "le lien d activation manque")
+
+ def test_le_drapeau_survit_a_un_echec(self):
+ # comme pour le ZeDMD : on retente au demarrage suivant plutot que de
+ # perdre ce que l utilisateur a saisi
+ s = REJEU.read_text(encoding="utf-8")
+ self.assertIn("drapeau conservé", s)
+ self.assertIn("write_mappings", s)
+ self.assertIn("write_vpinfe", s)
+
+
+class LesLibellesSuivent(unittest.TestCase):
+ def test_les_cinq_langues(self):
+ d = json.loads(I18N.read_text(encoding="utf-8"))
+ tailles = {k: len(v) for k, v in d.items()}
+ self.assertEqual(len(set(tailles.values())), 1, "langues divergentes : %s" % tailles)
+ for lang in LANGUES:
+ for cle in ("inputs_title", "inputs_hint", "inputs_press", "inputs_timeout",
+ "inputs_more", "inputs_count", "inputs_skip_ok"):
+ self.assertIn(cle, d[lang], "%s manque en %s" % (cle, lang))
+
+ def test_l_aide_dit_que_l_etape_est_facultative(self):
+ d = json.loads(I18N.read_text(encoding="utf-8"))
+ for lang, mot in (("fr", "facultative"), ("en", "Optional"), ("de", "Optionaler"),
+ ("it", "facoltativo"), ("es", "opcional")):
+ self.assertIn(mot, d[lang]["inputs_hint"],
+ "%s : rien ne dit que l etape peut etre passee" % lang)
diff --git a/usr/local/sbin/pincabos-inputs-installer-apply b/usr/local/sbin/pincabos-inputs-installer-apply
new file mode 100755
index 00000000..af2eb941
--- /dev/null
+++ b/usr/local/sbin/pincabos-inputs-installer-apply
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# PINCABOS_INSTALLEUR_BOUTONS_V1
+# Rejoue au premier démarrage le mappage capturé pendant l'installation.
+# Comme pour le ZeDMD, le drapeau est CONSERVÉ tant que l'écriture échoue :
+# le cabinet retentera au démarrage suivant plutôt que de perdre le choix.
+set -u
+DRAPEAU=/opt/pincabos/flags/inputs-installer.pending
+SOURCE=/opt/pincabos/config/inputs-installer.json
+
+[ -f "$DRAPEAU" ] || exit 0
+if [ ! -s "$SOURCE" ]; then
+ echo "aucun mappage de l'installateur à rejouer"
+ rm -f "$DRAPEAU"
+ exit 0
+fi
+
+if runuser -u pinball -- /usr/bin/python3 - "$SOURCE" <<'PY'
+import json
+import sys
+
+sys.path.insert(0, "/opt/pincabos/tools")
+import pincabos_vpx_input as vi
+
+mappings = (json.load(open(sys.argv[1])).get("mappings") or {})
+if not mappings:
+ print("mappage vide")
+ raise SystemExit(0)
+vi.write_mappings(mappings)
+vi.write_vpinfe(vi.vpinfe_values(mappings))
+print("mappage de l'installateur appliqué : %d action(s)" % len(mappings))
+PY
+then
+ rm -f "$DRAPEAU"
+else
+ echo "écriture du mappage refusée (voir Map Commander), drapeau conservé"
+fi
+exit 0