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 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-07-10 16:23:49 +02:00
parent 9d4cdc35bd
commit f90dbdae8b
7 changed files with 94 additions and 40 deletions

View File

@ -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,

View File

@ -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")

View File

@ -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

View File

@ -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." \

View File

@ -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=<id> 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

View File

@ -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__":

View File

@ -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