diff --git a/scripts/kiosk/90-carwatch-kiosk.rules b/scripts/kiosk/90-carwatch-kiosk.rules new file mode 100644 index 0000000..0ebf746 --- /dev/null +++ b/scripts/kiosk/90-carwatch-kiosk.rules @@ -0,0 +1,3 @@ +# Start the CarWatch kiosk when a display is plugged in (DRM hotplug event). +# Installed by install-kiosk*.sh with the kiosk user substituted for KUSER. +SUBSYSTEM=="drm", ACTION=="change", ENV{HOTPLUG}=="1", TAG+="systemd", ENV{SYSTEMD_WANTS}+="carwatch-kiosk@KUSER.service" diff --git a/scripts/kiosk/carwatch-kiosk.service b/scripts/kiosk/carwatch-kiosk.service index e594487..022687d 100644 --- a/scripts/kiosk/carwatch-kiosk.service +++ b/scripts/kiosk/carwatch-kiosk.service @@ -20,6 +20,10 @@ Environment=XDG_SESSION_TYPE=wayland ExecStart=/usr/local/bin/carwatch-kiosk.sh Restart=always RestartSec=3 +# 78 = the launcher found no connected panel: stay down until udev +# (90-carwatch-kiosk.rules) starts us on hotplug, instead of a 3 s loop. +RestartPreventExitStatus=78 +SuccessExitStatus=78 [Install] WantedBy=graphical.target diff --git a/scripts/kiosk/carwatch-kiosk.sh b/scripts/kiosk/carwatch-kiosk.sh index 8b3b0f9..54b5176 100755 --- a/scripts/kiosk/carwatch-kiosk.sh +++ b/scripts/kiosk/carwatch-kiosk.sh @@ -17,7 +17,35 @@ # CARWATCH_KIOSK_TRIES / CARWATCH_KIOSK_SLEEP readiness attempts (120) and # seconds between them (2) # CARWATCH_DASH_TOKEN_FILE, CARWATCH_DASH_URL +# CARWATCH_DRM_DIR where connector status lives (default /sys/class/drm; +# tests point it at a fake tree) +# CARWATCH_PANEL_POLL seconds between panel checks while the browser runs (5; +# also bounds how fast a crashed browser is noticed) +# +# No panel, no browser (petrus, 15 Sep 2026: the VTA's display had moved to +# the Pi, and the kiosk kept drawing the dash for nobody: one full core, 80 C, +# fans, for four hours). If no DRM connector reports "connected" the script +# exits 78 (EX_CONFIG), which the unit lists in RestartPreventExitStatus, so +# systemd leaves it alone; the udev rule installed next to the unit starts it +# again the moment a panel is plugged in. While the browser runs, the panel is +# re-checked every CARWATCH_PANEL_POLL seconds and the browser is stopped when +# it goes away. set -u +NO_PANEL_EXIT=78 +DRM_DIR="${CARWATCH_DRM_DIR:-/sys/class/drm}" +PANEL_POLL="${CARWATCH_PANEL_POLL:-5}" +panel_connected() { + local f + for f in "$DRM_DIR"/card*-*/status; do + [ -r "$f" ] || continue + [ "$(cat "$f" 2>/dev/null)" = "connected" ] && return 0 + done + return 1 +} +if ! panel_connected; then + echo "carwatch-kiosk: no display connected, not starting the browser" >&2 + exit "$NO_PANEL_EXIT" +fi TOKEN_FILE="${CARWATCH_DASH_TOKEN_FILE:-$HOME/.carwatch/dash-token}" DASH_URL="${CARWATCH_DASH_URL:-http://127.0.0.1:8088/dash}" HEALTH_URL="${DASH_URL%/dash}/" @@ -49,9 +77,23 @@ URL="$DASH_URL" mkdir -p "$PROFILE" 2>/dev/null || true export XDG_SESSION_TYPE=wayland export WLR_LIBINPUT_NO_DEVICES=1 -exec "$CAGE" -d -- "$CHROMIUM" \ +"$CAGE" -d -- "$CHROMIUM" \ --kiosk --ozone-platform=wayland --no-first-run --noerrdialogs \ --disable-infobars --disable-session-crashed-bubble --disable-pinch \ --overscroll-history-navigation=0 --check-for-update-interval=31536000 \ --password-store=basic --user-data-dir="$PROFILE" \ - "$URL" + "$URL" & +BROWSER=$! +trap 'kill "$BROWSER" 2>/dev/null' TERM INT +# Watch the panel while the browser runs: unplugged panel = stop drawing. +while kill -0 "$BROWSER" 2>/dev/null; do + if ! panel_connected; then + echo "carwatch-kiosk: display disconnected, stopping the browser" >&2 + kill "$BROWSER" 2>/dev/null + wait "$BROWSER" 2>/dev/null + exit "$NO_PANEL_EXIT" + fi + sleep "$PANEL_POLL" & + wait $! 2>/dev/null +done +wait "$BROWSER" diff --git a/scripts/kiosk/install-kiosk-ubuntu.sh b/scripts/kiosk/install-kiosk-ubuntu.sh index 9f0b0aa..eb0fa53 100755 --- a/scripts/kiosk/install-kiosk-ubuntu.sh +++ b/scripts/kiosk/install-kiosk-ubuntu.sh @@ -17,6 +17,8 @@ cat > /etc/systemd/system/carwatch-kiosk@.service.d/snap-chromium.conf <<'CONF' Environment=CARWATCH_CHROMIUM=/snap/bin/chromium CONF install -m 644 "$HERE/carwatch-kiosk.service" /etc/systemd/system/carwatch-kiosk@.service +sed "s/KUSER/${KUSER}/g" "$HERE/90-carwatch-kiosk.rules" > /etc/udev/rules.d/90-carwatch-kiosk.rules +udevadm control --reload-rules 2>/dev/null || true usermod -aG video,input,render "$KUSER" 2>/dev/null || true # keep an existing tty1 text console, moved to tty2 (Ctrl+Alt+F2) if systemctl cat vta-console.service >/dev/null 2>&1; then diff --git a/scripts/kiosk/install-kiosk.sh b/scripts/kiosk/install-kiosk.sh index 820a4ad..2bcf44e 100755 --- a/scripts/kiosk/install-kiosk.sh +++ b/scripts/kiosk/install-kiosk.sh @@ -9,6 +9,8 @@ apt-get update -q apt-get install -y -q --no-install-recommends cage chromium seatd grim fonts-noto-color-emoji install -m 755 "$HERE/carwatch-kiosk.sh" /usr/local/bin/carwatch-kiosk.sh install -m 644 "$HERE/carwatch-kiosk.service" /etc/systemd/system/carwatch-kiosk@.service +sed "s/KUSER/${KUSER}/g" "$HERE/90-carwatch-kiosk.rules" > /etc/udev/rules.d/90-carwatch-kiosk.rules +udevadm control --reload-rules 2>/dev/null || true usermod -aG video,input,render "$KUSER" 2>/dev/null || true systemctl daemon-reload systemctl disable --now getty@tty1.service || true diff --git a/tests/test_kiosk_launcher.py b/tests/test_kiosk_launcher.py index 9ac0d9f..0aeb6b4 100644 --- a/tests/test_kiosk_launcher.py +++ b/tests/test_kiosk_launcher.py @@ -14,6 +14,14 @@ def _stub(dirpath: Path, name: str, body: str) -> None: p.chmod(p.stat().st_mode | stat.S_IEXEC) +def _drm(tmp: Path, status: str = "connected") -> Path: + """A fake /sys/class/drm with one HDMI connector reporting [status].""" + d = tmp / "drm" / "card0-HDMI-A-1" + d.mkdir(parents=True, exist_ok=True) + (d / "status").write_text(status + "\n") + return tmp / "drm" + + class KioskLauncherTest(unittest.TestCase): def _run(self, curl_ok: bool): tmp = Path(tempfile.mkdtemp()) @@ -24,8 +32,9 @@ def _run(self, curl_ok: bool): cage_log = tmp / "cage.log" _stub(binp, "cage", f'echo "$@" > "{cage_log}"; exit 0') env = dict(os.environ, HOME=str(home), PATH=f"{binp}:{os.environ['PATH']}", - CARWATCH_KIOSK_TRIES="3", CARWATCH_KIOSK_SLEEP="0", - CARWATCH_CAGE=str(binp / "cage"), CARWATCH_CHROMIUM="/usr/bin/chromium") + CARWATCH_KIOSK_TRIES="3", CARWATCH_KIOSK_SLEEP="0", CARWATCH_PANEL_POLL="1", + CARWATCH_CAGE=str(binp / "cage"), CARWATCH_CHROMIUM="/usr/bin/chromium", + CARWATCH_DRM_DIR=str(_drm(tmp))) res = subprocess.run(["bash", str(SCRIPT)], env=env, capture_output=True, text=True, timeout=30) return res, cage_log, home @@ -48,12 +57,53 @@ def test_snap_chromium_uses_snap_writable_profile(self): binp = tmp / "bin"; binp.mkdir(); _stub(binp, "curl", "exit 0") cage_log = tmp / "cage.log"; _stub(binp, "cage", f'echo "$@" > "{cage_log}"; exit 0') env = dict(os.environ, HOME=str(home), PATH=f"{binp}:{os.environ['PATH']}", - CARWATCH_KIOSK_TRIES="1", CARWATCH_KIOSK_SLEEP="0", - CARWATCH_CAGE=str(binp / "cage"), CARWATCH_CHROMIUM="/snap/bin/chromium") + CARWATCH_KIOSK_TRIES="1", CARWATCH_KIOSK_SLEEP="0", CARWATCH_PANEL_POLL="1", + CARWATCH_CAGE=str(binp / "cage"), CARWATCH_CHROMIUM="/snap/bin/chromium", + CARWATCH_DRM_DIR=str(_drm(tmp))) res = subprocess.run(["bash", str(SCRIPT)], env=env, capture_output=True, text=True, timeout=30) self.assertEqual(res.returncode, 0, res.stderr) self.assertIn(f"--user-data-dir={home}/snap/chromium/common/carwatch-kiosk", cage_log.read_text()) + # petrus, 15 Sep 2026: the VTA's panel had moved to the Pi and the kiosk + # kept drawing the dash for nobody (one core, 80 C, four hours). + def test_no_panel_means_no_browser_and_exit_78(self): + tmp = Path(tempfile.mkdtemp()); home = tmp / "home"; (home / ".carwatch").mkdir(parents=True) + binp = tmp / "bin"; binp.mkdir(); _stub(binp, "curl", "exit 0") + cage_log = tmp / "cage.log"; _stub(binp, "cage", f'echo "$@" > "{cage_log}"; exit 0') + env = dict(os.environ, HOME=str(home), PATH=f"{binp}:{os.environ['PATH']}", + CARWATCH_KIOSK_TRIES="1", CARWATCH_KIOSK_SLEEP="0", CARWATCH_PANEL_POLL="1", + CARWATCH_CAGE=str(binp / "cage"), CARWATCH_CHROMIUM="/usr/bin/chromium", + CARWATCH_DRM_DIR=str(_drm(tmp, "disconnected"))) + res = subprocess.run(["bash", str(SCRIPT)], env=env, capture_output=True, text=True, timeout=30) + self.assertEqual(res.returncode, 78, res.stderr) + self.assertFalse(cage_log.exists(), "browser started with no panel connected") + self.assertIn("no display connected", res.stderr) + + def test_browser_stopped_when_panel_unplugged(self): + tmp = Path(tempfile.mkdtemp()); home = tmp / "home"; (home / ".carwatch").mkdir(parents=True) + binp = tmp / "bin"; binp.mkdir(); _stub(binp, "curl", "exit 0") + started = tmp / "started" + # a browser that runs until killed + _stub(binp, "cage", f'touch "{started}"; trap "exit 0" TERM; while :; do sleep 1; done') + drm = _drm(tmp, "connected") + env = dict(os.environ, HOME=str(home), PATH=f"{binp}:{os.environ['PATH']}", + CARWATCH_KIOSK_TRIES="1", CARWATCH_KIOSK_SLEEP="0", CARWATCH_PANEL_POLL="1", + CARWATCH_CAGE=str(binp / "cage"), CARWATCH_CHROMIUM="/usr/bin/chromium", + CARWATCH_DRM_DIR=str(drm)) + proc = subprocess.Popen(["bash", str(SCRIPT)], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + import time + for _ in range(50): + if started.exists(): break + time.sleep(0.1) + self.assertTrue(started.exists(), "browser never started with a panel connected") + (drm / "card0-HDMI-A-1" / "status").write_text("disconnected\n") + try: + _, err = proc.communicate(timeout=15) + except subprocess.TimeoutExpired: + proc.kill(); self.fail("launcher kept running after the panel was unplugged") + self.assertEqual(proc.returncode, 78, err) + self.assertIn("display disconnected", err) + if __name__ == "__main__": unittest.main()