Skip to main content

rustbac_client/
intrinsic.rs

1//! Server-side intrinsic reporting engine.
2//!
3//! Implements ASHRAE 135 Clause 13 intrinsic reporting algorithms
4//! for BACnet server objects. The engine evaluates property changes
5//! against configured alarm conditions and generates event notifications.
6
7use rustbac_core::types::{ObjectId, PropertyId};
8use std::collections::HashMap;
9use std::sync::RwLock;
10use std::time::Instant;
11
12/// BACnet event states for intrinsic reporting.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum IntrinsicEventState {
15    Normal,
16    Fault,
17    OffNormal,
18    HighLimit,
19    LowLimit,
20}
21
22impl IntrinsicEventState {
23    /// Convert to the BACnet enumerated value.
24    pub fn to_u32(self) -> u32 {
25        match self {
26            Self::Normal => 0,
27            Self::Fault => 1,
28            Self::OffNormal => 2,
29            Self::HighLimit => 3,
30            Self::LowLimit => 4,
31        }
32    }
33}
34
35/// Tracks which event-state transitions have been acknowledged.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct AckedTransitions {
38    pub to_offnormal: bool,
39    pub to_fault: bool,
40    pub to_normal: bool,
41}
42
43impl Default for AckedTransitions {
44    fn default() -> Self {
45        Self {
46            to_offnormal: true,
47            to_fault: true,
48            to_normal: true,
49        }
50    }
51}
52
53/// Intrinsic reporting algorithm configuration.
54#[derive(Debug, Clone)]
55pub enum IntrinsicAlgorithm {
56    /// CHANGE_OF_VALUE — fires when value changes by more than `cov_increment`.
57    ChangeOfValue {
58        cov_increment: f64,
59    },
60    /// OUT_OF_RANGE — fires when value exceeds high/low limits with deadband.
61    OutOfRange {
62        high_limit: f64,
63        low_limit: f64,
64        deadband: f64,
65    },
66    /// CHANGE_OF_STATE — fires when enumerated value matches an alarm value.
67    ChangeOfState {
68        alarm_values: Vec<u32>,
69    },
70    /// FLOATING_LIMIT — fires when value deviates from a setpoint by more than diff limits.
71    FloatingLimit {
72        setpoint_ref: ObjectId,
73        setpoint_property: PropertyId,
74        high_diff_limit: f64,
75        low_diff_limit: f64,
76        deadband: f64,
77    },
78    /// COMMAND_FAILURE — fires when feedback doesn't match command after time delay.
79    CommandFailure {
80        feedback_ref: ObjectId,
81        feedback_property: PropertyId,
82        time_delay_ms: u64,
83    },
84}
85
86/// Internal state for a single enrolled object.
87#[derive(Debug)]
88struct IntrinsicState {
89    object_id: ObjectId,
90    algorithm: IntrinsicAlgorithm,
91    current_event_state: IntrinsicEventState,
92    acked_transitions: AckedTransitions,
93    notification_class: u32,
94    time_delay_ms: u64,
95    /// When a pending transition was first detected (for time-delay).
96    pending_since: Option<Instant>,
97    /// The pending event state (waiting for time delay to elapse).
98    pending_state: Option<IntrinsicEventState>,
99    /// Last reported value (for COV algorithm).
100    last_reported_value: Option<f64>,
101}
102
103/// A pending event notification generated by the engine.
104#[derive(Debug, Clone)]
105pub struct PendingEventNotification {
106    /// The object that generated the event.
107    pub object_id: ObjectId,
108    /// The notification class for routing.
109    pub notification_class: u32,
110    /// The event state that triggered the notification.
111    pub event_state: IntrinsicEventState,
112    /// The previous event state.
113    pub from_state: IntrinsicEventState,
114    /// The property that was evaluated.
115    pub property_id: PropertyId,
116    /// The value that triggered the event (if applicable).
117    pub value: Option<f64>,
118}
119
120/// Configuration for enrolling an object in intrinsic reporting.
121pub struct IntrinsicEnrollment {
122    pub object_id: ObjectId,
123    pub algorithm: IntrinsicAlgorithm,
124    pub notification_class: u32,
125    pub time_delay_ms: u64,
126}
127
128/// Intrinsic reporting engine that evaluates property changes
129/// against configured alarm conditions.
130pub struct IntrinsicReportingEngine {
131    states: RwLock<HashMap<ObjectId, IntrinsicState>>,
132}
133
134impl IntrinsicReportingEngine {
135    /// Create a new empty engine.
136    pub fn new() -> Self {
137        Self {
138            states: RwLock::new(HashMap::new()),
139        }
140    }
141
142    /// Enroll an object for intrinsic reporting.
143    pub fn enroll(&self, enrollment: IntrinsicEnrollment) {
144        let state = IntrinsicState {
145            object_id: enrollment.object_id,
146            algorithm: enrollment.algorithm,
147            current_event_state: IntrinsicEventState::Normal,
148            acked_transitions: AckedTransitions::default(),
149            notification_class: enrollment.notification_class,
150            time_delay_ms: enrollment.time_delay_ms,
151            pending_since: None,
152            pending_state: None,
153            last_reported_value: None,
154        };
155        self.states
156            .write()
157            .expect("IntrinsicReportingEngine lock")
158            .insert(enrollment.object_id, state);
159    }
160
161    /// Remove an object from intrinsic reporting.
162    pub fn unenroll(&self, object_id: ObjectId) {
163        self.states
164            .write()
165            .expect("IntrinsicReportingEngine lock")
166            .remove(&object_id);
167    }
168
169    /// Evaluate a property change and return any pending notifications.
170    ///
171    /// Call this whenever a monitored property value changes.
172    /// The `setpoint_value` parameter is only needed for FloatingLimit;
173    /// pass `None` otherwise.
174    /// The `feedback_value` parameter is only needed for CommandFailure;
175    /// pass `None` otherwise.
176    pub fn evaluate(
177        &self,
178        object_id: ObjectId,
179        property_id: PropertyId,
180        value: f64,
181        setpoint_value: Option<f64>,
182        feedback_value: Option<f64>,
183    ) -> Vec<PendingEventNotification> {
184        let mut states = self
185            .states
186            .write()
187            .expect("IntrinsicReportingEngine lock");
188
189        let state = match states.get_mut(&object_id) {
190            Some(s) => s,
191            None => return Vec::new(),
192        };
193
194        let now = Instant::now();
195        let mut notifications = Vec::new();
196
197        // COV is special: it fires notifications without changing event state.
198        if let IntrinsicAlgorithm::ChangeOfValue { cov_increment } = &state.algorithm {
199            let should_fire = match state.last_reported_value {
200                Some(last) => (value - last).abs() > *cov_increment,
201                None => true, // First evaluation always fires.
202            };
203            if should_fire {
204                state.last_reported_value = Some(value);
205                notifications.push(PendingEventNotification {
206                    object_id,
207                    notification_class: state.notification_class,
208                    event_state: state.current_event_state,
209                    from_state: state.current_event_state,
210                    property_id,
211                    value: Some(value),
212                });
213            }
214            return notifications;
215        }
216
217        let new_state = match &state.algorithm {
218            IntrinsicAlgorithm::OutOfRange {
219                high_limit,
220                low_limit,
221                deadband,
222            } => evaluate_out_of_range(
223                value,
224                *high_limit,
225                *low_limit,
226                *deadband,
227                state.current_event_state,
228            ),
229            IntrinsicAlgorithm::ChangeOfValue { .. } => unreachable!(),
230            IntrinsicAlgorithm::ChangeOfState { alarm_values } => {
231                evaluate_change_of_state(value as u32, alarm_values)
232            }
233            IntrinsicAlgorithm::FloatingLimit {
234                high_diff_limit,
235                low_diff_limit,
236                deadband,
237                ..
238            } => {
239                let setpoint = setpoint_value.unwrap_or(0.0);
240                evaluate_floating_limit(
241                    value,
242                    setpoint,
243                    *high_diff_limit,
244                    *low_diff_limit,
245                    *deadband,
246                    state.current_event_state,
247                )
248            }
249            IntrinsicAlgorithm::CommandFailure { time_delay_ms, .. } => {
250                let feedback = feedback_value.unwrap_or(value);
251                evaluate_command_failure(value, feedback, *time_delay_ms, state, now)
252            }
253        };
254
255        if new_state != state.current_event_state {
256            // Check time delay.
257            if state.time_delay_ms == 0 {
258                // Immediate transition.
259                let from = state.current_event_state;
260                state.current_event_state = new_state;
261                state.pending_since = None;
262                state.pending_state = None;
263
264                // Update acked_transitions.
265                match new_state {
266                    IntrinsicEventState::Normal => {
267                        state.acked_transitions.to_normal = false;
268                    }
269                    IntrinsicEventState::OffNormal
270                    | IntrinsicEventState::HighLimit
271                    | IntrinsicEventState::LowLimit => {
272                        state.acked_transitions.to_offnormal = false;
273                    }
274                    IntrinsicEventState::Fault => {
275                        state.acked_transitions.to_fault = false;
276                    }
277                }
278
279                if let IntrinsicAlgorithm::ChangeOfValue { .. } = &state.algorithm {
280                    state.last_reported_value = Some(value);
281                }
282
283                notifications.push(PendingEventNotification {
284                    object_id,
285                    notification_class: state.notification_class,
286                    event_state: new_state,
287                    from_state: from,
288                    property_id,
289                    value: Some(value),
290                });
291            } else {
292                // Time delay pending.
293                match state.pending_state {
294                    Some(pending) if pending == new_state => {
295                        // Same pending state — check if delay elapsed.
296                        if let Some(since) = state.pending_since {
297                            if now.duration_since(since).as_millis() as u64 >= state.time_delay_ms {
298                                let from = state.current_event_state;
299                                state.current_event_state = new_state;
300                                state.pending_since = None;
301                                state.pending_state = None;
302
303                                match new_state {
304                                    IntrinsicEventState::Normal => {
305                                        state.acked_transitions.to_normal = false;
306                                    }
307                                    IntrinsicEventState::OffNormal
308                                    | IntrinsicEventState::HighLimit
309                                    | IntrinsicEventState::LowLimit => {
310                                        state.acked_transitions.to_offnormal = false;
311                                    }
312                                    IntrinsicEventState::Fault => {
313                                        state.acked_transitions.to_fault = false;
314                                    }
315                                }
316
317                                if let IntrinsicAlgorithm::ChangeOfValue { .. } = &state.algorithm {
318                                    state.last_reported_value = Some(value);
319                                }
320
321                                notifications.push(PendingEventNotification {
322                                    object_id,
323                                    notification_class: state.notification_class,
324                                    event_state: new_state,
325                                    from_state: from,
326                                    property_id,
327                                    value: Some(value),
328                                });
329                            }
330                        }
331                    }
332                    _ => {
333                        // New pending state — start delay timer.
334                        state.pending_since = Some(now);
335                        state.pending_state = Some(new_state);
336                    }
337                }
338            }
339        } else {
340            // Value returned to current state — cancel any pending transition.
341            state.pending_since = None;
342            state.pending_state = None;
343        }
344
345        notifications
346    }
347
348    /// Acknowledge a transition for an enrolled object.
349    pub fn acknowledge(
350        &self,
351        object_id: ObjectId,
352        event_state: IntrinsicEventState,
353    ) -> bool {
354        let mut states = self
355            .states
356            .write()
357            .expect("IntrinsicReportingEngine lock");
358        if let Some(state) = states.get_mut(&object_id) {
359            match event_state {
360                IntrinsicEventState::Normal => {
361                    state.acked_transitions.to_normal = true;
362                }
363                IntrinsicEventState::OffNormal
364                | IntrinsicEventState::HighLimit
365                | IntrinsicEventState::LowLimit => {
366                    state.acked_transitions.to_offnormal = true;
367                }
368                IntrinsicEventState::Fault => {
369                    state.acked_transitions.to_fault = true;
370                }
371            }
372            true
373        } else {
374            false
375        }
376    }
377
378    /// Get the current event state for an enrolled object.
379    pub fn event_state(&self, object_id: ObjectId) -> Option<IntrinsicEventState> {
380        self.states
381            .read()
382            .expect("IntrinsicReportingEngine lock")
383            .get(&object_id)
384            .map(|s| s.current_event_state)
385    }
386
387    /// Get the acked transitions for an enrolled object.
388    pub fn acked_transitions(&self, object_id: ObjectId) -> Option<AckedTransitions> {
389        self.states
390            .read()
391            .expect("IntrinsicReportingEngine lock")
392            .get(&object_id)
393            .map(|s| s.acked_transitions)
394    }
395}
396
397impl Default for IntrinsicReportingEngine {
398    fn default() -> Self {
399        Self::new()
400    }
401}
402
403// ─────────────────────────────────────────────────────────────────────────────
404// Algorithm implementations
405// ─────────────────────────────────────────────────────────────────────────────
406
407fn evaluate_out_of_range(
408    value: f64,
409    high_limit: f64,
410    low_limit: f64,
411    deadband: f64,
412    current_state: IntrinsicEventState,
413) -> IntrinsicEventState {
414    match current_state {
415        IntrinsicEventState::Normal => {
416            if value > high_limit {
417                IntrinsicEventState::HighLimit
418            } else if value < low_limit {
419                IntrinsicEventState::LowLimit
420            } else {
421                IntrinsicEventState::Normal
422            }
423        }
424        IntrinsicEventState::HighLimit => {
425            if value < low_limit {
426                IntrinsicEventState::LowLimit
427            } else if value <= high_limit - deadband {
428                IntrinsicEventState::Normal
429            } else {
430                IntrinsicEventState::HighLimit
431            }
432        }
433        IntrinsicEventState::LowLimit => {
434            if value > high_limit {
435                IntrinsicEventState::HighLimit
436            } else if value >= low_limit + deadband {
437                IntrinsicEventState::Normal
438            } else {
439                IntrinsicEventState::LowLimit
440            }
441        }
442        _ => {
443            // From Fault or OffNormal, re-evaluate fresh.
444            if value > high_limit {
445                IntrinsicEventState::HighLimit
446            } else if value < low_limit {
447                IntrinsicEventState::LowLimit
448            } else {
449                IntrinsicEventState::Normal
450            }
451        }
452    }
453}
454
455fn evaluate_change_of_state(value: u32, alarm_values: &[u32]) -> IntrinsicEventState {
456    if alarm_values.contains(&value) {
457        IntrinsicEventState::OffNormal
458    } else {
459        IntrinsicEventState::Normal
460    }
461}
462
463fn evaluate_floating_limit(
464    value: f64,
465    setpoint: f64,
466    high_diff_limit: f64,
467    low_diff_limit: f64,
468    deadband: f64,
469    current_state: IntrinsicEventState,
470) -> IntrinsicEventState {
471    let high_threshold = setpoint + high_diff_limit;
472    let low_threshold = setpoint - low_diff_limit;
473
474    match current_state {
475        IntrinsicEventState::Normal => {
476            if value > high_threshold {
477                IntrinsicEventState::HighLimit
478            } else if value < low_threshold {
479                IntrinsicEventState::LowLimit
480            } else {
481                IntrinsicEventState::Normal
482            }
483        }
484        IntrinsicEventState::HighLimit => {
485            if value < low_threshold {
486                IntrinsicEventState::LowLimit
487            } else if value <= high_threshold - deadband {
488                IntrinsicEventState::Normal
489            } else {
490                IntrinsicEventState::HighLimit
491            }
492        }
493        IntrinsicEventState::LowLimit => {
494            if value > high_threshold {
495                IntrinsicEventState::HighLimit
496            } else if value >= low_threshold + deadband {
497                IntrinsicEventState::Normal
498            } else {
499                IntrinsicEventState::LowLimit
500            }
501        }
502        _ => {
503            if value > high_threshold {
504                IntrinsicEventState::HighLimit
505            } else if value < low_threshold {
506                IntrinsicEventState::LowLimit
507            } else {
508                IntrinsicEventState::Normal
509            }
510        }
511    }
512}
513
514fn evaluate_command_failure(
515    command_value: f64,
516    feedback_value: f64,
517    _time_delay_ms: u64,
518    _state: &IntrinsicState,
519    _now: Instant,
520) -> IntrinsicEventState {
521    // Simple comparison: if feedback doesn't match command, it's OffNormal.
522    // Time delay is handled by the outer evaluate() method.
523    if (command_value - feedback_value).abs() > f64::EPSILON {
524        IntrinsicEventState::OffNormal
525    } else {
526        IntrinsicEventState::Normal
527    }
528}
529
530/// Encode an UnconfirmedEventNotification PDU.
531///
532/// Returns the encoded bytes or `None` if the buffer is too small.
533pub fn encode_unconfirmed_event_notification(
534    process_id: u32,
535    initiating_device_id: ObjectId,
536    event_object_id: ObjectId,
537    event_state: IntrinsicEventState,
538    notification_class: u32,
539    from_state: IntrinsicEventState,
540) -> Option<Vec<u8>> {
541    use rustbac_core::apdu::UnconfirmedRequestHeader;
542    use rustbac_core::encoding::primitives::encode_ctx_unsigned;
543    use rustbac_core::encoding::writer::Writer;
544    use rustbac_core::npdu::Npdu;
545
546    let mut buf = [0u8; 1400];
547    let mut w = Writer::new(&mut buf);
548
549    Npdu::new(0).encode(&mut w).ok()?;
550
551    // UnconfirmedEventNotification service choice = 0x03
552    UnconfirmedRequestHeader {
553        service_choice: 0x03,
554    }
555    .encode(&mut w)
556    .ok()?;
557
558    // [0] process-identifier
559    encode_ctx_unsigned(&mut w, 0, process_id).ok()?;
560    // [1] initiating-device-identifier
561    encode_ctx_unsigned(&mut w, 1, initiating_device_id.raw()).ok()?;
562    // [2] event-object-identifier
563    encode_ctx_unsigned(&mut w, 2, event_object_id.raw()).ok()?;
564    // [4] notification-class
565    encode_ctx_unsigned(&mut w, 4, notification_class).ok()?;
566    // [9] event-type — 0 = change-of-bitstring (placeholder; actual type depends on algorithm)
567    encode_ctx_unsigned(&mut w, 9, 0).ok()?;
568    // [11] to-state
569    encode_ctx_unsigned(&mut w, 11, event_state.to_u32()).ok()?;
570
571    Some(w.as_written().to_vec())
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use rustbac_core::types::ObjectType;
578
579    fn av(instance: u32) -> ObjectId {
580        ObjectId::new(ObjectType::AnalogValue, instance)
581    }
582
583    #[test]
584    fn out_of_range_high_limit() {
585        let engine = IntrinsicReportingEngine::new();
586        engine.enroll(IntrinsicEnrollment {
587            object_id: av(1),
588            algorithm: IntrinsicAlgorithm::OutOfRange {
589                high_limit: 100.0,
590                low_limit: 0.0,
591                deadband: 5.0,
592            },
593            notification_class: 1,
594            time_delay_ms: 0,
595        });
596
597        // Normal → HighLimit
598        let notifs = engine.evaluate(av(1), PropertyId::PresentValue, 105.0, None, None);
599        assert_eq!(notifs.len(), 1);
600        assert_eq!(notifs[0].event_state, IntrinsicEventState::HighLimit);
601        assert_eq!(notifs[0].from_state, IntrinsicEventState::Normal);
602    }
603
604    #[test]
605    fn out_of_range_deadband_hysteresis() {
606        let engine = IntrinsicReportingEngine::new();
607        engine.enroll(IntrinsicEnrollment {
608            object_id: av(1),
609            algorithm: IntrinsicAlgorithm::OutOfRange {
610                high_limit: 100.0,
611                low_limit: 0.0,
612                deadband: 5.0,
613            },
614            notification_class: 1,
615            time_delay_ms: 0,
616        });
617
618        // Go to HighLimit
619        engine.evaluate(av(1), PropertyId::PresentValue, 105.0, None, None);
620
621        // Still HighLimit (within deadband: 100 - 5 = 95, value is 97)
622        let notifs = engine.evaluate(av(1), PropertyId::PresentValue, 97.0, None, None);
623        assert!(notifs.is_empty());
624
625        // Back to Normal (below deadband threshold: 94 < 95)
626        let notifs = engine.evaluate(av(1), PropertyId::PresentValue, 94.0, None, None);
627        assert_eq!(notifs.len(), 1);
628        assert_eq!(notifs[0].event_state, IntrinsicEventState::Normal);
629    }
630
631    #[test]
632    fn out_of_range_low_limit() {
633        let engine = IntrinsicReportingEngine::new();
634        engine.enroll(IntrinsicEnrollment {
635            object_id: av(1),
636            algorithm: IntrinsicAlgorithm::OutOfRange {
637                high_limit: 100.0,
638                low_limit: 10.0,
639                deadband: 2.0,
640            },
641            notification_class: 1,
642            time_delay_ms: 0,
643        });
644
645        let notifs = engine.evaluate(av(1), PropertyId::PresentValue, 5.0, None, None);
646        assert_eq!(notifs.len(), 1);
647        assert_eq!(notifs[0].event_state, IntrinsicEventState::LowLimit);
648    }
649
650    #[test]
651    fn change_of_value_fires_on_increment() {
652        let engine = IntrinsicReportingEngine::new();
653        engine.enroll(IntrinsicEnrollment {
654            object_id: av(1),
655            algorithm: IntrinsicAlgorithm::ChangeOfValue {
656                cov_increment: 1.0,
657            },
658            notification_class: 1,
659            time_delay_ms: 0,
660        });
661
662        // First evaluation always fires (no last reported value).
663        let notifs = engine.evaluate(av(1), PropertyId::PresentValue, 50.0, None, None);
664        assert_eq!(notifs.len(), 1);
665
666        // Small change — no notification.
667        let notifs = engine.evaluate(av(1), PropertyId::PresentValue, 50.5, None, None);
668        assert!(notifs.is_empty());
669
670        // Large change — notification.
671        let notifs = engine.evaluate(av(1), PropertyId::PresentValue, 52.0, None, None);
672        assert_eq!(notifs.len(), 1);
673    }
674
675    #[test]
676    fn change_of_state_alarm() {
677        let engine = IntrinsicReportingEngine::new();
678        engine.enroll(IntrinsicEnrollment {
679            object_id: av(1),
680            algorithm: IntrinsicAlgorithm::ChangeOfState {
681                alarm_values: vec![3, 5],
682            },
683            notification_class: 1,
684            time_delay_ms: 0,
685        });
686
687        // Normal value
688        let notifs = engine.evaluate(av(1), PropertyId::PresentValue, 1.0, None, None);
689        assert!(notifs.is_empty());
690
691        // Alarm value
692        let notifs = engine.evaluate(av(1), PropertyId::PresentValue, 3.0, None, None);
693        assert_eq!(notifs.len(), 1);
694        assert_eq!(notifs[0].event_state, IntrinsicEventState::OffNormal);
695    }
696
697    #[test]
698    fn floating_limit() {
699        let engine = IntrinsicReportingEngine::new();
700        engine.enroll(IntrinsicEnrollment {
701            object_id: av(1),
702            algorithm: IntrinsicAlgorithm::FloatingLimit {
703                setpoint_ref: av(2),
704                setpoint_property: PropertyId::PresentValue,
705                high_diff_limit: 5.0,
706                low_diff_limit: 5.0,
707                deadband: 2.0,
708            },
709            notification_class: 1,
710            time_delay_ms: 0,
711        });
712
713        // Setpoint = 50, value = 56 > 50 + 5 = 55 → HighLimit
714        let notifs = engine.evaluate(av(1), PropertyId::PresentValue, 56.0, Some(50.0), None);
715        assert_eq!(notifs.len(), 1);
716        assert_eq!(notifs[0].event_state, IntrinsicEventState::HighLimit);
717    }
718
719    #[test]
720    fn command_failure() {
721        let engine = IntrinsicReportingEngine::new();
722        engine.enroll(IntrinsicEnrollment {
723            object_id: av(1),
724            algorithm: IntrinsicAlgorithm::CommandFailure {
725                feedback_ref: av(2),
726                feedback_property: PropertyId::PresentValue,
727                time_delay_ms: 0,
728            },
729            notification_class: 1,
730            time_delay_ms: 0,
731        });
732
733        // Command=50, Feedback=50 → Normal
734        let notifs = engine.evaluate(av(1), PropertyId::PresentValue, 50.0, None, Some(50.0));
735        assert!(notifs.is_empty());
736
737        // Command=50, Feedback=30 → OffNormal
738        let notifs = engine.evaluate(av(1), PropertyId::PresentValue, 50.0, None, Some(30.0));
739        assert_eq!(notifs.len(), 1);
740        assert_eq!(notifs[0].event_state, IntrinsicEventState::OffNormal);
741    }
742
743    #[test]
744    fn time_delay_pending() {
745        let engine = IntrinsicReportingEngine::new();
746        engine.enroll(IntrinsicEnrollment {
747            object_id: av(1),
748            algorithm: IntrinsicAlgorithm::OutOfRange {
749                high_limit: 100.0,
750                low_limit: 0.0,
751                deadband: 5.0,
752            },
753            notification_class: 1,
754            time_delay_ms: 10000, // 10 seconds
755        });
756
757        // Value exceeds limit but time delay hasn't elapsed.
758        let notifs = engine.evaluate(av(1), PropertyId::PresentValue, 105.0, None, None);
759        assert!(notifs.is_empty(), "should not fire before time delay");
760
761        // State should still be Normal.
762        assert_eq!(
763            engine.event_state(av(1)),
764            Some(IntrinsicEventState::Normal)
765        );
766    }
767
768    #[test]
769    fn acknowledge_transition() {
770        let engine = IntrinsicReportingEngine::new();
771        engine.enroll(IntrinsicEnrollment {
772            object_id: av(1),
773            algorithm: IntrinsicAlgorithm::OutOfRange {
774                high_limit: 100.0,
775                low_limit: 0.0,
776                deadband: 5.0,
777            },
778            notification_class: 1,
779            time_delay_ms: 0,
780        });
781
782        engine.evaluate(av(1), PropertyId::PresentValue, 105.0, None, None);
783
784        let acked = engine.acked_transitions(av(1)).unwrap();
785        assert!(!acked.to_offnormal);
786
787        engine.acknowledge(av(1), IntrinsicEventState::HighLimit);
788
789        let acked = engine.acked_transitions(av(1)).unwrap();
790        assert!(acked.to_offnormal);
791    }
792
793    #[test]
794    fn enroll_unenroll() {
795        let engine = IntrinsicReportingEngine::new();
796        engine.enroll(IntrinsicEnrollment {
797            object_id: av(1),
798            algorithm: IntrinsicAlgorithm::OutOfRange {
799                high_limit: 100.0,
800                low_limit: 0.0,
801                deadband: 5.0,
802            },
803            notification_class: 1,
804            time_delay_ms: 0,
805        });
806
807        assert!(engine.event_state(av(1)).is_some());
808        engine.unenroll(av(1));
809        assert!(engine.event_state(av(1)).is_none());
810    }
811
812    #[test]
813    fn encode_event_notification_produces_bytes() {
814        let result = encode_unconfirmed_event_notification(
815            1,
816            ObjectId::new(ObjectType::Device, 42),
817            av(1),
818            IntrinsicEventState::HighLimit,
819            1,
820            IntrinsicEventState::Normal,
821        );
822        assert!(result.is_some());
823        assert!(result.unwrap().len() > 10);
824    }
825}