160 lines
7.6 KiB
Bash
Executable File
160 lines
7.6 KiB
Bash
Executable File
#!/bin/bash
|
|
# Webcam presence detection daemon.
|
|
# Checks for motion at an adaptive interval (20s normally, backed off to 120s
|
|
# under heavy system load) and shares caffeine's systemd-inhibit idle lock
|
|
# while the user is detected, so hypridle never fires during an active session.
|
|
#
|
|
# Reads the physical camera directly, but only briefly: each tick opens the
|
|
# device, grabs a handful of frames, and releases it, so the camera stays free
|
|
# for other apps (video calls, howdy) the rest of the time. If another app is
|
|
# already holding the camera when a tick fires, the grab fails — and that
|
|
# "busy" is itself proof the user is present (someone's on a call), so it counts
|
|
# as presence rather than an error. This replaced an earlier v4l2loopback-mirror
|
|
# design that held the camera open 24/7 and only served one reader at a time.
|
|
#
|
|
# Camera selection: set PRESENCE_DETECT_CAMERA env var or write
|
|
# CAMERA=<id> to ~/.config/presence-detect.conf
|
|
#
|
|
# Exit codes from python helper:
|
|
# 0 = motion (present) 1 = no motion (away)
|
|
# 2 = camera unavailable -> skip the tick, leave inhibit state unchanged
|
|
# 3 = camera busy / in use -> treat as present (another app holds the camera)
|
|
|
|
# Resolve the script's real directory so the Python helper path stays valid
|
|
# even when invoked via a symlink or from a different cwd.
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
PYTHON_DETECT="$SCRIPT_DIR/python/presence_detect.py"
|
|
# Shared with caffeine.sh: both the manual toggle and this daemon drive the
|
|
# same systemd-inhibit lock, so caffeine-status.sh reflects either source.
|
|
PID_FILE="/tmp/caffeine-inhibit.pid"
|
|
# Marks that *this daemon* (not the manual caffeine toggle) currently holds
|
|
# the lock, so a "no motion" tick never releases a manually-started session.
|
|
OWNED_FLAG="/tmp/presence-inhibit-owned"
|
|
# Records whether the camera currently SEES the user (motion on the last tick),
|
|
# INDEPENDENT of who holds the idle lock. Unlike OWNED_FLAG this is set even when
|
|
# a manual caffeine session already owns the lock (so the daemon never starts its
|
|
# own inhibitor). presence-status.sh reads this so the Eww widget can show the
|
|
# presence input as a distinct signal from a manual caffeine toggle.
|
|
PRESENCE_FLAG="/tmp/presence-detected"
|
|
PRESENCE_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/presence-detect.conf"
|
|
|
|
INTERVAL_FAST=2 # near-idle system (< LOAD_LIGHT): poll fast for responsiveness
|
|
INTERVAL_IDLE=20 # seconds between checks under normal load
|
|
INTERVAL_BUSY=120 # seconds between checks when CPU or RAM is >= LOAD_BUSY
|
|
LOAD_BUSY=0.70 # CPU-or-RAM fraction at/above which we back off to INTERVAL_BUSY
|
|
LOAD_LIGHT=0.30 # CPU-or-RAM fraction below which we speed up to INTERVAL_FAST
|
|
NPROC="$(nproc)"
|
|
|
|
# Grace window: keep reporting "present" for this long after the last detected
|
|
# motion, so brief stillness (reading, thinking, a slow moment) never drops the
|
|
# lock the instant one tick sees nothing. Any motion tick resets the clock.
|
|
GRACE_SECONDS=90
|
|
|
|
# Resolve camera ID: env var takes highest priority, then config file, then default 0.
|
|
_camera_id() {
|
|
if [[ -n "$PRESENCE_DETECT_CAMERA" ]]; then
|
|
echo "$PRESENCE_DETECT_CAMERA"
|
|
elif [[ -f "$PRESENCE_CFG" ]]; then
|
|
# -oP 'CAMERA=\K[0-9]+': Perl-style look-behind strips "CAMERA=" prefix.
|
|
grep -oP 'CAMERA=\K[0-9]+' "$PRESENCE_CFG" 2>/dev/null || echo 0
|
|
else
|
|
echo 0
|
|
fi
|
|
}
|
|
|
|
# Prints current CPU-or-RAM usage as a fraction (0..1): the higher of the
|
|
# core-count-normalized 1-minute load average and the used-RAM fraction. Load
|
|
# average is a cheap, sampling-free proxy for "CPU busy" — no extra measurement
|
|
# delay per tick.
|
|
_resource_usage() {
|
|
local load1 cpu_frac mem_frac
|
|
load1="$(awk '{print $1}' /proc/loadavg)"
|
|
cpu_frac="$(awk -v l="$load1" -v n="$NPROC" 'BEGIN{print l/n}')"
|
|
mem_frac="$(free | awk '/^Mem:/ {print $3/$2}')"
|
|
awk -v c="$cpu_frac" -v m="$mem_frac" 'BEGIN{print (c>m)?c:m}'
|
|
}
|
|
|
|
# Three-tier poll interval based on current resource usage:
|
|
# >= LOAD_BUSY -> INTERVAL_BUSY (heavy load: back off, stay out of the way)
|
|
# < LOAD_LIGHT -> INTERVAL_FAST (near-idle: poll fast, cheap and responsive)
|
|
# otherwise -> INTERVAL_IDLE (normal load)
|
|
_next_interval() {
|
|
local u; u="$(_resource_usage)"
|
|
awk -v u="$u" -v busy="$LOAD_BUSY" -v light="$LOAD_LIGHT" \
|
|
-v fb="$INTERVAL_BUSY" -v ff="$INTERVAL_FAST" -v fi="$INTERVAL_IDLE" \
|
|
'BEGIN{ if (u >= busy) print fb; else if (u < light) print ff; else print fi }'
|
|
}
|
|
|
|
# Returns true if the inhibitor sentinel process is still alive.
|
|
_inhibit_running() {
|
|
[[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null
|
|
}
|
|
|
|
_start_inhibit() {
|
|
# Guard: don't start a second inhibitor if one is already active — whether
|
|
# it's ours from a previous tick or a manually-started caffeine session.
|
|
_inhibit_running && return
|
|
# --what=idle: target the logind idle-inhibit lock that hypridle polls.
|
|
# "sleep infinity" is the sentinel; its PID is saved so we can kill it later.
|
|
systemd-inhibit --what=idle --who="presence-detect" \
|
|
--why="User presence detected" --mode=block \
|
|
sleep infinity &
|
|
echo $! > "$PID_FILE"
|
|
touch "$OWNED_FLAG"
|
|
# logger writes to the system journal — visible via `journalctl -t presence-detect`.
|
|
logger -t presence-detect "Motion detected — idle inhibited"
|
|
}
|
|
|
|
_stop_inhibit() {
|
|
_inhibit_running || return
|
|
# Never release a lock we didn't start — that would be a manual caffeine
|
|
# session, which must persist regardless of presence.
|
|
[[ -f "$OWNED_FLAG" ]] || return
|
|
# Killing the sleep process releases the systemd-inhibit lock automatically.
|
|
kill "$(cat "$PID_FILE")" 2>/dev/null
|
|
rm -f "$PID_FILE" "$OWNED_FLAG"
|
|
logger -t presence-detect "No motion — idle inhibit released"
|
|
}
|
|
|
|
_cleanup() {
|
|
# On daemon stop (systemd unit stop, user logout, etc.), release the idle
|
|
# lock (only if we're the one holding it) and clear the presence flag —
|
|
# with the daemon gone, nothing is watching.
|
|
_stop_inhibit
|
|
rm -f "$PRESENCE_FLAG"
|
|
exit 0
|
|
}
|
|
# Intercept termination signals to ensure the inhibitor PID is never orphaned.
|
|
trap _cleanup SIGTERM SIGINT SIGHUP
|
|
|
|
# Epoch seconds of the last tick that saw motion; drives the GRACE_SECONDS
|
|
# window below. 0 = never seen yet, so a no-motion tick at startup releases
|
|
# immediately (nothing is held anyway).
|
|
LAST_MOTION=0
|
|
|
|
while true; do
|
|
DEVICE="$(_camera_id)"
|
|
# Run the OpenCV motion detector; stderr suppressed to keep the journal clean.
|
|
python3 "$PYTHON_DETECT" "$DEVICE" 2>/dev/null
|
|
rc=$?
|
|
now="$(date +%s)"
|
|
case $rc in
|
|
# Motion (0) or camera busy/in use (3): user is present. rc=3 means
|
|
# another app (a video call, howdy) is holding the camera, which is
|
|
# itself proof you're here, so it keeps the session awake even though we
|
|
# can't read frames. Both refresh the grace clock and mark presence.
|
|
# PRESENCE_FLAG is updated *independently* of the inhibit lock — it
|
|
# reflects "user is present" even during a manual caffeine session (where
|
|
# _start_inhibit is a no-op because the lock is already held).
|
|
0|3) LAST_MOTION="$now"; touch "$PRESENCE_FLAG"; _start_inhibit ;;
|
|
# No motion: only actually release once we've been still for the whole
|
|
# grace window. Within it, leave the flag/lock exactly as they were so a
|
|
# brief pause in movement doesn't flicker presence off.
|
|
1) if (( now - LAST_MOTION >= GRACE_SECONDS )); then
|
|
rm -f "$PRESENCE_FLAG"; _stop_inhibit
|
|
fi ;;
|
|
# rc=2 = camera unavailable (unplugged/gone) — silently skip, state unchanged
|
|
esac
|
|
sleep "$(_next_interval)"
|
|
done
|