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
|
#!/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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
56
src/bread.rs
56
src/bread.rs
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
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.
|
/// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
118
src/main.rs
118
src/main.rs
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue