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
parent
a0221b42dc
commit
defa69faab
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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 ;;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
v4l2loopback
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
57
sysupdate.sh
57
sysupdate.sh
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue