Compare commits

..

No commits in common. "bdecba0cf38e5943c14f0c4de8942a7c95e47501" and "dc9cf6d830ff69e00bae179544a74979a43e04ac" have entirely different histories.

14 changed files with 230 additions and 244 deletions

View File

@ -115,29 +115,18 @@
(defpoll disks :interval "600s"
"~/Dotfiles/desktopenvs/hyprland/scripts/dysk-phydisks.sh")
; caffeine-manual poll — true only when a MANUAL caffeine toggle holds the shared
; idle lock (i.e. the lock is held but the presence daemon does not own it).
(defpoll caffeine-manual :interval "2s"
"~/Dotfiles/desktopenvs/hyprlua/scripts/caffeine-manual-status.sh")
(defpoll caffeine-active :interval "2s"
"~/Dotfiles/desktopenvs/hyprlua/scripts/caffeine-status.sh")
; presence-active poll — true when the webcam presence-detect daemon currently
; sees you, independent of the idle lock. A distinct input from a manual caffeine
; toggle, so the two can be true at the same time.
; presence-active poll — true only when the presence-detect daemon (not a manual
; caffeine toggle) is holding the idle lock, i.e. the camera currently sees you.
(defpoll presence-active :interval "2s"
"~/Dotfiles/desktopenvs/hyprlua/scripts/presence-status.sh")
; caffeine widget — two inputs (manual inhibit, presence detected) → four states:
; neither: 󰒲 sleep, purple
; manual only: ☕ coffee, purple
; presence only: 󰈈 eye, pink (.caffeine-presence)
; manual + presence: ☕ coffee, pink
; Colour tracks presence (pink accent); the glyph is coffee whenever manually
; inhibiting, else the eye when only presence holds it, else the sleep glyph.
(defwidget caffeine []
(button :class {presence-active == "true" ? "music caffeine-presence" : "music"}
:onclick "~/Dotfiles/desktopenvs/hyprlua/scripts/caffeine.sh"
:tooltip {caffeine-manual == "true"
? (presence-active == "true" ? "Caffeine: ON (manual + presence)" : "Caffeine: ON (manual)")
: (presence-active == "true" ? "Awake: presence detected" : "Idle: no inhibit")}
{caffeine-manual == "true" ? "☕" : presence-active == "true" ? "󰈈" : "󰒲"}))
:tooltip {presence-active == "true" ? "Caffeine: ON (presence)"
: caffeine-active == "true" ? "Caffeine: ON" : "Caffeine: OFF"}
{caffeine-active == "true" ? "☕" : "󰅺"}))

View File

@ -161,29 +161,18 @@
(defpoll calender :interval "600s"
"~/Dotfiles/desktopenvs/hyprland/scripts/calender-fix.sh")
; caffeine-manual poll — true only when a MANUAL caffeine toggle holds the shared
; idle lock (i.e. the lock is held but the presence daemon does not own it).
(defpoll caffeine-manual :interval "2s"
"~/Dotfiles/desktopenvs/hyprlua/scripts/caffeine-manual-status.sh")
(defpoll caffeine-active :interval "2s"
"~/Dotfiles/desktopenvs/hyprlua/scripts/caffeine-status.sh")
; presence-active poll — true when the webcam presence-detect daemon currently
; sees you, independent of the idle lock. A distinct input from a manual caffeine
; toggle, so the two can be true at the same time.
; presence-active poll — true only when the presence-detect daemon (not a manual
; caffeine toggle) is holding the idle lock, i.e. the camera currently sees you.
(defpoll presence-active :interval "2s"
"~/Dotfiles/desktopenvs/hyprlua/scripts/presence-status.sh")
; caffeine widget — two inputs (manual inhibit, presence detected) → four states:
; neither: 󰒲 sleep, purple
; manual only: ☕ coffee, purple
; presence only: 󰈈 eye, pink (.caffeine-presence)
; manual + presence: ☕ coffee, pink
; Colour tracks presence (pink accent); the glyph is coffee whenever manually
; inhibiting, else the eye when only presence holds it, else the sleep glyph.
(defwidget caffeine []
(button :class {presence-active == "true" ? "music caffeine-presence" : "music"}
:onclick "~/Dotfiles/desktopenvs/hyprlua/scripts/caffeine.sh"
:tooltip {caffeine-manual == "true"
? (presence-active == "true" ? "Caffeine: ON (manual + presence)" : "Caffeine: ON (manual)")
: (presence-active == "true" ? "Awake: presence detected" : "Idle: no inhibit")}
{caffeine-manual == "true" ? "☕" : presence-active == "true" ? "󰈈" : "󰒲"}))
:tooltip {presence-active == "true" ? "Caffeine: ON (presence)"
: caffeine-active == "true" ? "Caffeine: ON" : "Caffeine: OFF"}
{caffeine-active == "true" ? "☕" : "󰅺"}))

