feat(presence-detect): mirror camera via v4l2loopback so the daemon never blocks it

presence-detect.sh now feeds the real webcam into a v4l2loopback virtual
device (/dev/video42) via a self-healing ffmpeg background process, and reads
motion off that mirror instead of opening the physical camera directly. This
means the daemon's 20s-interval checks never contend with other apps (video
calls, howdy) for exclusive camera access. Falls back to direct camera access
if the loopback isn't set up yet, so the feature degrades gracefully rather
than breaking.

New etc-v4l2loopback/ ships the modules-load.d/modprobe.d config (fixed
video_nr=42, exclusive_caps=1) following the repo's existing etc-greetd/
etc-lightdm/ convention. Deliberately no runtime sudo/modprobe calls inside
the daemon itself — the module loads declaratively at boot instead, matching
this repo's existing caution around unattended sudo.

hyprlua.sh and niri.sh installers now pull in linux-headers,
v4l2loopback-dkms, and v4l2loopback-utils and deploy the module config
immediately. sysupdate.sh gained _ensure_v4l2loopback(), wired into
sync_configs(), so existing machines pick up the new packages and /etc
config on their next --config or --both run without needing a fresh install.

enroll-biometrics.sh's camera picker now excludes the loopback device itself
(by card_label) so configuring the real camera can't accidentally target the
mirror it feeds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-07-10 16:39:11 +02:00
parent a0221b42dc
commit defa69faab
7 changed files with 172 additions and 5 deletions

View File

