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>
main
Amir Alexander Abdelbaki 2026-07-11 03:07:22 +02:00
parent dc9cf6d830
commit 1fa5ebee70
7 changed files with 146 additions and 62 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

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

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

View File

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

View File

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