Full rearchitecture using Claude

- event bus systen
- all components are independent
- preparation for web frontend
This commit is contained in:
2026-08-26 13:22:28 +02:00
parent 55bcc9448c
commit d44c24ec97
70 changed files with 7591 additions and 1011 deletions

View 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"]

View 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: ...

View 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"]

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

View 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),
}

View 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()),
]

View 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)

View 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),
]