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