diff --git a/desktopenvs/hyprlua/scripts/enroll-biometrics.sh b/desktopenvs/hyprlua/scripts/enroll-biometrics.sh index 8883457..0e72859 100755 --- a/desktopenvs/hyprlua/scripts/enroll-biometrics.sh +++ b/desktopenvs/hyprlua/scripts/enroll-biometrics.sh @@ -82,10 +82,22 @@ is_capture_device() { | 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() { for dev in /dev/video*; do [[ -c "$dev" ]] || continue is_capture_device "$dev" || continue + is_loopback_device "$dev" && continue local id="${dev#/dev/video}" local name name=$(v4l2-ctl --device="$dev" --info 2>/dev/null \ diff --git a/desktopenvs/hyprlua/scripts/presence-detect.sh b/desktopenvs/hyprlua/scripts/presence-detect.sh index 8cd8e33..0c424d0 100755 --- a/desktopenvs/hyprlua/scripts/presence-detect.sh +++ b/desktopenvs/hyprlua/scripts/presence-detect.sh @@ -4,6 +4,14 @@ # 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. +# # Camera selection: set PRESENCE_DETECT_CAMERA env var or write # CAMERA= to ~/.config/presence-detect.conf # @@ -23,6 +31,10 @@ PID_FILE="/tmp/caffeine-inhibit.pid" OWNED_FLAG="/tmp/presence-inhibit-owned" 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_BUSY=120 # seconds between checks when CPU or RAM is >= LOAD_THRESHOLD LOAD_THRESHOLD=0.70 @@ -59,6 +71,46 @@ _next_interval() { 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. _inhibit_running() { [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null @@ -91,18 +143,22 @@ _stop_inhibit() { } _cleanup() { - # On daemon stop (systemd unit stop, user logout, etc.), release the lock - # cleanly, but only if we're the one holding it. + # 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. _stop_inhibit + _stop_feeder exit 0 } # Intercept termination signals to ensure the inhibitor PID is never orphaned. trap _cleanup SIGTERM SIGINT SIGHUP 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. - python3 "$PYTHON_DETECT" "$CAMERA" 2>/dev/null + python3 "$PYTHON_DETECT" "$DEVICE" 2>/dev/null rc=$? case $rc in 0) _start_inhibit ;; diff --git a/etc-v4l2loopback/modprobe.d/v4l2loopback.conf b/etc-v4l2loopback/modprobe.d/v4l2loopback.conf new file mode 100644 index 0000000..946b607 --- /dev/null +++ b/etc-v4l2loopback/modprobe.d/v4l2loopback.conf @@ -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 diff --git a/etc-v4l2loopback/modules-load.d/v4l2loopback.conf b/etc-v4l2loopback/modules-load.d/v4l2loopback.conf new file mode 100644 index 0000000..d394e47 --- /dev/null +++ b/etc-v4l2loopback/modules-load.d/v4l2loopback.conf @@ -0,0 +1 @@ +v4l2loopback diff --git a/setup/modules/Desktop-Environments/hyprlua.sh b/setup/modules/Desktop-Environments/hyprlua.sh index d09f216..1d07f8a 100755 --- a/setup/modules/Desktop-Environments/hyprlua.sh +++ b/setup/modules/Desktop-Environments/hyprlua.sh @@ -13,7 +13,8 @@ # - Config source paths point to desktopenvs/hyprlua/ # - Device-specific overrides live in ~/.config/hypr/usr/ (Lua modules # 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 # - gsettings sets prefer-dark globally (relevant for GTK apps under Hyprland) # - 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 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[@]}" +# --------------------------------------------------------------------------- +# 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 # --------------------------------------------------------------------------- diff --git a/setup/modules/Desktop-Environments/niri.sh b/setup/modules/Desktop-Environments/niri.sh index face325..3fe244b 100755 --- a/setup/modules/Desktop-Environments/niri.sh +++ b/setup/modules/Desktop-Environments/niri.sh @@ -28,10 +28,21 @@ sudo pacman -Syu --noconfirm --needed \ pcmanfm-qt udisks2 ly kew \ pavucontrol playerctl wf-recorder sound-theme-freedesktop \ 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 # crate links against; without it `cargo build` below # 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 log "Enabling essential services..." enable_service NetworkManager.service diff --git a/sysupdate.sh b/sysupdate.sh index e1e73e9..c90e572 100755 --- a/sysupdate.sh +++ b/sysupdate.sh @@ -760,6 +760,62 @@ _ensure_hyprshutdown() { 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() { section "Config Sync" @@ -771,6 +827,7 @@ sync_configs() { _ensure_plymouth_regreet _ensure_astal_menu_deps _ensure_hyprshutdown + _ensure_v4l2loopback # ── Preferred: use the installed update-configs.sh ─────────────────────── local cfg_script