#!/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= 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, or the camera # being in use by another app, on the last tick). presence-status.sh reads this # so the Eww widget can show presence as a distinct signal from the idle lock. # It reflects live camera state only while the daemon is actively watching: # during a MANUAL caffeine session the daemon stops reading the camera entirely # (to leave it free) and clears this flag, so presence reads false there. 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 } # True if any process OTHER than this daemon currently holds the real camera # open — a video call, howdy, etc. `fuser` lists same-user holders without root # (every camera app runs as us), and we only ever call this when we aren't # reading the camera ourselves, so any holder is another app. If fuser isn't # installed we return false: the tick then just tries the camera and the Python # helper's busy/in-use path (exit 3) still covers an already-held device. _camera_in_use() { command -v fuser >/dev/null 2>&1 || return 1 fuser -s "/dev/video${1}" 2>/dev/null } # 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 # When idle is inhibited MANUALLY (a caffeine toggle, not this daemon), the # lock is already held regardless of presence, so there's no reason to look # at the camera — skip the read entirely and leave the device free for other # apps. Detected the same way caffeine-manual-status.sh does it: the shared # lock is alive but we don't own it. We clear PRESENCE_FLAG because we're no # longer watching and must not report a stale "camera sees you". if _inhibit_running && [[ ! -f "$OWNED_FLAG" ]]; then rm -f "$PRESENCE_FLAG" sleep "$(_next_interval)" continue fi DEVICE="$(_camera_id)" now="$(date +%s)" # Back-off: if another app already holds the camera (a video call, howdy, # ...), don't even try to read it — attempting would be pointless (we'd just # get EBUSY) and a simultaneous open could make the app itself fail. A camera # in use is proof you're present, so mark presence and wait it out instead. if _camera_in_use "$DEVICE"; then LAST_MOTION="$now"; touch "$PRESENCE_FLAG"; _start_inhibit sleep "$(_next_interval)" continue fi # Run the OpenCV motion detector; stderr suppressed to keep the journal clean. python3 "$PYTHON_DETECT" "$DEVICE" 2>/dev/null rc=$? 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. 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