293 lines
11 KiB
Python
293 lines
11 KiB
Python
"""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())
|