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
12 changes: 12 additions & 0 deletions etc/systemd/system/pincabos-screen-hotplug.service
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,15 @@ StartLimitBurst=3
[Service]
Type=oneshot
ExecStart=/usr/local/libexec/pincabos/pincabos-screen-hotplug

[Install]
# PINCABOS_ROTATION_AU_BOOT_V1 : cette unite est la SEULE a appliquer
# playfield_rotation (xrandr --rotate, depuis screens.json). Elle n etait
# demarree que par la regle udev « ACTION==change, SUBSYSTEM==drm » : sur un
# demarrage normal, ecrans deja branches, les connecteurs sont enumeres en
# « add » et l evenement « change » ne vient jamais. La rotation choisie dans
# l installateur n etait donc jamais appliquee au reboot (remonte par Flo le
# 09/09/2026 : playfield tete en bas, corrige a la main depuis la WebApp).
# L ordre est deja prevu pour le boot (After=pincabos-screen-topology-boot,
# PINCABOS_TOPOLOGIE_VERROU_BOOT_V1) : il ne manquait que le declenchement.
WantedBy=graphical.target
15 changes: 12 additions & 3 deletions opt/pincabos/script/installer/pincabos-live-installer
Original file line number Diff line number Diff line change
Expand Up @@ -1097,9 +1097,13 @@ apply_target_dmd() {
install -o 1000 -g 1000 -m 0664 "$src" "$TARGET/opt/pincabos/config/zedmd.json"
install -d -m 0755 "$TARGET/opt/pincabos/flags"
date -Is > "$TARGET/opt/pincabos/flags/dmd-installer.pending"
if [ -x "$TARGET/opt/pincabos/tools/pincabos-zedmd" ]; then
chroot "$TARGET" runuser -u pinball -- /opt/pincabos/tools/pincabos-zedmd apply 2>&1 | sed 's/^/ /' || true
fi
# PINCABOS_ZEDMD_AU_PREMIER_DEMARRAGE_V1 : on n applique plus rien depuis le
# chroot. Un ZeDMD en Wi-Fi n y est pas joignable — pas de reseau final, pas
# de peripherique — et le « || true » avalait l echec en silence (remonte par
# Flo le 09/09/2026 : IP renseignee a l installation, ZeDMD inactif ensuite).
# Le drapeau ci-dessus suffit : pincabos-dmd-installer.service rejoue
# l application au premier demarrage et CONSERVE le drapeau tant qu elle
# echoue, donc il retente aux demarrages suivants.
pco_go "zedmd.json installed (mode: $(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("mode","off"))' "$src" 2>/dev/null || echo '?'))"
}

Expand Down Expand Up @@ -1240,6 +1244,11 @@ apply_target_ownership() {
/opt/pincabos/config /opt/pincabos/logs /opt/pincabos/backups \
/opt/pincabos/state /opt/pincabos/download /opt/pincabos/tmp \
/opt/pincabos/uploads /opt/pincabos/cache /opt/pinball 2>/dev/null || true
# PINCABOS_CIBLE_DROITS_V2 : le compte du joueur aussi. Remonte par Patrick le
# 09/09/2026 : ~/.local/share/VPinballX appartenait a root, le lanceur VPX
# echouait a migrer le dossier de preferences (« mv : Permission denied ») et
# mourait sur son set -e. Aucune table ne demarrait, sans un mot d erreur.
chroot "$TARGET" chown -R pinball:pinball /home/pinball 2>/dev/null || true
pco_go "Droits du compte joueur appliques"
}

