Dotfiles/desktopenvs/hyprlua/scripts/presence-detect.sh

204 lines
9.0 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"
# 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"
# Must match the video_nr in etc-v4l2loopback/modprobe.d/v4l2loopback.conf.
LOOPBACK_ID=42
FEEDER_PID_FILE="/tmp/presence-loopback-feeder.pid"
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 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.
# Also clear the presence flag — with the daemon gone, nothing is watching.
_stop_inhibit
_stop_feeder
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
# 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=$?
now="$(date +%s)"
case $rc in
# Motion: refresh the grace clock and mark presence. PRESENCE_FLAG is
# updated *independently* of the inhibit lock — it reflects "camera sees
# you" even during a manual caffeine session (where _start_inhibit is a
# no-op because the lock is already held).
0) 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 busy/unavailable — silently skip, state unchanged
esac
sleep "$(_next_interval)"
done