Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions etc/systemd/system/pincabos-inputs-installer.service
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions opt/pincabos/installer-gui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down
70 changes: 65 additions & 5 deletions opt/pincabos/installer-gui/i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."
}
}
116 changes: 116 additions & 0 deletions opt/pincabos/installer-gui/inputs.py
Original file line number Diff line number Diff line change
@@ -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 {}),
}
59 changes: 57 additions & 2 deletions opt/pincabos/installer-gui/templates/wizard.html
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,23 @@ <h2 data-i18n="toys_title"></h2>
<div id="toys-strips"></div>
<div class="hint" id="toys-status"></div>
<div class="btns"><button class="ghost" onclick="go('st-sound')" data-i18n="back"></button>
<button class="primary" id="toys-next" onclick="loadNetwork(false);go('st-network')" data-i18n="next"></button></div>
<button class="primary" id="toys-next" onclick="loadInputs();go('st-inputs')" data-i18n="next"></button></div>
</section>

<!-- PINCABOS_INSTALLEUR_BOUTONS_V1 : le cabinet est branché, c'est le moment
d'appuyer sur ses boutons. Étape facultative : sans elle, le clavier reste. -->
<section class="step" id="st-inputs">
<h2 data-i18n="inputs_title"></h2>
<p style="color:var(--muted);margin-bottom:12px" data-i18n="inputs_hint"></p>
<div class="card" style="margin-bottom:14px">
<div class="row" style="margin-bottom:4px"><label data-i18n="inputs_devices"></label><span id="inputs-devices" style="flex:1"></span></div>
<div class="hint" data-i18n="inputs_devices_note" style="margin-top:0"></div>
</div>
<div id="inputs-list"></div>
<div style="margin:10px 0"><a href="#" id="inputs-more" onclick="inputsMore();return false" data-i18n="inputs_more"></a></div>
<div class="hint" id="inputs-status"></div>
<div class="btns"><button class="ghost" onclick="go('st-toys')" data-i18n="back"></button>
<button class="primary" id="inputs-next" onclick="loadNetwork(false);go('st-network')" data-i18n="next"></button></div>
</section>

<section class="step" id="st-network">
Expand Down Expand Up @@ -577,6 +593,45 @@ <h2 style="text-align:center" data-i18n="install_done"></h2>
// (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='<div class="card">'+actions.map(a=>{
const pris=inputsLabel(a.id);
return '<div class="row" style="margin-bottom:6px">'
+'<label style="flex:1">'+a.label+(a.essentielle?"":' <span style="opacity:.6">·</span>')+'</label>'
+'<code style="flex:1" id="in-v-'+a.id+'">'+(pris||"—")+'</code>'
+'<button class="ghost" onclick="captureAction(\''+a.id+'\')" data-i18n="inputs_press">'+t("inputs_press")+'</button>'
+(pris?'<button class="ghost" onclick="clearAction(\''+a.id+'\')">✕</button>':'')
+'</div>'}).join("")+'</div>';
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)||[]}}
Expand Down Expand Up @@ -780,7 +835,7 @@ <h2 style="text-align:center" data-i18n="install_done"></h2>
// 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",'<img class="egerie egerie-page" id="egerie" alt="" onerror="this.hidden=true">');
function egeriePour(id){const im=document.getElementById("egerie"),p=EGERIE_PAGES[id];if(!im||!p)return;
Expand Down
Loading
Loading