Compare commits

...

6 Commits

Author SHA1 Message Date
Amir Alexander Abdelbaki bdecba0cf3 feat(hyprlua): float xdg portal dialogs above their parent
Portal-backed file/app choosers (xdg-desktop-portal-gtk) — e.g. Steam's
"select library folder" picker, plus Chromium/Electron/Flatpak dialogs —
run in a separate process and open as parentless top-levels with no
xdg-dialog or X11-dialog hint. Hyprland can't tell they're dialogs and
tiles them, unlike in-process xdg dialogs which it auto-floats. Match the
portal backend's class and float + centre it so it lands above its parent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 04:28:42 +02:00
Amir Alexander Abdelbaki 05693a71b7 chore(presence-detect): remove now-unused v4l2loopback provisioning
presence-detect.sh no longer mirrors the camera through a v4l2loopback device,
so the module setup is dead weight. Remove it:

- delete etc-v4l2loopback/ (modprobe.d + modules-load.d configs)
- drop the deploy blocks and linux-headers/v4l2loopback-dkms/v4l2loopback-utils
  package installs from the niri and hyprlua DE installers
- remove _ensure_v4l2loopback() and its call from sysupdate.sh
- reword enroll-biometrics.sh's loopback filter comment (kept as a harmless
  defensive exclusion) so it no longer points at the deleted config

Live system cleanup (module unload + /etc configs) is separate and needs root.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 04:11:16 +02:00
Amir Alexander Abdelbaki c2fa8d3e75 feat(presence-detect): back off while another app holds the camera
Before each tick, check whether another process already has the real camera
open (fuser, which sees same-user holders without root — every camera app runs
as us). If so, skip the read entirely for that tick: attempting would be
pointless (we'd get EBUSY) and a simultaneous open could make the other app
fail. A camera in use is proof the user is present, so mark presence and wait
it out, touching the camera zero times for the whole duration another app holds
it — instead of poking it every tick and racing the app for access.

V4L2 has no way to be notified that another app wants the device, so this
can't preempt an in-progress read; it just guarantees we never contend while
another app is already streaming. Falls through to a normal read (and the
Python helper's exit-3 busy path) if fuser isn't installed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 04:11:16 +02:00
Amir Alexander Abdelbaki 83687764d8 feat(presence-detect): skip the camera entirely during a manual caffeine session
When idle is inhibited manually (a caffeine toggle, not this daemon), the lock
is held regardless of presence, so polling the camera is pointless. Guard the
loop: if the shared idle lock is alive but we don't own it (same test as
caffeine-manual-status.sh), skip the read and leave the camera free for other
apps for the whole manual session. PRESENCE_FLAG is cleared while skipping,
since we're no longer watching — so the widget's presence input reads false
during a manual session instead of showing a stale "camera sees you".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 04:01:16 +02:00
Amir Alexander Abdelbaki bc544898c1 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>
2026-07-11 03:51:44 +02:00
Amir Alexander Abdelbaki 1fa5ebee70 feat(presence-detect): 4-state caffeine widget + steadier, faster detection
Rework the Eww caffeine widget into two independent inputs — manual inhibit
and presence detected — yielding four states across all three bars:
  neither            → 󰒲 sleep,  purple
  manual only        →  coffee, purple
  presence only      → 󰈈 eye,    pink
  manual + presence  →  coffee, pink
Colour tracks presence; the coffee cup shows only while manually inhibiting.

To make the two inputs independent, the daemon now writes a standalone
/tmp/presence-detected flag each tick (decoupled from lock ownership, so it's
true even during a manual session). presence-status.sh reports that flag
(presence input); new caffeine-manual-status.sh reports lock-held-and-not-
presence-owned (manual input).

Make detection less finnicky: space the detector's frame grabs (0.06s) so slow
movement registers and loopback reads never diff a stale duplicate frame, and
lower the thresholds (delta 25→18, area 2%→0.8%). Add a 90s grace window so
brief stillness never flickers presence off; any motion resets the clock.

Add a third poll tier — INTERVAL_FAST=2s when CPU-or-RAM usage is below 30%
(LOAD_LIGHT) — alongside the existing 20s normal and 120s busy tiers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 03:07:22 +02:00
14 changed files with 244 additions and 230 deletions

View File

@ -115,18 +115,29 @@
(defpoll disks :interval "600s"
"~/Dotfiles/desktopenvs/hyprland/scripts/dysk-phydisks.sh")
(defpoll caffeine-active :interval "2s"
"~/Dotfiles/desktopenvs/hyprlua/scripts/caffeine-status.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")
; 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.
; 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.
(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 {presence-active == "true" ? "Caffeine: ON (presence)"
: caffeine-active == "true" ? "Caffeine: ON" : "Caffeine: OFF"}
{caffeine-active == "true" ? "☕" : "󰅺"}))
: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" ? "󰈈" : "󰒲"}))

View File

