1use serde::{Deserialize, Serialize};
4
5use crate::ids::{EventId, SessionId, SourceId, WindowId};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub enum CsiEventKind {
10 PresenceStarted,
12 PresenceEnded,
14 MotionDetected,
16 MotionSettled,
18 BaselineChanged,
20 SignalQualityDropped,
22 DeviceDisconnected,
24 BreathingCandidate,
26 AnomalyDetected,
28 CalibrationRequired,
30}
31
32impl CsiEventKind {
33 pub fn slug(self) -> &'static str {
35 match self {
36 CsiEventKind::PresenceStarted => "presence_started",
37 CsiEventKind::PresenceEnded => "presence_ended",
38 CsiEventKind::MotionDetected => "motion_detected",
39 CsiEventKind::MotionSettled => "motion_settled",
40 CsiEventKind::BaselineChanged => "baseline_changed",
41 CsiEventKind::SignalQualityDropped => "signal_quality_dropped",
42 CsiEventKind::DeviceDisconnected => "device_disconnected",
43 CsiEventKind::BreathingCandidate => "breathing_candidate",
44 CsiEventKind::AnomalyDetected => "anomaly_detected",
45 CsiEventKind::CalibrationRequired => "calibration_required",
46 }
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct CsiEvent {
55 pub event_id: EventId,
57 pub kind: CsiEventKind,
59 pub session_id: SessionId,
61 pub source_id: SourceId,
63 pub timestamp_ns: u64,
65 pub confidence: f32,
67 pub evidence_window_ids: Vec<WindowId>,
69 pub calibration_version: Option<String>,
71 pub metadata_json: String,
73}
74
75#[derive(Debug, Clone, PartialEq, thiserror::Error)]
77#[non_exhaustive]
78pub enum EventError {
79 #[error("event has no evidence window")]
81 NoEvidence,
82 #[error("confidence {0} out of [0,1]")]
84 ConfidenceOutOfRange(f32),
85}
86
87impl CsiEvent {
88 pub fn new(
90 event_id: EventId,
91 kind: CsiEventKind,
92 session_id: SessionId,
93 source_id: SourceId,
94 timestamp_ns: u64,
95 confidence: f32,
96 evidence_window_ids: Vec<WindowId>,
97 ) -> Self {
98 CsiEvent {
99 event_id,
100 kind,
101 session_id,
102 source_id,
103 timestamp_ns,
104 confidence,
105 evidence_window_ids,
106 calibration_version: None,
107 metadata_json: "{}".to_string(),
108 }
109 }
110
111 pub fn with_calibration(mut self, version: impl Into<String>) -> Self {
113 self.calibration_version = Some(version.into());
114 self
115 }
116
117 pub fn with_metadata<T: Serialize>(mut self, meta: &T) -> Result<Self, serde_json::Error> {
119 self.metadata_json = serde_json::to_string(meta)?;
120 Ok(self)
121 }
122
123 pub fn validate(&self) -> Result<(), EventError> {
125 if self.evidence_window_ids.is_empty() {
126 return Err(EventError::NoEvidence);
127 }
128 if !(0.0..=1.0).contains(&self.confidence) || !self.confidence.is_finite() {
129 return Err(EventError::ConfidenceOutOfRange(self.confidence));
130 }
131 Ok(())
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn slugs_are_stable() {
141 assert_eq!(CsiEventKind::PresenceStarted.slug(), "presence_started");
142 assert_eq!(CsiEventKind::AnomalyDetected.slug(), "anomaly_detected");
143 }
144
145 #[test]
146 fn requires_evidence_and_bounded_confidence() {
147 let mut e = CsiEvent::new(
148 EventId(0),
149 CsiEventKind::MotionDetected,
150 SessionId(0),
151 SourceId::from("t"),
152 1_000,
153 0.7,
154 vec![WindowId(3)],
155 );
156 assert!(e.validate().is_ok());
157
158 e.evidence_window_ids.clear();
159 assert_eq!(e.validate(), Err(EventError::NoEvidence));
160
161 e.evidence_window_ids.push(WindowId(3));
162 e.confidence = 1.2;
163 assert_eq!(e.validate(), Err(EventError::ConfidenceOutOfRange(1.2)));
164 }
165
166 #[test]
167 fn metadata_and_calibration_roundtrip() {
168 #[derive(Serialize)]
169 struct M {
170 motion_energy: f32,
171 }
172 let e = CsiEvent::new(
173 EventId(1),
174 CsiEventKind::PresenceStarted,
175 SessionId(0),
176 SourceId::from("t"),
177 5,
178 0.9,
179 vec![WindowId(0)],
180 )
181 .with_calibration("livingroom@v3")
182 .with_metadata(&M { motion_energy: 1.25 })
183 .unwrap();
184 assert_eq!(e.calibration_version.as_deref(), Some("livingroom@v3"));
185 assert!(e.metadata_json.contains("1.25"));
186 let json = serde_json::to_string(&e).unwrap();
187 assert_eq!(serde_json::from_str::<CsiEvent>(&json).unwrap(), e);
188 }
189}