Skip to main content

bacnet_objects/
event.rs

1//! Intrinsic reporting — OUT_OF_RANGE event state machine.
2//!
3//! Per ASHRAE 135-2020 Clause 13.3.2, the OUT_OF_RANGE algorithm monitors
4//! an analog present_value against HIGH_LIMIT and LOW_LIMIT with a DEADBAND
5//! to prevent oscillation at the boundary.
6
7use bacnet_types::enums::{EventState, EventType};
8
9/// A detected change in event state.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct EventStateChange {
12    /// The previous event state.
13    pub from: EventState,
14    /// The new event state.
15    pub to: EventState,
16}
17
18impl EventStateChange {
19    /// Derive the BACnet EventType from the state transition.
20    ///
21    /// If either the `from` or `to` state is `HIGH_LIMIT` or `LOW_LIMIT`,
22    /// the event type is `OUT_OF_RANGE`. Otherwise it is `CHANGE_OF_STATE`.
23    pub fn event_type(&self) -> EventType {
24        if self.from == EventState::HIGH_LIMIT
25            || self.from == EventState::LOW_LIMIT
26            || self.to == EventState::HIGH_LIMIT
27            || self.to == EventState::LOW_LIMIT
28        {
29            EventType::OUT_OF_RANGE
30        } else {
31            EventType::CHANGE_OF_STATE
32        }
33    }
34
35    /// Derive the event transition category from the state change.
36    ///
37    /// - `to == NORMAL` -> `ToNormal`
38    /// - `to == FAULT` -> `ToFault`
39    /// - Everything else (OFFNORMAL, HIGH_LIMIT, LOW_LIMIT) -> `ToOffnormal`
40    pub fn transition(&self) -> EventTransition {
41        if self.to == EventState::NORMAL {
42            EventTransition::ToNormal
43        } else if self.to == EventState::FAULT {
44            EventTransition::ToFault
45        } else {
46            EventTransition::ToOffnormal
47        }
48    }
49}
50
51/// Event transition category per ASHRAE 135-2020 Clause 13.2.5.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum EventTransition {
54    /// Transition to an off-normal state (OFFNORMAL, HIGH_LIMIT, LOW_LIMIT, etc.).
55    ToOffnormal,
56    /// Transition to FAULT.
57    ToFault,
58    /// Transition to NORMAL.
59    ToNormal,
60}
61
62impl EventTransition {
63    /// Bit mask for this transition in the `BACnetDestination.transitions` field.
64    ///
65    /// bit 0 = TO_OFFNORMAL, bit 1 = TO_FAULT, bit 2 = TO_NORMAL.
66    pub fn bit_mask(self) -> u8 {
67        match self {
68            EventTransition::ToOffnormal => 0x01,
69            EventTransition::ToFault => 0x02,
70            EventTransition::ToNormal => 0x04,
71        }
72    }
73}
74
75/// Which limits are enabled.
76///
77/// Encoded as a BACnet BIT STRING: bit 0 = low_limit_enable, bit 1 = high_limit_enable.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct LimitEnable {
80    pub low_limit_enable: bool,
81    pub high_limit_enable: bool,
82}
83
84impl LimitEnable {
85    pub const NONE: Self = Self {
86        low_limit_enable: false,
87        high_limit_enable: false,
88    };
89
90    pub const BOTH: Self = Self {
91        low_limit_enable: true,
92        high_limit_enable: true,
93    };
94
95    /// Encode as a BACnet bitstring byte (2 bits used, 6 unused).
96    pub fn to_bits(self) -> u8 {
97        let mut bits = 0u8;
98        if self.low_limit_enable {
99            bits |= 0x80; // bit 0 (MSB first)
100        }
101        if self.high_limit_enable {
102            bits |= 0x40; // bit 1
103        }
104        bits
105    }
106
107    /// Decode from a BACnet bitstring byte.
108    pub fn from_bits(byte: u8) -> Self {
109        Self {
110            low_limit_enable: byte & 0x80 != 0,
111            high_limit_enable: byte & 0x40 != 0,
112        }
113    }
114}
115
116/// OUT_OF_RANGE event detector for analog objects.
117///
118/// Implements the OUT_OF_RANGE event state machine:
119/// - NORMAL → HIGH_LIMIT when `present_value > high_limit` (if high_limit enabled)
120/// - NORMAL → LOW_LIMIT when `present_value < low_limit` (if low_limit enabled)
121/// - HIGH_LIMIT → NORMAL when `present_value < high_limit - deadband`
122/// - LOW_LIMIT → NORMAL when `present_value > low_limit + deadband`
123/// - HIGH_LIMIT → LOW_LIMIT when `present_value < low_limit`
124/// - LOW_LIMIT → HIGH_LIMIT when `present_value > high_limit`
125#[derive(Debug, Clone)]
126pub struct OutOfRangeDetector {
127    pub high_limit: f32,
128    pub low_limit: f32,
129    pub deadband: f32,
130    pub limit_enable: LimitEnable,
131    pub notification_class: u32,
132    pub notify_type: u32,
133    pub event_enable: u8,
134    pub time_delay: u32,
135    pub event_state: EventState,
136    /// Acknowledged-transitions bitfield (3 bits: TO_OFFNORMAL, TO_FAULT, TO_NORMAL).
137    /// A set bit means the corresponding transition has been acknowledged.
138    pub acked_transitions: u8,
139}
140
141impl Default for OutOfRangeDetector {
142    fn default() -> Self {
143        Self {
144            high_limit: 100.0,
145            low_limit: 0.0,
146            deadband: 1.0,
147            limit_enable: LimitEnable::NONE,
148            notification_class: 0,
149            notify_type: 0, // ALARM
150            event_enable: 0,
151            time_delay: 0,
152            event_state: EventState::NORMAL,
153            acked_transitions: 0b111, // all acknowledged by default
154        }
155    }
156}
157
158impl OutOfRangeDetector {
159    /// Event_Enable bit masks.
160    const TO_OFFNORMAL: u8 = 0x01;
161    const TO_FAULT: u8 = 0x02;
162    const TO_NORMAL: u8 = 0x04;
163
164    /// Evaluate the present value against configured limits.
165    ///
166    /// Returns `Some(EventStateChange)` if the event state changed **and**
167    /// the corresponding `event_enable` bit is set.
168    /// Internal state always updates regardless of event_enable.
169    ///
170    /// Note: This implementation uses instant transitions (ignores time_delay).
171    pub fn evaluate(&mut self, present_value: f32) -> Option<EventStateChange> {
172        let new_state = self.compute_new_state(present_value);
173        if new_state != self.event_state {
174            let change = EventStateChange {
175                from: self.event_state,
176                to: new_state,
177            };
178            self.event_state = new_state;
179
180            // Check event_enable bitmask
181            let enabled = match new_state {
182                s if s == EventState::NORMAL => self.event_enable & Self::TO_NORMAL != 0,
183                s if s == EventState::HIGH_LIMIT || s == EventState::LOW_LIMIT => {
184                    self.event_enable & Self::TO_OFFNORMAL != 0
185                }
186                _ => self.event_enable & Self::TO_FAULT != 0,
187            };
188
189            if enabled {
190                Some(change)
191            } else {
192                None
193            }
194        } else {
195            None
196        }
197    }
198
199    fn compute_new_state(&self, pv: f32) -> EventState {
200        let high_enabled = self.limit_enable.high_limit_enable;
201        let low_enabled = self.limit_enable.low_limit_enable;
202
203        match self.event_state {
204            s if s == EventState::NORMAL => {
205                // Check for HIGH_LIMIT violation first (higher priority)
206                if high_enabled && pv > self.high_limit {
207                    return EventState::HIGH_LIMIT;
208                }
209                if low_enabled && pv < self.low_limit {
210                    return EventState::LOW_LIMIT;
211                }
212                EventState::NORMAL
213            }
214            s if s == EventState::HIGH_LIMIT => {
215                // Can transition to LOW_LIMIT directly
216                if low_enabled && pv < self.low_limit {
217                    return EventState::LOW_LIMIT;
218                }
219                // Return to NORMAL with deadband
220                if pv < self.high_limit - self.deadband {
221                    return EventState::NORMAL;
222                }
223                EventState::HIGH_LIMIT
224            }
225            s if s == EventState::LOW_LIMIT => {
226                // Can transition to HIGH_LIMIT directly
227                if high_enabled && pv > self.high_limit {
228                    return EventState::HIGH_LIMIT;
229                }
230                // Return to NORMAL with deadband
231                if pv > self.low_limit + self.deadband {
232                    return EventState::NORMAL;
233                }
234                EventState::LOW_LIMIT
235            }
236            _ => self.event_state, // No change for unknown states
237        }
238    }
239}
240
241// ---------------------------------------------------------------------------
242// CHANGE_OF_STATE event detector
243// ---------------------------------------------------------------------------
244
245/// CHANGE_OF_STATE event detector for binary and multi-state objects.
246///
247/// Transitions to OFFNORMAL when the monitored value
248/// matches any value in the `alarm_values` list. Returns to NORMAL when
249/// the value no longer matches any alarm value.
250#[derive(Debug, Clone)]
251pub struct ChangeOfStateDetector {
252    /// Values that trigger an OFFNORMAL state.
253    pub alarm_values: Vec<u32>,
254    pub notification_class: u32,
255    pub notify_type: u32,
256    pub event_enable: u8,
257    pub time_delay: u32,
258    pub event_state: EventState,
259    pub acked_transitions: u8,
260}
261
262impl Default for ChangeOfStateDetector {
263    fn default() -> Self {
264        Self {
265            alarm_values: Vec::new(),
266            notification_class: 0,
267            notify_type: 0,
268            event_enable: 0,
269            time_delay: 0,
270            event_state: EventState::NORMAL,
271            acked_transitions: 0b111,
272        }
273    }
274}
275
276impl ChangeOfStateDetector {
277    const TO_OFFNORMAL: u8 = 0x01;
278    const TO_FAULT: u8 = 0x02;
279    const TO_NORMAL: u8 = 0x04;
280
281    /// Evaluate the present value against alarm_values.
282    ///
283    /// Returns `Some(EventStateChange)` if the event state changed and the
284    /// corresponding `event_enable` bit is set.
285    pub fn evaluate(&mut self, present_value: u32) -> Option<EventStateChange> {
286        let is_alarm = self.alarm_values.contains(&present_value);
287        let new_state = if is_alarm {
288            EventState::OFFNORMAL
289        } else {
290            EventState::NORMAL
291        };
292
293        if new_state != self.event_state {
294            let change = EventStateChange {
295                from: self.event_state,
296                to: new_state,
297            };
298            self.event_state = new_state;
299
300            let enabled = match new_state {
301                s if s == EventState::NORMAL => self.event_enable & Self::TO_NORMAL != 0,
302                s if s == EventState::OFFNORMAL => self.event_enable & Self::TO_OFFNORMAL != 0,
303                _ => self.event_enable & Self::TO_FAULT != 0,
304            };
305
306            if enabled {
307                Some(change)
308            } else {
309                None
310            }
311        } else {
312            None
313        }
314    }
315}
316
317/// COMMAND_FAILURE event detector for commandable output objects (BO, MSO).
318///
319/// Transitions to OFFNORMAL when present_value differs
320/// from feedback_value. Returns to NORMAL when they match.
321#[derive(Debug, Clone)]
322pub struct CommandFailureDetector {
323    pub notification_class: u32,
324    pub notify_type: u32,
325    pub event_enable: u8,
326    pub time_delay: u32,
327    pub event_state: EventState,
328    pub acked_transitions: u8,
329}
330
331impl Default for CommandFailureDetector {
332    fn default() -> Self {
333        Self {
334            notification_class: 0,
335            notify_type: 0,
336            event_enable: 0,
337            time_delay: 0,
338            event_state: EventState::NORMAL,
339            acked_transitions: 0b111,
340        }
341    }
342}
343
344impl CommandFailureDetector {
345    const TO_OFFNORMAL: u8 = 0x01;
346    #[allow(dead_code)]
347    const TO_FAULT: u8 = 0x02;
348    const TO_NORMAL: u8 = 0x04;
349
350    /// Evaluate present_value vs feedback_value.
351    ///
352    /// Returns `Some(EventStateChange)` if the event state changed.
353    pub fn evaluate(
354        &mut self,
355        present_value: u32,
356        feedback_value: u32,
357    ) -> Option<EventStateChange> {
358        let new_state = if present_value != feedback_value {
359            EventState::OFFNORMAL
360        } else {
361            EventState::NORMAL
362        };
363
364        if new_state != self.event_state {
365            let change = EventStateChange {
366                from: self.event_state,
367                to: new_state,
368            };
369            self.event_state = new_state;
370
371            let enabled = match new_state {
372                s if s == EventState::NORMAL => self.event_enable & Self::TO_NORMAL != 0,
373                s if s == EventState::OFFNORMAL => self.event_enable & Self::TO_OFFNORMAL != 0,
374                _ => false,
375            };
376
377            if enabled {
378                Some(change)
379            } else {
380                None
381            }
382        } else {
383            None
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    fn make_detector() -> OutOfRangeDetector {
393        OutOfRangeDetector {
394            high_limit: 80.0,
395            low_limit: 20.0,
396            deadband: 2.0,
397            limit_enable: LimitEnable::BOTH,
398            notification_class: 1,
399            notify_type: 0,
400            event_enable: 0x07, // all transitions
401            time_delay: 0,
402            event_state: EventState::NORMAL,
403            acked_transitions: 0b111,
404        }
405    }
406
407    #[test]
408    fn normal_stays_normal_within_limits() {
409        let mut det = make_detector();
410        assert!(det.evaluate(50.0).is_none());
411        assert_eq!(det.event_state, EventState::NORMAL);
412    }
413
414    #[test]
415    fn normal_to_high_limit() {
416        let mut det = make_detector();
417        let change = det.evaluate(81.0).unwrap();
418        assert_eq!(change.from, EventState::NORMAL);
419        assert_eq!(change.to, EventState::HIGH_LIMIT);
420        assert_eq!(det.event_state, EventState::HIGH_LIMIT);
421    }
422
423    #[test]
424    fn normal_to_low_limit() {
425        let mut det = make_detector();
426        let change = det.evaluate(19.0).unwrap();
427        assert_eq!(change.from, EventState::NORMAL);
428        assert_eq!(change.to, EventState::LOW_LIMIT);
429        assert_eq!(det.event_state, EventState::LOW_LIMIT);
430    }
431
432    #[test]
433    fn at_boundary_no_transition() {
434        let mut det = make_detector();
435        // At exactly high_limit — not exceeded, stays NORMAL
436        assert!(det.evaluate(80.0).is_none());
437        // At exactly low_limit — not below, stays NORMAL
438        assert!(det.evaluate(20.0).is_none());
439    }
440
441    #[test]
442    fn high_limit_to_normal_with_deadband() {
443        let mut det = make_detector();
444        det.evaluate(81.0); // → HIGH_LIMIT
445
446        // Still above (high_limit - deadband) = 78.0 — stay HIGH_LIMIT
447        assert!(det.evaluate(79.0).is_none());
448
449        // Drop below deadband threshold
450        let change = det.evaluate(77.0).unwrap();
451        assert_eq!(change.from, EventState::HIGH_LIMIT);
452        assert_eq!(change.to, EventState::NORMAL);
453    }
454
455    #[test]
456    fn low_limit_to_normal_with_deadband() {
457        let mut det = make_detector();
458        det.evaluate(19.0); // → LOW_LIMIT
459
460        // Still below (low_limit + deadband) = 22.0 — stay LOW_LIMIT
461        assert!(det.evaluate(21.0).is_none());
462
463        // Rise above deadband threshold
464        let change = det.evaluate(23.0).unwrap();
465        assert_eq!(change.from, EventState::LOW_LIMIT);
466        assert_eq!(change.to, EventState::NORMAL);
467    }
468
469    #[test]
470    fn high_limit_to_low_limit_direct() {
471        let mut det = make_detector();
472        det.evaluate(81.0); // → HIGH_LIMIT
473
474        // Drop directly below low_limit
475        let change = det.evaluate(19.0).unwrap();
476        assert_eq!(change.from, EventState::HIGH_LIMIT);
477        assert_eq!(change.to, EventState::LOW_LIMIT);
478    }
479
480    #[test]
481    fn low_limit_to_high_limit_direct() {
482        let mut det = make_detector();
483        det.evaluate(19.0); // → LOW_LIMIT
484
485        // Jump directly above high_limit
486        let change = det.evaluate(81.0).unwrap();
487        assert_eq!(change.from, EventState::LOW_LIMIT);
488        assert_eq!(change.to, EventState::HIGH_LIMIT);
489    }
490
491    #[test]
492    fn high_limit_disabled_no_transition() {
493        let mut det = make_detector();
494        det.limit_enable.high_limit_enable = false;
495
496        // Above high_limit but disabled — stays NORMAL
497        assert!(det.evaluate(100.0).is_none());
498    }
499
500    #[test]
501    fn low_limit_disabled_no_transition() {
502        let mut det = make_detector();
503        det.limit_enable.low_limit_enable = false;
504
505        // Below low_limit but disabled — stays NORMAL
506        assert!(det.evaluate(0.0).is_none());
507    }
508
509    #[test]
510    fn both_limits_disabled() {
511        let mut det = make_detector();
512        det.limit_enable = LimitEnable::NONE;
513        assert!(det.evaluate(100.0).is_none());
514        assert!(det.evaluate(0.0).is_none());
515    }
516
517    #[test]
518    fn limit_enable_bits_round_trip() {
519        let le = LimitEnable::BOTH;
520        let bits = le.to_bits();
521        let decoded = LimitEnable::from_bits(bits);
522        assert_eq!(decoded, le);
523
524        let le = LimitEnable {
525            low_limit_enable: true,
526            high_limit_enable: false,
527        };
528        let bits = le.to_bits();
529        let decoded = LimitEnable::from_bits(bits);
530        assert_eq!(decoded, le);
531    }
532
533    #[test]
534    fn deadband_at_exact_boundary() {
535        let mut det = make_detector();
536        det.evaluate(81.0); // → HIGH_LIMIT
537
538        // At exactly (high_limit - deadband) = 78.0 — still HIGH_LIMIT (need to be below)
539        assert!(det.evaluate(78.0).is_none());
540
541        // Just below
542        let change = det.evaluate(77.99).unwrap();
543        assert_eq!(change.to, EventState::NORMAL);
544    }
545
546    #[test]
547    fn event_state_change_derives_event_type() {
548        use bacnet_types::enums::EventType;
549
550        let change = EventStateChange {
551            from: EventState::NORMAL,
552            to: EventState::HIGH_LIMIT,
553        };
554        assert_eq!(change.event_type(), EventType::OUT_OF_RANGE);
555    }
556
557    #[test]
558    fn event_state_change_to_normal_from_high() {
559        use bacnet_types::enums::EventType;
560
561        let change = EventStateChange {
562            from: EventState::HIGH_LIMIT,
563            to: EventState::NORMAL,
564        };
565        assert_eq!(change.event_type(), EventType::OUT_OF_RANGE);
566    }
567
568    #[test]
569    fn event_enable_zero_suppresses_all_notifications() {
570        let mut det = make_detector();
571        det.event_enable = 0x00; // all disabled
572
573        // Should transition internally but return None
574        assert!(det.evaluate(81.0).is_none());
575        assert_eq!(det.event_state, EventState::HIGH_LIMIT); // state still updated
576
577        assert!(det.evaluate(50.0).is_none());
578        assert_eq!(det.event_state, EventState::NORMAL); // state still updated
579
580        assert!(det.evaluate(19.0).is_none());
581        assert_eq!(det.event_state, EventState::LOW_LIMIT); // state still updated
582    }
583
584    #[test]
585    fn event_enable_to_normal_only() {
586        let mut det = make_detector();
587        det.event_enable = 0x04; // only TO_NORMAL
588
589        // NORMAL → HIGH_LIMIT: TO_OFFNORMAL not enabled, suppressed
590        assert!(det.evaluate(81.0).is_none());
591        assert_eq!(det.event_state, EventState::HIGH_LIMIT);
592
593        // HIGH_LIMIT → NORMAL: TO_NORMAL enabled, fires
594        let change = det.evaluate(50.0).unwrap();
595        assert_eq!(change.from, EventState::HIGH_LIMIT);
596        assert_eq!(change.to, EventState::NORMAL);
597
598        // NORMAL → LOW_LIMIT: TO_OFFNORMAL not enabled, suppressed
599        assert!(det.evaluate(19.0).is_none());
600        assert_eq!(det.event_state, EventState::LOW_LIMIT);
601
602        // LOW_LIMIT → NORMAL: TO_NORMAL enabled, fires
603        let change = det.evaluate(50.0).unwrap();
604        assert_eq!(change.from, EventState::LOW_LIMIT);
605        assert_eq!(change.to, EventState::NORMAL);
606    }
607
608    #[test]
609    fn event_enable_to_offnormal_only() {
610        let mut det = make_detector();
611        det.event_enable = 0x01; // only TO_OFFNORMAL
612
613        // NORMAL → HIGH_LIMIT: TO_OFFNORMAL enabled, fires
614        let change = det.evaluate(81.0).unwrap();
615        assert_eq!(change.to, EventState::HIGH_LIMIT);
616
617        // HIGH_LIMIT → NORMAL: TO_NORMAL not enabled, suppressed
618        assert!(det.evaluate(50.0).is_none());
619        assert_eq!(det.event_state, EventState::NORMAL);
620    }
621
622    #[test]
623    fn event_state_change_generic() {
624        use bacnet_types::enums::EventType;
625
626        let change = EventStateChange {
627            from: EventState::NORMAL,
628            to: EventState::NORMAL,
629        };
630        assert_eq!(change.event_type(), EventType::CHANGE_OF_STATE);
631    }
632
633    // --- ChangeOfStateDetector tests ---
634
635    #[test]
636    fn cos_normal_when_no_alarm_values() {
637        let mut det = ChangeOfStateDetector {
638            event_enable: 0x07,
639            ..Default::default()
640        };
641        assert!(det.evaluate(0).is_none()); // empty alarm_values → always NORMAL
642    }
643
644    #[test]
645    fn cos_normal_to_offnormal() {
646        let mut det = ChangeOfStateDetector {
647            alarm_values: vec![1], // ACTIVE (1) is alarm
648            event_enable: 0x07,
649            ..Default::default()
650        };
651        let change = det.evaluate(1).unwrap();
652        assert_eq!(change.from, EventState::NORMAL);
653        assert_eq!(change.to, EventState::OFFNORMAL);
654    }
655
656    #[test]
657    fn cos_offnormal_to_normal() {
658        let mut det = ChangeOfStateDetector {
659            alarm_values: vec![1],
660            event_enable: 0x07,
661            ..Default::default()
662        };
663        det.evaluate(1); // → OFFNORMAL
664        let change = det.evaluate(0).unwrap(); // back to NORMAL
665        assert_eq!(change.from, EventState::OFFNORMAL);
666        assert_eq!(change.to, EventState::NORMAL);
667    }
668
669    #[test]
670    fn cos_stays_offnormal_while_in_alarm() {
671        let mut det = ChangeOfStateDetector {
672            alarm_values: vec![1],
673            event_enable: 0x07,
674            ..Default::default()
675        };
676        det.evaluate(1); // → OFFNORMAL
677        assert!(det.evaluate(1).is_none()); // still alarm value, no change
678    }
679
680    #[test]
681    fn cos_multistate_alarm_values() {
682        let mut det = ChangeOfStateDetector {
683            alarm_values: vec![3, 5, 7], // multiple alarm states
684            event_enable: 0x07,
685            ..Default::default()
686        };
687        assert!(det.evaluate(1).is_none()); // not an alarm state
688        let change = det.evaluate(5).unwrap();
689        assert_eq!(change.to, EventState::OFFNORMAL);
690        assert!(det.evaluate(3).is_none()); // still offnormal (different alarm value)
691        let change = det.evaluate(2).unwrap();
692        assert_eq!(change.to, EventState::NORMAL);
693    }
694
695    // --- CommandFailureDetector tests ---
696
697    #[test]
698    fn cmdfail_matching_stays_normal() {
699        let mut det = CommandFailureDetector {
700            event_enable: 0x07,
701            ..Default::default()
702        };
703        assert!(det.evaluate(1, 1).is_none()); // present == feedback
704    }
705
706    #[test]
707    fn cmdfail_mismatch_goes_offnormal() {
708        let mut det = CommandFailureDetector {
709            event_enable: 0x07,
710            ..Default::default()
711        };
712        let change = det.evaluate(1, 0).unwrap(); // present != feedback
713        assert_eq!(change.to, EventState::OFFNORMAL);
714    }
715
716    #[test]
717    fn cmdfail_match_restores_normal() {
718        let mut det = CommandFailureDetector {
719            event_enable: 0x07,
720            ..Default::default()
721        };
722        det.evaluate(1, 0); // → OFFNORMAL
723        let change = det.evaluate(1, 1).unwrap(); // match → NORMAL
724        assert_eq!(change.to, EventState::NORMAL);
725    }
726}