Compare commits
6 Commits
dc9cf6d830
...
bdecba0cf3
| Author | SHA1 | Date |
|---|---|---|
|
|
bdecba0cf3 | |
|
|
05693a71b7 | |
|
|
c2fa8d3e75 | |
|
|
83687764d8 | |
|
|
bc544898c1 | |
|
|
1fa5ebee70 |
|
|
@ -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" ? "" : ""}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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" ? "" : ""}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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" ? "" : ""}))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1 +0,0 @@
|
|||
v4l2loopback
|
||||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
57
sysupdate.sh
57
sysupdate.sh
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue