Skip to content

Commit

Permalink
feat: Add Idle Shutdown Timer support (#2332)
Browse files Browse the repository at this point in the history
* 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

* refactor: Break down IdleTimer into 2 standard GenericMultiTimerClass and GenericEndlessTimerClass timers

* feat: Introducing new Timer UI, including Idle Shutdown

* refactor: Abstract into functions

* Adding Sleep timer / not functional

* Finalize Volume Fadeout Shutdown timer

* Fix flake8

* Fix more flake8s

* Fix small bugs

* Improve multitimer.py suggested by #2386

* Fix flake8

---------

Co-authored-by: pabera <[email protected]>
  • Loading branch information
hoffie and pabera authored Nov 10, 2024
1 parent bdc1a23 commit 0df1a0c
Show file tree
Hide file tree
Showing 15 changed files with 1,029 additions and 233 deletions.
19 changes: 17 additions & 2 deletions documentation/developers/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion documentation/developers/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions resources/default-settings/jukebox.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 29 additions & 28 deletions src/jukebox/components/timers/__init__.py
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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",
Expand All @@ -62,21 +47,26 @@ 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",
timeout, stop_player)
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...
Expand All @@ -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
194 changes: 194 additions & 0 deletions src/jukebox/components/timers/idle_shutdown_timer.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 0df1a0c

Please sign in to comment.