Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Idle Shutdown Timer support #2332

Merged
merged 13 commits into from
Nov 10, 2024
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
Loading