Skip to content

Commit 0df1a0c

Browse files
hoffiepabera
andauthored
feat: Add Idle Shutdown Timer support (MiczFlor#2332)
* 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: MiczFlor#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 MiczFlor#2386 * Fix flake8 --------- Co-authored-by: pabera <[email protected]>
1 parent bdc1a23 commit 0df1a0c

File tree

15 files changed

+1029
-233
lines changed

15 files changed

+1029
-233
lines changed

documentation/developers/docker.md

+17-2
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,21 @@ would be of course useful to get rid of them, but currently we make a
230230
trade-off between a development environment and solving the specific
231231
details.
232232

233+
### Error when local libzmq Dockerfile has not been built:
234+
235+
``` bash
236+
------
237+
> [jukebox internal] load metadata for docker.io/library/libzmq:local:
238+
------
239+
failed to solve: libzmq:local: pull access denied, repository does not exist or may require authorization: server message: insufficient_scope: authorization failed
240+
```
241+
242+
Build libzmq for your host machine
243+
244+
``` bash
245+
docker build -f docker/Dockerfile.libzmq -t libzmq:local .
246+
```
247+
233248
### `mpd` container
234249

235250
#### Pulseaudio issue on Mac
@@ -286,7 +301,7 @@ Error starting userland proxy: listen tcp4 0.0.0.0:6600: bind: address already i
286301

287302
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)
288303

289-
#### Other error messages
304+
#### MPD issues
290305

