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
parent
9d4cdc35bd
commit
f90dbdae8b
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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." \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue