"""Home Assistant MQTT Discovery payloads and command dispatch. SECURITY BOUNDARY — this module is the entire remote-control API of the thin client. Per project-plan Phase 11.4, the local LLM is never given 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 in the codebase: no HTTP listener, no websocket server, no exposed Sway IPC socket, no shell endpoint. If a future feature needs a new control path, it belongs as another entity below, not as another listener. Consequently, 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 and DigestCanvas._url(), both of which build their commands from local constants and use 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"thinclient/{node_id}" self.availability_topic = f"{self.base}/availability" self.media_state_topic = f"{self.base}/media/state" self.detail_level_state_topic = f"{self.base}/digest/detail_level/state" self.audio_sink_state_topic = f"{self.base}/audio/sink/state" self.capture_source_state_topic = f"{self.base}/capture/source/state" self.remote_target_state_topic = f"{self.base}/remote/target/state" self.input_text_state_topic = f"{self.base}/input/text/state" self.display_power_state_topic = f"{self.base}/display/power/state" self._handlers: dict[str, Callable[[str], None]] = {} self.device = { "identifiers": [f"thinclient_{node_id}"], "name": friendly_name, "manufacturer": "SmartestHome", "model": "Sway thin client", "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) def publish_detail_level(self, level: str) -> None: self.client.publish(self.detail_level_state_topic, level, qos=1, retain=True) # --- entities ----------------------------------------------------------- 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 — this # payload is only consumed if the HACS "MQTT Media Player" custom integration # is installed (see hosts/thin-client/README.md). The button/number entities # below give the same transport control with stock HA, so a plain install still # gets working playback control; do not remove them in favour of this one. 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:music", }, ) 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", }, ) def register_digest(self, on_show, on_detail_level, detail_levels, current_level) -> None: self._publish_config( "button", "digest_show", { "name": "Show digest canvas", "command_topic": self._command_topic("digest/show", on_show), "icon": "mdi:earth", }, ) self._publish_config( "select", "digest_detail_level", { "name": "Digest detail level", "command_topic": self._command_topic("digest/detail_level/set", on_detail_level), "state_topic": self.detail_level_state_topic, "options": list(detail_levels), "icon": "mdi:format-list-bulleted", }, ) self.publish_detail_level(current_level) def register_admin_canvas(self, on_show) -> None: """The sys-admin-llm's display surface (docs/project-plan.md Phase 13). Deliberately just a button, no select/detail-level — see admin_canvas.py's docstring for why there's nothing else here for MQTT to configure. Content reaches the canvas through a completely separate path (HA -> admin-canvas's write API, never through this agent); this button only ever switches workspace and (re)opens the fixed canvas URL, exactly like "Show digest canvas" below does for the digest. """ self._publish_config( "button", "admin_canvas_show", { "name": "Show admin canvas", "command_topic": self._command_topic("admin/show", on_show), "icon": "mdi:monitor-dashboard", }, ) def register_display_power(self, on_set, current: bool) -> None: """The TV's own power, as a switch HA can drive from room presence. A switch rather than a button, because the interesting automation is "this area became unoccupied" -> off, "somebody walked in" -> on, and that needs a state HA can read back as well as set. The state is what this agent last did, not what the panel reports: CEC gives no reliable read-back, and a state that lies about the TV having been turned off by its own remote is better than one that blocks the next wake — see display_power.set_power(). """ self._publish_config( "switch", "display_power", { "name": "Display", "command_topic": self._command_topic("display/power/set", on_set), "state_topic": self.display_power_state_topic, "payload_on": "ON", "payload_off": "OFF", "icon": "mdi:television", }, ) self.publish_display_power(current) def publish_display_power(self, on: bool) -> None: self.client.publish( self.display_power_state_topic, "ON" if on else "OFF", qos=1, retain=True ) 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": "Workspace", "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) def register_audio_output(self, options, on_select, current) -> None: """Audio-output select. Options are WirePlumber sink *descriptions*. Re-published on every reconnect rather than kept live: the option list comes from whatever `wpctl status` shows at that moment, and a select whose options changed under Home Assistant mid-session is worse than one that refreshes when the agent does. See audio_control.AudioControl for the node.name mapping. """ self._publish_config( "select", "audio_output", { "name": "Audio output", "command_topic": self._command_topic("audio/sink/set", on_select), "state_topic": self.audio_sink_state_topic, "options": list(options), "icon": "mdi:speaker", }, ) self.publish_audio_output(current) def publish_audio_output(self, option: str) -> None: self.client.publish(self.audio_sink_state_topic, option, qos=1, retain=True) def register_capture_select(self, options, on_select, current) -> None: """Capture-card ("receiver box") source select. See capture_control.py. Safe to call again with an updated `options` list at any time, not just at connect — MQTT discovery re-publishing the same config topic (same component/node_id/object_id) just updates Home Assistant's copy of it, which is exactly how main.py's background poll picks up a newly plugged-in capture card without waiting for the next MQTT reconnect. """ self._publish_config( "select", "capture_source", { "name": "Capture source", "command_topic": self._command_topic("capture/source/set", on_select), "state_topic": self.capture_source_state_topic, "options": list(options), "icon": "mdi:video-input-hdmi", }, ) self.publish_capture_source(current) def publish_capture_source(self, option: str) -> None: self.client.publish(self.capture_source_state_topic, option, qos=1, retain=True) def register_remote_desktop(self, targets, on_select, on_connect, on_disconnect, current) -> None: self._publish_config( "select", "remote_target", { "name": "Remote desktop target", "command_topic": self._command_topic("remote/target/set", on_select), "state_topic": self.remote_target_state_topic, "options": list(targets), "icon": "mdi:remote-desktop", }, ) self.publish_remote_target(current) for object_id, name, handler, icon in ( ("remote_connect", "Remote desktop connect", on_connect, "mdi:lan-connect"), ("remote_disconnect", "Remote desktop disconnect", on_disconnect, "mdi:lan-disconnect"), ): self._publish_config( "button", object_id, { "name": name, "command_topic": self._command_topic(f"remote/{object_id}", handler), "icon": icon, }, ) def publish_remote_target(self, name) -> None: self.client.publish(self.remote_target_state_topic, name or "", qos=1, retain=True) def register_input_control(self, on_type, on_move, on_click) -> None: """Keyboard/pointer injection into the focused window. The `text` platform is what gives the HA mobile app a real text field with a submit action; a button with a payload could not carry free text, and an `input_text` helper would put the field in HA's own state machine rather than on this device. Its payload is the one value in this whole module that is used as content rather than as a selector — see the security note in input_control.py. """ self._publish_config( "text", "input_text", { "name": "Type text", "command_topic": self._command_topic("input/text/set", on_type), "state_topic": self.input_text_state_topic, "min": 0, "max": 255, "mode": "text", "icon": "mdi:keyboard", }, ) self.client.publish(self.input_text_state_topic, "", qos=1, retain=True) for object_id, name, direction, icon in ( ("pointer_up", "Pointer up", "UP", "mdi:arrow-up-bold"), ("pointer_down", "Pointer down", "DOWN", "mdi:arrow-down-bold"), ("pointer_left", "Pointer left", "LEFT", "mdi:arrow-left-bold"), ("pointer_right", "Pointer right", "RIGHT", "mdi:arrow-right-bold"), ): self._publish_config( "button", object_id, { "name": name, "command_topic": self._command_topic( f"input/pointer/{direction.lower()}", lambda _payload, direction=direction: on_move(direction), ), "icon": icon, }, ) for object_id, name, button, icon in ( ("pointer_click", "Left click", "LEFT", "mdi:cursor-default-click"), ("pointer_right_click", "Right click", "RIGHT", "mdi:cursor-default-click-outline"), ): self._publish_config( "button", object_id, { "name": name, "command_topic": self._command_topic( f"input/click/{button.lower()}", lambda _payload, button=button: on_click(button), ), "icon": icon, }, )