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