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>
190 lines
6.2 KiB
Python
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)
|