"""Home Assistant MQTT Discovery payloads and command dispatch. SECURITY BOUNDARY — this module is the entire remote-control API of the Steam TV box. Same principle as hosts/thin-client/agent/thinclient_agent/mqtt_discovery.py and the touch panel's, applied to this device: the local LLM never gets a network path to this machine. The only chain is: LLM tool call -> Home Assistant service call -> MQTT -> this dispatcher. That property holds only as long as this stays the sole inbound control surface: no HTTP listener, no websocket server, no exposed Sway IPC socket, no shell endpoint. A new feature belongs as another entity below, not as another listener. It is worth restating on this host in particular, because it is the one machine here with a real GPU, a Steam login and a games library on it — i.e. the one with something worth taking. Nothing in this image listens on a port except sshd (key-only) and wayvnc (password-mandatory, fails closed). Steam's own remote-play ports are not opened by anything here; if you want them, that is a deliberate decision to make in the firewall, not a default this image ships. Every command handler here is a fixed, enumerated action. A payload never becomes an argv element, a shell string, or a URL host — see the launch table in main.py, which builds every command from local constants and uses the payload only to pick between known values. """ from __future__ import annotations import json import logging from typing import Callable from . import __version__ log = logging.getLogger(__name__) DISCOVERY_PREFIX = "homeassistant" class Discovery: def __init__(self, client, node_id: str, friendly_name: str, room: str = ""): self.client = client self.node_id = node_id self.friendly_name = friendly_name self.room = (room or "").strip() self.base = f"steamtv/{node_id}" self.availability_topic = f"{self.base}/availability" self.media_state_topic = f"{self.base}/media/state" self.session_state_topic = f"{self.base}/session/state" self._handlers: dict[str, Callable[[str], None]] = {} self.device = { "identifiers": [f"steamtv_{node_id}"], "name": friendly_name, "manufacturer": "SmartestHome", "model": "Steam TV box", "sw_version": __version__, } # Which room this physically sits in, as an HA area_id. `suggested_area` is # honoured by HA only when the device is FIRST discovered — moving a device # later means moving it in HA too, this cannot un-file it. Omitted entirely # when unset, because an empty suggested_area is not the same request as no # suggestion. See docs/rooms-and-endpoints.md. if self.room: self.device["suggested_area"] = self.room # --- plumbing ----------------------------------------------------------- def _publish_config(self, component: str, object_id: str, payload: dict) -> None: payload = { "availability_topic": self.availability_topic, "device": self.device, "unique_id": f"{self.node_id}_{object_id}", **payload, } topic = f"{DISCOVERY_PREFIX}/{component}/{self.node_id}/{object_id}/config" self.client.publish(topic, json.dumps(payload), qos=1, retain=True) def _command_topic(self, suffix: str, handler) -> str: topic = f"{self.base}/{suffix}" self._handlers[topic] = handler return topic def subscribe_all(self) -> None: for topic in self._handlers: self.client.subscribe(topic, qos=1) def dispatch(self, topic: str, payload: str) -> None: handler = self._handlers.get(topic) if handler is None: log.warning("no handler for %s", topic) return try: handler(payload) except Exception: log.exception("handler for %s failed", topic) def publish_available(self, available: bool = True) -> None: self.client.publish( self.availability_topic, "online" if available else "offline", qos=1, retain=True, ) def publish_media_state(self, state: dict) -> None: self.client.publish(self.media_state_topic, json.dumps(state), qos=0, retain=True) # --- media -------------------------------------------------------------- def register_media_player(self, on_command, on_volume) -> None: command_topic = self._command_topic("media/command", on_command) volume_topic = self._command_topic("media/volume/set", lambda p: on_volume(float(p))) # Core Home Assistant's MQTT integration has NO media_player platform — see # hosts/thin-client/README.md's identical caveat. The button/number entities # below give the same transport control with stock HA. self._publish_config( "media_player", "media", { "name": "Media", "state_topic": self.media_state_topic, "state_template": "{{ value_json.state }}", "command_topic": command_topic, "volume_command_topic": volume_topic, "volume_state_topic": self.media_state_topic, "volume_template": "{{ value_json.volume }}", "title_template": "{{ value_json.title }}", "artist_template": "{{ value_json.artist }}", "album_template": "{{ value_json.album }}", }, ) for object_id, name, payload, icon in ( ("media_play_pause", "Play/pause", "PLAY_PAUSE", "mdi:play-pause"), ("media_next", "Next track", "NEXT", "mdi:skip-next"), ("media_previous", "Previous track", "PREVIOUS", "mdi:skip-previous"), ("media_stop", "Stop", "STOP", "mdi:stop"), ): self._publish_config( "button", object_id, { "name": name, "command_topic": command_topic, "payload_press": payload, "icon": icon, }, ) self._publish_config( "sensor", "media_state", { "name": "Playback state", "state_topic": self.media_state_topic, "value_template": "{{ value_json.state }}", "json_attributes_topic": self.media_state_topic, "icon": "mdi:play-circle", }, ) self._publish_config( "number", "media_volume", { "name": "Volume", "command_topic": volume_topic, "state_topic": self.media_state_topic, "value_template": "{{ value_json.volume }}", "min": 0, "max": 1, "step": 0.05, "mode": "slider", "icon": "mdi:volume-high", }, ) # --- apps / workspaces -------------------------------------------------- def register_app_launchers(self, apps, on_launch) -> None: for key, app in apps.items(): self._publish_config( "button", f"launch_{key}", { "name": f"Launch {app.name}", "command_topic": self._command_topic( f"app/{key}/launch", lambda _payload, key=key: on_launch(key), ), "icon": app.icon, }, ) def register_workspace_select(self, workspaces, on_workspace, state_topic_value) -> None: self._publish_config( "select", "workspace", { "name": "Screen", "command_topic": self._command_topic("workspace/set", on_workspace), "state_topic": f"{self.base}/workspace/state", "options": list(workspaces), "icon": "mdi:view-dashboard", }, ) self.client.publish( f"{self.base}/workspace/state", state_topic_value, qos=1, retain=True ) def publish_workspace(self, name: str) -> None: self.client.publish(f"{self.base}/workspace/state", name, qos=1, retain=True) # --- session mode ------------------------------------------------------- def register_session_mode(self, modes, on_select, on_stop_media) -> None: """The "what is this box doing" surface — see session_mode.py. A sensor and a select rather than one entity, because the two are not the same question. The sensor reports four states (gaming / steam / media / idle); the select offers only the two that are meaningful to *ask for*. "idle" is not something you can request, and "steam" (Steam up but not focused) is a transitional state nobody sets on purpose. """ self._publish_config( "sensor", "session_mode", { "name": "Session", "state_topic": self.session_state_topic, "value_template": "{{ value_json.mode }}", "json_attributes_topic": self.session_state_topic, "icon": "mdi:gamepad-variant", }, ) self._publish_config( "select", "session_mode_select", { "name": "Mode", "command_topic": self._command_topic("session/mode/set", on_select), "state_topic": self.session_state_topic, "value_template": "{{ value_json.mode }}", "options": list(modes), "icon": "mdi:gamepad-variant", }, ) # The only thing in this whole surface that shuts something down, and therefore # its own explicit button rather than a side effect of switching mode. See # SessionMode.select()'s docstring for why the mode switch is additive. self._publish_config( "button", "stop_media", { "name": "Stop media apps", "command_topic": self._command_topic( "session/media/stop", lambda _payload: on_stop_media() ), "icon": "mdi:close-circle-outline", }, ) def publish_session_state(self, mode: str, attributes: dict | None = None) -> None: payload = {"mode": mode} if attributes: payload.update(attributes) self.client.publish( self.session_state_topic, json.dumps(payload), qos=1, retain=True ) # --- audio output ------------------------------------------------------- def register_audio_output(self, options, current, on_select) -> None: self._publish_config( "select", "audio_output", { "name": "Audio output", "command_topic": self._command_topic("audio/output/set", on_select), "state_topic": f"{self.base}/audio/output/state", "options": list(options), "icon": "mdi:speaker", }, ) self.publish_audio_output(current) def publish_audio_output(self, option: str) -> None: self.client.publish( f"{self.base}/audio/output/state", option, qos=1, retain=True ) # --- display power ------------------------------------------------------ def register_display_power(self, on_command, initial: bool = True) -> None: self._publish_config( "switch", "display", { "name": "Display", "command_topic": self._command_topic("display/set", on_command), "state_topic": f"{self.base}/display/state", "payload_on": "ON", "payload_off": "OFF", "icon": "mdi:television", }, ) self.publish_display_power(initial) def publish_display_power(self, on: bool) -> None: self.client.publish( f"{self.base}/display/state", "ON" if on else "OFF", qos=1, retain=True )