diff --git a/config.yaml b/config.yaml index d67fa98..50b80a8 100644 --- a/config.yaml +++ b/config.yaml @@ -2,12 +2,20 @@ check-interval: 1 timers: gaming: apps: - - retroarch - minecraft + - xmcl + - mcpelauncher-ui # how many minutes of usage - time-limit: 40 + time-limit: 5 # within how many hours of interval - limit-interval: 12 + limit-interval: 4 + # warn n minutes before reaching the limit + warn-threshold: 4 + # commands run on GNOME to pre-warn/final-warn + warn-command: > + runuser -l randogoth -c 'DISPLAY=:0 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus notify-send "App Timer" "Gaming time is almost up ({time_left_int} min left)" -u critical && canberra-gtk-play -i complete' + final-warn-command: > + runuser -l randogoth -c 'DISPLAY=:0 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus notify-send "App Timer" "Final minute before shutdown ({time_left_seconds}s)" -u critical && canberra-gtk-play -i alarm-clock-elapsed' websurf: apps: - chrome diff --git a/lib/__init__.py b/lib/__init__.py index bb236a0..bcb8c92 100644 --- a/lib/__init__.py +++ b/lib/__init__.py @@ -1,3 +1,4 @@ +import math import os, time import subprocess @@ -27,6 +28,7 @@ class Usage(object): super(Usage, self).__init__() self.timer = timer self.file = '%s/usage/%s' % (CONFIG_PATH, self.timer.name) + self.final_warning_sent = False @property def current(self): @@ -45,7 +47,9 @@ class Usage(object): def release(self): '''Remove the timer usage to restart the counter''' - os.remove(self.file) + if os.path.exists(self.file): + os.remove(self.file) + self.final_warning_sent = False def isOffLimit(self): '''Is the timer usage off its limits''' @@ -99,18 +103,89 @@ class Timer(object): apps = [app.strip() for app in item_apps] return apps + @property + def warnThreshold(self): + threshold = self.item.get('warn-threshold', None) + if threshold is None: + return None + return float(threshold) + + @property + def warnCommand(self): + return self.item.get('warn-command') + + @property + def finalWarnCommand(self): + return self.item.get('final-warn-command') + + def _command_context(self, time_left): + minutes = max(time_left, 0) + seconds = minutes * 60 + return { + 'timer_name': self.name, + 'time_left': minutes, + 'time_left_int': int(math.ceil(minutes)), + 'time_left_floor': int(math.floor(minutes)), + 'time_left_seconds': int(math.ceil(seconds)), + } + + def _prepareCommand(self, command, context): + if isinstance(command, (list, tuple)): + command = ' '.join([str(part) for part in command]) + if not command: + return None + context = context or {} + try: + return command.format(**context) + except (KeyError, IndexError, ValueError) as exc: + print('Timer %s warning command format error (%s), using raw command' % (self.name, exc)) + return command + + def _runCommand(self, command, label, context=None): + cmd = self._prepareCommand(command, context) + if not cmd: + return + print('Timer %s running %s command: %s' % (self.name, label, cmd)) + shell(cmd) + + def maybeWarn(self, check_interval): + if self.timeLimit < 0: + return + current_usage = self.usage.current + time_left = self.timeLimit - current_usage + if time_left <= 0: + if self.usage.final_warning_sent: + self.usage.final_warning_sent = False + return + context = self._command_context(time_left) + if (self.warnThreshold is not None and self.warnCommand and + time_left <= self.warnThreshold): + self._runCommand(self.warnCommand, 'warning', context) + final_command = self.finalWarnCommand + if not final_command: + return + interval = float(check_interval) + if time_left <= interval: + if not self.usage.final_warning_sent: + self._runCommand(final_command, 'final warning', context) + self.usage.final_warning_sent = True + else: + # reset flag if the next loop is no longer the final one + if self.usage.final_warning_sent: + self.usage.final_warning_sent = False + def isRunning(self): running = False for app in self.apps: - cmd = 'ps aux | grep -v grep | grep "%s"' % app + cmd = 'pgrep -f "%s"' % app res = shell(cmd) - if len(res): + if res.strip(): running = True return running def block(self): for app in self.apps: - cmd = 'killall "%s"' % app + cmd = 'pkill -f "%s"' % app shell(cmd) diff --git a/readme.md b/readme.md index 0b6e83b..fcc2935 100644 --- a/readme.md +++ b/readme.md @@ -23,6 +23,21 @@ timers: time-limit: 60 # within how many hours of interval limit-interval: 12 + # optional: warn N minutes before the limit is reached + warn-threshold: 5 + # run on every loop inside the warning window (anything executable) + warn-command: "notify-send 'App Timer' 'Only {time_left_int} minutes left'" + # run only on the final loop before apps are blocked + final-warn-command: "notify-send 'App Timer' 'Last minute before shutdown'" +``` + +`warn-threshold` is measured in minutes and triggers the `warn-command` every loop while there is still time remaining. `final-warn-command` fires once on the last loop before the timer exceeds its limit (based on `check-interval`). All commands are executed through the shell, so they can show desktop notifications, play sounds, etc. You can interpolate values in the command string using `{timer_name}`, `{time_left}`, `{time_left_int}`, `{time_left_floor}`, and `{time_left_seconds}` (any Python format specifiers are supported, e.g. `{time_left:.1f}`). + +If the systemd service runs as `root`, wrap desktop commands so they execute inside your session: + +```yaml +warn-command: > + runuser -l randogoth -c 'DISPLAY=:0 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus notify-send "App Timer" "{timer_name}: {time_left_int} minutes left"' ``` ## Installation diff --git a/timer.py b/timer.py index 28ba190..26047bd 100644 --- a/timer.py +++ b/timer.py @@ -12,6 +12,7 @@ def check_timers(config): if not timer.isRunning(): continue print('Timer %s is running' % timer.name) + timer.maybeWarn(config.checkInterval) # check for off limit apps if timer.usage.isOffLimit(): print('Timer %s is off limit' % timer.name)