From 0556bb7c8b0b830ede0a690fa9e7b71384fd7b0b Mon Sep 17 00:00:00 2001 From: Yann Beaucoud Date: Wed, 9 Sep 2026 22:48:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(installeur):=20un=20r=C3=B4le=20par=20sort?= =?UTF-8?q?ie=20adressable,=20et=20des=20toys=20enfin=20nomm=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lot 2 des toys. Le nom du toy dans cabinet.xml était générique : « Backboard HD » pour la première matrice, « Matrice 2 », « Ruban 1.2 »… Exact, mais illisible le jour où l'on relit son cabinet — et c'est ce nom que DOF cherche ensuite pour piloter la sortie. Chaque sortie reçoit maintenant un rôle : backboard, sous le meuble, côté gauche, côté droit, fronton, flippers, intérieur du meuble. En mode matrice, un rôle pour tout le contrôleur ; en mode rubans, un par sortie utilisée, avec le nombre de LED affiché à côté pour qu'on sache de quoi on parle. Le rôle reste FACULTATIF. Sans lui, le nom générique demeure : rien ne change pour un cabinet déjà configuré, et la proposition n'en avance aucun — cohérent avec PINCABOS_TOYS_SANS_SUPPOSITION_V1. « backboard » garde volontairement « Backboard HD » : c'est le nom qu'ont les cabinets en service, et le changer casserait leur configuration DOF. Les cinq langues suivent. 765 tests. Co-Authored-By: Claude Opus 5 --- opt/pincabos/installer-gui/app.py | 2 + opt/pincabos/installer-gui/i18n.json | 70 ++++++++- .../installer-gui/templates/wizard.html | 21 ++- opt/pincabos/tests/test_toys_roles.py | 142 ++++++++++++++++++ opt/pincabos/tools/pincabos_dof.py | 52 ++++++- 5 files changed, 276 insertions(+), 11 deletions(-) create mode 100755 opt/pincabos/tests/test_toys_roles.py diff --git a/opt/pincabos/installer-gui/app.py b/opt/pincabos/installer-gui/app.py index f41dc784..0a6ac26b 100755 --- a/opt/pincabos/installer-gui/app.py +++ b/opt/pincabos/installer-gui/app.py @@ -379,6 +379,8 @@ def toys_detection(): "auto": pco_dof.cartes_auto(det) if pco_dof else [], "strips": pco_dof.controleurs_de_rubans(det) if pco_dof else [], "arrangements": list(pco_dof.ARRANGEMENTS) if pco_dof else [], "color_orders": list(pco_dof.ORDRES_COULEUR) if pco_dof else [], + # PINCABOS_TOYS_ROLES_V1 : à quoi sert chaque sortie adressable + "roles": list(pco_dof.ROLE_IDS) if pco_dof else [], "proposition": pco_dof.proposer_toys(det) if pco_dof else {"controllers": []}, "_det": det} diff --git a/opt/pincabos/installer-gui/i18n.json b/opt/pincabos/installer-gui/i18n.json index 76d2e56e..3b4d8dc0 100755 --- a/opt/pincabos/installer-gui/i18n.json +++ b/opt/pincabos/installer-gui/i18n.json @@ -214,7 +214,19 @@ "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." + "inputs_skip_ok": "Aucun bouton appuyé : vous pourrez le faire dans Map Commander.", + "toys_role": "Rôle", + "toys_role_output": "Sortie %n", + "toys_role_aucun": "— sans rôle —", + "toys_role_backboard": "Backboard", + "toys_role_sous-caisse": "Sous le meuble", + "toys_role_cote-gauche": "Côté gauche", + "toys_role_cote-droit": "Côté droit", + "toys_role_fronton": "Fronton", + "toys_role_flippers": "Flippers", + "toys_role_interieur": "Intérieur du meuble", + "toys_role_libre": "Libre", + "toys_role_hint": "Le rôle donne son nom à la sortie dans la configuration DOF. Sans lui, elle s'appellera « Ruban 1.2 » — exact, mais illisible le jour où vous relirez votre cabinet." }, "en": { "choose_lang": "Choose your language", @@ -431,7 +443,19 @@ "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." + "inputs_skip_ok": "No button pressed: you will be able to do it in Map Commander.", + "toys_role": "Role", + "toys_role_output": "Output %n", + "toys_role_aucun": "— no role —", + "toys_role_backboard": "Backboard", + "toys_role_sous-caisse": "Under the cabinet", + "toys_role_cote-gauche": "Left side", + "toys_role_cote-droit": "Right side", + "toys_role_fronton": "Backbox", + "toys_role_flippers": "Flippers", + "toys_role_interieur": "Inside the cabinet", + "toys_role_libre": "Spare", + "toys_role_hint": "The role names the output in the DOF configuration. Without it, it will be called “Ruban 1.2” — accurate, but unreadable the day you come back to your cabinet." }, "de": { "choose_lang": "Sprache auswählen", @@ -648,7 +672,19 @@ "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." + "inputs_skip_ok": "Keine Taste gedrückt: Sie können es in Map Commander nachholen.", + "toys_role": "Rolle", + "toys_role_output": "Ausgang %n", + "toys_role_aucun": "— ohne Rolle —", + "toys_role_backboard": "Backboard", + "toys_role_sous-caisse": "Unter dem Gehäuse", + "toys_role_cote-gauche": "Linke Seite", + "toys_role_cote-droit": "Rechte Seite", + "toys_role_fronton": "Backbox", + "toys_role_flippers": "Flipper", + "toys_role_interieur": "Gehäuseinneres", + "toys_role_libre": "Frei", + "toys_role_hint": "Die Rolle benennt den Ausgang in der DOF-Konfiguration. Ohne sie heißt er „Ruban 1.2“ — korrekt, aber unlesbar, wenn Sie Ihr Cabinet später wieder ansehen." }, "it": { "choose_lang": "Scegli la tua lingua", @@ -865,7 +901,19 @@ "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." + "inputs_skip_ok": "Nessun pulsante premuto: potrai farlo in Map Commander.", + "toys_role": "Ruolo", + "toys_role_output": "Uscita %n", + "toys_role_aucun": "— senza ruolo —", + "toys_role_backboard": "Backboard", + "toys_role_sous-caisse": "Sotto il mobile", + "toys_role_cote-gauche": "Lato sinistro", + "toys_role_cote-droit": "Lato destro", + "toys_role_fronton": "Backbox", + "toys_role_flippers": "Flipper", + "toys_role_interieur": "Interno del mobile", + "toys_role_libre": "Libera", + "toys_role_hint": "Il ruolo dà il nome all'uscita nella configurazione DOF. Senza, si chiamerà «Ruban 1.2» — esatto, ma illeggibile il giorno in cui rileggerai il tuo cabinato." }, "es": { "choose_lang": "Elige tu idioma", @@ -1082,6 +1130,18 @@ "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." + "inputs_skip_ok": "Ningún botón pulsado: podrás hacerlo en Map Commander.", + "toys_role": "Función", + "toys_role_output": "Salida %n", + "toys_role_aucun": "— sin función —", + "toys_role_backboard": "Backboard", + "toys_role_sous-caisse": "Bajo el mueble", + "toys_role_cote-gauche": "Lado izquierdo", + "toys_role_cote-droit": "Lado derecho", + "toys_role_fronton": "Backbox", + "toys_role_flippers": "Flippers", + "toys_role_interieur": "Interior del mueble", + "toys_role_libre": "Libre", + "toys_role_hint": "La función da nombre a la salida en la configuración DOF. Sin ella se llamará «Ruban 1.2» — exacto, pero ilegible el día que vuelvas a tu mueble." } } diff --git a/opt/pincabos/installer-gui/templates/wizard.html b/opt/pincabos/installer-gui/templates/wizard.html index ffee3b7f..2a7f98d9 100755 --- a/opt/pincabos/installer-gui/templates/wizard.html +++ b/opt/pincabos/installer-gui/templates/wizard.html @@ -651,6 +651,11 @@

