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>
This commit is contained in:
2026-10-04 00:29:18 +02:00
parent a55cc4f67f
commit d465a74cde
37 changed files with 1570 additions and 68 deletions

View File

@@ -14,6 +14,7 @@ from musicmouse.effects import (
OFF,
EffectAlexaSwipeConfig,
EffectCircularConfig,
EffectSegmentSelect,
EffectStaticConfig,
)
from musicmouse.events import (
@@ -23,9 +24,12 @@ from musicmouse.events import (
Event,
LedEffectChanged,
RfidTokenRead,
RotaryTurned,
SelectorPressed,
SelectorTurned,
TouchButtonPressed,
)
from musicmouse.hardware import Button, ButtonAction, LedZone, TouchButton
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
from musicmouse.simulator import FakeTransport
FUCHS = bytes.fromhex("04a1b2c3d4")
@@ -82,6 +86,56 @@ async def test_button_press_reaches_the_bus(
]
def _turn(increment: int, direction: RotaryDirection) -> bytes:
return encode_input_event(RotaryTurned(position=0, increment=increment, direction=direction))
def _knob(action: ButtonAction) -> bytes:
return encode_input_event(ButtonEvent(button=Button.ROTARY, action=action))
async def test_a_captured_knob_turns_the_selector_not_the_volume(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.capture_knob(True)
device.feed(_turn(2, RotaryDirection.UP))
device.feed(_turn(1, RotaryDirection.DOWN))
# The first turn after boot reports no increment, but it was still a detent.
device.feed(_turn(0, RotaryDirection.UP))
await bus.drain()
assert [e.steps for e in only(seen, SelectorTurned)] == [2, -1, 1]
assert not only(seen, RotaryTurned)
async def test_a_captured_knob_press_is_the_selectors_alone(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.capture_knob(True)
for action in (ButtonAction.PRESSED, ButtonAction.RELEASED):
device.feed(_knob(action))
# The other buttons are not captured.
device.feed(encode_input_event(ButtonEvent(button=Button.LEFT, action=ButtonAction.PRESSED)))
await bus.drain()
assert len(only(seen, SelectorPressed)) == 1
assert [e.button for e in only(seen, ButtonEvent)] == [Button.LEFT]
async def test_a_released_knob_is_a_volume_knob_again(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.capture_knob(True)
device.capture_knob(False)
device.feed(_turn(1, RotaryDirection.UP))
device.feed(_knob(ButtonAction.PRESSED))
await bus.drain()
assert len(only(seen, RotaryTurned)) == 1
assert len(only(seen, ButtonEvent)) == 1
assert not only(seen, SelectorTurned) and not only(seen, SelectorPressed)
async def test_touch_press_reaches_the_bus(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
@@ -255,6 +309,19 @@ async def test_an_effect_a_zone_does_not_support_is_reported_not_sent(
assert "cannot be sent to the mouse LEDs" in caplog.text
async def test_only_the_ring_can_be_a_selector(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
effect = EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 1, 0, 0)))
device.set_effect(LedZone.RING, effect)
device.set_effect(LedZone.SHELF, effect)
await bus.drain()
sent = transport.effect(LedZone.RING)
assert sent is not None and sent.as_bytes() == effect.as_bytes()
assert transport.effect(LedZone.SHELF) is None
async def test_button_brightness_sets_both_backlights(
device: MusicMouseDevice, transport: FakeTransport
) -> None: