ScreenService plays a synthesised two-bubble "blubb" (musicmouse.blubb) after each switch, through aplay on the music's ALSA device at the player's volume: rising for on, falling for off, going by the last word the toggle command prints. Nothing plays with simulated audio or at volume 0. Built from samples rather than shipped as a file, like the front-end's WebAudio blips, so its loudness follows the volume. Del maps to a new "screen" action on every page, which App.tsx sends to the new POST /api/screen/toggle only when the page is served from localhost - the kiosk on the device - so Del on a tablet does not switch off a screen in another room. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
5.9 KiB
Python
187 lines
5.9 KiB
Python
"""The screen service: runs the configured command per toggle, and never takes the bus down."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import io
|
|
import os
|
|
import shlex
|
|
import sys
|
|
import wave
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from musicmouse.blubb import RATE, blubb_wav
|
|
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,
|
|
*,
|
|
alsa_device: str | None = None,
|
|
volume: float = 0.5,
|
|
) -> tuple[ScreenService, asyncio.Task[None]]:
|
|
service = ScreenService(bus, config, alsa_device=alsa_device, volume=lambda: volume)
|
|
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
|
|
|
|
|
|
# ------------------------------------------------------------------------ the blubb
|
|
|
|
|
|
def test_the_blubb_is_a_short_mono_wav() -> None:
|
|
with wave.open(io.BytesIO(blubb_wav(on=True, gain=0.5))) as wav:
|
|
assert (wav.getnchannels(), wav.getsampwidth(), wav.getframerate()) == (1, 2, RATE)
|
|
assert 0.1 < wav.getnframes() / RATE < 0.5
|
|
|
|
|
|
def test_on_and_off_sound_different() -> None:
|
|
assert blubb_wav(on=True, gain=0.5) != blubb_wav(on=False, gain=0.5)
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_aplay(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""An ``aplay`` on PATH that records its arguments and what it was fed."""
|
|
bin_dir = tmp_path / "bin"
|
|
bin_dir.mkdir()
|
|
record = tmp_path / "aplay"
|
|
script = bin_dir / "aplay"
|
|
script.write_text(
|
|
f'#!/bin/sh\necho "$@" > {shlex.quote(str(record))}.args\n'
|
|
f"cat > {shlex.quote(str(record))}.wav\n"
|
|
)
|
|
script.chmod(0o755)
|
|
monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ['PATH']}")
|
|
return record
|
|
|
|
|
|
async def test_a_switch_plays_the_blubb_its_way(bus: EventBus, fake_aplay: Path) -> None:
|
|
service, task = await _started(
|
|
bus, ScreenConfig(toggle_command=_python("print('off')")), alsa_device="softvol_effects"
|
|
)
|
|
try:
|
|
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
|
|
await _settle(service)
|
|
finally:
|
|
task.cancel()
|
|
|
|
assert Path(f"{fake_aplay}.args").read_text().split() == ["-q", "-D", "softvol_effects", "-"]
|
|
assert Path(f"{fake_aplay}.wav").read_bytes() == blubb_wav(on=False, gain=0.35 * 0.5)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("output", "alsa_device", "volume"),
|
|
[
|
|
("print('on')", None, 0.5), # simulated audio
|
|
("print('on')", "softvol_effects", 0.0), # muted
|
|
("print('done')", "softvol_effects", 0.5), # no direction to go by
|
|
],
|
|
)
|
|
async def test_no_blubb(
|
|
bus: EventBus, fake_aplay: Path, output: str, alsa_device: str | None, volume: float
|
|
) -> None:
|
|
service, task = await _started(
|
|
bus, ScreenConfig(toggle_command=_python(output)), alsa_device=alsa_device, volume=volume
|
|
)
|
|
try:
|
|
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
|
|
await _settle(service)
|
|
finally:
|
|
task.cancel()
|
|
|
|
assert not Path(f"{fake_aplay}.wav").exists()
|