Files
musicmouse/python-backend/tests/test_mqtt.py

563 lines
19 KiB
Python

from __future__ import annotations
import json
import logging
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
import pytest
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,
EffectStaticDetailedConfig,
EffectSwipeAndChange,
)
from musicmouse.events import (
ButtonEvent,
Event,
NextTrackRequested,
PauseRequested,
RfidTokenRead,
SetVolumeRequested,
TouchButtonPressed,
)
from musicmouse.hardware import Button, ButtonAction, LedZone, TouchButton
from musicmouse.services.mqtt.entity import Entity
from musicmouse.services.mqtt.lights import (
BLACK,
LightEntity,
effect_names,
parse_positional_effect,
)
from musicmouse.services.mqtt.player import TransportButton, VolumeNumber
from musicmouse.services.mqtt.service import build_entities
from musicmouse.services.mqtt.triggers import ButtonTrigger, TagScanner, TouchTrigger
from musicmouse.simulator.fake_player import FakePlayer
from musicmouse.simulator.fake_transport import FakeTransport
TAG_MAP = {bytes.fromhex("04a1b2c3d4"): "fuchs"}
@dataclass
class RecordingPublisher:
"""Stands in for the broker connection."""
messages: list[tuple[str, str, bool]] = field(default_factory=list)
async def publish(self, topic: str, payload: str, *, retain: bool = False) -> None:
self.messages.append((topic, payload, retain))
def payloads_on(self, topic: str) -> list[str]:
return [payload for sent_topic, payload, _ in self.messages if sent_topic == topic]
def last_json(self, topic: str) -> dict:
return json.loads(self.payloads_on(topic)[-1])
def clear(self) -> None:
self.messages.clear()
@pytest.fixture
async def bus() -> AsyncIterator[EventBus]:
async with EventBus() as running:
yield running
@pytest.fixture
def mqtt_config() -> MqttConfig:
return MqttConfig(server="broker.local")
@pytest.fixture
def transport() -> FakeTransport:
return FakeTransport()
@pytest.fixture
def mouse(bus: EventBus, transport: FakeTransport) -> MusicMouseDevice:
device = MusicMouseDevice(bus, transport, TAG_MAP, port="simulated")
transport.attach(device.feed)
return device
@pytest.fixture
def player(bus: EventBus) -> FakePlayer:
return FakePlayer(bus, initial_volume=40)
@pytest.fixture
def publisher() -> RecordingPublisher:
return RecordingPublisher()
def attach(entity: Entity, publisher: RecordingPublisher) -> Entity:
entity.attach(publisher)
return entity
@pytest.fixture
def shelf(
bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, publisher: RecordingPublisher
) -> LightEntity:
entity = LightEntity(bus, mqtt_config, mouse, LedZone.SHELF, "Shelf")
entity.attach(publisher)
return entity
def command(**fields: object) -> str:
return json.dumps(fields)
# ------------------------------------------------------------------ effect names
def test_the_old_effect_names_still_decode_the_same_way() -> None:
"""Values taken from the hand-written elif chain in the old mqtt_json.py."""
assert parse_positional_effect("side_0.2") == (0.9, 0.1, 1)
assert parse_positional_effect("side_0.5") == (0.75, 0.25, 1)
assert parse_positional_effect("top_0.2") == (0.4, 0.6, 1)
assert parse_positional_effect("top_0.5") == (0.25, 0.75, 1)
assert parse_positional_effect("side_0.2_inc4") == (0.9, 0.1, 4)
assert parse_positional_effect("side_0.2_inc8") == (0.9, 0.1, 8)
assert parse_positional_effect("top_0.5_inc4") == (0.25, 0.75, 4)
@pytest.mark.parametrize("name", ["static", "circular", "sideways_0.2", "side", "top_", "side_x"])
def test_non_positional_names_are_not_parsed(name: str) -> None:
assert parse_positional_effect(name) is None
def test_every_old_effect_name_is_still_offered() -> None:
previously_offered = {
"static", "circular", "wipeup", "twocolor", "twocolorrandom",
"side_0.2", "side_0.5", "side_0.2_inc4", "side_0.2_inc8", "side_0.5_inc4",
"top_0.2", "top_0.5", "top_0.2_inc4", "top_0.5_inc4",
} # fmt: skip
assert previously_offered <= set(effect_names())
def test_every_offered_effect_name_can_be_built(
shelf: LightEntity, mouse: MusicMouseDevice
) -> None:
for name in effect_names():
shelf._state.update({"state": "ON", "effect": name})
assert shelf._build_effect() is not None
# ------------------------------------------------------------------- light commands
async def test_turning_the_light_on_sets_a_static_effect(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
) -> None:
await shelf.handle(
shelf.command_topic,
command(state="ON", color={"r": 255, "g": 0, "b": 0, "w": 0}, brightness=255),
)
await bus.drain()
effect = mouse.effect(LedZone.SHELF)
assert isinstance(effect, EffectStaticDetailedConfig)
assert effect.color == ColorRGBW(1.0, 0.0, 0.0, 0.0)
async def test_brightness_scales_the_colour(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
) -> None:
await shelf.handle(
shelf.command_topic,
command(state="ON", color={"r": 255, "g": 0, "b": 0, "w": 0}, brightness=128),
)
await bus.drain()
effect = mouse.effect(LedZone.SHELF)
assert isinstance(effect, EffectStaticDetailedConfig)
assert effect.color.r == pytest.approx(128 / 255, abs=0.01)
async def test_turning_the_light_off_sends_black(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
) -> None:
await shelf.handle(shelf.command_topic, command(state="OFF"))
await bus.drain()
effect = mouse.effect(LedZone.SHELF)
assert isinstance(effect, EffectStaticDetailedConfig)
assert effect.color == BLACK
async def test_a_positional_effect_becomes_a_detailed_static(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
) -> None:
await shelf.handle(shelf.command_topic, command(state="ON", effect="top_0.5_inc4"))
await bus.drain()
effect = mouse.effect(LedZone.SHELF)
assert isinstance(effect, EffectStaticDetailedConfig)
assert (effect.begin, effect.end, effect.increment) == (0.25, 0.75, 4)
@pytest.mark.parametrize(
("name", "expected"),
[
("circular", EffectCircularConfig),
("wipeup", EffectSwipeAndChange),
("twocolor", EffectRandomTwoColorInterpolationConfig),
("twocolorrandom", EffectRandomTwoColorInterpolationConfig),
],
)
async def test_named_effects(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, name: str, expected: type
) -> None:
await shelf.handle(shelf.command_topic, command(state="ON", effect=name))
await bus.drain()
assert isinstance(mouse.effect(LedZone.SHELF), expected)
async def test_twocolorrandom_randomises_both_hues(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
) -> None:
await shelf.handle(shelf.command_topic, command(state="ON", effect="twocolorrandom"))
await bus.drain()
effect = mouse.effect(LedZone.SHELF)
assert isinstance(effect, EffectRandomTwoColorInterpolationConfig)
assert effect.hue1_random and effect.hue2_random
async def test_an_unknown_effect_turns_the_light_off_with_a_warning(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING):
await shelf.handle(shelf.command_topic, command(state="ON", effect="disco"))
await bus.drain()
effect = mouse.effect(LedZone.SHELF)
assert isinstance(effect, EffectStaticDetailedConfig)
assert effect.color == BLACK
assert "Unknown effect 'disco'" in caplog.text
async def test_malformed_json_is_ignored(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING):
await shelf.handle(shelf.command_topic, "not json at all")
await bus.drain()
assert mouse.effect(LedZone.SHELF) is None
assert "non-JSON command" in caplog.text
async def test_the_previous_colour_is_remembered_for_two_colour_effects(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
) -> None:
await shelf.handle(
shelf.command_topic,
command(state="ON", color={"r": 255, "g": 0, "b": 0, "w": 0}, brightness=255),
)
await shelf.handle(
shelf.command_topic,
command(
state="ON",
color={"r": 0, "g": 0, "b": 255, "w": 0},
brightness=255,
effect="twocolor",
),
)
await bus.drain()
effect = mouse.effect(LedZone.SHELF)
assert isinstance(effect, EffectRandomTwoColorInterpolationConfig)
assert effect.color1 == ColorRGBW(0, 0, 1, 0)
assert effect.color2 == ColorRGBW(1, 0, 0, 0)
# ---------------------------------------------------------------- state reporting
async def test_state_is_published_after_a_command(
bus: EventBus, shelf: LightEntity, publisher: RecordingPublisher
) -> None:
await shelf.handle(shelf.command_topic, command(state="ON", effect="static"))
await bus.drain()
assert publisher.last_json(shelf.state_topic)["state"] == "ON"
async def test_a_local_effect_updates_what_home_assistant_sees(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, publisher: RecordingPublisher
) -> None:
"""The whole point of publishing from LedEffectChanged rather than the command echo."""
await shelf.handle(shelf.command_topic, command(state="OFF"))
await bus.drain()
assert publisher.last_json(shelf.state_topic)["state"] == "OFF"
mouse.set_effect(
LedZone.SHELF, EffectCircularConfig(color=ColorRGBW(0, 1, 0, 0)), origin="device"
)
await bus.drain()
reported = publisher.last_json(shelf.state_topic)
assert reported["state"] == "ON"
assert reported["color"] == {"r": 0, "g": 255, "b": 0, "w": 0}
async def test_a_local_off_effect_is_reported_as_off(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, publisher: RecordingPublisher
) -> None:
from musicmouse.effects import OFF
await shelf.handle(shelf.command_topic, command(state="ON", effect="static"))
await bus.drain()
mouse.set_effect(LedZone.SHELF, OFF(), origin="device")
await bus.drain()
assert publisher.last_json(shelf.state_topic)["state"] == "OFF"
async def test_another_zone_does_not_affect_this_entity(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, publisher: RecordingPublisher
) -> None:
publisher.clear()
mouse.set_effect(LedZone.RING, EffectCircularConfig(), origin="device")
await bus.drain()
assert publisher.payloads_on(shelf.state_topic) == []
async def test_discovery_declares_a_json_rgbw_light_with_effects(shelf: LightEntity) -> None:
payload = shelf.discovery_payload()
assert payload["schema"] == "json"
assert payload["supported_color_modes"] == ["rgbw"]
assert payload["command_topic"] == shelf.command_topic
assert "top_0.5_inc4" in payload["effect_list"]
assert payload["device"]["identifiers"] == ["musicmouse"]
async def test_announce_publishes_retained_discovery_then_state(
shelf: LightEntity, publisher: RecordingPublisher
) -> None:
await shelf.announce()
topics = [topic for topic, _, _ in publisher.messages]
assert topics == [shelf.discovery_topic, shelf.state_topic]
assert publisher.messages[0][2] is True # discovery is retained
async def test_nothing_is_published_while_offline(shelf: LightEntity) -> None:
shelf.attach(None)
await shelf.announce() # must not raise
# ----------------------------------------------------------------------- player
async def test_volume_command_emits_an_intent(
bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer
) -> None:
seen: list[Event] = []
bus.subscribe(SetVolumeRequested, seen.append)
entity = VolumeNumber(bus, mqtt_config, player)
await entity.handle(entity.command_topic, "37")
await bus.drain()
assert seen == [SetVolumeRequested(volume=37, source="mqtt")]
async def test_a_non_numeric_volume_is_ignored(
bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer, caplog: pytest.LogCaptureFixture
) -> None:
seen: list[Event] = []
bus.subscribe(SetVolumeRequested, seen.append)
entity = VolumeNumber(bus, mqtt_config, player)
with caplog.at_level(logging.WARNING):
await entity.handle(entity.command_topic, "loud")
await bus.drain()
assert seen == []
assert "non-numeric volume" in caplog.text
async def test_volume_state_follows_the_player(
bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer, publisher: RecordingPublisher
) -> None:
entity = VolumeNumber(bus, mqtt_config, player)
entity.attach(publisher)
player.set_volume(22)
await bus.drain()
assert publisher.payloads_on(entity.state_topic)[-1] == "22"
@pytest.mark.parametrize(
("action", "expected"),
[("next", NextTrackRequested), ("pause", PauseRequested)],
)
async def test_transport_buttons_emit_intents(
bus: EventBus, mqtt_config: MqttConfig, action: str, expected: type[Event]
) -> None:
seen: list[Event] = []
bus.subscribe(expected, seen.append)
entity = TransportButton(bus, mqtt_config, action, f"Music Mouse {action}")
await entity.handle(entity.command_topic, "PRESS")
await bus.drain()
assert len(seen) == 1
assert seen[0].source == "mqtt"
async def test_the_player_sensor_reports_the_current_track(
bus: EventBus,
mqtt_config: MqttConfig,
mouse: MusicMouseDevice,
player: FakePlayer,
publisher: RecordingPublisher,
) -> None:
from pathlib import Path
from musicmouse.media import Playlist, Track
from musicmouse.services.mqtt.player import PlayerSensor
entity = PlayerSensor(bus, mqtt_config, mouse, player)
entity.attach(publisher)
player.set_playlist(Playlist(name="fuchs", tracks=(Track(Path("/m/01 - Song.mp3")),)))
player.play_from_start()
await bus.drain()
assert publisher.payloads_on(entity.state_topic)[-1] == "playing"
attributes = publisher.last_json(f"{entity.base_topic}/attributes")
assert attributes["playlist"] == "fuchs"
assert attributes["title"] == "01 - Song"
assert attributes["volume"] == 40
async def test_the_player_sensor_reports_the_mouse_s_active_figure(
bus: EventBus,
mqtt_config: MqttConfig,
transport: FakeTransport,
mouse: MusicMouseDevice,
player: FakePlayer,
publisher: RecordingPublisher,
) -> None:
"""The sensor reads ``mouse.active_figure`` rather than keeping its own copy of it,
so it agrees with the mouse even if it missed the event that changed it."""
from musicmouse.services.mqtt.player import PlayerSensor
entity = PlayerSensor(bus, mqtt_config, mouse, player)
entity.attach(publisher)
transport.inject(RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), source="device"))
await bus.drain()
attributes = publisher.last_json(f"{entity.base_topic}/attributes")
assert attributes["figure"] == "fuchs"
assert mouse.active_figure == "fuchs"
# --------------------------------------------------------------------- triggers
async def test_a_button_trigger_fires_only_for_its_own_event(
bus: EventBus, mqtt_config: MqttConfig, publisher: RecordingPublisher
) -> None:
trigger = ButtonTrigger(bus, mqtt_config, Button.ROTARY, ButtonAction.PRESSED)
trigger.attach(publisher)
bus.emit(ButtonEvent(button=Button.LEFT, action=ButtonAction.PRESSED, source="device"))
bus.emit(ButtonEvent(button=Button.ROTARY, action=ButtonAction.RELEASED, source="device"))
bus.emit(ButtonEvent(button=Button.ROTARY, action=ButtonAction.PRESSED, source="device"))
await bus.drain()
assert publisher.payloads_on(trigger.trigger_topic) == ["pressed"]
async def test_button_trigger_discovery(bus: EventBus, mqtt_config: MqttConfig) -> None:
trigger = ButtonTrigger(bus, mqtt_config, Button.ROTARY, ButtonAction.LONG_PRESSED)
payload = trigger.discovery_payload()
assert trigger.discovery_topic == (
"homeassistant/device_automation/musicmouse/rotary_long_pressed/config"
)
assert payload["automation_type"] == "trigger"
assert payload["type"] == "button_long_press"
assert payload["subtype"] == "rotary"
async def test_a_touch_trigger_fires(
bus: EventBus, mqtt_config: MqttConfig, publisher: RecordingPublisher
) -> None:
trigger = TouchTrigger(bus, mqtt_config, TouchButton.LEFT_EAR, pressed=True)
trigger.attach(publisher)
bus.emit(TouchButtonPressed(button=TouchButton.RIGHT_EAR, source="device"))
bus.emit(TouchButtonPressed(button=TouchButton.LEFT_EAR, source="device"))
await bus.drain()
assert publisher.payloads_on(trigger.trigger_topic) == ["left_ear"]
async def test_the_tag_scanner_publishes_every_read(
bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, publisher: RecordingPublisher
) -> None:
scanner = TagScanner(bus, mqtt_config)
scanner.attach(publisher)
bus.emit(
RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), figure="fuchs", source="device")
)
await bus.drain()
assert json.loads(publisher.payloads_on(scanner.scan_topic)[-1]) == {
"tag_id": "04a1b2c3d4",
"figure": "fuchs",
"known": True,
}
async def test_tag_scanner_discovery(bus: EventBus, mqtt_config: MqttConfig) -> None:
scanner = TagScanner(bus, mqtt_config)
assert scanner.discovery_topic == "homeassistant/tag/musicmouse/config"
assert scanner.discovery_payload()["value_template"] == "{{ value_json.tag_id }}"
# ---------------------------------------------------------------------- wiring
def test_every_entity_has_a_unique_discovery_topic(
bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, player: FakePlayer
) -> None:
entities = build_entities(bus, mqtt_config, mouse, player)
topics = [entity.discovery_topic for entity in entities]
assert len(topics) == len(set(topics))
assert len(entities) > 20
def test_command_topics_do_not_collide(
bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, player: FakePlayer
) -> None:
entities = build_entities(bus, mqtt_config, mouse, player)
topics = [topic for entity in entities for topic in entity.command_topics()]
assert len(topics) == len(set(topics))
def test_all_three_led_zones_are_exposed(
bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, player: FakePlayer
) -> None:
entities = build_entities(bus, mqtt_config, mouse, player)
lights = [e for e in entities if isinstance(e, LightEntity)]
assert {light.zone for light in lights} == set(LedZone)