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