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