From f90dbdae8bd49bb2218e08e46dc7e1b9971d0aac Mon Sep 17 00:00:00 2001 From: The_miro Date: Fri, 10 Jul 2026 16:23:49 +0200 Subject: [PATCH] feat(presence-detect): switch to motion detection, adaptive interval, shared caffeine lock Replace the Haar-cascade face detector with lightweight frame-differencing motion detection so the daemon can poll every 20s (backing off to 120s under heavy CPU/RAM load) without adding noticeable overhead. Presence detection now shares caffeine's systemd-inhibit lock instead of holding its own, so the caffeine status widget reflects either source. A new ownership flag ensures the daemon only ever releases a lock it acquired itself, so a manually-started caffeine session is never interrupted by the user stepping away. Also re-enables the daemon in hyprlua's autostart (was previously only wired up for niri). Co-Authored-By: Claude Sonnet 5 --- desktopenvs/hyprlua/hypr/hypridle.conf | 11 +-- desktopenvs/hyprlua/hypr/usr/autostart.lua | 2 +- desktopenvs/hyprlua/scripts/caffeine.sh | 7 +- .../hyprlua/scripts/enroll-biometrics.sh | 6 +- .../hyprlua/scripts/presence-detect.sh | 69 ++++++++++++++----- .../hyprlua/scripts/python/presence_detect.py | 32 +++++---- desktopenvs/niri/scripts/caffeine.sh | 7 +- 7 files changed, 94 insertions(+), 40 deletions(-) diff --git a/desktopenvs/hyprlua/hypr/hypridle.conf b/desktopenvs/hyprlua/hypr/hypridle.conf index 8e59f18..56dd19c 100644 --- a/desktopenvs/hyprlua/hypr/hypridle.conf +++ b/desktopenvs/hyprlua/hypr/hypridle.conf @@ -3,9 +3,9 @@ # # hypridle watches for user inactivity and triggers actions (lock, suspend) # after configurable timeouts. It integrates with the presence-detection daemon -# (presence-detect.sh) which resets the idle timer every ~2 minutes while the -# webcam detects a person, so these timeouts only fire when the user has truly -# stepped away from the machine. +# (presence-detect.sh) which resets the idle timer every 20s (backed off to 120s +# under heavy CPU/RAM load) while the webcam detects motion, so these timeouts +# only fire when the user has truly stepped away from the machine. # # Reference: https://wiki.hypr.land/Hypr-Ecosystem/hypridle/ # ============================================================================= @@ -34,8 +34,9 @@ general { ignore_dbus_inhibit = false # respect systemd-inhibit locks (presence-detect, caffeine) } -# Presence detection resets the idle timer every 2 minutes while you're visible, -# so these timeouts only run when you've actually stepped away. +# Presence detection resets the idle timer every 20s (up to 120s under load) +# while motion is detected, so these timeouts only run when you've actually +# stepped away. # First idle listener: lock the screen after 2.5 minutes of inactivity. # 150 seconds is long enough to avoid triggering during short pauses (reading, diff --git a/desktopenvs/hyprlua/hypr/usr/autostart.lua b/desktopenvs/hyprlua/hypr/usr/autostart.lua index 1da8048..bee738b 100644 --- a/desktopenvs/hyprlua/hypr/usr/autostart.lua +++ b/desktopenvs/hyprlua/hypr/usr/autostart.lua @@ -19,7 +19,7 @@ hl.on("hyprland.start", function() hl.exec_cmd("blueman-applet") hl.exec_cmd("blueman-tray") hl.exec_cmd("hypridle") - -- hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/presence-detect.sh") + hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/presence-detect.sh") hl.exec_cmd("chamel") hl.exec_cmd("ydotoold") hl.exec_cmd("[workspace special:magic silent] opendeck") diff --git a/desktopenvs/hyprlua/scripts/caffeine.sh b/desktopenvs/hyprlua/scripts/caffeine.sh index 8dc8951..03db103 100755 --- a/desktopenvs/hyprlua/scripts/caffeine.sh +++ b/desktopenvs/hyprlua/scripts/caffeine.sh @@ -1,14 +1,18 @@ #!/bin/bash # Toggle idle inhibit via systemd-inhibit (hypridle respects the logind idle hint). # The PID file tracks the background sleep process used to hold the inhibitor lock. +# This lock is shared with presence-detect.sh, which also inhibits idle while +# motion is detected — OWNED_FLAG marks lock ownership between the two, so clear +# it on every manual toggle: this action is always a manual takeover of the lock. PID_FILE="/tmp/caffeine-inhibit.pid" +OWNED_FLAG="/tmp/presence-inhibit-owned" # kill -0 sends no signal; it just checks whether the process is still alive. # If the PID file exists and the process is running, caffeine is active → turn it off. if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then # Killing the sleep process releases the systemd-inhibit lock automatically. kill "$(cat "$PID_FILE")" - rm -f "$PID_FILE" + rm -f "$PID_FILE" "$OWNED_FLAG" notify-send -t 2000 "Caffeine" "Idle inhibit OFF" else # --what=idle: block the logind idle-inhibit slot that hypridle watches. @@ -21,5 +25,6 @@ else sleep infinity & # $! is the PID of the most recently backgrounded process (sleep infinity above). echo $! > "$PID_FILE" + rm -f "$OWNED_FLAG" notify-send -t 2000 "Caffeine" "Idle inhibited" fi diff --git a/desktopenvs/hyprlua/scripts/enroll-biometrics.sh b/desktopenvs/hyprlua/scripts/enroll-biometrics.sh index 0c49454..8883457 100755 --- a/desktopenvs/hyprlua/scripts/enroll-biometrics.sh +++ b/desktopenvs/hyprlua/scripts/enroll-biometrics.sh @@ -212,15 +212,15 @@ presence_test_camera() { clear printf "\n\033[1;35m Testing presence detection on /dev/video%s...\033[0m\n" "$cam" printf "\033[35m ─────────────────────────────────────────\033[0m\n\n" - printf " Look at the camera and stay still.\n\n" + printf " Move around in front of the camera.\n\n" python3 "$PYTHON_DETECT" "$cam" 2>/dev/null local rc=$? case $rc in - 0) msg "Test Result" "Face detected!\n\nPresence detection is working correctly." 8 52 ;; + 0) msg "Test Result" "Motion detected!\n\nPresence detection is working correctly." 8 52 ;; 1) msg "Test Result" \ - "No face detected.\n\nMake sure you are in front of the camera\nand there is adequate lighting." \ + "No motion detected.\n\nMake sure you are in front of the camera,\nmoving slightly, with adequate lighting." \ 10 56 ;; 2) msg "Camera Error" \ "Could not open /dev/video${cam}.\n\nTry configuring a different camera first." \ diff --git a/desktopenvs/hyprlua/scripts/presence-detect.sh b/desktopenvs/hyprlua/scripts/presence-detect.sh index bbc2f86..8cd8e33 100755 --- a/desktopenvs/hyprlua/scripts/presence-detect.sh +++ b/desktopenvs/hyprlua/scripts/presence-detect.sh @@ -1,20 +1,32 @@ #!/bin/bash # Webcam presence detection daemon. -# Checks for a face every 2 minutes; holds a systemd-inhibit idle lock while -# the user is detected so hypridle never fires during an active session. +# 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. # # Camera selection: set PRESENCE_DETECT_CAMERA env var or write # CAMERA= to ~/.config/presence-detect.conf # -# Exit codes from python helper: 0=face, 1=no face, 2=camera error +# 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" -INHIBIT_PID_FILE="/tmp/presence-inhibit.pid" +# 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" -INTERVAL=120 # seconds between checks + +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() { @@ -28,34 +40,59 @@ _camera_id() { 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 inhibitor sentinel process is still alive. _inhibit_running() { - [[ -f "$INHIBIT_PID_FILE" ]] && kill -0 "$(cat "$INHIBIT_PID_FILE")" 2>/dev/null + [[ -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. + # 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 $! > "$INHIBIT_PID_FILE" + echo $! > "$PID_FILE" + touch "$OWNED_FLAG" # logger writes to the system journal — visible via `journalctl -t presence-detect`. - logger -t presence-detect "Presence detected — idle inhibited" + 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 "$INHIBIT_PID_FILE")" 2>/dev/null - rm -f "$INHIBIT_PID_FILE" - logger -t presence-detect "No presence — idle inhibit released" + 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 lock cleanly. + # On daemon stop (systemd unit stop, user logout, etc.), release the lock + # cleanly, but only if we're the one holding it. _stop_inhibit exit 0 } @@ -64,13 +101,13 @@ trap _cleanup SIGTERM SIGINT SIGHUP while true; do CAMERA="$(_camera_id)" - # Run the OpenCV haar-cascade detector; stderr suppressed to keep the journal clean. + # Run the OpenCV motion detector; stderr suppressed to keep the journal clean. python3 "$PYTHON_DETECT" "$CAMERA" 2>/dev/null rc=$? case $rc in 0) _start_inhibit ;; 1) _stop_inhibit ;; - # rc=2 = camera unavailable — leave current inhibit state unchanged + # rc=2 = camera busy/unavailable — silently skip, state unchanged esac - sleep "$INTERVAL" + sleep "$(_next_interval)" done diff --git a/desktopenvs/hyprlua/scripts/python/presence_detect.py b/desktopenvs/hyprlua/scripts/python/presence_detect.py index 9cb06d5..6ab16c9 100755 --- a/desktopenvs/hyprlua/scripts/python/presence_detect.py +++ b/desktopenvs/hyprlua/scripts/python/presence_detect.py @@ -1,16 +1,18 @@ #!/usr/bin/env python3 """ -Webcam face presence detector using OpenCV haar cascades. -Exit codes: 0 = face detected, 1 = no face, 2 = camera error +Webcam motion presence detector using frame differencing. +Exit codes: 0 = motion detected, 1 = no motion, 2 = camera error (busy/unavailable) Usage: presence_detect.py [camera_id] """ import sys import cv2 +import numpy as np FRAMES_TO_CHECK = 6 -FACES_NEEDED = 2 # require face in at least N frames to count as present -SCALE_FACTOR = 1.1 -MIN_NEIGHBORS = 4 +DIFFS_NEEDED = 2 # require motion in at least N consecutive-frame diffs +PIXEL_DELTA_THRESHOLD = 25 # per-pixel grayscale delta to count as "changed" +MOTION_AREA_RATIO = 0.02 # fraction of pixels that must change to call it motion +BLUR_KSIZE = (21, 21) # Gaussian blur kernel to suppress sensor noise def detect(camera_id: int) -> int: @@ -18,23 +20,27 @@ def detect(camera_id: int) -> int: if not cap.isOpened(): return 2 - cascade = cv2.CascadeClassifier( - cv2.data.haarcascades + "haarcascade_frontalface_default.xml" - ) - detected = 0 + motion_diffs = 0 + prev_gray = None try: for _ in range(FRAMES_TO_CHECK): ok, frame = cap.read() if not ok: continue gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) - faces = cascade.detectMultiScale(gray, SCALE_FACTOR, MIN_NEIGHBORS) - if len(faces) > 0: - detected += 1 + gray = cv2.GaussianBlur(gray, BLUR_KSIZE, 0) + + if prev_gray is not None: + delta = cv2.absdiff(prev_gray, gray) + changed = np.count_nonzero(delta > PIXEL_DELTA_THRESHOLD) + if changed / delta.size >= MOTION_AREA_RATIO: + motion_diffs += 1 + + prev_gray = gray finally: cap.release() - return 0 if detected >= FACES_NEEDED else 1 + return 0 if motion_diffs >= DIFFS_NEEDED else 1 if __name__ == "__main__": diff --git a/desktopenvs/niri/scripts/caffeine.sh b/desktopenvs/niri/scripts/caffeine.sh index db3b021..d4ee2b9 100755 --- a/desktopenvs/niri/scripts/caffeine.sh +++ b/desktopenvs/niri/scripts/caffeine.sh @@ -1,10 +1,14 @@ #!/bin/bash # Toggle idle inhibit via systemd-inhibit (swayidle respects the logind idle hint). +# This lock is shared with presence-detect.sh, which also inhibits idle while +# motion is detected — OWNED_FLAG marks lock ownership between the two, so clear +# it on every manual toggle: this action is always a manual takeover of the lock. PID_FILE="/tmp/caffeine-inhibit.pid" +OWNED_FLAG="/tmp/presence-inhibit-owned" if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then kill "$(cat "$PID_FILE")" - rm -f "$PID_FILE" + rm -f "$PID_FILE" "$OWNED_FLAG" notify-send -t 2000 "Caffeine" "Idle inhibit OFF" else systemd-inhibit --what=idle:sleep \ @@ -13,5 +17,6 @@ else --mode=block \ sleep infinity & echo $! > "$PID_FILE" + rm -f "$OWNED_FLAG" notify-send -t 2000 "Caffeine" "Idle inhibited" fi