SmartestHome/hosts/thin-client/agent/thinclient_agent/mqtt_discovery.py

417 lines
17 KiB
Python

"""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):
self.client = client
self.node_id = node_id
self.friendly_name = friendly_name
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__,
}
# --- 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,
},
)