diff --git a/desktopenvs/hyprlua/eww-nobattery/eww.yuck b/desktopenvs/hyprlua/eww-nobattery/eww.yuck index 302118f..a4dd111 100644 --- a/desktopenvs/hyprlua/eww-nobattery/eww.yuck +++ b/desktopenvs/hyprlua/eww-nobattery/eww.yuck @@ -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" ? "󰈈" : "󰒲"})) diff --git a/desktopenvs/hyprlua/eww-touch/eww.yuck b/desktopenvs/hyprlua/eww-touch/eww.yuck index 6c2415d..792f99e 100644 --- a/desktopenvs/hyprlua/eww-touch/eww.yuck +++ b/desktopenvs/hyprlua/eww-touch/eww.yuck @@ -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" ? "󰈈" : "󰒲"})) diff --git a/desktopenvs/hyprlua/eww/eww.yuck b/desktopenvs/hyprlua/eww/eww.yuck index 260bb40..00323d7 100644 --- a/desktopenvs/hyprlua/eww/eww.yuck +++ b/desktopenvs/hyprlua/eww/eww.yuck @@ -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" ? "󰈈" : "󰒲"})) diff --git a/desktopenvs/hyprlua/scripts/caffeine-manual-status.sh b/desktopenvs/hyprlua/scripts/caffeine-manual-status.sh new file mode 100755 index 0000000..f899b45 --- /dev/null +++ b/desktopenvs/hyprlua/scripts/caffeine-manual-status.sh @@ -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 diff --git a/desktopenvs/hyprlua/scripts/presence-detect.sh b/desktopenvs/hyprlua/scripts/presence-detect.sh index 0c424d0..35d94e4 100755 --- a/desktopenvs/hyprlua/scripts/presence-detect.sh +++ b/desktopenvs/hyprlua/scripts/presence-detect.sh @@ -29,17 +29,30 @@ 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 on the last tick), +# INDEPENDENT of who holds the idle lock. Unlike OWNED_FLAG this is set even when +# a manual caffeine session already owns the lock (so the daemon never starts its +# own inhibitor). presence-status.sh reads this so the Eww widget can show the +# presence input as a distinct signal from a manual caffeine toggle. +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,23 +65,27 @@ _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 +# 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 + 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 loopback feeder (ffmpeg mirroring real cam -> loopback) @@ -145,13 +162,20 @@ _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. + # Also 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). @@ -160,9 +184,19 @@ while true; do # Run the OpenCV motion detector; stderr suppressed to keep the journal clean. python3 "$PYTHON_DETECT" "$DEVICE" 2>/dev/null rc=$? + now="$(date +%s)" case $rc in - 0) _start_inhibit ;; - 1) _stop_inhibit ;; + # Motion: refresh the grace clock and mark presence. PRESENCE_FLAG is + # updated *independently* of the inhibit lock — it reflects "camera sees + # you" even during a manual caffeine session (where _start_inhibit is a + # no-op because the lock is already held). + 0) 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 busy/unavailable — silently skip, state unchanged esac sleep "$(_next_interval)" diff --git a/desktopenvs/hyprlua/scripts/presence-status.sh b/desktopenvs/hyprlua/scripts/presence-status.sh index 0c60489..f2115d2 100755 --- a/desktopenvs/hyprlua/scripts/presence-status.sh +++ b/desktopenvs/hyprlua/scripts/presence-status.sh @@ -1,20 +1,17 @@ #!/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 "motion" tick and removes it +# on every "no motion" tick, so this is a clean, standalone signal — true even +# during a manual caffeine session (where the daemon never starts its own +# inhibitor). 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" diff --git a/desktopenvs/hyprlua/scripts/python/presence_detect.py b/desktopenvs/hyprlua/scripts/python/presence_detect.py index 6ab16c9..589a403 100755 --- a/desktopenvs/hyprlua/scripts/python/presence_detect.py +++ b/desktopenvs/hyprlua/scripts/python/presence_detect.py @@ -5,14 +5,22 @@ Exit codes: 0 = motion detected, 1 = no motion, 2 = camera error (busy/unavailab Usage: presence_detect.py [camera_id] """ 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 (a) slow, small movements barely +# differ between adjacent frames and (b) reading off the ffmpeg v4l2loopback +# mirror can hand back the SAME buffered frame twice → a zero delta even while +# you're moving. 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: @@ -23,7 +31,9 @@ def detect(camera_id: int) -> int: motion_diffs = 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