diff --git a/python-backend/config.yml.example b/python-backend/config.yml.example index 13bda14..1ba379c 100644 --- a/python-backend/config.yml.example +++ b/python-backend/config.yml.example @@ -94,9 +94,17 @@ general: port: 2222 # this deployment's lircd listens on 2222, not its own # default of 8765 - see the ansible role remote_name: "Hauppauge" # other remotes registered with the same lircd (an LED - # remote, say) are ignored + # remote, say) are ignored (case-insensitively) reconnect_interval: 5.0 + # The attached monitor, switched by the remote's power key. Omit the section on a + # device without one and the key does nothing. The command runs on every press, split + # shell-style but not through a shell; it decides itself whether that means on or off + # (ansible's pi_kiosk installs kiosk-screen for this). + # screen: + # toggle_command: "/usr/local/bin/kiosk-screen toggle" + # timeout: 5.0 # seconds before a hung command is killed + # Home Assistant integration. Omit the whole section to run without MQTT. # The backend exposes three lights, a player sensor, a volume slider, transport # buttons, device triggers for every button/touch area, and a tag scanner. diff --git a/python-backend/musicmouse/__main__.py b/python-backend/musicmouse/__main__.py index 06f5b93..eadcc9f 100644 --- a/python-backend/musicmouse/__main__.py +++ b/python-backend/musicmouse/__main__.py @@ -39,6 +39,7 @@ from musicmouse.services.base import Service from musicmouse.services.lirc import LircService from musicmouse.services.mqtt import MqttService, build_entities from musicmouse.services.podcasts import PodcastFeedService +from musicmouse.services.screen import ScreenService from musicmouse.services.web import WebService from musicmouse.tippen.curriculum import CurriculumError from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime @@ -371,6 +372,10 @@ def _build_services( else: services.append(LircService(app, lirc_config, clock=clock)) + screen_config = app.config.general.screen + if screen_config is not None: + services.append(ScreenService(app.bus, screen_config)) + # Unconditional: a show only starts downloading once someone drops a `feed.txt` # into its folder, so there is nothing to gate here with its own config section. web_service = _service_of_type(services, WebService) diff --git a/python-backend/musicmouse/config.py b/python-backend/musicmouse/config.py index 54f268c..8a4e09d 100644 --- a/python-backend/musicmouse/config.py +++ b/python-backend/musicmouse/config.py @@ -163,6 +163,19 @@ class LircConfig(_Strict): reconnect_interval: float = Field(default=5.0, gt=0) +class ScreenConfig(_Strict): + """The monitor attached to the device, switched from the IR remote's power key. + + Omit the whole section on a device without one: the key then does nothing. + """ + + #: Run on every press, split shell-style but not through a shell. On musicdolphin + #: this is ansible's ``kiosk-screen toggle``, which asks X whether the monitor is + #: on and flips it - the backend keeps no screen state of its own. + toggle_command: str + timeout: float = Field(default=5.0, gt=0) + + class LibraryConfig(_Strict): """Where the music lives. @@ -280,6 +293,7 @@ class GeneralConfig(_Strict): web: WebConfig | None = None ha: HaConfig | None = None lirc: LircConfig | None = None + screen: ScreenConfig | None = None tippen: TippenConfig | None = None min_volume: int = Field(default=0, ge=0, le=200) diff --git a/python-backend/musicmouse/events.py b/python-backend/musicmouse/events.py index a09eb8d..2a48e64 100644 --- a/python-backend/musicmouse/events.py +++ b/python-backend/musicmouse/events.py @@ -46,6 +46,7 @@ __all__ = [ "RotaryTurned", "SetVolumeRequested", "StateEvent", + "ToggleScreenRequested", "TouchButtonPressed", "TouchButtonReleased", "TrackChanged", @@ -213,6 +214,11 @@ class SetVolumeRequested(IntentEvent): volume: int +@dataclass(frozen=True, slots=True, kw_only=True) +class ToggleScreenRequested(IntentEvent): + """Switch the attached monitor off if it is on, on if it is off (``general.screen``).""" + + @dataclass(frozen=True, slots=True, kw_only=True) class LedEffectRequested(IntentEvent): zone: LedZone diff --git a/python-backend/musicmouse/services/lirc/service.py b/python-backend/musicmouse/services/lirc/service.py index 9433cff..7843f63 100644 --- a/python-backend/musicmouse/services/lirc/service.py +++ b/python-backend/musicmouse/services/lirc/service.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio import logging from collections.abc import Callable +from dataclasses import replace from musicmouse.app import App from musicmouse.clock import Clock, RealClock @@ -25,6 +26,7 @@ from musicmouse.events import ( PlaySeriesLatestRequested, PreviousTrackRequested, SetVolumeRequested, + ToggleScreenRequested, VolumeChangeRequested, ) from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line @@ -43,12 +45,20 @@ _TRANSPORT: dict[str, Callable[[], IntentEvent]] = { "KEY_PREVIOUS": lambda: PreviousTrackRequested(source="lirc"), "KEY_REWIND": lambda: PreviousTrackRequested(source="lirc"), "KEY_FORWARD": lambda: NextTrackRequested(source="lirc"), + # Does nothing unless general.screen is configured - see ScreenService. + "KEY_POWER": lambda: ToggleScreenRequested(source="lirc"), } #: Acted on every repeat, for a continuous ramp while held - same feel as the rotary #: encoder (see ``reactions.playback.rotary_turned``). _VOLUME: dict[str, int] = {"KEY_VOLUMEUP": 1, "KEY_VOLUMEDOWN": -1} +#: irserver reports every line of a held button as repeat ``00``, so a long-ish press +#: would act several times. A line of the same button arriving within this many seconds +#: of the previous one is counted as a repeat instead. The Hauppauge remote resends +#: about every 0.11s while held; lircd numbers repeats itself and is unaffected. +_REPEAT_GAP = 0.25 + _DIGITS: dict[str, Digit] = { "BTN_0": "0", "BTN_1": "1", @@ -73,6 +83,9 @@ class LircService: #: Volume to restore on the next mute press, remembered the way the frontend's #: own mute toggle does (App.tsx) rather than via any new Player API. self._pre_mute_volume: int | None = None + #: The last line acted on, for counting repeats irserver does not count. + self._last: LircButtonEvent | None = None + self._last_at = 0.0 async def run(self) -> None: """Connect, read lines until the link drops, wait, repeat. Runs until cancelled.""" @@ -101,11 +114,25 @@ class LircService: event = parse_line(raw.decode(errors="replace")) if event is None or event.remote.casefold() != self.config.remote_name.casefold(): continue - self._handle(event) + self._handle(self._count_repeat(event)) finally: writer.close() self.app.bus.emit(ConnectionChanged(target="lirc", connected=False, source="lirc")) + def _count_repeat(self, event: LircButtonEvent) -> LircButtonEvent: + now = self._clock.now() + last, last_at = self._last, self._last_at + self._last_at = now + if ( + event.repeat == 0 + and last is not None + and last.button == event.button + and now - last_at < _REPEAT_GAP + ): + event = replace(event, repeat=last.repeat + 1) + self._last = event + return event + def _handle(self, event: LircButtonEvent) -> None: if event.button in _TRANSPORT: if event.repeat == 0: diff --git a/python-backend/musicmouse/services/screen.py b/python-backend/musicmouse/services/screen.py new file mode 100644 index 0000000..e96c04c --- /dev/null +++ b/python-backend/musicmouse/services/screen.py @@ -0,0 +1,77 @@ +"""The attached monitor: runs ``general.screen.toggle_command`` on each toggle request. + +A service rather than a reaction because the command is a subprocess: the bus awaits +async handlers, so running it there would hold every other event up behind ``xset``. +Here each press gets its own task, and a hung or failing command is only logged. +""" + +from __future__ import annotations + +import asyncio +import logging +import shlex + +from musicmouse.bus import EventBus +from musicmouse.config import ScreenConfig +from musicmouse.events import ToggleScreenRequested + +_log = logging.getLogger(__name__) + +__all__ = ["ScreenService"] + + +class ScreenService: + name = "screen" + + def __init__(self, bus: EventBus, config: ScreenConfig) -> None: + self.bus = bus + self.config = config + self._running: asyncio.Task[None] | None = None + + async def run(self) -> None: + unsubscribe = self.bus.subscribe(ToggleScreenRequested, self._on_toggle) + try: + await asyncio.Event().wait() + finally: + unsubscribe() + if self._running is not None: + self._running.cancel() + + def _on_toggle(self, _event: ToggleScreenRequested) -> None: + # A second press while the first command is still running would read the + # monitor's state before the first one changed it, and undo it. + if self._running is not None and not self._running.done(): + _log.debug("Screen toggle still running; ignoring this press") + return + self._running = asyncio.create_task(self.toggle(), name="screen-toggle") + + async def toggle(self) -> None: + argv = shlex.split(self.config.toggle_command) + try: + process = await asyncio.create_subprocess_exec( + *argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT + ) + except OSError as exc: + _log.warning("Screen toggle %r could not start: %s", self.config.toggle_command, exc) + return + try: + output, _ = await asyncio.wait_for(process.communicate(), self.config.timeout) + except TimeoutError: + process.kill() + await process.wait() + _log.warning( + "Screen toggle %r took longer than %gs; killed", + self.config.toggle_command, + self.config.timeout, + ) + return + text = output.decode(errors="replace").strip() + if process.returncode != 0: + _log.warning( + "Screen toggle %r failed (exit %d): %s", + self.config.toggle_command, + process.returncode, + text, + ) + else: + _log.info("Screen toggled%s", f": {text}" if text else "") diff --git a/python-backend/tests/test_lirc.py b/python-backend/tests/test_lirc.py index 82cc7eb..2832dd7 100644 --- a/python-backend/tests/test_lirc.py +++ b/python-backend/tests/test_lirc.py @@ -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 diff --git a/python-backend/tests/test_screen.py b/python-backend/tests/test_screen.py new file mode 100644 index 0000000..a7511a9 --- /dev/null +++ b/python-backend/tests/test_screen.py @@ -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