can't be bothered writing a commit message
This commit is contained in:
parent
0423d83311
commit
4648fefb8e
8 changed files with 349 additions and 63 deletions
|
|
@ -1,10 +1,27 @@
|
|||
#!/usr/bin/env bash
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
MODEL_COMMIT="0f9ca4a9fc80170fd505168fd1132b837141f7df"
|
||||
MODEL_SHA256="34cd7e60aeff28744c657de7a3dc64e872d506741de66987f3426f2b79f88017"
|
||||
|
||||
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
|
||||
cp ultraface-rfb-320.onnx ~/.local/share/crustd/models/ultraface-rfb-320.onnx
|
||||
echo "installed to ~/.local/share/crustd/models/ultraface-rfb-320.onnx"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
[Unit]
|
||||
Description=crustd - tiered presence detection for Bread
|
||||
After=graphical-session.target
|
||||
PartOf=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
|
@ -13,7 +14,26 @@ PrivateTmp=true
|
|||
ProtectSystem=strict
|
||||
ReadWritePaths=%h/.local/share/crustd %h/.config/crustd
|
||||
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]
|
||||
WantedBy=default.target
|
||||
WantedBy=graphical-session.target
|
||||
|
|
|
|||
54
src/bread.rs
54
src/bread.rs
|
|
@ -1,11 +1,23 @@
|
|||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::json;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
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
|
||||
/// 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
|
||||
|
|
@ -30,37 +42,57 @@ impl BreadClient {
|
|||
|
||||
/// Emits `<event_prefix>.<suffix>` with the given JSON payload. Errors are logged
|
||||
/// 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<()> {
|
||||
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!({
|
||||
"id": "crustd",
|
||||
"method": "emit",
|
||||
"params": { "event": event, "data": data },
|
||||
});
|
||||
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
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.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();
|
||||
reader
|
||||
.read_line(&mut response)
|
||||
.await
|
||||
.context("reading emit response")?;
|
||||
|
||||
if !response.contains("\"emitted\":true") {
|
||||
warn!(response = %response.trim(), "bread did not confirm emit");
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use v4l::buffer::Type;
|
||||
|
|
@ -7,6 +8,13 @@ use v4l::io::traits::CaptureStream;
|
|||
use v4l::video::Capture;
|
||||
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.
|
||||
pub struct GrayFrame {
|
||||
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)
|
||||
.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")?;
|
||||
fmt.width = width;
|
||||
fmt.height = height;
|
||||
fmt.fourcc = FourCC::new(b"YUYV");
|
||||
fmt.fourcc = requested_fourcc;
|
||||
Capture::set_format(&dev, &fmt).context("setting 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)
|
||||
.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.
|
||||
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);
|
||||
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 —
|
||||
/// callers must not hold the IR camera open outside of this function, since that's
|
||||
/// 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 =
|
||||
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)
|
||||
.context("starting ir capture stream")?;
|
||||
stream.set_timeout(CAPTURE_TIMEOUT);
|
||||
|
||||
// Discard the first frame post-open the same way as the RGB path.
|
||||
let _ = stream.next().context("priming ir stream")?;
|
||||
|
||||
let expected = (fmt.width * fmt.height) as usize;
|
||||
let mut frames = Vec::with_capacity(count as usize);
|
||||
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 {
|
||||
width: fmt.width,
|
||||
height: fmt.height,
|
||||
luma: buf.to_vec(),
|
||||
luma: buf[..expected].to_vec(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,11 @@ pub struct MotionConfig {
|
|||
pub diff_threshold: u8,
|
||||
/// Minimum count of changed pixels (out of 320x180) to call it motion.
|
||||
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 {
|
||||
|
|
@ -79,6 +84,7 @@ impl Default for MotionConfig {
|
|||
input_idle_timeout_ms: 20_000,
|
||||
diff_threshold: 24,
|
||||
blob_min_pixels: 200,
|
||||
ir_burst_cooldown_ms: 3_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -155,11 +161,61 @@ impl Default for Config {
|
|||
|
||||
impl Config {
|
||||
pub fn load(path: &PathBuf) -> Result<Self> {
|
||||
if !path.exists() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let cfg = if !path.exists() {
|
||||
Self::default()
|
||||
} else {
|
||||
let raw = std::fs::read_to_string(path)?;
|
||||
Ok(toml::from_str(&raw)?)
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
25
src/idle.rs
25
src/idle.rs
|
|
@ -17,6 +17,7 @@ use wayland_protocols::ext::idle_notify::v1::client::ext_idle_notifier_v1::ExtId
|
|||
/// wayland-client event loop is blocking.
|
||||
pub struct IdleWatcher {
|
||||
idle: Arc<AtomicBool>,
|
||||
alive: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl IdleWatcher {
|
||||
|
|
@ -25,27 +26,40 @@ impl IdleWatcher {
|
|||
/// signal" and fall back to the camera-only behavior rather than erroring out.
|
||||
pub fn spawn(timeout_ms: u32) -> Option<Self> {
|
||||
let idle = Arc::new(AtomicBool::new(false));
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
let idle_for_thread = Arc::clone(&idle);
|
||||
let alive_for_thread = Arc::clone(&alive);
|
||||
let (ready_tx, ready_rx) = mpsc::channel();
|
||||
|
||||
std::thread::Builder::new()
|
||||
.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()?;
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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 _ = ready_tx.send(false);
|
||||
return;
|
||||
|
|
@ -72,8 +86,9 @@ fn run(timeout_ms: u32, idle: Arc<AtomicBool>, ready_tx: mpsc::Sender<bool>) {
|
|||
|
||||
loop {
|
||||
if queue.blocking_dispatch(&mut state).is_err() {
|
||||
// Compositor connection dropped; stick with the last known idle state
|
||||
// rather than spinning a busy loop.
|
||||
// Compositor connection dropped. Mark the watcher dead so callers stop
|
||||
// trusting the frozen idle flag, rather than spinning a busy loop.
|
||||
alive.store(false, Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
104
src/main.rs
104
src/main.rs
|
|
@ -20,6 +20,12 @@ use motion::MotionGate;
|
|||
use presence::{PresenceState, PresenceTracker};
|
||||
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]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
|
|
@ -67,10 +73,17 @@ async fn main() -> Result<()> {
|
|||
|
||||
// While present, RGB motion (typing, mouse, posture shifts) — or, when available,
|
||||
// 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
|
||||
// IR burst to double-check someone's actually still there.
|
||||
// resets this clock. It's also reset after every retention IR check (positive or
|
||||
// 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();
|
||||
|
||||
// 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!(
|
||||
rgb_device,
|
||||
ir_device,
|
||||
|
|
@ -103,7 +116,11 @@ async fn main() -> Result<()> {
|
|||
|
||||
if presence.state() == PresenceState::Present {
|
||||
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
|
||||
// sufficient proof of presence on its own. No camera opened at all.
|
||||
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
|
||||
// touched just to decide whether to look.
|
||||
let rgb_device_owned = rgb_device.clone();
|
||||
let capture = tokio::task::spawn_blocking(move || camera::grab_rgb_luma(&rgb_device_owned, 320, 180))
|
||||
.await
|
||||
.context("rgb capture task panicked");
|
||||
let capture_task =
|
||||
tokio::task::spawn_blocking(move || camera::grab_rgb_luma(&rgb_device_owned, 320, 180));
|
||||
let capture = tokio::select! {
|
||||
_ = &mut shutdown => {
|
||||
info!("shutting down");
|
||||
break;
|
||||
}
|
||||
res = capture_task => res.context("rgb capture task panicked"),
|
||||
};
|
||||
|
||||
let frame = match capture {
|
||||
Ok(Ok(frame)) => frame,
|
||||
|
|
@ -157,13 +180,30 @@ async fn main() -> Result<()> {
|
|||
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) => {
|
||||
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) => {}
|
||||
}
|
||||
handle_observation(&mut presence, &bread, face_seen).await;
|
||||
}
|
||||
Err(err) => warn!(error = %err, "ir retention check failed"),
|
||||
}
|
||||
|
|
@ -176,16 +216,40 @@ async fn main() -> Result<()> {
|
|||
// as someone arriving. IR camera stays closed and dark the rest of the time.
|
||||
if motion.is_motion {
|
||||
idle_interval = cfg.motion.active_poll_ms;
|
||||
match run_ir_check(&ir_device, &cfg.presence, &mut detector).await {
|
||||
|
||||
// Sustained non-face motion (a TV, a pet, trees through a window) would
|
||||
// otherwise re-trigger a burst on every active-poll tick forever — cap
|
||||
// how often a burst can actually fire while nothing is confirming a face.
|
||||
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;
|
||||
}
|
||||
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();
|
||||
}
|
||||
handle_observation(&mut presence, &bread, face_seen).await;
|
||||
tokio::select! {
|
||||
_ = &mut shutdown => {
|
||||
info!("shutting down");
|
||||
break;
|
||||
}
|
||||
_ = handle_observation(&mut presence, &bread, face_seen) => {}
|
||||
}
|
||||
}
|
||||
Err(err) => warn!(error = %err, "ir burst check failed"),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
idle_interval = (idle_interval * 3 / 2).min(cfg.motion.max_idle_poll_ms);
|
||||
}
|
||||
|
|
@ -196,7 +260,6 @@ async fn main() -> Result<()> {
|
|||
|
||||
struct IrCheckResult {
|
||||
max_confidence: f32,
|
||||
max_mean_luma: f32,
|
||||
}
|
||||
|
||||
/// 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 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 || {
|
||||
camera::grab_ir_burst(&device, IR_CAPTURE_WIDTH, IR_CAPTURE_HEIGHT, burst_frames)
|
||||
})
|
||||
.await
|
||||
.context("ir capture task panicked")??;
|
||||
|
||||
let mut max_confidence = 0.0f32;
|
||||
let mut max_mean_luma = 0.0f32;
|
||||
for frame in &frames {
|
||||
let confidence = detector.max_face_confidence(frame)?;
|
||||
max_confidence = max_confidence.max(confidence);
|
||||
max_mean_luma = max_mean_luma.max(frame.mean_luma());
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
max_confidence,
|
||||
max_mean_luma,
|
||||
threshold = cfg.min_confidence,
|
||||
"ir burst result"
|
||||
);
|
||||
tracing::debug!(max_confidence, 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) {
|
||||
|
|
|
|||
|
|
@ -66,13 +66,28 @@ impl FaceDetector {
|
|||
.run(ort::inputs!["input" => input])
|
||||
.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>()
|
||||
.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.
|
||||
let (_shape, data) = scores;
|
||||
let max_face_prob = data
|
||||
.chunks_exact(2)
|
||||
.map(|pair| pair[1])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue