- event bus systen - all components are independent - preparation for web frontend
108 lines
3.5 KiB
Python
108 lines
3.5 KiB
Python
"""Shared plumbing for Home-Assistant-discoverable MQTT entities.
|
|
|
|
Adding an entity should be about thirty lines: a discovery payload, a state payload,
|
|
and whatever bus subscriptions keep it current.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from abc import ABC, abstractmethod
|
|
from typing import Any, ClassVar
|
|
|
|
from musicmouse.bus import EventBus
|
|
from musicmouse.config import MqttConfig
|
|
from musicmouse.services.base import Publisher
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
__all__ = ["Entity"]
|
|
|
|
|
|
class Entity(ABC):
|
|
#: Home Assistant MQTT component, e.g. "light", "sensor", "device_automation".
|
|
component: ClassVar[str]
|
|
|
|
def __init__(self, bus: EventBus, config: MqttConfig, object_id: str, name: str) -> None:
|
|
self.bus = bus
|
|
self.config = config
|
|
self.object_id = object_id
|
|
self.name = name
|
|
self._publisher: Publisher | None = None
|
|
self.subscribe()
|
|
|
|
# -------------------------------------------------------------------- topics
|
|
|
|
@property
|
|
def unique_id(self) -> str:
|
|
return f"{self.config.device_id}_{self.object_id}"
|
|
|
|
@property
|
|
def base_topic(self) -> str:
|
|
return f"{self.config.base_topic}/{self.object_id}"
|
|
|
|
@property
|
|
def state_topic(self) -> str:
|
|
return f"{self.base_topic}/state"
|
|
|
|
@property
|
|
def command_topic(self) -> str:
|
|
return f"{self.base_topic}/set"
|
|
|
|
@property
|
|
def discovery_topic(self) -> str:
|
|
return f"{self.config.discovery_prefix}/{self.component}/{self.unique_id}/config"
|
|
|
|
def command_topics(self) -> tuple[str, ...]:
|
|
"""Topics the service should route to :meth:`handle`."""
|
|
return ()
|
|
|
|
# ------------------------------------------------------------------ contract
|
|
|
|
@abstractmethod
|
|
def discovery_payload(self) -> dict[str, Any]:
|
|
"""The retained config Home Assistant reads to create this entity."""
|
|
|
|
def subscribe(self) -> None:
|
|
"""Register bus handlers. Called once, at construction."""
|
|
|
|
async def handle(self, topic: str, payload: str) -> None:
|
|
"""React to a command on one of :meth:`command_topics`."""
|
|
|
|
async def publish_state(self) -> None:
|
|
"""Push current state out. Called on connect and whenever state changes."""
|
|
|
|
# ------------------------------------------------------------------- runtime
|
|
|
|
def attach(self, publisher: Publisher | None) -> None:
|
|
self._publisher = publisher
|
|
|
|
@property
|
|
def online(self) -> bool:
|
|
return self._publisher is not None
|
|
|
|
async def publish(self, topic: str, payload: Any, *, retain: bool = False) -> None:
|
|
"""Send ``payload`` (JSON-encoded unless it is already a string).
|
|
|
|
A no-op while the broker is unreachable: state is republished on reconnect.
|
|
"""
|
|
if self._publisher is None:
|
|
return
|
|
text = payload if isinstance(payload, str) else json.dumps(payload)
|
|
await self._publisher.publish(topic, text, retain=retain)
|
|
|
|
async def announce(self) -> None:
|
|
"""Publish discovery, then current state."""
|
|
await self.publish(self.discovery_topic, self.discovery_payload(), retain=True)
|
|
await self.publish_state()
|
|
|
|
def device_block(self) -> dict[str, Any]:
|
|
"""Ties every entity to one device in Home Assistant's UI."""
|
|
return {
|
|
"identifiers": [self.config.device_id],
|
|
"name": self.config.device_name,
|
|
"manufacturer": "bauer.tech",
|
|
"model": "MusicMouse",
|
|
}
|