init commit

This commit is contained in:
Breadway 2026-07-22 12:03:32 +08:00
commit 0423d83311
16 changed files with 2581 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
/models/*.onnx
*.onnx

1437
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

30
Cargo.toml Normal file
View file

@ -0,0 +1,30 @@
[package]
name = "crustd"
version = "0.1.0"
edition = "2021"
description = "Tiered, power-efficient presence detection daemon for Bread"
license = "MIT"
[[bin]]
name = "crustd"
path = "src/main.rs"
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "macros", "signal", "sync", "io-util"] }
v4l = "0.14"
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "download-binaries", "tls-native", "copy-dylibs"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
directories = "5"
wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client", "staging"] }
[profile.release]
opt-level = 2
lto = true
codegen-units = 1
strip = true

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Breadway Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

17
README.md Normal file
View file

@ -0,0 +1,17 @@
# crustd
Tiered, power-efficient presence detection daemon for [Bread](https://github.com/Breadway/bread).
## Building
```bash
cargo build --release
```
## Running
```bash
./target/release/crustd
```
See `packaging/` for distribution packaging.

10
models/fetch.sh Executable file
View file

@ -0,0 +1,10 @@
#!/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.
set -euo pipefail
cd "$(dirname "$0")"
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"
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"

View file

@ -0,0 +1,19 @@
[Unit]
Description=crustd - tiered presence detection for Bread
After=graphical-session.target
[Service]
Type=simple
ExecStart=%h/.local/bin/crustd
Restart=on-failure
RestartSec=5
# Power/hardening: no network, no writes outside its own state dir, camera access only.
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=%h/.local/share/crustd %h/.config/crustd
NoNewPrivileges=true
SupplementaryGroups=video render
[Install]
WantedBy=default.target

69
src/bread.rs Normal file
View file

@ -0,0 +1,69 @@
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde_json::json;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tracing::warn;
/// 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
/// between them.
pub struct BreadClient {
socket_path: PathBuf,
event_prefix: String,
}
impl BreadClient {
pub fn new(configured_path: &str, event_prefix: String) -> Self {
let socket_path = if configured_path.is_empty() {
default_socket_path()
} else {
PathBuf::from(configured_path)
};
Self {
socket_path,
event_prefix,
}
}
/// 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.
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)?);
stream
.write_all(line.as_bytes())
.await
.context("writing emit request")?;
let mut reader = BufReader::new(stream);
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(())
}
}
fn default_socket_path() -> PathBuf {
let runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(runtime_dir).join("bread").join("breadd.sock")
}

149
src/camera.rs Normal file
View file

@ -0,0 +1,149 @@
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use v4l::buffer::Type;
use v4l::io::mmap::Stream as MmapStream;
use v4l::io::traits::CaptureStream;
use v4l::video::Capture;
use v4l::FourCC;
/// A single grayscale frame: raw luma bytes plus the dimensions they came from.
pub struct GrayFrame {
pub width: u32,
pub height: u32,
pub luma: Vec<u8>,
}
impl GrayFrame {
/// Mean luma across the whole frame — used both to normalize the RGB motion
/// diff against global brightness changes, and as a cheap non-ML occupancy
/// signal on IR frames (something close to the sensor reflects a lot more IR
/// light back than an empty room, independent of whether a face is detected).
pub fn mean_luma(&self) -> f32 {
if self.luma.is_empty() {
return 0.0;
}
self.luma.iter().map(|&b| b as u64).sum::<u64>() as f32 / self.luma.len() as f32
}
}
/// Which physical sensor a device should be: used to auto-resolve a stable path
/// instead of trusting a possibly-renumbered `/dev/videoN` node.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceKind {
Rgb,
Ir,
}
/// Resolves a configured device string to an actual path.
///
/// A value of `"auto:rgb"` / `"auto:ir"` probes the stable `/dev/v4l/by-path/*`
/// symlinks and picks by capture capability (supports MJPG/YUYV color formats =
/// RGB sensor; GREY-only = IR sensor) rather than trusting `/dev/videoN` numbering,
/// which isn't stable across reboots or external camera plug events. Any other
/// value is used as a literal path, for explicit overrides.
pub fn resolve_device(configured: &str, want: DeviceKind) -> Result<PathBuf> {
if configured != "auto:rgb" && configured != "auto:ir" {
return Ok(PathBuf::from(configured));
}
let by_path = Path::new("/dev/v4l/by-path");
let entries = std::fs::read_dir(by_path)
.with_context(|| format!("listing {by_path:?} for camera auto-detection"))?;
let mut seen = std::collections::HashSet::new();
for entry in entries.flatten() {
let symlink_path = entry.path();
let Ok(resolved) = std::fs::canonicalize(&symlink_path) else {
continue;
};
if !seen.insert(resolved.clone()) {
continue; // by-path lists each physical device under multiple aliases
}
let Ok(dev) = v4l::Device::with_path(&resolved) else {
continue;
};
let Ok(formats) = Capture::enum_formats(&dev) else {
continue;
};
let has_color = formats
.iter()
.any(|f| f.fourcc == FourCC::new(b"MJPG") || f.fourcc == FourCC::new(b"YUYV"));
let has_grey = formats.iter().any(|f| f.fourcc == FourCC::new(b"GREY"));
let matches = match want {
DeviceKind::Rgb => has_color,
DeviceKind::Ir => has_grey && !has_color,
};
if matches {
return Ok(resolved);
}
}
anyhow::bail!("no {want:?} camera found under {by_path:?}; set camera.rgb_device / camera.ir_device explicitly in crustd.toml")
}
/// Grabs one YUYV frame from the RGB camera and returns just the luma (Y) plane.
///
/// Opens the device, captures, and drops it before returning — nothing about this
/// camera is left streaming between polls.
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 mut fmt = Capture::format(&dev).context("reading current rgb format")?;
fmt.width = width;
fmt.height = height;
fmt.fourcc = FourCC::new(b"YUYV");
Capture::set_format(&dev, &fmt).context("setting rgb format")?;
let fmt = Capture::format(&dev).context("reading confirmed rgb format")?;
let mut stream = MmapStream::with_buffers(&mut dev, Type::VideoCapture, 2)
.context("starting rgb capture stream")?;
// 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 mut luma = Vec::with_capacity((fmt.width * fmt.height) as usize);
for chunk in buf.chunks_exact(2) {
luma.push(chunk[0]);
}
Ok(GrayFrame {
width: fmt.width,
height: fmt.height,
luma,
})
}
/// Grabs `count` frames from the IR camera in a single short burst and returns them.
///
/// 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>> {
let mut dev =
v4l::Device::with_path(device).with_context(|| format!("opening ir camera {device}"))?;
let fmt = Capture::format(&dev).context("reading ir format")?;
let mut stream = MmapStream::with_buffers(&mut dev, Type::VideoCapture, 2)
.context("starting ir capture stream")?;
// Discard the first frame post-open the same way as the RGB path.
let _ = stream.next().context("priming ir stream")?;
let mut frames = Vec::with_capacity(count as usize);
for _ in 0..count {
let (buf, _meta) = stream.next().context("capturing ir frame")?;
frames.push(GrayFrame {
width: fmt.width,
height: fmt.height,
luma: buf.to_vec(),
});
}
Ok(frames)
}

179
src/config.rs Normal file
View file

@ -0,0 +1,179 @@
use std::path::PathBuf;
use anyhow::Result;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Config {
pub camera: CameraConfig,
pub motion: MotionConfig,
pub presence: PresenceConfig,
pub bread: BreadConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct CameraConfig {
/// Visible-light camera used for the always-on motion gate. Never opens the IR emitter.
pub rgb_device: String,
/// IR camera used only in short bursts for face detection/recognition.
pub ir_device: String,
}
impl Default for CameraConfig {
fn default() -> Self {
Self {
// Auto-resolved by capture capability via /dev/v4l/by-path at startup —
// see camera::resolve_device. Set to a literal /dev/videoN to override.
rgb_device: "auto:rgb".to_string(),
ir_device: "auto:ir".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct MotionConfig {
/// Poll interval when nothing has moved recently.
pub idle_poll_ms: u64,
/// Poll interval right after motion was seen, before it settles back down.
pub active_poll_ms: u64,
/// Ceiling for backoff when the room has been still for a long time.
pub max_idle_poll_ms: u64,
/// RGB poll interval while presence is already confirmed. This is the retention
/// signal — typing, mouse movement, posture shifts — and it's cheap (no IR, no
/// inference), so it can run continuously without a power or annoyance cost.
pub present_poll_ms: u64,
/// How long RGB has to be continuously still while present before we bother
/// spending an IR burst to double-check someone's still there. A person actively
/// working is essentially never RGB-still for this long, so in normal use this
/// keeps the IR camera dark for the entire session.
pub present_stillness_timeout_ms: u64,
/// After an IR retention check comes back with no face, how soon to retry — much
/// faster than `present_stillness_timeout_ms` so a real departure is confirmed
/// (and presence released) within `release_frames` short retries instead of
/// waiting a full stillness timeout each time. A positive check at any point
/// resets back to the slow cadence.
pub present_retry_after_miss_ms: u64,
/// How long the compositor's seat (keyboard/mouse) must be idle, via
/// ext-idle-notify-v1, before Present-mode falls back to the camera-based
/// retention cascade at all. While input is active, no camera is opened —
/// typing/mousing is treated as sufficient proof of presence on its own.
pub input_idle_timeout_ms: u32,
/// Per-pixel luma delta (0-255) that counts as "changed".
pub diff_threshold: u8,
/// Minimum count of changed pixels (out of 320x180) to call it motion.
pub blob_min_pixels: usize,
}
impl Default for MotionConfig {
fn default() -> Self {
Self {
idle_poll_ms: 2_000,
active_poll_ms: 250,
max_idle_poll_ms: 15_000,
present_poll_ms: 1_000,
present_stillness_timeout_ms: 45_000,
present_retry_after_miss_ms: 8_000,
input_idle_timeout_ms: 20_000,
diff_threshold: 24,
blob_min_pixels: 200,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct PresenceConfig {
/// Path to the ONNX face-detection model used for the presence tier.
pub face_detect_model: String,
/// Consecutive positive IR bursts required before presence flips on.
pub confirm_frames: u32,
/// Consecutive negative IR bursts required before presence flips off.
pub release_frames: u32,
/// Minimum detector confidence to count a frame as "face seen".
pub min_confidence: f32,
/// Frames grabbed per IR burst (averaged/voted, then the camera is closed).
pub ir_burst_frames: u32,
/// Consecutive retention misses required before the recheck cadence escalates
/// from the slow stillness-timeout interval to the fast retry interval. Kept
/// above 1 so a single missed frontal-face check (person glanced down, turned to
/// talk to someone) gets a second slow-cadence check before anything urgent
/// happens, rather than immediately starting a fast countdown toward auto-lock.
///
/// (An earlier version of this tried a "mean IR brightness = still occupied"
/// fallback signal instead, on the theory that a nearby person reflects
/// noticeably more IR than an empty room. Measured on real hardware, empty-room
/// and occupied-room mean luma overlapped too much to use — the frame is
/// dominated by background reflectance, not near-field subject reflectance.
/// Discarded rather than shipped; this grace-period approach doesn't depend on
/// that assumption.)
pub retention_grace_misses: u32,
}
impl Default for PresenceConfig {
fn default() -> Self {
Self {
face_detect_model: "~/.local/share/crustd/models/ultraface-rfb-320.onnx".to_string(),
confirm_frames: 2,
release_frames: 3,
min_confidence: 0.7,
ir_burst_frames: 3,
retention_grace_misses: 2,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct BreadConfig {
/// Empty = resolve $XDG_RUNTIME_DIR/bread/breadd.sock at runtime, matching breadd's default.
pub socket_path: String,
pub event_prefix: String,
}
impl Default for BreadConfig {
fn default() -> Self {
Self {
socket_path: String::new(),
event_prefix: "bread.presence".to_string(),
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
camera: CameraConfig::default(),
motion: MotionConfig::default(),
presence: PresenceConfig::default(),
bread: BreadConfig::default(),
}
}
}
impl Config {
pub fn load(path: &PathBuf) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let raw = std::fs::read_to_string(path)?;
Ok(toml::from_str(&raw)?)
}
pub fn default_path() -> PathBuf {
directories::ProjectDirs::from("dev", "breadway", "crustd")
.map(|d| d.config_dir().join("crustd.toml"))
.unwrap_or_else(|| PathBuf::from("crustd.toml"))
}
}
pub fn expand_home(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home).join(rest);
}
}
PathBuf::from(path)
}

126
src/idle.rs Normal file
View file

@ -0,0 +1,126 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use wayland_client::globals::{registry_queue_init, GlobalListContents};
use wayland_client::protocol::{wl_registry, wl_seat};
use wayland_client::protocol::wl_seat::WlSeat;
use wayland_client::{Connection, Dispatch, QueueHandle};
use wayland_protocols::ext::idle_notify::v1::client::ext_idle_notification_v1::{
self, ExtIdleNotificationV1,
};
use wayland_protocols::ext::idle_notify::v1::client::ext_idle_notifier_v1::ExtIdleNotifierV1;
/// Tracks whether the compositor considers the seat's input (keyboard/mouse/etc.)
/// idle, via the standard `ext-idle-notify-v1` protocol (the same one `hypridle`
/// itself relies on). Runs its own connection on a dedicated thread since the
/// wayland-client event loop is blocking.
pub struct IdleWatcher {
idle: Arc<AtomicBool>,
}
impl IdleWatcher {
/// Returns `None` if the compositor doesn't support ext-idle-notify-v1 (or the
/// connection otherwise fails) — callers should treat that as "can't use this
/// 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 idle_for_thread = Arc::clone(&idle);
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))
.ok()?;
ready_rx.recv().ok()?.then_some(Self { idle })
}
pub fn is_idle(&self) -> bool {
self.idle.load(Ordering::Relaxed)
}
}
struct State {
idle: Arc<AtomicBool>,
}
fn run(timeout_ms: u32, idle: Arc<AtomicBool>, ready_tx: mpsc::Sender<bool>) {
let Ok(conn) = Connection::connect_to_env() else {
let _ = ready_tx.send(false);
return;
};
let Ok((globals, mut queue)) = registry_queue_init::<State>(&conn) else {
let _ = ready_tx.send(false);
return;
};
let qh = queue.handle();
let seat = globals.bind::<WlSeat, _, _>(&qh, 1..=8, ());
let notifier = globals.bind::<ExtIdleNotifierV1, _, _>(&qh, 1..=1, ());
let (Ok(seat), Ok(notifier)) = (seat, notifier) else {
let _ = ready_tx.send(false);
return;
};
let mut state = State { idle };
let _notification = notifier.get_idle_notification(timeout_ms, &seat, &qh, ());
let _ = ready_tx.send(true);
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.
break;
}
}
}
impl Dispatch<ExtIdleNotificationV1, ()> for State {
fn event(
state: &mut Self,
_proxy: &ExtIdleNotificationV1,
event: ext_idle_notification_v1::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
) {
match event {
ext_idle_notification_v1::Event::Idled => state.idle.store(true, Ordering::Relaxed),
ext_idle_notification_v1::Event::Resumed => state.idle.store(false, Ordering::Relaxed),
_ => {}
}
}
}
// No-op dispatch impls for objects whose events we don't care about.
impl Dispatch<WlSeat, ()> for State {
fn event(_: &mut Self, _: &WlSeat, _: wl_seat::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
}
impl Dispatch<ExtIdleNotifierV1, ()> for State {
fn event(
_: &mut Self,
_: &ExtIdleNotifierV1,
_: <ExtIdleNotifierV1 as wayland_client::Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for State {
fn event(
_: &mut Self,
_: &wl_registry::WlRegistry,
_: wl_registry::Event,
_: &GlobalListContents,
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}

28
src/lid.rs Normal file
View file

@ -0,0 +1,28 @@
use std::path::Path;
/// Best-effort lid state via the ACPI button interface. Cameras obviously can't see
/// anything useful with the lid shut, so callers should skip capture entirely rather
/// than burn power opening devices against a blocked sensor.
///
/// Returns `false` (assume open) if there's no ACPI lid button on this machine —
/// desktops and some laptops won't have one, and that's not an error condition.
pub fn is_closed() -> bool {
let Ok(entries) = std::fs::read_dir("/proc/acpi/button/lid") else {
return false;
};
for entry in entries.flatten() {
let state_path = entry.path().join("state");
if let Some(state) = read_state(&state_path) {
if state.contains("closed") {
return true;
}
}
}
false
}
fn read_state(path: &Path) -> Option<String> {
std::fs::read_to_string(path).ok()
}

259
src/main.rs Normal file
View file

@ -0,0 +1,259 @@
mod bread;
mod camera;
mod config;
mod idle;
mod lid;
mod motion;
mod presence;
mod vision;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use tracing::{info, warn};
use bread::BreadClient;
use camera::DeviceKind;
use config::{Config, PresenceConfig};
use idle::IdleWatcher;
use motion::MotionGate;
use presence::{PresenceState, PresenceTracker};
use vision::{Backend, FaceDetector};
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("crustd=info")),
)
.init();
let config_path = Config::default_path();
let cfg = Config::load(&config_path)?;
info!(?config_path, "loaded config");
let model_path = config::expand_home(&cfg.presence.face_detect_model);
if !model_path.exists() {
anyhow::bail!(
"face detector model not found at {model_path:?}; place ultraface-rfb-320.onnx \
there or point presence.face_detect_model at it in crustd.toml"
);
}
let mut detector = FaceDetector::load(&model_path, Backend::Cpu)?;
let mut motion_gate = MotionGate::new();
let mut presence = PresenceTracker::new(&cfg.presence);
let bread = BreadClient::new(&cfg.bread.socket_path, cfg.bread.event_prefix.clone());
let rgb_device = camera::resolve_device(&cfg.camera.rgb_device, DeviceKind::Rgb)
.context("resolving rgb camera device")?
.to_string_lossy()
.into_owned();
let ir_device = camera::resolve_device(&cfg.camera.ir_device, DeviceKind::Ir)
.context("resolving ir camera device")?
.to_string_lossy()
.into_owned();
let idle_watcher = IdleWatcher::spawn(cfg.motion.input_idle_timeout_ms);
if idle_watcher.is_none() {
warn!(
"compositor doesn't support ext-idle-notify-v1 (or connection failed); \
falling back to camera-only retention, no compositor idle fast-path"
);
}
let mut idle_interval = cfg.motion.idle_poll_ms;
// 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.
let mut last_rgb_motion_at = Instant::now();
info!(
rgb_device,
ir_device,
compositor_idle_signal = idle_watcher.is_some(),
"crustd started; ir camera is opened only in short bursts, never streamed continuously"
);
let mut shutdown = std::pin::pin!(shutdown_signal());
loop {
let sleep_ms = if presence.state() == PresenceState::Present {
cfg.motion.present_poll_ms
} else {
idle_interval
};
tokio::select! {
_ = &mut shutdown => {
info!("shutting down");
break;
}
_ = tokio::time::sleep(Duration::from_millis(sleep_ms)) => {}
}
if lid::is_closed() {
// Nothing a camera can see with the lid shut; don't bother opening one.
tracing::trace!("lid closed, skipping this tick");
continue;
}
if presence.state() == PresenceState::Present {
if let Some(watcher) = &idle_watcher {
if !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();
continue;
}
}
}
// 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 frame = match capture {
Ok(Ok(frame)) => frame,
Ok(Err(err)) => {
warn!(error = %err, "rgb capture failed");
continue;
}
Err(err) => {
warn!(error = %err, "rgb capture task join error");
continue;
}
};
let motion = motion_gate.observe(frame, &cfg.motion);
tracing::trace!(changed_pixels = motion.changed_pixels, "rgb motion sample");
if presence.state() == PresenceState::Present {
if motion.is_motion {
last_rgb_motion_at = Instant::now();
continue;
}
// RGB (and, per above, compositor input) has been still; only escalate to
// an IR retention check once it's been still long enough to matter. The
// first `retention_grace_misses` misses still use the slow stillness
// cadence — a single missed frontal-face check (glanced down, turned to
// talk to someone) shouldn't immediately start an urgent countdown toward
// auto-lock. Only after that grace period does it retry at the fast
// cadence, and any positive check resets back to the slow cadence.
let due_after = if presence.negative_streak() >= cfg.presence.retention_grace_misses {
cfg.motion.present_retry_after_miss_ms
} else {
cfg.motion.present_stillness_timeout_ms
};
if last_rgb_motion_at.elapsed() < Duration::from_millis(due_after) {
continue;
}
match run_ir_check(&ir_device, &cfg.presence, &mut detector).await {
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;
}
Err(err) => warn!(error = %err, "ir retention check failed"),
}
continue;
}
// Absent: escalate to an IR acquisition burst as soon as RGB sees motion.
// Acquisition deliberately requires an actual frontal face match (not the
// looser occupancy signal) — a hand or mug near the sensor shouldn't count
// 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 {
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;
}
Err(err) => warn!(error = %err, "ir burst check failed"),
}
} else {
idle_interval = (idle_interval * 3 / 2).min(cfg.motion.max_idle_poll_ms);
}
}
Ok(())
}
struct IrCheckResult {
max_confidence: f32,
max_mean_luma: f32,
}
/// Opens the IR camera for exactly one short burst, runs the tier-1 detector over
/// each frame, and closes it again before returning.
async fn run_ir_check(ir_device: &str, cfg: &PresenceConfig, detector: &mut FaceDetector) -> Result<IrCheckResult> {
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))
.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"
);
Ok(IrCheckResult { max_confidence, max_mean_luma })
}
async fn handle_observation(presence: &mut PresenceTracker, bread: &BreadClient, positive: bool) {
if let Some(new_state) = presence.observe(positive) {
info!(state = new_state.as_str(), "presence state changed");
let data = serde_json::json!({ "state": new_state.as_str() });
if let Err(err) = bread.emit("changed", data).await {
warn!(error = %err, "failed to emit presence event to bread");
}
}
}
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
use tokio::signal::unix::{signal, SignalKind};
if let Ok(mut sig) = signal(SignalKind::terminate()) {
sig.recv().await;
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}

49
src/motion.rs Normal file
View file

@ -0,0 +1,49 @@
use crate::camera::GrayFrame;
use crate::config::MotionConfig;
/// Cheap frame-differencing gate over the RGB camera. This is the only thing that
/// runs continuously — it never touches the IR camera, so nothing flashes while
/// the room is empty or still.
pub struct MotionGate {
previous: Option<GrayFrame>,
}
pub struct MotionResult {
pub changed_pixels: usize,
pub is_motion: bool,
}
impl MotionGate {
pub fn new() -> Self {
Self { previous: None }
}
pub fn observe(&mut self, frame: GrayFrame, cfg: &MotionConfig) -> MotionResult {
let changed_pixels = match &self.previous {
None => 0,
Some(prev) if prev.luma.len() != frame.luma.len() => 0,
Some(prev) => {
// Subtract the frame-wide brightness shift before thresholding, so a
// light turning on/off (which moves every pixel by roughly the same
// amount) doesn't register as motion — only localized change does.
let mean_shift = frame.mean_luma() - prev.mean_luma();
prev.luma
.iter()
.zip(frame.luma.iter())
.filter(|(&a, &b)| {
let adjusted = b as f32 - mean_shift;
(adjusted - a as f32).abs() >= cfg.diff_threshold as f32
})
.count()
}
};
let is_motion = changed_pixels >= cfg.blob_min_pixels;
self.previous = Some(frame);
MotionResult {
changed_pixels,
is_motion,
}
}
}

75
src/presence.rs Normal file
View file

@ -0,0 +1,75 @@
use crate::config::PresenceConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PresenceState {
Absent,
Present,
}
impl PresenceState {
pub fn as_str(&self) -> &'static str {
match self {
PresenceState::Absent => "absent",
PresenceState::Present => "present",
}
}
}
/// Debounced presence state machine. Feed it one boolean per IR burst ("was a face
/// seen above threshold") and it reports state transitions only after `confirm_frames`
/// consecutive positives (to flip on) or `release_frames` consecutive negatives (to
/// flip off) — this is what keeps a single flickery frame from toggling the state.
pub struct PresenceTracker {
state: PresenceState,
positive_streak: u32,
negative_streak: u32,
confirm_frames: u32,
release_frames: u32,
}
impl PresenceTracker {
pub fn new(cfg: &PresenceConfig) -> Self {
Self {
state: PresenceState::Absent,
positive_streak: 0,
negative_streak: 0,
confirm_frames: cfg.confirm_frames.max(1),
release_frames: cfg.release_frames.max(1),
}
}
pub fn state(&self) -> PresenceState {
self.state
}
/// Count of consecutive failed retention checks since presence was last
/// confirmed. Zero means the last check (if any) was positive, or none has run
/// yet — callers use this to decide whether to use the slow stillness-timeout
/// cadence or the fast retry-after-miss cadence for the next IR check.
pub fn negative_streak(&self) -> u32 {
self.negative_streak
}
/// Returns `Some(new_state)` only when the debounced state actually changes.
pub fn observe(&mut self, face_seen: bool) -> Option<PresenceState> {
if face_seen {
self.positive_streak += 1;
self.negative_streak = 0;
} else {
self.negative_streak += 1;
self.positive_streak = 0;
}
match self.state {
PresenceState::Absent if self.positive_streak >= self.confirm_frames => {
self.state = PresenceState::Present;
Some(self.state)
}
PresenceState::Present if self.negative_streak >= self.release_frames => {
self.state = PresenceState::Absent;
Some(self.state)
}
_ => None,
}
}
}

110
src/vision.rs Normal file
View file

@ -0,0 +1,110 @@
use anyhow::Result;
use ort::session::builder::GraphOptimizationLevel;
use ort::session::Session;
use ort::value::Value;
use crate::camera::GrayFrame;
const MODEL_WIDTH: usize = 320;
const MODEL_HEIGHT: usize = 240;
/// Which execution provider a `FaceDetector` was built against.
///
/// `Npu` is intentionally unimplemented: this hardware (RyzenAI-npu6 / Krackan) has
/// no matching xclbin overlay in the installed Vitis AI SDK (it only ships Phoenix
/// overlays), so there's nothing for the VitisAI EP to load yet. The seam is here so
/// that dropping in a Krackan xclbin later is a config change, not a rewrite: swap
/// this match arm to call `SessionBuilder::with_execution_providers` with the VitisAI
/// EP once `vaip_config.json` points at a compatible overlay.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Backend {
Cpu,
Npu,
}
/// Tier-1 presence face detector (UltraFace-RFB-320 class model). Runs only against
/// short IR bursts handed to it by the presence loop — it never touches the camera
/// itself.
pub struct FaceDetector {
session: Session,
}
impl FaceDetector {
pub fn load(model_path: &std::path::Path, backend: Backend) -> Result<Self> {
if backend == Backend::Npu {
anyhow::bail!(
"NPU backend requested but no Krackan/Strix xclbin is available for the \
VitisAI execution provider on this system (only Phoenix overlays are \
installed under ryzenai-env). Falling back is the caller's job; this \
constructor refuses rather than silently running on CPU under a false name."
);
}
let session = Session::builder()
.map_err(|e| anyhow::anyhow!("creating onnxruntime session builder: {e}"))?
.with_optimization_level(GraphOptimizationLevel::Level3)
.map_err(|e| anyhow::anyhow!("setting graph optimization level: {e}"))?
.with_intra_threads(1)
.map_err(|e| anyhow::anyhow!("setting intra-op thread count: {e}"))?
.commit_from_file(model_path)
.map_err(|e| anyhow::anyhow!("loading face detector model from {model_path:?}: {e}"))?;
Ok(Self { session })
}
/// Resizes/normalizes the frame to the model's expected input and returns the
/// highest face-class probability across all anchor boxes. Callers decide the
/// confidence threshold; this just reports the peak score.
pub fn max_face_confidence(&mut self, frame: &GrayFrame) -> Result<f32> {
let tensor = preprocess(frame);
let input = Value::from_array(([1usize, 3, MODEL_HEIGHT, MODEL_WIDTH], tensor))
.map_err(|e| anyhow::anyhow!("building input tensor: {e}"))?;
let outputs = self
.session
.run(ort::inputs!["input" => input])
.map_err(|e| anyhow::anyhow!("running face detector session: {e}"))?;
let scores = outputs["scores"]
.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.
// 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])
.fold(0.0f32, f32::max);
Ok(max_face_prob)
}
}
/// Nearest-neighbor stretch-resize to 320x240 + grayscale-replicated-to-RGB +
/// `(x - 127) / 128` normalization, matching the reference UltraFace preprocessing.
/// Nearest-neighbor is a deliberate v1 simplification (no extra image/resize
/// dependency) — a proper area-average downscale would be a bit more accurate but
/// isn't needed for a presence gate.
fn preprocess(frame: &GrayFrame) -> Vec<f32> {
let mut out = vec![0f32; 3 * MODEL_HEIGHT * MODEL_WIDTH];
let plane_len = MODEL_HEIGHT * MODEL_WIDTH;
for y in 0..MODEL_HEIGHT {
let src_y = y * frame.height as usize / MODEL_HEIGHT;
for x in 0..MODEL_WIDTH {
let src_x = x * frame.width as usize / MODEL_WIDTH;
let src_idx = src_y * frame.width as usize + src_x;
let luma = *frame.luma.get(src_idx).unwrap_or(&0) as f32;
let normalized = (luma - 127.0) / 128.0;
let dst_idx = y * MODEL_WIDTH + x;
out[dst_idx] = normalized; // R
out[plane_len + dst_idx] = normalized; // G
out[2 * plane_len + dst_idx] = normalized; // B
}
}
out
}