View File

@ -201,28 +201,25 @@
(defpoll disks :interval "600s"
"~/Dotfiles/desktopenvs/hyprland/scripts/dysk-phydisks.sh")
; caffeine-manual poll — true only when a MANUAL caffeine toggle holds the shared
; idle lock (i.e. the lock is held but the presence daemon does not own it).
(defpoll caffeine-manual :interval "2s"
"~/Dotfiles/desktopenvs/hyprlua/scripts/caffeine-manual-status.sh")
; caffeine-active poll — checks whether the caffeine systemd-inhibit lock is active.
; Returns "true" or "false" string every 2 seconds. Used to toggle the icon.
(defpoll caffeine-active :interval "2s"
"~/Dotfiles/desktopenvs/hyprlua/scripts/caffeine-status.sh")
; presence-active poll — true when the webcam presence-detect daemon currently
; sees you, independent of the idle lock. A distinct input from a manual caffeine
; toggle, so the two can be true at the same time.
; presence-active poll — true only when the presence-detect daemon (not a manual
; caffeine toggle) is holding the idle lock, i.e. the camera currently sees you.
; Used to tint the caffeine widget in the Active Hot Pink accent.
(defpoll presence-active :interval "2s"
"~/Dotfiles/desktopenvs/hyprlua/scripts/presence-status.sh")
; caffeine widget — two inputs (manual inhibit, presence detected) → four states:
; neither: 󰒲 sleep, purple
; manual only: ☕ coffee, purple
; presence only: 󰈈 eye, pink (.caffeine-presence)
; manual + presence: ☕ coffee, pink
; Colour tracks presence (pink accent); the glyph is coffee whenever manually
; inhibiting, else the eye when only presence holds it, else the sleep glyph.
; caffeine widget — a button that toggles the caffeine idle-inhibit lock.
; ☕ = caffeine is ON (screen won't sleep), 󰅺 = caffeine is OFF (normal idle).
; When presence detection is the reason the lock is held, the widget also turns
; the Active Hot Pink accent (.caffeine-presence) so auto-awake is distinct from
; a manual caffeine session. Tooltip shows the current state for clarity.
(defwidget caffeine []
(button :class {presence-active == "true" ? "music caffeine-presence" : "music"}
:onclick "~/Dotfiles/desktopenvs/hyprlua/scripts/caffeine.sh"
:tooltip {caffeine-manual == "true"
? (presence-active == "true" ? "Caffeine: ON (manual + presence)" : "Caffeine: ON (manual)")
: (presence-active == "true" ? "Awake: presence detected" : "Idle: no inhibit")}
{caffeine-manual == "true" ? "☕" : presence-active == "true" ? "󰈈" : "󰒲"}))
:tooltip {presence-active == "true" ? "Caffeine: ON (presence)"
: caffeine-active == "true" ? "Caffeine: ON" : "Caffeine: OFF"}
{caffeine-active == "true" ? "☕" : "󰅺"}))

View File

