Full rearchitecture using Claude
- event bus systen - all components are independent - preparation for web frontend
This commit is contained in:
9
python-backend/musicmouse/services/__init__.py
Normal file
9
python-backend/musicmouse/services/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Front-ends: things that mirror state outwards and turn requests into intents.
|
||||
|
||||
``MqttService`` is the reference implementation. A web service would be another file
|
||||
here plus one line in the app - devices and reactions would not change.
|
||||
"""
|
||||
|
||||
from musicmouse.services.base import Service
|
||||
|
||||
__all__ = ["Service"]
|
||||
26
python-backend/musicmouse/services/base.py
Normal file
26
python-backend/musicmouse/services/base.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""What a front-end has to look like.
|
||||
|
||||
A service gets the bus, subscribes to state events to push outward, and emits intents
|
||||
inward. Nothing else in the app knows which services exist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
__all__ = ["Publisher", "Service"]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Service(Protocol):
|
||||
name: str
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Long-running task. Cancelled on shutdown; may reconnect internally."""
|
||||
...
|
||||
|
||||
|
||||
class Publisher(Protocol):
|
||||
"""How an entity sends something out, without knowing about the connection."""
|
||||
|
||||
async def publish(self, topic: str, payload: str, *, retain: bool = False) -> None: ...
|
||||
6
python-backend/musicmouse/services/mqtt/__init__.py
Normal file
6
python-backend/musicmouse/services/mqtt/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Home Assistant integration over MQTT."""
|
||||
|
||||
from musicmouse.services.mqtt.entity import Entity
|
||||
from musicmouse.services.mqtt.service import MqttService, build_entities
|
||||
|
||||
__all__ = ["Entity", "MqttService", "build_entities"]
|
||||
107
python-backend/musicmouse/services/mqtt/entity.py
Normal file
107
python-backend/musicmouse/services/mqtt/entity.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""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",
|
||||
}
|
||||
250
python-backend/musicmouse/services/mqtt/lights.py
Normal file
250
python-backend/musicmouse/services/mqtt/lights.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""Each LED zone as a Home-Assistant-discoverable JSON light.
|
||||
|
||||
Two things changed from the old ``ShelveLightMqtt``:
|
||||
|
||||
* The ``side_*``/``top_*`` effect names are parsed rather than enumerated, so adding a
|
||||
width or an increment is a data change (see :data:`WIDTHS`, :data:`INCREMENTS`).
|
||||
* State is published from :class:`~musicmouse.events.LedEffectChanged` - the device's
|
||||
report of what it actually did - instead of echoing back the command. When a figure
|
||||
animation overrides an MQTT-set colour, Home Assistant now follows along.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.color import ColorRGBW
|
||||
from musicmouse.config import MqttConfig
|
||||
from musicmouse.devices.mouse import MusicMouseDevice
|
||||
from musicmouse.effects import (
|
||||
EffectCircularConfig,
|
||||
EffectRandomTwoColorInterpolationConfig,
|
||||
EffectStaticConfig,
|
||||
EffectStaticDetailedConfig,
|
||||
EffectSwipeAndChange,
|
||||
LedEffect,
|
||||
)
|
||||
from musicmouse.events import LedEffectChanged
|
||||
from musicmouse.hardware import LedZone
|
||||
from musicmouse.services.mqtt.entity import Entity
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["LightEntity", "effect_names", "parse_positional_effect"]
|
||||
|
||||
BLACK = ColorRGBW(0, 0, 0, 0)
|
||||
|
||||
#: Effects that are not simply "light this fraction of the strip".
|
||||
BASE_EFFECTS = ("static", "circular", "wipeup", "twocolor", "twocolorrandom")
|
||||
|
||||
#: Fraction of the strip lit by a positional effect.
|
||||
WIDTHS = (0.2, 0.5)
|
||||
#: Light every n-th LED. 1 is solid.
|
||||
INCREMENTS = (1, 4, 8)
|
||||
|
||||
_POSITIONAL = re.compile(r"^(?P<position>side|top)_(?P<width>\d+(?:\.\d+)?)(?:_inc(?P<inc>\d+))?$")
|
||||
|
||||
DEFAULT_TRANSITION_S = 0.3
|
||||
|
||||
|
||||
def effect_names() -> list[str]:
|
||||
"""Every effect name this entity accepts, for the discovery ``effect_list``."""
|
||||
positional = [
|
||||
f"{position}_{width:g}" + ("" if increment == 1 else f"_inc{increment}")
|
||||
for position in ("side", "top")
|
||||
for width in WIDTHS
|
||||
for increment in INCREMENTS
|
||||
]
|
||||
return [*BASE_EFFECTS, *positional]
|
||||
|
||||
|
||||
def parse_positional_effect(name: str) -> tuple[float, float, int] | None:
|
||||
"""``"side_0.2_inc4"`` -> ``(begin, end, increment)``, or ``None`` if not one.
|
||||
|
||||
``side`` lights a band around the far end of the strip and wraps; ``top`` lights a
|
||||
band centred on the middle.
|
||||
"""
|
||||
match = _POSITIONAL.match(name)
|
||||
if match is None:
|
||||
return None
|
||||
width = float(match["width"])
|
||||
increment = int(match["inc"] or 1)
|
||||
if match["position"] == "side":
|
||||
return 1.0 - width / 2, width / 2, increment
|
||||
return 0.5 - width / 2, 0.5 + width / 2, increment
|
||||
|
||||
|
||||
class LightEntity(Entity):
|
||||
component = "light"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus: EventBus,
|
||||
config: MqttConfig,
|
||||
mouse: MusicMouseDevice,
|
||||
zone: LedZone,
|
||||
name: str,
|
||||
) -> None:
|
||||
self.zone = zone
|
||||
self.mouse = mouse
|
||||
self._state: dict[str, Any] = {
|
||||
"state": "OFF",
|
||||
"color": {"r": 255, "g": 255, "b": 255, "w": 0},
|
||||
"color_mode": "rgbw",
|
||||
"brightness": 30,
|
||||
"effect": "static",
|
||||
}
|
||||
self._last_color = ColorRGBW(0.5, 0.5, 0.5, 0)
|
||||
super().__init__(bus, config, object_id=f"light_{zone}", name=name)
|
||||
|
||||
# ---------------------------------------------------------------- discovery
|
||||
|
||||
def discovery_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": "json",
|
||||
"name": self.name,
|
||||
"unique_id": self.unique_id,
|
||||
"command_topic": self.command_topic,
|
||||
"state_topic": self.state_topic,
|
||||
"brightness": True,
|
||||
"color_mode": True,
|
||||
"supported_color_modes": ["rgbw"],
|
||||
"effect": True,
|
||||
"effect_list": effect_names(),
|
||||
"device": self.device_block(),
|
||||
}
|
||||
|
||||
def command_topics(self) -> tuple[str, ...]:
|
||||
return (self.command_topic,)
|
||||
|
||||
def subscribe(self) -> None:
|
||||
self.bus.subscribe(LedEffectChanged, self._on_led_changed)
|
||||
|
||||
# ----------------------------------------------------------------- commands
|
||||
|
||||
async def handle(self, topic: str, payload: str) -> None:
|
||||
try:
|
||||
command = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
_log.warning("Ignoring non-JSON command on %s: %r", topic, payload[:120])
|
||||
return
|
||||
if not isinstance(command, dict):
|
||||
_log.warning("Ignoring command on %s: expected an object, got %r", topic, command)
|
||||
return
|
||||
|
||||
self._remember_previous_color(command)
|
||||
self._state.update(command)
|
||||
self.mouse.set_effect(self.zone, self._build_effect(), origin="mqtt")
|
||||
# No publish here: LedEffectChanged will report what the device actually did.
|
||||
|
||||
def _remember_previous_color(self, command: dict[str, Any]) -> None:
|
||||
"""Two-colour effects interpolate from the colour that was set before."""
|
||||
if "color" not in command:
|
||||
return
|
||||
brightness = command.get("brightness", self._state["brightness"])
|
||||
new_color = _color_from_json(command["color"], brightness)
|
||||
current = _color_from_json(self._state["color"], self._state["brightness"])
|
||||
if new_color != current:
|
||||
self._last_color = current
|
||||
|
||||
def _build_effect(self) -> LedEffect:
|
||||
state = self._state
|
||||
color = _color_from_json(state["color"], state["brightness"])
|
||||
transition_ms = float(state.get("transition", DEFAULT_TRANSITION_S)) * 1000
|
||||
effect = str(state.get("effect", "static"))
|
||||
|
||||
if state["state"] == "OFF":
|
||||
return _static(BLACK, transition_ms)
|
||||
|
||||
if (positional := parse_positional_effect(effect)) is not None:
|
||||
begin, end, increment = positional
|
||||
return EffectStaticDetailedConfig(
|
||||
color,
|
||||
increment=increment,
|
||||
begin=begin,
|
||||
end=end,
|
||||
transition_time_in_ms=transition_ms,
|
||||
)
|
||||
|
||||
match effect:
|
||||
case "static":
|
||||
return _static(color, transition_ms)
|
||||
case "circular":
|
||||
return EffectCircularConfig(speed=180, width=90, color=color)
|
||||
case "wipeup":
|
||||
swipe_and_change = EffectSwipeAndChange()
|
||||
swipe_and_change.swipe.primary_color = self._last_color
|
||||
swipe_and_change.swipe.secondary_color = color
|
||||
swipe_and_change.swipe.bell_curve_width_in_leds = 10
|
||||
swipe_and_change.swipe.transition_width = 30
|
||||
swipe_and_change.swipe.start_position = 0
|
||||
swipe_and_change.swipe.swipe_speed = 260
|
||||
swipe_and_change.change.color1 = color
|
||||
swipe_and_change.change.color2 = self._last_color
|
||||
return swipe_and_change
|
||||
case "twocolor" | "twocolorrandom":
|
||||
random_hues = effect == "twocolorrandom"
|
||||
return EffectRandomTwoColorInterpolationConfig(
|
||||
color1=color,
|
||||
color2=self._last_color,
|
||||
hue1_random=random_hues,
|
||||
hue2_random=random_hues,
|
||||
start_with_existing=True,
|
||||
)
|
||||
case _:
|
||||
_log.warning("Unknown effect %r on %s, turning it off", effect, self.zone)
|
||||
return _static(BLACK, transition_ms)
|
||||
|
||||
# -------------------------------------------------------------------- state
|
||||
|
||||
async def _on_led_changed(self, event: LedEffectChanged) -> None:
|
||||
if event.zone is not self.zone:
|
||||
return
|
||||
if event.origin != "mqtt":
|
||||
self._reconcile(event.effect)
|
||||
await self.publish_state()
|
||||
|
||||
def _reconcile(self, effect: LedEffect) -> None:
|
||||
"""Fold an effect this entity did not ask for into the reported state.
|
||||
|
||||
The mapping is lossy - the firmware has richer effects than the HA light
|
||||
schema - so only on/off and a colour are taken. The effect *name* is left
|
||||
alone, since reporting one outside ``effect_list`` would confuse HA.
|
||||
"""
|
||||
color = getattr(effect, "color", None)
|
||||
if isinstance(effect, EffectStaticConfig | EffectStaticDetailedConfig) and color == BLACK:
|
||||
self._state["state"] = "OFF"
|
||||
return
|
||||
|
||||
self._state["state"] = "ON"
|
||||
if isinstance(color, ColorRGBW):
|
||||
self._state["color"] = _color_to_json(color)
|
||||
self._state["brightness"] = 255
|
||||
|
||||
async def publish_state(self) -> None:
|
||||
await self.publish(self.state_topic, self._state)
|
||||
|
||||
|
||||
def _static(color: ColorRGBW, transition_ms: float) -> LedEffect:
|
||||
if transition_ms > 0:
|
||||
return EffectStaticDetailedConfig(color, transition_time_in_ms=transition_ms)
|
||||
return EffectStaticConfig(color)
|
||||
|
||||
|
||||
def _color_from_json(color: dict[str, int], brightness: int = 255) -> ColorRGBW:
|
||||
scale = brightness / 255
|
||||
r, g, b, w = ((color.get(channel, 0) / 255) * scale for channel in "rgbw")
|
||||
return ColorRGBW(r, g, b, w)
|
||||
|
||||
|
||||
def _color_to_json(color: ColorRGBW) -> dict[str, int]:
|
||||
return {
|
||||
"r": round(color.r * 255),
|
||||
"g": round(color.g * 255),
|
||||
"b": round(color.b * 255),
|
||||
"w": round(color.w * 255),
|
||||
}
|
||||
168
python-backend/musicmouse/services/mqtt/player.py
Normal file
168
python-backend/musicmouse/services/mqtt/player.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""The audio player, exposed to Home Assistant.
|
||||
|
||||
Home Assistant has no MQTT ``media_player`` platform, so the player is published as
|
||||
the pieces that do exist: a sensor for what is going on, a number for the volume, and
|
||||
buttons for the transport. Commands come back in as intents, which is the same path
|
||||
the physical buttons take.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.config import MqttConfig
|
||||
from musicmouse.devices.player import Player
|
||||
from musicmouse.events import (
|
||||
ActiveFigureChanged,
|
||||
Event,
|
||||
IntentEvent,
|
||||
NextTrackRequested,
|
||||
PauseRequested,
|
||||
PlaybackChanged,
|
||||
PlayRequested,
|
||||
PrevTrackRequested,
|
||||
SetVolumeRequested,
|
||||
TrackChanged,
|
||||
VolumeChanged,
|
||||
)
|
||||
from musicmouse.services.mqtt.entity import Entity
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["PlayerSensor", "TransportButton", "VolumeNumber", "player_entities"]
|
||||
|
||||
|
||||
class PlayerSensor(Entity):
|
||||
component = "sensor"
|
||||
|
||||
def __init__(self, bus: EventBus, config: MqttConfig, player: Player) -> None:
|
||||
self.player = player
|
||||
self._figure: str | None = None
|
||||
super().__init__(bus, config, object_id="player", name="Music Mouse Player")
|
||||
|
||||
def discovery_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"unique_id": self.unique_id,
|
||||
"state_topic": self.state_topic,
|
||||
"json_attributes_topic": f"{self.base_topic}/attributes",
|
||||
"icon": "mdi:music-circle",
|
||||
"device": self.device_block(),
|
||||
}
|
||||
|
||||
def subscribe(self) -> None:
|
||||
for event_type in (PlaybackChanged, TrackChanged, VolumeChanged, ActiveFigureChanged):
|
||||
self.bus.subscribe(event_type, self._on_change)
|
||||
|
||||
async def _on_change(self, event: Event) -> None:
|
||||
if isinstance(event, ActiveFigureChanged):
|
||||
self._figure = event.figure
|
||||
await self.publish_state()
|
||||
|
||||
async def publish_state(self) -> None:
|
||||
track = self.player.current_track
|
||||
playlist = self.player.playlist
|
||||
await self.publish(self.state_topic, "playing" if self.player.is_playing else "paused")
|
||||
await self.publish(
|
||||
f"{self.base_topic}/attributes",
|
||||
{
|
||||
"figure": self._figure,
|
||||
"playlist": playlist.name if playlist else None,
|
||||
"track_index": self.player.track_index,
|
||||
"track_count": len(playlist) if playlist else 0,
|
||||
"title": track.title if track else None,
|
||||
"volume": self.player.volume,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class VolumeNumber(Entity):
|
||||
component = "number"
|
||||
|
||||
def __init__(self, bus: EventBus, config: MqttConfig, player: Player) -> None:
|
||||
self.player = player
|
||||
self._min = 0
|
||||
self._max = 100
|
||||
super().__init__(bus, config, object_id="volume", name="Music Mouse Volume")
|
||||
|
||||
def discovery_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"unique_id": self.unique_id,
|
||||
"command_topic": self.command_topic,
|
||||
"state_topic": self.state_topic,
|
||||
"min": self._min,
|
||||
"max": self._max,
|
||||
"step": 1,
|
||||
"mode": "slider",
|
||||
"icon": "mdi:volume-high",
|
||||
"device": self.device_block(),
|
||||
}
|
||||
|
||||
def command_topics(self) -> tuple[str, ...]:
|
||||
return (self.command_topic,)
|
||||
|
||||
def subscribe(self) -> None:
|
||||
self.bus.subscribe(VolumeChanged, self._on_volume)
|
||||
|
||||
async def _on_volume(self, _event: VolumeChanged) -> None:
|
||||
await self.publish_state()
|
||||
|
||||
async def handle(self, topic: str, payload: str) -> None:
|
||||
try:
|
||||
volume = int(float(payload))
|
||||
except ValueError:
|
||||
_log.warning("Ignoring non-numeric volume on %s: %r", topic, payload[:40])
|
||||
return
|
||||
self.bus.emit(SetVolumeRequested(volume=volume, source="mqtt"))
|
||||
|
||||
async def publish_state(self) -> None:
|
||||
await self.publish(self.state_topic, str(self.player.volume))
|
||||
|
||||
|
||||
class TransportButton(Entity):
|
||||
component = "button"
|
||||
|
||||
#: Button object id -> the intent pressing it emits.
|
||||
INTENTS: ClassVar[dict[str, type[IntentEvent]]] = {
|
||||
"next": NextTrackRequested,
|
||||
"previous": PrevTrackRequested,
|
||||
"play": PlayRequested,
|
||||
"pause": PauseRequested,
|
||||
}
|
||||
|
||||
def __init__(self, bus: EventBus, config: MqttConfig, action: str, name: str) -> None:
|
||||
self.action = action
|
||||
super().__init__(bus, config, object_id=f"button_{action}", name=name)
|
||||
|
||||
def discovery_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"unique_id": self.unique_id,
|
||||
"command_topic": self.command_topic,
|
||||
"device": self.device_block(),
|
||||
}
|
||||
|
||||
def command_topics(self) -> tuple[str, ...]:
|
||||
return (self.command_topic,)
|
||||
|
||||
async def handle(self, topic: str, payload: str) -> None:
|
||||
# HA publishes "PRESS"; the payload carries no information beyond "it happened".
|
||||
_log.debug("Transport button %s pressed via %s (%r)", self.action, topic, payload[:20])
|
||||
self.bus.emit(self.INTENTS[self.action](source="mqtt"))
|
||||
|
||||
|
||||
def player_entities(bus: EventBus, config: MqttConfig, player: Player) -> list[Entity]:
|
||||
names = {
|
||||
"next": "Music Mouse Next",
|
||||
"previous": "Music Mouse Previous",
|
||||
"play": "Music Mouse Play",
|
||||
"pause": "Music Mouse Pause",
|
||||
}
|
||||
return [
|
||||
PlayerSensor(bus, config, player),
|
||||
VolumeNumber(bus, config, player),
|
||||
*(TransportButton(bus, config, action, name) for action, name in names.items()),
|
||||
]
|
||||
134
python-backend/musicmouse/services/mqtt/service.py
Normal file
134
python-backend/musicmouse/services/mqtt/service.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""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, 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)
|
||||
157
python-backend/musicmouse/services/mqtt/triggers.py
Normal file
157
python-backend/musicmouse/services/mqtt/triggers.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Button, touch and RFID events, published for Home Assistant to automate on.
|
||||
|
||||
This is what replaces the old direct ``hass-client`` calls. The backend no longer
|
||||
knows that pressing the rotary encoder toggles ``light.kinderzimmer_fluter`` or that
|
||||
the left ear means pink - it reports what happened, and the automation lives in Home
|
||||
Assistant where it can be changed without a deploy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.config import MqttConfig
|
||||
from musicmouse.events import ButtonEvent, RfidTokenRead, TouchButtonPressed, TouchButtonReleased
|
||||
from musicmouse.hardware import Button, ButtonAction, TouchButton
|
||||
from musicmouse.services.mqtt.entity import Entity
|
||||
|
||||
__all__ = ["ButtonTrigger", "TagScanner", "TouchTrigger", "trigger_entities"]
|
||||
|
||||
#: Which button actions are worth automating on, and the HA trigger type for each.
|
||||
BUTTON_ACTIONS: dict[ButtonAction, str] = {
|
||||
ButtonAction.PRESSED: "button_short_press",
|
||||
ButtonAction.DOUBLE_CLICKED: "button_double_press",
|
||||
ButtonAction.LONG_PRESSED: "button_long_press",
|
||||
}
|
||||
|
||||
|
||||
class _Trigger(Entity):
|
||||
component = "device_automation"
|
||||
|
||||
@property
|
||||
def discovery_topic(self) -> str:
|
||||
# Device triggers are addressed by node id + object id, not by unique id.
|
||||
return (
|
||||
f"{self.config.discovery_prefix}/device_automation/"
|
||||
f"{self.config.device_id}/{self.object_id}/config"
|
||||
)
|
||||
|
||||
@property
|
||||
def trigger_topic(self) -> str:
|
||||
return f"{self.config.base_topic}/trigger/{self.object_id}"
|
||||
|
||||
def _payload(self, trigger_type: str, subtype: str) -> dict[str, Any]:
|
||||
return {
|
||||
"automation_type": "trigger",
|
||||
"topic": self.trigger_topic,
|
||||
"type": trigger_type,
|
||||
"subtype": subtype,
|
||||
"device": self.device_block(),
|
||||
}
|
||||
|
||||
|
||||
class ButtonTrigger(_Trigger):
|
||||
def __init__(
|
||||
self, bus: EventBus, config: MqttConfig, button: Button, action: ButtonAction
|
||||
) -> None:
|
||||
self.button = button
|
||||
self.action = action
|
||||
super().__init__(
|
||||
bus,
|
||||
config,
|
||||
object_id=f"{button.slug}_{action.slug}",
|
||||
name=f"{button.slug} {action.slug}",
|
||||
)
|
||||
|
||||
def discovery_payload(self) -> dict[str, Any]:
|
||||
return self._payload(BUTTON_ACTIONS[self.action], self.button.slug)
|
||||
|
||||
def subscribe(self) -> None:
|
||||
self.bus.subscribe(ButtonEvent, self._on_button)
|
||||
|
||||
async def _on_button(self, event: ButtonEvent) -> None:
|
||||
if event.button is self.button and event.action is self.action:
|
||||
await self.publish(self.trigger_topic, self.action.slug)
|
||||
|
||||
|
||||
class TouchTrigger(_Trigger):
|
||||
TYPES: ClassVar[dict[bool, str]] = {
|
||||
True: "button_short_press",
|
||||
False: "button_short_release",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self, bus: EventBus, config: MqttConfig, button: TouchButton, *, pressed: bool
|
||||
) -> None:
|
||||
self.button = button
|
||||
self.pressed = pressed
|
||||
suffix = "touched" if pressed else "released"
|
||||
super().__init__(
|
||||
bus,
|
||||
config,
|
||||
object_id=f"{button.slug}_{suffix}",
|
||||
name=f"{button.slug} {suffix}",
|
||||
)
|
||||
|
||||
def discovery_payload(self) -> dict[str, Any]:
|
||||
return self._payload(self.TYPES[self.pressed], self.button.slug)
|
||||
|
||||
def subscribe(self) -> None:
|
||||
if self.pressed:
|
||||
self.bus.subscribe(TouchButtonPressed, self._on_touch)
|
||||
else:
|
||||
self.bus.subscribe(TouchButtonReleased, self._on_touch)
|
||||
|
||||
async def _on_touch(self, event: TouchButtonPressed | TouchButtonReleased) -> None:
|
||||
if event.button is self.button:
|
||||
await self.publish(self.trigger_topic, self.button.slug)
|
||||
|
||||
|
||||
class TagScanner(Entity):
|
||||
"""The RFID reader, as an HA tag scanner - the natural fit for "a tag was read"."""
|
||||
|
||||
component = "tag"
|
||||
|
||||
def __init__(self, bus: EventBus, config: MqttConfig) -> None:
|
||||
super().__init__(bus, config, object_id="tag", name="Music Mouse Reader")
|
||||
|
||||
@property
|
||||
def discovery_topic(self) -> str:
|
||||
return f"{self.config.discovery_prefix}/tag/{self.config.device_id}/config"
|
||||
|
||||
@property
|
||||
def scan_topic(self) -> str:
|
||||
return f"{self.config.base_topic}/tag"
|
||||
|
||||
def discovery_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"topic": self.scan_topic,
|
||||
"value_template": "{{ value_json.tag_id }}",
|
||||
"device": self.device_block(),
|
||||
}
|
||||
|
||||
def subscribe(self) -> None:
|
||||
self.bus.subscribe(RfidTokenRead, self._on_tag)
|
||||
|
||||
async def _on_tag(self, event: RfidTokenRead) -> None:
|
||||
await self.publish(
|
||||
self.scan_topic,
|
||||
{"tag_id": event.tag_id.hex(), "figure": event.figure, "known": event.known},
|
||||
)
|
||||
|
||||
|
||||
def trigger_entities(bus: EventBus, config: MqttConfig) -> list[Entity]:
|
||||
return [
|
||||
*(
|
||||
ButtonTrigger(bus, config, button, action)
|
||||
for button in Button
|
||||
for action in BUTTON_ACTIONS
|
||||
),
|
||||
*(
|
||||
TouchTrigger(bus, config, button, pressed=pressed)
|
||||
for button in TouchButton
|
||||
for pressed in (True, False)
|
||||
),
|
||||
TagScanner(bus, config),
|
||||
]
|
||||
Reference in New Issue
Block a user