diff --git a/desktopenvs/hyprlua/scripts/presence-detect.sh b/desktopenvs/hyprlua/scripts/presence-detect.sh index 35d94e4..a88efb0 100755 --- a/desktopenvs/hyprlua/scripts/presence-detect.sh +++ b/desktopenvs/hyprlua/scripts/presence-detect.sh @@ -4,20 +4,21 @@ # 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. +# 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, 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. +# 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. @@ -37,10 +38,6 @@ OWNED_FLAG="/tmp/presence-inhibit-owned" 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 @@ -88,46 +85,6 @@ _next_interval() { '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 @@ -161,10 +118,9 @@ _stop_inhibit() { _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. + # lock (only if we're the one holding it) and clear the presence flag — + # with the daemon gone, nothing is watching. _stop_inhibit - _stop_feeder rm -f "$PRESENCE_FLAG" exit 0 } @@ -177,27 +133,27 @@ trap _cleanup SIGTERM SIGINT SIGHUP 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)" + DEVICE="$(_camera_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 ;; + # 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. + # PRESENCE_FLAG is updated *independently* of the inhibit lock — it + # reflects "user is present" even during a manual caffeine session (where + # _start_inhibit is a no-op because the lock is already held). + 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 busy/unavailable — silently skip, state unchanged + # rc=2 = camera unavailable (unplugged/gone) — silently skip, state unchanged esac sleep "$(_next_interval)" done diff --git a/desktopenvs/hyprlua/scripts/python/presence_detect.py b/desktopenvs/hyprlua/scripts/python/presence_detect.py index 589a403..a9f0214 100755 --- a/desktopenvs/hyprlua/scripts/python/presence_detect.py +++ b/desktopenvs/hyprlua/scripts/python/presence_detect.py @@ -1,9 +1,20 @@ #!/usr/bin/env python3 """ Webcam motion presence detector using frame differencing. -Exit codes: 0 = motion detected, 1 = no motion, 2 = camera error (busy/unavailable) +Exit codes: + 0 = motion detected (user present) + 1 = no motion (user likely away) + 2 = camera unavailable (device node missing / gone) -> caller skips tick + 3 = camera busy / in use (another app holds it) -> treat as present Usage: presence_detect.py [camera_id] + +Reads the physical camera directly, only for the ~0.5s it takes to grab a few +frames, then releases it — so a video call, howdy, etc. can use the camera the +rest of the time. When one of those apps IS holding the camera, we can't read +it; rather than mistake that for "no motion", we report it as busy/in-use (3), +which the daemon treats as presence: a camera in use means the user is here. """ +import os import sys import time import cv2 @@ -15,20 +26,27 @@ PIXEL_DELTA_THRESHOLD = 18 # per-pixel grayscale delta to count as "changed" MOTION_AREA_RATIO = 0.008 # fraction of pixels that must change to call it motion BLUR_KSIZE = (21, 21) # Gaussian blur kernel to suppress sensor noise # Spacing between the frames we diff. Without it the frames are grabbed -# back-to-back within a few milliseconds, so (a) slow, small movements barely -# differ between adjacent frames and (b) reading off the ffmpeg v4l2loopback -# mirror can hand back the SAME buffered frame twice → a zero delta even while -# you're moving. Spacing the grabs out gives slow motion a real temporal -# baseline and guarantees each compared frame is a fresh one. +# back-to-back within a few milliseconds, so slow, small movements barely differ +# between adjacent frames. Spacing the grabs out gives slow motion a real +# temporal baseline and guarantees each compared frame is a fresh one. INTER_FRAME_DELAY = 0.06 # seconds between grabs (~0.5s total observation window) def detect(camera_id: int) -> int: - cap = cv2.VideoCapture(camera_id) - if not cap.isOpened(): + # A missing device node means the camera is genuinely gone (unplugged, module + # not loaded) -> "unavailable" (2), and the daemon leaves presence untouched. + # A node that exists but won't open (below) or won't yield frames (further + # down) means something else is streaming it -> "busy/in use" (3) = present. + if not os.path.exists(f"/dev/video{camera_id}"): return 2 + cap = cv2.VideoCapture(camera_id) + if not cap.isOpened(): + # Node exists but we can't open it: another app owns the camera. + return 3 + motion_diffs = 0 + frames_read = 0 prev_gray = None try: for i in range(FRAMES_TO_CHECK): @@ -37,6 +55,7 @@ def detect(camera_id: int) -> int: ok, frame = cap.read() if not ok: continue + frames_read += 1 gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) gray = cv2.GaussianBlur(gray, BLUR_KSIZE, 0) @@ -50,6 +69,13 @@ def detect(camera_id: int) -> int: finally: cap.release() + # Opened but produced no frames at all: on V4L2 a second opener can succeed + # yet have its reads starved while another app streams the device. Treat that + # as busy/in-use (present) rather than "no motion", so an active video call + # keeps the session awake even though we never see a usable frame. + if frames_read == 0: + return 3 + return 0 if motion_diffs >= DIFFS_NEEDED else 1