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