can't be bothered writing a commit message

This commit is contained in:
Breadway 2026-08-03 09:36:54 +08:00
parent 0423d83311
commit 4648fefb8e
8 changed files with 349 additions and 63 deletions

View file

@ -1,10 +1,27 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Downloads the tier-1 presence face detector (UltraFace-RFB-320, ~1.2MB, MIT-licensed) # Downloads the tier-1 presence face detector (UltraFace-RFB-320, ~1.2MB, MIT-licensed)
# from the upstream Ultra-Light-Fast-Generic-Face-Detector-1MB repo. # from the upstream Ultra-Light-Fast-Generic-Face-Detector-1MB repo.
#
# Pinned to a commit (the last one that touched this file, as of writing) rather
# than `master` — a mutable branch ref means a compromised/force-pushed upstream
# repo could silently substitute a different model, which crustd then loads and
# executes as an ONNX computation graph with no other integrity check anywhere in
# the chain. The sha256 check below is the actual guard; the pinned commit just
# keeps re-runs of this script reproducible.
#
# To update: bump MODEL_COMMIT to the new commit that changed the model, download
# once, verify the new file is what you expect, then update MODEL_SHA256 to match.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
MODEL_COMMIT="0f9ca4a9fc80170fd505168fd1132b837141f7df"
MODEL_SHA256="34cd7e60aeff28744c657de7a3dc64e872d506741de66987f3426f2b79f88017"
curl -fsSL -o ultraface-rfb-320.onnx \ curl -fsSL -o ultraface-rfb-320.onnx \
"https://raw.githubusercontent.com/Linzaer/Ultra-Light-Fast-Generic-Face-Detector-1MB/master/models/onnx/version-RFB-320.onnx" "https://raw.githubusercontent.com/Linzaer/Ultra-Light-Fast-Generic-Face-Detector-1MB/${MODEL_COMMIT}/models/onnx/version-RFB-320.onnx"
echo "${MODEL_SHA256} ultraface-rfb-320.onnx" | sha256sum -c -
mkdir -p ~/.local/share/crustd/models mkdir -p ~/.local/share/crustd/models
cp ultraface-rfb-320.onnx ~/.local/share/crustd/models/ultraface-rfb-320.onnx cp ultraface-rfb-320.onnx ~/.local/share/crustd/models/ultraface-rfb-320.onnx
echo "installed to ~/.local/share/crustd/models/ultraface-rfb-320.onnx" echo "installed to ~/.local/share/crustd/models/ultraface-rfb-320.onnx"

View file

@ -1,6 +1,7 @@
[Unit] [Unit]
Description=crustd - tiered presence detection for Bread Description=crustd - tiered presence detection for Bread
After=graphical-session.target After=graphical-session.target
PartOf=graphical-session.target
[Service] [Service]
Type=simple Type=simple
@ -13,7 +14,26 @@ PrivateTmp=true
ProtectSystem=strict ProtectSystem=strict
ReadWritePaths=%h/.local/share/crustd %h/.config/crustd ReadWritePaths=%h/.local/share/crustd %h/.config/crustd
NoNewPrivileges=true NoNewPrivileges=true
SupplementaryGroups=video render SupplementaryGroups=video
DevicePolicy=closed
DeviceAllow=char-video4linux rw
PrivateNetwork=true
RestrictAddressFamilies=AF_UNIX
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictNamespaces=true
RestrictSUIDSGID=true
LockPersonality=true
SystemCallFilter=@system-service
SystemCallArchitectures=native
UMask=0077
# ProtectProc is deliberately omitted here rather than enabled unverified: it
# restricts visibility into *other* processes' /proc/<pid> entries (not /proc/acpi
# itself, which src/lid.rs reads), so it likely doesn't conflict with lid detection
# — but that's an inference, not a test against real hardware/kernel behavior. Worth
# enabling (e.g. ProtectProc=invisible) once someone can confirm `bread lid` still
# works with it on target hardware.
[Install] [Install]
WantedBy=default.target WantedBy=graphical-session.target

View file

