Screen: switch off by itself when nothing is going on
With `general.screen.off_command` set, the monitor switches off once nothing has played and nobody has done anything for `auto_off_minutes` (15 by default, 0 for never, editable in parent mode). "Doing anything" is any intent a person could have caused: a remote key, a web command, or a key press or tap in the kiosk, which the page reports at most every 30s via POST /api/screen/activity. The first such intent after an auto-off runs `on_command`, so the remote wakes the screen too. The auto-off itself is silent; switches a person asks for still blubb. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -219,6 +219,7 @@ copied to the device.
|
|||||||
| `POST /api/volume` | `{percent}` or `{delta_percent}` |
|
| `POST /api/volume` | `{percent}` or `{delta_percent}` |
|
||||||
| `POST /api/remote/press/{digit}` | what the IR remote's number key does - the keyboard's digits |
|
| `POST /api/remote/press/{digit}` | what the IR remote's number key does - the keyboard's digits |
|
||||||
| `GET` `PUT /api/remote/mapping` | number key -> album (optionally from one `track_index`) or podcast show |
|
| `GET` `PUT /api/remote/mapping` | number key -> album (optionally from one `track_index`) or podcast show |
|
||||||
|
| `POST /api/screen/toggle` `/screen/activity` | the kiosk's Del key; its key presses and taps, holding off the idle screen-off |
|
||||||
| `GET` `PUT /api/settings` | parent mode |
|
| `GET` `PUT /api/settings` | parent mode |
|
||||||
|
|
||||||
Two decisions worth knowing:
|
Two decisions worth knowing:
|
||||||
|
|||||||
@@ -101,8 +101,15 @@ general:
|
|||||||
# device without one and the key does nothing. The command runs on every press, split
|
# 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
|
# 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).
|
# (ansible's pi_kiosk installs kiosk-screen for this).
|
||||||
|
#
|
||||||
|
# With an off_command the screen also switches itself off once nothing has played and
|
||||||
|
# nobody has touched the remote, the web UI or the kiosk keyboard for auto_off_minutes
|
||||||
|
# (editable in parent mode; 0 = never). The next press after that runs on_command.
|
||||||
# screen:
|
# screen:
|
||||||
# toggle_command: "/usr/local/bin/kiosk-screen toggle"
|
# toggle_command: "/usr/local/bin/kiosk-screen toggle"
|
||||||
|
# off_command: "/usr/local/bin/kiosk-screen off"
|
||||||
|
# on_command: "/usr/local/bin/kiosk-screen on"
|
||||||
|
# auto_off_minutes: 15
|
||||||
# timeout: 5.0 # seconds before a hung command is killed
|
# 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.
|
||||||
|
|||||||
@@ -381,6 +381,8 @@ def _build_services(
|
|||||||
screen_config,
|
screen_config,
|
||||||
alsa_device=None if general.audio_simulated else general.alsa_device,
|
alsa_device=None if general.audio_simulated else general.alsa_device,
|
||||||
volume=lambda: app.player.volume / 100,
|
volume=lambda: app.player.volume / 100,
|
||||||
|
is_playing=lambda: app.player.is_playing,
|
||||||
|
clock=clock,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,14 @@ class ScreenConfig(_Strict):
|
|||||||
#: this is ansible's ``kiosk-screen toggle``, which asks X whether the monitor is
|
#: 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.
|
#: on and flips it - the backend keeps no screen state of its own.
|
||||||
toggle_command: str
|
toggle_command: str
|
||||||
|
#: Switches the monitor off unconditionally, for the idle auto-off. Without it
|
||||||
|
#: there is no auto-off.
|
||||||
|
off_command: str | None = None
|
||||||
|
#: Switches it back on when someone is about again after an auto-off.
|
||||||
|
on_command: str | None = None
|
||||||
|
#: Minutes without music or anyone at the device before ``off_command`` runs.
|
||||||
|
#: 0 means never. Parent mode edits this.
|
||||||
|
auto_off_minutes: int = Field(default=15, ge=0)
|
||||||
timeout: float = Field(default=5.0, gt=0)
|
timeout: float = Field(default=5.0, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ __all__ = [
|
|||||||
"TouchButtonPressed",
|
"TouchButtonPressed",
|
||||||
"TouchButtonReleased",
|
"TouchButtonReleased",
|
||||||
"TrackChanged",
|
"TrackChanged",
|
||||||
|
"UserActivity",
|
||||||
"VolumeChangeRequested",
|
"VolumeChangeRequested",
|
||||||
"VolumeChanged",
|
"VolumeChanged",
|
||||||
]
|
]
|
||||||
@@ -234,6 +235,12 @@ class ToggleScreenRequested(IntentEvent):
|
|||||||
"""Switch the attached monitor off if it is on, on if it is off (``general.screen``)."""
|
"""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 UserActivity(IntentEvent):
|
||||||
|
"""Someone is at the device - a key press or tap in the kiosk browser. Requests
|
||||||
|
nothing by itself; it keeps the screen from switching off (``ScreenService``)."""
|
||||||
|
|
||||||
|
|
||||||
@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
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""The attached monitor: runs ``general.screen.toggle_command`` on each toggle request.
|
"""The attached monitor: runs ``general.screen.toggle_command`` on each toggle request,
|
||||||
|
and switches it off by itself when nothing is going on.
|
||||||
|
|
||||||
A service rather than a reaction because the command is a subprocess: the bus awaits
|
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``.
|
async handlers, so running it there would hold every other event up behind ``xset``.
|
||||||
@@ -7,6 +8,13 @@ Here each press gets its own task, and a hung or failing command is only logged.
|
|||||||
Each switch is answered with a "blubb" (:mod:`musicmouse.blubb`) on the music's own
|
Each switch is answered with a "blubb" (:mod:`musicmouse.blubb`) on the music's own
|
||||||
ALSA device, at the player's volume. The command's last word of output says which way
|
ALSA device, at the player's volume. The command's last word of output says which way
|
||||||
it went - ``on`` or ``off``, as ``kiosk-screen`` prints it; anything else gets no sound.
|
it went - ``on`` or ``off``, as ``kiosk-screen`` prints it; anything else gets no sound.
|
||||||
|
|
||||||
|
With an ``off_command`` it also switches the monitor off by itself once nothing has
|
||||||
|
played and nobody has done anything for ``auto_off_minutes`` - silently, since whoever
|
||||||
|
it would tell may be asleep. "Doing anything" is any intent a person could have caused:
|
||||||
|
a remote key, a web command, a key press in the kiosk browser (:class:`UserActivity`).
|
||||||
|
The first such intent after an auto-off runs ``on_command``, so the monitor wakes up
|
||||||
|
for the remote too, not only for the keyboard X already wakes it for.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -18,8 +26,15 @@ from collections.abc import Callable
|
|||||||
|
|
||||||
from musicmouse.blubb import blubb_wav
|
from musicmouse.blubb import blubb_wav
|
||||||
from musicmouse.bus import EventBus
|
from musicmouse.bus import EventBus
|
||||||
|
from musicmouse.clock import Clock, RealClock
|
||||||
from musicmouse.config import ScreenConfig
|
from musicmouse.config import ScreenConfig
|
||||||
from musicmouse.events import ToggleScreenRequested
|
from musicmouse.events import (
|
||||||
|
Event,
|
||||||
|
IntentEvent,
|
||||||
|
LedEffectRequested,
|
||||||
|
PlaybackChanged,
|
||||||
|
ToggleScreenRequested,
|
||||||
|
)
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -29,6 +44,10 @@ __all__ = ["ScreenService"]
|
|||||||
#: mastered near full scale; a notification sound sits well under it.
|
#: mastered near full scale; a notification sound sits well under it.
|
||||||
_BLUBB_GAIN = 0.35
|
_BLUBB_GAIN = 0.35
|
||||||
|
|
||||||
|
#: How often the idle auto-off looks at the clock. Coarse on purpose: nobody notices
|
||||||
|
#: whether a screen went dark after 15 minutes or 15 and a half.
|
||||||
|
_IDLE_CHECK_INTERVAL = 30.0
|
||||||
|
|
||||||
|
|
||||||
class ScreenService:
|
class ScreenService:
|
||||||
name = "screen"
|
name = "screen"
|
||||||
@@ -40,40 +59,97 @@ class ScreenService:
|
|||||||
*,
|
*,
|
||||||
alsa_device: str | None,
|
alsa_device: str | None,
|
||||||
volume: Callable[[], float],
|
volume: Callable[[], float],
|
||||||
|
is_playing: Callable[[], bool] = lambda: False,
|
||||||
|
clock: Clock | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
|
#: Read afresh on every check: parent mode changes ``auto_off_minutes`` on this
|
||||||
|
#: very object.
|
||||||
self.config = config
|
self.config = config
|
||||||
#: Where the blubb plays, or ``None`` for no sound (simulated audio).
|
#: Where the blubb plays, or ``None`` for no sound (simulated audio).
|
||||||
self.alsa_device = alsa_device
|
self.alsa_device = alsa_device
|
||||||
#: The player's volume as a fraction, read at the moment the sound plays.
|
#: The player's volume as a fraction, read at the moment the sound plays.
|
||||||
self.volume = volume
|
self.volume = volume
|
||||||
|
self.is_playing = is_playing
|
||||||
|
self._clock = clock or RealClock()
|
||||||
|
self._last_activity = self._clock.now()
|
||||||
|
#: The monitor was last switched by the idle auto-off, not by a person.
|
||||||
|
self._auto_off = False
|
||||||
self._running: asyncio.Task[None] | None = None
|
self._running: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
unsubscribe = self.bus.subscribe(ToggleScreenRequested, self._on_toggle)
|
unsubscribe_toggle = self.bus.subscribe(ToggleScreenRequested, self._on_toggle)
|
||||||
|
unsubscribe_all = self.bus.subscribe_all(self._on_event)
|
||||||
try:
|
try:
|
||||||
await asyncio.Event().wait()
|
while True:
|
||||||
|
await self._clock.sleep(_IDLE_CHECK_INTERVAL)
|
||||||
|
self.check_idle()
|
||||||
finally:
|
finally:
|
||||||
unsubscribe()
|
unsubscribe_toggle()
|
||||||
|
unsubscribe_all()
|
||||||
if self._running is not None:
|
if self._running is not None:
|
||||||
self._running.cancel()
|
self._running.cancel()
|
||||||
|
|
||||||
def _on_toggle(self, _event: ToggleScreenRequested) -> None:
|
def _on_toggle(self, _event: ToggleScreenRequested) -> None:
|
||||||
# A second press while the first command is still running would read the
|
# Whichever way the press goes, a person decided it; nothing to wake from.
|
||||||
|
self._auto_off = False
|
||||||
|
self._start(self.config.toggle_command, sound=True)
|
||||||
|
|
||||||
|
def _on_event(self, event: Event) -> None:
|
||||||
|
if isinstance(event, PlaybackChanged):
|
||||||
|
self._last_activity = self._clock.now()
|
||||||
|
return
|
||||||
|
# LED intents arrive from Home Assistant automations as well as people, and
|
||||||
|
# "system" intents are the backend talking to itself.
|
||||||
|
if (
|
||||||
|
not isinstance(event, IntentEvent)
|
||||||
|
or isinstance(event, LedEffectRequested)
|
||||||
|
or event.source == "system"
|
||||||
|
):
|
||||||
|
return
|
||||||
|
self._last_activity = self._clock.now()
|
||||||
|
if self._auto_off and not isinstance(event, ToggleScreenRequested):
|
||||||
|
self._auto_off = False
|
||||||
|
if self.config.on_command:
|
||||||
|
self._start(self.config.on_command, sound=True)
|
||||||
|
|
||||||
|
def check_idle(self) -> None:
|
||||||
|
"""Switch the monitor off if it has been idle long enough. Called every
|
||||||
|
:data:`_IDLE_CHECK_INTERVAL` by :meth:`run`."""
|
||||||
|
now = self._clock.now()
|
||||||
|
if self.is_playing():
|
||||||
|
# The countdown starts when the music stops, not when it started.
|
||||||
|
self._last_activity = now
|
||||||
|
return
|
||||||
|
minutes = self.config.auto_off_minutes
|
||||||
|
if self._auto_off or not minutes or not self.config.off_command:
|
||||||
|
return
|
||||||
|
if now - self._last_activity < minutes * 60:
|
||||||
|
return
|
||||||
|
if self._start(self.config.off_command, sound=False):
|
||||||
|
_log.info("Nothing played for %d minutes; switching the screen off", minutes)
|
||||||
|
self._auto_off = True
|
||||||
|
|
||||||
|
def _start(self, command: str, *, sound: bool) -> bool:
|
||||||
|
# A second switch while the first command is still running would read the
|
||||||
# monitor's state before the first one changed it, and undo it.
|
# monitor's state before the first one changed it, and undo it.
|
||||||
if self._running is not None and not self._running.done():
|
if self._running is not None and not self._running.done():
|
||||||
_log.debug("Screen toggle still running; ignoring this press")
|
_log.debug("Screen command still running; ignoring this one")
|
||||||
return
|
return False
|
||||||
self._running = asyncio.create_task(self.toggle(), name="screen-toggle")
|
self._running = asyncio.create_task(self._run(command, sound=sound), name="screen")
|
||||||
|
return True
|
||||||
|
|
||||||
async def toggle(self) -> None:
|
async def toggle(self) -> None:
|
||||||
argv = shlex.split(self.config.toggle_command)
|
await self._run(self.config.toggle_command, sound=True)
|
||||||
|
|
||||||
|
async def _run(self, command: str, *, sound: bool) -> None:
|
||||||
|
argv = shlex.split(command)
|
||||||
try:
|
try:
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
|
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
_log.warning("Screen toggle %r could not start: %s", self.config.toggle_command, exc)
|
_log.warning("Screen command %r could not start: %s", command, exc)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
output, _ = await asyncio.wait_for(process.communicate(), self.config.timeout)
|
output, _ = await asyncio.wait_for(process.communicate(), self.config.timeout)
|
||||||
@@ -81,23 +157,18 @@ class ScreenService:
|
|||||||
process.kill()
|
process.kill()
|
||||||
await process.wait()
|
await process.wait()
|
||||||
_log.warning(
|
_log.warning(
|
||||||
"Screen toggle %r took longer than %gs; killed",
|
"Screen command %r took longer than %gs; killed", command, self.config.timeout
|
||||||
self.config.toggle_command,
|
|
||||||
self.config.timeout,
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
text = output.decode(errors="replace").strip()
|
text = output.decode(errors="replace").strip()
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
_log.warning(
|
_log.warning(
|
||||||
"Screen toggle %r failed (exit %d): %s",
|
"Screen command %r failed (exit %d): %s", command, process.returncode, text
|
||||||
self.config.toggle_command,
|
|
||||||
process.returncode,
|
|
||||||
text,
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
_log.info("Screen toggled%s", f": {text}" if text else "")
|
_log.info("Screen switched%s", f": {text}" if text else "")
|
||||||
state = text.split()[-1] if text else ""
|
state = text.split()[-1] if text else ""
|
||||||
if state in ("on", "off"):
|
if sound and state in ("on", "off"):
|
||||||
await self.blubb(on=state == "on")
|
await self.blubb(on=state == "on")
|
||||||
|
|
||||||
async def blubb(self, *, on: bool) -> None:
|
async def blubb(self, *, on: bool) -> None:
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from musicmouse.events import (
|
|||||||
SeekRequested,
|
SeekRequested,
|
||||||
SetVolumeRequested,
|
SetVolumeRequested,
|
||||||
ToggleScreenRequested,
|
ToggleScreenRequested,
|
||||||
|
UserActivity,
|
||||||
)
|
)
|
||||||
from musicmouse.services.web.hub import StateHub
|
from musicmouse.services.web.hub import StateHub
|
||||||
from musicmouse.services.web.remote_settings import read_mapping, write_mapping
|
from musicmouse.services.web.remote_settings import read_mapping, write_mapping
|
||||||
@@ -198,6 +199,12 @@ def build_router(
|
|||||||
# no-op, like the remote's power key.
|
# no-op, like the remote's power key.
|
||||||
return emit(ToggleScreenRequested(source="web"))
|
return emit(ToggleScreenRequested(source="web"))
|
||||||
|
|
||||||
|
@router.post("/screen/activity", status_code=204)
|
||||||
|
def screen_activity() -> Response:
|
||||||
|
# The kiosk page on the device reports key presses and taps here, so the
|
||||||
|
# screen's idle auto-off does not blank it under a child who is browsing.
|
||||||
|
return emit(UserActivity(source="web"))
|
||||||
|
|
||||||
@router.post("/seek", status_code=204)
|
@router.post("/seek", status_code=204)
|
||||||
def seek(body: SeekIn) -> Response:
|
def seek(body: SeekIn) -> Response:
|
||||||
return emit(SeekRequested(position=body.position, source="web"))
|
return emit(SeekRequested(position=body.position, source="web"))
|
||||||
@@ -237,8 +244,13 @@ def build_router(
|
|||||||
general = app.config.general
|
general = app.config.general
|
||||||
# Applied live as well as saved: a parent lowering the ceiling expects the next
|
# Applied live as well as saved: a parent lowering the ceiling expects the next
|
||||||
# song to be quieter, not the next boot.
|
# song to be quieter, not the next boot.
|
||||||
for key, value in body.model_dump().items():
|
values = body.model_dump()
|
||||||
|
auto_off = values.pop("screen_auto_off_minutes")
|
||||||
|
for key, value in values.items():
|
||||||
setattr(general, key, value)
|
setattr(general, key, value)
|
||||||
|
if auto_off is not None and general.screen is not None:
|
||||||
|
# The same object ScreenService reads on every tick.
|
||||||
|
general.screen.auto_off_minutes = auto_off
|
||||||
app.player.set_volume_limits(general.min_volume, general.max_volume)
|
app.player.set_volume_limits(general.min_volume, general.max_volume)
|
||||||
await asyncio.to_thread(write_settings, config_path, body)
|
await asyncio.to_thread(write_settings, config_path, body)
|
||||||
await hub.broadcast_state()
|
await hub.broadcast_state()
|
||||||
|
|||||||
@@ -241,6 +241,9 @@ class SettingsOut(BaseModel):
|
|||||||
initial_volume: int
|
initial_volume: int
|
||||||
volume_increment: int
|
volume_increment: int
|
||||||
button_leds_brightness: float
|
button_leds_brightness: float
|
||||||
|
#: ``general.screen.auto_off_minutes``, or ``None`` when there is no screen to
|
||||||
|
#: switch off (no ``screen`` section, or one without an ``off_command``).
|
||||||
|
screen_auto_off_minutes: int | None
|
||||||
|
|
||||||
|
|
||||||
class SettingsIn(BaseModel):
|
class SettingsIn(BaseModel):
|
||||||
@@ -249,6 +252,8 @@ class SettingsIn(BaseModel):
|
|||||||
initial_volume: int = Field(ge=0, le=200)
|
initial_volume: int = Field(ge=0, le=200)
|
||||||
volume_increment: int = Field(ge=1, le=100)
|
volume_increment: int = Field(ge=1, le=100)
|
||||||
button_leds_brightness: float = Field(ge=0, le=1)
|
button_leds_brightness: float = Field(ge=0, le=1)
|
||||||
|
#: Ignored without a screen section. ``None`` leaves it as it is.
|
||||||
|
screen_auto_off_minutes: int | None = Field(default=None, ge=0, le=720)
|
||||||
|
|
||||||
|
|
||||||
class HaDeviceOut(BaseModel):
|
class HaDeviceOut(BaseModel):
|
||||||
|
|||||||
@@ -48,12 +48,16 @@ def to_percent(volume: int, general: GeneralConfig) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def read_settings(general: GeneralConfig) -> SettingsOut:
|
def read_settings(general: GeneralConfig) -> SettingsOut:
|
||||||
|
screen = general.screen
|
||||||
return SettingsOut(
|
return SettingsOut(
|
||||||
min_volume=general.min_volume,
|
min_volume=general.min_volume,
|
||||||
max_volume=general.max_volume,
|
max_volume=general.max_volume,
|
||||||
initial_volume=general.initial_volume,
|
initial_volume=general.initial_volume,
|
||||||
volume_increment=general.volume_increment,
|
volume_increment=general.volume_increment,
|
||||||
button_leds_brightness=general.button_leds_brightness,
|
button_leds_brightness=general.button_leds_brightness,
|
||||||
|
screen_auto_off_minutes=(
|
||||||
|
screen.auto_off_minutes if screen is not None and screen.off_command else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +95,11 @@ def write_settings(path: Path, settings: SettingsIn) -> None:
|
|||||||
"""Patch the settings into ``config.yml`` in place."""
|
"""Patch the settings into ``config.yml`` in place."""
|
||||||
document = load_document(path)
|
document = load_document(path)
|
||||||
general = document["general"]
|
general = document["general"]
|
||||||
for key, value in settings.model_dump().items():
|
values = settings.model_dump()
|
||||||
|
# The one setting that lives in a subsection rather than flat under `general`.
|
||||||
|
auto_off = values.pop("screen_auto_off_minutes")
|
||||||
|
for key, value in values.items():
|
||||||
general[key] = value
|
general[key] = value
|
||||||
|
if auto_off is not None and general.get("screen") is not None:
|
||||||
|
general["screen"]["auto_off_minutes"] = auto_off
|
||||||
atomic_write(path, document)
|
atomic_write(path, document)
|
||||||
|
|||||||
@@ -114,6 +114,14 @@ def test_a_series_slot_cannot_name_a_song(config_dir: Path) -> None:
|
|||||||
assert "track_index" in message
|
assert "track_index" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_screen_switches_off_after_15_minutes_by_default(config_dir: Path) -> None:
|
||||||
|
data = _config(screen={"toggle_command": "kiosk-screen toggle"})
|
||||||
|
screen = load_config(write_config(config_dir, data)).general.screen
|
||||||
|
assert screen is not None
|
||||||
|
assert screen.auto_off_minutes == 15
|
||||||
|
assert screen.off_command is None # ...but only once there is a way to do it
|
||||||
|
|
||||||
|
|
||||||
def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None:
|
def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None:
|
||||||
data = _config(
|
data = _config(
|
||||||
ha={
|
ha={
|
||||||
|
|||||||
@@ -15,8 +15,18 @@ import pytest
|
|||||||
|
|
||||||
from musicmouse.blubb import RATE, blubb_wav
|
from musicmouse.blubb import RATE, blubb_wav
|
||||||
from musicmouse.bus import EventBus
|
from musicmouse.bus import EventBus
|
||||||
|
from musicmouse.clock import FakeClock
|
||||||
|
from musicmouse.color import ColorRGBW
|
||||||
from musicmouse.config import ScreenConfig
|
from musicmouse.config import ScreenConfig
|
||||||
from musicmouse.events import ToggleScreenRequested
|
from musicmouse.effects import EffectStaticConfig
|
||||||
|
from musicmouse.events import (
|
||||||
|
LedEffectRequested,
|
||||||
|
PlaybackChanged,
|
||||||
|
PlayRemoteSlotRequested,
|
||||||
|
ToggleScreenRequested,
|
||||||
|
UserActivity,
|
||||||
|
)
|
||||||
|
from musicmouse.hardware import LedZone
|
||||||
from musicmouse.services.screen import ScreenService
|
from musicmouse.services.screen import ScreenService
|
||||||
|
|
||||||
|
|
||||||
@@ -184,3 +194,157 @@ async def test_no_blubb(
|
|||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
assert not Path(f"{fake_aplay}.wav").exists()
|
assert not Path(f"{fake_aplay}.wav").exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- idle auto-off
|
||||||
|
|
||||||
|
|
||||||
|
class _Idle:
|
||||||
|
"""A started service with a fake clock and an ``off`` and ``on`` command that log."""
|
||||||
|
|
||||||
|
def __init__(self, bus: EventBus, tmp_path: Path, **config: object) -> None:
|
||||||
|
self.bus = bus
|
||||||
|
self.off_log = tmp_path / "off"
|
||||||
|
self.on_log = tmp_path / "on"
|
||||||
|
self.playing = False
|
||||||
|
self.clock = FakeClock(idle=bus.drain)
|
||||||
|
settings: dict[str, object] = {
|
||||||
|
"toggle_command": _python("print('toggled')"),
|
||||||
|
"off_command": _appending(self.off_log, then="print('off')"),
|
||||||
|
"on_command": _appending(self.on_log, then="print('on')"),
|
||||||
|
"auto_off_minutes": 15,
|
||||||
|
}
|
||||||
|
settings.update(config)
|
||||||
|
self.service = ScreenService(
|
||||||
|
bus,
|
||||||
|
ScreenConfig.model_validate(settings),
|
||||||
|
alsa_device=None,
|
||||||
|
volume=lambda: 0.5,
|
||||||
|
is_playing=lambda: self.playing,
|
||||||
|
clock=self.clock,
|
||||||
|
)
|
||||||
|
self.task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _Idle:
|
||||||
|
self.task = asyncio.create_task(self.service.run())
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> None:
|
||||||
|
assert self.task is not None
|
||||||
|
self.task.cancel()
|
||||||
|
|
||||||
|
async def wait(self, minutes: float) -> None:
|
||||||
|
await self.clock.advance(minutes * 60)
|
||||||
|
await _settle(self.service)
|
||||||
|
|
||||||
|
def offs(self) -> int:
|
||||||
|
return len(self.off_log.read_text().splitlines()) if self.off_log.exists() else 0
|
||||||
|
|
||||||
|
def ons(self) -> int:
|
||||||
|
return len(self.on_log.read_text().splitlines()) if self.on_log.exists() else 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_screen_goes_off_after_the_idle_time(bus: EventBus, tmp_path: Path) -> None:
|
||||||
|
async with _Idle(bus, tmp_path) as idle:
|
||||||
|
await idle.wait(14)
|
||||||
|
assert idle.offs() == 0
|
||||||
|
await idle.wait(1)
|
||||||
|
assert idle.offs() == 1
|
||||||
|
await idle.wait(60)
|
||||||
|
assert idle.offs() == 1 # once, not every half minute after
|
||||||
|
|
||||||
|
|
||||||
|
async def test_not_while_music_plays(bus: EventBus, tmp_path: Path) -> None:
|
||||||
|
async with _Idle(bus, tmp_path) as idle:
|
||||||
|
idle.playing = True
|
||||||
|
await idle.wait(60)
|
||||||
|
assert idle.offs() == 0
|
||||||
|
|
||||||
|
# The countdown starts when the music stops.
|
||||||
|
idle.playing = False
|
||||||
|
await bus.emit_and_wait(PlaybackChanged(playing=False, source="player"))
|
||||||
|
await idle.wait(14)
|
||||||
|
assert idle.offs() == 0
|
||||||
|
await idle.wait(1)
|
||||||
|
assert idle.offs() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"event",
|
||||||
|
[UserActivity(source="web"), PlayRemoteSlotRequested(digit="3", source="lirc")],
|
||||||
|
)
|
||||||
|
async def test_activity_restarts_the_countdown(
|
||||||
|
bus: EventBus, tmp_path: Path, event: UserActivity | PlayRemoteSlotRequested
|
||||||
|
) -> None:
|
||||||
|
async with _Idle(bus, tmp_path) as idle:
|
||||||
|
await idle.wait(10)
|
||||||
|
await bus.emit_and_wait(event)
|
||||||
|
await idle.wait(10)
|
||||||
|
assert idle.offs() == 0
|
||||||
|
await idle.wait(5)
|
||||||
|
assert idle.offs() == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_automations_do_not_count_as_activity(bus: EventBus, tmp_path: Path) -> None:
|
||||||
|
async with _Idle(bus, tmp_path) as idle:
|
||||||
|
await idle.wait(10)
|
||||||
|
effect = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
|
||||||
|
automation = LedEffectRequested(zone=LedZone.MOUSE, effect=effect, source="mqtt")
|
||||||
|
await bus.emit_and_wait(automation)
|
||||||
|
await idle.wait(5)
|
||||||
|
assert idle.offs() == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_someone_coming_back_switches_it_on(bus: EventBus, tmp_path: Path) -> None:
|
||||||
|
async with _Idle(bus, tmp_path) as idle:
|
||||||
|
await idle.wait(15)
|
||||||
|
assert idle.offs() == 1
|
||||||
|
|
||||||
|
await bus.emit_and_wait(UserActivity(source="web"))
|
||||||
|
await _settle(idle.service)
|
||||||
|
await bus.emit_and_wait(UserActivity(source="web"))
|
||||||
|
await _settle(idle.service)
|
||||||
|
assert idle.ons() == 1 # only the first wakes it
|
||||||
|
|
||||||
|
await idle.wait(15)
|
||||||
|
assert idle.offs() == 2 # and the countdown runs again
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_power_key_after_an_auto_off_is_left_to_the_toggle(
|
||||||
|
bus: EventBus, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
async with _Idle(bus, tmp_path) as idle:
|
||||||
|
await idle.wait(15)
|
||||||
|
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
|
||||||
|
await _settle(idle.service)
|
||||||
|
await bus.emit_and_wait(UserActivity(source="web"))
|
||||||
|
await _settle(idle.service)
|
||||||
|
assert idle.ons() == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zero_minutes_means_never(bus: EventBus, tmp_path: Path) -> None:
|
||||||
|
async with _Idle(bus, tmp_path, auto_off_minutes=0) as idle:
|
||||||
|
await idle.wait(600)
|
||||||
|
assert idle.offs() == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_off_command_means_never(bus: EventBus, tmp_path: Path) -> None:
|
||||||
|
async with _Idle(bus, tmp_path, off_command=None) as idle:
|
||||||
|
await idle.wait(600)
|
||||||
|
assert idle.offs() == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_new_idle_time_applies_at_once(bus: EventBus, tmp_path: Path) -> None:
|
||||||
|
async with _Idle(bus, tmp_path) as idle:
|
||||||
|
idle.service.config.auto_off_minutes = 5 # what parent mode does
|
||||||
|
await idle.wait(5)
|
||||||
|
assert idle.offs() == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_auto_off_is_silent(bus: EventBus, tmp_path: Path, fake_aplay: Path) -> None:
|
||||||
|
async with _Idle(bus, tmp_path) as idle:
|
||||||
|
idle.service.alsa_device = "softvol_effects"
|
||||||
|
await idle.wait(15)
|
||||||
|
assert idle.offs() == 1
|
||||||
|
assert not Path(f"{fake_aplay}.wav").exists()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
import httpx2
|
import httpx2
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from musicmouse.config import WebConfig, load_config
|
from musicmouse.config import ScreenConfig, WebConfig, load_config
|
||||||
from musicmouse.services.web.service import build_app
|
from musicmouse.services.web.service import build_app
|
||||||
from musicmouse.services.web.settings import to_device_volume, to_percent
|
from musicmouse.services.web.settings import to_device_volume, to_percent
|
||||||
from musicmouse.simulator.harness import Simulation, build_simulation
|
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||||
@@ -99,8 +99,46 @@ def test_a_degenerate_range_reads_as_full(config_dir: Path) -> None:
|
|||||||
|
|
||||||
async def test_settings_expose_only_the_editable_subset(client: httpx2.AsyncClient) -> None:
|
async def test_settings_expose_only_the_editable_subset(client: httpx2.AsyncClient) -> None:
|
||||||
body = (await client.get("/api/settings")).json()
|
body = (await client.get("/api/settings")).json()
|
||||||
assert set(body) == set(SETTINGS)
|
assert set(body) == {*SETTINGS, "screen_auto_off_minutes"}
|
||||||
assert body["max_volume"] == 60
|
assert body["max_volume"] == 60
|
||||||
|
assert body["screen_auto_off_minutes"] is None # no screen to switch off
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_screen_auto_off_is_read_and_saved_into_its_section(
|
||||||
|
client: httpx2.AsyncClient, sim: Simulation, config_dir: Path
|
||||||
|
) -> None:
|
||||||
|
path = config_dir / "config.yml"
|
||||||
|
path.write_text(
|
||||||
|
COMMENTED.replace(
|
||||||
|
"\nfigures:",
|
||||||
|
" screen:\n toggle_command: kiosk-screen toggle\n"
|
||||||
|
" off_command: kiosk-screen off\n\nfigures:",
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
sim.app.config.general.screen = ScreenConfig(
|
||||||
|
toggle_command="kiosk-screen toggle", off_command="kiosk-screen off"
|
||||||
|
)
|
||||||
|
assert (await client.get("/api/settings")).json()["screen_auto_off_minutes"] == 15
|
||||||
|
|
||||||
|
response = await client.put("/api/settings", json={**SETTINGS, "screen_auto_off_minutes": 30})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["screen_auto_off_minutes"] == 30
|
||||||
|
assert sim.app.config.general.screen.auto_off_minutes == 30 # live, for ScreenService
|
||||||
|
|
||||||
|
reloaded = load_config(path)
|
||||||
|
assert reloaded.general.screen is not None
|
||||||
|
assert reloaded.general.screen.auto_off_minutes == 30
|
||||||
|
assert "screen_auto_off_minutes" not in path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_screen_auto_off_without_a_screen_is_ignored(
|
||||||
|
client: httpx2.AsyncClient, config_dir: Path
|
||||||
|
) -> None:
|
||||||
|
response = await client.put("/api/settings", json={**SETTINGS, "screen_auto_off_minutes": 30})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["screen_auto_off_minutes"] is None
|
||||||
|
assert "screen" not in (config_dir / "config.yml").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------- writing
|
# -------------------------------------------------------------------------- writing
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ const UNMUTE_PERCENT = 60;
|
|||||||
* there must not switch off a screen in another room. */
|
* there must not switch off a screen in another room. */
|
||||||
const ON_THE_DEVICE = ["localhost", "127.0.0.1"].includes(window.location.hostname);
|
const ON_THE_DEVICE = ["localhost", "127.0.0.1"].includes(window.location.hostname);
|
||||||
|
|
||||||
|
/** The kiosk reports a key press or tap at most this often - enough to hold off the
|
||||||
|
* screen's idle auto-off, which counts in minutes. */
|
||||||
|
const SCREEN_ACTIVITY_THROTTLE_MS = 30_000;
|
||||||
|
|
||||||
/** A podcast episode has no next/previous track to skip to - Next/Previous nudge the
|
/** A podcast episode has no next/previous track to skip to - Next/Previous nudge the
|
||||||
* position instead, the way scrubbing past an ad break usually works. */
|
* position instead, the way scrubbing past an ad break usually works. */
|
||||||
const PODCAST_SKIP_SECONDS = 30;
|
const PODCAST_SKIP_SECONDS = 30;
|
||||||
@@ -389,6 +393,26 @@ export function App() {
|
|||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// The kiosk on the device: tell the backend someone is here, so the screen's idle
|
||||||
|
// auto-off does not blank it under a child browsing without music. Capture phase, so
|
||||||
|
// a key the typing tab or a dialog swallows still counts.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ON_THE_DEVICE) return;
|
||||||
|
let last = 0;
|
||||||
|
const onActivity = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - last < SCREEN_ACTIVITY_THROTTLE_MS) return;
|
||||||
|
last = now;
|
||||||
|
void api.screenActivity().catch(() => {});
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onActivity, true);
|
||||||
|
window.addEventListener("pointerdown", onActivity, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onActivity, true);
|
||||||
|
window.removeEventListener("pointerdown", onActivity, true);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// An abandoned "A" press disarms itself rather than leaving the app half-primed.
|
// An abandoned "A" press disarms itself rather than leaving the app half-primed.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ui.assignPending) return;
|
if (!ui.assignPending) return;
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export const api = {
|
|||||||
setVolume: (percent: number) => post("/volume", { percent }),
|
setVolume: (percent: number) => post("/volume", { percent }),
|
||||||
nudgeVolume: (deltaPercent: number) => post("/volume", { delta_percent: deltaPercent }),
|
nudgeVolume: (deltaPercent: number) => post("/volume", { delta_percent: deltaPercent }),
|
||||||
toggleScreen: () => post("/screen/toggle"),
|
toggleScreen: () => post("/screen/toggle"),
|
||||||
|
/** Someone is at the kiosk: holds off the screen's idle auto-off. */
|
||||||
|
screenActivity: () => post("/screen/activity"),
|
||||||
|
|
||||||
settings: () => request<Settings>("/settings"),
|
settings: () => request<Settings>("/settings"),
|
||||||
saveSettings: (settings: Settings) =>
|
saveSettings: (settings: Settings) =>
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ export interface Settings {
|
|||||||
initial_volume: number;
|
initial_volume: number;
|
||||||
volume_increment: number;
|
volume_increment: number;
|
||||||
button_leds_brightness: number;
|
button_leds_brightness: number;
|
||||||
|
/** Minutes without music before the device's screen switches itself off, 0 = never.
|
||||||
|
* `null` when there is no screen to switch (no `off_command` configured). */
|
||||||
|
screen_auto_off_minutes: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerMessage =
|
export type ServerMessage =
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import { useEffect, useState } from "react";
|
|||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { Settings } from "../api/types";
|
import type { Settings } from "../api/types";
|
||||||
|
|
||||||
const FIELDS: Array<[keyof Settings, string, string, number, number, number]> = [
|
/** Every setting that is always there; the screen's auto-off is only sometimes. */
|
||||||
|
type AlwaysSetting = Exclude<keyof Settings, "screen_auto_off_minutes">;
|
||||||
|
|
||||||
|
const FIELDS: Array<[AlwaysSetting, string, string, number, number, number]> = [
|
||||||
["min_volume", "Minimale Lautstärke", "Untergrenze am Gerät (0-200)", 0, 200, 1],
|
["min_volume", "Minimale Lautstärke", "Untergrenze am Gerät (0-200)", 0, 200, 1],
|
||||||
["max_volume", "Maximale Lautstärke", "Was 100 % in der Kinder-Ansicht bedeutet", 0, 200, 1],
|
["max_volume", "Maximale Lautstärke", "Was 100 % in der Kinder-Ansicht bedeutet", 0, 200, 1],
|
||||||
["initial_volume", "Start-Lautstärke", "Beim Einschalten", 0, 200, 1],
|
["initial_volume", "Start-Lautstärke", "Beim Einschalten", 0, 200, 1],
|
||||||
@@ -19,6 +22,48 @@ const FIELDS: Array<[keyof Settings, string, string, number, number, number]> =
|
|||||||
["button_leds_brightness", "Tastenbeleuchtung", "0 bis 1", 0, 1, 0.05],
|
["button_leds_brightness", "Tastenbeleuchtung", "0 bis 1", 0, 1, 0.05],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function NumberField({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 var(--hue))" }}>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(Number(event.target.value))}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "2px solid oklch(85% 0.02 var(--hue))",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ParentPanel({ onClose }: { onClose: () => void }) {
|
export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||||
const [settings, setSettings] = useState<Settings | null>(null);
|
const [settings, setSettings] = useState<Settings | null>(null);
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
@@ -83,31 +128,31 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
|||||||
) : (
|
) : (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 18 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 18 }}>
|
||||||
{FIELDS.map(([key, label, hint, min, max, step]) => (
|
{FIELDS.map(([key, label, hint, min, max, step]) => (
|
||||||
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
<NumberField
|
||||||
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
|
key={key}
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 var(--hue))" }}>
|
label={label}
|
||||||
{hint}
|
hint={hint}
|
||||||
</span>
|
min={min}
|
||||||
<input
|
max={max}
|
||||||
type="number"
|
step={step}
|
||||||
min={min}
|
value={settings[key]}
|
||||||
max={max}
|
onChange={(value) => setSettings({ ...settings, [key]: value })}
|
||||||
step={step}
|
/>
|
||||||
value={settings[key]}
|
|
||||||
onChange={(event) =>
|
|
||||||
setSettings({ ...settings, [key]: Number(event.target.value) })
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
padding: "8px 12px",
|
|
||||||
borderRadius: 10,
|
|
||||||
border: "2px solid oklch(85% 0.02 var(--hue))",
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Only when the device has a screen it can switch off by itself. */}
|
||||||
|
{settings.screen_auto_off_minutes !== null && (
|
||||||
|
<NumberField
|
||||||
|
label="Bildschirm aus nach"
|
||||||
|
hint="Minuten ohne Musik und ohne Bedienung, 0 = nie"
|
||||||
|
min={0}
|
||||||
|
max={720}
|
||||||
|
step={1}
|
||||||
|
value={settings.screen_auto_off_minutes}
|
||||||
|
onChange={(value) => setSettings({ ...settings, screen_auto_off_minutes: value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 10, marginTop: 6 }}>
|
<div style={{ display: "flex", gap: 10, marginTop: 6 }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => void save()}
|
onClick={() => void save()}
|
||||||
|
|||||||
Reference in New Issue
Block a user