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:
@@ -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
|
||||
|
||||
110
python-backend/tests/test_screen.py
Normal file
110
python-backend/tests/test_screen.py
Normal 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
|
||||
Reference in New Issue
Block a user