"""thinclient-agent entrypoint.""" from __future__ import annotations import json import logging import os import signal import socket import sys import threading from dataclasses import dataclass from datetime import datetime, timezone import paho.mqtt.client as mqtt from .admin_canvas import AdminCanvas from .audio_control import AudioControl from .capture_control import CaptureControl, find_audio_card from .digest_canvas import DETAIL_LEVELS, DigestCanvas from .display_power import DisplayPower from .input_control import InputControl from .mpris_bridge import MprisBridge from .mqtt_discovery import Discovery from .remote_desktop import RemoteDesktop from .sway_control import WS_ADMIN, WS_CAPTURE, WS_DIGEST, WS_MEDIA, WS_WEB, SwayControl CONFIG_PATH = os.environ.get("THINCLIENT_AGENT_CONFIG", "/etc/thinclient-agent/config.env") CONFIG_KEYS = ( "MQTT_BROKER_HOST", "MQTT_BROKER_PORT", "MQTT_USERNAME", "MQTT_PASSWORD", "HA_URL", "DIGEST_WEB_URL", "ADMIN_WEB_URL", "KIOSK_USERNAME", "THINCLIENT_NAME", ) WORKSPACES = (WS_WEB, WS_DIGEST, WS_MEDIA, WS_ADMIN, WS_CAPTURE) # How often the background thread re-scans for capture cards (hot-plugged, not # just present at boot) and republishes the HA select's options if they changed. # See main()'s poll_capture_devices(). CAPTURE_POLL_SECONDS = 20 # Fixed, household-wide — not under this device's own thinclient/ namespace. # "Was the digest looked at" is a fact about the digest, not about this specific # machine, and digest-engine (a different physical host, see # digest-engine/viewed_tracker.py) has no reason to know or care which of possibly # several thin clients showed it. DIGEST_VIEWED_TOPIC = "smarthome/digest/viewed" log = logging.getLogger("thinclient-agent") def publish_digest_viewed(client: mqtt.Client) -> None: # Retained so digest-engine's next run — which may start hours later, on a # different machine — can read it without racing to be subscribed at the instant # it's published. payload = json.dumps( {"viewed_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")} ) client.publish(DIGEST_VIEWED_TOPIC, payload, qos=1, retain=True) @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]: digest_url = (config.get("DIGEST_WEB_URL") or "").rstrip("/") return { "firefox": App( name="Firefox", command=["/usr/local/bin/digest-browser", f"{digest_url}/full.html?detail_level=full"], workspace=WS_DIGEST, icon="mdi:firefox", ), "web_browser": App( name="web browser", # Separate from the digest window above: minimal chrome instead of --kiosk, # its own Firefox profile, and it lands on 1:web. See configs/firefox/. command=["/usr/local/bin/web-browser"], workspace=WS_WEB, icon="mdi:web", ), "steam_link": App( name="Steam Link", # Forced onto Xwayland: native Wayland black-screens/flickers on wlroots # (project-plan Phase 11.7). command=[ "flatpak", "run", "--env=SDL_VIDEODRIVER=x11", "com.valvesoftware.SteamLink", ], process_pattern="SteamLink", workspace=WS_MEDIA, focus_criteria='class="steamlink"', icon="mdi:steam", ), } 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 "thinclient" friendly_name = config.get("THINCLIENT_NAME") or f"Thin client ({hostname})" broker_host = config.get("MQTT_BROKER_HOST", "") broker_port = int(config.get("MQTT_BROKER_PORT") or 1883) sway = SwayControl() canvas = DigestCanvas(sway, config.get("DIGEST_WEB_URL", "")) admin_canvas = AdminCanvas(sway, config.get("ADMIN_WEB_URL", "")) # The TV's own power. DISPLAY_OUTPUTS names which Sway output(s) are the TV # ("*" on a machine driving one screen); DISPLAY_USE_CEC=false drops to DPMS # only, for a panel whose CEC is broken or deliberately disabled. display = DisplayPower( sway, cec_device=config.get("CEC_DEVICE", ""), outputs=config.get("DISPLAY_OUTPUTS", "*"), use_cec=str(config.get("DISPLAY_USE_CEC", "true")).strip().lower() == "true", ) apps = build_apps(config) audio = AudioControl(sway.session_env) capture = CaptureControl() remote = RemoteDesktop(sway) keyboard = InputControl(sway.session_env) # Applied before MQTT is even attempted: the audio preference is a local setting and # must hold with the container host powered off (Phase 11.10). audio.list_sinks() audio.apply_preferred() client = make_client(f"thinclient-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) mpris = MprisBridge(discovery.publish_media_state, sway.session_env) def on_detail_level(payload: str) -> None: discovery.publish_detail_level(canvas.set_detail_level(payload)) 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_show_digest(payload: str) -> None: canvas.show(payload) discovery.publish_workspace(WS_DIGEST) # Covers both triggers of this one handler: the manual "Show digest canvas" # button and an HA automation's voice-resolved "play my digest" request (Phase # 11.8) — both are a real, on-screen display, unlike the compact HA-dashboard # iframe, which never reaches this agent at all and so never marks anything # viewed. See digest-engine/viewed_tracker.py for what reads this. publish_digest_viewed(client) def on_show_admin_canvas(_payload: str) -> None: # Payload intentionally ignored — see admin_canvas.py's docstring for why # there is nothing in it for this agent to act on. No viewed-tracking here # either; that concept is specific to the scheduled digest. admin_canvas.show() discovery.publish_workspace(WS_ADMIN) def on_display_power(payload: str) -> None: # Driven by an HA automation on room occupancy (see hosts/thin-client's # README): a TV showing a canvas to an empty room is the single largest # power draw this machine is attached to. The decision stays in HA, where # presence already lives; this only does what it is told. discovery.publish_display_power(display.handle_command(payload)) def on_audio_output(payload: str) -> None: discovery.publish_audio_output(audio.select(payload)) def on_capture_select(payload: str) -> None: device = capture.select(payload) discovery.publish_capture_source(capture.current_option()) if device is None: # "none", or an unrecognised/unplugged-since source — nothing to show, # same as never having picked one. return audio_card = find_audio_card(device.path) or "" sway.launch_app( ["/usr/local/bin/capture-view", device.path, audio_card], workspace=WS_CAPTURE ) discovery.publish_workspace(WS_CAPTURE) def poll_capture_devices(stop_event: threading.Event) -> None: last_options: list[str] | None = None while not stop_event.wait(CAPTURE_POLL_SECONDS): capture.list_devices() options = capture.options() if options != last_options: log.info("capture source list changed: %s", options) discovery.register_capture_select( options, on_capture_select, capture.current_option() ) last_options = options def on_remote_target(payload: str) -> None: discovery.publish_remote_target(remote.select(payload)) def on_type(payload: str) -> None: keyboard.type_text(payload) def on_move(direction: str) -> None: keyboard.move(direction) def on_click(button: str) -> None: keyboard.click(button) 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_digest(on_show_digest, on_detail_level, DETAIL_LEVELS, canvas.detail_level) discovery.register_admin_canvas(on_show_admin_canvas) discovery.register_display_power(on_display_power, display.state) discovery.register_app_launchers(apps, on_launch) discovery.register_workspace_select(WORKSPACES, on_workspace, WS_DIGEST) # audio.apply_preferred() already ran once at startup (before MQTT was even # attempted, per the comment above) — re-list here so the select's options # reflect this exact moment rather than whatever was plugged in at boot. audio.list_sinks() discovery.register_audio_output(audio.options(), on_audio_output, audio.current_option()) capture.list_devices() discovery.register_capture_select( capture.options(), on_capture_select, capture.current_option() ) discovery.register_remote_desktop( remote.options(), on_remote_target, remote.connect, remote.disconnect, remote.current ) discovery.register_input_control(on_type, on_move, on_click) 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) capture_thread: threading.Thread | None = None 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(): Phase 11.10 requires # the kiosk to come up and play media with the container host powered off, so # this agent must never be able to stall the session waiting on the broker. client.connect_async(broker_host, broker_port, keepalive=60) client.loop_start() # Own thread rather than piggybacking on paho's loop thread (which only # pumps MQTT I/O): a newly plugged-in capture card should appear in HA # without waiting for a reconnect, per the plan's "active poll" choice. capture_thread = threading.Thread( target=poll_capture_devices, args=(stop_event,), daemon=True, name="capture-poll" ) capture_thread.start() log.info("thinclient-agent %s started (node_id=%s)", node_id, node_id) try: mpris.run_forever(stop_event) finally: log.info("shutting down") if broker_host: discovery.publish_available(False) client.loop_stop() client.disconnect() if capture_thread is not None: capture_thread.join(timeout=5) return 0 if __name__ == "__main__": raise SystemExit(main())