Expand Down
26 changes: 20 additions & 6 deletions opt/pincabos/scripts/VPXlauncher.pincabos-original.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,17 +83,31 @@ fi
# Un symlink de compatibilité conserve l ancien chemin pour tous les lecteurs.
VPX_PREF_DIR="$PCO_VPX_PREF"
VPX_LEGACY_PREF="$PCO_VPX_LEGACY_PREF"
# PINCABOS_VPX_PREFPATH_V2 : la migration ne doit JAMAIS empecher de jouer.
# Remonte par Patrick le 09/09/2026, sur une installation neuve :
# mv: cannot move '/home/pinball/.local/share/VPinballX/10.8'
# to '/home/pinball/.pincabos/vpx': Permission denied
# Le dossier appartenait a root ; renommer demande le droit d ecriture sur le
# PARENT. Le mv echouait, `set -e` tuait le lanceur, VPX ne demarrait pas — et
# rien ne le disait : VPinFE se contentait de reafficher le menu.
# Les droits sont corriges a l installation (PINCABOS_CIBLE_DROITS_V2), mais le
# lanceur doit rester jouable meme quand la migration est impossible : on garde
# alors l ancien dossier, en le disant.
if [[ ! -e "${VPX_PREF_DIR}" ]]; then
mkdir -p "$(dirname "${VPX_PREF_DIR}")"
mkdir -p "$(dirname "${VPX_PREF_DIR}")" 2>/dev/null || true
if [[ -d "${VPX_LEGACY_PREF}" && ! -L "${VPX_LEGACY_PREF}" ]]; then
mv "${VPX_LEGACY_PREF}" "${VPX_PREF_DIR}"
else
mkdir -p "${VPX_PREF_DIR}"
if ! mv "${VPX_LEGACY_PREF}" "${VPX_PREF_DIR}" 2>/dev/null; then
echo "PINCABOS [PREFS] Migration impossible (droits ?) : on continue avec ${VPX_LEGACY_PREF}." >&2
VPX_PREF_DIR="${VPX_LEGACY_PREF}"
fi
elif ! mkdir -p "${VPX_PREF_DIR}" 2>/dev/null; then
echo "PINCABOS [PREFS] ${VPX_PREF_DIR} increable : on continue avec ${VPX_LEGACY_PREF}." >&2
VPX_PREF_DIR="${VPX_LEGACY_PREF}"
fi
fi
if [[ ! -e "${VPX_LEGACY_PREF}" ]]; then
mkdir -p "$(dirname "${VPX_LEGACY_PREF}")"
ln -sn "${VPX_PREF_DIR}" "${VPX_LEGACY_PREF}"
mkdir -p "$(dirname "${VPX_LEGACY_PREF}")" 2>/dev/null || true
ln -sn "${VPX_PREF_DIR}" "${VPX_LEGACY_PREF}" 2>/dev/null || true
fi
chown -h "$PINBALL_USER:$PINBALL_USER" "${VPX_LEGACY_PREF}" 2>/dev/null || true
chown "$PINBALL_USER:$PINBALL_USER" "$(dirname "${VPX_PREF_DIR}")" "${VPX_PREF_DIR}" 2>/dev/null || true
Expand Down
11 changes: 11 additions & 0 deletions opt/pincabos/scripts/VPXlauncher.real.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,24 @@ run_with_front_windows() {
"$PLACER" --place >/dev/null 2>&1 &
fi
local rc=0
local DEBUT=$SECONDS
set +e
"$REAL" "$@"
rc=$?
set -e
if [[ -x "$PLACER" ]]; then
"$PLACER" --restore >/dev/null 2>&1 || true
fi
# PINCABOS_LANCEUR_SORTIE_PARLANTE_V1 : seul le code 139 (SIGSEGV) etait
# traite ; tout autre echec repartait en silence. Le 09/09/2026, VPX rendait
# la main en moins d une seconde chez Patrick et le journal du lanceur ne
# montrait que « Lancement Original direct » puis « frontend reactive » a la
# meme seconde — aucune trace de la cause. Un retour immediat et non nul est
# anormal : on le dit, avec le code.
if [[ "$rc" -ne 0 ]] && (( SECONDS - DEBUT < 5 )); then
echo "PINCABOS [LANCEUR] VPX a rendu la main en $(( SECONDS - DEBUT ))s avec le code $rc." >&2
echo "PINCABOS [LANCEUR] Table : ${TABLE:-inconnue}" >&2
fi
# PINCABOS_VULKAN_SEGFAULT_FALLBACK_V1 : 139 = tue par le signal 11
if [[ "$rc" -eq 139 && -n "${TABLE:-}" && "${PINCABOS_BACKEND_RETRY:-0}" != "1" && -x "$BACKEND_FALLBACK" ]]; then
if "$BACKEND_FALLBACK" "$TABLE"; then
Expand Down
24 changes: 17 additions & 7 deletions opt/pincabos/tests/test_dof_port_famille.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
partager la meme recherche.
"""
import importlib.util
import types
import unittest
from pathlib import Path

Expand All @@ -45,12 +46,23 @@ def _module():
}


def _poser_bus(m, bus):
"""Fait croire au module qu il voit ce bus USB.

On remplace l ATTRIBUT `glob` du module par un objet a nous. Surtout pas
`m.glob.glob = ...` : `m.glob` est le module glob lui-meme, partage par tout
le processus — le remplacer casse silencieusement tous les tests suivants qui
listent des fichiers (deux echecs a retardement, 09/09/2026).
"""
m._udev = lambda dev: bus.get(dev, {})
m.glob = types.SimpleNamespace(
glob=lambda motif: sorted(d for d in bus if d.startswith(motif.rstrip("*"))))


class PortsDistincts(unittest.TestCase):
def setUp(self):
self.m = _module()
self.m._udev = lambda dev: FAUX_BUS.get(dev, {})
self.m.glob.glob = lambda motif: sorted(
d for d in FAUX_BUS if d.startswith(motif.rstrip("*")))
_poser_bus(self.m, FAUX_BUS)

def test_la_wemos_ne_prend_pas_le_port_de_la_teensy(self):
strips = [{"controller": "TeensyStripController", "name": "Teensy 1"},
Expand All @@ -68,7 +80,7 @@ def test_l_ordre_de_declaration_ne_change_rien(self):
self.assertEqual(ports, ["/dev/ttyUSB0", "/dev/ttyACM1"])

def test_sans_materiel_le_repli_reste_dans_la_bonne_famille(self):
self.m.glob.glob = lambda motif: []
_poser_bus(self.m, {})
strips = [{"controller": "WemosD1MPStripController", "name": "Wemos 1"},
{"controller": "TeensyStripController", "name": "Teensy 2"}]
ports = [r["com_port"] for r in self.m._resoudre_ports(strips)]
Expand All @@ -84,9 +96,7 @@ def test_deux_cartes_de_la_meme_famille_ne_se_marchent_pas_dessus(self):
bus = dict(FAUX_BUS)
bus["/dev/ttyUSB1"] = {"ID_VENDOR_ID": "10c4", "ID_SERIAL": "cp210x",
"ID_MODEL": "CP2102", "ID_SERIAL_SHORT": ""}
self.m._udev = lambda dev: bus.get(dev, {})
self.m.glob.glob = lambda motif: sorted(
d for d in bus if d.startswith(motif.rstrip("*")))
_poser_bus(self.m, bus)
strips = [{"controller": "WemosD1MPStripController", "name": "W1"},
{"controller": "WemosD1MPStripController", "name": "W2"}]
ports = [r["com_port"] for r in self.m._resoudre_ports(strips)]
Expand Down
127 changes: 127 additions & 0 deletions opt/pincabos/tests/test_installation_atteint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Ce que l'installateur pose doit atteindre le systeme qui tourne.

Trois remontees du 09/09/2026, meme famille :

Flo « Reglage ecran ok pendant l'installation mais apres reboot sur le dd
ca n'a pas suivi » — playfield tete en bas, refait a la main depuis
la WebApp.
Flo « J'ai fait la config IP de mon zedmd pendant l'installation mais il
n'est pas actif sous vpinfe. »
Patrick « Ca semble vouloir se lancer, l'affichage s'estompe, une animation de
chargement, et ca revient sur l'interface. »

Trois causes distinctes, toutes du meme genre : le choix est bien enregistre,
mais personne ne le rejoue la ou il compte.
"""
import re
import unittest
from pathlib import Path

from _charge import RACINE

R = Path(RACINE)
HOTPLUG = R / "etc/systemd/system/pincabos-screen-hotplug.service"
LIEN = R / "etc/systemd/system/graphical.target.wants/pincabos-screen-hotplug.service"
INSTALLEUR = R / "opt/pincabos/script/installer/pincabos-live-installer"
LANCEUR = R / "opt/pincabos/scripts/VPXlauncher.pincabos-original.sh"
REEL = R / "opt/pincabos/scripts/VPXlauncher.real.sh"


class RotationAppliqueeAuDemarrage(unittest.TestCase):
"""La rotation du playfield vient de screens.json et n est posee que par
xrandr, dans pincabos-screen-hotplug. Cette unite n etait demarree que par
une regle udev « change » sur drm — qui ne survient pas quand les ecrans
sont deja branches au demarrage."""

def test_l_unite_est_demarree_au_boot(self):
s = HOTPLUG.read_text(encoding="utf-8")
self.assertIn("[Install]", s, "sans [Install], aucune cible ne la demarre")
self.assertRegex(s, r"(?m)^WantedBy=graphical\.target$")

def test_le_lien_d_activation_existe(self):
self.assertTrue(LIEN.is_symlink(), "le lien graphical.target.wants manque")

def test_l_ordre_du_boot_est_conserve(self):
# PINCABOS_TOPOLOGIE_VERROU_BOOT_V1 : la topologie de demarrage passe
# d abord, sinon le verrou bloquait le frontend (regression de la 4.32).
s = HOTPLUG.read_text(encoding="utf-8")
self.assertRegex(s, r"(?m)^After=.*pincabos-screen-topology-boot\.service")

def test_c_est_toujours_le_hotplug_qui_tourne_les_ecrans(self):
# si un jour la rotation demenage, ce test doit etre revu sciemment
code = (R / "usr/local/libexec/pincabos/pincabos-screen-hotplug").read_text(encoding="utf-8")
self.assertIn("--rotate", code)
self.assertIn("screens.json", code)


class ZedmdRejoueLaOuIlEstJoignable(unittest.TestCase):
"""Un ZeDMD en Wi-Fi n est pas joignable depuis le chroot du media : pas de
reseau final, pas de peripherique. L application appartient au premier
demarrage du cabinet."""

def test_plus_d_application_dans_le_chroot(self):
s = INSTALLEUR.read_text(encoding="utf-8")
self.assertNotIn("pincabos-zedmd apply", s,
"l installateur ne doit plus appliquer le ZeDMD depuis le chroot")

def test_le_drapeau_de_rejeu_reste_pose(self):
# c est lui qui declenche pincabos-dmd-installer.service au boot, et ce
# service CONSERVE le drapeau tant que l application echoue : il retente.
s = INSTALLEUR.read_text(encoding="utf-8")
self.assertIn("flags/dmd-installer.pending", s)

def test_le_service_de_rejeu_est_active(self):
self.assertTrue((R / "etc/systemd/system/multi-user.target.wants"
/ "pincabos-dmd-installer.service").exists())


class LeCompteDuJoueurLuiAppartient(unittest.TestCase):
"""PINCABOS_CIBLE_DROITS_V2 — le chown s arretait a /opt."""

def test_home_pinball_est_dans_le_chown(self):
corps = INSTALLEUR.read_text(encoding="utf-8") \
.split("apply_target_ownership() {", 1)[1].split("\n}", 1)[0]
self.assertRegex(corps, r"chown -R pinball:pinball /home/pinball\b",
"sans lui, VPX ne peut pas migrer son dossier de preferences")


class LaMigrationDesPrefsNeTuePasLeLanceur(unittest.TestCase):
"""Chez Patrick :

mv: cannot move '/home/pinball/.local/share/VPinballX/10.8'
to '/home/pinball/.pincabos/vpx': Permission denied

Renommer un dossier demande le droit d ecriture sur le PARENT. Le mv
echouait, `set -Eeuo pipefail` tuait le lanceur, VPX ne demarrait pas — et
VPinFE se contentait de reafficher son menu."""

def test_le_mv_est_garde(self):
s = LANCEUR.read_text(encoding="utf-8")
bloc = s.split("PINCABOS_VPX_PREFPATH_V2", 1)[1][:1500]
self.assertRegex(bloc, r'if ! mv "\$\{VPX_LEGACY_PREF\}" "\$\{VPX_PREF_DIR\}"',
"le mv doit etre teste, pas laisse tuer le script")

def test_le_repli_garde_l_ancien_dossier(self):
bloc = LANCEUR.read_text(encoding="utf-8").split("PINCABOS_VPX_PREFPATH_V2", 1)[1][:1500]
self.assertIn('VPX_PREF_DIR="${VPX_LEGACY_PREF}"', bloc,
"quand la migration echoue, on doit continuer avec l ancien dossier")

def test_le_set_e_est_toujours_la(self):
# on ne corrige pas en desarmant le garde-fou du script entier
self.assertRegex(LANCEUR.read_text(encoding="utf-8"), r"(?m)^set -Eeuo pipefail$")


class LeLanceurDitPourquoiVpxEstParti(unittest.TestCase):
"""Seul le code 139 (SIGSEGV) etait traite. Un retour immediat et non nul
repartait en silence : le journal ne montrait que « Lancement Original
direct » puis « frontend reactive », a la meme seconde."""

def test_une_sortie_rapide_et_non_nulle_est_journalisee(self):
s = REEL.read_text(encoding="utf-8")
self.assertIn("PINCABOS_LANCEUR_SORTIE_PARLANTE_V1", s)
self.assertRegex(s, r'\$rc" -ne 0')
self.assertIn("SECONDS - DEBUT", s)

def test_le_repli_opengl_sur_139_est_conserve(self):
s = REEL.read_text(encoding="utf-8")
self.assertIn('"$rc" -eq 139', s, "le repli OpenGL ne doit pas disparaitre")
9 changes: 8 additions & 1 deletion opt/pincabos/tests/test_installer_dmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,14 @@ def test_iso_sh(self):
self.assertIn(" apply_target_network\n apply_target_dmd\n", s)
self.assertIn('"$TARGET/opt/pincabos/config/zedmd.json"', s)
self.assertIn("dmd-installer.pending", s)
self.assertIn("runuser -u pinball -- /opt/pincabos/tools/pincabos-zedmd apply", s)
# PINCABOS_ZEDMD_AU_PREMIER_DEMARRAGE_V1 : l installateur n applique plus
# rien depuis le chroot. Un ZeDMD en Wi-Fi n y est pas joignable — pas de
# reseau final, pas de peripherique — et le « || true » avalait l echec
# (remonte par Flo le 09/09/2026 : IP renseignee, ZeDMD inactif ensuite).
# Le drapeau suffit : pincabos-dmd-installer.service rejoue au premier
# demarrage et CONSERVE le drapeau tant que l application echoue.
self.assertNotIn("pincabos-zedmd apply", s,
"plus d application ZeDMD depuis le chroot du media")

def test_premier_demarrage(self):
u = (R / "etc/systemd/system/pincabos-dmd-installer.service").read_text(encoding="utf-8")
Expand Down
Loading