@ -1,11 +1,23 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde_json::json; use serde_json::json;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream; use tokio::net::UnixStream;
use tracing::warn; use tracing::warn;
/// Upper bound on connect + write + read for a single emit. Without this, a bread
/// daemon that accepts the connection but then hangs (deadlocked module, blocked on
/// its own IPC) would stall the presence loop forever, since none of these calls
/// have a timeout of their own.
const IPC_TIMEOUT: Duration = Duration::from_secs(3);
/// Caps how much a single response line can grow before giving up — the peer is
/// local and trusted, but there's no reason to let a misbehaving daemon that never
/// sends a newline grow this buffer without bound.
const MAX_RESPONSE_BYTES: u64 = 4096;
/// Thin client for breadd's line-delimited JSON-RPC IPC socket. Connects fresh for /// Thin client for breadd's line-delimited JSON-RPC IPC socket. Connects fresh for
/// each emit rather than holding a persistent connection — presence changes are rare /// each emit rather than holding a persistent connection — presence changes are rare
/// (debounced, seconds-to-minutes apart), so there's no reason to keep a socket open /// (debounced, seconds-to-minutes apart), so there's no reason to keep a socket open
@ -30,39 +42,59 @@ impl BreadClient {
/// Emits `<event_prefix>.<suffix>` with the given JSON payload. Errors are logged /// Emits `<event_prefix>.<suffix>` with the given JSON payload. Errors are logged
/// and swallowed by the caller's choice — a missing/unreachable bread daemon /// and swallowed by the caller's choice — a missing/unreachable bread daemon
/// shouldn't crash the presence loop. /// shouldn't crash the presence loop. Bounded by `IPC_TIMEOUT` end-to-end so a
/// daemon that accepts the connection but never responds can't hang the caller.
pub async fn emit(&self, suffix: &str, data: serde_json::Value) -> Result<()> { pub async fn emit(&self, suffix: &str, data: serde_json::Value) -> Result<()> {
let event = format!("{}.{}", self.event_prefix, suffix); let event = format!("{}.{}", self.event_prefix, suffix);
let mut stream = UnixStream::connect(&self.socket_path)
.await
.with_context(|| format!("connecting to bread socket at {:?}", self.socket_path))?;
let request = json!({ let request = json!({
"id": "crustd", "id": "crustd",
"method": "emit", "method": "emit",
"params": { "event": event, "data": data }, "params": { "event": event, "data": data },
}); });
let line = format!("{}\n", serde_json::to_string(&request)?); let line = format!("{}\n", serde_json::to_string(&request)?);
let response = tokio::time::timeout(IPC_TIMEOUT, self.send_and_wait(&line))
.await
.context("bread emit timed out")??;
if !response_confirms_emitted(&response) {
warn!(response = %response.trim(), "bread did not confirm emit");
}
Ok(())
}
async fn send_and_wait(&self, line: &str) -> Result<String> {
let mut stream = UnixStream::connect(&self.socket_path)
.await
.with_context(|| format!("connecting to bread socket at {:?}", self.socket_path))?;
stream stream
.write_all(line.as_bytes()) .write_all(line.as_bytes())
.await .await
.context("writing emit request")?; .context("writing emit request")?;
let mut reader = BufReader::new(stream); let mut reader = BufReader::new(stream.take(MAX_RESPONSE_BYTES));
let mut response = String::new(); let mut response = String::new();
reader reader
.read_line(&mut response) .read_line(&mut response)
.await .await
.context("reading emit response")?; .context("reading emit response")?;
if !response.contains("\"emitted\":true") { Ok(response)
warn!(response = %response.trim(), "bread did not confirm emit");
}
Ok(())
} }
} }
/// Parses the emit response as JSON and checks `emitted == true`, rather than doing
/// a raw substring match — a differently-formatted (but still correct) response,
/// e.g. with extra whitespace or reordered fields, shouldn't be misread as a failure.
fn response_confirms_emitted(response: &str) -> bool {
serde_json::from_str::<serde_json::Value>(response)
.ok()
.and_then(|v| v.get("emitted").and_then(|e| e.as_bool()))
.unwrap_or(false)
}
fn default_socket_path() -> PathBuf { fn default_socket_path() -> PathBuf {
let runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); let runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(runtime_dir).join("bread").join("breadd.sock") PathBuf::from(runtime_dir).join("bread").join("breadd.sock")

View file

@ -1,4 +1,5 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use v4l::buffer::Type; use v4l::buffer::Type;
@ -7,6 +8,13 @@ use v4l::io::traits::CaptureStream;
use v4l::video::Capture; use v4l::video::Capture;
use v4l::FourCC; use v4l::FourCC;
/// Upper bound on how long a single `stream.next()` call may block waiting for a
/// frame. Without this, a wedged or contended device (another process holding the
/// sensor, a stalled UVC controller) blocks the calling thread forever — since
/// captures run inside `spawn_blocking`, that leaks a blocking-pool thread and can
/// stall the whole presence loop indefinitely.
const CAPTURE_TIMEOUT: Duration = Duration::from_millis(2000);
/// A single grayscale frame: raw luma bytes plus the dimensions they came from. /// A single grayscale frame: raw luma bytes plus the dimensions they came from.
pub struct GrayFrame { pub struct GrayFrame {
pub width: u32, pub width: u32,
@ -93,19 +101,52 @@ pub fn grab_rgb_luma(device: &str, width: u32, height: u32) -> Result<GrayFrame>
let mut dev = v4l::Device::with_path(device) let mut dev = v4l::Device::with_path(device)
.with_context(|| format!("opening rgb camera {device}"))?; .with_context(|| format!("opening rgb camera {device}"))?;
let requested_fourcc = FourCC::new(b"YUYV");
let mut fmt = Capture::format(&dev).context("reading current rgb format")?; let mut fmt = Capture::format(&dev).context("reading current rgb format")?;
fmt.width = width; fmt.width = width;
fmt.height = height; fmt.height = height;
fmt.fourcc = FourCC::new(b"YUYV"); fmt.fourcc = requested_fourcc;
Capture::set_format(&dev, &fmt).context("setting rgb format")?; Capture::set_format(&dev, &fmt).context("setting rgb format")?;
let fmt = Capture::format(&dev).context("reading confirmed rgb format")?; let fmt = Capture::format(&dev).context("reading confirmed rgb format")?;
// set_format is best-effort: the driver may silently substitute the closest
// format/resolution it actually supports. Treating a substituted format as if
// it were YUYV at the requested size would corrupt every downstream pixel
// computation (or, for a resolution change, blow the motion-blob threshold out
// of proportion) with nothing ever logging why. Fail loudly instead.
if fmt.fourcc != requested_fourcc || fmt.width != width || fmt.height != height {
anyhow::bail!(
"rgb camera {device} does not support {width}x{height} {requested_fourcc}; \
driver reported {}x{} {} instead",
fmt.width,
fmt.height,
fmt.fourcc
);
}
let mut stream = MmapStream::with_buffers(&mut dev, Type::VideoCapture, 2) let mut stream = MmapStream::with_buffers(&mut dev, Type::VideoCapture, 2)
.context("starting rgb capture stream")?; .context("starting rgb capture stream")?;
stream.set_timeout(CAPTURE_TIMEOUT);
// First frame after (re)starting a stream is often stale/partially exposed; take the second. // First frame after (re)starting a stream is often stale/partially exposed; take the second.
let _ = stream.next().context("priming rgb stream")?; let _ = stream.next().context("priming rgb stream")?;
let (buf, _meta) = stream.next().context("capturing rgb frame")?; let (buf, meta) = stream.next().context("capturing rgb frame")?;
// The mmap buffer is sized for the driver's worst-case allocation, not the
// actual frame — only `bytesused` bytes at its start are real pixel data for
// this frame. Using the whole buffer would treat leftover memory from a prior
// (possibly larger) allocation as image content.
let expected = (fmt.width * fmt.height * 2) as usize;
let bytesused = meta.bytesused as usize;
if bytesused < expected {
anyhow::bail!(
"rgb frame from {device} truncated: got {bytesused} bytes, expected {expected} \
for {}x{} YUYV",
fmt.width,
fmt.height
);
}
let buf = &buf[..expected];
let mut luma = Vec::with_capacity((fmt.width * fmt.height) as usize); let mut luma = Vec::with_capacity((fmt.width * fmt.height) as usize);
for chunk in buf.chunks_exact(2) { for chunk in buf.chunks_exact(2) {
@ -124,24 +165,56 @@ pub fn grab_rgb_luma(device: &str, width: u32, height: u32) -> Result<GrayFrame>
/// The device is opened once for the whole burst and dropped immediately after — /// The device is opened once for the whole burst and dropped immediately after —
/// callers must not hold the IR camera open outside of this function, since that's /// callers must not hold the IR camera open outside of this function, since that's
/// what keeps the IR illuminator from flashing continuously. /// what keeps the IR illuminator from flashing continuously.
pub fn grab_ir_burst(device: &str, count: u32) -> Result<Vec<GrayFrame>> { pub fn grab_ir_burst(device: &str, width: u32, height: u32, count: u32) -> Result<Vec<GrayFrame>> {
let mut dev = let mut dev =
v4l::Device::with_path(device).with_context(|| format!("opening ir camera {device}"))?; v4l::Device::with_path(device).with_context(|| format!("opening ir camera {device}"))?;
let fmt = Capture::format(&dev).context("reading ir format")?; // V4L2 format is persistent device state — without explicitly setting it here,
// this capture would silently inherit whatever pixel format/resolution the
// previous consumer (another app, or a prior crustd run) last configured on the
// node, and every downstream byte would be misinterpreted with no error raised.
let requested_fourcc = FourCC::new(b"GREY");
let mut fmt = Capture::format(&dev).context("reading current ir format")?;
fmt.width = width;
fmt.height = height;
fmt.fourcc = requested_fourcc;
Capture::set_format(&dev, &fmt).context("setting ir format")?;
let fmt = Capture::format(&dev).context("reading confirmed ir format")?;
if fmt.fourcc != requested_fourcc || fmt.width != width || fmt.height != height {
anyhow::bail!(
"ir camera {device} does not support {width}x{height} {requested_fourcc}; \
driver reported {}x{} {} instead",
fmt.width,
fmt.height,
fmt.fourcc
);
}
let mut stream = MmapStream::with_buffers(&mut dev, Type::VideoCapture, 2) let mut stream = MmapStream::with_buffers(&mut dev, Type::VideoCapture, 2)
.context("starting ir capture stream")?; .context("starting ir capture stream")?;
stream.set_timeout(CAPTURE_TIMEOUT);
// Discard the first frame post-open the same way as the RGB path. // Discard the first frame post-open the same way as the RGB path.
let _ = stream.next().context("priming ir stream")?; let _ = stream.next().context("priming ir stream")?;
let expected = (fmt.width * fmt.height) as usize;
let mut frames = Vec::with_capacity(count as usize); let mut frames = Vec::with_capacity(count as usize);
for _ in 0..count { for _ in 0..count {
let (buf, _meta) = stream.next().context("capturing ir frame")?; let (buf, meta) = stream.next().context("capturing ir frame")?;
let bytesused = meta.bytesused as usize;
if bytesused < expected {
anyhow::bail!(
"ir frame from {device} truncated: got {bytesused} bytes, expected {expected} \
for {}x{} GREY",
fmt.width,
fmt.height
);
}
frames.push(GrayFrame { frames.push(GrayFrame {
width: fmt.width, width: fmt.width,
height: fmt.height, height: fmt.height,
luma: buf.to_vec(), luma: buf[..expected].to_vec(),
}); });
} }

View file

@ -65,6 +65,11 @@ pub struct MotionConfig {
pub diff_threshold: u8, pub diff_threshold: u8,
/// Minimum count of changed pixels (out of 320x180) to call it motion. /// Minimum count of changed pixels (out of 320x180) to call it motion.
pub blob_min_pixels: usize, pub blob_min_pixels: usize,
/// Minimum time between IR acquisition bursts while Absent and RGB motion is
/// present but no face has been confirmed yet. Without a floor here, sustained
/// non-face motion (a TV, a pet, trees through a window) would retrigger a burst
/// on every `active_poll_ms` tick indefinitely.
pub ir_burst_cooldown_ms: u64,
} }
impl Default for MotionConfig { impl Default for MotionConfig {
@ -79,6 +84,7 @@ impl Default for MotionConfig {
input_idle_timeout_ms: 20_000, input_idle_timeout_ms: 20_000,
diff_threshold: 24, diff_threshold: 24,
blob_min_pixels: 200, blob_min_pixels: 200,
ir_burst_cooldown_ms: 3_000,
} }
} }
} }
@ -155,11 +161,61 @@ impl Default for Config {
impl Config { impl Config {
pub fn load(path: &PathBuf) -> Result<Self> { pub fn load(path: &PathBuf) -> Result<Self> {
if !path.exists() { let cfg = if !path.exists() {
return Ok(Self::default()); Self::default()
} else {
let raw = std::fs::read_to_string(path)?;
toml::from_str(&raw)?
};
cfg.validate()?;
Ok(cfg)
}
/// Rejects config values that would otherwise produce a permanent hot-spin poll
/// loop or a presence state that can never change (e.g. a zero poll interval, or
/// a confidence floor of 0.0 that makes every frame "a face"). Called once after
/// load so a bad config fails fast with a clear message instead of the daemon
/// running in a silently broken mode.
pub fn validate(&self) -> Result<()> {
const MIN_POLL_MS: u64 = 50;
let polls = [
("motion.idle_poll_ms", self.motion.idle_poll_ms),
("motion.active_poll_ms", self.motion.active_poll_ms),
("motion.max_idle_poll_ms", self.motion.max_idle_poll_ms),
("motion.present_poll_ms", self.motion.present_poll_ms),
("motion.present_stillness_timeout_ms", self.motion.present_stillness_timeout_ms),
("motion.present_retry_after_miss_ms", self.motion.present_retry_after_miss_ms),
("motion.ir_burst_cooldown_ms", self.motion.ir_burst_cooldown_ms),
];
for (name, value) in polls {
if value < MIN_POLL_MS {
anyhow::bail!("{name} must be at least {MIN_POLL_MS}ms, got {value}ms");
}
} }
let raw = std::fs::read_to_string(path)?;
Ok(toml::from_str(&raw)?) if self.presence.ir_burst_frames == 0 {
anyhow::bail!("presence.ir_burst_frames must be at least 1");
}
if !(self.presence.min_confidence > 0.0 && self.presence.min_confidence <= 1.0) {
anyhow::bail!(
"presence.min_confidence must be in (0.0, 1.0], got {}",
self.presence.min_confidence
);
}
if self.motion.present_retry_after_miss_ms > self.motion.present_stillness_timeout_ms {
anyhow::bail!(
"motion.present_retry_after_miss_ms ({}) must not exceed \
motion.present_stillness_timeout_ms ({}) the retry cadence is meant \
to be faster than the initial stillness timeout, not slower",
self.motion.present_retry_after_miss_ms,
self.motion.present_stillness_timeout_ms
);
}
Ok(())
} }
pub fn default_path() -> PathBuf { pub fn default_path() -> PathBuf {

View file

@ -17,6 +17,7 @@ use wayland_protocols::ext::idle_notify::v1::client::ext_idle_notifier_v1::ExtId
/// wayland-client event loop is blocking. /// wayland-client event loop is blocking.
pub struct IdleWatcher { pub struct IdleWatcher {
idle: Arc<AtomicBool>, idle: Arc<AtomicBool>,
alive: Arc<AtomicBool>,
} }
impl IdleWatcher { impl IdleWatcher {
@ -25,27 +26,40 @@ impl IdleWatcher {
/// signal" and fall back to the camera-only behavior rather than erroring out. /// signal" and fall back to the camera-only behavior rather than erroring out.
pub fn spawn(timeout_ms: u32) -> Option<Self> { pub fn spawn(timeout_ms: u32) -> Option<Self> {
let idle = Arc::new(AtomicBool::new(false)); let idle = Arc::new(AtomicBool::new(false));
let alive = Arc::new(AtomicBool::new(true));
let idle_for_thread = Arc::clone(&idle); let idle_for_thread = Arc::clone(&idle);
let alive_for_thread = Arc::clone(&alive);
let (ready_tx, ready_rx) = mpsc::channel(); let (ready_tx, ready_rx) = mpsc::channel();
std::thread::Builder::new() std::thread::Builder::new()
.name("crustd-idle-notify".into()) .name("crustd-idle-notify".into())
.spawn(move || run(timeout_ms, idle_for_thread, ready_tx)) .spawn(move || run(timeout_ms, idle_for_thread, alive_for_thread, ready_tx))
.ok()?; .ok()?;
ready_rx.recv().ok()?.then_some(Self { idle }) ready_rx.recv().ok()?.then_some(Self { idle, alive })
} }
/// Whether the compositor last reported the seat as idle. Only meaningful while
/// [`Self::is_alive`] is `true` — once the watcher thread has exited, this value
/// is frozen at whatever it last was and must not be trusted either way.
pub fn is_idle(&self) -> bool { pub fn is_idle(&self) -> bool {
self.idle.load(Ordering::Relaxed) self.idle.load(Ordering::Relaxed)
} }
/// `false` once the compositor connection has dropped and the background thread
/// has exited. Callers must check this before trusting `is_idle() == false` as
/// proof of active input — otherwise a dead watcher whose last known state was
/// "not idle" permanently short-circuits the camera-based retention checks.
pub fn is_alive(&self) -> bool {
self.alive.load(Ordering::Relaxed)
}
} }
struct State { struct State {
idle: Arc<AtomicBool>, idle: Arc<AtomicBool>,
} }
fn run(timeout_ms: u32, idle: Arc<AtomicBool>, ready_tx: mpsc::Sender<bool>) { fn run(timeout_ms: u32, idle: Arc<AtomicBool>, alive: Arc<AtomicBool>, ready_tx: mpsc::Sender<bool>) {
let Ok(conn) = Connection::connect_to_env() else { let Ok(conn) = Connection::connect_to_env() else {
let _ = ready_tx.send(false); let _ = ready_tx.send(false);
return; return;
@ -72,8 +86,9 @@ fn run(timeout_ms: u32, idle: Arc<AtomicBool>, ready_tx: mpsc::Sender<bool>) {
loop { loop {
if queue.blocking_dispatch(&mut state).is_err() { if queue.blocking_dispatch(&mut state).is_err() {
// Compositor connection dropped; stick with the last known idle state // Compositor connection dropped. Mark the watcher dead so callers stop
// rather than spinning a busy loop. // trusting the frozen idle flag, rather than spinning a busy loop.
alive.store(false, Ordering::Relaxed);
break; break;
} }
} }

View file

@ -20,6 +20,12 @@ use motion::MotionGate;
use presence::{PresenceState, PresenceTracker}; use presence::{PresenceState, PresenceTracker};
use vision::{Backend, FaceDetector}; use vision::{Backend, FaceDetector};
/// IR frames are captured at the face detector's native input size — nearest-
/// neighbor resizing from anything larger only throws away detail, and the sensor
/// doesn't need to match the RGB motion-gate resolution.
const IR_CAPTURE_WIDTH: u32 = 320;
const IR_CAPTURE_HEIGHT: u32 = 240;
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
@ -67,10 +73,17 @@ async fn main() -> Result<()> {
// While present, RGB motion (typing, mouse, posture shifts) — or, when available, // While present, RGB motion (typing, mouse, posture shifts) — or, when available,
// the compositor directly reporting active input — is the retention signal that // the compositor directly reporting active input — is the retention signal that
// resets this clock. Only once things have been still for a while do we spend an // resets this clock. It's also reset after every retention IR check (positive or
// IR burst to double-check someone's actually still there. // negative) so the check cadence is measured from "time since we last looked",
// not "time since the room was last active" — otherwise, once stillness crosses
// the threshold once, it stays crossed and every following tick re-fires the check.
let mut last_rgb_motion_at = Instant::now(); let mut last_rgb_motion_at = Instant::now();
// Rate-limits IR bursts while Absent: without this, sustained non-face RGB
// motion (a TV, a pet, trees through a window) would open the IR camera and run
// inference on every single active-poll tick, forever.
let mut last_ir_burst_at: Option<Instant> = None;
info!( info!(
rgb_device, rgb_device,
ir_device, ir_device,
@ -103,7 +116,11 @@ async fn main() -> Result<()> {
if presence.state() == PresenceState::Present { if presence.state() == PresenceState::Present {
if let Some(watcher) = &idle_watcher { if let Some(watcher) = &idle_watcher {
if !watcher.is_idle() { // Only trust "not idle" as proof of activity while the watcher is
// still alive — a dead watcher's idle flag is frozen at its last
// value and must not be allowed to permanently disable the
// camera-based retention checks below.
if watcher.is_alive() && !watcher.is_idle() {
// Compositor confirms the seat has active input right now — that's // Compositor confirms the seat has active input right now — that's
// sufficient proof of presence on its own. No camera opened at all. // sufficient proof of presence on its own. No camera opened at all.
last_rgb_motion_at = Instant::now(); last_rgb_motion_at = Instant::now();
@ -115,9 +132,15 @@ async fn main() -> Result<()> {
// Both states share the same cheap RGB capture from here — IR is never // Both states share the same cheap RGB capture from here — IR is never
// touched just to decide whether to look. // touched just to decide whether to look.
let rgb_device_owned = rgb_device.clone(); let rgb_device_owned = rgb_device.clone();
let capture = tokio::task::spawn_blocking(move || camera::grab_rgb_luma(&rgb_device_owned, 320, 180)) let capture_task =
.await tokio::task::spawn_blocking(move || camera::grab_rgb_luma(&rgb_device_owned, 320, 180));
.context("rgb capture task panicked"); let capture = tokio::select! {
_ = &mut shutdown => {
info!("shutting down");
break;
}
res = capture_task => res.context("rgb capture task panicked"),
};
let frame = match capture { let frame = match capture {
Ok(Ok(frame)) => frame, Ok(Ok(frame)) => frame,
@ -157,13 +180,30 @@ async fn main() -> Result<()> {
continue; continue;
} }
match run_ir_check(&ir_device, &cfg.presence, &mut detector).await { let ir_result = tokio::select! {
_ = &mut shutdown => {
info!("shutting down");
break;
}
res = run_ir_check(&ir_device, &cfg.presence, &mut detector) => res,
};
// Reset the reference clock now that a check has actually happened,
// regardless of outcome — otherwise `elapsed()` stays past `due_after` on
// every following tick and the cadence collapses to the poll interval
// instead of the slow/fast retry cadence above.
last_rgb_motion_at = Instant::now();
match ir_result {
Ok(result) => { Ok(result) => {
let face_seen = result.max_confidence >= cfg.presence.min_confidence; let face_seen = result.max_confidence >= cfg.presence.min_confidence;
if face_seen { tokio::select! {
last_rgb_motion_at = Instant::now(); _ = &mut shutdown => {
info!("shutting down");
break;
}
_ = handle_observation(&mut presence, &bread, face_seen) => {}
} }
handle_observation(&mut presence, &bread, face_seen).await;
} }
Err(err) => warn!(error = %err, "ir retention check failed"), Err(err) => warn!(error = %err, "ir retention check failed"),
} }
@ -176,15 +216,39 @@ async fn main() -> Result<()> {
// as someone arriving. IR camera stays closed and dark the rest of the time. // as someone arriving. IR camera stays closed and dark the rest of the time.
if motion.is_motion { if motion.is_motion {
idle_interval = cfg.motion.active_poll_ms; idle_interval = cfg.motion.active_poll_ms;
match run_ir_check(&ir_device, &cfg.presence, &mut detector).await {
Ok(result) => { // Sustained non-face motion (a TV, a pet, trees through a window) would
let face_seen = result.max_confidence >= cfg.presence.min_confidence; // otherwise re-trigger a burst on every active-poll tick forever — cap
if face_seen { // how often a burst can actually fire while nothing is confirming a face.
last_rgb_motion_at = Instant::now(); let burst_due = last_ir_burst_at
.map(|at| at.elapsed() >= Duration::from_millis(cfg.motion.ir_burst_cooldown_ms))
.unwrap_or(true);
if burst_due {
last_ir_burst_at = Some(Instant::now());
let ir_result = tokio::select! {
_ = &mut shutdown => {
info!("shutting down");
break;
} }
handle_observation(&mut presence, &bread, face_seen).await; res = run_ir_check(&ir_device, &cfg.presence, &mut detector) => res,
};
match ir_result {
Ok(result) => {
let face_seen = result.max_confidence >= cfg.presence.min_confidence;
if face_seen {
last_rgb_motion_at = Instant::now();
}
tokio::select! {
_ = &mut shutdown => {
info!("shutting down");
break;
}
_ = handle_observation(&mut presence, &bread, face_seen) => {}
}
}
Err(err) => warn!(error = %err, "ir burst check failed"),
} }
Err(err) => warn!(error = %err, "ir burst check failed"),
} }
} else { } else {
idle_interval = (idle_interval * 3 / 2).min(cfg.motion.max_idle_poll_ms); idle_interval = (idle_interval * 3 / 2).min(cfg.motion.max_idle_poll_ms);
@ -196,7 +260,6 @@ async fn main() -> Result<()> {
struct IrCheckResult { struct IrCheckResult {
max_confidence: f32, max_confidence: f32,
max_mean_luma: f32,
} }
/// Opens the IR camera for exactly one short burst, runs the tier-1 detector over /// Opens the IR camera for exactly one short burst, runs the tier-1 detector over
@ -205,26 +268,21 @@ async fn run_ir_check(ir_device: &str, cfg: &PresenceConfig, detector: &mut Face
let device = ir_device.to_string(); let device = ir_device.to_string();
let burst_frames = cfg.ir_burst_frames; let burst_frames = cfg.ir_burst_frames;
let frames = tokio::task::spawn_blocking(move || camera::grab_ir_burst(&device, burst_frames)) let frames = tokio::task::spawn_blocking(move || {
.await camera::grab_ir_burst(&device, IR_CAPTURE_WIDTH, IR_CAPTURE_HEIGHT, burst_frames)
.context("ir capture task panicked")??; })
.await
.context("ir capture task panicked")??;
let mut max_confidence = 0.0f32; let mut max_confidence = 0.0f32;
let mut max_mean_luma = 0.0f32;
for frame in &frames { for frame in &frames {
let confidence = detector.max_face_confidence(frame)?; let confidence = detector.max_face_confidence(frame)?;
max_confidence = max_confidence.max(confidence); max_confidence = max_confidence.max(confidence);
max_mean_luma = max_mean_luma.max(frame.mean_luma());
} }
tracing::debug!( tracing::debug!(max_confidence, threshold = cfg.min_confidence, "ir burst result");
max_confidence,
max_mean_luma,
threshold = cfg.min_confidence,
"ir burst result"
);
Ok(IrCheckResult { max_confidence, max_mean_luma }) Ok(IrCheckResult { max_confidence })
} }
async fn handle_observation(presence: &mut PresenceTracker, bread: &BreadClient, positive: bool) { async fn handle_observation(presence: &mut PresenceTracker, bread: &BreadClient, positive: bool) {

View file

@ -66,13 +66,28 @@ impl FaceDetector {
.run(ort::inputs!["input" => input]) .run(ort::inputs!["input" => input])
.map_err(|e| anyhow::anyhow!("running face detector session: {e}"))?; .map_err(|e| anyhow::anyhow!("running face detector session: {e}"))?;
let scores = outputs["scores"] // Indexing outputs by name (`outputs["scores"]`) panics if the loaded model
// doesn't have an output by that name — a real possibility since the model
// path is user/config-controlled. Look it up fallibly instead so a
// mismatched model produces a normal error the caller can log and recover
// from, rather than aborting the whole daemon.
let scores_value = outputs
.get("scores")
.ok_or_else(|| anyhow::anyhow!("face detector model has no \"scores\" output"))?;
let (shape, data) = scores_value
.try_extract_tensor::<f32>() .try_extract_tensor::<f32>()
.map_err(|e| anyhow::anyhow!("extracting scores output: {e}"))?; .map_err(|e| anyhow::anyhow!("extracting scores output: {e}"))?;
// scores is [1, N, 2]: column 0 = background prob, column 1 = face prob. // scores is expected to be [1, N, 2]: column 0 = background prob, column 1 =
// face prob. Verify the layout before treating pair[1] as the face score —
// a differently-shaped export (e.g. channel-first [1, 2, N]) would otherwise
// silently read the wrong values with no error.
if shape.len() != 3 || shape[0] != 1 || shape[2] != 2 {
anyhow::bail!("face detector model output has unexpected shape {shape:?}, expected [1, N, 2]");
}
// The exported graph already applies softmax, so these are usable directly. // The exported graph already applies softmax, so these are usable directly.
let (_shape, data) = scores;
let max_face_prob = data let max_face_prob = data
.chunks_exact(2) .chunks_exact(2)
.map(|pair| pair[1]) .map(|pair| pair[1])