use std::time::Duration;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
use super::{WatchConfig, WatchEvent};
pub async fn run_camera_watcher(
config: WatchConfig,
event_tx: mpsc::Sender<WatchEvent>,
cancel: CancellationToken,
) {
debug!("camera watcher starting");
let mut prev_frame: Option<Vec<u8>> = None;
loop {
tokio::select! {
_ = cancel.cancelled() => {
debug!("camera watcher cancelled");
break;
}
_ = tokio::time::sleep(Duration::from_millis(config.camera_poll_interval_ms)) => {
prev_frame = process_camera_frame(&config, &event_tx, prev_frame).await;
}
}
}
}
async fn process_camera_frame(
config: &WatchConfig,
event_tx: &mpsc::Sender<WatchEvent>,
prev_frame: Option<Vec<u8>>,
) -> Option<Vec<u8>> {
let threshold = config.camera_motion_threshold;
let result =
tokio::task::spawn_blocking(move || capture_and_analyse(threshold, prev_frame)).await;
match result {
Ok((new_frame, maybe_event)) => {
if let Some(event) = maybe_event {
if event_tx.try_send(event).is_err() {
debug!("camera event channel full — event dropped");
}
}
new_frame
}
Err(join_err) => {
warn!(error = %join_err, "camera capture task panicked");
let _ = event_tx.try_send(WatchEvent::Error {
source: "camera_watcher".into(),
message: join_err.to_string(),
});
None }
}
}
fn capture_and_analyse(
motion_threshold: f32,
prev_frame: Option<Vec<u8>>,
) -> (Option<Vec<u8>>, Option<WatchEvent>) {
#[cfg(feature = "camera")]
{
use crate::camera::{capture_frame, detect_gestures};
let image = match capture_frame(None) {
Ok(img) => img,
Err(e) => {
warn!(error = %e, "camera frame capture failed");
return (
prev_frame,
Some(WatchEvent::Error {
source: "camera_watcher".into(),
message: e.to_string(),
}),
);
}
};
let motion = prev_frame
.as_deref()
.map(|prev| estimate_motion(prev, &image.jpeg_data))
.unwrap_or(0.0);
debug!(motion, threshold = motion_threshold, "camera motion check");
if motion <= motion_threshold {
return (Some(image.jpeg_data), None);
}
let full_image = image;
let gestures = match detect_gestures(&full_image) {
Ok(g) => g,
Err(e) => {
warn!(error = %e, "gesture detection failed");
return (
Some(full_image.jpeg_data),
Some(WatchEvent::Error {
source: "camera_watcher".into(),
message: e.to_string(),
}),
);
}
};
let timestamp = super::audio_watcher::current_timestamp_pub();
let event = gestures
.into_iter()
.max_by(|a, b| {
a.confidence
.partial_cmp(&b.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|d| WatchEvent::Gesture {
gesture: d.gesture.as_name().to_string(),
confidence: d.confidence,
hand: format!("{:?}", d.hand).to_lowercase(),
timestamp,
});
(Some(full_image.jpeg_data), event)
}
#[cfg(not(feature = "camera"))]
{
let _ = (motion_threshold, prev_frame);
(None, None)
}
}
#[must_use]
pub fn estimate_motion(prev: &[u8], curr: &[u8]) -> f32 {
if prev.is_empty() {
return 0.0;
}
let diff = (prev.len() as f32 - curr.len() as f32).abs();
(diff / prev.len() as f32).min(1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn motion_identical_frames_returns_zero() {
let frame = vec![42u8; 800_000];
let motion = estimate_motion(&frame, &frame);
assert_eq!(motion, 0.0);
}
#[test]
fn motion_different_sizes_returns_positive() {
let big = vec![0u8; 1000];
let small = vec![0u8; 700];
let motion = estimate_motion(&big, &small);
assert!(motion > 0.0, "expected positive motion, got {motion}");
}
#[test]
fn motion_completely_different_sizes_is_bounded_to_one() {
let big = vec![0u8; 10_000];
let tiny = vec![0u8; 1];
let motion = estimate_motion(&big, &tiny);
assert!(motion <= 1.0);
assert!(motion > 0.9);
}
#[test]
fn motion_empty_prev_returns_zero() {
let motion = estimate_motion(&[], &[1, 2, 3]);
assert_eq!(motion, 0.0);
}
#[test]
fn motion_five_percent_size_diff_near_threshold() {
let prev = vec![0u8; 1000];
let curr = vec![0u8; 950]; let motion = estimate_motion(&prev, &curr);
assert!(
(motion - 0.05).abs() < 0.001,
"expected ~0.05, got {motion}"
);
}
#[test]
fn motion_same_length_same_content_zero() {
let data = vec![255u8; 500];
let motion = estimate_motion(&data, &data.clone());
assert_eq!(motion, 0.0);
}
#[test]
fn default_camera_poll_interval_is_two_seconds() {
let cfg = WatchConfig::default();
assert_eq!(cfg.camera_poll_interval_ms, 2000);
}
#[test]
fn default_camera_motion_threshold_is_five_percent() {
let cfg = WatchConfig::default();
assert!((cfg.camera_motion_threshold - 0.05).abs() < 1e-6);
}
}