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

Open
wants to merge 4 commits into
base: future3/develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
30 changes: 29 additions & 1 deletion src/jukebox/components/timers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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...
Expand All @@ -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
131 changes: 131 additions & 0 deletions src/jukebox/components/timers/idle_shutdown_timer.py
Original file line number Diff line number Diff line change
@@ -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
hoffie marked this conversation as resolved.
Show resolved Hide resolved
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
Loading