135 lines
4.6 KiB
Python
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)
|