Files
musicmouse/python-backend/tests/test_effects.py
Martin Bauer d465a74cde Figures: several albums per figure, picked on the LED ring
A figure folder that holds album folders instead of audio can play any of them,
up to six, in folder order (depth-first, alphabetical). Each is its own album,
titled "Fuchs - <folder>" and of the figure's kind. Putting the figure down plays
the album it played last, or the first, straight away; another figure placed in
between forgets the position in that album, not the album itself.

Meanwhile the ring offers all of them: one section per album from a fixed palette,
edges shifting towards the neighbours' hue, the selected one bright and wobbling.
The knob moves the selection one section per detent and wraps; pressing plays the
selected album, and left alone for 8 s the picker fades into the figure's colours
and the music carries on. While open, the device hands the knob to the picker
(`capture_knob`): turns do not change the volume and the press is not published to
Home Assistant. A tick per step and a confirm "pling" go with it.

The firmware only draws: one new ring effect, LED_WHEEL_EFFECT_SEGMENT_SELECT (23),
re-sent on every detent with the section to glide from. The look is tunable through
a temporary `general.figure_selection` config section until the right values
become constants.

Every UI sound - the screen's blubb, the parents' timer, the picker - now follows
the music volume with a floor, times a new parent-mode "Effekt-Lautstärke"
(`general.effects_volume`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:29:18 +02:00

190 lines
6.2 KiB
Python

"""Golden-byte tests for the LED effect payloads.
These pin the exact layout the firmware reads back into its C++ structs. If one of
them fails, either the firmware struct changed or an effect field was reordered - both
would otherwise show up only as garbled LEDs on the real device.
"""
from __future__ import annotations
import pytest
from musicmouse.color import ColorHSV, ColorRGBW
from musicmouse.effects import (
EffectAlexaSwipeConfig,
EffectCircularConfig,
EffectRandomTwoColorInterpolationConfig,
EffectReverseSwipe,
EffectSegmentSelect,
EffectStaticConfig,
EffectStaticDetailedConfig,
EffectSwipeAndChange,
LedEffect,
)
def test_color_rgbw_is_four_bytes() -> None:
assert ColorRGBW(1.0, 0.5, 0.0, 0.25).as_bytes().hex() == "ff7f003f"
def test_color_rgbw_rejects_out_of_range_channels() -> None:
with pytest.raises(ValueError, match=r"within 0\.\.1"):
ColorRGBW(1.5, 0, 0, 0).as_bytes()
def test_color_hsv_is_three_floats() -> None:
# h=180.0, s=1.0, v=0.5
assert ColorHSV(180.0, 1.0, 0.5).as_bytes().hex() == "00003443" "0000803f" "0000003f"
def test_color_hsv_from_rgb() -> None:
assert ColorHSV.from_rgb(ColorRGBW(1, 0, 0, 0)) == ColorHSV(0.0, 1.0, 1.0)
assert ColorHSV.from_rgb(ColorRGBW(0, 1, 0, 0)) == ColorHSV(120.0, 1.0, 1.0)
def test_static() -> None:
effect = EffectStaticConfig(ColorRGBW(1.0, 0.5, 0.0, 0.25), begin=3, end=45)
assert effect.as_bytes().hex() == "ff7f003f" "0300" "2d00"
def test_static_detailed() -> None:
effect = EffectStaticDetailedConfig(
ColorRGBW(0, 0, 0, 1.0), increment=4, begin=0.25, end=0.75, transition_time_in_ms=500
)
assert effect.as_bytes().hex() == (
"000000ff" # color
"0400" # increment (uint16)
"0000803e" # begin 0.25f
"0000403f" # end 0.75f
"0000fa43" # transition 500.0f
)
def test_circular() -> None:
effect = EffectCircularConfig(speed=360, width=180, color=ColorRGBW(0, 0, 1, 0))
assert effect.as_bytes().hex() == "0000b443" "00003443" "0000ff00"
def test_reverse_swipe() -> None:
effect = EffectReverseSwipe(swipe_speed=720, bell_curve_width_in_leds=3, start_position=180)
assert effect.as_bytes().hex() == "00003444" "00004040" "00003443"
def test_alexa_swipe() -> None:
effect = EffectAlexaSwipeConfig(
primary_color_width=180,
transition_width=180,
swipe_speed=720,
bell_curve_width_in_leds=3,
start_position=180,
forward=False,
primary_color=ColorRGBW(1, 0, 0, 0),
secondary_color=ColorRGBW(0, 1, 0, 0),
)
assert effect.as_bytes().hex() == (
"00003443" "00003443" "00003444" "00004040" "00003443" # five floats
"00" # forward = false
"ff000000" # primary
"00ff0000" # secondary
)
def test_random_two_color_interpolation() -> None:
effect = EffectRandomTwoColorInterpolationConfig(
cycle_durations_ms=1000,
start_with_existing=True,
num_segments=3,
hue1_random=False,
hue2_random=True,
color1=ColorHSV(180.0, 1.0, 0.5),
color2=ColorHSV(0.0, 0.0, 0.0),
)
assert effect.as_bytes().hex() == (
"e8030000" # cycle_durations_ms int32
"01" # start_with_existing
"03000000" # num_segments int32
"00" # hue1_random
"01" # hue2_random
"000034430000803f0000003f" # color1 hsv
"000000000000000000000000" # color2 hsv
)
def test_rgb_colors_are_converted_to_hsv_on_the_wire() -> None:
"""The firmware struct is HSV; reactions hand it RGBW figure colours."""
as_rgb = EffectRandomTwoColorInterpolationConfig(
color1=ColorRGBW(1, 0, 0, 0), color2=ColorRGBW(0, 1, 0, 0)
)
as_hsv = EffectRandomTwoColorInterpolationConfig(
color1=ColorHSV(0.0, 1.0, 1.0), color2=ColorHSV(120.0, 1.0, 1.0)
)
assert as_rgb.as_bytes() == as_hsv.as_bytes()
def test_swipe_and_change_is_the_two_payloads_concatenated() -> None:
effect = EffectSwipeAndChange()
assert effect.as_bytes() == effect.swipe.as_bytes() + effect.change.as_bytes()
@pytest.mark.parametrize(
("effect", "size"),
[
(EffectStaticConfig(ColorRGBW(0, 0, 0, 0)), 8),
(EffectStaticDetailedConfig(ColorRGBW(0, 0, 0, 0)), 18),
(EffectCircularConfig(), 12),
(EffectAlexaSwipeConfig(), 29),
(EffectRandomTwoColorInterpolationConfig(), 35),
(EffectReverseSwipe(), 12),
(EffectSwipeAndChange(), 64),
(EffectSegmentSelect(colors=(ColorRGBW(0, 0, 0, 0),)), 53),
],
)
def test_payload_sizes(effect: LedEffect, size: int) -> None:
assert len(effect.as_bytes()) == size
def test_segment_select() -> None:
effect = EffectSegmentSelect(
colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 0, 1, 0)),
selected=1,
previous=0,
gap_leds=2,
offset_degrees=180.0,
direction=-1,
dim_factor=0.5,
wobble_amplitude=1.5,
wobble_period_ms=1000.0,
slide_ms=200.0,
edge_blend=0.25,
)
assert effect.as_bytes().hex() == (
"02" "01" "00" "02" # count, selected, previous, gap
"ff000000" "0000ff00" + "00000000" * 4 # always six colours on the wire
+ "00003443" # offset 180.0
"ff" # direction -1
"0000003f" # dim 0.5
"0000c03f" # wobble 1.5
"00007a44" # period 1000.0
"00004843" # slide 200.0
"0000803e" # edge blend 0.25
)
def test_segment_select_without_previous_does_not_glide() -> None:
effect = EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0),) * 3, selected=2)
assert effect.as_bytes()[:3] == bytes([3, 2, 2])
def test_segment_select_round_trips() -> None:
# Every float exact in float32, as the firmware has them.
effect = EffectSegmentSelect(
colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 1, 0, 0)), previous=1, dim_factor=0.25
)
assert EffectSegmentSelect.from_bytes(effect.as_bytes()) == effect
@pytest.mark.parametrize("count", [0, 7])
def test_segment_select_takes_one_to_six_sections(count: int) -> None:
with pytest.raises(ValueError, match="between 1 and 6"):
EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0),) * count)