Skip to content

Commit

Permalink
Add Idle Shutdown Timer support
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
hoffie committed Apr 11, 2024
1 parent 3865c5a commit 7cc4f00
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 2 deletions.
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 @@ -102,6 +102,10 @@ gpioz:
enable: false
config_file: ../../shared/settings/gpio.yaml
timers:
idle_shutdown:
# If you uncomment the following line, the box will shutdown approximately after that many seconds of inactivity.
# Inactivity is defined as: no music playing, no active SSH sessions, no changes in configs or audio content.
#timeout_sec: 900
# 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
18 changes: 17 additions & 1 deletion src/jukebox/components/timers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
import jukebox.cfghandler
import jukebox.plugs as plugin
from .idle_shutdown_timer import IdleShutdownTimer


logger = logging.getLogger('jb.timers')
Expand Down Expand Up @@ -46,6 +47,7 @@ def __call__(self, iteration):
timer_shutdown: GenericTimerClass
timer_stop_player: GenericTimerClass
timer_fade_volume: GenericMultiTimerClass
timer_idle_shutdown: IdleShutdownTimer


@plugin.finalize
Expand Down Expand Up @@ -77,6 +79,15 @@ 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=None)
if timeout is None:
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...
Expand All @@ -101,4 +112,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
125 changes: 125 additions & 0 deletions src/jukebox/components/timers/idle_shutdown_timer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# 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
"""
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

0 comments on commit 7cc4f00

Please sign in to comment.