170 lines
6.9 KiB
Bash
Executable File
170 lines
6.9 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.
|
|
#
|
|
# Never opens the real camera directly for detection. Instead it mirrors the
|
|
# real camera into a v4l2loopback virtual device (/dev/video42, set up by the
|
|
# v4l2loopback.conf files under etc-v4l2loopback/ + the DE installer) and reads
|
|
# motion off that mirror, so this daemon never holds — or contends for — the
|
|
# physical camera that other apps (video calls, howdy) also want. If the
|
|
# loopback device isn't present (module not installed/loaded yet), it falls
|
|
# back to reading the real camera directly, same as before.
|
|
#
|
|
# Camera selection: set PRESENCE_DETECT_CAMERA env var or write
|
|
# CAMERA=<id> to ~/.config/presence-detect.conf
|
|
#
|
|
# Exit codes from python helper: 0=motion, 1=no motion, 2=camera error (busy/unavailable)
|
|
# On camera error the daemon silently skips the tick and leaves the inhibit
|
|
# state exactly as it was on the previous run.
|
|
|
|
# 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"
|
|
PRESENCE_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/presence-detect.conf"
|
|
|
|
# Must match the video_nr in etc-v4l2loopback/modprobe.d/v4l2loopback.conf.
|
|
LOOPBACK_ID=42
|
|
FEEDER_PID_FILE="/tmp/presence-loopback-feeder.pid"
|
|
|
|
INTERVAL_IDLE=20 # seconds between checks under normal load
|
|
INTERVAL_BUSY=120 # seconds between checks when CPU or RAM is >= LOAD_THRESHOLD
|
|
LOAD_THRESHOLD=0.70
|
|
NPROC="$(nproc)"
|
|
|
|
# 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
|
|
}
|
|
|
|
# True if 1-minute load average (normalized by core count) or RAM usage is
|
|
# at/above LOAD_THRESHOLD. Load average is a cheap, sampling-free proxy for
|
|
# "CPU busy" — avoids adding an extra measurement delay to every tick.
|
|
_system_busy() {
|
|
local load1 cpu_busy mem_frac mem_busy
|
|
load1="$(awk '{print $1}' /proc/loadavg)"
|
|
cpu_busy="$(awk -v l="$load1" -v n="$NPROC" -v t="$LOAD_THRESHOLD" \
|
|
'BEGIN{print (l/n >= t) ? 1 : 0}')"
|
|
[[ "$cpu_busy" == "1" ]] && return 0
|
|
|
|
mem_frac="$(free | awk '/^Mem:/ {print $3/$2}')"
|
|
mem_busy="$(awk -v m="$mem_frac" -v t="$LOAD_THRESHOLD" 'BEGIN{print (m >= t) ? 1 : 0}')"
|
|
[[ "$mem_busy" == "1" ]]
|
|
}
|
|
|
|
_next_interval() {
|
|
if _system_busy; then echo "$INTERVAL_BUSY"; else echo "$INTERVAL_IDLE"; fi
|
|
}
|
|
|
|
# Returns true if the loopback feeder (ffmpeg mirroring real cam -> loopback)
|
|
# is still alive.
|
|
_feeder_running() {
|
|
[[ -f "$FEEDER_PID_FILE" ]] && kill -0 "$(cat "$FEEDER_PID_FILE")" 2>/dev/null
|
|
}
|
|
|
|
# Starts the ffmpeg mirror if the loopback device exists and nothing is
|
|
# feeding it yet. No-op (returns failure) if the loopback isn't set up —
|
|
# callers fall back to reading the real camera directly in that case.
|
|
_start_feeder() {
|
|
_feeder_running && return 0
|
|
[[ -e "/dev/video${LOOPBACK_ID}" ]] || return 1
|
|
|
|
local real_cam; real_cam="$(_camera_id)"
|
|
ffmpeg -hide_banner -loglevel error -f v4l2 -i "/dev/video${real_cam}" \
|
|
-f v4l2 "/dev/video${LOOPBACK_ID}" &
|
|
echo $! > "$FEEDER_PID_FILE"
|
|
# Give ffmpeg a moment to open the real device and start writing frames
|
|
# before the first detection read off the loopback.
|
|
sleep 1
|
|
_feeder_running || { rm -f "$FEEDER_PID_FILE"; return 1; }
|
|
logger -t presence-detect "Loopback feeder started (/dev/video${real_cam} -> /dev/video${LOOPBACK_ID})"
|
|
}
|
|
|
|
_stop_feeder() {
|
|
_feeder_running || return
|
|
kill "$(cat "$FEEDER_PID_FILE")" 2>/dev/null
|
|
rm -f "$FEEDER_PID_FILE"
|
|
}
|
|
|
|
# The device id handed to the Python detector: the loopback mirror whenever
|
|
# it's up, otherwise the real camera as a direct-access fallback.
|
|
_detect_device_id() {
|
|
if _feeder_running; then
|
|
echo "$LOOPBACK_ID"
|
|
else
|
|
_camera_id
|
|
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 stop mirroring the camera.
|
|
_stop_inhibit
|
|
_stop_feeder
|
|
exit 0
|
|
}
|
|
# Intercept termination signals to ensure the inhibitor PID is never orphaned.
|
|
trap _cleanup SIGTERM SIGINT SIGHUP
|
|
|
|
while true; do
|
|
# Self-healing: retry the feeder every tick if it isn't up (camera
|
|
# unplugged/replugged, ffmpeg crashed, module loaded after daemon start).
|
|
_feeder_running || _start_feeder
|
|
DEVICE="$(_detect_device_id)"
|
|
# Run the OpenCV motion detector; stderr suppressed to keep the journal clean.
|
|
python3 "$PYTHON_DETECT" "$DEVICE" 2>/dev/null
|
|
rc=$?
|
|
case $rc in
|
|
0) _start_inhibit ;;
|
|
1) _stop_inhibit ;;
|
|
# rc=2 = camera busy/unavailable — silently skip, state unchanged
|
|
esac
|
|
sleep "$(_next_interval)"
|
|
done
|