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

@@ -94,9 +94,17 @@ general:
port: 2222 # this deployment's lircd listens on 2222, not its own port: 2222 # this deployment's lircd listens on 2222, not its own
# default of 8765 - see the ansible role # default of 8765 - see the ansible role
remote_name: "Hauppauge" # other remotes registered with the same lircd (an LED 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 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. # Home Assistant integration. Omit the whole section to run without MQTT.
# The backend exposes three lights, a player sensor, a volume slider, transport # The backend exposes three lights, a player sensor, a volume slider, transport
# buttons, device triggers for every button/touch area, and a tag scanner. # buttons, device triggers for every button/touch area, and a tag scanner.

View File

@@ -39,6 +39,7 @@ from musicmouse.services.base import Service
from musicmouse.services.lirc import LircService from musicmouse.services.lirc import LircService
from musicmouse.services.mqtt import MqttService, build_entities from musicmouse.services.mqtt import MqttService, build_entities
from musicmouse.services.podcasts import PodcastFeedService from musicmouse.services.podcasts import PodcastFeedService
from musicmouse.services.screen import ScreenService
from musicmouse.services.web import WebService from musicmouse.services.web import WebService
from musicmouse.tippen.curriculum import CurriculumError from musicmouse.tippen.curriculum import CurriculumError
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
@@ -371,6 +372,10 @@ def _build_services(
else: else:
services.append(LircService(app, lirc_config, clock=clock)) 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` # 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. # into its folder, so there is nothing to gate here with its own config section.
web_service = _service_of_type(services, WebService) web_service = _service_of_type(services, WebService)

View File

@@ -163,6 +163,19 @@ class LircConfig(_Strict):
reconnect_interval: float = Field(default=5.0, gt=0) 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): class LibraryConfig(_Strict):
"""Where the music lives. """Where the music lives.
@@ -280,6 +293,7 @@ class GeneralConfig(_Strict):
web: WebConfig | None = None web: WebConfig | None = None
ha: HaConfig | None = None ha: HaConfig | None = None
lirc: LircConfig | None = None lirc: LircConfig | None = None
screen: ScreenConfig | None = None
tippen: TippenConfig | None = None tippen: TippenConfig | None = None
min_volume: int = Field(default=0, ge=0, le=200) min_volume: int = Field(default=0, ge=0, le=200)

View File

@@ -46,6 +46,7 @@ __all__ = [
"RotaryTurned", "RotaryTurned",
"SetVolumeRequested", "SetVolumeRequested",
"StateEvent", "StateEvent",
"ToggleScreenRequested",
"TouchButtonPressed", "TouchButtonPressed",
"TouchButtonReleased", "TouchButtonReleased",
"TrackChanged", "TrackChanged",
@@ -213,6 +214,11 @@ class SetVolumeRequested(IntentEvent):
volume: int 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) @dataclass(frozen=True, slots=True, kw_only=True)
class LedEffectRequested(IntentEvent): class LedEffectRequested(IntentEvent):
zone: LedZone zone: LedZone

View File

@@ -11,6 +11,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from collections.abc import Callable from collections.abc import Callable
from dataclasses import replace
from musicmouse.app import App from musicmouse.app import App
from musicmouse.clock import Clock, RealClock from musicmouse.clock import Clock, RealClock
@@ -25,6 +26,7 @@ from musicmouse.events import (
PlaySeriesLatestRequested, PlaySeriesLatestRequested,
PreviousTrackRequested, PreviousTrackRequested,
SetVolumeRequested, SetVolumeRequested,
ToggleScreenRequested,
VolumeChangeRequested, VolumeChangeRequested,
) )
from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line 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_PREVIOUS": lambda: PreviousTrackRequested(source="lirc"),
"KEY_REWIND": lambda: PreviousTrackRequested(source="lirc"), "KEY_REWIND": lambda: PreviousTrackRequested(source="lirc"),
"KEY_FORWARD": lambda: NextTrackRequested(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 #: Acted on every repeat, for a continuous ramp while held - same feel as the rotary
#: encoder (see ``reactions.playback.rotary_turned``). #: encoder (see ``reactions.playback.rotary_turned``).
_VOLUME: dict[str, int] = {"KEY_VOLUMEUP": 1, "KEY_VOLUMEDOWN": -1} _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] = { _DIGITS: dict[str, Digit] = {
"BTN_0": "0", "BTN_0": "0",
"BTN_1": "1", "BTN_1": "1",
@@ -73,6 +83,9 @@ class LircService:
#: Volume to restore on the next mute press, remembered the way the frontend's #: 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. #: own mute toggle does (App.tsx) rather than via any new Player API.
self._pre_mute_volume: int | None = None 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: async def run(self) -> None:
"""Connect, read lines until the link drops, wait, repeat. Runs until cancelled.""" """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")) event = parse_line(raw.decode(errors="replace"))
if event is None or event.remote.casefold() != self.config.remote_name.casefold(): if event is None or event.remote.casefold() != self.config.remote_name.casefold():
continue continue
self._handle(event) self._handle(self._count_repeat(event))
finally: finally:
writer.close() writer.close()
self.app.bus.emit(ConnectionChanged(target="lirc", connected=False, source="lirc")) 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: def _handle(self, event: LircButtonEvent) -> None:
if event.button in _TRANSPORT: if event.button in _TRANSPORT:
if event.repeat == 0: if event.repeat == 0:

View File

@@ -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 "")

View File

@@ -21,6 +21,7 @@ from musicmouse.events import (
PlayRequested, PlayRequested,
PlaySeriesLatestRequested, PlaySeriesLatestRequested,
PreviousTrackRequested, PreviousTrackRequested,
ToggleScreenRequested,
VolumeChangeRequested, VolumeChangeRequested,
) )
from musicmouse.services.lirc import LircService 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) 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( async def test_mute_toggles_and_restores(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None: ) -> None:
@@ -281,6 +332,9 @@ async def test_mute_toggles_and_restores(
await sim.bus.drain() await sim.bus.drain()
assert sim.player.volume == 0 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 station.send("0 00 KEY_MUTE Hauppauge")
await sim.bus.drain() await sim.bus.drain()
assert sim.player.volume == 37 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