"""touchpanel-agent entrypoint.""" from __future__ import annotations import logging import os import signal import socket import sys import threading from dataclasses import dataclass import paho.mqtt.client as mqtt from .mpris_bridge import MprisBridge from .mqtt_discovery import Discovery from .sway_control import WS_HOME, WS_SPOTIFY, WS_WEB, SwayControl CONFIG_PATH = os.environ.get("TOUCHPANEL_AGENT_CONFIG", "/etc/touchpanel-agent/config.env") CONFIG_KEYS = ( "MQTT_BROKER_HOST", "MQTT_BROKER_PORT", "MQTT_USERNAME", "MQTT_PASSWORD", "HA_URL", "KIOSK_USERNAME", "TOUCHPANEL_NAME", ) WORKSPACES = (WS_SPOTIFY, WS_HOME, WS_WEB) log = logging.getLogger("touchpanel-agent") @dataclass(frozen=True) class App: name: str command: list[str] process_pattern: str | None = None workspace: str | None = None focus_criteria: str | None = None icon: str = "mdi:application" def load_config(path: str = CONFIG_PATH) -> dict[str, str]: values: dict[str, str] = {} try: with open(path, encoding="utf-8") as handle: for line in handle: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") values[key.strip()] = value.strip().strip('"').strip("'") except OSError as exc: log.warning("could not read %s (%s); falling back to the environment", path, exc) for key in CONFIG_KEYS: if key in os.environ: values[key] = os.environ[key] return values def build_apps(_config: dict[str, str]) -> dict[str, App]: return { "spotify": App( name="Spotify", command=["/usr/local/bin/spotify-launch"], process_pattern="com.spotify.Client", workspace=WS_SPOTIFY, focus_criteria='app_id="spotify"', icon="mdi:spotify", ), "home": App( name="Home", # ha-kiosk is already running (started at session boot, see # configs/sway/config) and supervises its own restart loop — this just # switches workspace and focuses the existing window rather than # relaunching it, since the HA dashboard is stateful. See ha-kiosk's own # comment for why this is deliberately not a kill-and-relaunch like the # thin client's digest canvas. command=["/usr/local/bin/ha-kiosk"], process_pattern="ha-kiosk", workspace=WS_HOME, focus_criteria='app_id="chromium.*"', icon="mdi:home-assistant", ), "web_browser": App( name="web browser", command=["/usr/local/bin/web-browser"], workspace=WS_WEB, icon="mdi:web", ), } def make_client(client_id: str) -> mqtt.Client: # paho-mqtt 2.x requires an explicit callback API version; bookworm's # python3-paho-mqtt is 1.6.x and has no such argument. VERSION1 is requested when # available so the callback signatures below are identical under both. callback_api = getattr(mqtt, "CallbackAPIVersion", None) if callback_api is not None: return mqtt.Client(callback_api.VERSION1, client_id=client_id) return mqtt.Client(client_id=client_id) def main() -> int: logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", stream=sys.stdout, ) config = load_config() hostname = socket.gethostname() node_id = "".join(c if c.isalnum() else "_" for c in hostname).strip("_") or "touchpanel" friendly_name = config.get("TOUCHPANEL_NAME") or f"Touch panel ({hostname})" # The HA area this device sits in, published as suggested_area — see # docs/rooms-and-endpoints.md. Blank is fine and means "no suggestion". room = config.get("TOUCHPANEL_ROOM", "") broker_host = config.get("MQTT_BROKER_HOST", "") broker_port = int(config.get("MQTT_BROKER_PORT") or 1883) sway = SwayControl() apps = build_apps(config) client = make_client(f"touchpanel-agent-{node_id}") if config.get("MQTT_USERNAME"): client.username_pw_set(config["MQTT_USERNAME"], config.get("MQTT_PASSWORD") or None) discovery = Discovery(client, node_id, friendly_name, room) mpris = MprisBridge(discovery.publish_media_state, sway.session_env) def on_launch(key: str) -> None: app = apps[key] sway.launch_app( app.command, process_pattern=app.process_pattern, workspace=app.workspace, focus_criteria=app.focus_criteria, ) if app.workspace: discovery.publish_workspace(app.workspace) def on_workspace(payload: str) -> None: name = payload.strip() # Enumerated, never passed through: see the security note in mqtt_discovery.py. if name not in WORKSPACES: log.warning("ignoring unknown workspace %r", name) return sway.switch_workspace(name) discovery.publish_workspace(name) def on_connect(_client, _userdata, _flags, rc): if rc != 0: log.error("MQTT connection refused (rc=%s)", rc) return log.info("connected to MQTT broker %s:%s", broker_host, broker_port) discovery.register_media_player(mpris.handle_command, mpris.set_volume) discovery.register_app_launchers(apps, on_launch) discovery.register_workspace_select(WORKSPACES, on_workspace, WS_HOME) discovery.subscribe_all() discovery.publish_available(True) def on_disconnect(_client, _userdata, rc): log.warning("disconnected from MQTT broker (rc=%s); paho will retry", rc) def on_message(_client, _userdata, message): discovery.dispatch(message.topic, message.payload.decode("utf-8", "replace")) client.on_connect = on_connect client.on_disconnect = on_disconnect client.on_message = on_message client.will_set(discovery.availability_topic, "offline", qos=1, retain=True) stop_event = threading.Event() def handle_signal(_signum, _frame): stop_event.set() signal.signal(signal.SIGTERM, handle_signal) signal.signal(signal.SIGINT, handle_signal) if not broker_host: log.error("MQTT_BROKER_HOST is not set in %s — running without HA control", CONFIG_PATH) else: # connect_async + loop_start, never a blocking connect(): the panel must come # up and show Spotify/HA/the browser with the container host powered off, # same "reactive path never depends on a remote service" rule as the thin # client's Phase 11.10. client.connect_async(broker_host, broker_port, keepalive=60) client.loop_start() log.info("touchpanel-agent %s started (node_id=%s)", node_id, node_id) if not config.get("HA_URL"): log.warning("HA_URL is not set — the Home workspace will have nothing to show") try: mpris.run_forever(stop_event) finally: log.info("shutting down") if broker_host: discovery.publish_available(False) client.loop_stop() client.disconnect() return 0 if __name__ == "__main__": raise SystemExit(main())