IR remote: power key switches the screen; count irserver's repeats

KEY_POWER emits ToggleScreenRequested, which a new ScreenService turns into the
optional general.screen.toggle_command (musicdolphin: ansible's kiosk-screen). It is
a service, not a reaction, so the subprocess runs in its own task instead of holding
the bus; a press while one is still running is dropped, and failures, hangs and a
missing command are only logged.

irserver reports every line of a held button as repeat 00, so a slightly long press
on Next skipped several tracks and Power would switch the screen off and on again.
A line of the same button within 0.25s of the previous one now counts as a repeat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 16:13:22 +02:00
parent 92644e9519
commit 2d0caeb732
8 changed files with 303 additions and 2 deletions

View File

@@ -21,6 +21,7 @@ from musicmouse.events import (
PlayRequested,
PlaySeriesLatestRequested,
PreviousTrackRequested,
ToggleScreenRequested,
VolumeChangeRequested,
)
from musicmouse.services.lirc import LircService
@@ -265,6 +266,56 @@ async def test_volume_buttons_act_on_every_repeat(
assert all(event.delta < 0 for event in seen)
async def test_a_held_button_from_irserver_acts_once(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
# irserver sends every line of a held button as repeat 00.
station, server = lircd
port = server.sockets[0].getsockname()[1]
seen: list[Event] = []
sim.bus.subscribe(NextTrackRequested, seen.append)
sim.bus.subscribe(VolumeChangeRequested, seen.append)
assert isinstance(sim.clock, FakeClock)
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
for _ in range(3):
await station.send("b7b0000000000000 00 key_forward hauppauge")
await sim.clock.advance(0.11)
await sim.clock.advance(1.0)
await station.send("b7b0000000000000 00 key_forward hauppauge")
# Volume still ramps for as long as it is held.
for _ in range(3):
await station.send("b720000000000000 00 key_volumeup hauppauge")
await sim.clock.advance(0.11)
await sim.bus.drain()
finally:
task.cancel()
assert [type(event) for event in seen] == [NextTrackRequested] * 2 + [VolumeChangeRequested] * 3
async def test_the_power_button_toggles_the_screen(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
station, server = lircd
port = server.sockets[0].getsockname()[1]
seen: list[Event] = []
sim.bus.subscribe(ToggleScreenRequested, seen.append)
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
await station.send("b7d0000000000000 00 key_power hauppauge")
await station.send("b7d0000000000000 00 key_power hauppauge") # still held
await sim.bus.drain()
finally:
task.cancel()
assert len(seen) == 1
async def test_mute_toggles_and_restores(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
@@ -281,6 +332,9 @@ async def test_mute_toggles_and_restores(
await sim.bus.drain()
assert sim.player.volume == 0
# A second press, not the first one held - see the repeat counting below.
assert isinstance(sim.clock, FakeClock)
await sim.clock.advance(1.0)
await station.send("0 00 KEY_MUTE Hauppauge")
await sim.bus.drain()
assert sim.player.volume == 37

View File

@@ -0,0 +1,110 @@
"""The screen service: runs the configured command per toggle, and never takes the bus down."""
from __future__ import annotations
import asyncio
import shlex
import sys
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from musicmouse.bus import EventBus
from musicmouse.config import ScreenConfig
from musicmouse.events import ToggleScreenRequested
from musicmouse.services.screen import ScreenService
def _python(code: str) -> str:
return f"{shlex.quote(sys.executable)} -c {shlex.quote(code)}"
def _appending(log: Path, *, then: str = "") -> str:
"""A command that appends a line to ``log`` each time it runs."""
return _python(f"open({str(log)!r}, 'a').write('ran\\n'); {then}")
@pytest.fixture
async def bus() -> AsyncIterator[EventBus]:
async with EventBus() as bus:
yield bus
async def _settle(service: ScreenService) -> None:
while service._running is not None and not service._running.done():
await asyncio.sleep(0.01)
async def _started(bus: EventBus, config: ScreenConfig) -> tuple[ScreenService, asyncio.Task[None]]:
service = ScreenService(bus, config)
task = asyncio.create_task(service.run())
await asyncio.sleep(0) # let it subscribe
return service, task
async def test_each_press_runs_the_command(bus: EventBus, tmp_path: Path) -> None:
log = tmp_path / "ran"
service, task = await _started(bus, ScreenConfig(toggle_command=_appending(log)))
try:
for _ in range(2):
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
task.cancel()
assert log.read_text() == "ran\nran\n"
async def test_a_press_while_the_command_runs_is_ignored(bus: EventBus, tmp_path: Path) -> None:
log = tmp_path / "ran"
command = _appending(log, then="import time; time.sleep(0.3)")
service, task = await _started(bus, ScreenConfig(toggle_command=command))
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
task.cancel()
assert log.read_text() == "ran\n"
async def test_a_failing_command_is_logged(bus: EventBus, caplog: pytest.LogCaptureFixture) -> None:
command = _python("import sys; print('no display'); sys.exit(3)")
service, task = await _started(bus, ScreenConfig(toggle_command=command))
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
task.cancel()
assert "exit 3" in caplog.text
assert "no display" in caplog.text
async def test_a_missing_command_is_logged(
bus: EventBus, caplog: pytest.LogCaptureFixture, tmp_path: Path
) -> None:
service, task = await _started(
bus, ScreenConfig(toggle_command=str(tmp_path / "no-such-command"))
)
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
task.cancel()
assert "could not start" in caplog.text
async def test_a_hung_command_is_killed(bus: EventBus, caplog: pytest.LogCaptureFixture) -> None:
command = _python("import time; time.sleep(30)")
service, task = await _started(bus, ScreenConfig(toggle_command=command, timeout=0.2))
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await asyncio.wait_for(_settle(service), 5)
finally:
task.cancel()
assert "killed" in caplog.text