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.6 (Table 13-7), 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 crate::prelude::*;
8use core::ops::ControlFlow;
9
10use bacnet_types::bitstring::{EventTransitionBits, LimitEnable};
11use bacnet_types::enums::{EventState, EventType, NotifyType, Reliability};
12use bacnet_types::primitives::BACnetTimeStamp;
13
14pub(crate) mod history;
15pub(crate) mod options;
16pub(crate) mod state_reporting;
17
18/// A detected change in event state.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct EventStateChange {
21    /// The previous event state.
22    pub from: EventState,
23    /// The new event state.
24    pub to: EventState,
25}
26
27/// A fire-ready transition and its external distribution policy.
28///
29/// ASHRAE 135-2020 Clause 13.2.2.1.4 attaches four effects to every
30/// transition: `Event_State` takes the new state, `Event_Time_Stamps` records
31/// when it happened, `Event_Message_Texts` (on objects that have it) records
32/// the message, and both alarm-acknowledgment and notification distribution
33/// are told about it. `Event_Enable` gates none of the four; it only turns off
34/// external distribution further downstream (Clause 13.2.5), so a cleared bit
35/// must not suppress the three property updates, nor the alarm-acknowledgment
36/// part of the hand-off.
37///
38/// The object intrinsic-reporting hooks return this value as an uncommitted
39/// proposal. The server commits object-owned state and history before considering
40/// distribution, including when `distribute == false`. A failed commit leaves the
41/// proposal retryable. The value alone does not prove a committed object transition.
42/// Standalone detector `probe` and `tick` methods may finalize detector-local state
43/// according to their own contracts; they do not commit object event history.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct TransitionOutcome {
46    /// The proposed transition, to be committed regardless of `distribute`.
47    pub change: EventStateChange,
48    /// The Event Type selected by the detector's event algorithm, with the
49    /// mandatory `CHANGE_OF_RELIABILITY` override for transitions to/from FAULT.
50    pub event_type: EventType,
51    /// The `Event_Enable` bit for this transition's direction.
52    pub distribute: bool,
53}
54
55impl EventStateChange {
56    /// Select the notification's Event Type for this transition.
57    ///
58    /// ASHRAE 135-2020 Clause 13.2.5.3 requires
59    /// `CHANGE_OF_RELIABILITY` for every transition to or from FAULT.
60    /// Any other transition reports the Event Type that belongs to the
61    /// object's own configured algorithm (Clauses 13.8.1.1 and 13.9.1.1),
62    /// which the caller passes as `algorithm`.
63    pub fn event_type(&self, algorithm: EventType) -> EventType {
64        if self.from == EventState::FAULT || self.to == EventState::FAULT {
65            EventType::CHANGE_OF_RELIABILITY
66        } else {
67            algorithm
68        }
69    }
70
71    /// Derive the event transition category from the state change.
72    ///
73    /// - `to == NORMAL` -> `ToNormal`
74    /// - `to == FAULT` -> `ToFault`
75    /// - Everything else (OFFNORMAL, HIGH_LIMIT, LOW_LIMIT) -> `ToOffnormal`
76    pub fn transition(&self) -> EventTransition {
77        EventTransition::for_target_state(self.to)
78    }
79}
80
81/// Event transition category per ASHRAE 135-2020 Clause 13.2.5.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum EventTransition {
84    /// Transition to an off-normal state (OFFNORMAL, HIGH_LIMIT, LOW_LIMIT, etc.).
85    ToOffnormal,
86    /// Transition to FAULT.
87    ToFault,
88    /// Transition to NORMAL.
89    ToNormal,
90}
91
92/// Object-owned GetEnrollmentSummary event capability.
93///
94/// This internal projection identifies the object's one configured or implied
95/// event algorithm and the coordinate of its latest successful commit. It does
96/// not expose detector state or treat the CHANGE_OF_RELIABILITY notification
97/// override as a second event algorithm.
98#[doc(hidden)]
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct EnrollmentSummaryCapability {
101    /// Configured or object-type-implied event algorithm.
102    pub event_type: EventType,
103    /// Latest successfully committed transition coordinate.
104    pub last_transition: Option<EventTransition>,
105}
106
107impl EventTransition {
108    /// Classify a destination event state under ASHRAE 135-2020 Clause 13.2.
109    ///
110    /// NORMAL and FAULT are the only states outside the offnormal category,
111    /// as also specified by Clause 13.2.2.1.2. Therefore the residual case
112    /// is deliberately TO_OFFNORMAL, not TO_FAULT.
113    pub fn for_target_state(state: EventState) -> Self {
114        if state == EventState::NORMAL {
115            Self::ToNormal
116        } else if state == EventState::FAULT {
117            Self::ToFault
118        } else {
119            Self::ToOffnormal
120        }
121    }
122
123    /// This transition's flag in a `BACnetEventTransitionBits` value, such as
124    /// `Event_Enable`, `Acked_Transitions`, `Ack_Required` or a
125    /// `BACnetDestination`'s transitions.
126    pub fn bit_mask(self) -> EventTransitionBits {
127        match self {
128            EventTransition::ToOffnormal => EventTransitionBits::TO_OFFNORMAL,
129            EventTransition::ToFault => EventTransitionBits::TO_FAULT,
130            EventTransition::ToNormal => EventTransitionBits::TO_NORMAL,
131        }
132    }
133
134    /// Positional index into the NotificationClass `PRIORITY` array, ordered
135    /// `[TO_OFFNORMAL, TO_FAULT, TO_NORMAL]`. `ACK_REQUIRED` is a bit string:
136    /// test it with [`EventTransition::bit_mask`].
137    pub fn index(self) -> usize {
138        match self {
139            EventTransition::ToOffnormal => 0,
140            EventTransition::ToFault => 1,
141            EventTransition::ToNormal => 2,
142        }
143    }
144}
145
146/// All object-owned values needed to commit one event-state transition.
147///
148/// Callers stage the timestamp and optional message before invoking an
149/// object's internal commit hook. The object then validates the transition
150/// coordinate and source state before changing any of its event properties.
151#[derive(Debug, Clone, PartialEq)]
152pub struct EventTransitionCommit {
153    /// The expected source state and exact destination state.
154    pub change: EventStateChange,
155    /// The destination state's transition coordinate.
156    pub coordinate: EventTransition,
157    /// Whether the referenced Notification Class requires acknowledgment.
158    pub ack_required: bool,
159    /// The timestamp CHOICE to store for `coordinate`.
160    pub timestamp: BACnetTimeStamp,
161    /// Replacement message for `coordinate`, or `None` when the property is absent.
162    pub message_text: Option<String>,
163}
164
165/// Failure to atomically commit an event-state transition.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum EventTransitionCommitError {
168    /// The object does not implement the internal transition-commit channel.
169    Unsupported,
170    /// The supplied coordinate does not classify the exact destination state.
171    CoordinateTargetMismatch {
172        /// The coordinate supplied by the caller.
173        coordinate: EventTransition,
174        /// The exact destination state supplied by the caller.
175        target: EventState,
176    },
177    /// The object's current state no longer matches the staged source state.
178    CurrentStateMismatch {
179        /// The source state expected by the staged transition.
180        expected: EventState,
181        /// The object's current state when the commit was attempted.
182        actual: EventState,
183    },
184}
185
186/// Pending (delayed) intrinsic-reporting transition state, shared by every
187/// detector that honors [`OutOfRangeDetector::time_delay`] and its peers.
188///
189/// Per ASHRAE 135-2020 Clause 13.3, a transition to a new `EventState` is
190/// delayed by `Time_Delay` seconds. While the delay counts down the
191/// observable `event_state` stays at the *old* (confirmed) state; if the
192/// triggering condition clears before the delay elapses the pending
193/// transition is cancelled and no notification is sent.
194///
195/// The countdown advances once per elapsed wall-clock second via
196/// the detector's `tick` (for example [`OutOfRangeDetector::tick`]), never per `probe` call — so a fast poll
197/// loop writing the same out-of-range value cannot shorten the delay.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct PendingTransition {
200    /// The state the detector wants to transition to once the delay elapses.
201    pub state: EventState,
202    /// Seconds remaining; seeded with the direction-appropriate delay (see
203    /// `delay_toward`) and decremented per tick.
204    pub remaining: u32,
205}
206
207impl PendingTransition {
208    /// Begin a pending transition to `state`, seeded with `delay` seconds.
209    fn seed(state: EventState, delay: u32) -> Self {
210        Self {
211            state,
212            remaining: delay,
213        }
214    }
215}
216
217/// Select the delay governing a transition toward `target`.
218///
219/// ASHRAE 135-2020 Clause 13.3 gives the event algorithms two independent
220/// delays. pTimeDelay measures the required persistence of an offnormal
221/// condition in seconds before indicating that state: it governs
222/// every indication into an OFFNORMAL state, including offnormal→offnormal
223/// re-indication (CHANGE_OF_STATE (c) at 13.3.2, OUT_OF_RANGE (d)/(g) at
224/// 13.3.6, COMMAND_FAILURE (a) at 13.3.4). pTimeDelayNormal measures the
225/// required persistence of a Normal condition in seconds and gates only the
226/// sustained-condition return to
227/// NORMAL (CHANGE_OF_STATE (b), COMMAND_FAILURE (b), OUT_OF_RANGE (e)/(h)).
228///
229/// An absent pTimeDelayNormal uses pTimeDelay as its fallback, so `None`
230/// behaves exactly as `time_delay`, never as an error or a zero.
231///
232/// FAULT never reaches this selector: Clause 13.2.2 fault precedence runs
233/// ahead of the event algorithm and carries no delay term.
234fn delay_toward(time_delay: u32, time_delay_normal: Option<u32>, target: EventState) -> u32 {
235    if target == EventState::NORMAL {
236        time_delay_normal.unwrap_or(time_delay)
237    } else {
238        time_delay
239    }
240}
241
242/// What an algorithm proposes while Event_Algorithm_Inhibit is TRUE, once
243/// fault precedence has had its say (Clauses 13.2.2.1 and 13.2.2.1.5): its
244/// own result is ignored and any countdown dropped, so a condition has to
245/// last its whole delay again once the inhibit clears, and an offnormal
246/// state goes back to NORMAL at once. `None` when already NORMAL.
247fn inhibited_target(
248    event_state: EventState,
249    pending: &mut Option<PendingTransition>,
250) -> Option<EventState> {
251    *pending = None;
252    (event_state != EventState::NORMAL).then_some(EventState::NORMAL)
253}
254
255/// What Clause 13.2.2's fault-precedence rule dictates for a single evaluation.
256///
257/// ASHRAE 135-2020 Clause 13.2.2 assigns normal/offnormal selection to the
258/// event algorithm and fault selection to Reliability. A Reliability value
259/// unequal to NO_FAULT_DETECTED overrides normal/offnormal detection,
260/// making the object's event state FAULT.
261///
262/// **Whether FAULT holds is a standing condition; whether a transition fires is
263/// an edge.** Clause 13.2.2.1 ties the Fault state to a reliability-evaluation
264/// result unequal to NO_FAULT_DETECTED, so
265/// the FAULT determination is re-derived from `reliability` on every evaluation
266/// and is never latched. But the same clause's ToFault transition requires a
267/// **change** in Reliability, which is an edge and cannot be derived from
268/// the current value alone. That is what `fault_reliability` stores: the value in
269/// force at the last entry to FAULT, and nothing else.
270///
271/// The distinction matters because the fault proposal runs at the head of both
272/// detector paths, and the server drives the periodic path once per second.
273/// Deciding re-entry from the standing condition rather than the edge would
274/// emit a notification every second for as long as any object stayed faulted.
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub(crate) enum FaultPrecedence {
277    /// Reliability is bad and FAULT does not hold yet: transition immediately.
278    ///
279    /// Clause 13.2.2.1 puts no delay on ToFault: the moment reliability
280    /// evaluation yields any value other than NO_FAULT_DETECTED, the object
281    /// goes to Fault and the usual transition actions run. `Time_Delay` belongs
282    /// to the event algorithm (Clause 13.3.1 uses pTimeDelay to require sustained
283    /// offnormal conditions before an indication), and
284    /// the algorithm is precisely what fault detection takes precedence over.
285    EnterFault,
286    /// Reliability is bad, **unchanged**, and FAULT already holds: the standing
287    /// condition is satisfied, so no transition fires — and the algorithm must
288    /// not run. [`FaultPrecedence::ReenterFault`] takes precedence when the value
289    /// differs; this variant is only the unchanged case.
290    HoldFault,
291    /// Reliability changed while FAULT already holds: execute the transition
292    /// actions and re-enter FAULT.
293    ///
294    /// In Clause 13.2.2.1's state machine a Reliability change while in Fault
295    /// is itself a ToFault transition, provided the new value is still a fault
296    /// (not NO_FAULT_DETECTED), so the transition actions run again.
297    ///
298    /// Also selected when FAULT holds with no recorded value — a state this
299    /// crate never produces but a downstream implementor can construct, since
300    /// both fields are public. See the comment on that match arm.
301    ReenterFault,
302    /// Reliability recovered while in FAULT.
303    ///
304    /// Clause 13.2.2.1 leaves Fault for Normal, with its transition actions, as
305    /// soon as reliability evaluation is back to NO_FAULT_DETECTED.
306    /// **NORMAL specifically —
307    /// not a state re-derived from the event algorithm.** Recovering straight
308    /// into HIGH_LIMIT because the present value is still out of range would
309    /// invent a transition the state machine does not define; the algorithm gets
310    /// to move the object out of NORMAL afterwards, under its own conditions and
311    /// its own `Time_Delay`.
312    RecoverToNormal,
313    /// No fault in play; the event algorithm determines the state.
314    RunAlgorithm,
315}
316
317/// Apply Clause 13.2.2's fault-precedence rule to one evaluation.
318///
319/// Shared by every detector so the rule has a single definition: a detector
320/// added later that forgets to consult it is a visible omission rather than a
321/// silently missing clause.
322pub(crate) fn fault_precedence(
323    reliability: Reliability,
324    fault_reliability: Option<Reliability>,
325    current: EventState,
326) -> FaultPrecedence {
327    let faulted = reliability != Reliability::NO_FAULT_DETECTED;
328    match (faulted, current == EventState::FAULT, fault_reliability) {
329        (true, false, _) => FaultPrecedence::EnterFault,
330        (true, true, Some(previous)) if previous == reliability => FaultPrecedence::HoldFault,
331        (true, true, Some(_)) => FaultPrecedence::ReenterFault,
332        // In FAULT with no recorded value. This breaks the field's invariant
333        // (`Some` exactly while FAULT holds) and no path in this crate produces
334        // it, but both `event_state` and `fault_reliability` are public on a
335        // published crate, so a downstream implementor can construct it.
336        //
337        // Re-entering is the deliberate choice, because it is self-healing:
338        // `ReenterFault` stores `Some(reliability)` and the invariant holds from
339        // then on, at a cost of one transition that may report no real change.
340        // Holding would be worse than it looks — it stores nothing, so the field
341        // would stay `None` forever and every later genuine change would land
342        // back on this arm and hold again, permanently disabling re-entry for
343        // that detector.
344        (true, true, None) => FaultPrecedence::ReenterFault,
345        (false, true, _) => FaultPrecedence::RecoverToNormal,
346        (false, false, _) => FaultPrecedence::RunAlgorithm,
347    }
348}
349
350/// OUT_OF_RANGE event detector for analog objects.
351///
352/// Implements the OUT_OF_RANGE event state machine:
353/// - NORMAL → HIGH_LIMIT when `present_value > high_limit` (if high_limit enabled)
354/// - NORMAL → LOW_LIMIT when `present_value < low_limit` (if low_limit enabled)
355/// - HIGH_LIMIT → NORMAL when `present_value < high_limit - deadband`
356/// - LOW_LIMIT → NORMAL when `present_value > low_limit + deadband`
357/// - HIGH_LIMIT → LOW_LIMIT when `present_value < low_limit`
358/// - LOW_LIMIT → HIGH_LIMIT when `present_value > high_limit`
359///
360/// `Time_Delay` (and `Time_Delay_Normal` for the NORMAL direction) is
361/// honored via the split [`Self::probe`] / [`Self::tick`] entry points: a
362/// present-value write calls `probe`, which seeds a pending transition (or
363/// fires immediately when the direction-appropriate delay is zero,
364/// `delay_toward`); a one-second periodic task calls `tick` to advance the
365/// countdown and fire on expiry.
366#[derive(Debug, Clone)]
367pub struct OutOfRangeDetector {
368    /// High_Limit; a present value above it is a HIGH_LIMIT condition.
369    pub high_limit: f32,
370    /// Low_Limit; a present value below it is a LOW_LIMIT condition.
371    pub low_limit: f32,
372    /// Deadband applied when returning toward NORMAL, in the monitored value's units.
373    pub deadband: f32,
374    /// Which of the two limits are checked.
375    pub limit_enable: LimitEnable,
376    /// Instance number of the Notification Class object that distributes the events.
377    pub notification_class: u32,
378    /// Notify_Type: whether the transitions are reported as alarms or as events.
379    pub notify_type: NotifyType,
380    /// Event_Enable: the transitions whose notifications are distributed.
381    pub event_enable: EventTransitionBits,
382    /// Seconds an offnormal condition must persist before TO_OFFNORMAL fires, and the NORMAL
383    /// delay too when `time_delay_normal` is `None`; 0 fires immediately. Fault transitions are
384    /// never delayed.
385    pub time_delay: u32,
386    /// `Time_Delay_Normal` (property 356): the Clause 13.3.6 pTimeDelayNormal
387    /// parameter — the number of seconds a return to normal has to be
388    /// sustained before the detector confirms it. `None` is the not-configured case
389    /// and uses `time_delay` as the fallback required for absent pTimeDelayNormal.
390    pub time_delay_normal: Option<u32>,
391    /// Current event state.
392    pub event_state: EventState,
393    /// Acked_Transitions: a set flag means that transition was acknowledged.
394    pub acked_transitions: EventTransitionBits,
395    /// Pending delayed transition, or `None` when no delay is in progress.
396    pub pending: Option<PendingTransition>,
397    /// Reliability value in force at the last entry to FAULT; `None` outside FAULT.
398    pub fault_reliability: Option<Reliability>,
399}
400
401impl Default for OutOfRangeDetector {
402    fn default() -> Self {
403        Self {
404            high_limit: 100.0,
405            low_limit: 0.0,
406            deadband: 1.0,
407            limit_enable: LimitEnable::empty(),
408            notification_class: 0,
409            notify_type: NotifyType::ALARM,
410            event_enable: EventTransitionBits::empty(),
411            time_delay: 0,
412            time_delay_normal: None,
413            event_state: EventState::NORMAL,
414            acked_transitions: EventTransitionBits::all(), // all acknowledged by default
415            pending: None,
416            fault_reliability: None,
417        }
418    }
419}
420
421impl OutOfRangeDetector {
422    /// BACnet Event Type corresponding to this detector's event algorithm.
423    pub const ALGORITHM: EventType = EventType::OUT_OF_RANGE;
424
425    /// Evaluate the present value against configured limits.
426    ///
427    /// This is the per-write entry point: it seeds a pending delayed
428    /// transition (or fires immediately when the direction-appropriate delay
429    /// is zero) but never advances the countdown — so repeated writes to the
430    /// same value do not shorten the delay. Returns `Some(TransitionOutcome)`
431    /// whenever a transition fires; the outcome's `distribute` flag carries
432    /// the `event_enable` bit rather than withholding the transition.
433    pub fn evaluate(
434        &mut self,
435        present_value: f32,
436        reliability: Reliability,
437    ) -> Option<TransitionOutcome> {
438        self.probe(present_value, reliability)
439    }
440
441    /// Apply Clause 13.2.2 fault precedence ahead of the event algorithm.
442    ///
443    /// `Break` means reliability governed this evaluation and the algorithm must
444    /// not run; the payload is the transition it produced, if any. `Continue`
445    /// hands the decision to the algorithm.
446    ///
447    /// Each detector carries its own copy because each reaches its own
448    /// `event_state`, `pending`, and confirmation; the clause interpretation
449    /// they share lives once, in [`fault_precedence`].
450    fn fault_proposal(&self, reliability: Reliability) -> ControlFlow<Option<TransitionOutcome>> {
451        match fault_precedence(reliability, self.fault_reliability, self.event_state) {
452            FaultPrecedence::EnterFault | FaultPrecedence::ReenterFault => {
453                ControlFlow::Break(self.proposal(EventState::FAULT))
454            }
455            FaultPrecedence::HoldFault => ControlFlow::Break(None),
456            FaultPrecedence::RecoverToNormal => {
457                ControlFlow::Break(self.proposal(EventState::NORMAL))
458            }
459            FaultPrecedence::RunAlgorithm => ControlFlow::Continue(()),
460        }
461    }
462
463    /// Per-write probe: seed or cancel a pending transition, fire on zero delay.
464    ///
465    /// When the direction-appropriate delay (`delay_toward`) is zero the
466    /// transition is confirmed immediately and `event_state` is updated,
467    /// preserving the legacy instant-transition behavior. Otherwise a
468    /// [`PendingTransition`] is seeded (or cleared if the condition
469    /// reverted) and `None` is returned; the periodic [`Self::tick`]
470    /// advances and eventually confirms it.
471    pub fn probe(
472        &mut self,
473        present_value: f32,
474        reliability: Reliability,
475    ) -> Option<TransitionOutcome> {
476        let outcome = self.propose(present_value, reliability, false);
477        if let Some(ref outcome) = outcome {
478            self.confirm_transition(&outcome.change, reliability);
479        }
480        outcome
481    }
482
483    /// Stage a per-write transition without confirming any transition-owned state.
484    pub(crate) fn propose(
485        &mut self,
486        present_value: f32,
487        reliability: Reliability,
488        inhibited: bool,
489    ) -> Option<TransitionOutcome> {
490        if let ControlFlow::Break(result) = self.fault_proposal(reliability) {
491            return result;
492        }
493        if inhibited {
494            return inhibited_target(self.event_state, &mut self.pending)
495                .and_then(|target| self.proposal(target));
496        }
497        let desired = self.compute_new_state(present_value);
498        if desired == self.event_state {
499            // Condition reverted to the confirmed state: cancel any pending
500            // transition without firing or notifying.
501            self.pending = None;
502            return None;
503        }
504        let delay = delay_toward(self.time_delay, self.time_delay_normal, desired);
505        if delay == 0 {
506            return self.proposal(desired);
507        }
508        // Nonzero delay: seed a pending transition only when there is none to
509        // the same target. A redundant write of the same qualifying value must
510        // NOT restart the countdown (ASHRAE 135-2020 Clause 13.3 times the
511        // delay from when the condition began to hold); re-seeding here would
512        // let writes faster than the 1s tick pin the transition forever. The
513        // periodic `tick` advances it.
514        if self.pending.as_ref().is_none_or(|p| p.state != desired) {
515            self.pending = Some(PendingTransition::seed(desired, delay));
516        }
517        None
518    }
519
520    /// Periodic tick: advance the countdown and fire on expiry.
521    ///
522    /// Returns `Some(TransitionOutcome)` when the pending transition's delay
523    /// elapses this tick, or `None` if still counting down / no pending
524    /// transition / the condition reverted (which cancels the pending).
525    pub fn tick(
526        &mut self,
527        present_value: f32,
528        reliability: Reliability,
529    ) -> Option<TransitionOutcome> {
530        let outcome = self.tick_proposal(present_value, reliability, false);
531        if let Some(ref outcome) = outcome {
532            self.confirm_transition(&outcome.change, reliability);
533        }
534        outcome
535    }
536
537    /// Advance a pending countdown, leaving a fire-ready transition retryable.
538    pub(crate) fn tick_proposal(
539        &mut self,
540        present_value: f32,
541        reliability: Reliability,
542        inhibited: bool,
543    ) -> Option<TransitionOutcome> {
544        if let ControlFlow::Break(result) = self.fault_proposal(reliability) {
545            return result;
546        }
547        if inhibited {
548            return inhibited_target(self.event_state, &mut self.pending)
549                .and_then(|target| self.proposal(target));
550        }
551        let desired = self.compute_new_state(present_value);
552        if desired == self.event_state {
553            self.pending = None;
554            return None;
555        }
556        match &mut self.pending {
557            Some(p) if p.state == desired => {
558                if p.remaining > 1 {
559                    p.remaining -= 1;
560                    return None;
561                }
562                self.proposal(desired)
563            }
564            _ => {
565                // Condition changed target mid-delay, or no pending yet: re-seed
566                // with the delay for the CURRENT target's direction.
567                let delay = delay_toward(self.time_delay, self.time_delay_normal, desired);
568                self.pending = Some(PendingTransition::seed(desired, delay));
569                None
570            }
571        }
572    }
573
574    /// Confirm a transition to `new_state`: mutate `event_state` and report the
575    /// transition along with whether `Event_Enable` permits distributing it.
576    ///
577    /// The transition is always reported. A cleared `Event_Enable` bit sets
578    /// `distribute` to false; it does not withhold the transition, because
579    /// none of Clause 13.2.2.1.4's transition actions are `Event_Enable`-scoped
580    /// — the property disables external distribution downstream, inside the
581    /// event-notification-distribution process (Clause 13.2.5, and Clause 12.12
582    /// which defines the property in those terms).
583    fn proposal(&self, new_state: EventState) -> Option<TransitionOutcome> {
584        let change = EventStateChange {
585            from: self.event_state,
586            to: new_state,
587        };
588        let transition_bit = EventTransition::for_target_state(new_state).bit_mask();
589        let distribute = self.event_enable.contains(transition_bit);
590        let event_type = change.event_type(Self::ALGORITHM);
591        Some(TransitionOutcome {
592            change,
593            event_type,
594            distribute,
595        })
596    }
597
598    /// Finalize detector-local state only after the object commit kernel succeeds.
599    pub(crate) fn confirm_transition(
600        &mut self,
601        change: &EventStateChange,
602        reliability: Reliability,
603    ) {
604        self.event_state = change.to;
605        self.pending = None;
606        self.fault_reliability = if change.to == EventState::FAULT {
607            Some(reliability)
608        } else {
609            None
610        };
611    }
612
613    fn compute_new_state(&self, pv: f32) -> EventState {
614        let high_enabled = self.limit_enable.contains(LimitEnable::HIGH_LIMIT_ENABLE);
615        let low_enabled = self.limit_enable.contains(LimitEnable::LOW_LIMIT_ENABLE);
616
617        match self.event_state {
618            s if s == EventState::NORMAL => {
619                // Check for HIGH_LIMIT violation first (higher priority)
620                if high_enabled && pv > self.high_limit {
621                    return EventState::HIGH_LIMIT;
622                }
623                if low_enabled && pv < self.low_limit {
624                    return EventState::LOW_LIMIT;
625                }
626                EventState::NORMAL
627            }
628            s if s == EventState::HIGH_LIMIT => {
629                // Can transition to LOW_LIMIT directly
630                if low_enabled && pv < self.low_limit {
631                    return EventState::LOW_LIMIT;
632                }
633                // Return to NORMAL with deadband
634                if pv < self.high_limit - self.deadband {
635                    return EventState::NORMAL;
636                }
637                EventState::HIGH_LIMIT
638            }
639            s if s == EventState::LOW_LIMIT => {
640                // Can transition to HIGH_LIMIT directly
641                if high_enabled && pv > self.high_limit {
642                    return EventState::HIGH_LIMIT;
643                }
644                // Return to NORMAL with deadband
645                if pv > self.low_limit + self.deadband {
646                    return EventState::NORMAL;
647                }
648                EventState::LOW_LIMIT
649            }
650            _ => self.event_state, // No change for unknown states
651        }
652    }
653}
654
655// ---------------------------------------------------------------------------
656// CHANGE_OF_STATE event detector
657// ---------------------------------------------------------------------------
658
659/// CHANGE_OF_STATE event detector for binary and multi-state objects.
660///
661/// Transitions to OFFNORMAL when the monitored value
662/// matches any value in the `alarm_values` list. Returns to NORMAL when
663/// the value no longer matches any alarm value.
664///
665/// `Time_Delay` is honored via the split [`Self::probe`] / [`Self::tick`]
666/// entry points; see [`OutOfRangeDetector`] for the delay contract.
667#[derive(Debug, Clone)]
668pub struct ChangeOfStateDetector {
669    /// Values that trigger an OFFNORMAL state.
670    pub alarm_values: Vec<u32>,
671    /// Instance number of the Notification Class object that distributes the events.
672    pub notification_class: u32,
673    /// Notify_Type: whether the transitions are reported as alarms or as events.
674    pub notify_type: NotifyType,
675    /// Event_Enable: the transitions whose notifications are distributed.
676    pub event_enable: EventTransitionBits,
677    /// Seconds an offnormal condition must persist before TO_OFFNORMAL fires, and the NORMAL
678    /// delay too when `time_delay_normal` is `None`; 0 fires immediately. Fault transitions are
679    /// never delayed.
680    pub time_delay: u32,
681    /// `Time_Delay_Normal` (property 356): the Clause 13.3.2 pTimeDelayNormal
682    /// parameter — the number of seconds a return to normal has to be
683    /// sustained before the detector confirms it. `None` is the not-configured case
684    /// and uses `time_delay` as the fallback required for absent pTimeDelayNormal.
685    pub time_delay_normal: Option<u32>,
686    /// Current event state.
687    pub event_state: EventState,
688    /// Acked_Transitions: a set flag means that transition was acknowledged.
689    pub acked_transitions: EventTransitionBits,
690    /// Pending delayed transition, or `None` when no delay is in progress.
691    pub pending: Option<PendingTransition>,
692    /// Reliability value in force at the last entry to FAULT; `None` outside FAULT.
693    pub fault_reliability: Option<Reliability>,
694}
695
696impl Default for ChangeOfStateDetector {
697    fn default() -> Self {
698        Self {
699            alarm_values: Vec::new(),
700            notification_class: 0,
701            notify_type: NotifyType::ALARM,
702            event_enable: EventTransitionBits::empty(),
703            time_delay: 0,
704            time_delay_normal: None,
705            event_state: EventState::NORMAL,
706            acked_transitions: EventTransitionBits::all(),
707            pending: None,
708            fault_reliability: None,
709        }
710    }
711}
712
713impl ChangeOfStateDetector {
714    /// BACnet Event Type corresponding to this detector's event algorithm.
715    pub const ALGORITHM: EventType = EventType::CHANGE_OF_STATE;
716
717    /// Per-write entry point; see [`OutOfRangeDetector::evaluate`].
718    pub fn evaluate(
719        &mut self,
720        present_value: u32,
721        reliability: Reliability,
722    ) -> Option<TransitionOutcome> {
723        self.probe(present_value, reliability)
724    }
725
726    /// Clause 13.2.2 fault precedence; see [`OutOfRangeDetector::fault_proposal`].
727    fn fault_proposal(&self, reliability: Reliability) -> ControlFlow<Option<TransitionOutcome>> {
728        match fault_precedence(reliability, self.fault_reliability, self.event_state) {
729            FaultPrecedence::EnterFault | FaultPrecedence::ReenterFault => {
730                ControlFlow::Break(self.proposal(EventState::FAULT))
731            }
732            FaultPrecedence::HoldFault => ControlFlow::Break(None),
733            FaultPrecedence::RecoverToNormal => {
734                ControlFlow::Break(self.proposal(EventState::NORMAL))
735            }
736            FaultPrecedence::RunAlgorithm => ControlFlow::Continue(()),
737        }
738    }
739
740    /// Per-write probe: seed or cancel a pending transition, fire on zero delay.
741    pub fn probe(
742        &mut self,
743        present_value: u32,
744        reliability: Reliability,
745    ) -> Option<TransitionOutcome> {
746        let outcome = self.propose(present_value, reliability, false);
747        if let Some(ref outcome) = outcome {
748            self.confirm_transition(&outcome.change, reliability);
749        }
750        outcome
751    }
752
753    /// Stage a per-write transition without confirming any transition-owned state.
754    pub(crate) fn propose(
755        &mut self,
756        present_value: u32,
757        reliability: Reliability,
758        inhibited: bool,
759    ) -> Option<TransitionOutcome> {
760        if let ControlFlow::Break(result) = self.fault_proposal(reliability) {
761            return result;
762        }
763        if inhibited {
764            return inhibited_target(self.event_state, &mut self.pending)
765                .and_then(|target| self.proposal(target));
766        }
767        let desired = self.compute_new_state(present_value);
768        if desired == self.event_state {
769            self.pending = None;
770            return None;
771        }
772        let delay = delay_toward(self.time_delay, self.time_delay_normal, desired);
773        if delay == 0 {
774            return self.proposal(desired);
775        }
776        // See [`OutOfRangeDetector::probe`]: do not restart an in-flight
777        // countdown to the same target on a redundant qualifying write.
778        if self.pending.as_ref().is_none_or(|p| p.state != desired) {
779            self.pending = Some(PendingTransition::seed(desired, delay));
780        }
781        None
782    }
783
784    /// Periodic tick: advance the countdown and fire on expiry.
785    pub fn tick(
786        &mut self,
787        present_value: u32,
788        reliability: Reliability,
789    ) -> Option<TransitionOutcome> {
790        let outcome = self.tick_proposal(present_value, reliability, false);
791        if let Some(ref outcome) = outcome {
792            self.confirm_transition(&outcome.change, reliability);
793        }
794        outcome
795    }
796
797    /// Advance a pending countdown, leaving a fire-ready transition retryable.
798    pub(crate) fn tick_proposal(
799        &mut self,
800        present_value: u32,
801        reliability: Reliability,
802        inhibited: bool,
803    ) -> Option<TransitionOutcome> {
804        if let ControlFlow::Break(result) = self.fault_proposal(reliability) {
805            return result;
806        }
807        if inhibited {
808            return inhibited_target(self.event_state, &mut self.pending)
809                .and_then(|target| self.proposal(target));
810        }
811        let desired = self.compute_new_state(present_value);
812        if desired == self.event_state {
813            self.pending = None;
814            return None;
815        }
816        match &mut self.pending {
817            Some(p) if p.state == desired => {
818                if p.remaining > 1 {
819                    p.remaining -= 1;
820                    return None;
821                }
822                self.proposal(desired)
823            }
824            _ => {
825                // Re-seed with the delay for the CURRENT target's direction.
826                let delay = delay_toward(self.time_delay, self.time_delay_normal, desired);
827                self.pending = Some(PendingTransition::seed(desired, delay));
828                None
829            }
830        }
831    }
832
833    /// Confirm a transition to `new_state`, reporting whether `Event_Enable`
834    /// permits distributing it. The transition itself is always reported; see
835    /// [`TransitionOutcome`].
836    fn proposal(&self, new_state: EventState) -> Option<TransitionOutcome> {
837        let change = EventStateChange {
838            from: self.event_state,
839            to: new_state,
840        };
841        let transition_bit = EventTransition::for_target_state(new_state).bit_mask();
842        let distribute = self.event_enable.contains(transition_bit);
843        let event_type = change.event_type(Self::ALGORITHM);
844        Some(TransitionOutcome {
845            change,
846            event_type,
847            distribute,
848        })
849    }
850
851    /// Finalize detector-local state only after the object commit kernel succeeds.
852    pub(crate) fn confirm_transition(
853        &mut self,
854        change: &EventStateChange,
855        reliability: Reliability,
856    ) {
857        self.event_state = change.to;
858        self.pending = None;
859        self.fault_reliability = if change.to == EventState::FAULT {
860            Some(reliability)
861        } else {
862            None
863        };
864    }
865
866    fn compute_new_state(&self, present_value: u32) -> EventState {
867        if self.alarm_values.contains(&present_value) {
868            EventState::OFFNORMAL
869        } else {
870            EventState::NORMAL
871        }
872    }
873}
874
875/// COMMAND_FAILURE event detector for commandable output objects (BO, MSO).
876///
877/// Transitions to OFFNORMAL when present_value differs
878/// from feedback_value. Returns to NORMAL when they match.
879///
880/// `Time_Delay` is honored via the split [`Self::probe`] / [`Self::tick`]
881/// entry points; see [`OutOfRangeDetector`] for the delay contract.
882#[derive(Debug, Clone)]
883pub struct CommandFailureDetector {
884    /// Instance number of the Notification Class object that distributes the events.
885    pub notification_class: u32,
886    /// Notify_Type: whether the transitions are reported as alarms or as events.
887    pub notify_type: NotifyType,
888    /// Event_Enable: the transitions whose notifications are distributed.
889    pub event_enable: EventTransitionBits,
890    /// Seconds an offnormal condition must persist before TO_OFFNORMAL fires, and the NORMAL
891    /// delay too when `time_delay_normal` is `None`; 0 fires immediately. Fault transitions are
892    /// never delayed.
893    pub time_delay: u32,
894    /// `Time_Delay_Normal` (property 356): the Clause 13.3.4 pTimeDelayNormal
895    /// parameter — the number of seconds a return to normal has to be
896    /// sustained before the detector confirms it. `None` is the not-configured case
897    /// and uses `time_delay` as the fallback required for absent pTimeDelayNormal.
898    pub time_delay_normal: Option<u32>,
899    /// Current event state.
900    pub event_state: EventState,
901    /// Acked_Transitions: a set flag means that transition was acknowledged.
902    pub acked_transitions: EventTransitionBits,
903    /// Pending delayed transition, or `None` when no delay is in progress.
904    pub pending: Option<PendingTransition>,
905    /// Reliability value in force at the last entry to FAULT; `None` outside FAULT.
906    pub fault_reliability: Option<Reliability>,
907}
908
909impl Default for CommandFailureDetector {
910    fn default() -> Self {
911        Self {
912            notification_class: 0,
913            notify_type: NotifyType::ALARM,
914            event_enable: EventTransitionBits::empty(),
915            time_delay: 0,
916            time_delay_normal: None,
917            event_state: EventState::NORMAL,
918            acked_transitions: EventTransitionBits::all(),
919            pending: None,
920            fault_reliability: None,
921        }
922    }
923}
924
925impl CommandFailureDetector {
926    /// BACnet Event Type corresponding to this detector's event algorithm.
927    pub const ALGORITHM: EventType = EventType::COMMAND_FAILURE;
928
929    /// Per-write entry point; see [`OutOfRangeDetector::evaluate`].
930    pub fn evaluate(
931        &mut self,
932        present_value: u32,
933        feedback_value: u32,
934        reliability: Reliability,
935    ) -> Option<TransitionOutcome> {
936        self.probe(present_value, feedback_value, reliability)
937    }
938
939    /// Clause 13.2.2 fault precedence; see [`OutOfRangeDetector::fault_proposal`].
940    fn fault_proposal(&self, reliability: Reliability) -> ControlFlow<Option<TransitionOutcome>> {
941        match fault_precedence(reliability, self.fault_reliability, self.event_state) {
942            FaultPrecedence::EnterFault | FaultPrecedence::ReenterFault => {
943                ControlFlow::Break(self.proposal(EventState::FAULT))
944            }
945            FaultPrecedence::HoldFault => ControlFlow::Break(None),
946            FaultPrecedence::RecoverToNormal => {
947                ControlFlow::Break(self.proposal(EventState::NORMAL))
948            }
949            FaultPrecedence::RunAlgorithm => ControlFlow::Continue(()),
950        }
951    }
952
953    /// Per-write probe: seed or cancel a pending transition, fire on zero delay.
954    pub fn probe(
955        &mut self,
956        present_value: u32,
957        feedback_value: u32,
958        reliability: Reliability,
959    ) -> Option<TransitionOutcome> {
960        let outcome = self.propose(present_value, feedback_value, reliability, false);
961        if let Some(ref outcome) = outcome {
962            self.confirm_transition(&outcome.change, reliability);
963        }
964        outcome
965    }
966
967    /// Stage a per-write transition without confirming any transition-owned state.
968    pub(crate) fn propose(
969        &mut self,
970        present_value: u32,
971        feedback_value: u32,
972        reliability: Reliability,
973        inhibited: bool,
974    ) -> Option<TransitionOutcome> {
975        if let ControlFlow::Break(result) = self.fault_proposal(reliability) {
976            return result;
977        }
978        if inhibited {
979            return inhibited_target(self.event_state, &mut self.pending)
980                .and_then(|target| self.proposal(target));
981        }
982        let desired = self.compute_new_state(present_value, feedback_value);
983        if desired == self.event_state {
984            self.pending = None;
985            return None;
986        }
987        let delay = delay_toward(self.time_delay, self.time_delay_normal, desired);
988        if delay == 0 {
989            return self.proposal(desired);
990        }
991        // See [`OutOfRangeDetector::probe`]: do not restart an in-flight
992        // countdown to the same target on a redundant qualifying write.
993        if self.pending.as_ref().is_none_or(|p| p.state != desired) {
994            self.pending = Some(PendingTransition::seed(desired, delay));
995        }
996        None
997    }
998
999    /// Periodic tick: advance the countdown and fire on expiry.
1000    pub fn tick(
1001        &mut self,
1002        present_value: u32,
1003        feedback_value: u32,
1004        reliability: Reliability,
1005    ) -> Option<TransitionOutcome> {
1006        let outcome = self.tick_proposal(present_value, feedback_value, reliability, false);
1007        if let Some(ref outcome) = outcome {
1008            self.confirm_transition(&outcome.change, reliability);
1009        }
1010        outcome
1011    }
1012
1013    /// Advance a pending countdown, leaving a fire-ready transition retryable.
1014    pub(crate) fn tick_proposal(
1015        &mut self,
1016        present_value: u32,
1017        feedback_value: u32,
1018        reliability: Reliability,
1019        inhibited: bool,
1020    ) -> Option<TransitionOutcome> {
1021        if let ControlFlow::Break(result) = self.fault_proposal(reliability) {
1022            return result;
1023        }
1024        if inhibited {
1025            return inhibited_target(self.event_state, &mut self.pending)
1026                .and_then(|target| self.proposal(target));
1027        }
1028        let desired = self.compute_new_state(present_value, feedback_value);
1029        if desired == self.event_state {
1030            self.pending = None;
1031            return None;
1032        }
1033        match &mut self.pending {
1034            Some(p) if p.state == desired => {
1035                if p.remaining > 1 {
1036                    p.remaining -= 1;
1037                    return None;
1038                }
1039                self.proposal(desired)
1040            }
1041            _ => {
1042                // Re-seed with the delay for the CURRENT target's direction.
1043                let delay = delay_toward(self.time_delay, self.time_delay_normal, desired);
1044                self.pending = Some(PendingTransition::seed(desired, delay));
1045                None
1046            }
1047        }
1048    }
1049
1050    /// Confirm a transition to `new_state`, reporting whether `Event_Enable`
1051    /// permits distributing it. The transition itself is always reported; see
1052    /// [`TransitionOutcome`].
1053    fn proposal(&self, new_state: EventState) -> Option<TransitionOutcome> {
1054        let change = EventStateChange {
1055            from: self.event_state,
1056            to: new_state,
1057        };
1058        // The FAULT arm reads `Event_Enable` like the other two. It previously
1059        // hardcoded `false`, which was unobservable only because no reliability
1060        // ever reached a detector (#200); Clause 13.2.5 scopes `Event_Enable` to
1061        // distribution uniformly across all three transition directions, so
1062        // there is no basis for treating TO_FAULT differently.
1063        let transition_bit = EventTransition::for_target_state(new_state).bit_mask();
1064        let distribute = self.event_enable.contains(transition_bit);
1065        let event_type = change.event_type(Self::ALGORITHM);
1066        Some(TransitionOutcome {
1067            change,
1068            event_type,
1069            distribute,
1070        })
1071    }
1072
1073    /// Finalize detector-local state only after the object commit kernel succeeds.
1074    pub(crate) fn confirm_transition(
1075        &mut self,
1076        change: &EventStateChange,
1077        reliability: Reliability,
1078    ) {
1079        self.event_state = change.to;
1080        self.pending = None;
1081        self.fault_reliability = if change.to == EventState::FAULT {
1082            Some(reliability)
1083        } else {
1084            None
1085        };
1086    }
1087
1088    fn compute_new_state(&self, present_value: u32, feedback_value: u32) -> EventState {
1089        if present_value != feedback_value {
1090            EventState::OFFNORMAL
1091        } else {
1092            EventState::NORMAL
1093        }
1094    }
1095}
1096
1097#[cfg(test)]
1098pub(crate) use history::commit_test_proposal;
1099pub(crate) use history::impl_builtin_intrinsic_reporting;
1100pub(crate) use state_reporting::impl_change_of_state_reporting;
1101
1102#[cfg(test)]
1103mod fault_tests;
1104#[cfg(test)]
1105mod inhibit_tests;
1106#[cfg(test)]
1107mod tests;
1108#[cfg(test)]
1109mod time_delay_normal_tests;