From 09e99cbb37a1683470a946f2208ae967b12a2b9d Mon Sep 17 00:00:00 2001 From: Christian Hoffmann Date: Thu, 11 Apr 2024 23:50:33 +0200 Subject: [PATCH 01/11] feat: Add Idle Shutdown Timer support This adds an optional idle shutdown timer which can be enabled via timers.idle_shutdown.timeout_sec in the jukebox.yaml config. The system will shut down after the given number of seconds if no activity has been detected during that time. Activity is defined as: - music playing - active SSH sessions - changes in configs or audio content. Fixes: #1970 --- documentation/developers/status.md | 3 +- .../default-settings/jukebox.default.yaml | 4 + src/jukebox/components/timers/__init__.py | 30 +++- .../components/timers/idle_shutdown_timer.py | 131 ++++++++++++++++++ 4 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 src/jukebox/components/timers/idle_shutdown_timer.py 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 c087cc024..d87c676fa 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -102,6 +102,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..28e054f37 100644 --- a/src/jukebox/components/timers/__init__.py +++ b/src/jukebox/components/timers/__init__.py @@ -5,11 +5,14 @@ import logging import jukebox.cfghandler import jukebox.plugs as plugin +from .idle_shutdown_timer import IdleShutdownTimer logger = logging.getLogger('jb.timers') cfg = jukebox.cfghandler.get_handler('jukebox') +IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS = 60 + # --------------------------------------------------------------------------- # Action functions for Timers @@ -46,6 +49,7 @@ def __call__(self, iteration): timer_shutdown: GenericTimerClass timer_stop_player: GenericTimerClass timer_fade_volume: GenericMultiTimerClass +timer_idle_shutdown: IdleShutdownTimer @plugin.finalize @@ -77,6 +81,25 @@ def finalize(): timer_fade_volume.__doc__ = "Timer step-wise volume fade out and shutdown" plugin.register(timer_fade_volume, name='timer_fade_volume', package=plugin.loaded_as(__name__)) + global timer_idle_shutdown + timeout = cfg.setndefault('timers', 'idle_shutdown', 'timeout_sec', value=0) + try: + timeout = int(timeout) + except ValueError: + logger.warning(f'invalid timers.idle_shutdown.timeout_sec value {repr(timeout)}') + timeout = 0 + if 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') + timeout = 0 + if not timeout: + timer_idle_shutdown = None + else: + timer_idle_shutdown = IdleShutdownTimer(timeout) + timer_idle_shutdown.__doc__ = 'Timer for automatic shutdown on idle' + timer_idle_shutdown.start() + # 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 +124,9 @@ 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] + ret = [timer_shutdown.timer_thread, timer_stop_player.timer_thread, timer_fade_volume.timer_thread] + global timer_idle_shutdown + if timer_idle_shutdown is not None: + timer_idle_shutdown.cancel() + ret += [timer_idle_shutdown] + 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..e6a2dddad --- /dev/null +++ b/src/jukebox/components/timers/idle_shutdown_timer.py @@ -0,0 +1,131 @@ +# RPi-Jukebox-RFID Version 3 +# Copyright (c) See file LICENSE in project root folder + +import time +import os +import re +import logging +import threading +import jukebox.plugs as plugin + + +logger = logging.getLogger('jb.timers.idle_shutdown_timer') +SSH_CHILD_RE = re.compile(r'sshd: [^/].*') +PATHS = ['shared/settings', + 'shared/audiofolders'] + + +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(threading.Thread): + """ + Shuts down the system if no activity is detected. + The following activity is covered: + - playing music + - active SSH sessions + - changes of configs or audio content + + Note: This does not use one of the generic timers as there don't seem + to be any benefits here. The shutdown timer is kind of special in that it + is a timer which is expected *not* to fire most of the time, because some + activity would restart it. Using threading.Thread directly allows us to + keep using a single, persistent thread. + """ + shutdown_after_seconds: int + last_activity: float = 0 + files_num_entries: int = 0 + files_latest_mtime: float = 0 + running: bool = True + last_player_status = None + SLEEP_INTERVAL_SECONDS: int = 10 + + def __init__(self, timeout_seconds): + super().__init__(name=__class__.__name__) + self.shutdown_after_seconds = timeout_seconds + self.base_path = os.path.join(os.path.dirname(__file__), '..', '..', '..', '..') + self.record_activity() + logger.debug('Started IdleShutdownTimer') + + def record_activity(self): + self.last_activity = get_seconds_since_boot() + + def check(self): + if self.last_activity + self.shutdown_after_seconds > get_seconds_since_boot(): + return + logger.info('No player activity, starting further checks') + if self._has_active_ssh_sessions(): + logger.info('Active SSH sessions found, will not shutdown now') + self.record_activity() + return + if self._has_changed_files(): + logger.info('Changes files found, will not shutdown now') + self.record_activity() + return + logger.info(f'No activity since {self.shutdown_after_seconds} seconds, shutting down') + plugin.call_ignore_errors('host', 'shutdown') + + def run(self): + # We need this once as a baseline: + self._has_changed_files() + # We rely on playerstatus being sent in regular intervals. If this + # is no longer the case at some point, we would need an additional + # timer thread. + while self.running: + time.sleep(self.SLEEP_INTERVAL_SECONDS) + player_status = plugin.call('player', 'ctrl', 'playerstatus') + if player_status == self.last_player_status: + self.check() + else: + self.record_activity() + self.last_player_status = player_status.copy() + + def cancel(self): + self.running = False + + @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 From e418f9a7da4d1f58359e61eae94b71ba316d0b86 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Mon, 22 Apr 2024 16:47:28 +0200 Subject: [PATCH 02/11] refactor: Break down IdleTimer into 2 standard GenericMultiTimerClass and GenericEndlessTimerClass timers --- src/jukebox/components/timers/__init__.py | 45 ++--- .../components/timers/idle_shutdown_timer.py | 170 ++++++++++++------ src/jukebox/jukebox/multitimer.py | 12 +- 3 files changed, 139 insertions(+), 88 deletions(-) diff --git a/src/jukebox/components/timers/__init__.py b/src/jukebox/components/timers/__init__.py index 28e054f37..b627ef906 100644 --- a/src/jukebox/components/timers/__init__.py +++ b/src/jukebox/components/timers/__init__.py @@ -1,18 +1,16 @@ # 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, GenericMultiTimerClass) from .idle_shutdown_timer import IdleShutdownTimer logger = logging.getLogger('jb.timers') cfg = jukebox.cfghandler.get_handler('jukebox') -IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS = 60 - # --------------------------------------------------------------------------- # Action functions for Timers @@ -54,9 +52,7 @@ def __call__(self, iteration): @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", @@ -66,6 +62,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", @@ -73,6 +70,7 @@ 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__)) + # Volume Fade Timer 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) @@ -81,24 +79,11 @@ def finalize(): timer_fade_volume.__doc__ = "Timer step-wise volume fade out and shutdown" plugin.register(timer_fade_volume, name='timer_fade_volume', package=plugin.loaded_as(__name__)) + # Idle Timer global timer_idle_shutdown - timeout = cfg.setndefault('timers', 'idle_shutdown', 'timeout_sec', value=0) - try: - timeout = int(timeout) - except ValueError: - logger.warning(f'invalid timers.idle_shutdown.timeout_sec value {repr(timeout)}') - timeout = 0 - if 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') - timeout = 0 - if not timeout: - timer_idle_shutdown = None - else: - timer_idle_shutdown = IdleShutdownTimer(timeout) - timer_idle_shutdown.__doc__ = 'Timer for automatic shutdown on idle' - timer_idle_shutdown.start() + 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 @@ -124,9 +109,15 @@ def atexit(**ignored_kwargs): timer_stop_player.cancel() global timer_fade_volume timer_fade_volume.cancel() - ret = [timer_shutdown.timer_thread, timer_stop_player.timer_thread, timer_fade_volume.timer_thread] global timer_idle_shutdown - if timer_idle_shutdown is not None: - timer_idle_shutdown.cancel() - ret += [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 index e6a2dddad..92e82ff99 100644 --- a/src/jukebox/components/timers/idle_shutdown_timer.py +++ b/src/jukebox/components/timers/idle_shutdown_timer.py @@ -1,19 +1,25 @@ # RPi-Jukebox-RFID Version 3 # Copyright (c) See file LICENSE in project root folder -import time import os import re import logging -import threading +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 @@ -25,71 +31,119 @@ def get_seconds_since_boot(): return float(seconds_since_boot) -class IdleShutdownTimer(threading.Thread): - """ - Shuts down the system if no activity is detected. - The following activity is covered: - - playing music - - active SSH sessions - - changes of configs or audio content - - Note: This does not use one of the generic timers as there don't seem - to be any benefits here. The shutdown timer is kind of special in that it - is a timer which is expected *not* to fire most of the time, because some - activity would restart it. Using threading.Thread directly allows us to - keep using a single, persistent thread. - """ - shutdown_after_seconds: int - last_activity: float = 0 +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 + + 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 + + self.init_idle_shutdown() + plugin.register(self.private_timer_idle_shutdown, name='private_timer_idle_shutdown', package=self.package) + + self.init_idle_check() + plugin.register(self.private_timer_idle_check, name='private_timer_idle_check', package=self.package) + + # 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" + + # 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.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): + 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): + """Return idle_shutdown timeout_sec stored in jukebox.yaml""" + 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'], + '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 - running: bool = True - last_player_status = None - SLEEP_INTERVAL_SECONDS: int = 10 - def __init__(self, timeout_seconds): - super().__init__(name=__class__.__name__) - self.shutdown_after_seconds = timeout_seconds + def __init__(self, iterations) -> None: self.base_path = os.path.join(os.path.dirname(__file__), '..', '..', '..', '..') - self.record_activity() - logger.debug('Started IdleShutdownTimer') - def record_activity(self): - self.last_activity = get_seconds_since_boot() - - def check(self): - if self.last_activity + self.shutdown_after_seconds > get_seconds_since_boot(): - return - logger.info('No player activity, starting further checks') + def __call__(self, iteration): + logger.debug('Last checks before shutting down') if self._has_active_ssh_sessions(): - logger.info('Active SSH sessions found, will not shutdown now') - self.record_activity() + 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.info('Changes files found, will not shutdown now') - self.record_activity() - return - logger.info(f'No activity since {self.shutdown_after_seconds} seconds, shutting down') + # 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(f'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') - def run(self): - # We need this once as a baseline: - self._has_changed_files() - # We rely on playerstatus being sent in regular intervals. If this - # is no longer the case at some point, we would need an additional - # timer thread. - while self.running: - time.sleep(self.SLEEP_INTERVAL_SECONDS) - player_status = plugin.call('player', 'ctrl', 'playerstatus') - if player_status == self.last_player_status: - self.check() - else: - self.record_activity() - self.last_player_status = player_status.copy() - - def cancel(self): - self.running = False - @staticmethod def _has_active_ssh_sessions(): logger.debug('Checking for SSH activity') diff --git a/src/jukebox/jukebox/multitimer.py b/src/jukebox/jukebox/multitimer.py index b03aae83f..cac5f8505 100644 --- a/src/jukebox/jukebox/multitimer.py +++ b/src/jukebox/jukebox/multitimer.py @@ -4,8 +4,7 @@ """Multitimer Module""" import threading -from typing import ( - Callable) +from typing import Callable import logging import jukebox.cfghandler import jukebox.plugs as plugin @@ -114,7 +113,7 @@ def __init__(self, name, wait_seconds: float, function, args=None, kwargs=None): def start(self, wait_seconds=None): """Start the timer (with default or new parameters)""" 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 @@ -215,6 +214,7 @@ def __init__(self, name, wait_seconds_per_iteration: float, function, args=None, # Negative iteration count causes endless looping self._iterations = -1 + @plugin.tag def get_state(self): return {'enabled': self.is_alive(), 'wait_seconds_per_iteration': self.get_timeout(), @@ -251,7 +251,13 @@ def start(self, iterations=None, wait_seconds_per_iteration=None): @plugin.tag def get_state(self): + remaining_seconds = max( + 0, + self.get_timeout() - (int(time()) - self._start_time) + ) + return {'enabled': self.is_alive(), 'wait_seconds_per_iteration': self.get_timeout(), + 'remaining_seconds': remaining_seconds, 'iterations': self._iterations, 'type': 'GenericMultiTimerClass'} From 698de7d946812862f7e7f38839e105933a7472a7 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Wed, 24 Apr 2024 12:25:54 +0200 Subject: [PATCH 03/11] feat: Introducing new Timer UI, including Idle Shutdown --- .../components/timers/idle_shutdown_timer.py | 19 ++-- src/webapp/public/locales/de/translation.json | 11 +- src/webapp/public/locales/en/translation.json | 15 ++- src/webapp/src/commands/index.js | 19 ++++ .../src/components/Settings/timers/index.js | 4 +- .../Settings/timers/set-timer-dialog.js | 101 ++++++++++++++++++ .../src/components/Settings/timers/timer.js | 59 +++++----- 7 files changed, 183 insertions(+), 45 deletions(-) create mode 100644 src/webapp/src/components/Settings/timers/set-timer-dialog.js diff --git a/src/jukebox/components/timers/idle_shutdown_timer.py b/src/jukebox/components/timers/idle_shutdown_timer.py index 92e82ff99..2bbe86cc4 100644 --- a/src/jukebox/components/timers/idle_shutdown_timer.py +++ b/src/jukebox/components/timers/idle_shutdown_timer.py @@ -38,23 +38,26 @@ def __init__(self, package: str, idle_timeout: int) -> 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() + plugin.register(self.private_timer_idle_shutdown, name='private_timer_idle_shutdown', package=self.package) + + self.init_idle_check() + plugin.register(self.private_timer_idle_check, name='private_timer_idle_check', package=self.package) + + 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 - self.init_idle_shutdown() - plugin.register(self.private_timer_idle_shutdown, name='private_timer_idle_shutdown', package=self.package) - - self.init_idle_check() - plugin.register(self.private_timer_idle_check, name='private_timer_idle_check', package=self.package) - # 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): @@ -86,18 +89,20 @@ def start(self, wait_seconds: int): @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): - """Return idle_shutdown timeout_sec stored in jukebox.yaml""" + """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'], } diff --git a/src/webapp/public/locales/de/translation.json b/src/webapp/public/locales/de/translation.json index d1a4391d6..6ef4a919e 100644 --- a/src/webapp/public/locales/de/translation.json +++ b/src/webapp/public/locales/de/translation.json @@ -227,7 +227,16 @@ "shutdown": "Herunterfahren", "fade-volume": "Lautstärke ausblenden", "idle-shutdown": "Leerlaufabschaltung", - "ended": "Beendet" + "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 74fd9a696..33be04740 100644 --- a/src/webapp/public/locales/en/translation.json +++ b/src/webapp/public/locales/en/translation.json @@ -224,10 +224,19 @@ "option-label-off": "Off", "title": "Timers", "stop-player": "Stop player", - "shutdown": "Shut Down", + "shutdown": "Shutdown", "fade-volume": "Fade volume", - "idle-shutdown": "Idle Shut Down", - "ended": "Done" + "idle-shutdown": "Idle shutdown", + "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 8c844d8da..2e4b58174 100644 --- a/src/webapp/src/commands/index.js +++ b/src/webapp/src/commands/index.js @@ -215,6 +215,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..4b0092ba3 100644 --- a/src/webapp/src/components/Settings/timers/index.js +++ b/src/webapp/src/components/Settings/timers/index.js @@ -31,8 +31,8 @@ const SettingsTimers = () => { > - - {/* */} + {/* */} + 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..002a4a09a --- /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}`)} )} + + + + {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..e5fba097e 100644 --- a/src/webapp/src/components/Settings/timers/timer.js +++ b/src/webapp/src/components/Settings/timers/timer.js @@ -4,20 +4,15 @@ import { useTranslation } from 'react-i18next'; import { Box, Grid, - Switch, Typography, } from '@mui/material'; -import { useTheme } from '@mui/material/styles'; import request from '../../../utils/request'; -import { - Countdown, - SliderTimer -} from '../../general'; +import { Countdown } from '../../general'; +import SetTimerDialog from './set-timer-dialog'; const Timer = ({ type }) => { const { t } = useTranslation(); - const theme = useTheme(); // Constants const pluginName = `timer_${type.replace('-', '_')}`; @@ -28,14 +23,15 @@ const Timer = ({ type }) => { const [isLoading, setIsLoading] = useState(true); const [status, setStatus] = useState({ enabled: false }); const [waitSeconds, setWaitSeconds] = useState(0); + const [running, setRunning] = useState(true); // Requests const cancelTimer = async () => { await request(`${pluginName}.cancel`); - setStatus({ enabled: false }); + setEnabled(false); }; - const setTimer = async (event, wait_seconds) => { + const setTimer = async (wait_seconds) => { await cancelTimer(); if (wait_seconds > 0) { @@ -57,17 +53,14 @@ const Timer = ({ type }) => { setStatus(timerStatus); setEnabled(timerStatus?.enabled); - setWaitSeconds(timerStatus?.wait_seconds || 0); + if (timerStatus.running === undefined) { + setRunning(true); + } + else { + setRunning(timerStatus.running); + } }, [pluginName]); - - // Event Handlers - const handleSwitch = (event) => { - setEnabled(event.target.checked); - setWaitSeconds(0); // Always start the slider at 0 - cancelTimer(); - } - // Effects useEffect(() => { fetchTimerStatus(); @@ -85,31 +78,33 @@ const Timer = ({ type }) => { alignItems: 'center', marginLeft: '0', }}> - {status?.enabled && + {enabled && running && setEnabled(false)} stringEnded={t('settings.timers.ended')} /> } + {enabled && !running && + + Paused + + } {error && ⚠️ } - + {!isLoading && + + } - {enabled && - - - - } ); }; From 5a38e0e2813a256e7c4de90702834a9b4e9aa861 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Thu, 2 May 2024 19:19:13 +0200 Subject: [PATCH 04/11] refactor: Abstract into functions --- src/jukebox/components/timers/idle_shutdown_timer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/jukebox/components/timers/idle_shutdown_timer.py b/src/jukebox/components/timers/idle_shutdown_timer.py index 2bbe86cc4..fc4d26512 100644 --- a/src/jukebox/components/timers/idle_shutdown_timer.py +++ b/src/jukebox/components/timers/idle_shutdown_timer.py @@ -38,13 +38,10 @@ def __init__(self, package: str, idle_timeout: int) -> None: self.idle_timeout = 0 self.package = package self.idle_check_interval = IDLE_CHECK_INTERVAL - self.set_idle_timeout(idle_timeout) + self.set_idle_timeout(idle_timeout) self.init_idle_shutdown() - plugin.register(self.private_timer_idle_shutdown, name='private_timer_idle_shutdown', package=self.package) - self.init_idle_check() - plugin.register(self.private_timer_idle_check, name='private_timer_idle_check', package=self.package) def set_idle_timeout(self, idle_timeout): try: @@ -68,6 +65,7 @@ def init_idle_shutdown(self): 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): @@ -81,6 +79,8 @@ def init_idle_check(self): 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""" From 51f10894f2317d86441a00e636143fe6247ba07b Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Wed, 26 Jun 2024 07:52:23 +0200 Subject: [PATCH 05/11] Adding Sleep timer / not functional --- documentation/developers/docker.md | 19 ++++- src/jukebox/components/timers/__init__.py | 36 +++------ .../components/timers/idle_shutdown_timer.py | 4 +- .../timers/volume_fadeout_shutdown_timer.py | 77 +++++++++++++++++++ src/jukebox/jukebox/daemon.py | 2 +- src/jukebox/jukebox/multitimer.py | 4 +- src/webapp/public/locales/de/translation.json | 6 +- src/webapp/public/locales/en/translation.json | 8 +- .../src/components/Settings/timers/index.js | 2 +- 9 files changed, 119 insertions(+), 39 deletions(-) create mode 100644 src/jukebox/components/timers/volume_fadeout_shutdown_timer.py 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/src/jukebox/components/timers/__init__.py b/src/jukebox/components/timers/__init__.py index b627ef906..dd79604d3 100644 --- a/src/jukebox/components/timers/__init__.py +++ b/src/jukebox/components/timers/__init__.py @@ -4,8 +4,9 @@ import logging import jukebox.cfghandler import jukebox.plugs as plugin -from jukebox.multitimer import (GenericTimerClass, GenericMultiTimerClass) +from jukebox.multitimer import GenericTimerClass from .idle_shutdown_timer import IdleShutdownTimer +from .volume_fadeout_shutdown_timer import VolumeFadoutAndShutdown logger = logging.getLogger('jb.timers') @@ -24,29 +25,12 @@ def stop_player(): logger.info("Stopping the player on timer request...") 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 @@ -70,13 +54,15 @@ 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__)) - # Volume Fade Timer + # Volume Fadeout and Shutdown Timer 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" + current_volume = plugin.call('volume', 'ctrl', 'get_volume') + step_size = cfg.setndefault('timers', 'timer_fade_volume', 'step_size', value=3) + timer_fade_volume = VolumeFadoutAndShutdown( + name=f"{plugin.loaded_as(__name__)}.timer_fade_volume", + current_volume=current_volume + # step_size=step_size + ) plugin.register(timer_fade_volume, name='timer_fade_volume', package=plugin.loaded_as(__name__)) # Idle Timer diff --git a/src/jukebox/components/timers/idle_shutdown_timer.py b/src/jukebox/components/timers/idle_shutdown_timer.py index fc4d26512..3f13cb675 100644 --- a/src/jukebox/components/timers/idle_shutdown_timer.py +++ b/src/jukebox/components/timers/idle_shutdown_timer.py @@ -130,10 +130,10 @@ class IdleShutdown(): files_num_entries: int = 0 files_latest_mtime: float = 0 - def __init__(self, iterations) -> None: + def __init__(self) -> None: self.base_path = os.path.join(os.path.dirname(__file__), '..', '..', '..', '..') - def __call__(self, iteration): + 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') 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..779caabf5 --- /dev/null +++ b/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py @@ -0,0 +1,77 @@ +import logging +import time +import jukebox.cfghandler +import jukebox.plugs as plugin +from jukebox.multitimer import GenericMultiTimerClass + +logger = logging.getLogger('jb.timers') +cfg = jukebox.cfghandler.get_handler('jukebox') + +class VolumeFadoutAndShutdown: + def __init__(self, name, current_volume=100): + self.timer = None + self.name = name + self.current_volume = current_volume + self.iterations = None + self.wait_seconds_per_iteration = None + self.start_time = None # Store the start time + self.total_wait_seconds = None # Store the total wait time + + def _calculate_fadeout_parameters(self, wait_seconds, step_size): + """Calculate iterations and wait_seconds_per_iteration based on wait_seconds and step_size.""" + self.iterations = max(1, int(self.current_volume / step_size)) # Ensure at least one iteration + self.wait_seconds_per_iteration = float(wait_seconds) / self.iterations + self.total_wait_seconds = wait_seconds + + @plugin.tag + def start(self, wait_seconds=None, step_size=3): + """Start the volume fadeout and shutdown timer.""" + if wait_seconds is None or step_size is None: + raise ValueError("Both wait_seconds and step_size must be provided") + + self._calculate_fadeout_parameters(wait_seconds, step_size) + self.start_time = time() # Record the start time + + self.timer = GenericMultiTimerClass( + name=self.name, + iterations=self.iterations, + wait_seconds_per_iteration=self.wait_seconds_per_iteration, + callee=lambda iterations: VolumeFadoutAndShutdownActionClass(iterations, step_size) + ) + self.timer.start() + + @plugin.tag + def cancel(self): + """Cancel the volume fadeout and shutdown timer.""" + if self.timer and self.timer.is_alive(): + self.timer.cancel() + + @plugin.tag + def get_state(self): + """Get the current state of the volume fadeout and shutdown timer.""" + if not self.timer: + return {} + + timer_state = self.timer.get_state() + elapsed_time = time() - self.start_time + remaining_total_seconds = max(0, self.total_wait_seconds - elapsed_time) + + return { + **timer_state, + 'remaining_total_seconds': remaining_total_seconds, + 'total_wait_seconds': self.total_wait_seconds, + } + +class VolumeFadoutAndShutdownActionClass: + def __init__(self, iterations, step_size): + self.iterations = iterations + self.step_size = step_size + self.volume = plugin.call('volume', 'ctrl', 'get_volume') + + def __call__(self, iteration): + self.volume -= self.step_size + 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') 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 cac5f8505..853d36993 100644 --- a/src/jukebox/jukebox/multitimer.py +++ b/src/jukebox/jukebox/multitimer.py @@ -251,13 +251,15 @@ def start(self, iterations=None, wait_seconds_per_iteration=None): @plugin.tag def get_state(self): - remaining_seconds = max( + 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 b6daa245e..5fb602d26 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'", diff --git a/src/webapp/public/locales/en/translation.json b/src/webapp/public/locales/en/translation.json index 277c4de1c..a8198725b 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'", @@ -230,9 +230,9 @@ "option-label-off": "Off", "title": "Timers", "stop-player": "Stop player", - "shutdown": "Shutdown", - "fade-volume": "Fade volume", - "idle-shutdown": "Idle shutdown", + "shutdown": "Shut Down", + "fade-volume": "Fade volume and Shut Down", + "idle-shutdown": "Idle Shut Down", "ended": "Done", "set": "Set timer", "cancel": "Cancel", diff --git a/src/webapp/src/components/Settings/timers/index.js b/src/webapp/src/components/Settings/timers/index.js index 4b0092ba3..35c9f0a02 100644 --- a/src/webapp/src/components/Settings/timers/index.js +++ b/src/webapp/src/components/Settings/timers/index.js @@ -31,7 +31,7 @@ const SettingsTimers = () => { > - {/* */} + From a98829082e77e034168c6031b5841fff18718320 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Sun, 3 Nov 2024 12:03:00 +0100 Subject: [PATCH 06/11] Finalize Volume Fadeout Shutdown timer --- src/jukebox/components/timers/__init__.py | 7 +- .../timers/volume_fadeout_shutdown_timer.py | 231 ++++++++++++++---- src/webapp/public/locales/de/translation.json | 20 +- src/webapp/public/locales/en/translation.json | 20 +- .../src/components/Settings/timers/index.js | 21 +- .../src/components/Settings/timers/timer.js | 203 ++++++++------- 6 files changed, 334 insertions(+), 168 deletions(-) diff --git a/src/jukebox/components/timers/__init__.py b/src/jukebox/components/timers/__init__.py index dd79604d3..14371cbe3 100644 --- a/src/jukebox/components/timers/__init__.py +++ b/src/jukebox/components/timers/__init__.py @@ -55,13 +55,8 @@ def finalize(): plugin.register(timer_stop_player, name='timer_stop_player', package=plugin.loaded_as(__name__)) # Volume Fadeout and Shutdown Timer - global timer_fade_volume - current_volume = plugin.call('volume', 'ctrl', 'get_volume') - step_size = cfg.setndefault('timers', 'timer_fade_volume', 'step_size', value=3) timer_fade_volume = VolumeFadoutAndShutdown( - name=f"{plugin.loaded_as(__name__)}.timer_fade_volume", - current_volume=current_volume - # step_size=step_size + name=f"{plugin.loaded_as(__name__)}.timer_fade_volume" ) plugin.register(timer_fade_volume, name='timer_fade_volume', package=plugin.loaded_as(__name__)) diff --git a/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py b/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py index 779caabf5..6fdaa8372 100644 --- a/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py +++ b/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py @@ -2,76 +2,201 @@ import time import jukebox.cfghandler import jukebox.plugs as plugin -from jukebox.multitimer import GenericMultiTimerClass +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: - def __init__(self, name, current_volume=100): - self.timer = None + """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.current_volume = current_volume - self.iterations = None - self.wait_seconds_per_iteration = None - self.start_time = None # Store the start time - self.total_wait_seconds = None # Store the total wait time - - def _calculate_fadeout_parameters(self, wait_seconds, step_size): - """Calculate iterations and wait_seconds_per_iteration based on wait_seconds and step_size.""" - self.iterations = max(1, int(self.current_volume / step_size)) # Ensure at least one iteration - self.wait_seconds_per_iteration = float(wait_seconds) / self.iterations - self.total_wait_seconds = wait_seconds + 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, step_size=3): - """Start the volume fadeout and shutdown timer.""" - if wait_seconds is None or step_size is None: - raise ValueError("Both wait_seconds and step_size must be provided") - - self._calculate_fadeout_parameters(wait_seconds, step_size) - self.start_time = time() # Record the start time - - self.timer = GenericMultiTimerClass( - name=self.name, - iterations=self.iterations, - wait_seconds_per_iteration=self.wait_seconds_per_iteration, - callee=lambda iterations: VolumeFadoutAndShutdownActionClass(iterations, step_size) + 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 ) - self.timer.start() + + # 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 the volume fadeout and shutdown timer.""" - if self.timer and self.timer.is_alive(): - self.timer.cancel() + """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 bool( + (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 volume fadeout and shutdown timer.""" - if not self.timer: - return {} + """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 + } - timer_state = self.timer.get_state() - elapsed_time = time() - self.start_time - remaining_total_seconds = max(0, self.total_wait_seconds - elapsed_time) + # 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 { - **timer_state, - 'remaining_total_seconds': remaining_total_seconds, - 'total_wait_seconds': self.total_wait_seconds, + 'enabled': True, + 'type': 'VolumeFadoutAndShutdown', + 'total_duration': self.total_duration, + 'remaining_seconds': remaining, + 'progress_percent': progress, + 'fadeout_started': self.fadeout_started, + 'error': None } -class VolumeFadoutAndShutdownActionClass: - def __init__(self, iterations, step_size): - self.iterations = iterations - self.step_size = step_size - self.volume = plugin.call('volume', 'ctrl', 'get_volume') - - def __call__(self, iteration): - self.volume -= self.step_size - 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') + @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/webapp/public/locales/de/translation.json b/src/webapp/public/locales/de/translation.json index 5fb602d26..1c6729415 100644 --- a/src/webapp/public/locales/de/translation.json +++ b/src/webapp/public/locales/de/translation.json @@ -229,10 +229,22 @@ "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", + "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", diff --git a/src/webapp/public/locales/en/translation.json b/src/webapp/public/locales/en/translation.json index a8198725b..20a28bdac 100644 --- a/src/webapp/public/locales/en/translation.json +++ b/src/webapp/public/locales/en/translation.json @@ -229,10 +229,22 @@ "option-label-timeslot": "{{value}} min", "option-label-off": "Off", "title": "Timers", - "stop-player": "Stop player", - "shutdown": "Shut Down", - "fade-volume": "Fade volume and Shut Down", - "idle-shutdown": "Idle Shut Down", + "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", diff --git a/src/webapp/src/components/Settings/timers/index.js b/src/webapp/src/components/Settings/timers/index.js index 35c9f0a02..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/timer.js b/src/webapp/src/components/Settings/timers/timer.js index e5fba097e..28625f86a 100644 --- a/src/webapp/src/components/Settings/timers/timer.js +++ b/src/webapp/src/components/Settings/timers/timer.js @@ -1,111 +1,138 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; - -import { - Box, - Grid, - Typography, -} from '@mui/material'; - -import request from '../../../utils/request'; +import { Box, ListItem, ListItemText, Typography } from '@mui/material'; import { Countdown } from '../../general'; import SetTimerDialog from './set-timer-dialog'; +import request from '../../../utils/request'; -const Timer = ({ type }) => { - const { t } = useTranslation(); - - // 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); - const [running, setRunning] = useState(true); - - // Requests - const cancelTimer = async () => { - await request(`${pluginName}.cancel`); - setEnabled(false); - }; + const fetchTimerStatus = useCallback(async () => { + try { + const { result: timerStatus, error: timerStatusError } = await request(`${pluginName}.get_state`); - const setTimer = async (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); - if (timerStatus.running === undefined) { - setRunning(true); - } - else { - setRunning(timerStatus.running); + 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 })); } - }, [pluginName]); + }; + + 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}`)} - - - {enabled && running && - setEnabled(false)} - stringEnded={t('settings.timers.ended')} - /> - } - {enabled && !running && - - Paused - - } - {error && - ⚠️ - } - {!isLoading && - - } - - - + + } + > + + ); }; From c292c4b8118b57567b8d273d966a4e743dd9679c Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Sun, 3 Nov 2024 12:32:58 +0100 Subject: [PATCH 07/11] Fix flake8 --- .../timers/volume_fadeout_shutdown_timer.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py b/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py index 6fdaa8372..550378d17 100644 --- a/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py +++ b/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py @@ -4,13 +4,16 @@ 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): @@ -27,6 +30,7 @@ def __call__(self, iteration, *args, **kwargs): 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. @@ -158,10 +162,10 @@ def cancel(self): @plugin.tag def is_alive(self): """Check if any timer is currently active""" - return bool( - (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()) + 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 From 793d26cf93ea387a372e2a69a6037033ca7ca197 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Sun, 3 Nov 2024 12:51:55 +0100 Subject: [PATCH 08/11] Fix more flake8s --- src/jukebox/components/timers/__init__.py | 1 + src/jukebox/components/timers/idle_shutdown_timer.py | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/jukebox/components/timers/__init__.py b/src/jukebox/components/timers/__init__.py index 14371cbe3..4fe70aa80 100644 --- a/src/jukebox/components/timers/__init__.py +++ b/src/jukebox/components/timers/__init__.py @@ -25,6 +25,7 @@ def stop_player(): logger.info("Stopping the player on timer request...") plugin.call_ignore_errors('player', 'ctrl', 'stop') + # --------------------------------------------------------------------------- # Create the timers # --------------------------------------------------------------------------- diff --git a/src/jukebox/components/timers/idle_shutdown_timer.py b/src/jukebox/components/timers/idle_shutdown_timer.py index 3f13cb675..d1881b522 100644 --- a/src/jukebox/components/timers/idle_shutdown_timer.py +++ b/src/jukebox/components/timers/idle_shutdown_timer.py @@ -141,10 +141,14 @@ def __call__(self): 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)]) + # plugin.call_ignore_errors( + # 'timers', + # 'private_timer_idle_shutdown', + # 'set_timeout', + # args=[int(EXTEND_IDLE_TIMEOUT)]) # return - logger.info(f'No activity, shutting down') + 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') From 683574d856b51bff1408b784f4fce700f1e938bc Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Sat, 9 Nov 2024 22:40:59 +0100 Subject: [PATCH 09/11] Fix small bugs --- src/webapp/src/components/Settings/timers/set-timer-dialog.js | 2 +- src/webapp/src/components/general/Countdown.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webapp/src/components/Settings/timers/set-timer-dialog.js b/src/webapp/src/components/Settings/timers/set-timer-dialog.js index 002a4a09a..c948916e8 100644 --- a/src/webapp/src/components/Settings/timers/set-timer-dialog.js +++ b/src/webapp/src/components/Settings/timers/set-timer-dialog.js @@ -69,7 +69,7 @@ export default function SetTimerDialog({ aria-describedby="alert-dialog-description" > - {t('settings.timers.dialog.title', { value: t(`settings.timers.${type}`)} )} + {t('settings.timers.dialog.title', { value: t(`settings.timers.${type}.title`)} )} 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); } From 42083d4d8b4ac3dc9fab4a82d20cce9c3347254e Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Sun, 10 Nov 2024 00:01:47 +0100 Subject: [PATCH 10/11] Improve multitimer.py suggested by #2386 --- src/jukebox/jukebox/multitimer.py | 353 +++++++++++++++++++++++------- 1 file changed, 271 insertions(+), 82 deletions(-) diff --git a/src/jukebox/jukebox/multitimer.py b/src/jukebox/jukebox/multitimer.py index 853d36993..87a2132e1 100644 --- a/src/jukebox/jukebox/multitimer.py +++ b/src/jukebox/jukebox/multitimer.py @@ -1,10 +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 @@ -17,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 @@ -42,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(): @@ -57,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(): @@ -68,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: @@ -87,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 [] @@ -103,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.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: @@ -127,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: @@ -141,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() @@ -172,94 +273,182 @@ 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 @plugin.tag - def get_state(self): - return {'enabled': self.is_alive(), - 'wait_seconds_per_iteration': self.get_timeout(), - 'type': 'GenericEndlessTimerClass'} + 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): + 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'} + 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' + } From ef887f21b92e8df6f61b4f2658db93f955a6881d Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Sun, 10 Nov 2024 00:04:32 +0100 Subject: [PATCH 11/11] Fix flake8 --- src/jukebox/jukebox/multitimer.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jukebox/jukebox/multitimer.py b/src/jukebox/jukebox/multitimer.py index 87a2132e1..facb2cce8 100644 --- a/src/jukebox/jukebox/multitimer.py +++ b/src/jukebox/jukebox/multitimer.py @@ -441,8 +441,7 @@ def get_state(self) -> Dict[str, Any]: 0, self.get_timeout() - (int(time()) - self._start_time) ) - remaining_seconds = (self.get_timeout() * self._iterations + - remaining_seconds_current_iteration) + remaining_seconds = (self.get_timeout() * self._iterations + remaining_seconds_current_iteration) return { 'enabled': self.is_alive(),