fix(presence-detect): read camera directly instead of v4l2loopback mirror
The loopback feeder (defa69f) held /dev/video0 open 24/7, so real apps —
video calls, howdy — could never open the camera, and the single loopback
device only ever served one reader at a time (a second consumer got
VIDIOC_REQBUFS -EBUSY). Net effect: the camera was permanently blocked and
nothing could access it.
Drop the ffmpeg mirror and read the physical camera directly, only for the
~0.5s per tick it takes to grab frames, then release it — so the camera is
free for other apps the rest of the time. When another app is already holding
the camera, the Python detector now distinguishes "busy/in use" (device node
present but unreadable → exit 3) from "unavailable" (node gone → exit 2), and
the daemon treats busy as presence: a camera in use is itself proof the user
is here, so a video call keeps the session awake.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
parent
1fa5ebee70
commit
bc544898c1
|
|
@ -4,20 +4,21 @@
|
||||||
# under heavy system load) and shares caffeine's systemd-inhibit idle lock
|
# 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.
|
# 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
|
# Reads the physical camera directly, but only briefly: each tick opens the
|
||||||
# real camera into a v4l2loopback virtual device (/dev/video42, set up by the
|
# device, grabs a handful of frames, and releases it, so the camera stays free
|
||||||
# v4l2loopback.conf files under etc-v4l2loopback/ + the DE installer) and reads
|
# for other apps (video calls, howdy) the rest of the time. If another app is
|
||||||
# motion off that mirror, so this daemon never holds — or contends for — the
|
# already holding the camera when a tick fires, the grab fails — and that
|
||||||
# physical camera that other apps (video calls, howdy) also want. If the
|
# "busy" is itself proof the user is present (someone's on a call), so it counts
|
||||||
# loopback device isn't present (module not installed/loaded yet), it falls
|
# as presence rather than an error. This replaced an earlier v4l2loopback-mirror
|
||||||
# back to reading the real camera directly, same as before.
|
# 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 selection: set PRESENCE_DETECT_CAMERA env var or write
|
||||||
# CAMERA=<id> to ~/.config/presence-detect.conf
|
# CAMERA=<id> to ~/.config/presence-detect.conf
|
||||||
#
|
#
|
||||||
# Exit codes from python helper: 0=motion, 1=no motion, 2=camera error (busy/unavailable)
|
# Exit codes from python helper:
|
||||||
# On camera error the daemon silently skips the tick and leaves the inhibit
|
# 0 = motion (present) 1 = no motion (away)
|
||||||
# state exactly as it was on the previous run.
|
# 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
|
# Resolve the script's real directory so the Python helper path stays valid
|
||||||
# even when invoked via a symlink or from a different cwd.
|
# 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_FLAG="/tmp/presence-detected"
|
||||||
PRESENCE_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/presence-detect.conf"
|
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_FAST=2 # near-idle system (< LOAD_LIGHT): poll fast for responsiveness
|
||||||
INTERVAL_IDLE=20 # seconds between checks under normal load
|
INTERVAL_IDLE=20 # seconds between checks under normal load
|
||||||
INTERVAL_BUSY=120 # seconds between checks when CPU or RAM is >= LOAD_BUSY
|
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 }'
|
'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.
|
# Returns true if the inhibitor sentinel process is still alive.
|
||||||
_inhibit_running() {
|
_inhibit_running() {
|
||||||
[[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null
|
[[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null
|
||||||
|
|
@ -161,10 +118,9 @@ _stop_inhibit() {
|
||||||
|
|
||||||
_cleanup() {
|
_cleanup() {
|
||||||
# On daemon stop (systemd unit stop, user logout, etc.), release the idle
|
# 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.
|
# lock (only if we're the one holding it) and clear the presence flag —
|
||||||
# Also clear the presence flag — with the daemon gone, nothing is watching.
|
# with the daemon gone, nothing is watching.
|
||||||
_stop_inhibit
|
_stop_inhibit
|
||||||
_stop_feeder
|
|
||||||
rm -f "$PRESENCE_FLAG"
|
rm -f "$PRESENCE_FLAG"
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
@ -177,27 +133,27 @@ trap _cleanup SIGTERM SIGINT SIGHUP
|
||||||
LAST_MOTION=0
|
LAST_MOTION=0
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
# Self-healing: retry the feeder every tick if it isn't up (camera
|
DEVICE="$(_camera_id)"
|
||||||
# unplugged/replugged, ffmpeg crashed, module loaded after daemon start).
|
|
||||||
_feeder_running || _start_feeder
|
|
||||||
DEVICE="$(_detect_device_id)"
|
|
||||||
# Run the OpenCV motion detector; stderr suppressed to keep the journal clean.
|
# Run the OpenCV motion detector; stderr suppressed to keep the journal clean.
|
||||||
python3 "$PYTHON_DETECT" "$DEVICE" 2>/dev/null
|
python3 "$PYTHON_DETECT" "$DEVICE" 2>/dev/null
|
||||||
rc=$?
|
rc=$?
|
||||||
now="$(date +%s)"
|
now="$(date +%s)"
|
||||||
case $rc in
|
case $rc in
|
||||||
# Motion: refresh the grace clock and mark presence. PRESENCE_FLAG is
|
# Motion (0) or camera busy/in use (3): user is present. rc=3 means
|
||||||
# updated *independently* of the inhibit lock — it reflects "camera sees
|
# another app (a video call, howdy) is holding the camera, which is
|
||||||
# you" even during a manual caffeine session (where _start_inhibit is a
|
# itself proof you're here, so it keeps the session awake even though we
|
||||||
# no-op because the lock is already held).
|
# can't read frames. Both refresh the grace clock and mark presence.
|
||||||
0) LAST_MOTION="$now"; touch "$PRESENCE_FLAG"; _start_inhibit ;;
|
# 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
|
# 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
|
# grace window. Within it, leave the flag/lock exactly as they were so a
|
||||||
# brief pause in movement doesn't flicker presence off.
|
# brief pause in movement doesn't flicker presence off.
|
||||||
1) if (( now - LAST_MOTION >= GRACE_SECONDS )); then
|
1) if (( now - LAST_MOTION >= GRACE_SECONDS )); then
|
||||||
rm -f "$PRESENCE_FLAG"; _stop_inhibit
|
rm -f "$PRESENCE_FLAG"; _stop_inhibit
|
||||||
fi ;;
|
fi ;;
|
||||||
# rc=2 = camera busy/unavailable — silently skip, state unchanged
|
# rc=2 = camera unavailable (unplugged/gone) — silently skip, state unchanged
|
||||||
esac
|
esac
|
||||||
sleep "$(_next_interval)"
|
sleep "$(_next_interval)"
|
||||||
done
|
done
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,20 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Webcam motion presence detector using frame differencing.
|
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]
|
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 sys
|
||||||
import time
|
import time
|
||||||
import cv2
|
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
|
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
|
BLUR_KSIZE = (21, 21) # Gaussian blur kernel to suppress sensor noise
|
||||||
# Spacing between the frames we diff. Without it the frames are grabbed
|
# 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
|
# back-to-back within a few milliseconds, so slow, small movements barely differ
|
||||||
# differ between adjacent frames and (b) reading off the ffmpeg v4l2loopback
|
# between adjacent frames. Spacing the grabs out gives slow motion a real
|
||||||
# mirror can hand back the SAME buffered frame twice → a zero delta even while
|
# temporal baseline and guarantees each compared frame is a fresh one.
|
||||||
# you're moving. 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)
|
INTER_FRAME_DELAY = 0.06 # seconds between grabs (~0.5s total observation window)
|
||||||
|
|
||||||
|
|
||||||
def detect(camera_id: int) -> int:
|
def detect(camera_id: int) -> int:
|
||||||
cap = cv2.VideoCapture(camera_id)
|
# A missing device node means the camera is genuinely gone (unplugged, module
|
||||||
if not cap.isOpened():
|
# 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
|
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
|
motion_diffs = 0
|
||||||
|
frames_read = 0
|
||||||
prev_gray = None
|
prev_gray = None
|
||||||
try:
|
try:
|
||||||
for i in range(FRAMES_TO_CHECK):
|
for i in range(FRAMES_TO_CHECK):
|
||||||
|
|
@ -37,6 +55,7 @@ def detect(camera_id: int) -> int:
|
||||||
ok, frame = cap.read()
|
ok, frame = cap.read()
|
||||||
if not ok:
|
if not ok:
|
||||||
continue
|
continue
|
||||||
|
frames_read += 1
|
||||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||||
gray = cv2.GaussianBlur(gray, BLUR_KSIZE, 0)
|
gray = cv2.GaussianBlur(gray, BLUR_KSIZE, 0)
|
||||||
|
|
||||||
|
|
@ -50,6 +69,13 @@ def detect(camera_id: int) -> int:
|
||||||
finally:
|
finally:
|
||||||
cap.release()
|
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
|
return 0 if motion_diffs >= DIFFS_NEEDED else 1
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue