Files
musicmouse/python-backend/musicmouse/services/mqtt/entity.py
Martin Bauer d44c24ec97 Full rearchitecture using Claude
- event bus systen
- all components are independent
- preparation for web frontend
2026-08-26 13:22:28 +02:00

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",
}