@ -82,10 +82,22 @@ is_capture_device() {
| grep -qE '^[[:space:]]*\[[0-9]+\]:' | grep -qE '^[[:space:]]*\[[0-9]+\]:'
} }
# True for the v4l2loopback mirror presence-detect.sh feeds (card_label set in
# etc-v4l2loopback/modprobe.d/v4l2loopback.conf). It's not a real camera —
# configuring howdy or presence detection to read *from* it would just point
# them at their own mirrored output, so it's excluded from the picker.
is_loopback_device() {
local card
card=$(v4l2-ctl --device="$1" --info 2>/dev/null \
| awk -F': ' '/Card type/{print $2}' | head -1)
[[ "$card" == "presence-loopback" ]]
}
list_cameras() { list_cameras() {
for dev in /dev/video*; do for dev in /dev/video*; do
[[ -c "$dev" ]] || continue [[ -c "$dev" ]] || continue
is_capture_device "$dev" || continue is_capture_device "$dev" || continue
is_loopback_device "$dev" && continue
local id="${dev#/dev/video}" local id="${dev#/dev/video}"
local name local name
name=$(v4l2-ctl --device="$dev" --info 2>/dev/null \ name=$(v4l2-ctl --device="$dev" --info 2>/dev/null \

View File

@ -4,6 +4,14 @@
# 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
# 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.
#
# 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
# #
@ -23,6 +31,10 @@ PID_FILE="/tmp/caffeine-inhibit.pid"
OWNED_FLAG="/tmp/presence-inhibit-owned" OWNED_FLAG="/tmp/presence-inhibit-owned"
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_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_THRESHOLD INTERVAL_BUSY=120 # seconds between checks when CPU or RAM is >= LOAD_THRESHOLD
LOAD_THRESHOLD=0.70 LOAD_THRESHOLD=0.70
@ -59,6 +71,46 @@ _next_interval() {
if _system_busy; then echo "$INTERVAL_BUSY"; else echo "$INTERVAL_IDLE"; fi if _system_busy; then echo "$INTERVAL_BUSY"; else echo "$INTERVAL_IDLE"; 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
@ -91,18 +143,22 @@ _stop_inhibit() {
} }
_cleanup() { _cleanup() {
# On daemon stop (systemd unit stop, user logout, etc.), release the lock # On daemon stop (systemd unit stop, user logout, etc.), release the idle
# cleanly, but only if we're the one holding it. # lock (only if we're the one holding it) and stop mirroring the camera.
_stop_inhibit _stop_inhibit
_stop_feeder
exit 0 exit 0
} }
# Intercept termination signals to ensure the inhibitor PID is never orphaned. # Intercept termination signals to ensure the inhibitor PID is never orphaned.
trap _cleanup SIGTERM SIGINT SIGHUP trap _cleanup SIGTERM SIGINT SIGHUP
while true; do while true; do
CAMERA="$(_camera_id)" # 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)"
# 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" "$CAMERA" 2>/dev/null python3 "$PYTHON_DETECT" "$DEVICE" 2>/dev/null
rc=$? rc=$?
case $rc in case $rc in
0) _start_inhibit ;; 0) _start_inhibit ;;

View File

@ -0,0 +1,11 @@
# presence-detect.sh mirrors the real webcam into this virtual device so its
# 20s-interval motion checks never contend with other apps (Zoom, howdy, ...)
# for exclusive access to the physical camera.
#
# video_nr is fixed so the device always lands at /dev/video42, regardless of
# how many physical webcam nodes (including UVC metadata nodes) get enumerated
# first at boot.
#
# exclusive_caps=1 makes the device report standard single-purpose webcam
# capabilities, which most capture apps (ffmpeg, OpenCV, browsers) expect.
options v4l2loopback video_nr=42 card_label="presence-loopback" exclusive_caps=1

View File

@ -0,0 +1 @@
v4l2loopback

View File

@ -13,7 +13,8 @@
# - Config source paths point to desktopenvs/hyprlua/ # - Config source paths point to desktopenvs/hyprlua/
# - Device-specific overrides live in ~/.config/hypr/usr/ (Lua modules # - Device-specific overrides live in ~/.config/hypr/usr/ (Lua modules
# loaded via require("usr.*")), not at ~/.config/ root # loaded via require("usr.*")), not at ~/.config/ root
# - Adds python-opencv + v4l-utils for the webcam presence-detection daemon # - Adds python-opencv + v4l-utils for the webcam presence-detection daemon,
# plus v4l2loopback-dkms so it mirrors the camera instead of holding it
# - Tablet mode also installs evdev-right-click-emulation from AUR # - Tablet mode also installs evdev-right-click-emulation from AUR
# - gsettings sets prefer-dark globally (relevant for GTK apps under Hyprland) # - gsettings sets prefer-dark globally (relevant for GTK apps under Hyprland)
# - qt6ct is NOT installed (hyprlua dropped it from the package list) # - qt6ct is NOT installed (hyprlua dropped it from the package list)
@ -158,9 +159,27 @@ HYPRLUA_PACKAGES=(
python-opencv # OpenCV Python bindings; webcam presence-detection daemon python-opencv # OpenCV Python bindings; webcam presence-detection daemon
v4l-utils # Video4Linux2 tools for enumerating and configuring webcams v4l-utils # Video4Linux2 tools for enumerating and configuring webcams
linux-headers # kernel headers needed to DKMS-build v4l2loopback below
v4l2loopback-dkms # virtual /dev/video device presence-detect.sh mirrors
v4l2loopback-utils # module keeps mirroring across kernel updates via DKMS
) )
sudo pacman -Syu --noconfirm --needed -- "${HYPRLUA_PACKAGES[@]}" sudo pacman -Syu --noconfirm --needed -- "${HYPRLUA_PACKAGES[@]}"
# ---------------------------------------------------------------------------
# 2b. v4l2loopback camera mirror for presence-detect.sh
# ---------------------------------------------------------------------------
# presence-detect.sh reads motion off this virtual device instead of the real
# webcam, so its 20s-interval checks never contend with other apps (video
# calls, howdy) for exclusive access to the physical camera. Config here is
# static (fixed video_nr=42) — see etc-v4l2loopback/modprobe.d/v4l2loopback.conf
# for why. Loaded immediately so it's usable without a reboot.
log "Deploying v4l2loopback camera-mirror config..."
sudo install -Dm644 ~/Dotfiles/etc-v4l2loopback/modules-load.d/v4l2loopback.conf \
/etc/modules-load.d/v4l2loopback.conf
sudo install -Dm644 ~/Dotfiles/etc-v4l2loopback/modprobe.d/v4l2loopback.conf \
/etc/modprobe.d/v4l2loopback.conf
sudo modprobe v4l2loopback || warn "v4l2loopback did not load — presence-detect.sh will fall back to direct camera access until this is resolved (reboot may be required after a fresh DKMS build)."
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 3. Enable essential services # 3. Enable essential services
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -28,10 +28,21 @@ sudo pacman -Syu --noconfirm --needed \
pcmanfm-qt udisks2 ly kew \ pcmanfm-qt udisks2 ly kew \
pavucontrol playerctl wf-recorder sound-theme-freedesktop \ pavucontrol playerctl wf-recorder sound-theme-freedesktop \
xinput jq python-opencv v4l-utils \ xinput jq python-opencv v4l-utils \
linux-headers v4l2loopback-dkms v4l2loopback-utils \
gtk-layer-shell # GTK3 wlr-layer-shell library EWW's gtk-layer-shell-sys gtk-layer-shell # GTK3 wlr-layer-shell library EWW's gtk-layer-shell-sys
# crate links against; without it `cargo build` below # crate links against; without it `cargo build` below
# fails at that build script with a pkg-config error. # fails at that build script with a pkg-config error.
# 2b. v4l2loopback camera mirror for presence-detect.sh — see hyprlua.sh for
# the full rationale (mirrors the real webcam into /dev/video42 so the daemon
# never contends with other apps for exclusive camera access).
log "Deploying v4l2loopback camera-mirror config..."
sudo install -Dm644 ~/Dotfiles/etc-v4l2loopback/modules-load.d/v4l2loopback.conf \
/etc/modules-load.d/v4l2loopback.conf
sudo install -Dm644 ~/Dotfiles/etc-v4l2loopback/modprobe.d/v4l2loopback.conf \
/etc/modprobe.d/v4l2loopback.conf
sudo modprobe v4l2loopback || warn "v4l2loopback did not load — presence-detect.sh will fall back to direct camera access until this is resolved (reboot may be required after a fresh DKMS build)."
# 3. Enable essential services # 3. Enable essential services
log "Enabling essential services..." log "Enabling essential services..."
enable_service NetworkManager.service enable_service NetworkManager.service

View File

@ -760,6 +760,62 @@ _ensure_hyprshutdown() {
fi fi
} }
# Ensure the v4l2loopback camera mirror presence-detect.sh reads motion off is
# installed, configured, and loaded. Not fatal to skip — presence-detect.sh
# falls back to opening the real camera directly when /dev/video42 is absent —
# but until this is set up the daemon is back to contending with other apps
# (video calls, howdy) for the physical camera, which is the whole point of
# the loopback mirror.
_ensure_v4l2loopback() {
local de_dir; de_dir=$(_de_source_dir)
[[ -n "${de_dir:-}" && -f "$de_dir/scripts/presence-detect.sh" ]] || return 0
local -a needed=(linux-headers v4l2loopback-dkms v4l2loopback-utils)
local -a missing=()
for pkg in "${needed[@]}"; do
pacman -Qq "$pkg" &>/dev/null || missing+=("$pkg")
done
if [[ ${#missing[@]} -eq 0 ]]; then
ok "v4l2loopback packages already installed"
else
warn "v4l2loopback camera mirror is missing packages: ${missing[*]}"
if ask "Install them now?"; then
if sudo pacman -S --noconfirm --needed "${missing[@]}"; then
ok "v4l2loopback packages installed"
else
warn "Failed to install some v4l2loopback packages"
fi
fi
fi
local src="$DOTFILES/etc-v4l2loopback"
if [[ ! -d "$src" ]]; then
warn "etc-v4l2loopback/ not found in dotfiles — skipping module config deploy"
return 0
fi
local changed=false f
for f in modules-load.d/v4l2loopback.conf modprobe.d/v4l2loopback.conf; do
if ! diff -q "$src/$f" "/etc/$f" &>/dev/null; then
sudo install -Dm644 "$src/$f" "/etc/$f" && changed=true
fi
done
if $changed; then
ok "v4l2loopback config deployed to /etc"
else
ok "v4l2loopback config already up to date"
fi
if lsmod | grep -q '^v4l2loopback'; then
ok "v4l2loopback module already loaded"
elif sudo modprobe v4l2loopback; then
ok "v4l2loopback module loaded"
else
warn "v4l2loopback failed to load — presence-detect.sh will fall back to direct camera access (a reboot may be required after a fresh DKMS build)"
fi
}
sync_configs() { sync_configs() {
section "Config Sync" section "Config Sync"
@ -771,6 +827,7 @@ sync_configs() {
_ensure_plymouth_regreet _ensure_plymouth_regreet
_ensure_astal_menu_deps _ensure_astal_menu_deps
_ensure_hyprshutdown _ensure_hyprshutdown
_ensure_v4l2loopback
# ── Preferred: use the installed update-configs.sh ─────────────────────── # ── Preferred: use the installed update-configs.sh ───────────────────────
local cfg_script local cfg_script