417 lines
17 KiB
Python
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,
|
|
},
|
|
)
|