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

@@ -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:

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