if(!strips.length){box.innerHTML='
'+esc(t("toys_strips_none"))+'
';refreshToysStatus();return} box.innerHTML=S.toys.controllers.map((c,i)=>{const hw=strips.find(s=>s.serial===c.serial)||{}; const sel=(id,opts,val)=>''; + // PINCABOS_TOYS_ROLES_V1 : le rôle donne son nom au toy dans cabinet.xml. + // Sans rôle, on garde le nom générique d'avant (« Ruban 1.2 »). + const selRole=(k,val)=>''; const num=(k,val,min,max)=>''; return '
' +'
' @@ -659,10 +664,15 @@

+'
' +(c.mode==="matrice"?'
'+num("width",c.width,1,1024)+' × '+num("height",c.height,1,1024) +'= '+((+c.width)*(+c.height))+' LED
' - +'
'+sel("arrangement",d.arrangements||[],c.arrangement)+'
':'') + +'
'+sel("arrangement",d.arrangements||[],c.arrangement)+'
' + +'
'+selRole("role",c.role||"")+'
':'') +'
' +''+esc(t("toys_total"))+' '+stripsTotal(c)+'
' +'
'+esc(t(c.mode==="matrice"?"toys_outputs_hint_matrix":"toys_outputs_hint_strips"))+'
' + +(c.mode==="rubans"?(c.strips||[]).map((n,j)=>n>0 + ? '
'+selRole("role"+j,(c.roles||[])[j]||"") + +''+n+' LED
' + : "").join(""):"") +'
'+sel("color_order",d.color_orders||[],c.color_order) +''+num("brightness",c.brightness,1,100) +''+num("ledwiz_number",c.ledwiz_number,1,128)+'
':'') @@ -677,11 +687,18 @@

renderToys()}); document.getElementById("toys-strips").addEventListener("input",e=>{const el=e.target;if(!el.dataset.k)return;const c=toysCtrl(+el.dataset.i);S.toys.touched=true; if(el.dataset.k==="strips"){c.strips=el.value.split(/[\s,;]+/).filter(x=>x!=="").map(x=>Math.max(0,parseInt(x,10)||0)).slice(0,10)} + else if(/^role\d+$/.test(el.dataset.k)){const j=+el.dataset.k.slice(4); + c.roles=c.roles||[];while(c.roles.length<=j)c.roles.push("");c.roles[j]=el.value} else if(["width","height","brightness","ledwiz_number"].includes(el.dataset.k)){c[el.dataset.k]=parseInt(el.value,10)||0} else{c[el.dataset.k]=el.value} refreshToysStatus(); const tot=el.closest(".card");if(tot){const sp=tot.querySelectorAll(".hint");}}); -document.getElementById("toys-strips").addEventListener("change",e=>{if(e.target.dataset.k)renderToys()}); +document.getElementById("toys-strips").addEventListener("change",e=>{ + const el=e.target;if(!el.dataset.k)return;const c=toysCtrl(+el.dataset.i);S.toys.touched=true; + if(/^role\d+$/.test(el.dataset.k)){const j=+el.dataset.k.slice(4); + c.roles=c.roles||[];while(c.roles.length<=j)c.roles.push("");c.roles[j]=el.value} + else if(el.dataset.k==="role"){c.role=el.value} + renderToys()}); // PINCABOS_INSTALLEUR_RESEAU_V1 — étape Réseau : DHCP par défaut, IP fixe // proposée depuis le bail, Wi-Fi seulement si un matériel est présent. Chaque // application est immédiate dans la session (le résultat se voit) ; le réseau diff --git a/opt/pincabos/tests/test_toys_roles.py b/opt/pincabos/tests/test_toys_roles.py new file mode 100755 index 00000000..c2e73604 --- /dev/null +++ b/opt/pincabos/tests/test_toys_roles.py @@ -0,0 +1,142 @@ +"""Chaque sortie adressable a un rôle (PINCABOS_TOYS_ROLES_V1). + +Le nom du toy dans cabinet.xml était générique : « Backboard HD » pour la +première matrice, « Matrice 2 », « Ruban 1.2 »… Exact, mais illisible le jour +où l'on relit sa configuration — et c'est ce nom que DOF cherche ensuite. + +Le rôle (backboard, sous le meuble, côtés, fronton, flippers, intérieur) donne +son nom à la sortie. Il reste FACULTATIF : sans lui, le nom générique demeure, +donc rien ne change pour un cabinet déjà configuré. + +« backboard » garde volontairement « Backboard HD » : c'est le nom qu'ont les +cabinets en service, et le changer casserait leur configuration DOF. +""" +import importlib.util +import json +import unittest +from pathlib import Path + +from _charge import RACINE + +R = Path(RACINE) +OUTIL = R / "opt/pincabos/tools/pincabos_dof.py" +GUI = R / "opt/pincabos/installer-gui" +WIZARD = GUI / "templates/wizard.html" +I18N = GUI / "i18n.json" +LANGUES = ("fr", "en", "de", "it", "es") + +DETECTES = [{"dev": "/dev/ttyACM1", "serial": "TEENSY123", "auto_config": False, + "kind": "TeensyStripController (strip adressable)"}] + + +def _module(): + spec = importlib.util.spec_from_file_location("pincabos_dof", OUTIL) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + return m + + +class LeCatalogue(unittest.TestCase): + def setUp(self): + self.m = _module() + + def test_les_roles_attendus(self): + for r in ("backboard", "sous-caisse", "cote-gauche", "cote-droit", + "fronton", "flippers", "interieur", "libre"): + self.assertIn(r, self.m.ROLE_IDS) + + def test_backboard_garde_son_nom_historique(self): + # les cabinets en service ont « Backboard HD » dans leur cabinet.xml + self.assertEqual(self.m.nom_toy("backboard", "peu importe"), "Backboard HD") + + def test_sans_role_le_nom_generique_demeure(self): + self.assertEqual(self.m.nom_toy("", "Ruban 1.2"), "Ruban 1.2") + self.assertEqual(self.m.nom_toy("libre", "Ruban 1.2"), "Ruban 1.2") + + +class LaValidation(unittest.TestCase): + def setUp(self): + self.m = _module() + + def _ctrl(self, **kw): + c = {"serial": "TEENSY123", "enabled": True, "mode": "rubans", + "strips": [144, 144], "arrangement": "LeftRightTopDown", + "color_order": "GRB", "brightness": 25, "ledwiz_number": 30} + c.update(kw) + return {"controllers": [c]} + + def test_un_role_connu_passe(self): + erreurs, ok = self.m.valider_toys(self._ctrl(roles=["sous-caisse", "fronton"]), DETECTES) + self.assertEqual(erreurs, []) + self.assertEqual(ok["controllers"][0]["roles"], ["sous-caisse", "fronton"]) + + def test_un_role_inconnu_est_refuse(self): + erreurs, _ = self.m.valider_toys(self._ctrl(roles=["gyrophare"]), DETECTES) + self.assertTrue(any("rôle inconnu" in e for e in erreurs)) + + def test_le_role_reste_facultatif(self): + erreurs, ok = self.m.valider_toys(self._ctrl(), DETECTES) + self.assertEqual(erreurs, []) + self.assertEqual(ok["controllers"][0]["roles"], []) + self.assertEqual(ok["controllers"][0]["role"], "") + + def test_la_proposition_n_avance_aucun_role(self): + # cohérent avec PINCABOS_TOYS_SANS_SUPPOSITION_V1 + for c in self.m.proposer_toys(DETECTES)["controllers"]: + self.assertEqual(c["role"], "") + self.assertEqual(c["roles"], []) + + +class LesToysPortentLeNomDuRole(unittest.TestCase): + def setUp(self): + self.m = _module() + + def test_en_rubans_chaque_sortie_est_nommee(self): + choix = {"controllers": [{"serial": "TEENSY123", "enabled": True, "mode": "rubans", + "strips": [144, 0, 72], "roles": ["sous-caisse", "", "fronton"], + "arrangement": "LeftRightTopDown", "color_order": "GRB", + "brightness": 25, "ledwiz_number": 30}]} + _, ok = self.m.valider_toys(choix, DETECTES) + inv = self.m.inventaire_json(ok, DETECTES) + noms = [t["name"] for t in inv["devices"][0]["toys"]] + self.assertEqual(noms, ["Undercab", "Backbox"], + "la sortie vide ne produit pas de toy, les autres portent leur rôle") + + def test_une_sortie_sans_role_garde_le_nom_generique(self): + choix = {"controllers": [{"serial": "TEENSY123", "enabled": True, "mode": "rubans", + "strips": [144, 144], "roles": ["sous-caisse"], + "arrangement": "LeftRightTopDown", "color_order": "GRB", + "brightness": 25, "ledwiz_number": 30}]} + _, ok = self.m.valider_toys(choix, DETECTES) + noms = [t["name"] for t in self.m.inventaire_json(ok, DETECTES)["devices"][0]["toys"]] + self.assertEqual(noms, ["Undercab", "Ruban 1.2"]) + + def test_en_matrice_le_role_nomme_le_controleur(self): + choix = {"controllers": [{"serial": "TEENSY123", "enabled": True, "mode": "matrice", + "width": 144, "height": 16, "role": "fronton", + "strips": [512, 512, 512, 512, 256], + "arrangement": "TopDownAlternateLeftRight", "color_order": "GRB", + "brightness": 25, "ledwiz_number": 30}]} + _, ok = self.m.valider_toys(choix, DETECTES) + self.assertEqual(self.m.inventaire_json(ok, DETECTES)["devices"][0]["toy"]["name"], "Backbox") + + +class LAssistantLeDemande(unittest.TestCase): + def test_le_choix_est_dans_la_page(self): + h = WIZARD.read_text(encoding="utf-8") + self.assertIn("selRole", h) + self.assertIn('t("toys_role_"+r)', h) + self.assertIn("c.roles=c.roles||[]", h, "le tableau des rôles doit être alimenté") + + def test_l_api_expose_les_roles(self): + s = (GUI / "app.py").read_text(encoding="utf-8") + self.assertIn('"roles": list(pco_dof.ROLE_IDS)', s) + + def test_un_libelle_par_role_dans_les_cinq_langues(self): + d = json.loads(I18N.read_text(encoding="utf-8")) + m = _module() + 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 r in m.ROLE_IDS: + self.assertIn("toys_role_" + r, d[lang], "%s manque en %s" % (r, lang)) diff --git a/opt/pincabos/tools/pincabos_dof.py b/opt/pincabos/tools/pincabos_dof.py index 4cd867ab..1e954d4a 100644 --- a/opt/pincabos/tools/pincabos_dof.py +++ b/opt/pincabos/tools/pincabos_dof.py @@ -146,6 +146,30 @@ def charger(chemin: Path = CONFIG) -> dict: ORDRES_COULEUR = ("RGB", "RBG", "GRB", "GBR", "BRG", "BGR") MODES = ("matrice", "rubans") MAX_SORTIES = 10 + +# PINCABOS_TOYS_ROLES_V1 : à quoi sert chaque sortie adressable. Le rôle donne +# son nom au toy dans cabinet.xml — c'est ce nom que DOF cherche ensuite. Sans +# rôle, on retombe sur le nom générique d'avant (« Ruban 1.2 »), qui ne dit rien +# à personne quand il faut relire sa configuration six mois plus tard. +# « backboard » garde « Backboard HD » : c'est le nom qu'ont déjà les cabinets +# en service, et le changer casserait leur configuration DOF. +ROLES = ( + ("backboard", "Backboard HD"), + ("sous-caisse", "Undercab"), + ("cote-gauche", "Left Side"), + ("cote-droit", "Right Side"), + ("fronton", "Backbox"), + ("flippers", "Flippers"), + ("interieur", "Inside Cabinet"), + ("libre", ""), +) +ROLE_IDS = tuple(r for r, _ in ROLES) +ROLE_NOMS = dict(ROLES) + + +def nom_toy(role: str, defaut: str) -> str: + """Le nom que portera le toy : celui du rôle, ou le nom générique.""" + return ROLE_NOMS.get(str(role or ""), "") or defaut MAX_LEDS_SORTIE = 1100 @@ -206,7 +230,8 @@ def proposer_toys(detectes: list) -> dict: ctrls.append({"serial": c["serial"], "type": c["type"], "enabled": False, "ledwiz_number": 30 + i, "brightness": 25, "color_order": "GRB", "mode": "matrice", "width": 0, "height": 0, - "arrangement": "TopDownAlternateLeftRight", "strips": []}) + "arrangement": "TopDownAlternateLeftRight", "strips": [], + "role": "", "roles": []}) return {"controllers": ctrls} @@ -254,6 +279,21 @@ def valider_toys(choix, detectes: list) -> tuple[list, dict]: prop["arrangement"] = str(c.get("arrangement") or "TopDownAlternateLeftRight") if prop["arrangement"] not in ARRANGEMENTS: erreurs.append(f"contrôleur {i + 1} : arrangement inconnu") + # PINCABOS_TOYS_ROLES_V1 : le rôle est facultatif — sans lui on garde le + # nom générique. En matrice, un seul rôle pour tout le contrôleur ; en + # rubans, un par sortie, aligné sur strips. + prop["role"] = str(c.get("role") or "") + if prop["role"] and prop["role"] not in ROLE_IDS: + erreurs.append(f"contrôleur {i + 1} : rôle inconnu {prop['role']}") + roles_bruts = c.get("roles") if isinstance(c.get("roles"), list) else [] + roles_ok = [] + for r in roles_bruts[:MAX_SORTIES]: + r = str(r or "") + if r and r not in ROLE_IDS: + erreurs.append(f"contrôleur {i + 1} : rôle inconnu {r}") + break + roles_ok.append(r) + prop["roles"] = roles_ok if mode == "matrice": # PINCABOS_TOYS_SANS_SUPPOSITION_V1 : zero est desormais une reponse # valide — c est celle d un controleur qu on laisse eteint parce @@ -286,17 +326,21 @@ def inventaire_json(choix: dict, detectes: list) -> dict: "label": f"{t} {i + 1}", "enabled": bool(c["enabled"]), "serial": c["serial"], "com_port": "auto", "host": "", "leds_per_strip": (list(c["strips"]) + [0] * MAX_SORTIES)[:MAX_SORTIES], "ledwiz_number": c["ledwiz_number"]} + roles = list(c.get("roles") or []) if c["mode"] == "matrice": - d["toy"] = {"name": "Backboard HD" if i == 0 else f"Matrice {i + 1}", "width": c["width"], "height": c["height"], + d["toy"] = {"name": nom_toy(c.get("role"), "Backboard HD" if i == 0 else f"Matrice {i + 1}"), + "width": c["width"], "height": c["height"], "arrangement": c["arrangement"], "color_order": c["color_order"], "first_led": 1, "brightness": c["brightness"], "fading_curve": "Linear"} d["ledwiz_outputs"] = 9 else: toys, premier, k = [], 1, 0 - for s in c["strips"]: + for sortie, s in enumerate(c["strips"]): if s > 0: k += 1 - toys.append({"name": f"Ruban {i + 1}.{k}", "width": s, "height": 1, "arrangement": "LeftRightTopDown", + role = roles[sortie] if sortie < len(roles) else "" + toys.append({"name": nom_toy(role, f"Ruban {i + 1}.{k}"), + "width": s, "height": 1, "arrangement": "LeftRightTopDown", "color_order": c["color_order"], "first_led": premier, "brightness": c["brightness"], "fading_curve": "Linear"}) premier += s