diff --git a/documentation/developers/docker.md b/documentation/developers/docker.md index 6a0af80c7..5f8b6f845 100644 --- a/documentation/developers/docker.md +++ b/documentation/developers/docker.md @@ -230,6 +230,21 @@ would be of course useful to get rid of them, but currently we make a trade-off between a development environment and solving the specific details. +### Error when local libzmq Dockerfile has not been built: + +``` bash +------ + > [jukebox internal] load metadata for docker.io/library/libzmq:local: +------ +failed to solve: libzmq:local: pull access denied, repository does not exist or may require authorization: server message: insufficient_scope: authorization failed +``` + +Build libzmq for your host machine + +``` bash +docker build -f docker/Dockerfile.libzmq -t libzmq:local . +``` + ### `mpd` container #### Pulseaudio issue on Mac @@ -286,7 +301,7 @@ Error starting userland proxy: listen tcp4 0.0.0.0:6600: bind: address already i Read these threads for details: [thread 1](https://unix.stackexchange.com/questions/456909/socket-already-in-use-but-is-not-listed-mpd) and [thread 2](https://stackoverflow.com/questions/5106674/error-address-already-in-use-while-binding-socket-with-address-but-the-port-num/5106755#5106755) -#### Other error messages +#### MPD issues When starting the `mpd` container, you will see the following errors. You can ignore them, MPD will run. @@ -309,7 +324,7 @@ mpd | alsa_mixer: snd_mixer_handle_events() failed: Input/output error mpd | exception: Failed to read mixer for 'My ALSA Device': snd_mixer_handle_events() failed: Input/output error ``` -### `jukebox` container +#### `jukebox` container Many features of the Phoniebox are based on the Raspberry Pi hardware. This hardware can\'t be mocked in a virtual Docker environment. As a diff --git a/documentation/developers/status.md b/documentation/developers/status.md index 0a40f8125..146e5c50c 100644 --- a/documentation/developers/status.md +++ b/documentation/developers/status.md @@ -166,7 +166,8 @@ Topics marked _in progress_ are already in the process of implementation by comm - [x] Publish mechanism of timer status - [x] Change multitimer function call interface such that endless timer etc. won't pass the `iteration` kwarg - [ ] Make timer settings persistent -- [ ] Idle timer +- [x] Idle timer (basic implementation covering player, SSH, config and audio content changes) +- [ ] Idle timer: Do we need further extensions? - This needs clearer specification: Idle is when no music is playing and no user interaction is taking place - i.e., needs information from RPC AND from player status. Let's do this when we see a little clearer about Spotify diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index 9bb214f3d..d3326ef55 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -108,6 +108,10 @@ gpioz: enable: false config_file: ../../shared/settings/gpio.yaml timers: + idle_shutdown: + # If you want the box to shutdown on inactivity automatically, configure timeout_sec with a number of seconds (at least 60). + # Inactivity is defined as: no music playing, no active SSH sessions, no changes in configs or audio content. + timeout_sec: 0 # These timers are always disabled after start # The following values only give the default values. # These can be changed when enabling the respective timer on a case-by-case basis w/o saving diff --git a/src/jukebox/components/timers/__init__.py b/src/jukebox/components/timers/__init__.py index bef7ccf81..4fe70aa80 100644 --- a/src/jukebox/components/timers/__init__.py +++ b/src/jukebox/components/timers/__init__.py @@ -1,10 +1,12 @@ # RPi-Jukebox-RFID Version 3 # Copyright (c) See file LICENSE in project root folder -from jukebox.multitimer import (GenericTimerClass, GenericMultiTimerClass) import logging import jukebox.cfghandler import jukebox.plugs as plugin +from jukebox.multitimer import GenericTimerClass +from .idle_shutdown_timer import IdleShutdownTimer +from .volume_fadeout_shutdown_timer import VolumeFadoutAndShutdown logger = logging.getLogger('jb.timers') @@ -24,35 +26,18 @@ def stop_player(): plugin.call_ignore_errors('player', 'ctrl', 'stop') -class VolumeFadeOutActionClass: - def __init__(self, iterations): - self.iterations = iterations - # Get the current volume, calculate step size - self.volume = plugin.call('volume', 'ctrl', 'get_volume') - self.step = float(self.volume) / iterations - - def __call__(self, iteration): - self.volume = self.volume - self.step - logger.debug(f"Decrease volume to {self.volume} (Iteration index {iteration}/{self.iterations}-1)") - plugin.call_ignore_errors('volume', 'ctrl', 'set_volume', args=[int(self.volume)]) - if iteration == 0: - logger.debug("Shut down from volume fade out") - plugin.call_ignore_errors('host', 'shutdown') - - # --------------------------------------------------------------------------- # Create the timers # --------------------------------------------------------------------------- timer_shutdown: GenericTimerClass timer_stop_player: GenericTimerClass -timer_fade_volume: GenericMultiTimerClass +timer_fade_volume: VolumeFadoutAndShutdown +timer_idle_shutdown: IdleShutdownTimer @plugin.finalize def finalize(): - # TODO: Example with how to call the timers from RPC? - - # Create the various timers with fitting doc for plugin reference + # Shutdown Timer global timer_shutdown timeout = cfg.setndefault('timers', 'shutdown', 'default_timeout_sec', value=60 * 60) timer_shutdown = GenericTimerClass(f"{plugin.loaded_as(__name__)}.timer_shutdown", @@ -62,6 +47,7 @@ def finalize(): # auto-registration would register it with that module. Manually set package to this plugin module plugin.register(timer_shutdown, name='timer_shutdown', package=plugin.loaded_as(__name__)) + # Stop Playback Timer global timer_stop_player timeout = cfg.setndefault('timers', 'stop_player', 'default_timeout_sec', value=60 * 60) timer_stop_player = GenericTimerClass(f"{plugin.loaded_as(__name__)}.timer_stop_player", @@ -69,14 +55,18 @@ def finalize(): timer_stop_player.__doc__ = "Timer for automatic player stop" plugin.register(timer_stop_player, name='timer_stop_player', package=plugin.loaded_as(__name__)) - global timer_fade_volume - timeout = cfg.setndefault('timers', 'volume_fade_out', 'default_time_per_iteration_sec', value=15 * 60) - steps = cfg.setndefault('timers', 'volume_fade_out', 'number_of_steps', value=10) - timer_fade_volume = GenericMultiTimerClass(f"{plugin.loaded_as(__name__)}.timer_fade_volume", - steps, timeout, VolumeFadeOutActionClass) - timer_fade_volume.__doc__ = "Timer step-wise volume fade out and shutdown" + # Volume Fadeout and Shutdown Timer + timer_fade_volume = VolumeFadoutAndShutdown( + name=f"{plugin.loaded_as(__name__)}.timer_fade_volume" + ) plugin.register(timer_fade_volume, name='timer_fade_volume', package=plugin.loaded_as(__name__)) + # Idle Timer + global timer_idle_shutdown + idle_timeout = cfg.setndefault('timers', 'idle_shutdown', 'timeout_sec', value=0) + timer_idle_shutdown = IdleShutdownTimer(package=plugin.loaded_as(__name__), idle_timeout=idle_timeout) + plugin.register(timer_idle_shutdown, name='timer_idle_shutdown', package=plugin.loaded_as(__name__)) + # The idle Timer does work in a little sneaky way # Idle is when there are no calls through the plugin module # Ahh, but also when music is playing this is not idle... @@ -101,4 +91,15 @@ def atexit(**ignored_kwargs): timer_stop_player.cancel() global timer_fade_volume timer_fade_volume.cancel() - return [timer_shutdown.timer_thread, timer_stop_player.timer_thread, timer_fade_volume.timer_thread] + global timer_idle_shutdown + timer_idle_shutdown.cancel() + global timer_idle_check + timer_idle_check.cancel() + ret = [ + timer_shutdown.timer_thread, + timer_stop_player.timer_thread, + timer_fade_volume.timer_thread, + timer_idle_shutdown.timer_thread, + timer_idle_check.timer_thread + ] + return ret diff --git a/src/jukebox/components/timers/idle_shutdown_timer.py b/src/jukebox/components/timers/idle_shutdown_timer.py new file mode 100644 index 000000000..d1881b522 --- /dev/null +++ b/src/jukebox/components/timers/idle_shutdown_timer.py @@ -0,0 +1,194 @@ +# RPi-Jukebox-RFID Version 3 +# Copyright (c) See file LICENSE in project root folder + +import os +import re +import logging +import jukebox.cfghandler +import jukebox.plugs as plugin +from jukebox.multitimer import (GenericEndlessTimerClass, GenericMultiTimerClass) + + +logger = logging.getLogger('jb.timers.idle_shutdown_timer') +cfg = jukebox.cfghandler.get_handler('jukebox') + +SSH_CHILD_RE = re.compile(r'sshd: [^/].*') +PATHS = ['shared/settings', + 'shared/audiofolders'] + +IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS = 60 +EXTEND_IDLE_TIMEOUT = 60 +IDLE_CHECK_INTERVAL = 10 + + +def get_seconds_since_boot(): + # We may not have a stable clock source when there is no network + # connectivity (yet). As we only need to measure the relative time which + # has passed, we can just calculate based on the seconds since boot. + with open('/proc/uptime') as f: + line = f.read() + seconds_since_boot, _ = line.split(' ', 1) + return float(seconds_since_boot) + + +class IdleShutdownTimer: + def __init__(self, package: str, idle_timeout: int) -> None: + self.private_timer_idle_shutdown = None + self.private_timer_idle_check = None + self.idle_timeout = 0 + self.package = package + self.idle_check_interval = IDLE_CHECK_INTERVAL + + self.set_idle_timeout(idle_timeout) + self.init_idle_shutdown() + self.init_idle_check() + + def set_idle_timeout(self, idle_timeout): + try: + self.idle_timeout = int(idle_timeout) + except ValueError: + logger.warning(f'invalid timers.idle_shutdown.timeout_sec value {repr(idle_timeout)}') + + if self.idle_timeout < IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS: + logger.info('disabling idle shutdown timer; set ' + 'timers.idle_shutdown.timeout_sec to at least ' + f'{IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS} seconds to enable') + self.idle_timeout = 0 + + # Using GenericMultiTimerClass instead of GenericTimerClass as it supports classes rather than functions + # Calling GenericMultiTimerClass with iterations=1 is the same as GenericTimerClass + def init_idle_shutdown(self): + self.private_timer_idle_shutdown = GenericMultiTimerClass( + name=f"{self.package}.private_timer_idle_shutdown", + iterations=1, + wait_seconds_per_iteration=self.idle_timeout, + callee=IdleShutdown + ) + self.private_timer_idle_shutdown.__doc__ = "Timer to shutdown after system is idle for a given time" + plugin.register(self.private_timer_idle_shutdown, name='private_timer_idle_shutdown', package=self.package) + + # Regularly check if player has activity, if not private_timer_idle_check will start/cancel private_timer_idle_shutdown + def init_idle_check(self): + idle_check_timer_instance = IdleCheck() + self.private_timer_idle_check = GenericEndlessTimerClass( + name=f"{self.package}.private_timer_idle_check", + wait_seconds_per_iteration=self.idle_check_interval, + function=idle_check_timer_instance + ) + self.private_timer_idle_check.__doc__ = 'Timer to check if system is idle' + if self.idle_timeout: + self.private_timer_idle_check.start() + + plugin.register(self.private_timer_idle_check, name='private_timer_idle_check', package=self.package) + + @plugin.tag + def start(self, wait_seconds: int): + """Sets idle_shutdown timeout_sec stored in jukebox.yaml""" + cfg.setn('timers', 'idle_shutdown', 'timeout_sec', value=wait_seconds) + plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'start') + + @plugin.tag + def cancel(self): + """Cancels all idle timers and disables idle shutdown in jukebox.yaml""" + plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'cancel') + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel') + cfg.setn('timers', 'idle_shutdown', 'timeout_sec', value=0) + + @plugin.tag + def get_state(self): + """Returns the current state of Idle Shutdown""" + idle_check_state = plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'get_state') + idle_shutdown_state = plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'get_state') + + return { + 'enabled': idle_check_state['enabled'], + 'running': idle_shutdown_state['enabled'], + 'remaining_seconds': idle_shutdown_state['remaining_seconds'], + 'wait_seconds': idle_shutdown_state['wait_seconds_per_iteration'], + } + + +class IdleCheck: + def __init__(self) -> None: + self.last_player_status = plugin.call('player', 'ctrl', 'playerstatus') + logger.debug('Started IdleCheck with initial state: {}'.format(self.last_player_status)) + + # Run function + def __call__(self): + player_status = plugin.call('player', 'ctrl', 'playerstatus') + + if self.last_player_status == player_status: + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'start') + else: + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel') + + self.last_player_status = player_status.copy() + return self.last_player_status + + +class IdleShutdown(): + files_num_entries: int = 0 + files_latest_mtime: float = 0 + + def __init__(self) -> None: + self.base_path = os.path.join(os.path.dirname(__file__), '..', '..', '..', '..') + + def __call__(self): + logger.debug('Last checks before shutting down') + if self._has_active_ssh_sessions(): + logger.debug('Active SSH sessions found, will not shutdown now') + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'set_timeout', args=[int(EXTEND_IDLE_TIMEOUT)]) + return + # if self._has_changed_files(): + # logger.debug('Changes files found, will not shutdown now') + # plugin.call_ignore_errors( + # 'timers', + # 'private_timer_idle_shutdown', + # 'set_timeout', + # args=[int(EXTEND_IDLE_TIMEOUT)]) + # return + + logger.info('No activity, shutting down') + plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'cancel') + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel') + plugin.call_ignore_errors('host', 'shutdown') + + @staticmethod + def _has_active_ssh_sessions(): + logger.debug('Checking for SSH activity') + with os.scandir('/proc') as proc_dir: + for proc_path in proc_dir: + if not proc_path.is_dir(): + continue + try: + with open(os.path.join(proc_path, 'cmdline')) as f: + cmdline = f.read() + except (FileNotFoundError, PermissionError): + continue + if SSH_CHILD_RE.match(cmdline): + return True + + def _has_changed_files(self): + # This is a rather expensive check, but it only runs twice + # when an idle shutdown is initiated. + # Only when there are actual changes (file transfers via + # SFTP, Samba, etc.), the check may run multiple times. + logger.debug('Scanning for file changes') + latest_mtime = 0 + num_entries = 0 + for path in PATHS: + for root, dirs, files in os.walk(os.path.join(self.base_path, path)): + for p in dirs + files: + mtime = os.stat(os.path.join(root, p)).st_mtime + latest_mtime = max(latest_mtime, mtime) + num_entries += 1 + + logger.debug(f'Completed file scan ({num_entries} entries, latest_mtime={latest_mtime})') + if self.files_latest_mtime != latest_mtime or self.files_num_entries != num_entries: + # We compare the number of entries to have a chance to detect file + # deletions as well. + self.files_latest_mtime = latest_mtime + self.files_num_entries = num_entries + return True + + return False diff --git a/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py b/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py new file mode 100644 index 000000000..550378d17 --- /dev/null +++ b/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py @@ -0,0 +1,206 @@ +import logging +import time +import jukebox.cfghandler +import jukebox.plugs as plugin +from jukebox.multitimer import GenericTimerClass, GenericMultiTimerClass + + +logger = logging.getLogger('jb.timers') +cfg = jukebox.cfghandler.get_handler('jukebox') + + +class VolumeFadeoutError(Exception): + """Custom exception for volume fadeout errors""" + pass + + +class VolumeFadeoutAction: + """Handles the actual volume fade out actions""" + def __init__(self, start_volume): + self.start_volume = start_volume + # Use 12 steps for 2 minutes = 10 seconds per step + self.iterations = 12 + self.volume_step = start_volume / (self.iterations - 1) + logger.debug(f"Initialized fadeout from volume {start_volume}") + + def __call__(self, iteration, *args, **kwargs): + """Called for each timer iteration""" + # Calculate target volume for this step + target_volume = max(0, int(self.start_volume - (self.iterations - iteration - 1) * self.volume_step)) + logger.debug(f"Fading volume to {target_volume} (Step {self.iterations - iteration}/{self.iterations})") + plugin.call_ignore_errors('volume', 'ctrl', 'set_volume', args=[target_volume]) + + +class VolumeFadoutAndShutdown: + """Timer system that gracefully fades out volume before shutdown. + + This timer manages three coordinated timers for a smooth shutdown sequence: + 1. Main shutdown timer: Runs for the full duration and triggers the final shutdown + 2. Fadeout start timer: Triggers the volume fadeout 2 minutes before shutdown + 3. Volume fadeout timer: Handles the actual volume reduction in the last 2 minutes + + Example for a 5-minute (300s) timer: + - t=0s: Shutdown timer starts (300s) + Fadeout start timer starts (180s) + - t=180s: Fadeout start timer triggers volume reduction + Volume fadeout begins (12 steps over 120s) + - t=300s: Shutdown timer triggers system shutdown + + The fadeout always takes 2 minutes (120s), regardless of the total timer duration. + The minimum total duration is 2 minutes to accommodate the fadeout period. + All timers can be cancelled together using the cancel() method. + """ + + MIN_TOTAL_DURATION = 120 # 2 minutes minimum + FADEOUT_DURATION = 120 # Last 2 minutes for fadeout + + def __init__(self, name): + self.name = name + self.default_timeout = cfg.setndefault('timers', 'volume_fadeout', 'default_timeout_sec', value=600) + + self.shutdown_timer = None + self.fadeout_start_timer = None + self.fadeout_timer = None + + self._reset_state() + + def _reset_state(self): + """Reset internal state variables""" + self.start_time = None + self.total_duration = None + self.current_volume = None + self.fadeout_started = False + + def _start_fadeout(self): + """Callback for fadeout_start_timer - initiates the volume fadeout""" + logger.info("Starting volume fadeout sequence") + self.fadeout_started = True + + # Get current volume at start of fadeout + self.current_volume = plugin.call('volume', 'ctrl', 'get_volume') + if self.current_volume <= 0: + logger.warning("Volume already at 0, waiting for shutdown") + return + + # Start the fadeout timer + self.fadeout_timer = GenericMultiTimerClass( + name=f"{self.name}_fadeout", + iterations=12, # 12 steps over 2 minutes = 10 seconds per step + wait_seconds_per_iteration=10, + callee=lambda iterations: VolumeFadeoutAction(self.current_volume) + ) + self.fadeout_timer.start() + + def _shutdown(self): + """Callback for shutdown_timer - performs the actual shutdown""" + logger.info("Timer complete, initiating shutdown") + plugin.call_ignore_errors('host', 'shutdown') + + @plugin.tag + def start(self, wait_seconds=None): + """Start the coordinated timer system + + Args: + wait_seconds (float): Total duration until shutdown (optional) + + Raises: + VolumeFadeoutError: If duration too short + """ + # Cancel any existing timers + self.cancel() + + # Use provided duration or default + duration = wait_seconds if wait_seconds is not None else self.default_timeout + + # Validate duration + if duration < self.MIN_TOTAL_DURATION: + raise VolumeFadeoutError(f"Duration must be at least {self.MIN_TOTAL_DURATION} seconds") + + self.start_time = time.time() + self.total_duration = duration + + # Start the main shutdown timer + self.shutdown_timer = GenericTimerClass( + name=f"{self.name}_shutdown", + wait_seconds=duration, + function=self._shutdown + ) + + # Start the fadeout start timer + fadeout_start_time = duration - self.FADEOUT_DURATION + self.fadeout_start_timer = GenericTimerClass( + name=f"{self.name}_fadeout_start", + wait_seconds=fadeout_start_time, + function=self._start_fadeout + ) + + logger.info( + f"Starting timer system: {fadeout_start_time}s until fadeout starts, " + f"total duration {duration}s" + ) + + self.shutdown_timer.start() + self.fadeout_start_timer.start() + + @plugin.tag + def cancel(self): + """Cancel all active timers""" + if self.shutdown_timer and self.shutdown_timer.is_alive(): + logger.info("Cancelling shutdown timer") + self.shutdown_timer.cancel() + + if self.fadeout_start_timer and self.fadeout_start_timer.is_alive(): + logger.info("Cancelling fadeout start timer") + self.fadeout_start_timer.cancel() + + if self.fadeout_timer and self.fadeout_timer.is_alive(): + logger.info("Cancelling volume fadeout") + self.fadeout_timer.cancel() + + self._reset_state() + + @plugin.tag + def is_alive(self): + """Check if any timer is currently active""" + return ( + (self.shutdown_timer and self.shutdown_timer.is_alive()) + or (self.fadeout_start_timer and self.fadeout_start_timer.is_alive()) + or (self.fadeout_timer and self.fadeout_timer.is_alive()) + ) + + @plugin.tag + def get_state(self): + """Get the current state of the timer system""" + if not self.is_alive() or not self.start_time: + return { + 'enabled': False, + 'type': 'VolumeFadoutAndShutdown', + 'total_duration': None, + 'remaining_seconds': 0, + 'progress_percent': 0, + 'error': None + } + + # Use the main shutdown timer for overall progress + elapsed = time.time() - self.start_time + remaining = max(0, self.total_duration - elapsed) + progress = min(100, (elapsed / self.total_duration) * 100 if self.total_duration else 0) + + return { + 'enabled': True, + 'type': 'VolumeFadoutAndShutdown', + 'total_duration': self.total_duration, + 'remaining_seconds': remaining, + 'progress_percent': progress, + 'fadeout_started': self.fadeout_started, + 'error': None + } + + @plugin.tag + def get_config(self): + """Get the current configuration""" + return { + 'default_timeout': self.default_timeout, + 'min_duration': self.MIN_TOTAL_DURATION, + 'fadeout_duration': self.FADEOUT_DURATION + } diff --git a/src/jukebox/jukebox/daemon.py b/src/jukebox/jukebox/daemon.py index e847428e2..1334e89f7 100755 --- a/src/jukebox/jukebox/daemon.py +++ b/src/jukebox/jukebox/daemon.py @@ -70,7 +70,7 @@ def signal_handler(self, esignal, frame): # systemd: By default, a SIGTERM is sent, followed by 90 seconds of waiting followed by a SIGKILL. # Pressing Ctrl-C gives SIGINT self._signal_cnt += 1 - timeout: float = 10.0 + timeout: float = 5.0 time_start = time.time_ns() msg = f"Received signal '{signal.Signals(esignal).name}'. Count = {self._signal_cnt}" print(msg) diff --git a/src/jukebox/jukebox/multitimer.py b/src/jukebox/jukebox/multitimer.py index b03aae83f..facb2cce8 100644 --- a/src/jukebox/jukebox/multitimer.py +++ b/src/jukebox/jukebox/multitimer.py @@ -1,11 +1,28 @@ -# RPi-Jukebox-RFID Version 3 -# Copyright (c) See file LICENSE in project root folder +"""MultiTimer Module -"""Multitimer Module""" +This module provides timer functionality with support for single, multiple, and endless iterations. +It includes three main timer classes: +- MultiTimer: The base timer implementation using threading +- GenericTimerClass: A single-event timer with plugin/RPC support +- GenericEndlessTimerClass: An endless repeating timer +- GenericMultiTimerClass: A multi-iteration timer with callback builder support + +Example usage: + # Single event timer + timer = GenericTimerClass("my_timer", 5.0, my_function) + timer.start() + + # Endless timer + endless_timer = GenericEndlessTimerClass("endless", 1.0, update_function) + endless_timer.start() + + # Multi-iteration timer + multi_timer = GenericMultiTimerClass("counter", 5, 1.0, CounterCallback) + multi_timer.start() +""" import threading -from typing import ( - Callable) +from typing import Callable, Optional, Any, Dict import logging import jukebox.cfghandler import jukebox.plugs as plugin @@ -18,18 +35,40 @@ class MultiTimer(threading.Thread): - """Call a function after a specified number of seconds, repeat that iteration times + """A threaded timer that calls a function after specified intervals. - May be cancelled during any of the wait times. - Function is called with keyword parameter 'iteration' (which decreases down to 0 for the last iteration) + This timer supports both limited iterations and endless execution modes. + In limited iteration mode, it counts down from iterations-1 to 0. + In endless mode (iterations < 0), it runs indefinitely until cancelled. - If iterations is negative, an endlessly repeating timer is created (which needs to be cancelled with cancel()) + The timer can be cancelled at any time using the cancel() method. - Initiates start and publishing by calling self.publish_callback + Attributes: + interval (float): Time in seconds between function calls + iterations (int): Number of times to call the function. Use negative for endless mode + function (Callable): Function to call on each iteration + args (list): Positional arguments to pass to the function + kwargs (dict): Keyword arguments to pass to the function + publish_callback (Optional[Callable]): Function to call on timer start/stop for state publishing - Note: Inspired by threading.Timer and generally using the same API""" + Example: + def my_func(iteration, x, y=10): + print(f"Iteration {iteration}: {x} + {y}") - def __init__(self, interval, iterations, function: Callable, args=None, kwargs=None): + timer = MultiTimer(2.0, 5, my_func, args=[5], kwargs={'y': 20}) + timer.start() + """ + + def __init__(self, interval: float, iterations: int, function: Callable, args=None, kwargs=None): + """Initialize the timer. + + Args: + interval: Seconds between function calls + iterations: Number of iterations (-1 for endless) + function: Function to call each iteration + args: Positional arguments for function + kwargs: Keyword arguments for function + """ super().__init__() self.interval = interval self.iterations = iterations @@ -43,14 +82,19 @@ def __init__(self, interval, iterations, function: Callable, args=None, kwargs=N def cancel(self): """Stop the timer if it hasn't finished all iterations yet.""" logger.debug(f"Cancel timer '{self.name}.") - # Assignment to _cmd_cancel is atomic -> OK for threads self._cmd_cancel = True self.event.set() def trigger(self): + """Trigger the next function call immediately.""" self.event.set() def run_endless(self): + """Run the timer in endless mode. + + The function is called every interval seconds with iteration=-1 + until cancelled. + """ while True: self.event.wait(self.interval) if self.event.is_set(): @@ -58,10 +102,14 @@ def run_endless(self): break else: self.event.clear() - # logger.debug(f"Execute timer action of '{self.name}'.") self.function(iteration=-1, *self.args, **self.kwargs) def run_limited(self): + """Run the timer for a limited number of iterations. + + The function is called every interval seconds with iteration + counting down from iterations-1 to 0. + """ for iteration in range(self.iterations - 1, -1, -1): self.event.wait(self.interval) if self.event.is_set(): @@ -69,10 +117,15 @@ def run_limited(self): break else: self.event.clear() - # logger.debug(f"Execute timer action #{iteration} of '{self.name}'.") self.function(*self.args, iteration=iteration, **self.kwargs) def run(self): + """Start the timer execution. + + This is called automatically when start() is called. + The timer runs in either endless or limited mode based on + the iterations parameter. + """ if self.publish_callback is not None: self.publish_callback() if self.iterations < 0: @@ -88,15 +141,43 @@ def run(self): class GenericTimerClass: + """A single-event timer with plugin/RPC support. + + This class provides a high-level interface for creating and managing + single-execution timers. It includes support for: + - Starting/stopping/toggling the timer + - Publishing timer state + - Getting remaining time + - Adjusting timeout duration + + The timer automatically handles the 'iteration' parameter internally, + so callback functions don't need to handle it. + + Attributes: + name (str): Identifier for the timer + _wait_seconds (float): Interval between function calls + _function (Callable): Wrapped function to call + _iterations (int): Number of iterations (1 for single-event) + + Example: + def update_display(message): + print(message) + + timer = GenericTimerClass("display_timer", 5.0, update_display, + args=["Hello World"]) + timer.start() """ - Interface for plugin / RPC accessibility for a single event timer - """ - def __init__(self, name, wait_seconds: float, function, args=None, kwargs=None): - """ - :param wait_seconds: The time in seconds to wait before calling function - :param function: The function to call with args and kwargs. - :param args: Parameters for function call - :param kwargs: Parameters for function call + + def __init__(self, name: str, wait_seconds: float, function: Callable, + args: Optional[list] = None, kwargs: Optional[dict] = None): + """Initialize the timer. + + Args: + name: Timer identifier + wait_seconds: Time to wait before function call + function: Function to call + args: Positional arguments for function + kwargs: Keyword arguments for function """ self.timer_thread = None self.args = args if args is not None else [] @@ -104,21 +185,25 @@ def __init__(self, name, wait_seconds: float, function, args=None, kwargs=None): self._wait_seconds = wait_seconds self._start_time = 0 # Hide away the argument 'iteration' that is passed from MultiTimer to function - # for a single event Timer (and also endless timers, as the inherit from here) self._function = lambda iteration, *largs, **lkwargs: function(*largs, **lkwargs) self._iterations = 1 self._name = name self._publish_core() @plugin.tag - def start(self, wait_seconds=None): - """Start the timer (with default or new parameters)""" + def start(self, wait_seconds: Optional[float] = None): + """Start the timer with optional new wait time. + + Args: + wait_seconds: Optional new interval to use + """ if self.is_alive(): - logger.error(f"Timer '{self._name}' started! Ignoring start command.") + logger.info(f"Timer '{self._name}' started! Ignoring start command.") return if wait_seconds is not None: self._wait_seconds = wait_seconds - self.timer_thread = MultiTimer(self._wait_seconds, self._iterations, self._function, *self.args, **self.kwargs) + self.timer_thread = MultiTimer(self._wait_seconds, self._iterations, + self._function, self.args, self.kwargs) self.timer_thread.daemon = True self.timer_thread.publish_callback = self._publish_core if self._name is not None: @@ -128,13 +213,13 @@ def start(self, wait_seconds=None): @plugin.tag def cancel(self): - """Cancel the timer""" + """Cancel the timer if it's running.""" if self.is_alive(): self.timer_thread.cancel() @plugin.tag def toggle(self): - """Toggle the activation of the timer""" + """Toggle between started and stopped states.""" if self.is_alive(): self.timer_thread.cancel() else: @@ -142,27 +227,42 @@ def toggle(self): @plugin.tag def trigger(self): - """Trigger the next target execution before the time is up""" + """Trigger the function call immediately.""" if self.is_alive(): self.timer_thread.trigger() @plugin.tag - def is_alive(self): - """Check if timer is active""" + def is_alive(self) -> bool: + """Check if timer is currently running. + + Returns: + bool: True if timer is active, False otherwise + """ if self.timer_thread is None: return False return self.timer_thread.is_alive() @plugin.tag - def get_timeout(self): - """Get the configured time-out + def get_timeout(self) -> float: + """Get the configured timeout interval. - :return: The total wait time. (Not the remaining wait time!)""" + Returns: + float: The wait time in seconds + """ return self._wait_seconds @plugin.tag - def set_timeout(self, wait_seconds: float): - """Set a new time-out in seconds. Re-starts the timer if already running!""" + def set_timeout(self, wait_seconds: float) -> float: + """Set a new timeout interval. + + If the timer is running, it will be restarted with the new interval. + + Args: + wait_seconds: New interval in seconds + + Returns: + float: The new wait time + """ self._wait_seconds = wait_seconds if self.is_alive(): self.cancel() @@ -173,85 +273,181 @@ def set_timeout(self, wait_seconds: float): @plugin.tag def publish(self): - """Publish the current state and config""" + """Publish current timer state.""" self._publish_core() @plugin.tag - def get_state(self): - """Get the current state and config as dictionary""" + def get_state(self) -> Dict[str, Any]: + """Get the current timer state. + + Returns: + dict: Timer state including: + - enabled: Whether timer is running + - remaining_seconds: Time until next function call + - wait_seconds: Configured interval + - type: Timer class name + """ remaining_seconds = max( 0, self.get_timeout() - (int(time()) - self._start_time) ) - return {'enabled': self.is_alive(), - 'remaining_seconds': remaining_seconds, - 'wait_seconds': self.get_timeout(), - 'type': 'GenericTimerClass'} + return { + 'enabled': self.is_alive(), + 'remaining_seconds': remaining_seconds, + 'wait_seconds': self.get_timeout(), + 'type': 'GenericTimerClass' + } - def _publish_core(self, enabled=None): - """Internal publish function with override for enabled + def _publish_core(self, enabled: Optional[bool] = None): + """Internal method to publish timer state. - Enable override is required as this is called from inside the timer when it finishes - This means the timer is still running, but it is the last thing it does. - Otherwise it is not possible to detect the timer change at the end""" + Args: + enabled: Override for enabled state + """ if self._name is not None: state = self.get_state() if enabled is not None: state['enabled'] = enabled logger.debug(f"{self._name}: State = {state}") - # This function may be called from different threads, - # so always freshly get the correct publisher instance publishing.get_publisher().send(self._name, state) class GenericEndlessTimerClass(GenericTimerClass): + """An endless repeating timer. + + This timer runs indefinitely until explicitly cancelled. + It inherits all functionality from GenericTimerClass but + sets iterations to -1 for endless mode. + + Example: + def heartbeat(): + print("Ping") + + timer = GenericEndlessTimerClass("heartbeat", 1.0, heartbeat) + timer.start() """ - Interface for plugin / RPC accessibility for an event timer call function endlessly every m seconds - """ - def __init__(self, name, wait_seconds_per_iteration: float, function, args=None, kwargs=None): - # Remove the necessity for the 'iterations' keyword that is added by GenericTimerClass + + def __init__(self, name: str, wait_seconds_per_iteration: float, + function: Callable, args=None, kwargs=None): + """Initialize endless timer. + + Args: + name: Timer identifier + wait_seconds_per_iteration: Interval between calls + function: Function to call repeatedly + args: Positional arguments for function + kwargs: Keyword arguments for function + """ super().__init__(name, wait_seconds_per_iteration, function, args, kwargs) # Negative iteration count causes endless looping self._iterations = -1 - def get_state(self): - return {'enabled': self.is_alive(), - 'wait_seconds_per_iteration': self.get_timeout(), - 'type': 'GenericEndlessTimerClass'} + @plugin.tag + def get_state(self) -> Dict[str, Any]: + """Get current timer state. + + Returns: + dict: Timer state including: + - enabled: Whether timer is running + - wait_seconds_per_iteration: Interval between calls + - type: Timer class name + """ + return { + 'enabled': self.is_alive(), + 'wait_seconds_per_iteration': self.get_timeout(), + 'type': 'GenericEndlessTimerClass' + } class GenericMultiTimerClass(GenericTimerClass): + """A multi-iteration timer with callback builder support. + + This timer executes a specified number of iterations with a callback + that's created for each full cycle. It's useful when you need stateful + callbacks or complex iteration handling. + + The callee parameter should be a class or function that: + 1. Takes iterations as a parameter during construction + 2. Returns a callable that accepts an iteration parameter + + Example: + class CountdownCallback: + def __init__(self, iterations): + self.total = iterations + + def __call__(self, iteration): + print(f"{iteration} of {self.total} remaining") + + timer = GenericMultiTimerClass("countdown", 5, 1.0, CountdownCallback) + timer.start() """ - Interface for plugin / RPC accessibility for an event timer that performs an action n times every m seconds - """ - def __init__(self, name, iterations: int, wait_seconds_per_iteration: float, callee, args=None, kwargs=None): - """ - :param iterations: Number of times callee is called - :param wait_seconds_per_iteration: Wait in seconds before each iteration - :param callee: A builder class that gets instantiated once as callee(*args, iterations=iterations, **kwargs). - Then with every time out iteration __call__(*args, iteration=iteration, **kwargs) is called. - 'iteration' is the current iteration count in decreasing order! - :param args: - :param kwargs: + + def __init__(self, name: str, iterations: int, wait_seconds_per_iteration: float, + callee: Callable, args=None, kwargs=None): + """Initialize multi-iteration timer. + + Args: + name: Timer identifier + iterations: Total number of iterations + wait_seconds_per_iteration: Interval between calls + callee: Callback builder class/function + args: Positional arguments for callee + kwargs: Keyword arguments for callee """ - super().__init__(name, wait_seconds_per_iteration, None, None, None) + # Initialize with a placeholder function - we'll set the real one in start() + super().__init__(name, wait_seconds_per_iteration, lambda: None, None, None) self.class_args = args if args is not None else [] self.class_kwargs = kwargs if kwargs is not None else {} self._iterations = iterations self._callee = callee @plugin.tag - def start(self, iterations=None, wait_seconds_per_iteration=None): - """Start the timer (with default or new parameters)""" + def start(self, iterations: Optional[int] = None, + wait_seconds_per_iteration: Optional[float] = None): + """Start the timer with optional new parameters. + + Args: + iterations: Optional new iteration count + wait_seconds_per_iteration: Optional new interval + """ if iterations is not None: self._iterations = iterations - self._function = self._callee(*self.class_args, iterations=self._iterations, **self.class_kwargs) + + def create_callback(): + instance = self._callee(*self.class_args, iterations=self._iterations, + **self.class_kwargs) + return lambda iteration, *args, **kwargs: instance(*args, + iteration=iteration, + **kwargs) + + self._function = create_callback() super().start(wait_seconds_per_iteration) @plugin.tag - def get_state(self): - return {'enabled': self.is_alive(), - 'wait_seconds_per_iteration': self.get_timeout(), - 'iterations': self._iterations, - 'type': 'GenericMultiTimerClass'} + def get_state(self) -> Dict[str, Any]: + """Get current timer state. + + Returns: + dict: Timer state including: + - enabled: Whether timer is running + - wait_seconds_per_iteration: Interval between calls + - remaining_seconds_current_iteration: Time until next call + - remaining_seconds: Total time remaining + - iterations: Total iteration count + - type: Timer class name + """ + remaining_seconds_current_iteration = max( + 0, + self.get_timeout() - (int(time()) - self._start_time) + ) + remaining_seconds = (self.get_timeout() * self._iterations + remaining_seconds_current_iteration) + + return { + 'enabled': self.is_alive(), + 'wait_seconds_per_iteration': self.get_timeout(), + 'remaining_seconds_current_iteration': remaining_seconds_current_iteration, + 'remaining_seconds': remaining_seconds, + 'iterations': self._iterations, + 'type': 'GenericMultiTimerClass' + } diff --git a/src/webapp/public/locales/de/translation.json b/src/webapp/public/locales/de/translation.json index 7dbdcf695..1c6729415 100644 --- a/src/webapp/public/locales/de/translation.json +++ b/src/webapp/public/locales/de/translation.json @@ -18,9 +18,9 @@ "shutdown": "Herunterfahren", "reboot": "Neustarten", "say_my_ip": "IP Addresse vorlesen", - "timer_shutdown": "Shut Down", - "timer_stop_player": "Stop player", - "timer_fade_volume": "Fade volume", + "timer_shutdown": "Herunterfahren", + "timer_stop_player": "Player stoppen", + "timer_fade_volume": "Lautstärke ausblenden und herunterfahren", "toggle_output": "Audio-Ausgang umschalten", "sync_rfidcards_all": "Alle Audiodateien und Karteneinträge synchronisieren", "sync_rfidcards_change_on_rfid_scan": "Aktivierung ändern für 'on RFID scan'", @@ -229,11 +229,32 @@ "option-label-timeslot": "{{value}} min", "option-label-off": "Aus", "title": "Timer", - "stop-player": "Wiedergabe stoppen", - "shutdown": "Herunterfahren", - "fade-volume": "Lautstärke ausblenden", - "idle-shutdown": "Leerlaufabschaltung", - "ended": "Beendet" + "stop-player": { + "title": "Wiedergabe stoppen", + "label": "Stoppt die Wiedergabe nach Ablauf des Timers." + }, + "shutdown": { + "title": "Herunterfahren", + "label": "Fährt die Phoniebox nach Ablauf des Timers herunter." + }, + "fade-volume": { + "title": "Lautstärke ausblenden", + "label": "Blendet die Lautstärke zum Ende des Timers langsam aus und fährt dann die Phoniebox herunter." + }, + "idle-shutdown": { + "title": "Leerlaufabschaltung", + "label": "Fährt die Phoniebox herunter, nachdem sie für eine bestimmte Zeit im Leerlauf war." + }, + "set": "Timer erstellen", + "cancel": "Abbrechen", + "paused": "Pausiert", + "ended": "Beendet", + "dialog": { + "title": "{{value}} Timer erstellen", + "description": "Wähle die Anzahl der Minuten nachdem die Aktion ausgeführt werden soll.", + "start": "Timer starten", + "cancel": "Abbrechen" + } }, "secondswipe": { "title": "Erneute Aktivierung (Second Swipe)", diff --git a/src/webapp/public/locales/en/translation.json b/src/webapp/public/locales/en/translation.json index 7ff66ecc4..20a28bdac 100644 --- a/src/webapp/public/locales/en/translation.json +++ b/src/webapp/public/locales/en/translation.json @@ -20,7 +20,7 @@ "say_my_ip": "Say IP address", "timer_shutdown": "Shut Down", "timer_stop_player": "Stop player", - "timer_fade_volume": "Fade volume", + "timer_fade_volume": "Fade volume and Shut Down", "toggle_output": "Toggle audio output", "sync_rfidcards_all": "Sync all audiofiles and card entries", "sync_rfidcards_change_on_rfid_scan": "Change activation of 'on RFID scan'", @@ -229,11 +229,32 @@ "option-label-timeslot": "{{value}} min", "option-label-off": "Off", "title": "Timers", - "stop-player": "Stop player", - "shutdown": "Shut Down", - "fade-volume": "Fade volume", - "idle-shutdown": "Idle Shut Down", - "ended": "Done" + "stop-player": { + "title": "Stop player", + "label": "Stops playback after the timer has ended." + }, + "shutdown": { + "title": "Shut Down", + "label": "Forces the Phoniebox to shut down after the timer has ended." + }, + "fade-volume": { + "title": "Fade volume and Shut Down", + "label": "Slowly fades out volume towards the end of the timer and then shuts down the Phoniebox." + }, + "idle-shutdown": { + "title": "Idle Shut Down", + "label": "Shuts down the Phoniebox after the Phoniebox was idle for a given time." + }, + "ended": "Done", + "set": "Set timer", + "cancel": "Cancel", + "paused": "Paused", + "dialog": { + "title": "Set {{value}} timer", + "description": "Choose the amount of minutes you want the action to be performed.", + "start": "Start timer", + "cancel": "Cancel" + } }, "secondswipe": { "title": "Second Swipe", diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js index f6f772875..1e984997e 100644 --- a/src/webapp/src/commands/index.js +++ b/src/webapp/src/commands/index.js @@ -216,6 +216,25 @@ const commands = { }, + 'timer_idle_shutdown.cancel': { + _package: 'timers', + plugin: 'timer_idle_shutdown', + method: 'cancel', + }, + 'timer_idle_shutdown.get_state': { + _package: 'timers', + plugin: 'timer_idle_shutdown', + method: 'get_state', + }, + 'timer_idle_shutdown': { + _package: 'timers', + plugin: 'timer_idle_shutdown', + method: 'start', + argKeys: ['wait_seconds'], + }, + + + // Host getAutohotspotStatus: { _package: 'host', diff --git a/src/webapp/src/components/Settings/timers/index.js b/src/webapp/src/components/Settings/timers/index.js index c47a42573..a143a3557 100644 --- a/src/webapp/src/components/Settings/timers/index.js +++ b/src/webapp/src/components/Settings/timers/index.js @@ -1,21 +1,18 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { useTheme } from '@mui/material/styles'; - import { Card, CardContent, CardHeader, Divider, Grid, + List, } from '@mui/material'; import Timer from './timer'; const SettingsTimers = () => { const { t } = useTranslation(); - const theme = useTheme(); - const spacer = { marginBottom: theme.spacing(2) } return ( @@ -24,15 +21,13 @@ const SettingsTimers = () => { /> - .MuiGrid-root:not(:last-child)': spacer }} - > - - - - {/* */} + + + + + + + diff --git a/src/webapp/src/components/Settings/timers/set-timer-dialog.js b/src/webapp/src/components/Settings/timers/set-timer-dialog.js new file mode 100644 index 000000000..c948916e8 --- /dev/null +++ b/src/webapp/src/components/Settings/timers/set-timer-dialog.js @@ -0,0 +1,101 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Grid, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; + +import { + SliderTimer +} from '../../general'; + +export default function SetTimerDialog({ + type, + enabled, + setTimer, + cancelTimer, + waitSeconds, + setWaitSeconds, +}) { + const { t } = useTranslation(); + const theme = useTheme(); + + const [dialogOpen, setDialogOpen] = useState(false); + + const handleClickOpen = () => { + setWaitSeconds(0); + setDialogOpen(true); + }; + + const handleCancel = () => { + setDialogOpen(false); + }; + + const handleSetTimer = () => { + setTimer(waitSeconds) + setDialogOpen(false); + } + + return ( + + {!enabled && + + } + {enabled && + + } + + + {t('settings.timers.dialog.title', { value: t(`settings.timers.${type}.title`)} )} + + + + {t('settings.timers.dialog.description')} + + + { setWaitSeconds(value) }} + /> + + + + + + + + + ); +} diff --git a/src/webapp/src/components/Settings/timers/timer.js b/src/webapp/src/components/Settings/timers/timer.js index 20b990edc..28625f86a 100644 --- a/src/webapp/src/components/Settings/timers/timer.js +++ b/src/webapp/src/components/Settings/timers/timer.js @@ -1,116 +1,138 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; - -import { - Box, - Grid, - Switch, - Typography, -} from '@mui/material'; -import { useTheme } from '@mui/material/styles'; - +import { Box, ListItem, ListItemText, Typography } from '@mui/material'; +import { Countdown } from '../../general'; +import SetTimerDialog from './set-timer-dialog'; import request from '../../../utils/request'; -import { - Countdown, - SliderTimer -} from '../../general'; - -const Timer = ({ type }) => { - const { t } = useTranslation(); - const theme = useTheme(); - // Constants +// Custom hook to manage timer state and logic +const useTimer = (type) => { const pluginName = `timer_${type.replace('-', '_')}`; + const [timerState, setTimerState] = useState({ + error: null, + enabled: false, + isLoading: true, + status: { enabled: false }, + waitSeconds: 0, + running: true + }); - // State - const [error, setError] = useState(null); - const [enabled, setEnabled] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [status, setStatus] = useState({ enabled: false }); - const [waitSeconds, setWaitSeconds] = useState(0); - - // Requests - const cancelTimer = async () => { - await request(`${pluginName}.cancel`); - setStatus({ enabled: false }); - }; + const fetchTimerStatus = useCallback(async () => { + try { + const { result: timerStatus, error: timerStatusError } = await request(`${pluginName}.get_state`); - const setTimer = async (event, wait_seconds) => { - await cancelTimer(); + if (timerStatusError) { + throw timerStatusError; + } - if (wait_seconds > 0) { - await request(pluginName, { wait_seconds } ); - fetchTimerStatus(); + setTimerState(prev => ({ + ...prev, + status: timerStatus, + enabled: timerStatus?.enabled, + running: timerStatus.running ?? true, + error: null, + isLoading: false + })); + } catch (error) { + setTimerState(prev => ({ + ...prev, + enabled: false, + error, + isLoading: false + })); } - } - - const fetchTimerStatus = useCallback(async () => { - const { - result: timerStatus, - error: timerStatusError - } = await request(`${pluginName}.get_state`); + }, [pluginName]); - if(timerStatusError) { - setEnabled(false); - return setError(timerStatusError); + const cancelTimer = async () => { + try { + await request(`${pluginName}.cancel`); + setTimerState(prev => ({ ...prev, enabled: false })); + } catch (error) { + setTimerState(prev => ({ ...prev, error })); } + }; - setStatus(timerStatus); - setEnabled(timerStatus?.enabled); - setWaitSeconds(timerStatus?.wait_seconds || 0); - }, [pluginName]); - + const setTimer = async (wait_seconds) => { + try { + await cancelTimer(); + if (wait_seconds > 0) { + await request(pluginName, { wait_seconds }); + await fetchTimerStatus(); + } + } catch (error) { + setTimerState(prev => ({ ...prev, error })); + } + }; - // Event Handlers - const handleSwitch = (event) => { - setEnabled(event.target.checked); - setWaitSeconds(0); // Always start the slider at 0 - cancelTimer(); - } + const setWaitSeconds = (seconds) => { + setTimerState(prev => ({ ...prev, waitSeconds: seconds })); + }; - // Effects useEffect(() => { fetchTimerStatus(); - setIsLoading(false); }, [fetchTimerStatus]); + return { + ...timerState, + setTimer, + cancelTimer, + setWaitSeconds + }; +}; + +// Separate component for timer actions +const TimerActions = ({ enabled, running, status, error, isLoading, type, onSetTimer, onCancelTimer, waitSeconds, onSetWaitSeconds }) => { + const { t } = useTranslation(); + + return ( + + {enabled && running && ( + onCancelTimer()} + stringEnded={t('settings.timers.ended')} + /> + )} + {enabled && !running && ( + {t('settings.timers.paused')} + )} + {error && ⚠️} + {!isLoading && ( + + )} + + ); +}; + +const Timer = ({ type }) => { + const { t } = useTranslation(); + const timer = useTimer(type); + return ( - - - - {t(`settings.timers.${type}`)} - - - {status?.enabled && - setEnabled(false)} - stringEnded={t('settings.timers.ended')} - /> - } - {error && - ⚠️ - } - - - - {enabled && - - - + } - + > + + ); }; diff --git a/src/webapp/src/components/general/Countdown.js b/src/webapp/src/components/general/Countdown.js index 84e91cb88..a3eab33e1 100644 --- a/src/webapp/src/components/general/Countdown.js +++ b/src/webapp/src/components/general/Countdown.js @@ -23,7 +23,7 @@ const Countdown = ({ onEnd, seconds, stringEnded = undefined }) => { } }, [onEndCallback, time]); - if (time) return toHHMMSS(time); + if (time) return toHHMMSS(Math.round(time)); if (stringEnded) return stringEnded; return toHHMMSS(0); }