Files
musicmouse/python-backend/musicmouse/services/mqtt/service.py

135 lines
4.6 KiB
Python

"""The MQTT connection: reconnect, discovery, and routing commands to entities.
Unlike the old ``start_mqtt``, entities outlive a dropped connection - they keep their
state and simply republish it - and a clean restart is not delayed by a reconnect
sleep it never needed.
"""
from __future__ import annotations
import logging
from collections.abc import Iterable
import aiomqtt
from musicmouse.bus import EventBus
from musicmouse.clock import Clock, RealClock
from musicmouse.config import MqttConfig
from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.player import Player
from musicmouse.events import ConnectionChanged
from musicmouse.hardware import LedZone
from musicmouse.services.mqtt.entity import Entity
from musicmouse.services.mqtt.lights import LightEntity
from musicmouse.services.mqtt.player import player_entities
from musicmouse.services.mqtt.triggers import trigger_entities
_log = logging.getLogger(__name__)
__all__ = ["MqttService", "build_entities"]
ZONE_NAMES = {
LedZone.SHELF: "Music Mouse Regal Licht",
LedZone.RING: "Music Mouse Ring",
LedZone.MOUSE: "Music Mouse Body",
}
def build_entities(
bus: EventBus, config: MqttConfig, mouse: MusicMouseDevice, player: Player
) -> list[Entity]:
"""Everything this backend exposes to Home Assistant."""
return [
*(LightEntity(bus, config, mouse, zone, ZONE_NAMES[zone]) for zone in LedZone),
*player_entities(bus, config, mouse, player),
*trigger_entities(bus, config),
]
class MqttService:
name = "mqtt"
def __init__(
self,
bus: EventBus,
config: MqttConfig,
entities: Iterable[Entity],
*,
clock: Clock | None = None,
) -> None:
self.bus = bus
self.config = config
self.entities = list(entities)
self._clock = clock or RealClock()
self._client: aiomqtt.Client | None = None
self._routes: dict[str, Entity] = {
topic: entity for entity in self.entities for topic in entity.command_topics()
}
# ------------------------------------------------------------------ Publisher
async def publish(self, topic: str, payload: str, *, retain: bool = False) -> None:
client = self._client
if client is None:
return
try:
await client.publish(topic, payload.encode(), retain=retain)
except aiomqtt.MqttError as exc:
_log.debug("Publish to %s failed: %s", topic, exc)
# -------------------------------------------------------------------- runtime
async def run(self) -> None:
while True:
try:
await self._session()
except aiomqtt.MqttError as exc:
_log.warning(
"MQTT connection to %s lost (%s); retrying in %gs",
self.config.server,
exc,
self.config.reconnect_interval,
)
finally:
self._detach()
await self._clock.sleep(self.config.reconnect_interval)
async def _session(self) -> None:
async with aiomqtt.Client(
hostname=self.config.server,
port=self.config.port,
username=self.config.user,
password=self.config.password,
) as client:
self._client = client
_log.info("Connected to MQTT broker %s:%d", self.config.server, self.config.port)
self.bus.emit(ConnectionChanged(target="mqtt", connected=True, source="mqtt"))
for entity in self.entities:
entity.attach(self)
await entity.announce()
await client.subscribe(f"{self.config.base_topic}/#")
async for message in client.messages:
await self._route(message)
def _detach(self) -> None:
if self._client is None:
return
self._client = None
for entity in self.entities:
entity.attach(None)
self.bus.emit(ConnectionChanged(target="mqtt", connected=False, source="mqtt"))
async def _route(self, message: aiomqtt.Message) -> None:
topic = message.topic.value
entity = self._routes.get(topic)
if entity is None:
return # our own state topics come back on the wildcard subscription
payload = message.payload
text = payload.decode(errors="replace") if isinstance(payload, bytes) else str(payload)
try:
await entity.handle(topic, text)
except Exception:
_log.exception("Entity %s failed on %s", entity.unique_id, topic)