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:
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
77
python-backend/musicmouse/services/screen.py
Normal file
77
python-backend/musicmouse/services/screen.py
Normal 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 "")
|
||||||
@@ -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
|
||||||
|
|||||||
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