@ -89,21 +89,6 @@ hl.window_rule({
move = "(cursor_x-380) (cursor_y-230)",
})
-- xdg portal dialogs — float, centred on the monitor (i.e. above the parent).
-- The GTK portal backend (xdg-desktop-portal-gtk) serves native file/app/print
-- choosers for portal-driven apps: Steam ("select library folder"), Chromium/
-- Electron, Flatpak apps, etc. It runs in its *own* process, so the dialog opens
-- as a parentless top-level with no xdg-dialog/X11-dialog hint — Hyprland has no
-- signal that it's a dialog and would otherwise tile it (unlike in-process xdg
-- dialogs, which it auto-floats). Natural size is kept, so the centre offsets use
-- the real window_w/window_h (no `size` to skew them; see the filepicker note).
hl.window_rule({
name = "xdg-portal-dialog",
match = { class = "xdg-desktop-portal-gtk" },
float = true,
move = "(monitor_w/2 - window_w/2) (monitor_h/2 - window_h/2)",
})
-- spotify floating
hl.window_rule({
name = "spotify-float",

View File

@ -1,18 +0,0 @@
#!/bin/bash
# Reports the MANUAL-INHIBIT input for the Eww caffeine widget: whether a manual
# caffeine toggle currently holds the shared idle lock.
#
# The lock is shared with presence-detect.sh, so "PID alive" alone can't tell the
# two sources apart. OWNED_FLAG is present only while the daemon owns the lock, so
# "PID alive AND OWNED_FLAG absent" uniquely means a manual caffeine session holds
# it. (For the PRESENCE input — camera sees you — see presence-status.sh.)
#
# Prints "true" for a manual caffeine session, else "false".
PID_FILE="/tmp/caffeine-inhibit.pid"
OWNED_FLAG="/tmp/presence-inhibit-owned"
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null && [[ ! -f "$OWNED_FLAG" ]]; then
echo "true"
else
echo "false"
fi

View File

@ -82,9 +82,10 @@ is_capture_device() {
| grep -qE '^[[:space:]]*\[[0-9]+\]:'
}
# True for a v4l2loopback virtual device (card type "presence-loopback"). Kept
# as a defensive filter: presence-detect no longer uses a loopback, but should
# one ever be present it isn't a real camera, so it's excluded from the picker.
# 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 \

View File

@ -4,21 +4,20 @@
# 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.
#
# 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.
# 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=<id> to ~/.config/presence-detect.conf
#
# 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)
# 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.
# Resolve the script's real directory so the Python helper path stays valid
# even when invoked via a symlink or from a different cwd.
@ -30,26 +29,16 @@ PID_FILE="/tmp/caffeine-inhibit.pid"
# Marks that *this daemon* (not the manual caffeine toggle) currently holds
# the lock, so a "no motion" tick never releases a manually-started session.
OWNED_FLAG="/tmp/presence-inhibit-owned"
# Records whether the camera currently SEES the user (motion, or the camera
# being in use by another app, on the last tick). presence-status.sh reads this
# so the Eww widget can show presence as a distinct signal from the idle lock.
# It reflects live camera state only while the daemon is actively watching:
# during a MANUAL caffeine session the daemon stops reading the camera entirely
# (to leave it free) and clears this flag, so presence reads false there.
PRESENCE_FLAG="/tmp/presence-detected"
PRESENCE_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/presence-detect.conf"
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
LOAD_BUSY=0.70 # CPU-or-RAM fraction at/above which we back off to INTERVAL_BUSY
LOAD_LIGHT=0.30 # CPU-or-RAM fraction below which we speed up to INTERVAL_FAST
NPROC="$(nproc)"
# Must match the video_nr in etc-v4l2loopback/modprobe.d/v4l2loopback.conf.
LOOPBACK_ID=42
FEEDER_PID_FILE="/tmp/presence-loopback-feeder.pid"
# Grace window: keep reporting "present" for this long after the last detected
# motion, so brief stillness (reading, thinking, a slow moment) never drops the
# lock the instant one tick sees nothing. Any motion tick resets the clock.
GRACE_SECONDS=90
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
NPROC="$(nproc)"
# Resolve camera ID: env var takes highest priority, then config file, then default 0.
_camera_id() {
@ -63,38 +52,63 @@ _camera_id() {
fi
}
# True if any process OTHER than this daemon currently holds the real camera
# open — a video call, howdy, etc. `fuser` lists same-user holders without root
# (every camera app runs as us), and we only ever call this when we aren't
# reading the camera ourselves, so any holder is another app. If fuser isn't
# installed we return false: the tick then just tries the camera and the Python
# helper's busy/in-use path (exit 3) still covers an already-held device.
_camera_in_use() {
command -v fuser >/dev/null 2>&1 || return 1
fuser -s "/dev/video${1}" 2>/dev/null
}
# Prints current CPU-or-RAM usage as a fraction (0..1): the higher of the
# core-count-normalized 1-minute load average and the used-RAM fraction. Load
# average is a cheap, sampling-free proxy for "CPU busy" — no extra measurement
# delay per tick.
_resource_usage() {
local load1 cpu_frac mem_frac
# True if 1-minute load average (normalized by core count) or RAM usage is
# at/above LOAD_THRESHOLD. Load average is a cheap, sampling-free proxy for
# "CPU busy" — avoids adding an extra measurement delay to every tick.
_system_busy() {
local load1 cpu_busy mem_frac mem_busy
load1="$(awk '{print $1}' /proc/loadavg)"
cpu_frac="$(awk -v l="$load1" -v n="$NPROC" 'BEGIN{print l/n}')"
cpu_busy="$(awk -v l="$load1" -v n="$NPROC" -v t="$LOAD_THRESHOLD" \
'BEGIN{print (l/n >= t) ? 1 : 0}')"
[[ "$cpu_busy" == "1" ]] && return 0
mem_frac="$(free | awk '/^Mem:/ {print $3/$2}')"
awk -v c="$cpu_frac" -v m="$mem_frac" 'BEGIN{print (c>m)?c:m}'
mem_busy="$(awk -v m="$mem_frac" -v t="$LOAD_THRESHOLD" 'BEGIN{print (m >= t) ? 1 : 0}')"
[[ "$mem_busy" == "1" ]]
}
# Three-tier poll interval based on current resource usage:
# >= LOAD_BUSY -> INTERVAL_BUSY (heavy load: back off, stay out of the way)
# < LOAD_LIGHT -> INTERVAL_FAST (near-idle: poll fast, cheap and responsive)
# otherwise -> INTERVAL_IDLE (normal load)
_next_interval() {
local u; u="$(_resource_usage)"
awk -v u="$u" -v busy="$LOAD_BUSY" -v light="$LOAD_LIGHT" \
-v fb="$INTERVAL_BUSY" -v ff="$INTERVAL_FAST" -v fi="$INTERVAL_IDLE" \
'BEGIN{ if (u >= busy) print fb; else if (u < light) print ff; else print 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.
@ -130,62 +144,26 @@ _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 clear the presence flag —
# with the daemon gone, nothing is watching.
# lock (only if we're the one holding it) and stop mirroring the camera.
_stop_inhibit
rm -f "$PRESENCE_FLAG"
_stop_feeder
exit 0
}
# Intercept termination signals to ensure the inhibitor PID is never orphaned.
trap _cleanup SIGTERM SIGINT SIGHUP
# Epoch seconds of the last tick that saw motion; drives the GRACE_SECONDS
# window below. 0 = never seen yet, so a no-motion tick at startup releases
# immediately (nothing is held anyway).
LAST_MOTION=0
while true; do
# When idle is inhibited MANUALLY (a caffeine toggle, not this daemon), the
# lock is already held regardless of presence, so there's no reason to look
# at the camera — skip the read entirely and leave the device free for other
# apps. Detected the same way caffeine-manual-status.sh does it: the shared
# lock is alive but we don't own it. We clear PRESENCE_FLAG because we're no
# longer watching and must not report a stale "camera sees you".
if _inhibit_running && [[ ! -f "$OWNED_FLAG" ]]; then
rm -f "$PRESENCE_FLAG"
sleep "$(_next_interval)"
continue
fi
DEVICE="$(_camera_id)"
now="$(date +%s)"
# Back-off: if another app already holds the camera (a video call, howdy,
# ...), don't even try to read it — attempting would be pointless (we'd just
# get EBUSY) and a simultaneous open could make the app itself fail. A camera
# in use is proof you're present, so mark presence and wait it out instead.
if _camera_in_use "$DEVICE"; then
LAST_MOTION="$now"; touch "$PRESENCE_FLAG"; _start_inhibit
sleep "$(_next_interval)"
continue
fi
# 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" "$DEVICE" 2>/dev/null
rc=$?
case $rc in
# 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.
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 unavailable (unplugged/gone) — silently skip, state unchanged
0) _start_inhibit ;;
1) _stop_inhibit ;;
# rc=2 = camera busy/unavailable — silently skip, state unchanged
esac
sleep "$(_next_interval)"
done

View File

@ -1,18 +1,20 @@
#!/bin/bash
# Reports the PRESENCE input for the Eww caffeine widget: whether the webcam
# presence-detect daemon currently sees the user (motion on its last tick),
# INDEPENDENT of whether the idle lock is held or who holds it.
# Reports whether the *presence-detect daemon* is currently holding the idle
# inhibitor (i.e. the camera saw motion and is auto-keeping the screen awake),
# as opposed to a manual caffeine toggle.
#
# presence-detect.sh writes PRESENCE_FLAG on every "present" tick and removes it
# on every "away" tick, so this is a clean, standalone signal. Note: during a
# MANUAL caffeine session the daemon stops reading the camera (to leave it free)
# and clears the flag, so presence reads false there. For the MANUAL-inhibit
# input, see caffeine-manual-status.sh.
# The lock is shared with caffeine.sh, so PID_FILE alone can't tell the two
# sources apart. OWNED_FLAG is touched only by presence-detect.sh when it starts
# the inhibitor and cleared by both a manual caffeine toggle and the daemon's
# own release — so "PID alive AND OWNED_FLAG present" uniquely means the daemon
# owns the lock right now.
#
# Prints "true" when presence is detected, else "false".
PRESENCE_FLAG="/tmp/presence-detected"
# Prints "true" when presence-detect owns the lock, else "false". Used by the
# Eww caffeine widget to highlight itself in the Active Hot Pink accent.
PID_FILE="/tmp/caffeine-inhibit.pid"
OWNED_FLAG="/tmp/presence-inhibit-owned"
if [[ -f "$PRESENCE_FLAG" ]]; then
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null && [[ -f "$OWNED_FLAG" ]]; then
echo "true"
else
echo "false"

View File

@ -1,61 +1,32 @@
#!/usr/bin/env python3
"""
Webcam motion presence detector using frame differencing.
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
Exit codes: 0 = motion detected, 1 = no motion, 2 = camera error (busy/unavailable)
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
import numpy as np
FRAMES_TO_CHECK = 8
FRAMES_TO_CHECK = 6
DIFFS_NEEDED = 2 # require motion in at least N consecutive-frame diffs
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
PIXEL_DELTA_THRESHOLD = 25 # per-pixel grayscale delta to count as "changed"
MOTION_AREA_RATIO = 0.02 # 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 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:
# 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
return 2
motion_diffs = 0
frames_read = 0
prev_gray = None
try:
for i in range(FRAMES_TO_CHECK):
if i > 0:
time.sleep(INTER_FRAME_DELAY)
for _ in range(FRAMES_TO_CHECK):
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)
@ -69,13 +40,6 @@ 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

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/
# - 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
# ---------------------------------------------------------------------------

View File

@ -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

View File

@ -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