@ -161,18 +161,29 @@
(defpoll calender :interval "600s"
"~/Dotfiles/desktopenvs/hyprland/scripts/calender-fix.sh")
(defpoll caffeine-active :interval "2s"
"~/Dotfiles/desktopenvs/hyprlua/scripts/caffeine-status.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")
; 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.
; 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.
(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 {presence-active == "true" ? "Caffeine: ON (presence)"
: caffeine-active == "true" ? "Caffeine: ON" : "Caffeine: OFF"}
{caffeine-active == "true" ? "☕" : "󰅺"}))
: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" ? "󰈈" : "󰒲"}))

View File

@ -201,25 +201,28 @@
(defpoll disks :interval "600s"
"~/Dotfiles/desktopenvs/hyprland/scripts/dysk-phydisks.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")
; 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")
; 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.
; 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.
(defpoll presence-active :interval "2s"
"~/Dotfiles/desktopenvs/hyprlua/scripts/presence-status.sh")
; 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.
; 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 {presence-active == "true" ? "Caffeine: ON (presence)"
: caffeine-active == "true" ? "Caffeine: ON" : "Caffeine: OFF"}
{caffeine-active == "true" ? "☕" : "󰅺"}))
: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" ? "󰈈" : "󰒲"}))

View File

@ -89,6 +89,21 @@ 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

@ -0,0 +1,18 @@
#!/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,10 +82,9 @@ 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.
# 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.
is_loopback_device() {
local card
card=$(v4l2-ctl --device="$1" --info 2>/dev/null \

View File

@ -4,20 +4,21 @@
# 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.
# 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.
#
# Camera selection: set PRESENCE_DETECT_CAMERA env var or write
# CAMERA=<id> to ~/.config/presence-detect.conf
#
# 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.
# 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)
# Resolve the script's real directory so the Python helper path stays valid
# even when invoked via a symlink or from a different cwd.
@ -29,17 +30,27 @@ 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"
# 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_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
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)"
# 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
# Resolve camera ID: env var takes highest priority, then config file, then default 0.
_camera_id() {
if [[ -n "$PRESENCE_DETECT_CAMERA" ]]; then
@ -52,63 +63,38 @@ _camera_id() {
fi
}
# 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
# 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
load1="$(awk '{print $1}' /proc/loadavg)"
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
cpu_frac="$(awk -v l="$load1" -v n="$NPROC" 'BEGIN{print l/n}')"
mem_frac="$(free | awk '/^Mem:/ {print $3/$2}')"
mem_busy="$(awk -v m="$mem_frac" -v t="$LOAD_THRESHOLD" 'BEGIN{print (m >= t) ? 1 : 0}')"
[[ "$mem_busy" == "1" ]]
awk -v c="$cpu_frac" -v m="$mem_frac" 'BEGIN{print (c>m)?c:m}'
}
# 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() {
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
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 }'
}
# Returns true if the inhibitor sentinel process is still alive.
@ -144,26 +130,62 @@ _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 stop mirroring the camera.
# lock (only if we're the one holding it) and clear the presence flag —
# with the daemon gone, nothing is watching.
_stop_inhibit
_stop_feeder
rm -f "$PRESENCE_FLAG"
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
# 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)"
# 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
# Run the OpenCV motion detector; stderr suppressed to keep the journal clean.
python3 "$PYTHON_DETECT" "$DEVICE" 2>/dev/null
rc=$?
case $rc in
0) _start_inhibit ;;
1) _stop_inhibit ;;
# rc=2 = camera busy/unavailable — silently skip, state unchanged
# 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
esac
sleep "$(_next_interval)"
done

View File

@ -1,20 +1,18 @@
#!/bin/bash
# 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.
# 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.
#
# 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.
# 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.
#
# 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"
# Prints "true" when presence is detected, else "false".
PRESENCE_FLAG="/tmp/presence-detected"
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null && [[ -f "$OWNED_FLAG" ]]; then
if [[ -f "$PRESENCE_FLAG" ]]; then
echo "true"
else
echo "false"

View File

@ -1,32 +1,61 @@
#!/usr/bin/env python3
"""
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]
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 = 6
FRAMES_TO_CHECK = 8
DIFFS_NEEDED = 2 # require motion in at least N consecutive-frame diffs
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
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
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:
cap = cv2.VideoCapture(camera_id)
if not cap.isOpened():
# 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
motion_diffs = 0
frames_read = 0
prev_gray = None
try:
for _ in range(FRAMES_TO_CHECK):
for i in range(FRAMES_TO_CHECK):
if i > 0:
time.sleep(INTER_FRAME_DELAY)
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)
@ -40,6 +69,13 @@ 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

@ -1,11 +0,0 @@
# 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

@ -1 +0,0 @@
v4l2loopback

View File

@ -13,8 +13,7 @@
# - 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,
# plus v4l2loopback-dkms so it mirrors the camera instead of holding it
# - Adds python-opencv + v4l-utils for the webcam presence-detection daemon
# - 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)
@ -159,27 +158,9 @@ 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,21 +28,10 @@ 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,62 +760,6 @@ _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"
@ -827,7 +771,6 @@ sync_configs() {
_ensure_plymouth_regreet
_ensure_astal_menu_deps
_ensure_hyprshutdown
_ensure_v4l2loopback
# ── Preferred: use the installed update-configs.sh ───────────────────────
local cfg_script