From 90d63966f9d1cc94e0504d113820237f086599d7 Mon Sep 17 00:00:00 2001 From: Yann Beaucoud Date: Wed, 9 Sep 2026 22:43:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(installeur):=20=C3=A9tape=20Boutons=20?= =?UTF-8?q?=E2=80=94=20le=20cabinet=20se=20mappe=20pendant=20l'installatio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patrick, 09/09/2026 : arrivé sur le frontend après installation, aucun bouton ne répondait et rien ne disait quoi faire. Son tire-bille marchait parfaitement — dès qu'il a trouvé Map Commander. Le matériel allait ; il manquait le moment de le déclarer. Le cabinet est branché PENDANT l'installation. C'est là qu'il faut appuyer sur ses boutons. L'étape réutilise le moteur de Map Commander tel quel (pincabos_vpx_input, déjà importé par l'assistant pour lister les cartes) : mêmes actions, mêmes jetons de liaison, même écriture. Pas de seconde liste qui divergerait. detect_once() est du pur stdlib — os, select, struct — donc il tourne sans rien ajouter au média. Sept actions essentielles d'abord (flips, Start, crédit, plunger, lockbar, sortie), le reste replié. Chaque ligne : « Appuyer », la liaison reconnue s'affiche, une croix pour effacer. L'étape est FACULTATIVE et le dit. Sans elle, le clavier reste, et Map Commander fera le travail plus tard — c'était le seul chemin jusqu'ici. L'écriture n'a PAS lieu depuis le chroot : ni les périphériques ni les chemins n'y sont ceux du cabinet installé. L'installateur pose le mappage et un drapeau ; pincabos-inputs-installer.service le rejoue au premier démarrage et CONSERVE le drapeau tant que l'écriture échoue. C'est la leçon du ZeDMD appliqué dans le chroot, remontée par Flo le même soir. Les cinq langues suivent. L'égérie prend la pose 09, penchée sur la bille. 752 tests. Co-Authored-By: Claude Opus 5 --- .../pincabos-inputs-installer.service | 1 + .../system/pincabos-inputs-installer.service | 15 ++ opt/pincabos/installer-gui/app.py | 54 ++++++ opt/pincabos/installer-gui/i18n.json | 70 +++++++- opt/pincabos/installer-gui/inputs.py | 116 ++++++++++++ .../installer-gui/templates/wizard.html | 59 +++++- .../script/installer/pincabos-live-installer | 21 +++ opt/pincabos/tests/test_installer_network.py | 2 +- opt/pincabos/tests/test_installeur_boutons.py | 168 ++++++++++++++++++ .../sbin/pincabos-inputs-installer-apply | 37 ++++ 10 files changed, 535 insertions(+), 8 deletions(-) create mode 120000 etc/systemd/system/multi-user.target.wants/pincabos-inputs-installer.service create mode 100644 etc/systemd/system/pincabos-inputs-installer.service create mode 100755 opt/pincabos/installer-gui/inputs.py create mode 100755 opt/pincabos/tests/test_installeur_boutons.py create mode 100755 usr/local/sbin/pincabos-inputs-installer-apply 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='
'+actions.map(a=>{ + const pris=inputsLabel(a.id); + return '
' + +'' + +''+(pris||"—")+'' + +'' + +(pris?'':'') + +'
'}).join("")+'
'; + 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