"""steamtv-agent entrypoint.""" from __future__ import annotations import itertools import logging import os import signal import socket import sys import threading from dataclasses import dataclass, field import paho.mqtt.client as mqtt from .audio_control import AudioControl from .display_power import DisplayPower from .mpris_bridge import MprisBridge from .mqtt_discovery import Discovery from .session_mode import MODES, SessionMode from .sway_control import WS_GAMES, WS_MEDIA, WS_MUSIC, WS_STEAM, WS_WEB, SwayControl CONFIG_PATH = os.environ.get("STEAMTV_AGENT_CONFIG", "/etc/steamtv-agent/config.env") CONFIG_KEYS = ( "MQTT_BROKER_HOST", "MQTT_BROKER_PORT", "MQTT_USERNAME", "MQTT_PASSWORD", "HA_URL", "KIOSK_USERNAME", "STEAMTV_NAME", "STEAMTV_ROOM", "GPU_VENDOR", "ENABLE_CEC", ) WORKSPACES = (WS_STEAM, WS_GAMES, WS_WEB, WS_MEDIA, WS_MUSIC) # How often the session-mode sensor is recomputed. Slower than the MPRIS poll on # purpose: "is a game running" changes on a timescale of minutes, and each check costs # two pgreps and a swaymsg. Riding the MPRIS loop rather than adding a second thread. SESSION_POLL_EVERY = 5 log = logging.getLogger("steamtv-agent") @dataclass(frozen=True) class App: name: str command: list[str] = field(default_factory=list) 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]: """The launch table. Every command is built from constants in this file — a payload only ever selects a key here, it never contributes an argv element. See the security note in mqtt_discovery.py.""" return { "steam": App( name="Steam Big Picture", command=["/usr/local/bin/steam-big-picture"], # No process_pattern: steam-big-picture does its own already-running check # and knows how to ask a live client to re-open Big Picture, which is more # than "focus the window" can do. workspace=WS_STEAM, icon="mdi:steam", ), "prism": App( name="Prism Launcher", # Goes through Steam (steam://rungameid/…) so Steam Input and the Steam # Controller API are live for Minecraft — see the script's header. Which is # also why there is no workspace here: when Steam runs it, the window # belongs to Steam's own workspace, and forcing 2:games would move it away # from the client that owns it. 2:games is where it lands only on the # direct-launch fallback path, and sway puts it there by focus anyway. command=["/usr/local/bin/prism-launch"], process_pattern="org.prismlauncher.PrismLauncher", focus_criteria='app_id="org.prismlauncher.PrismLauncher"', icon="mdi:minecraft", ), "web_browser": App( name="web browser", command=["/usr/local/bin/web-browser"], workspace=WS_WEB, icon="mdi:web", ), "media_player": App( name="media player", command=["/usr/local/bin/media-player"], process_pattern="media-player-idle", workspace=WS_MEDIA, focus_criteria='app_id="mpv"', icon="mdi:play-box", ), "spotify": App( name="Spotify", command=["/usr/local/bin/spotify-launch"], process_pattern="com.spotify.Client", workspace=WS_MUSIC, focus_criteria='app_id="com.spotify.Client"', icon="mdi:spotify", ), } def make_client(client_id: str) -> mqtt.Client: # paho-mqtt 2.x requires an explicit callback API version; older Debian releases # ship 1.6.x and have 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 "steamtv" friendly_name = config.get("STEAMTV_NAME") or f"Steam TV box ({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("STEAMTV_ROOM", "") broker_host = config.get("MQTT_BROKER_HOST", "") broker_port = int(config.get("MQTT_BROKER_PORT") or 1883) sway = SwayControl() apps = build_apps(config) audio = AudioControl(sway.session_env) session = SessionMode(sway) display = DisplayPower(sway, use_cec=config.get("ENABLE_CEC", "true") != "false") client = make_client(f"steamtv-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_session_mode(payload: str) -> None: mode = session.select(payload) discovery.publish_session_state(mode, session.attributes()) def on_stop_media() -> None: mode = session.stop_media() discovery.publish_session_state(mode, session.attributes()) def on_audio_output(payload: str) -> None: discovery.publish_audio_output(audio.select(payload)) def on_display(payload: str) -> None: discovery.publish_display_power(display.handle_command(payload)) 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_STEAM) discovery.register_session_mode(MODES, on_session_mode, on_stop_media) # The sink list is read here rather than at construction because WirePlumber may # not be up yet when this service starts (it is a system unit; the session is # not). By the time the broker connects, the session normally is. audio.list_sinks() audio.apply_preferred() discovery.register_audio_output(audio.options(), audio.current_option(), on_audio_output) discovery.register_display_power(on_display, display.state) discovery.subscribe_all() discovery.publish_available(True) discovery.publish_session_state(session.current(), session.attributes()) 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(): this box must boot to # Big Picture and play a game with the container host powered off. Same # "reactive path never depends on a remote service" rule as the thin client's # Phase 11.10 — and it matters more here, since nobody wants their console to # need the house's server to be up. client.connect_async(broker_host, broker_port, keepalive=60) client.loop_start() log.info("steamtv-agent %s started (node_id=%s)", node_id, node_id) ticks = itertools.count(1) def poll_session() -> None: if next(ticks) % SESSION_POLL_EVERY: return mode = session.current() if mode == session.last_published: return session.last_published = mode if broker_host: discovery.publish_session_state(mode, session.attributes()) # The MPRIS bridge owns the main loop; the session sensor rides along on it rather # than starting a second thread to do the same waiting. original_poll = mpris.poll_once def poll_both() -> None: original_poll() poll_session() mpris.poll_once = poll_both # type: ignore[method-assign] 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())