use std::time::Duration;
#[derive(Debug, Clone, Default)]
pub struct RawSnapshot {
pub node_id: String,
pub since_start: Duration,
pub timestamp_ms: i64,
pub presence: bool,
pub fall_detected: bool,
pub motion: f64, pub motion_energy: f64,
pub breathing_rate_bpm: Option<f64>,
pub heart_rate_bpm: Option<f64>,
pub n_persons: u32,
pub rssi_dbm: Option<f64>,
pub vital_confidence: f64,
pub active_zones: Vec<String>,
pub bed_zones: Vec<String>,
pub local_seconds_since_midnight: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PrimitiveState {
Boolean { active: bool, changed: bool, reason: Reason },
Scalar { value: f64, reason: Reason },
Event { event_type: &'static str, reason: Reason },
Idle,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Reason {
pub tags: Vec<String>,
}
impl Reason {
pub fn new(tags: &[&str]) -> Self {
Self { tags: tags.iter().map(|s| s.to_string()).collect() }
}
pub fn empty() -> Self {
Self { tags: Vec::new() }
}
}
#[derive(Debug, Clone)]
pub struct PrimitiveConfig {
pub warmup: Duration,
pub sleep_dwell: Duration,
pub distress_hr_multiple: f64,
pub distress_dwell: Duration,
pub room_active_motion_threshold: f64,
pub room_active_window: Duration,
pub room_active_exit_idle: Duration,
pub elderly_anomaly_multiple: f64,
pub meeting_min_persons: u32,
pub meeting_dwell: Duration,
pub bathroom_zone_tag: String,
pub fall_risk_event_threshold: f64,
pub bed_exit_window: (u32, u32), pub no_movement_dwell: Duration,
pub multi_room_gap: Duration,
}
impl Default for PrimitiveConfig {
fn default() -> Self {
Self {
warmup: Duration::from_secs(60),
sleep_dwell: Duration::from_secs(300),
distress_hr_multiple: 1.5,
distress_dwell: Duration::from_secs(60),
room_active_motion_threshold: 0.10,
room_active_window: Duration::from_secs(30),
room_active_exit_idle: Duration::from_secs(600),
elderly_anomaly_multiple: 2.0,
meeting_min_persons: 2,
meeting_dwell: Duration::from_secs(600),
bathroom_zone_tag: "bathroom".into(),
fall_risk_event_threshold: 70.0,
bed_exit_window: (22 * 3600, 6 * 3600), no_movement_dwell: Duration::from_secs(30 * 60),
multi_room_gap: Duration::from_secs(10),
}
}
}
pub fn in_window(now: u32, start: u32, end: u32) -> bool {
if start <= end {
now >= start && now < end
} else {
now >= start || now < end
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn in_window_simple_range() {
assert!(in_window(3 * 3600, 1 * 3600, 5 * 3600));
assert!(!in_window(10 * 3600, 1 * 3600, 5 * 3600));
}
#[test]
fn in_window_wrap_around_midnight() {
assert!(in_window(23 * 3600, 22 * 3600, 6 * 3600)); assert!(in_window(2 * 3600, 22 * 3600, 6 * 3600)); assert!(!in_window(12 * 3600, 22 * 3600, 6 * 3600)); assert!(in_window(0, 22 * 3600, 6 * 3600)); }
#[test]
fn primitive_config_defaults_match_adr() {
let c = PrimitiveConfig::default();
assert_eq!(c.warmup, Duration::from_secs(60));
assert_eq!(c.sleep_dwell, Duration::from_secs(300));
assert!((c.distress_hr_multiple - 1.5).abs() < 1e-9);
assert_eq!(c.meeting_min_persons, 2);
assert_eq!(c.bed_exit_window, (22 * 3600, 6 * 3600));
}
#[test]
fn reason_empty_has_no_tags() {
let r = Reason::empty();
assert!(r.tags.is_empty());
}
#[test]
fn reason_new_collects_string_owned() {
let r = Reason::new(&["motion<5%", "br=12bpm"]);
assert_eq!(r.tags, vec!["motion<5%".to_string(), "br=12bpm".to_string()]);
}
}