110 lines
4.2 KiB
Python
110 lines
4.2 KiB
Python
"""Home Assistant MQTT Discovery payloads and command dispatch.
|
|
|
|
SECURITY BOUNDARY — this module is the entire remote-control API of the kitchen
|
|
display. Same principle as every other host's agent in this project (Phase 11.4):
|
|
the local LLM never gets a network path to this machine directly. The only chain is
|
|
LLM tool call -> Home Assistant service call -> MQTT -> this dispatcher. There is no
|
|
HTTP listener, no websocket server, no exposed Sway IPC socket here.
|
|
|
|
This is deliberately NOT how the LLM reaches pantry-vision's actual inventory data —
|
|
that's a separate, already-published API (pantry-vision/server.py) that Home
|
|
Assistant or the LLM can call directly for reads/writes to Grocy. This agent only
|
|
ever controls what the physical screen is showing, exactly the same division of
|
|
labour as hosts/thin-client's "Show admin canvas" button vs. admin-canvas's own
|
|
write API.
|
|
"""
|
|
|
|
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"kitchendisplay/{node_id}"
|
|
self.availability_topic = f"{self.base}/availability"
|
|
self._handlers: dict[str, Callable[[str], None]] = {}
|
|
|
|
self.device = {
|
|
"identifiers": [f"kitchendisplay_{node_id}"],
|
|
"name": friendly_name,
|
|
"manufacturer": "SmartestHome",
|
|
"model": "Sway kitchen display",
|
|
"sw_version": __version__,
|
|
}
|
|
|
|
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 register_screens(self, on_show) -> None:
|
|
"""One button per screen — payload is the fixed page fragment (scan/
|
|
inventory/recipes), an enumerated constant handled in main.py, never passed
|
|
through as a URL/argv element unchanged (same rule as every other agent's
|
|
mqtt_discovery.py in this project).
|
|
"""
|
|
for object_id, name, fragment, icon in (
|
|
("show_scan", "Show scan", "scan", "mdi:camera"),
|
|
("show_inventory", "Show inventory", "inventory", "mdi:fridge-outline"),
|
|
("show_recipes", "Show recipes", "recipes", "mdi:chef-hat"),
|
|
# A different backend (identity, not pantry-vision) behind the same
|
|
# "Show X" shape — main.py's on_show() is what routes "register"
|
|
# differently from the other three fragments, not this module.
|
|
("show_register", "Show registration", "register", "mdi:account-plus"),
|
|
):
|
|
self._publish_config(
|
|
"button",
|
|
object_id,
|
|
{
|
|
"name": name,
|
|
"command_topic": self._command_topic(
|
|
f"show/{fragment}", lambda _payload, fragment=fragment: on_show(fragment)
|
|
),
|
|
"icon": icon,
|
|
},
|
|
)
|