291306
When starting the `mpd` container, you will see the following errors.
292307
You can ignore them, MPD will run.
@@ -309,7 +324,7 @@ mpd | alsa_mixer: snd_mixer_handle_events() failed: Input/output error
309324
mpd | exception: Failed to read mixer for 'My ALSA Device': snd_mixer_handle_events() failed: Input/output error
310325
```
311326

312-
### `jukebox` container
327+
#### `jukebox` container
313328

314329
Many features of the Phoniebox are based on the Raspberry Pi hardware.
315330
This hardware can\'t be mocked in a virtual Docker environment. As a

documentation/developers/status.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,8 @@ Topics marked _in progress_ are already in the process of implementation by comm
166166
- [x] Publish mechanism of timer status
167167
- [x] Change multitimer function call interface such that endless timer etc. won't pass the `iteration` kwarg
168168
- [ ] Make timer settings persistent
169-
- [ ] Idle timer
169+
- [x] Idle timer (basic implementation covering player, SSH, config and audio content changes)
170+
- [ ] Idle timer: Do we need further extensions?
170171
- This needs clearer specification: Idle is when no music is playing and no user interaction is taking place
171172
- i.e., needs information from RPC AND from player status. Let's do this when we see a little clearer about Spotify
172173

resources/default-settings/jukebox.default.yaml

+4
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ gpioz:
108108
enable: false
109109
config_file: ../../shared/settings/gpio.yaml
110110
timers:
111+
idle_shutdown:
112+
# If you want the box to shutdown on inactivity automatically, configure timeout_sec with a number of seconds (at least 60).
113+
# Inactivity is defined as: no music playing, no active SSH sessions, no changes in configs or audio content.
114+
timeout_sec: 0
111115
# These timers are always disabled after start
112116
# The following values only give the default values.
113117
# These can be changed when enabling the respective timer on a case-by-case basis w/o saving

src/jukebox/components/timers/__init__.py

+29-28
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
# RPi-Jukebox-RFID Version 3
22
# Copyright (c) See file LICENSE in project root folder
33

4-
from jukebox.multitimer import (GenericTimerClass, GenericMultiTimerClass)
54
import logging
65
import jukebox.cfghandler
76
import jukebox.plugs as plugin
7+
from jukebox.multitimer import GenericTimerClass
8+
from .idle_shutdown_timer import IdleShutdownTimer
9+
from .volume_fadeout_shutdown_timer import VolumeFadoutAndShutdown
810

911

1012
logger = logging.getLogger('jb.timers')
@@ -24,35 +26,18 @@ def stop_player():
2426
plugin.call_ignore_errors('player', 'ctrl', 'stop')
2527

2628

27-
class VolumeFadeOutActionClass:
28-
def __init__(self, iterations):
29-
self.iterations = iterations
30-
# Get the current volume, calculate step size
31-
self.volume = plugin.call('volume', 'ctrl', 'get_volume')
32-
self.step = float(self.volume) / iterations
33-
34-
def __call__(self, iteration):
35-
self.volume = self.volume - self.step
36-
logger.debug(f"Decrease volume to {self.volume} (Iteration index {iteration}/{self.iterations}-1)")
37-
plugin.call_ignore_errors('volume', 'ctrl', 'set_volume', args=[int(self.volume)])
38-
if iteration == 0:
39-
logger.debug("Shut down from volume fade out")
40-
plugin.call_ignore_errors('host', 'shutdown')
41-
42-
4329
# ---------------------------------------------------------------------------
4430
# Create the timers
4531
# ---------------------------------------------------------------------------
4632
timer_shutdown: GenericTimerClass
4733
timer_stop_player: GenericTimerClass
48-
timer_fade_volume: GenericMultiTimerClass
34+
timer_fade_volume: VolumeFadoutAndShutdown
35+
timer_idle_shutdown: IdleShutdownTimer
4936

5037

5138
@plugin.finalize
5239
def finalize():
53-
# TODO: Example with how to call the timers from RPC?
54-
55-
# Create the various timers with fitting doc for plugin reference
40+
# Shutdown Timer
5641
global timer_shutdown
5742
timeout = cfg.setndefault('timers', 'shutdown', 'default_timeout_sec', value=60 * 60)
5843
timer_shutdown = GenericTimerClass(f"{plugin.loaded_as(__name__)}.timer_shutdown",
@@ -62,21 +47,26 @@ def finalize():
6247
# auto-registration would register it with that module. Manually set package to this plugin module
6348
plugin.register(timer_shutdown, name='timer_shutdown', package=plugin.loaded_as(__name__))
6449

50+
# Stop Playback Timer
6551
global timer_stop_player
6652
timeout = cfg.setndefault('timers', 'stop_player', 'default_timeout_sec', value=60 * 60)
6753
timer_stop_player = GenericTimerClass(f"{plugin.loaded_as(__name__)}.timer_stop_player",
6854
timeout, stop_player)
6955
timer_stop_player.__doc__ = "Timer for automatic player stop"
7056
plugin.register(timer_stop_player, name='timer_stop_player', package=plugin.loaded_as(__name__))
7157

72-
global timer_fade_volume
73-
timeout = cfg.setndefault('timers', 'volume_fade_out', 'default_time_per_iteration_sec', value=15 * 60)
74-
steps = cfg.setndefault('timers', 'volume_fade_out', 'number_of_steps', value=10)
75-
timer_fade_volume = GenericMultiTimerClass(f"{plugin.loaded_as(__name__)}.timer_fade_volume",
76-
steps, timeout, VolumeFadeOutActionClass)
77-
timer_fade_volume.__doc__ = "Timer step-wise volume fade out and shutdown"
58+
# Volume Fadeout and Shutdown Timer
59+
timer_fade_volume = VolumeFadoutAndShutdown(
60+
name=f"{plugin.loaded_as(__name__)}.timer_fade_volume"
61+
)
7862
plugin.register(timer_fade_volume, name='timer_fade_volume', package=plugin.loaded_as(__name__))
7963

64+
# Idle Timer
65+
global timer_idle_shutdown
66+
idle_timeout = cfg.setndefault('timers', 'idle_shutdown', 'timeout_sec', value=0)
67+
timer_idle_shutdown = IdleShutdownTimer(package=plugin.loaded_as(__name__), idle_timeout=idle_timeout)
68+
plugin.register(timer_idle_shutdown, name='timer_idle_shutdown', package=plugin.loaded_as(__name__))
69+
8070
# The idle Timer does work in a little sneaky way
8171
# Idle is when there are no calls through the plugin module
8272
# Ahh, but also when music is playing this is not idle...
@@ -101,4 +91,15 @@ def atexit(**ignored_kwargs):
10191
timer_stop_player.cancel()
10292
global timer_fade_volume
10393
timer_fade_volume.cancel()
104-
return [timer_shutdown.timer_thread, timer_stop_player.timer_thread, timer_fade_volume.timer_thread]
94+
global timer_idle_shutdown
95+
timer_idle_shutdown.cancel()
96+
global timer_idle_check
97+
timer_idle_check.cancel()
98+
ret = [
99+
timer_shutdown.timer_thread,
100+
timer_stop_player.timer_thread,
101+
timer_fade_volume.timer_thread,
102+
timer_idle_shutdown.timer_thread,
103+
timer_idle_check.timer_thread
104+
]
105+
return ret
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# RPi-Jukebox-RFID Version 3
2+
# Copyright (c) See file LICENSE in project root folder
3+
4+
import os
5+
import re
6+
import logging
7+
import jukebox.cfghandler
8+
import jukebox.plugs as plugin
9+
from jukebox.multitimer import (GenericEndlessTimerClass, GenericMultiTimerClass)
10+
11+
12+
logger = logging.getLogger('jb.timers.idle_shutdown_timer')
13+
cfg = jukebox.cfghandler.get_handler('jukebox')
14+
15+
SSH_CHILD_RE = re.compile(r'sshd: [^/].*')
16+
PATHS = ['shared/settings',
17+
'shared/audiofolders']
18+
19+
IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS = 60
20+
EXTEND_IDLE_TIMEOUT = 60
21+
IDLE_CHECK_INTERVAL = 10
22+
23+
24+
def get_seconds_since_boot():
25+
# We may not have a stable clock source when there is no network
26+
# connectivity (yet). As we only need to measure the relative time which
27+
# has passed, we can just calculate based on the seconds since boot.
28+
with open('/proc/uptime') as f:
29+
line = f.read()
30+
seconds_since_boot, _ = line.split(' ', 1)
31+
return float(seconds_since_boot)
32+
33+
34+
class IdleShutdownTimer:
35+
def __init__(self, package: str, idle_timeout: int) -> None:
36+
self.private_timer_idle_shutdown = None
37+
self.private_timer_idle_check = None
38+
self.idle_timeout = 0
39+
self.package = package
40+
self.idle_check_interval = IDLE_CHECK_INTERVAL
41+
42+
self.set_idle_timeout(idle_timeout)
43+
self.init_idle_shutdown()
44+
self.init_idle_check()
45+
46+
def set_idle_timeout(self, idle_timeout):
47+
try:
48+
self.idle_timeout = int(idle_timeout)
49+
except ValueError:
50+
logger.warning(f'invalid timers.idle_shutdown.timeout_sec value {repr(idle_timeout)}')
51+
52+
if self.idle_timeout < IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS:
53+
logger.info('disabling idle shutdown timer; set '
54+
'timers.idle_shutdown.timeout_sec to at least '
55+
f'{IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS} seconds to enable')
56+
self.idle_timeout = 0
57+
58+
# Using GenericMultiTimerClass instead of GenericTimerClass as it supports classes rather than functions
59+
# Calling GenericMultiTimerClass with iterations=1 is the same as GenericTimerClass
60+
def init_idle_shutdown(self):
61+
self.private_timer_idle_shutdown = GenericMultiTimerClass(
62+
name=f"{self.package}.private_timer_idle_shutdown",
63+
iterations=1,
64+
wait_seconds_per_iteration=self.idle_timeout,
65+
callee=IdleShutdown
66+
)
67+
self.private_timer_idle_shutdown.__doc__ = "Timer to shutdown after system is idle for a given time"
68+
plugin.register(self.private_timer_idle_shutdown, name='private_timer_idle_shutdown', package=self.package)
69+
70+
# Regularly check if player has activity, if not private_timer_idle_check will start/cancel private_timer_idle_shutdown
71+
def init_idle_check(self):
72+
idle_check_timer_instance = IdleCheck()
73+
self.private_timer_idle_check = GenericEndlessTimerClass(
74+
name=f"{self.package}.private_timer_idle_check",
75+
wait_seconds_per_iteration=self.idle_check_interval,
76+
function=idle_check_timer_instance
77+
)
78+
self.private_timer_idle_check.__doc__ = 'Timer to check if system is idle'
79+
if self.idle_timeout:
80+
self.private_timer_idle_check.start()
81+
82+
plugin.register(self.private_timer_idle_check, name='private_timer_idle_check', package=self.package)
83+
84+
@plugin.tag
85+
def start(self, wait_seconds: int):
86+
"""Sets idle_shutdown timeout_sec stored in jukebox.yaml"""
87+
cfg.setn('timers', 'idle_shutdown', 'timeout_sec', value=wait_seconds)
88+
plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'start')
89+
90+
@plugin.tag
91+
def cancel(self):
92+
"""Cancels all idle timers and disables idle shutdown in jukebox.yaml"""
93+
plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'cancel')
94+
plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel')
95+
cfg.setn('timers', 'idle_shutdown', 'timeout_sec', value=0)
96+
97+
@plugin.tag
98+
def get_state(self):
99+
"""Returns the current state of Idle Shutdown"""
100+
idle_check_state = plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'get_state')
101+
idle_shutdown_state = plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'get_state')
102+
103+
return {
104+
'enabled': idle_check_state['enabled'],
105+
'running': idle_shutdown_state['enabled'],
106+
'remaining_seconds': idle_shutdown_state['remaining_seconds'],
107+
'wait_seconds': idle_shutdown_state['wait_seconds_per_iteration'],
108+
}
109+
110+
111+
class IdleCheck:
112+
def __init__(self) -> None:
113+
self.last_player_status = plugin.call('player', 'ctrl', 'playerstatus')
114+
logger.debug('Started IdleCheck with initial state: {}'.format(self.last_player_status))
115+
116+
# Run function
117+
def __call__(self):
118+
player_status = plugin.call('player', 'ctrl', 'playerstatus')
119+
120+
if self.last_player_status == player_status:
121+
plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'start')
122+
else:
123+
plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel')
124+
125+
self.last_player_status = player_status.copy()
126+
return self.last_player_status
127+
128+
129+
class IdleShutdown():
130+
files_num_entries: int = 0
131+
files_latest_mtime: float = 0
132+
133+
def __init__(self) -> None:
134+
self.base_path = os.path.join(os.path.dirname(__file__), '..', '..', '..', '..')
135+
136+
def __call__(self):
137+
logger.debug('Last checks before shutting down')
138+
if self._has_active_ssh_sessions():
139+
logger.debug('Active SSH sessions found, will not shutdown now')
140+
plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'set_timeout', args=[int(EXTEND_IDLE_TIMEOUT)])
141+
return
142+
# if self._has_changed_files():
143+
# logger.debug('Changes files found, will not shutdown now')
144+
# plugin.call_ignore_errors(
145+
# 'timers',
146+
# 'private_timer_idle_shutdown',
147+
# 'set_timeout',
148+
# args=[int(EXTEND_IDLE_TIMEOUT)])
149+
# return
150+
151+
logger.info('No activity, shutting down')
152+
plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'cancel')
153+
plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel')
154+
plugin.call_ignore_errors('host', 'shutdown')
155+
156+
@staticmethod
157+
def _has_active_ssh_sessions():
158+
logger.debug('Checking for SSH activity')
159+
with os.scandir('/proc') as proc_dir:
160+
for proc_path in proc_dir:
161+
if not proc_path.is_dir():
162+
continue
163+
try:
164+
with open(os.path.join(proc_path, 'cmdline')) as f:
165+
cmdline = f.read()
166+
except (FileNotFoundError, PermissionError):
167+
continue
168+
if SSH_CHILD_RE.match(cmdline):
169+
return True
170+
171+
def _has_changed_files(self):
172+
# This is a rather expensive check, but it only runs twice
173+
# when an idle shutdown is initiated.
174+
# Only when there are actual changes (file transfers via
175+
# SFTP, Samba, etc.), the check may run multiple times.
176+
logger.debug('Scanning for file changes')
177+
latest_mtime = 0
178+
num_entries = 0
179+
for path in PATHS:
180+
for root, dirs, files in os.walk(os.path.join(self.base_path, path)):
181+
for p in dirs + files:
182+
mtime = os.stat(os.path.join(root, p)).st_mtime
183+
latest_mtime = max(latest_mtime, mtime)
184+
num_entries += 1
185+
186+
logger.debug(f'Completed file scan ({num_entries} entries, latest_mtime={latest_mtime})')
187+
if self.files_latest_mtime != latest_mtime or self.files_num_entries != num_entries:
188+
# We compare the number of entries to have a chance to detect file
189+
# deletions as well.
190+
self.files_latest_mtime = latest_mtime
191+
self.files_num_entries = num_entries
192+
return True
193+
194+
return False

0 commit comments

Comments
 (0)