VoxIP Kiosk OS 1.0.0: Raspberry Pi kiosk with web management
CI / test-bookworm (push) Failing after 11s
CI / test-latest (push) Successful in 1m27s

Chromium kiosk without a desktop for the Raspberry Pi 3, a Flask web
interface on port 8080 (kiosk, system and network management), a
restricted root helper, network changes with automatic rollback, a
healthcheck timer, install/update/uninstall scripts and a pi-gen image
build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rothmanandClaude Opus 5.5 committed 2026-11-04 21:38:49 +01:00
commit a33c4822bf
96 files changed
+7535

No files matched your search

+275
View File
@@ -0,0 +1,275 @@
"""voxip-kiosk-agent — keeps the kiosk page where it should be.
Runs as user ``kiosk`` and steers Chromium through the DevTools Protocol on
127.0.0.1:
* shows the setup page (with setup code) until an admin password exists;
* leaves the local start page / Chromium error page for the kiosk URL as
soon as that URL is reachable, and shows the offline page meanwhile;
* follows URL changes from the web interface without restarting Chromium;
* reloads periodically when ``refresh_interval`` is set — but never while
the URL is unreachable, so a page that works offline keeps working;
* reloads a hung renderer, and closes the browser (systemd restarts it) if
that does not help.
"""
from __future__ import annotations
import json
import logging
import signal
import socket
import ssl
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Callable
from urllib.parse import urlencode, urlsplit
from . import logutil, sysinfo
from .cdp import CDP, CDPError
from .config import load_config
from .paths import Paths, load_paths
from .validation import mask_url
log = logging.getLogger("voxip-kiosk-agent")
TICK_SECONDS = 5
PROBE_MAX_AGE_LOADING = 10
PROBE_MAX_AGE_ONLINE = 60
HUNG_RELOAD_AFTER = 3
HUNG_RESTART_AFTER = 6
BLANK_URLS = {"", "about:blank", "chrome://newtab/", "chrome://new-tab-page/"}
def probe(url: str, timeout: float = 8.0) -> tuple[bool, str]:
"""Is ``url`` reachable over HTTPS with a valid certificate? → (ok, reason)."""
req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "VoxIP-Kiosk-OS/agent"})
try:
with urllib.request.urlopen(req, timeout=timeout, context=ssl.create_default_context()):
return True, ""
except urllib.error.HTTPError as exc:
if exc.code >= 500:
return False, f"Serverfout {exc.code}"
return True, ""
except urllib.error.URLError as exc:
return False, _reason(exc.reason)
except (OSError, ValueError) as exc:
return False, _reason(exc)
def _reason(exc: Any) -> str:
if isinstance(exc, ssl.SSLCertVerificationError) or "CERTIFICATE_VERIFY_FAILED" in str(exc):
return "Certificaat ongeldig"
if isinstance(exc, ssl.SSLError):
return "TLS-fout"
if isinstance(exc, socket.gaierror):
return "Naam niet gevonden (DNS)"
if isinstance(exc, (socket.timeout, TimeoutError)):
return "Time-out"
if isinstance(exc, ConnectionRefusedError):
return "Verbinding geweigerd"
if isinstance(exc, OSError) and exc.errno in (101, 113):
return "Geen netwerkverbinding"
return "Niet bereikbaar"
class Agent:
def __init__(self, paths: Paths, cdp: Any = None, probe_fn: Callable[[str], tuple[bool, str]] = probe,
clock: Callable[[], float] = time.monotonic, wall: Callable[[], float] = time.time,
hostname: Callable[[], str] = lambda: socket.gethostname().split(".")[0], addresses: Callable[[], dict] = sysinfo.ip_addresses):
self.paths = paths
self.cdp = cdp or CDP()
self.probe_fn = probe_fn
self.clock = clock
self.wall = wall
self.hostname = hostname
self.addresses = addresses
self.applied_url: str | None = None
self.last_load: float | None = None
self.hung = 0
self.offline_since: float | None = None
self.refresh_skipped_logged = False
self._probe: tuple[str, float, bool, str] | None = None
self.state: dict[str, Any] = {"last_error": None}
self._written = ""
self._setup_shown: str | None = None
# -- helpers ------------------------------------------------------------
def local_url(self, page: str, params: dict[str, Any]) -> str:
return f"file://{self.paths.www_dir}/{page}.html?" + urlencode(params)
def classify(self, url: str) -> str:
prefix = f"file://{self.paths.www_dir}/"
if url.startswith(prefix):
return url[len(prefix):].split(".html", 1)[0] # start / offline / setup
if url.startswith("chrome-error://"):
return "error"
if url in BLANK_URLS:
return "blank"
return "remote"
def reachable(self, url: str, max_age: float) -> tuple[bool, str]:
now = self.clock()
if self._probe and self._probe[0] == url and now - self._probe[1] < max_age:
return self._probe[2], self._probe[3]
ok, reason = self.probe_fn(url)
self._probe = (url, now, ok, reason)
self.state.update(target_reachable=ok, reason=reason, checked_at=int(self.wall()))
return ok, reason
def error(self, message: str) -> None:
log.warning(message)
self.state["last_error"] = {"at": int(self.wall()), "message": message}
def setup_required(self) -> bool:
return self.paths.setup_code_file.exists() and not self.paths.admin_file.exists()
def setup_url(self, config: dict[str, Any]) -> str:
try:
code = self.paths.setup_code_file.read_text(encoding="utf-8").strip()
except OSError:
code = ""
ips = sorted({a.split("/")[0] for addrs in self.addresses().values() for a in addrs if ":" not in a})
scheme = "https" if config["admin_https"] else "http"
return self.local_url("setup", {"code": code, "host": self.hostname(), "ips": ",".join(ips),
"port": config["admin_port"], "scheme": scheme, "name": config["device_name"]})
def offline_url(self, config: dict[str, Any], reason: str) -> str:
return self.local_url("offline", {"name": config["device_name"], "host": urlsplit(config["url"]).hostname or "",
"reason": reason, "since": int(self.offline_since or self.wall())})
def navigate(self, url: str) -> None:
self.cdp.navigate(url)
# -- main loop ----------------------------------------------------------
def tick(self) -> None:
config = load_config(self.paths.config_file)
target = config["url"]
self.state["updated"] = int(self.wall())
try:
page = self.cdp.main_page()
if page is None:
self.cdp.new_page(self.local_url("start", {"name": config["device_name"], "next": target}))
return
self._tick_page(config, target, page)
self.state["browser"] = "ok"
except CDPError as exc:
self.state.update(browser="unreachable", page=None)
self.hung = 0
log.debug("DevTools niet bereikbaar: %s", exc)
finally:
self.write_state()
def _tick_page(self, config: dict[str, Any], target: str, page: dict[str, Any]) -> None:
url = page.get("url", "")
kind = self.classify(url)
self.state.update(page=kind, page_url=mask_url(url) if kind == "remote" else kind)
if self.setup_required():
self.state["setup_required"] = True
want = self.setup_url(config)
if kind != "setup" or want != self._setup_shown:
self.navigate(want)
self._setup_shown = want
return
self.state["setup_required"] = False
self._setup_shown = None
url_changed = self.applied_url is not None and target != self.applied_url
if url_changed:
log.info("Nieuwe kiosk-URL: %s", mask_url(target))
self.applied_url = target
if kind != "remote" or url_changed:
ok, reason = self.reachable(target, 0 if url_changed else PROBE_MAX_AGE_LOADING)
if ok:
if self.offline_since is not None:
log.info("Kiosk-URL weer bereikbaar na %.0f s", self.wall() - self.offline_since)
self.navigate(target)
self.last_load = self.clock()
self.offline_since = None
self.state["offline_since"] = None
elif kind != "offline" or url_changed:
if self.offline_since is None:
self.offline_since = self.wall()
self.state["offline_since"] = int(self.offline_since)
self.error(f"Kiosk-URL niet bereikbaar ({reason}); offlinepagina getoond")
self.navigate(self.offline_url(config, reason))
self.hung = 0
return
if self.last_load is None:
self.last_load = self.clock()
if not self.cdp.responsive(page.get("webSocketDebuggerUrl")):
self.hung += 1
if self.hung == HUNG_RELOAD_AFTER:
self.error("Pagina reageert niet; herladen")
self.cdp.reload()
self.last_load = self.clock()
elif self.hung >= HUNG_RESTART_AFTER and config["browser_restart"]:
self.error("Pagina reageert nog steeds niet; Chromium wordt herstart")
self.hung = 0
self.cdp.close_browser()
return
self.hung = 0
ok, reason = self.reachable(target, PROBE_MAX_AGE_ONLINE)
interval = config["refresh_interval"]
if interval and self.clock() - self.last_load >= interval:
if ok:
log.info("Automatisch verversen (interval %s s)", interval)
self.cdp.reload()
self.last_load = self.clock()
self.refresh_skipped_logged = False
elif not self.refresh_skipped_logged:
self.error(f"Verversen overgeslagen: kiosk-URL niet bereikbaar ({reason})")
self.refresh_skipped_logged = True
def write_state(self) -> None:
state = dict(self.state, last_load_wall=int(self.wall() - (self.clock() - self.last_load)) if self.last_load else None)
payload = json.dumps(state, sort_keys=True)
if payload == self._written:
return
try:
tmp = self.paths.agent_state_file.with_suffix(".tmp")
tmp.write_text(payload, encoding="utf-8")
tmp.replace(self.paths.agent_state_file)
self._written = payload
except OSError as exc:
log.debug("Kon agentstatus niet schrijven: %s", exc)
def read_state(path: Path) -> dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def main() -> int:
logutil.setup()
agent = Agent(load_paths())
running = True
def stop(*_: Any) -> None:
nonlocal running
running = False
signal.signal(signal.SIGTERM, stop)
log.info("Kiosk-agent gestart")
while running:
try:
agent.tick()
except Exception: # never die on an unexpected page state; systemd would only restart us
log.exception("Onverwachte fout in de kiosk-agent")
for _ in range(TICK_SECONDS * 2):
if not running:
break
time.sleep(0.5)
return 0