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 &&
+
+ }
+
+
+ );
+}
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);
}