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
3 changes: 3 additions & 0 deletions scripts/kiosk/90-carwatch-kiosk.rules
Original file line number Diff line number Diff line change
@@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Trigger the kiosk on every panel reconnection

After the kiosk exits on an unplug, the DRM device remains active and tagged, so reconnecting the same persistent connector produces another change event but no new device activation. The systemd.device(5) documentation states that SYSTEMD_WANTS dependencies are acted upon only when a device first becomes active; consequently, later reconnects will leave the kiosk stopped indefinitely. Toggle SYSTEMD_READY with the connection state or use a mechanism that explicitly starts the service on each connected hotplug.

Useful? React with 馃憤聽/ 馃憥.

4 changes: 4 additions & 0 deletions scripts/kiosk/carwatch-kiosk.service
Original file line number Diff line number Diff line change
Expand Up @@ -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
46 changes: 44 additions & 2 deletions scripts/kiosk/carwatch-kiosk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}/"
Expand Down Expand Up @@ -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"
2 changes: 2 additions & 0 deletions scripts/kiosk/install-kiosk-ubuntu.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions scripts/kiosk/install-kiosk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 54 additions & 4 deletions tests/test_kiosk_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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

Expand All @@ -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()
Loading