From 8b0f9849f49148300040215cd78acf188fb507ec Mon Sep 17 00:00:00 2001 From: Justin Scott Date: Fri, 28 Aug 2026 23:02:09 -0400 Subject: [PATCH] feat(server): project committed event notification values --- .../bacnet-server/src/event_enrollment/api.rs | 72 ++ .../src/event_enrollment/commit.rs | 67 +- .../bacnet-server/src/event_enrollment/mod.rs | 97 +-- .../src/event_enrollment/support.rs | 3 + .../server/event_confirmed_routing_tests.rs | 40 + .../server/event_enable_distribution_tests.rs | 37 + ...nt_enrollment_notification_test_support.rs | 136 +++- .../event_enrollment_notification_tests.rs | 157 +++- .../src/server/event_message_policy_tests.rs | 8 +- .../server/event_network_priority_tests.rs | 2 +- .../src/server/event_notification_payload.rs | 715 ++++++++++++++++++ .../event_notification_payload_tests.rs | 359 +++++++++ .../src/server/event_notifications.rs | 113 ++- .../event_notifications_history_tests.rs | 103 ++- .../src/server/event_notifications_tests.rs | 29 +- .../src/server/event_recipient_route.rs | 17 + crates/bacnet-server/src/server/lifecycle.rs | 2 +- crates/bacnet-server/src/server/mod.rs | 1 + .../src/alarm_event/event_notification.rs | 39 +- .../tests/event_notification_decode.rs | 45 ++ .../src/alarm_event/tests/mod.rs | 1 + .../notification_parameters_reachable_wire.rs | 90 +++ 22 files changed, 1921 insertions(+), 212 deletions(-) create mode 100644 crates/bacnet-server/src/event_enrollment/api.rs create mode 100644 crates/bacnet-server/src/server/event_notification_payload.rs create mode 100644 crates/bacnet-server/src/server/event_notification_payload_tests.rs create mode 100644 crates/bacnet-services/src/alarm_event/tests/notification_parameters_reachable_wire.rs diff --git a/crates/bacnet-server/src/event_enrollment/api.rs b/crates/bacnet-server/src/event_enrollment/api.rs new file mode 100644 index 00000000..33af4313 --- /dev/null +++ b/crates/bacnet-server/src/event_enrollment/api.rs @@ -0,0 +1,72 @@ +use bacnet_objects::database::ObjectDatabase; +use bacnet_objects::event::EventStateChange; +use bacnet_types::enums::EventType; +use bacnet_types::primitives::ObjectIdentifier; + +use super::{ + evaluate_event_enrollments_for_delivery, EventEnrollmentDetailedEvaluationReport, + EventEnrollmentEvaluationReport, +}; + +/// A state transition detected during event enrollment evaluation. +#[derive(Debug, Clone, PartialEq)] +pub struct EventEnrollmentTransition { + /// The EventEnrollment object that detected the transition. + pub enrollment_oid: ObjectIdentifier, + /// The monitored object whose property triggered the transition. + pub monitored_oid: ObjectIdentifier, + /// The detected state change. `from == to` is a genuine same-state + /// transition emitted by applicable event algorithms. + pub change: EventStateChange, + /// The event type that was evaluated. + pub event_type: EventType, + /// Whether `Event_Enable` permits distributing a notification for this + /// transition. The transition is reported and `Event_State` persisted + /// either way; a cleared bit suppresses only the outbound notification. + pub distribute: bool, +} + +/// Evaluate all EventEnrollment objects in the database. +/// +/// For each active enrollment, reads the monitored property, evaluates the +/// configured algorithm, applies the Time_Delay / Time_Delay_Normal +/// countdown (seconds, converted by the module's delay helper), executes the +/// transition actions for every indicated transition that fires — same-state +/// included — and returns the fired transitions. +/// +/// `interval_secs` is the driving task's evaluation period in wall-clock +/// seconds; the lifecycle passes its (clamped to >= 1) +/// `event_enrollment_interval_secs`. The conversion is never-fire-early, and +/// the pending countdown retains no residual seconds: in-memory state plus +/// builder-config interval means no mid-run rescale exists. +pub fn evaluate_event_enrollments( + db: &mut ObjectDatabase, + interval_secs: u64, +) -> Vec { + evaluate_event_enrollments_report(db, interval_secs).transitions +} + +/// Evaluate all EventEnrollment objects and expose legacy commit diagnostics. +/// +/// This preserves the original report shape. Reliability results and typed +/// observation diagnostics are available from +/// [`evaluate_event_enrollments_detailed_report`]. +pub fn evaluate_event_enrollments_report( + db: &mut ObjectDatabase, + interval_secs: u64, +) -> EventEnrollmentEvaluationReport { + evaluate_event_enrollments_detailed_report(db, interval_secs).into_legacy() +} + +/// Evaluate all EventEnrollment objects and expose every detailed result. +/// +/// Unlike [`evaluate_event_enrollments_report`], this additive API includes +/// committed Reliability results plus typed Reliability and observation +/// diagnostics. Only results whose complete object-owned commit succeeds +/// appear in `transitions` or `reliability_results`. +pub fn evaluate_event_enrollments_detailed_report( + db: &mut ObjectDatabase, + interval_secs: u64, +) -> EventEnrollmentDetailedEvaluationReport { + evaluate_event_enrollments_for_delivery(db, interval_secs).report +} diff --git a/crates/bacnet-server/src/event_enrollment/commit.rs b/crates/bacnet-server/src/event_enrollment/commit.rs index 1573ac30..cf2973f4 100644 --- a/crates/bacnet-server/src/event_enrollment/commit.rs +++ b/crates/bacnet-server/src/event_enrollment/commit.rs @@ -10,6 +10,10 @@ use bacnet_types::enums::{EventState, EventType, PropertyIdentifier, Reliability use bacnet_types::primitives::{ObjectIdentifier, PropertyValue}; use super::EventEnrollmentTransition; +use crate::server::event_notification_payload::{ + project_event_enrollment_payload, CapturedReferencedValue, CommittedNotificationPayload, + EventEnrollmentProjectionSnapshot, +}; use crate::server::event_timestamp::{ confirm_event_timestamp, stage_event_timestamp, SampledEventClock, }; @@ -192,6 +196,8 @@ pub(crate) struct CommittedEventEnrollmentDelivery { pub(crate) result: CommittedEventEnrollmentResult, pub(crate) ack_required: bool, pub(crate) recipient_clock: SampledEventClock, + pub(crate) event_type: EventType, + pub(crate) event_values: CommittedNotificationPayload, } /// Public report plus the commit-order stream consumed only by the server. @@ -305,6 +311,7 @@ pub(super) struct FiredTransition { pub(super) to: EventState, pub(super) distribute: bool, pub(super) ack_required: bool, + pub(super) projection: EventEnrollmentProjectionSnapshot, } pub(super) struct ReliabilityUpdate { @@ -316,6 +323,7 @@ pub(super) struct ReliabilityUpdate { pub(super) distribute: bool, pub(super) ack_required: bool, pub(super) cause: EventEnrollmentReliabilityCause, + pub(super) referenced_value: CapturedReferencedValue, } impl EnrollmentUpdate { @@ -586,11 +594,32 @@ pub(super) fn apply_updates_for_delivery( cause: reliability.cause, }; report.reliability_results.push(result.clone()); - deliveries.push(CommittedEventEnrollmentDelivery { - result: CommittedEventEnrollmentResult::Reliability(result), - ack_required: reliability.ack_required, - recipient_clock, - }); + let event_type = EventType::CHANGE_OF_RELIABILITY; + if let Some(event_values) = project_event_enrollment_payload( + db, + oid, + reliability.monitored_oid, + result + .state_change + .as_ref() + .expect("committed Reliability delivery has a state change"), + event_type, + None, + Some(&reliability.referenced_value), + ) { + deliveries.push(CommittedEventEnrollmentDelivery { + result: CommittedEventEnrollmentResult::Reliability(result), + ack_required: reliability.ack_required, + recipient_clock, + event_type, + event_values, + }); + } else { + tracing::debug!( + enrollment = %oid, + "Committed Event Enrollment reliability payload rejected; suppressing distribution" + ); + } continue; } @@ -660,11 +689,29 @@ pub(super) fn apply_updates_for_delivery( distribute: fired.distribute, }; report.transitions.push(result.clone()); - deliveries.push(CommittedEventEnrollmentDelivery { - result: CommittedEventEnrollmentResult::Normal(result), - ack_required: fired.ack_required, - recipient_clock, - }); + if let Some(event_values) = project_event_enrollment_payload( + db, + oid, + Some(fired.monitored_oid), + &result.change, + event_type, + Some(&fired.projection), + None, + ) { + deliveries.push(CommittedEventEnrollmentDelivery { + result: CommittedEventEnrollmentResult::Normal(result), + ack_required: fired.ack_required, + recipient_clock, + event_type, + event_values, + }); + } else { + tracing::debug!( + enrollment = %oid, + ?event_type, + "Committed Event Enrollment payload rejected; suppressing distribution" + ); + } } EventEnrollmentEvaluationBatch { report, deliveries } diff --git a/crates/bacnet-server/src/event_enrollment/mod.rs b/crates/bacnet-server/src/event_enrollment/mod.rs index fcfcf1a0..b2791be2 100644 --- a/crates/bacnet-server/src/event_enrollment/mod.rs +++ b/crates/bacnet-server/src/event_enrollment/mod.rs @@ -40,6 +40,7 @@ //! notification sender without repeating these transition actions. mod algorithms; +mod api; mod commit; mod fault; mod reference; @@ -51,6 +52,10 @@ pub use algorithms::{ encode_change_of_bitstring_params, encode_change_of_state_params, encode_change_of_value_params, encode_floating_limit_params, encode_out_of_range_params, }; +pub use api::{ + evaluate_event_enrollments, evaluate_event_enrollments_detailed_report, + evaluate_event_enrollments_report, EventEnrollmentTransition, +}; pub use commit::{ EventEnrollmentDetailedEvaluationDiagnostic, EventEnrollmentDetailedEvaluationOutcome, EventEnrollmentDetailedEvaluationReport, EventEnrollmentDetailedEvaluationStage, @@ -88,8 +93,13 @@ enum LocalConfigurationReadError { Unavailable, } +use crate::server::event_notification_payload::{ + capture_status_flags, CapturedReferencedValue, EventEnrollmentProjectionSnapshot, +}; use bacnet_objects::database::ObjectDatabase; -use bacnet_objects::event::{EventStateChange, EventTransition}; +#[cfg(test)] +use bacnet_objects::event::EventStateChange; +use bacnet_objects::event::EventTransition; use bacnet_objects::event_enrollment::{EventEnrollmentEvalState, EventEnrollmentPending}; #[cfg(test)] use bacnet_objects::traits::BACnetObject; @@ -97,71 +107,6 @@ use bacnet_types::constructed::BACnetEventParameter; use bacnet_types::enums::{EventState, EventType, ObjectType, PropertyIdentifier, Reliability}; use bacnet_types::primitives::{ObjectIdentifier, PropertyValue}; -/// A state transition detected during event enrollment evaluation. -#[derive(Debug, Clone, PartialEq)] -pub struct EventEnrollmentTransition { - /// The EventEnrollment object that detected the transition. - pub enrollment_oid: ObjectIdentifier, - /// The monitored object whose property triggered the transition. - pub monitored_oid: ObjectIdentifier, - /// The detected state change. `from == to` is a genuine same-state - /// transition (Clause 13.2.2.1.4), emitted by CHANGE_OF_VALUE (Figure - /// 13-10's NORMAL→NORMAL) and CHANGE_OF_STATE condition (c). - pub change: EventStateChange, - /// The event type that was evaluated. - pub event_type: EventType, - /// Whether `Event_Enable` permits distributing a notification for this - /// transition. The transition is reported and `Event_State` persisted - /// either way; a cleared bit suppresses only the outbound notification - /// (ASHRAE 135-2020 Clause 12.12). - pub distribute: bool, -} - -/// Evaluate all EventEnrollment objects in the database. -/// -/// For each active enrollment, reads the monitored property, evaluates the -/// configured algorithm, applies the Time_Delay / Time_Delay_Normal -/// countdown (seconds, converted with [`passes_for_delay`]), executes the -/// Clause 13.2.2.1.4 transition actions for every indicated transition that -/// fires — same-state included — and returns the fired transitions. -/// -/// `interval_secs` is the driving task's evaluation period in wall-clock -/// seconds; the lifecycle passes its (clamped to >= 1) -/// `event_enrollment_interval_secs`. The conversion is never-fire-early, and -/// the pending countdown retains no residual seconds: in-memory state plus -/// builder-config interval means no mid-run rescale exists. -pub fn evaluate_event_enrollments( - db: &mut ObjectDatabase, - interval_secs: u64, -) -> Vec { - evaluate_event_enrollments_report(db, interval_secs).transitions -} - -/// Evaluate all EventEnrollment objects and expose legacy commit diagnostics. -/// -/// This preserves the original report shape. Reliability results and typed -/// observation diagnostics are available from -/// [`evaluate_event_enrollments_detailed_report`]. -pub fn evaluate_event_enrollments_report( - db: &mut ObjectDatabase, - interval_secs: u64, -) -> EventEnrollmentEvaluationReport { - evaluate_event_enrollments_detailed_report(db, interval_secs).into_legacy() -} - -/// Evaluate all EventEnrollment objects and expose every detailed result. -/// -/// Unlike [`evaluate_event_enrollments_report`], this additive API includes -/// committed Reliability results plus typed Reliability and observation -/// diagnostics. Only results whose complete object-owned commit succeeds -/// appear in `transitions` or `reliability_results`. -pub fn evaluate_event_enrollments_detailed_report( - db: &mut ObjectDatabase, - interval_secs: u64, -) -> EventEnrollmentDetailedEvaluationReport { - evaluate_event_enrollments_for_delivery(db, interval_secs).report -} - /// Evaluate and retain the private commit-order stream for server delivery. pub(crate) fn evaluate_event_enrollments_for_delivery( db: &mut ObjectDatabase, @@ -289,6 +234,7 @@ pub(crate) fn evaluate_event_enrollments_for_delivery( current_state, event_enable, EventEnrollmentReliabilityCause::Configuration, + CapturedReferencedValue::Unavailable, ); continue; } @@ -358,6 +304,7 @@ pub(crate) fn evaluate_event_enrollments_for_delivery( current_state, event_enable, EventEnrollmentReliabilityCause::Configuration, + CapturedReferencedValue::Unavailable, ); continue; } @@ -383,6 +330,7 @@ pub(crate) fn evaluate_event_enrollments_for_delivery( current_state, event_enable, EventEnrollmentReliabilityCause::Configuration, + CapturedReferencedValue::NotEvaluated, ); continue; } @@ -412,6 +360,7 @@ pub(crate) fn evaluate_event_enrollments_for_delivery( current_state, event_enable, EventEnrollmentReliabilityCause::MonitoredObject, + CapturedReferencedValue::NotEvaluated, ); continue; } @@ -437,6 +386,7 @@ pub(crate) fn evaluate_event_enrollments_for_delivery( current_state, event_enable, EventEnrollmentReliabilityCause::Configuration, + CapturedReferencedValue::Unavailable, ); continue; } @@ -468,6 +418,7 @@ pub(crate) fn evaluate_event_enrollments_for_delivery( current_state, event_enable, EventEnrollmentReliabilityCause::Configuration, + CapturedReferencedValue::from_evaluated(monitored_value.as_ref()), ); continue; } @@ -495,6 +446,7 @@ pub(crate) fn evaluate_event_enrollments_for_delivery( current_state, event_enable, EventEnrollmentReliabilityCause::FaultAlgorithm, + CapturedReferencedValue::from_evaluated(monitored_value.as_ref()), ); continue; } @@ -531,6 +483,7 @@ pub(crate) fn evaluate_event_enrollments_for_delivery( current_state, event_enable, cause, + CapturedReferencedValue::from_evaluated(monitored_value.as_ref()), ); continue; } @@ -554,6 +507,7 @@ pub(crate) fn evaluate_event_enrollments_for_delivery( current_state, event_enable, EventEnrollmentReliabilityCause::Configuration, + CapturedReferencedValue::Unavailable, ); continue; } @@ -611,6 +565,7 @@ pub(crate) fn evaluate_event_enrollments_for_delivery( let event_type = EventType::from_raw(event_type_raw); + let mut projection_setpoint = None; let (time_delay, arm) = match ¶ms { BACnetEventParameter::OutOfRange { high_limit, @@ -675,6 +630,7 @@ pub(crate) fn evaluate_event_enrollments_for_delivery( continue; } }; + projection_setpoint = Some(setpoint); ( *time_delay, eval_floating_limit_struct( @@ -877,6 +833,15 @@ pub(crate) fn evaluate_event_enrollments_for_delivery( to: fired.target, distribute, ack_required, + projection: EventEnrollmentProjectionSnapshot::new( + monitored.object_identifier, + monitored.property_identifier, + monitored.array_index, + monitored_value, + params, + capture_status_flags(monitored_obj), + projection_setpoint, + ), }); } diff --git a/crates/bacnet-server/src/event_enrollment/support.rs b/crates/bacnet-server/src/event_enrollment/support.rs index ae8a1c5d..b9fbe976 100644 --- a/crates/bacnet-server/src/event_enrollment/support.rs +++ b/crates/bacnet-server/src/event_enrollment/support.rs @@ -14,6 +14,7 @@ use super::algorithms::extract_real; use super::commit::{EnrollmentUpdate, ReliabilityUpdate}; use super::EventEnrollmentReliabilityCause; use super::LocalConfigurationReadError; +use crate::server::event_notification_payload::CapturedReferencedValue; pub(super) enum SetpointRead { Value(f32), @@ -167,6 +168,7 @@ pub(super) fn queue_reliability_transition( current_state: EventState, event_enable: u8, cause: EventEnrollmentReliabilityCause, + referenced_value: CapturedReferencedValue, ) { let target = if desired == Reliability::NO_FAULT_DETECTED { EventState::NORMAL @@ -189,6 +191,7 @@ pub(super) fn queue_reliability_transition( distribute: event_enable & transition_bit != 0, ack_required: ack_required_for_transition(db, enrollment, transition_bit), cause, + referenced_value, }); } diff --git a/crates/bacnet-server/src/server/event_confirmed_routing_tests.rs b/crates/bacnet-server/src/server/event_confirmed_routing_tests.rs index bab5c97e..260e22ff 100644 --- a/crates/bacnet-server/src/server/event_confirmed_routing_tests.rs +++ b/crates/bacnet-server/src/server/event_confirmed_routing_tests.rs @@ -354,6 +354,46 @@ async fn confirmed_retry_reuses_committed_message_bytes_after_history_changes() notification.message_text, Some("ANALOG_INPUT,1: NORMAL -> HIGH_LIMIT".into()) ); + assert_eq!( + notification.event_values, + Some( + bacnet_services::alarm_event::NotificationParameters::OutOfRange { + exceeding_value: 0.0, + status_flags: 0b1000, + deadband: 1.0, + exceeded_limit: 100.0, + } + ) + ); + + { + let mut db = harness.db.write().await; + let source = db.get_mut(&oid).unwrap(); + source + .write_property( + PropertyIdentifier::OUT_OF_SERVICE, + None, + PropertyValue::Boolean(true), + None, + ) + .unwrap(); + source + .write_property( + PropertyIdentifier::PRESENT_VALUE, + None, + PropertyValue::Real(99.0), + None, + ) + .unwrap(); + source + .write_property( + PropertyIdentifier::HIGH_LIMIT, + None, + PropertyValue::Real(101.0), + None, + ) + .unwrap(); + } harness .commit_transition(EventState::HIGH_LIMIT, EventState::NORMAL) diff --git a/crates/bacnet-server/src/server/event_enable_distribution_tests.rs b/crates/bacnet-server/src/server/event_enable_distribution_tests.rs index 2b2fb8eb..e97a7d98 100644 --- a/crates/bacnet-server/src/server/event_enable_distribution_tests.rs +++ b/crates/bacnet-server/src/server/event_enable_distribution_tests.rs @@ -20,6 +20,7 @@ use bacnet_objects::binary::{BinaryInputObject, BinaryValueObject}; use bacnet_objects::device::{DeviceConfig, DeviceObject}; use bacnet_objects::multistate::{MultiStateInputObject, MultiStateValueObject}; use bacnet_objects::traits::BACnetObject; +use bacnet_services::alarm_event::NotificationParameters; use bacnet_services::list_manipulation::ListElementRequest; use bacnet_transport::port::TransportPort; use bacnet_types::bitstring::EventTransitionBits; @@ -63,6 +64,42 @@ impl TransportPort for RecordingTransport { const ALARM_STATE: u64 = 2; const NORMAL_STATE: u64 = 1; +#[tokio::test] +async fn analog_event_enable_set_delivers_committed_event_values() { + use super::event_notifications_tests::{ + broadcasts_from_per_write_path, db_with_high_limit_transition, + decode_broadcast_notification, + }; + + let db = db_with_high_limit_transition(0x80); + db.write() + .await + .get_mut(&ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap()) + .unwrap() + .write_property( + PropertyIdentifier::NOTIFY_TYPE, + None, + PropertyValue::Enumerated(NotifyType::EVENT.to_raw()), + None, + ) + .unwrap(); + let sent = broadcasts_from_per_write_path(&db, 0).await; + + assert_eq!(sent.len(), 1); + let notification = decode_broadcast_notification(&StdMutex::new(sent)); + assert_eq!(notification.notify_type, NotifyType::EVENT.to_raw()); + assert_eq!(notification.event_type, EventType::OUT_OF_RANGE.to_raw()); + assert_eq!( + notification.event_values, + Some(NotificationParameters::OutOfRange { + exceeding_value: 81.0, + status_flags: 0b1000, + deadband: 2.0, + exceeded_limit: 80.0, + }) + ); +} + /// A Multi-state Input in a one-object database, plus what the per-write /// notification path needs to run against it. struct Fixture { diff --git a/crates/bacnet-server/src/server/event_enrollment_notification_test_support.rs b/crates/bacnet-server/src/server/event_enrollment_notification_test_support.rs index a9ce0059..d3c0a0c7 100644 --- a/crates/bacnet-server/src/server/event_enrollment_notification_test_support.rs +++ b/crates/bacnet-server/src/server/event_enrollment_notification_test_support.rs @@ -6,9 +6,11 @@ use bacnet_objects::device::{DeviceConfig, DeviceObject}; use bacnet_objects::event_enrollment::EventEnrollmentObject; use bacnet_objects::notification_class::NotificationClass; use bacnet_objects::traits::BACnetObject; -use bacnet_services::alarm_event::EventNotificationRequest; +use bacnet_services::alarm_event::{EventNotificationRequest, NotificationParameters}; use bacnet_transport::port::TransportPort; -use bacnet_types::constructed::{BACnetDeviceObjectPropertyReference, BACnetEventParameter}; +use bacnet_types::constructed::{ + BACnetDeviceObjectPropertyReference, BACnetEventParameter, FaultParameters, +}; use bacnet_types::enums::{ErrorClass, ErrorCode, EventState, EventType, Reliability}; use bacnet_types::primitives::BACnetTimeStamp; use bytes::Bytes; @@ -59,8 +61,8 @@ pub(super) struct ObservedObject { oid: ObjectIdentifier, name: String, value: PropertyValue, - reliability: PropertyValue, - status_flags: u8, + reliability: Option, + status_flags: Option, } impl ObservedObject { @@ -69,23 +71,35 @@ impl ObservedObject { oid: ObjectIdentifier::new(ObjectType::ANALOG_VALUE, instance).unwrap(), name: format!("observed-{instance}"), value, - reliability: PropertyValue::Enumerated(Reliability::NO_FAULT_DETECTED.to_raw()), - status_flags: 0, + reliability: Some(PropertyValue::Enumerated( + Reliability::NO_FAULT_DETECTED.to_raw(), + )), + status_flags: Some(0), } } pub(super) fn with_reliability(mut self, reliability: Reliability) -> Self { - self.reliability = PropertyValue::Enumerated(reliability.to_raw()); + self.reliability = Some(PropertyValue::Enumerated(reliability.to_raw())); self } pub(super) fn with_reliability_value(mut self, reliability: PropertyValue) -> Self { - self.reliability = reliability; + self.reliability = Some(reliability); self } pub(super) fn with_status_flags(mut self, status_flags: u8) -> Self { - self.status_flags = status_flags; + self.status_flags = Some(status_flags); + self + } + + pub(super) fn without_reliability(mut self) -> Self { + self.reliability = None; + self + } + + pub(super) fn without_status_flags(mut self) -> Self { + self.status_flags = None; self } } @@ -115,11 +129,22 @@ impl BACnetObject for ObservedObject { Ok(PropertyValue::Enumerated(ObjectType::ANALOG_VALUE.to_raw())) } p if p == PropertyIdentifier::PRESENT_VALUE => Ok(self.value.clone()), - p if p == PropertyIdentifier::RELIABILITY => Ok(self.reliability.clone()), - p if p == PropertyIdentifier::STATUS_FLAGS => Ok(PropertyValue::BitString { - unused_bits: 4, - data: vec![self.status_flags], - }), + p if p == PropertyIdentifier::RELIABILITY => { + self.reliability.clone().ok_or(Error::Protocol { + class: ErrorClass::PROPERTY.to_raw() as u32, + code: ErrorCode::UNKNOWN_PROPERTY.to_raw() as u32, + }) + } + p if p == PropertyIdentifier::STATUS_FLAGS => self + .status_flags + .map(|status_flags| PropertyValue::BitString { + unused_bits: 4, + data: vec![status_flags], + }) + .ok_or(Error::Protocol { + class: ErrorClass::PROPERTY.to_raw() as u32, + code: ErrorCode::UNKNOWN_PROPERTY.to_raw() as u32, + }), _ => Err(Error::Protocol { class: ErrorClass::PROPERTY.to_raw() as u32, code: ErrorCode::UNKNOWN_PROPERTY.to_raw() as u32, @@ -136,11 +161,11 @@ impl BACnetObject for ObservedObject { ) -> Result<(), Error> { match (property, value) { (p, value) if p == PropertyIdentifier::PRESENT_VALUE => self.value = value, - (p, value) if p == PropertyIdentifier::RELIABILITY => self.reliability = value, + (p, value) if p == PropertyIdentifier::RELIABILITY => self.reliability = Some(value), (p, PropertyValue::BitString { data, .. }) if p == PropertyIdentifier::STATUS_FLAGS && data.len() == 1 => { - self.status_flags = data[0]; + self.status_flags = Some(data[0]); } _ => { return Err(Error::Protocol { @@ -314,7 +339,35 @@ pub(super) fn assert_committed_reliability_notifications( assert_eq!(notification.to_state, to.to_raw()); assert!(notification.ack_required); assert_eq!(notification.message_text, None); - assert_eq!(notification.event_values, None); + let Some(NotificationParameters::ChangeOfReliability { + status_flags, + property_values, + .. + }) = notification.event_values.as_ref() + else { + panic!("Event Enrollment fault transition must carry CHANGE_OF_RELIABILITY values"); + }; + assert_eq!( + *status_flags, + if to == EventState::FAULT { 0b1100 } else { 0 } + ); + let mut properties = Vec::new(); + let mut position = 0; + while position < property_values.len() { + let (property, next) = BACnetPropertyValue::decode(property_values, position).unwrap(); + assert!(next > position); + properties.push(property.property_identifier); + position = next; + } + assert_eq!( + properties, + vec![ + PropertyIdentifier::OBJECT_PROPERTY_REFERENCE, + PropertyIdentifier::PRESENT_VALUE, + PropertyIdentifier::RELIABILITY, + PropertyIdentifier::STATUS_FLAGS, + ] + ); assert_eq!( notification.timestamp, BACnetTimeStamp::SequenceNumber(first_sequence + offset as u16) @@ -337,3 +390,52 @@ pub(super) fn event_state(db: &ObjectDatabase, oid: ObjectIdentifier) -> EventSt }; EventState::from_raw(raw) } + +#[tokio::test(start_paused = true)] +async fn event_enrollment_reliability_omits_only_unavailable_monitored_entries() { + let mut db = ObjectDatabase::new(); + let target = ObservedObject::new(70, PropertyValue::Real(-1.0)) + .without_reliability() + .without_status_flags(); + let target_oid = target.object_identifier(); + db.add(Box::new(target)).unwrap(); + let mut enrollment = enrollment( + 70, + EventType::OUT_OF_RANGE, + Some(target_oid), + out_of_range_parameters(0), + ); + enrollment.set_fault_parameters(Some(FaultParameters::FaultOutOfRange { + min_normal: 0.0, + max_normal: 10.0, + })); + db.add(Box::new(enrollment)).unwrap(); + + let (mut server, sent) = start_server(db, true).await; + tokio::time::sleep(Duration::from_millis(100)).await; + let notifications = drain_notifications(&sent); + assert_eq!(notifications.len(), 1); + let Some(NotificationParameters::ChangeOfReliability { + property_values, .. + }) = notifications[0].event_values.as_ref() + else { + panic!("fault transition must carry CHANGE_OF_RELIABILITY"); + }; + let mut identifiers = Vec::new(); + let mut position = 0; + while position < property_values.len() { + let (property, next) = BACnetPropertyValue::decode(property_values, position).unwrap(); + identifiers.push(property.property_identifier); + position = next; + } + assert_eq!( + identifiers, + vec![ + PropertyIdentifier::OBJECT_PROPERTY_REFERENCE, + PropertyIdentifier::PRESENT_VALUE, + ], + "only unavailable monitored Reliability and Status_Flags may be omitted" + ); + assert_eq!(notifications[0].message_text, None); + server.stop().await.unwrap(); +} diff --git a/crates/bacnet-server/src/server/event_enrollment_notification_tests.rs b/crates/bacnet-server/src/server/event_enrollment_notification_tests.rs index 13383030..bf049b42 100644 --- a/crates/bacnet-server/src/server/event_enrollment_notification_tests.rs +++ b/crates/bacnet-server/src/server/event_enrollment_notification_tests.rs @@ -4,6 +4,7 @@ use super::*; use bacnet_objects::event_log::EventLogObject; use bacnet_objects::notification_class::NotificationClass; use bacnet_objects::traits::BACnetObject; +use bacnet_services::alarm_event::{ChangeOfValueChoice, NotificationParameters}; use bacnet_types::constructed::{ BACnetDeviceObjectPropertyReference, BACnetEventParameter, BACnetPropertyStates, ChangeOfValueCriteria, FaultParameters, @@ -103,6 +104,29 @@ async fn every_evaluated_normal_algorithm_uses_committed_history_once_on_wire() ))) .unwrap(); + let cov_bits_target = ObservedObject::new( + 7, + PropertyValue::BitString { + unused_bits: 5, + data: vec![0x80], + }, + ); + let cov_bits_target_oid = cov_bits_target.object_identifier(); + db.add(Box::new(cov_bits_target)).unwrap(); + db.add(Box::new(enrollment( + 6, + EventType::CHANGE_OF_VALUE, + Some(cov_bits_target_oid), + BACnetEventParameter::ChangeOfValue { + time_delay: 0, + criteria: ChangeOfValueCriteria::Bitmask { + unused_bits: 5, + data: vec![0xe0], + }, + }, + ))) + .unwrap(); + let event_log_oid = ObjectIdentifier::new(ObjectType::EVENT_LOG, 1).unwrap(); db.add(Box::new(EventLogObject::new(1, "Event Log", 16).unwrap())) .unwrap(); @@ -112,13 +136,41 @@ async fn every_evaluated_normal_algorithm_uses_committed_history_once_on_wire() let first = drain_notifications(&sent); assert_eq!(first.len(), 4, "four immediate algorithms must deliver"); - for (index, (notification, expected_type)) in first + for (index, (notification, (expected_type, expected_values))) in first .iter() .zip([ - EventType::OUT_OF_RANGE, - EventType::FLOATING_LIMIT, - EventType::CHANGE_OF_STATE, - EventType::CHANGE_OF_BITSTRING, + ( + EventType::OUT_OF_RANGE, + NotificationParameters::OutOfRange { + exceeding_value: 85.0, + status_flags: 0, + deadband: 2.0, + exceeded_limit: 80.0, + }, + ), + ( + EventType::FLOATING_LIMIT, + NotificationParameters::FloatingLimit { + reference_value: 65.0, + status_flags: 0, + setpoint_value: 50.0, + error_limit: 10.0, + }, + ), + ( + EventType::CHANGE_OF_STATE, + NotificationParameters::ChangeOfState { + new_state: BACnetPropertyStates::BinaryValue(1), + status_flags: 0, + }, + ), + ( + EventType::CHANGE_OF_BITSTRING, + NotificationParameters::ChangeOfBitstring { + referenced_bitstring: (5, vec![0xe0]), + status_flags: 0, + }, + ), ]) .enumerate() { @@ -129,7 +181,7 @@ async fn every_evaluated_normal_algorithm_uses_committed_history_once_on_wire() ); assert!(notification.ack_required); assert_eq!(notification.message_text, None); - assert_eq!(notification.event_values, None); + assert_eq!(notification.event_values.as_ref(), Some(&expected_values)); let db = server.database().read().await; assert_eq!( notification.timestamp, @@ -155,13 +207,49 @@ async fn every_evaluated_normal_algorithm_uses_committed_history_once_on_wire() None, ) .unwrap(); + server + .database() + .write() + .await + .get_mut(&cov_bits_target_oid) + .unwrap() + .write_property( + PropertyIdentifier::PRESENT_VALUE, + None, + PropertyValue::BitString { + unused_bits: 5, + data: vec![0xa0], + }, + None, + ) + .unwrap(); tokio::time::sleep(Duration::from_secs(1)).await; let cov = drain_notifications(&sent); - assert_eq!(cov.len(), 1, "CHANGE_OF_VALUE threshold crossing delivers"); - assert_eq!(cov[0].event_type, EventType::CHANGE_OF_VALUE.to_raw()); - assert_eq!(cov[0].from_state, EventState::NORMAL.to_raw()); - assert_eq!(cov[0].to_state, EventState::NORMAL.to_raw()); + assert_eq!(cov.len(), 2, "both CHANGE_OF_VALUE choices deliver"); + for notification in &cov { + assert_eq!(notification.event_type, EventType::CHANGE_OF_VALUE.to_raw()); + assert_eq!(notification.from_state, EventState::NORMAL.to_raw()); + assert_eq!(notification.to_state, EventState::NORMAL.to_raw()); + } assert_eq!(cov[0].timestamp, BACnetTimeStamp::SequenceNumber(4)); + assert_eq!( + cov[0].event_values, + Some(NotificationParameters::ChangeOfValue { + new_value: ChangeOfValueChoice::ChangedValue(8.0), + status_flags: 0, + }) + ); + assert_eq!(cov[1].timestamp, BACnetTimeStamp::SequenceNumber(5)); + assert_eq!( + cov[1].event_values, + Some(NotificationParameters::ChangeOfValue { + new_value: ChangeOfValueChoice::ChangedBits { + unused_bits: 5, + data: vec![0xa0], + }, + status_flags: 0, + }) + ); tokio::time::sleep(Duration::from_secs(1)).await; assert!( @@ -169,7 +257,7 @@ async fn every_evaluated_normal_algorithm_uses_committed_history_once_on_wire() "the next no-transition pass must not duplicate a token or send" ); let db = server.database().write().await; - assert_eq!(db.reserve_event_sequence_number().number(), 5); + assert_eq!(db.reserve_event_sequence_number().number(), 6); assert_eq!( db.get(&event_log_oid) .unwrap() @@ -213,12 +301,23 @@ async fn event_enrollment_ack_policy_is_the_commit_time_snapshot() { }; { + let mut guard = db.write().await; + guard + .get_mut(&target_oid) + .unwrap() + .write_property( + PropertyIdentifier::PRESENT_VALUE, + None, + PropertyValue::Real(5.0), + None, + ) + .unwrap(); let mut replacement = NotificationClass::new(0, "NC-replaced").unwrap(); replacement.ack_required = [false; 3]; replacement.add_destination( crate::server::event_notifications_tests::local_broadcast_destination(), ); - db.write().await.add(Box::new(replacement)).unwrap(); + guard.add(Box::new(replacement)).unwrap(); } let sent = StdArc::new(StdMutex::new(Vec::new())); @@ -243,6 +342,16 @@ async fn event_enrollment_ack_policy_is_the_commit_time_snapshot() { notifications[0].ack_required, "send-time Notification Class edits must not replace committed ACK policy" ); + assert_eq!( + notifications[0].event_values, + Some(NotificationParameters::OutOfRange { + exceeding_value: 85.0, + status_flags: 0, + deadband: 2.0, + exceeded_limit: 80.0, + }), + "unconfirmed delivery must retain the committed monitored value" + ); assert_eq!( db.write().await.reserve_event_sequence_number().number(), 1, @@ -326,10 +435,18 @@ async fn reliability_producers_and_fault_cycle_deliver_change_of_reliability() { assert_committed_reliability_notifications( &db, &entries, - &[10, 11, 12, 13], + &[11, 12, 13], EventState::NORMAL, EventState::FAULT, - 0, + 1, + ); + assert_eq!( + event_state( + &db, + ObjectIdentifier::new(ObjectType::EVENT_ENROLLMENT, 10).unwrap() + ), + EventState::FAULT, + "malformed monitored Reliability still commits locally" ); } @@ -471,7 +588,17 @@ async fn mixed_normal_and_reliability_commits_preserve_enrollment_order() { .into_iter() .map(|notification| notification.event_object_identifier.instance_number()) .collect(); - assert_eq!(object_order, vec![20, 21, 22]); + assert_eq!(object_order, vec![20, 22]); + let db = server.database().read().await; + assert_eq!( + event_state( + &db, + ObjectIdentifier::new(ObjectType::EVENT_ENROLLMENT, 21).unwrap(), + ), + EventState::FAULT, + "missing Object_Property_Reference suppresses only the frame after commit" + ); + drop(db); server.stop().await.unwrap(); } diff --git a/crates/bacnet-server/src/server/event_message_policy_tests.rs b/crates/bacnet-server/src/server/event_message_policy_tests.rs index 34190855..443de92a 100644 --- a/crates/bacnet-server/src/server/event_message_policy_tests.rs +++ b/crates/bacnet-server/src/server/event_message_policy_tests.rs @@ -118,7 +118,13 @@ fn policy_format_uses_object_and_state_display_including_unknown_state_numbers() )) .unwrap(); - assert!(commit(&mut db, oid, EventState::NORMAL, unknown, true).is_some()); + let committed = commit(&mut db, oid, EventState::NORMAL, unknown, true) + .expect("the local transition and message commit independently of wire projection"); + assert!( + !crate::server::event_notifications::ResolvedIntrinsicTransition::Committed(committed) + .can_emit(), + "an unsupported structured projection suppresses only the outbound frame" + ); assert_eq!( message_slots(&db, oid), [ diff --git a/crates/bacnet-server/src/server/event_network_priority_tests.rs b/crates/bacnet-server/src/server/event_network_priority_tests.rs index a09355e5..12e899b8 100644 --- a/crates/bacnet-server/src/server/event_network_priority_tests.rs +++ b/crates/bacnet-server/src/server/event_network_priority_tests.rs @@ -8,7 +8,7 @@ //! //! Split from `event_notifications_tests.rs`, which is near the 700-LOC cap. -use super::event_notifications::network_priority_for_event; +use super::event_recipient_route::network_priority_for_event; use super::event_recipient_routing_tests::{ address_recipient, destination_for, distribute_with_priority, }; diff --git a/crates/bacnet-server/src/server/event_notification_payload.rs b/crates/bacnet-server/src/server/event_notification_payload.rs new file mode 100644 index 00000000..3a7f9b5a --- /dev/null +++ b/crates/bacnet-server/src/server/event_notification_payload.rs @@ -0,0 +1,715 @@ +//! Closed projection of committed built-in and Event Enrollment transitions. +//! +//! Every value is selected explicitly from the evaluated source while the +//! server still owns the database write guard. The resulting private wrapper +//! is the immutable payload carried to all recipients and confirmed retries. + +use bacnet_encoding::constructed::encode_property_state; +use bacnet_encoding::primitives::encode_property_value; +use bacnet_encoding::{constructed::validate_tlv_sequence, tags}; +use bacnet_objects::database::ObjectDatabase; +use bacnet_objects::event::EventStateChange; +use bacnet_objects::traits::BACnetObject; +use bacnet_services::alarm_event::{ChangeOfValueChoice, NotificationParameters}; +use bacnet_services::common::BACnetPropertyValue; +use bacnet_types::constructed::{BACnetEventParameter, BACnetPropertyStates}; +use bacnet_types::enums::{EventState, EventType, ObjectType, PropertyIdentifier}; +use bacnet_types::primitives::{ObjectIdentifier, PropertyValue}; +use bytes::BytesMut; + +/// One validated notification-parameter value captured for a committed event. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct CommittedNotificationPayload(NotificationParameters); + +#[derive(Clone, Copy)] +pub(crate) enum CapturedStatusFlags { + Value(u8), + Unavailable, + Malformed, +} + +#[derive(Clone)] +pub(crate) enum CapturedReferencedValue { + NotEvaluated, + Value(PropertyValue), + Unavailable, +} + +impl CapturedReferencedValue { + pub(crate) fn from_evaluated(value: Option<&PropertyValue>) -> Self { + value.cloned().map_or(Self::NotEvaluated, Self::Value) + } +} + +#[derive(Clone)] +pub(crate) struct EventEnrollmentProjectionSnapshot { + reference: MonitoredReference, + monitored_value: PropertyValue, + parameters: BACnetEventParameter, + status_flags: CapturedStatusFlags, + setpoint_value: Option, +} + +impl EventEnrollmentProjectionSnapshot { + pub(crate) fn new( + object_identifier: ObjectIdentifier, + property_identifier: PropertyIdentifier, + array_index: Option, + monitored_value: PropertyValue, + parameters: BACnetEventParameter, + status_flags: CapturedStatusFlags, + setpoint_value: Option, + ) -> Self { + Self { + reference: MonitoredReference { + object_identifier, + property_identifier, + array_index, + }, + monitored_value, + parameters, + status_flags, + setpoint_value, + } + } +} + +impl CommittedNotificationPayload { + pub(super) fn into_parameters(self) -> NotificationParameters { + self.0 + } + + #[cfg(test)] + pub(crate) fn for_test(parameters: NotificationParameters) -> Self { + Self(parameters) + } +} + +#[derive(Clone, Copy)] +struct MonitoredReference { + object_identifier: ObjectIdentifier, + property_identifier: PropertyIdentifier, + array_index: Option, +} + +enum OptionalProjectionValue { + Unavailable, + Value(PropertyValue), + Malformed, +} + +/// Project a built-in intrinsic source after its transition commit. +pub(crate) fn project_intrinsic_payload( + object: &dyn BACnetObject, + change: &EventStateChange, + event_type: EventType, +) -> Option { + let object_type = object.object_identifier().object_type(); + let params = if change.from == EventState::FAULT || change.to == EventState::FAULT { + (event_type == EventType::CHANGE_OF_RELIABILITY) + .then(|| project_builtin_reliability(object, object_type))?? + } else { + match object_type { + ObjectType::ANALOG_INPUT | ObjectType::ANALOG_OUTPUT | ObjectType::ANALOG_VALUE + if event_type == EventType::OUT_OF_RANGE => + { + project_builtin_out_of_range(object, change)? + } + ObjectType::BINARY_INPUT + | ObjectType::BINARY_VALUE + | ObjectType::MULTI_STATE_INPUT + | ObjectType::MULTI_STATE_VALUE + if event_type == EventType::CHANGE_OF_STATE => + { + project_builtin_change_of_state(object, object_type)? + } + ObjectType::BINARY_OUTPUT | ObjectType::MULTI_STATE_OUTPUT + if event_type == EventType::COMMAND_FAILURE => + { + project_builtin_command_failure(object, object_type)? + } + _ => return None, + } + }; + Some(CommittedNotificationPayload(params)) +} + +/// Project an Event Enrollment source after its transition commit. +pub(crate) fn project_event_enrollment_payload( + db: &ObjectDatabase, + enrollment_oid: ObjectIdentifier, + expected_monitored_oid: Option, + change: &EventStateChange, + event_type: EventType, + snapshot: Option<&EventEnrollmentProjectionSnapshot>, + reliability_value: Option<&CapturedReferencedValue>, +) -> Option { + let params = if change.from == EventState::FAULT || change.to == EventState::FAULT { + if event_type != EventType::CHANGE_OF_RELIABILITY { + return None; + } + let enrollment = db.get(&enrollment_oid)?; + let (reference_value, reference) = read_monitored_reference(enrollment)?; + if expected_monitored_oid.is_some_and(|expected| expected != reference.object_identifier) { + return None; + } + project_event_enrollment_reliability( + db, + enrollment, + reference_value, + reference, + reliability_value?, + )? + } else { + if event_type == EventType::NONE { + return None; + } + let snapshot = snapshot?; + if expected_monitored_oid + .is_some_and(|expected| expected != snapshot.reference.object_identifier) + { + return None; + } + project_event_enrollment_normal(snapshot, change, event_type)? + }; + Some(CommittedNotificationPayload(params)) +} + +fn project_builtin_out_of_range( + object: &dyn BACnetObject, + change: &EventStateChange, +) -> Option { + let exceeding_value = read_real(object, PropertyIdentifier::PRESENT_VALUE)?; + let deadband = read_real(object, PropertyIdentifier::DEADBAND)?; + if deadband < 0.0 { + return None; + } + let exceeded_limit = selected_limit( + change, + read_real(object, PropertyIdentifier::LOW_LIMIT)?, + read_real(object, PropertyIdentifier::HIGH_LIMIT)?, + )?; + Some(NotificationParameters::OutOfRange { + exceeding_value, + status_flags: required_status_flags(object)?, + deadband, + exceeded_limit, + }) +} + +fn project_builtin_change_of_state( + object: &dyn BACnetObject, + object_type: ObjectType, +) -> Option { + let present_value = object + .read_property(PropertyIdentifier::PRESENT_VALUE, None) + .ok()?; + let new_state = match (object_type, present_value) { + (ObjectType::BINARY_INPUT | ObjectType::BINARY_VALUE, PropertyValue::Enumerated(value)) + if value <= 1 => + { + BACnetPropertyStates::BinaryValue(value) + } + ( + ObjectType::MULTI_STATE_INPUT | ObjectType::MULTI_STATE_VALUE, + PropertyValue::Unsigned(value), + ) if value > 0 => BACnetPropertyStates::UnsignedValue(u32::try_from(value).ok()?), + _ => return None, + }; + Some(NotificationParameters::ChangeOfState { + new_state, + status_flags: required_status_flags(object)?, + }) +} + +fn project_builtin_command_failure( + object: &dyn BACnetObject, + object_type: ObjectType, +) -> Option { + let command = object + .read_property(PropertyIdentifier::PRESENT_VALUE, None) + .ok()?; + let feedback = object + .read_property(PropertyIdentifier::FEEDBACK_VALUE, None) + .ok()?; + match (&command, &feedback, object_type) { + (PropertyValue::Enumerated(a), PropertyValue::Enumerated(b), ObjectType::BINARY_OUTPUT) + if *a <= 1 && *b <= 1 => {} + ( + PropertyValue::Unsigned(a), + PropertyValue::Unsigned(b), + ObjectType::MULTI_STATE_OUTPUT, + ) if *a > 0 && *b > 0 && u32::try_from(*a).is_ok() && u32::try_from(*b).is_ok() => {} + _ => return None, + } + Some(NotificationParameters::CommandFailure { + command_value: encode_abstract_value(&command)?, + status_flags: required_status_flags(object)?, + feedback_value: encode_abstract_value(&feedback)?, + }) +} + +fn project_builtin_reliability( + object: &dyn BACnetObject, + object_type: ObjectType, +) -> Option { + let PropertyValue::Enumerated(reliability) = object + .read_property(PropertyIdentifier::RELIABILITY, None) + .ok()? + else { + return None; + }; + let present = object + .read_property(PropertyIdentifier::PRESENT_VALUE, None) + .ok()?; + validate_builtin_present_value(object_type, &present)?; + + let mut property_values = Vec::new(); + append_property_value( + &mut property_values, + PropertyIdentifier::PRESENT_VALUE, + None, + &present, + )?; + match object_type { + ObjectType::BINARY_OUTPUT | ObjectType::MULTI_STATE_OUTPUT => { + let feedback = object + .read_property(PropertyIdentifier::FEEDBACK_VALUE, None) + .ok()?; + validate_builtin_feedback_value(object_type, &feedback)?; + append_property_value( + &mut property_values, + PropertyIdentifier::FEEDBACK_VALUE, + None, + &feedback, + )?; + } + ObjectType::ANALOG_INPUT + | ObjectType::ANALOG_OUTPUT + | ObjectType::ANALOG_VALUE + | ObjectType::BINARY_INPUT + | ObjectType::BINARY_VALUE + | ObjectType::MULTI_STATE_INPUT + | ObjectType::MULTI_STATE_VALUE => {} + _ => return None, + } + + Some(NotificationParameters::ChangeOfReliability { + reliability, + status_flags: required_status_flags(object)?, + property_values, + }) +} + +fn project_event_enrollment_normal( + snapshot: &EventEnrollmentProjectionSnapshot, + change: &EventStateChange, + event_type: EventType, +) -> Option { + let reference = snapshot.reference; + let monitored_value = snapshot.monitored_value.clone(); + let status_flags = match snapshot.status_flags { + CapturedStatusFlags::Value(value) => value, + CapturedStatusFlags::Unavailable => 0, + CapturedStatusFlags::Malformed => return None, + }; + let parameters = snapshot.parameters.clone(); + + match (event_type, parameters) { + (EventType::CHANGE_OF_BITSTRING, BACnetEventParameter::ChangeOfBitstring { .. }) => { + let PropertyValue::BitString { unused_bits, data } = monitored_value else { + return None; + }; + validate_bitstring(unused_bits, &data)?; + Some(NotificationParameters::ChangeOfBitstring { + referenced_bitstring: (unused_bits, data), + status_flags, + }) + } + ( + EventType::CHANGE_OF_STATE, + BACnetEventParameter::ChangeOfState { list_of_values, .. }, + ) => Some(NotificationParameters::ChangeOfState { + new_state: property_state_for_value( + &monitored_value, + reference.object_identifier.object_type(), + reference.property_identifier, + &list_of_values, + )?, + status_flags, + }), + (EventType::CHANGE_OF_VALUE, BACnetEventParameter::ChangeOfValue { criteria, .. }) => { + let new_value = match (criteria, monitored_value) { + ( + bacnet_types::constructed::ChangeOfValueCriteria::ReferencedPropertyIncrement( + _, + ), + PropertyValue::Real(value), + ) if value.is_finite() => ChangeOfValueChoice::ChangedValue(value), + ( + bacnet_types::constructed::ChangeOfValueCriteria::Bitmask { .. }, + PropertyValue::BitString { unused_bits, data }, + ) => { + validate_bitstring(unused_bits, &data)?; + ChangeOfValueChoice::ChangedBits { unused_bits, data } + } + _ => return None, + }; + Some(NotificationParameters::ChangeOfValue { + new_value, + status_flags, + }) + } + ( + EventType::FLOATING_LIMIT, + BACnetEventParameter::FloatingLimit { + setpoint_reference, + low_diff_limit, + high_diff_limit, + .. + }, + ) => { + let PropertyValue::Real(reference_value) = monitored_value else { + return None; + }; + if !reference_value.is_finite() + || !low_diff_limit.is_finite() + || !high_diff_limit.is_finite() + { + return None; + } + if setpoint_reference.device_identifier.is_some() { + return None; + } + let setpoint_value = snapshot.setpoint_value?; + if !setpoint_value.is_finite() { + return None; + } + Some(NotificationParameters::FloatingLimit { + reference_value, + status_flags, + setpoint_value, + error_limit: selected_limit(change, low_diff_limit, high_diff_limit)?, + }) + } + ( + EventType::OUT_OF_RANGE, + BACnetEventParameter::OutOfRange { + low_limit, + high_limit, + deadband, + .. + }, + ) => { + let PropertyValue::Real(exceeding_value) = monitored_value else { + return None; + }; + if !exceeding_value.is_finite() + || !low_limit.is_finite() + || !high_limit.is_finite() + || !deadband.is_finite() + || deadband < 0.0 + { + return None; + } + Some(NotificationParameters::OutOfRange { + exceeding_value, + status_flags, + deadband, + exceeded_limit: selected_limit(change, low_limit, high_limit)?, + }) + } + _ => None, + } +} + +fn project_event_enrollment_reliability( + db: &ObjectDatabase, + enrollment: &dyn BACnetObject, + reference_value: PropertyValue, + reference: MonitoredReference, + captured_value: &CapturedReferencedValue, +) -> Option { + let PropertyValue::Enumerated(reliability) = enrollment + .read_property(PropertyIdentifier::RELIABILITY, None) + .ok()? + else { + return None; + }; + let mut property_values = Vec::new(); + append_property_value( + &mut property_values, + PropertyIdentifier::OBJECT_PROPERTY_REFERENCE, + None, + &reference_value, + )?; + + if let Some(monitored) = db.get(&reference.object_identifier) { + let referenced_value = match captured_value { + CapturedReferencedValue::Value(value) => Some(value.clone()), + CapturedReferencedValue::Unavailable => None, + CapturedReferencedValue::NotEvaluated => monitored + .read_property(reference.property_identifier, reference.array_index) + .ok(), + }; + if let Some(value) = referenced_value { + append_property_value( + &mut property_values, + reference.property_identifier, + reference.array_index, + &value, + )?; + } + match optional_reliability(monitored) { + OptionalProjectionValue::Value(value) => append_property_value( + &mut property_values, + PropertyIdentifier::RELIABILITY, + None, + &value, + )?, + OptionalProjectionValue::Unavailable => {} + OptionalProjectionValue::Malformed => return None, + } + match optional_status_flags(monitored) { + OptionalProjectionValue::Value(value) => append_property_value( + &mut property_values, + PropertyIdentifier::STATUS_FLAGS, + None, + &value, + )?, + OptionalProjectionValue::Unavailable => {} + OptionalProjectionValue::Malformed => return None, + } + } + + Some(NotificationParameters::ChangeOfReliability { + reliability, + status_flags: required_status_flags(enrollment)?, + property_values, + }) +} + +fn read_monitored_reference( + enrollment: &dyn BACnetObject, +) -> Option<(PropertyValue, MonitoredReference)> { + let value = enrollment + .read_property(PropertyIdentifier::OBJECT_PROPERTY_REFERENCE, None) + .ok()?; + let PropertyValue::List(items) = &value else { + return None; + }; + if !(2..=4).contains(&items.len()) { + return None; + } + let PropertyValue::ObjectIdentifier(object_identifier) = items[0] else { + return None; + }; + let PropertyValue::Unsigned(raw_property) = items[1] else { + return None; + }; + let raw_property = u32::try_from(raw_property).ok()?; + if raw_property > 0x3f_ffff { + return None; + } + let array_index = match items.get(2) { + None | Some(PropertyValue::Null) => None, + Some(PropertyValue::Unsigned(index)) => Some(u32::try_from(*index).ok()?), + Some(_) => return None, + }; + match items.get(3) { + None | Some(PropertyValue::Null) | Some(PropertyValue::ObjectIdentifier(_)) => {} + Some(_) => return None, + } + Some(( + value, + MonitoredReference { + object_identifier, + property_identifier: PropertyIdentifier::from_raw(raw_property), + array_index, + }, + )) +} + +fn property_state_for_value( + value: &PropertyValue, + object_type: ObjectType, + property: PropertyIdentifier, + alarm_values: &[BACnetPropertyStates], +) -> Option { + match value { + PropertyValue::Boolean(value) => Some(BACnetPropertyStates::BooleanValue(*value)), + PropertyValue::Signed(value) => Some(BACnetPropertyStates::IntegerValue(*value)), + PropertyValue::Unsigned(value) => Some(BACnetPropertyStates::UnsignedValue( + u32::try_from(*value).ok()?, + )), + PropertyValue::Enumerated(value) + if property == PropertyIdentifier::PRESENT_VALUE + && matches!( + object_type, + ObjectType::BINARY_INPUT | ObjectType::BINARY_OUTPUT | ObjectType::BINARY_VALUE + ) + && *value <= 1 => + { + Some(BACnetPropertyStates::BinaryValue(*value)) + } + PropertyValue::Enumerated(value) => { + let mut tag = None; + for alarm in alarm_values { + let mut encoded = BytesMut::new(); + encode_property_state(&mut encoded, alarm).ok()?; + let (candidate, _) = tags::decode_tag(&encoded, 0).ok()?; + if candidate.is_opening || candidate.is_closing || candidate.number >= 63 { + return None; + } + match tag { + Some(existing) if existing != candidate.number => return None, + None => tag = Some(candidate.number), + _ => {} + } + } + let tag = tag?; + let mut encoded = BytesMut::new(); + bacnet_encoding::primitives::encode_ctx_enumerated(&mut encoded, tag, *value); + let (state, consumed) = + bacnet_encoding::constructed::decode_property_state(&encoded, 0).ok()?; + (consumed == encoded.len()).then_some(state) + } + _ => None, + } +} + +fn selected_limit(change: &EventStateChange, low: f32, high: f32) -> Option { + if !low.is_finite() || !high.is_finite() { + return None; + } + if change.to == EventState::LOW_LIMIT + || (change.from == EventState::LOW_LIMIT && change.to == EventState::NORMAL) + { + Some(low) + } else if change.to == EventState::HIGH_LIMIT + || (change.from == EventState::HIGH_LIMIT && change.to == EventState::NORMAL) + { + Some(high) + } else { + None + } +} + +fn read_real(object: &dyn BACnetObject, property: PropertyIdentifier) -> Option { + let PropertyValue::Real(value) = object.read_property(property, None).ok()? else { + return None; + }; + value.is_finite().then_some(value) +} + +fn required_status_flags(object: &dyn BACnetObject) -> Option { + let value = object + .read_property(PropertyIdentifier::STATUS_FLAGS, None) + .ok()?; + status_flags(&value) +} + +pub(crate) fn capture_status_flags(object: &dyn BACnetObject) -> CapturedStatusFlags { + match object.read_property(PropertyIdentifier::STATUS_FLAGS, None) { + Ok(value) => status_flags(&value) + .map(CapturedStatusFlags::Value) + .unwrap_or(CapturedStatusFlags::Malformed), + Err(_) => CapturedStatusFlags::Unavailable, + } +} + +fn optional_status_flags(object: &dyn BACnetObject) -> OptionalProjectionValue { + match object.read_property(PropertyIdentifier::STATUS_FLAGS, None) { + Ok(value) if status_flags(&value).is_some() => OptionalProjectionValue::Value(value), + Ok(_) => OptionalProjectionValue::Malformed, + Err(_) => OptionalProjectionValue::Unavailable, + } +} + +fn optional_reliability(object: &dyn BACnetObject) -> OptionalProjectionValue { + match object.read_property(PropertyIdentifier::RELIABILITY, None) { + Ok(value @ PropertyValue::Enumerated(_)) => OptionalProjectionValue::Value(value), + Ok(_) => OptionalProjectionValue::Malformed, + Err(_) => OptionalProjectionValue::Unavailable, + } +} + +fn status_flags(value: &PropertyValue) -> Option { + let PropertyValue::BitString { unused_bits, data } = value else { + return None; + }; + (*unused_bits == 4 && data.len() == 1 && data[0] & 0x0f == 0).then_some(data[0] >> 4) +} + +fn validate_bitstring(unused_bits: u8, data: &[u8]) -> Option<()> { + if unused_bits > 7 || (data.is_empty() && unused_bits != 0) { + return None; + } + let trailing_mask = (1u8.checked_shl(u32::from(unused_bits))?).wrapping_sub(1); + data.last() + .is_none_or(|last| last & trailing_mask == 0) + .then_some(()) +} + +fn validate_builtin_present_value(object_type: ObjectType, value: &PropertyValue) -> Option<()> { + match (object_type, value) { + ( + ObjectType::ANALOG_INPUT | ObjectType::ANALOG_OUTPUT | ObjectType::ANALOG_VALUE, + PropertyValue::Real(value), + ) if value.is_finite() => Some(()), + ( + ObjectType::BINARY_INPUT | ObjectType::BINARY_OUTPUT | ObjectType::BINARY_VALUE, + PropertyValue::Enumerated(value), + ) if *value <= 1 => Some(()), + ( + ObjectType::MULTI_STATE_INPUT + | ObjectType::MULTI_STATE_OUTPUT + | ObjectType::MULTI_STATE_VALUE, + PropertyValue::Unsigned(value), + ) if *value > 0 && u32::try_from(*value).is_ok() => Some(()), + _ => None, + } +} + +fn validate_builtin_feedback_value(object_type: ObjectType, value: &PropertyValue) -> Option<()> { + match (object_type, value) { + (ObjectType::BINARY_OUTPUT, PropertyValue::Enumerated(value)) if *value <= 1 => Some(()), + (ObjectType::MULTI_STATE_OUTPUT, PropertyValue::Unsigned(value)) + if *value > 0 && u32::try_from(*value).is_ok() => + { + Some(()) + } + _ => None, + } +} + +fn encode_abstract_value(value: &PropertyValue) -> Option> { + let mut encoded = BytesMut::new(); + encode_property_value(&mut encoded, value).ok()?; + validate_tlv_sequence(&encoded, "committed notification abstract value").ok()?; + Some(encoded.to_vec()) +} + +fn append_property_value( + encoded: &mut Vec, + property_identifier: PropertyIdentifier, + property_array_index: Option, + value: &PropertyValue, +) -> Option<()> { + let value = encode_abstract_value(value)?; + let property_value = BACnetPropertyValue { + property_identifier, + property_array_index, + value, + priority: None, + }; + let mut entry = BytesMut::new(); + property_value.encode(&mut entry); + validate_tlv_sequence(&entry, "committed reliability property value").ok()?; + encoded.extend_from_slice(&entry); + Some(()) +} + +#[cfg(test)] +#[path = "event_notification_payload_tests.rs"] +mod tests; diff --git a/crates/bacnet-server/src/server/event_notification_payload_tests.rs b/crates/bacnet-server/src/server/event_notification_payload_tests.rs new file mode 100644 index 00000000..628da1ae --- /dev/null +++ b/crates/bacnet-server/src/server/event_notification_payload_tests.rs @@ -0,0 +1,359 @@ +use super::*; +use bacnet_types::enums::{ErrorClass, ErrorCode}; +use std::borrow::Cow; + +struct BuiltInProjectionObject { + oid: ObjectIdentifier, + present_value: PropertyValue, + feedback_value: Option, + reliability: PropertyValue, + status_flags: PropertyValue, +} + +impl BuiltInProjectionObject { + fn new( + instance: u32, + object_type: ObjectType, + present_value: PropertyValue, + feedback_value: Option, + ) -> Self { + Self { + oid: ObjectIdentifier::new(object_type, instance).unwrap(), + present_value, + feedback_value, + reliability: PropertyValue::Enumerated(2), + status_flags: PropertyValue::BitString { + unused_bits: 4, + data: vec![0xc0], + }, + } + } +} + +impl BACnetObject for BuiltInProjectionObject { + fn object_identifier(&self) -> ObjectIdentifier { + self.oid + } + + fn object_name(&self) -> &str { + "projection-source" + } + + fn read_property( + &self, + property: PropertyIdentifier, + _array_index: Option, + ) -> Result { + match property { + p if p == PropertyIdentifier::PRESENT_VALUE => Ok(self.present_value.clone()), + p if p == PropertyIdentifier::FEEDBACK_VALUE => { + self.feedback_value + .clone() + .ok_or(bacnet_types::error::Error::Protocol { + class: ErrorClass::PROPERTY.to_raw() as u32, + code: ErrorCode::UNKNOWN_PROPERTY.to_raw() as u32, + }) + } + p if p == PropertyIdentifier::RELIABILITY => Ok(self.reliability.clone()), + p if p == PropertyIdentifier::STATUS_FLAGS => Ok(self.status_flags.clone()), + p if p == PropertyIdentifier::HIGH_LIMIT => Ok(PropertyValue::Real(80.0)), + p if p == PropertyIdentifier::LOW_LIMIT => Ok(PropertyValue::Real(20.0)), + p if p == PropertyIdentifier::DEADBAND => Ok(PropertyValue::Real(2.0)), + _ => Err(bacnet_types::error::Error::Protocol { + class: ErrorClass::PROPERTY.to_raw() as u32, + code: ErrorCode::UNKNOWN_PROPERTY.to_raw() as u32, + }), + } + } + + fn write_property( + &mut self, + _property: PropertyIdentifier, + _array_index: Option, + _value: PropertyValue, + _priority: Option, + ) -> Result<(), bacnet_types::error::Error> { + Err(bacnet_types::error::Error::Protocol { + class: ErrorClass::PROPERTY.to_raw() as u32, + code: ErrorCode::WRITE_ACCESS_DENIED.to_raw() as u32, + }) + } + + fn property_list(&self) -> Cow<'static, [PropertyIdentifier]> { + Cow::Borrowed(&[]) + } +} + +fn normal_payload(object: &BuiltInProjectionObject) -> NotificationParameters { + let (event_type, to) = match object.oid.object_type() { + ObjectType::ANALOG_INPUT | ObjectType::ANALOG_OUTPUT | ObjectType::ANALOG_VALUE => { + (EventType::OUT_OF_RANGE, EventState::HIGH_LIMIT) + } + ObjectType::BINARY_INPUT + | ObjectType::BINARY_VALUE + | ObjectType::MULTI_STATE_INPUT + | ObjectType::MULTI_STATE_VALUE => (EventType::CHANGE_OF_STATE, EventState::OFFNORMAL), + ObjectType::BINARY_OUTPUT | ObjectType::MULTI_STATE_OUTPUT => { + (EventType::COMMAND_FAILURE, EventState::OFFNORMAL) + } + other => panic!("unexpected built-in type {other:?}"), + }; + project_intrinsic_payload( + object, + &EventStateChange { + from: EventState::NORMAL, + to, + }, + event_type, + ) + .unwrap() + .0 +} + +fn all_nine_sources() -> Vec<(BuiltInProjectionObject, NotificationParameters)> { + vec![ + ( + BuiltInProjectionObject::new( + 1, + ObjectType::ANALOG_INPUT, + PropertyValue::Real(85.0), + None, + ), + NotificationParameters::OutOfRange { + exceeding_value: 85.0, + status_flags: 0b1100, + deadband: 2.0, + exceeded_limit: 80.0, + }, + ), + ( + BuiltInProjectionObject::new( + 2, + ObjectType::ANALOG_OUTPUT, + PropertyValue::Real(85.0), + None, + ), + NotificationParameters::OutOfRange { + exceeding_value: 85.0, + status_flags: 0b1100, + deadband: 2.0, + exceeded_limit: 80.0, + }, + ), + ( + BuiltInProjectionObject::new( + 3, + ObjectType::ANALOG_VALUE, + PropertyValue::Real(85.0), + None, + ), + NotificationParameters::OutOfRange { + exceeding_value: 85.0, + status_flags: 0b1100, + deadband: 2.0, + exceeded_limit: 80.0, + }, + ), + ( + BuiltInProjectionObject::new( + 4, + ObjectType::BINARY_INPUT, + PropertyValue::Enumerated(1), + None, + ), + NotificationParameters::ChangeOfState { + new_state: BACnetPropertyStates::BinaryValue(1), + status_flags: 0b1100, + }, + ), + ( + BuiltInProjectionObject::new( + 5, + ObjectType::BINARY_VALUE, + PropertyValue::Enumerated(1), + None, + ), + NotificationParameters::ChangeOfState { + new_state: BACnetPropertyStates::BinaryValue(1), + status_flags: 0b1100, + }, + ), + ( + BuiltInProjectionObject::new( + 6, + ObjectType::MULTI_STATE_INPUT, + PropertyValue::Unsigned(3), + None, + ), + NotificationParameters::ChangeOfState { + new_state: BACnetPropertyStates::UnsignedValue(3), + status_flags: 0b1100, + }, + ), + ( + BuiltInProjectionObject::new( + 7, + ObjectType::MULTI_STATE_VALUE, + PropertyValue::Unsigned(3), + None, + ), + NotificationParameters::ChangeOfState { + new_state: BACnetPropertyStates::UnsignedValue(3), + status_flags: 0b1100, + }, + ), + ( + BuiltInProjectionObject::new( + 8, + ObjectType::BINARY_OUTPUT, + PropertyValue::Enumerated(1), + Some(PropertyValue::Enumerated(0)), + ), + NotificationParameters::CommandFailure { + command_value: vec![0x91, 0x01], + status_flags: 0b1100, + feedback_value: vec![0x91, 0x00], + }, + ), + ( + BuiltInProjectionObject::new( + 9, + ObjectType::MULTI_STATE_OUTPUT, + PropertyValue::Unsigned(3), + Some(PropertyValue::Unsigned(2)), + ), + NotificationParameters::CommandFailure { + command_value: vec![0x21, 0x03], + status_flags: 0b1100, + feedback_value: vec![0x21, 0x02], + }, + ), + ] +} + +#[test] +fn all_nine_builtin_normal_families_project_exact_typed_values() { + for (source, expected) in all_nine_sources() { + assert_eq!(normal_payload(&source), expected, "source {}", source.oid); + } +} + +#[test] +fn builtin_fault_projection_is_tag_19_with_explicit_property_order() { + for (source, _) in all_nine_sources() { + let payload = project_intrinsic_payload( + &source, + &EventStateChange { + from: EventState::NORMAL, + to: EventState::FAULT, + }, + EventType::CHANGE_OF_RELIABILITY, + ) + .unwrap() + .0; + let NotificationParameters::ChangeOfReliability { + reliability, + status_flags, + property_values, + } = payload + else { + panic!("{} did not project CHANGE_OF_RELIABILITY", source.oid); + }; + assert_eq!(reliability, 2); + assert_eq!(status_flags, 0b1100); + + let mut decoded = Vec::new(); + let mut offset = 0; + while offset < property_values.len() { + let (entry, next) = BACnetPropertyValue::decode(&property_values, offset).unwrap(); + assert!(next > offset); + decoded.push(entry); + offset = next; + } + let expected_properties = if matches!( + source.oid.object_type(), + ObjectType::BINARY_OUTPUT | ObjectType::MULTI_STATE_OUTPUT + ) { + vec![ + PropertyIdentifier::PRESENT_VALUE, + PropertyIdentifier::FEEDBACK_VALUE, + ] + } else { + vec![PropertyIdentifier::PRESENT_VALUE] + }; + assert_eq!( + decoded + .iter() + .map(|entry| entry.property_identifier) + .collect::>(), + expected_properties, + "{} property order", + source.oid + ); + assert_eq!( + decoded[0].value, + encode_abstract_value(&source.present_value).unwrap() + ); + if let Some(feedback) = &source.feedback_value { + assert_eq!(decoded[1].value, encode_abstract_value(feedback).unwrap()); + } + } +} + +#[test] +fn fault_recovery_requires_effective_reliability_type_and_none_is_not_projected() { + let source = + BuiltInProjectionObject::new(1, ObjectType::ANALOG_INPUT, PropertyValue::Real(50.0), None); + let recovery = EventStateChange { + from: EventState::FAULT, + to: EventState::NORMAL, + }; + assert!( + project_intrinsic_payload(&source, &recovery, EventType::CHANGE_OF_RELIABILITY).is_some() + ); + assert!(project_intrinsic_payload(&source, &recovery, EventType::OUT_OF_RANGE).is_none()); + assert!(project_intrinsic_payload( + &source, + &EventStateChange { + from: EventState::NORMAL, + to: EventState::HIGH_LIMIT, + }, + EventType::NONE, + ) + .is_none()); +} + +#[test] +fn malformed_required_builtin_data_fails_closed() { + let mut source = + BuiltInProjectionObject::new(1, ObjectType::ANALOG_INPUT, PropertyValue::Real(85.0), None); + source.status_flags = PropertyValue::Unsigned(0); + assert!(project_intrinsic_payload( + &source, + &EventStateChange { + from: EventState::NORMAL, + to: EventState::HIGH_LIMIT, + }, + EventType::OUT_OF_RANGE, + ) + .is_none()); +} + +#[test] +fn limit_selection_covers_entries_crossings_and_normal_recovery() { + let low = 20.0; + let high = 80.0; + for (from, to, expected) in [ + (EventState::NORMAL, EventState::LOW_LIMIT, low), + (EventState::HIGH_LIMIT, EventState::LOW_LIMIT, low), + (EventState::LOW_LIMIT, EventState::NORMAL, low), + (EventState::NORMAL, EventState::HIGH_LIMIT, high), + (EventState::LOW_LIMIT, EventState::HIGH_LIMIT, high), + (EventState::HIGH_LIMIT, EventState::NORMAL, high), + ] { + assert_eq!( + selected_limit(&EventStateChange { from, to }, low, high), + Some(expected) + ); + } +} diff --git a/crates/bacnet-server/src/server/event_notifications.rs b/crates/bacnet-server/src/server/event_notifications.rs index c5a95791..453a5888 100644 --- a/crates/bacnet-server/src/server/event_notifications.rs +++ b/crates/bacnet-server/src/server/event_notifications.rs @@ -1,16 +1,19 @@ use super::event_message_policy::intrinsic_event_message_text; -use super::event_recipient_route::{ConfirmedRecipientRoute, RecipientRoute}; +use super::event_notification_payload::{project_intrinsic_payload, CommittedNotificationPayload}; +use super::event_recipient_route::{ + network_priority_for_event, system_utc_recipient_filter_time, ConfirmedRecipientRoute, + RecipientRoute, +}; use super::event_timestamp::{ confirm_event_timestamp, sample_event_timestamp, stage_event_timestamp, SampledEventClock, }; use super::*; use bacnet_encoding::primitives::decode_timestamp_choice; use bacnet_objects::event::{EventTransition, EventTransitionCommit, TransitionOutcome}; -use bacnet_objects::notification_class::local_day_and_time; use bacnet_objects::traits::BACnetObject; use bacnet_types::constructed::BACnetRecipient; use bacnet_types::enums::EventType; -use bacnet_types::primitives::{BACnetTimeStamp, Time}; +use bacnet_types::primitives::BACnetTimeStamp; use crate::event_enrollment::{CommittedEventEnrollmentDelivery, CommittedEventEnrollmentResult}; @@ -46,6 +49,7 @@ pub(super) struct NotificationTransition { event_type: EventType, history_source: NotificationHistorySource, ack_required: Option, + event_values: Option, } impl From<(EventStateChange, EventType)> for NotificationTransition { @@ -55,6 +59,7 @@ impl From<(EventStateChange, EventType)> for NotificationTransition { event_type, history_source: NotificationHistorySource::SendTime, ack_required: None, + event_values: None, } } } @@ -67,6 +72,7 @@ pub(super) struct CommittedIntrinsicTransition { history_snapshot: CommittedHistorySnapshot, recipient_clock: SampledEventClock, ack_required: bool, + event_values: Option, } impl From for NotificationTransition { @@ -79,6 +85,7 @@ impl From for NotificationTransition { recipient_clock: committed.recipient_clock, }, ack_required: Some(committed.ack_required), + event_values: committed.event_values, } } } @@ -100,6 +107,10 @@ impl ResolvedIntrinsicTransition { Self::Legacy(outcome) => outcome.distribute, } } + + pub(super) fn can_emit(&self) -> bool { + !matches!(self, Self::Committed(committed) if committed.event_values.is_none()) + } } impl From for NotificationTransition { @@ -113,32 +124,6 @@ impl From for NotificationTransition { } } -/// Project an alarm/event priority onto the NPDU Network Priority. -/// -/// Clause 13.2.5.4: "the Network Priority as defined in Clause 6.2.2 shall be -/// set as a function of the alarm and event priority as defined in Table -/// 13-6". Lower event priority is more urgent: 00–63 is a Life Safety -/// message, 64–127 Critical Equipment, 128–191 Urgent, 192–255 Normal. -pub(super) fn network_priority_for_event(priority: u8) -> NetworkPriority { - match priority { - 0..=63 => NetworkPriority::LIFE_SAFETY, - 64..=127 => NetworkPriority::CRITICAL_EQUIPMENT, - 128..=191 => NetworkPriority::URGENT, - 192..=255 => NetworkPriority::NORMAL, - } -} - -/// Operational fallback for recipient-window filtering in clockless mode. -/// -/// This uses system UTC only to avoid dropping an alarm while no Device clock -/// is advertised; it does not create a Device DateTime or change wire -/// timestamp selection. -fn system_utc_recipient_filter_time(now: Duration) -> (u8, Time) { - let (today_bit, mut current_time) = local_day_and_time(now.as_secs(), 0); - current_time.hundredths = (now.subsec_millis() / 10) as u8; - (today_bit, current_time) -} - /// Read one exact committed transition coordinate through the object contract. /// /// Required properties are projected while the caller still owns the database @@ -190,37 +175,20 @@ pub(super) fn resolve_committed_event_enrollment_transition( db: &ObjectDatabase, committed: CommittedEventEnrollmentDelivery, ) -> Option<(ObjectIdentifier, bool, NotificationTransition)> { - let (oid, change, event_type, distribute) = match committed.result { - CommittedEventEnrollmentResult::Normal(result) => ( - result.enrollment_oid, - result.change, - result.event_type, - result.distribute, - ), + let CommittedEventEnrollmentDelivery { + result, + ack_required, + recipient_clock, + event_type, + event_values, + } = committed; + let (oid, change, distribute) = match result { + CommittedEventEnrollmentResult::Normal(result) => { + (result.enrollment_oid, result.change, result.distribute) + } CommittedEventEnrollmentResult::Reliability(result) => { - let object = db.get(&result.enrollment_oid)?; - let PropertyValue::Enumerated(configured_event_type) = object - .read_property(PropertyIdentifier::EVENT_TYPE, None) - .ok()? - else { - return None; - }; - let configured_event_type = EventType::from_raw(configured_event_type); - if ![ - EventType::OUT_OF_RANGE, - EventType::FLOATING_LIMIT, - EventType::CHANGE_OF_STATE, - EventType::CHANGE_OF_BITSTRING, - EventType::CHANGE_OF_VALUE, - EventType::NONE, - ] - .contains(&configured_event_type) - { - return None; - } - let event_type = result.event_type(configured_event_type)?; let change = result.state_change.clone()?; - (result.enrollment_oid, change, event_type, result.distribute) + (result.enrollment_oid, change, result.distribute) } }; @@ -240,9 +208,10 @@ pub(super) fn resolve_committed_event_enrollment_transition( event_type, history_source: NotificationHistorySource::Committed { snapshot: history_snapshot, - recipient_clock: committed.recipient_clock, + recipient_clock, }, - ack_required: Some(committed.ack_required), + ack_required: Some(ack_required), + event_values: Some(event_values), }, )) } @@ -298,13 +267,26 @@ impl BACnetServer { return None; } }; + let event_type = outcome.change.event_type(outcome.event_type); + let event_values = db + .get(oid) + .and_then(|object| project_intrinsic_payload(object, &outcome.change, event_type)) + .or_else(|| { + debug!( + %oid, + ?event_type, + "Committed intrinsic Event Values projection rejected; suppressing distribution" + ); + None + }); Some(CommittedIntrinsicTransition { change: outcome.change, - event_type: outcome.event_type, + event_type, distribute: outcome.distribute, history_snapshot, recipient_clock, ack_required, + event_values, }) } @@ -383,7 +365,7 @@ impl BACnetServer { // updated Acked_Transitions from the Notification Class policy and // stored the selected local message in the transition coordinate. if let Some(resolved) = resolved { - if resolved.distribute() { + if resolved.distribute() && resolved.can_emit() { Self::build_and_send_event_notification_with_bindings( db, network, @@ -453,6 +435,7 @@ impl BACnetServer { event_type, history_source, ack_required: ack_required_snapshot, + event_values, } = transition.into(); let system_utc = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -564,7 +547,11 @@ impl BACnetServer { }, from_state: change.from.to_raw(), to_state: change.to.to_raw(), - event_values: None, + event_values: if notify_type == NotifyType::ACK_NOTIFICATION.to_raw() { + None + } else { + event_values.map(CommittedNotificationPayload::into_parameters) + }, }; (base_notification, recipients) diff --git a/crates/bacnet-server/src/server/event_notifications_history_tests.rs b/crates/bacnet-server/src/server/event_notifications_history_tests.rs index b00c59fb..e485295e 100644 --- a/crates/bacnet-server/src/server/event_notifications_history_tests.rs +++ b/crates/bacnet-server/src/server/event_notifications_history_tests.rs @@ -3,13 +3,16 @@ use crate::event_enrollment::{ CommittedEventEnrollmentDelivery, CommittedEventEnrollmentResult, EventEnrollmentReliabilityCause, EventEnrollmentReliabilityResult, EventEnrollmentTransition, }; +use crate::server::event_notification_payload::CommittedNotificationPayload; use crate::server::event_timestamp::SampledEventClock; use bacnet_objects::event::{ EventStateChange, EventTransitionCommit, EventTransitionCommitError, TransitionOutcome, }; use bacnet_objects::traits::BACnetObject; +use bacnet_services::alarm_event::NotificationParameters; use bacnet_types::enums::{EventState, EventType}; use bacnet_types::primitives::{BACnetTimeStamp, Date, Time}; +use std::sync::atomic::{AtomicUsize, Ordering}; #[derive(Clone)] enum IndexedHistoryRead { @@ -23,6 +26,8 @@ struct AtomicHistoryObject { event_type: Option, timestamps: [IndexedHistoryRead; 3], messages: [IndexedHistoryRead; 3], + status_flags: Option, + commits: Arc, } impl AtomicHistoryObject { @@ -90,6 +95,17 @@ impl BACnetObject for AtomicHistoryObject { p if p == PropertyIdentifier::EVENT_MESSAGE_TEXTS => { Self::indexed(&self.messages, array_index) } + p if p == PropertyIdentifier::PRESENT_VALUE => Ok(PropertyValue::Real(50.0)), + p if p == PropertyIdentifier::STATUS_FLAGS => { + self.status_flags.clone().ok_or_else(|| Error::Protocol { + class: ErrorClass::PROPERTY.to_raw() as u32, + code: ErrorCode::UNKNOWN_PROPERTY.to_raw() as u32, + }) + } + p if p == PropertyIdentifier::RELIABILITY => Ok(PropertyValue::Enumerated(0)), + p if p == PropertyIdentifier::HIGH_LIMIT => Ok(PropertyValue::Real(80.0)), + p if p == PropertyIdentifier::LOW_LIMIT => Ok(PropertyValue::Real(20.0)), + p if p == PropertyIdentifier::DEADBAND => Ok(PropertyValue::Real(2.0)), _ => Err(Error::Protocol { class: ErrorClass::PROPERTY.to_raw() as u32, code: ErrorCode::UNKNOWN_PROPERTY.to_raw() as u32, @@ -119,6 +135,12 @@ impl BACnetObject for AtomicHistoryObject { PropertyIdentifier::NOTIFY_TYPE, PropertyIdentifier::EVENT_TIME_STAMPS, PropertyIdentifier::EVENT_MESSAGE_TEXTS, + PropertyIdentifier::PRESENT_VALUE, + PropertyIdentifier::STATUS_FLAGS, + PropertyIdentifier::RELIABILITY, + PropertyIdentifier::HIGH_LIMIT, + PropertyIdentifier::LOW_LIMIT, + PropertyIdentifier::DEADBAND, ]) } @@ -130,6 +152,7 @@ impl BACnetObject for AtomicHistoryObject { &mut self, _commit: EventTransitionCommit, ) -> Result<(), EventTransitionCommitError> { + self.commits.fetch_add(1, Ordering::Relaxed); Ok(()) } } @@ -160,6 +183,11 @@ fn atomic_history_database( event_type: Some(PropertyValue::Enumerated(EventType::OUT_OF_RANGE.to_raw())), timestamps, messages, + status_flags: Some(PropertyValue::BitString { + unused_bits: 4, + data: vec![0], + }), + commits: Arc::new(AtomicUsize::new(0)), })) .unwrap(); db.add(Box::new( @@ -241,6 +269,13 @@ fn committed_enrollment_normal( }), ack_required: true, recipient_clock: SampledEventClock::Unavailable, + event_type: EventType::OUT_OF_RANGE, + event_values: CommittedNotificationPayload::for_test(NotificationParameters::OutOfRange { + exceeding_value: 1.0, + status_flags: 0, + deadband: 0.0, + exceeded_limit: 1.0, + }), } } @@ -260,6 +295,14 @@ fn committed_enrollment_reliability(oid: ObjectIdentifier) -> CommittedEventEnro }), ack_required: true, recipient_clock: SampledEventClock::Unavailable, + event_type: EventType::CHANGE_OF_RELIABILITY, + event_values: CommittedNotificationPayload::for_test( + NotificationParameters::ChangeOfReliability { + reliability: bacnet_types::enums::Reliability::OVER_RANGE.to_raw(), + status_flags: 0, + property_values: Vec::new(), + }, + ), } } @@ -272,6 +315,11 @@ fn event_enrollment_projection_accepts_intentionally_absent_message_without_muta event_type: Some(PropertyValue::Enumerated(EventType::OUT_OF_RANGE.to_raw())), timestamps: repeated_timestamp_reads(BACnetTimeStamp::SequenceNumber(19)), messages: std::array::from_fn(|_| IndexedHistoryRead::Missing), + status_flags: Some(PropertyValue::BitString { + unused_bits: 4, + data: vec![0], + }), + commits: Arc::new(AtomicUsize::new(0)), })) .unwrap(); @@ -316,6 +364,11 @@ fn malformed_or_unreadable_event_enrollment_history_fails_closed_without_mutatio event_type: Some(PropertyValue::Enumerated(EventType::OUT_OF_RANGE.to_raw())), timestamps, messages: std::array::from_fn(|_| IndexedHistoryRead::Missing), + status_flags: Some(PropertyValue::BitString { + unused_bits: 4, + data: vec![0], + }), + commits: Arc::new(AtomicUsize::new(0)), })) .unwrap(); @@ -337,7 +390,7 @@ fn malformed_or_unreadable_event_enrollment_history_fails_closed_without_mutatio } #[test] -fn unreadable_or_malformed_reliability_event_type_fails_closed_without_mutation() { +fn committed_reliability_effective_type_does_not_reread_configured_event_type() { for (instance, event_type) in [ None, Some(PropertyValue::Unsigned(2)), @@ -353,6 +406,11 @@ fn unreadable_or_malformed_reliability_event_type_fails_closed_without_mutation( event_type, timestamps: repeated_timestamp_reads(BACnetTimeStamp::SequenceNumber(23)), messages: std::array::from_fn(|_| IndexedHistoryRead::Missing), + status_flags: Some(PropertyValue::BitString { + unused_bits: 4, + data: vec![0], + }), + commits: Arc::new(AtomicUsize::new(0)), })) .unwrap(); @@ -361,12 +419,53 @@ fn unreadable_or_malformed_reliability_event_type_fails_closed_without_mutation( &db, committed_enrollment_reliability(oid), ) - .is_none() + .is_some() ); assert_eq!(db.reserve_event_sequence_number().number(), 0); } } +#[test] +fn malformed_or_missing_required_projection_commits_locally_but_cannot_emit() { + for (instance, status_flags) in [Some(PropertyValue::Unsigned(0)), None] + .into_iter() + .enumerate() + { + let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 99 + instance as u32).unwrap(); + let commits = Arc::new(AtomicUsize::new(0)); + let mut db = ObjectDatabase::new(); + db.add(Box::new(AtomicHistoryObject { + oid, + event_type: Some(PropertyValue::Enumerated(EventType::OUT_OF_RANGE.to_raw())), + timestamps: repeated_timestamp_reads(BACnetTimeStamp::SequenceNumber(31)), + messages: std::array::from_fn(|_| empty_message_read()), + status_flags, + commits: Arc::clone(&commits), + })) + .unwrap(); + + let committed = BACnetServer::::commit_intrinsic_transition( + &mut db, + &oid, + TransitionOutcome { + change: EventStateChange { + from: EventState::NORMAL, + to: EventState::HIGH_LIMIT, + }, + event_type: EventType::OUT_OF_RANGE, + distribute: true, + }, + ) + .expect("the local atomic transition remains committed"); + assert_eq!(commits.load(Ordering::Relaxed), 1); + assert!( + !crate::server::event_notifications::ResolvedIntrinsicTransition::Committed(committed) + .can_emit(), + "malformed or missing required values suppress the frame before encoding" + ); + } +} + #[tokio::test] async fn committed_history_preserves_each_timestamp_choice_on_the_wire() { let choices = [ diff --git a/crates/bacnet-server/src/server/event_notifications_tests.rs b/crates/bacnet-server/src/server/event_notifications_tests.rs index a052deea..df44d1fc 100644 --- a/crates/bacnet-server/src/server/event_notifications_tests.rs +++ b/crates/bacnet-server/src/server/event_notifications_tests.rs @@ -115,7 +115,9 @@ async fn dcc_suppresses_periodic_event_send() { /// Panics with a useful message if no notification was sent (so a regression /// that silently drops the notification is caught rather than masking as /// "no broadcast = pass"). -fn decode_broadcast_notification(sent: &StdMutex>) -> EventNotificationRequest { +pub(super) fn decode_broadcast_notification( + sent: &StdMutex>, +) -> EventNotificationRequest { use bacnet_encoding::apdu::decode_apdu; use bacnet_encoding::npdu::decode_npdu; @@ -635,31 +637,6 @@ async fn event_enable_cleared_suppresses_per_write_send() { ); } -/// The other direction: with TO_OFFNORMAL set, the notification IS sent. -/// -/// Paired deliberately with the suppression test — a gate stuck permanently -/// closed would satisfy that one alone. Together they pin the gate to -/// `Event_Enable` rather than to a constant. -#[tokio::test] -async fn event_enable_set_permits_per_write_send() { - // TO_OFFNORMAL only: wire bit 0 = 0x80 (Clause 20.2.10). - let db = db_with_high_limit_transition(0x80); - let sent = broadcasts_from_per_write_path(&db, 0).await; - - assert_eq!( - sent.len(), - 1, - "Event_Enable with TO_OFFNORMAL set must distribute the notification" - ); - let sent = StdMutex::new(sent); - let notif = decode_broadcast_notification(&sent); - assert_eq!( - notif.event_type, - EventType::OUT_OF_RANGE.to_raw(), - "the detector's non-FAULT OUT_OF_RANGE algorithm must reach the wire" - ); -} - /// The periodic `Time_Delay` path has its own `Event_Enable` gate, and it needs /// its own test: the per-write tests above cannot reach it, because a nonzero /// `Time_Delay` makes the per-write probe return `None` by design. diff --git a/crates/bacnet-server/src/server/event_recipient_route.rs b/crates/bacnet-server/src/server/event_recipient_route.rs index 4d5f7208..4ccfeaaa 100644 --- a/crates/bacnet-server/src/server/event_recipient_route.rs +++ b/crates/bacnet-server/src/server/event_recipient_route.rs @@ -1,9 +1,26 @@ use super::device_bindings::{BindingFreshness, DeviceResolution}; use super::*; +use bacnet_objects::notification_class::local_day_and_time; use bacnet_types::constructed::BACnetAddress; +use bacnet_types::primitives::Time; const GLOBAL_BROADCAST_NETWORK: u16 = 0xFFFF; +pub(super) fn network_priority_for_event(priority: u8) -> NetworkPriority { + match priority { + 0..=63 => NetworkPriority::LIFE_SAFETY, + 64..=127 => NetworkPriority::CRITICAL_EQUIPMENT, + 128..=191 => NetworkPriority::URGENT, + 192..=255 => NetworkPriority::NORMAL, + } +} + +pub(super) fn system_utc_recipient_filter_time(now: Duration) -> (u8, Time) { + let (today_bit, mut current_time) = local_day_and_time(now.as_secs(), 0); + current_time.hundredths = (now.subsec_millis() / 10) as u8; + (today_bit, current_time) +} + /// The transport action selected for one matched Notification Class recipient. pub(super) enum RecipientRoute { LocalUnicast(MacAddr), diff --git a/crates/bacnet-server/src/server/lifecycle.rs b/crates/bacnet-server/src/server/lifecycle.rs index c06e4692..5b0dac57 100644 --- a/crates/bacnet-server/src/server/lifecycle.rs +++ b/crates/bacnet-server/src/server/lifecycle.rs @@ -640,7 +640,7 @@ impl BACnetServer { } }); if let Some(resolved) = resolved { - if resolved.distribute() { + if resolved.distribute() && resolved.can_emit() { out.push((oid, resolved)); } } diff --git a/crates/bacnet-server/src/server/mod.rs b/crates/bacnet-server/src/server/mod.rs index c226734d..72bc750a 100644 --- a/crates/bacnet-server/src/server/mod.rs +++ b/crates/bacnet-server/src/server/mod.rs @@ -860,6 +860,7 @@ mod device_bindings; mod dispatch; mod event_enrollment_lifecycle; mod event_message_policy; +pub(crate) mod event_notification_payload; mod event_notifications; mod event_recipient_route; pub(crate) mod event_timestamp; diff --git a/crates/bacnet-services/src/alarm_event/event_notification.rs b/crates/bacnet-services/src/alarm_event/event_notification.rs index 7b30f442..b97bcc39 100644 --- a/crates/bacnet-services/src/alarm_event/event_notification.rs +++ b/crates/bacnet-services/src/alarm_event/event_notification.rs @@ -72,15 +72,19 @@ impl EventNotificationRequest { if self.notify_type != 2 { primitives::encode_ctx_boolean(buf, 9, self.ack_required); } - // [10] fromState - primitives::encode_ctx_enumerated(buf, 10, self.from_state); + // [10] fromState (only for ALARM/EVENT) + if self.notify_type != 2 { + primitives::encode_ctx_enumerated(buf, 10, self.from_state); + } // [11] toState primitives::encode_ctx_enumerated(buf, 11, self.to_state); // [12] eventValues — optional - if let Some(ref params) = self.event_values { - tags::encode_opening_tag(buf, 12); - params.encode(buf)?; - tags::encode_closing_tag(buf, 12); + if self.notify_type != 2 { + if let Some(ref params) = self.event_values { + tags::encode_opening_tag(buf, 12); + params.encode(buf)?; + tags::encode_closing_tag(buf, 12); + } } Ok(()) } @@ -157,10 +161,25 @@ impl EventNotificationRequest { } } - // [10] fromState - let (from_state, new_offset) = - decode_context_u32(data, offset, 10, "EventNotification fromState")?; - offset = new_offset; + // [10] fromState (absent for ACK_NOTIFICATION) + let mut from_state = 0; + if offset < data.len() { + let (peek, _) = tags::decode_tag(data, offset)?; + if peek.is_context(10) { + (from_state, offset) = + decode_context_u32(data, offset, 10, "EventNotification fromState")?; + } else if notify_type != 2 { + return Err(Error::decoding( + offset, + "EventNotification expected fromState", + )); + } + } else if notify_type != 2 { + return Err(Error::decoding( + offset, + "EventNotification missing fromState", + )); + } // [11] toState let (to_state, new_offset) = diff --git a/crates/bacnet-services/src/alarm_event/tests/event_notification_decode.rs b/crates/bacnet-services/src/alarm_event/tests/event_notification_decode.rs index bd56f6df..a63bc832 100644 --- a/crates/bacnet-services/src/alarm_event/tests/event_notification_decode.rs +++ b/crates/bacnet-services/src/alarm_event/tests/event_notification_decode.rs @@ -204,6 +204,51 @@ fn event_notification_preserves_optional_envelope_fields() { assert!(!decoded.ack_required); } +#[test] +fn ack_notification_omits_ack_from_state_and_event_values_exactly() { + let request = EventNotificationRequest { + process_identifier: 1, + initiating_device_identifier: ObjectIdentifier::new(ObjectType::DEVICE, 1).unwrap(), + event_object_identifier: ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 3).unwrap(), + timestamp: BACnetTimeStamp::SequenceNumber(7), + notification_class: 5, + priority: 100, + event_type: 5, + message_text: None, + notify_type: 2, + ack_required: true, + from_state: 4, + to_state: 3, + event_values: Some(NotificationParameters::OutOfRange { + exceeding_value: 85.0, + status_flags: 0b1000, + deadband: 2.0, + exceeded_limit: 80.0, + }), + }; + + let mut encoded = BytesMut::new(); + request.encode(&mut encoded).unwrap(); + assert_eq!( + encoded.as_ref(), + &[ + 0x09, 0x01, 0x1c, 0x02, 0x00, 0x00, 0x01, 0x2c, 0x00, 0x00, 0x00, 0x03, 0x3e, 0x19, + 0x07, 0x3f, 0x49, 0x05, 0x59, 0x64, 0x69, 0x05, 0x89, 0x02, 0xb9, 0x03, + ], + "ACK_NOTIFICATION must end with To State [11] and omit [9], [10], and [12]" + ); + + let decoded = EventNotificationRequest::decode(&encoded).unwrap(); + assert_eq!(decoded.notify_type, 2); + assert!(!decoded.ack_required); + assert_eq!( + decoded.from_state, 0, + "absent From State uses the neutral default" + ); + assert_eq!(decoded.to_state, 3); + assert!(decoded.event_values.is_none()); +} + #[test] fn event_notification_rejects_every_truncated_prefix() { let request = EventNotificationRequest { diff --git a/crates/bacnet-services/src/alarm_event/tests/mod.rs b/crates/bacnet-services/src/alarm_event/tests/mod.rs index cff187d0..67e9e385 100644 --- a/crates/bacnet-services/src/alarm_event/tests/mod.rs +++ b/crates/bacnet-services/src/alarm_event/tests/mod.rs @@ -7,6 +7,7 @@ mod get_event_information_timestamps; mod notification_parameters; mod notification_parameters_boundaries; mod notification_parameters_life_safety; +mod notification_parameters_reachable_wire; mod notification_parameters_structured; mod property_states; mod service_round_trip; diff --git a/crates/bacnet-services/src/alarm_event/tests/notification_parameters_reachable_wire.rs b/crates/bacnet-services/src/alarm_event/tests/notification_parameters_reachable_wire.rs new file mode 100644 index 00000000..6469cd79 --- /dev/null +++ b/crates/bacnet-services/src/alarm_event/tests/notification_parameters_reachable_wire.rs @@ -0,0 +1,90 @@ +use super::*; + +fn assert_literal(params: NotificationParameters, literal: &[u8]) { + let mut encoded = BytesMut::new(); + params.encode(&mut encoded).unwrap(); + assert_eq!(encoded.as_ref(), literal); + assert_eq!(NotificationParameters::decode(literal, 0).unwrap(), params); +} + +#[test] +fn reachable_notification_parameter_alternatives_have_exact_literal_bytes() { + assert_literal( + NotificationParameters::ChangeOfBitstring { + referenced_bitstring: (5, vec![0xe0]), + status_flags: 0b1010, + }, + &[0x0e, 0x0a, 0x05, 0xe0, 0x1a, 0x04, 0xa0, 0x0f], + ); + assert_literal( + NotificationParameters::ChangeOfState { + new_state: BACnetPropertyStates::BinaryValue(1), + status_flags: 0b1000, + }, + &[0x1e, 0x0e, 0x19, 0x01, 0x0f, 0x1a, 0x04, 0x80, 0x1f], + ); + assert_literal( + NotificationParameters::ChangeOfValue { + new_value: ChangeOfValueChoice::ChangedValue(12.5), + status_flags: 0b0100, + }, + &[ + 0x2e, 0x0e, 0x1c, 0x41, 0x48, 0x00, 0x00, 0x0f, 0x1a, 0x04, 0x40, 0x2f, + ], + ); + assert_literal( + NotificationParameters::ChangeOfValue { + new_value: ChangeOfValueChoice::ChangedBits { + unused_bits: 5, + data: vec![0xa0], + }, + status_flags: 0b0010, + }, + &[0x2e, 0x0e, 0x0a, 0x05, 0xa0, 0x0f, 0x1a, 0x04, 0x20, 0x2f], + ); + assert_literal( + NotificationParameters::CommandFailure { + command_value: vec![0x91, 0x01], + status_flags: 0b1100, + feedback_value: vec![0x91, 0x00], + }, + &[ + 0x3e, 0x0e, 0x91, 0x01, 0x0f, 0x1a, 0x04, 0xc0, 0x2e, 0x91, 0x00, 0x2f, 0x3f, + ], + ); + assert_literal( + NotificationParameters::FloatingLimit { + reference_value: 50.0, + status_flags: 0b1000, + setpoint_value: 45.0, + error_limit: 2.0, + }, + &[ + 0x4e, 0x0c, 0x42, 0x48, 0x00, 0x00, 0x1a, 0x04, 0x80, 0x2c, 0x42, 0x34, 0x00, 0x00, + 0x3c, 0x40, 0x00, 0x00, 0x00, 0x4f, + ], + ); + assert_literal( + NotificationParameters::OutOfRange { + exceeding_value: 85.0, + status_flags: 0b1000, + deadband: 2.0, + exceeded_limit: 80.0, + }, + &[ + 0x5e, 0x0c, 0x42, 0xaa, 0x00, 0x00, 0x1a, 0x04, 0x80, 0x2c, 0x40, 0x00, 0x00, 0x00, + 0x3c, 0x42, 0xa0, 0x00, 0x00, 0x5f, + ], + ); + assert_literal( + NotificationParameters::ChangeOfReliability { + reliability: 2, + status_flags: 0b1100, + property_values: vec![0x09, 0x55, 0x2e, 0x44, 0x3f, 0x80, 0x00, 0x00, 0x2f], + }, + &[ + 0xfe, 0x13, 0x09, 0x02, 0x1a, 0x04, 0xc0, 0x2e, 0x09, 0x55, 0x2e, 0x44, 0x3f, 0x80, + 0x00, 0x00, 0x2f, 0x2f, 0xff, 0x13, + ], + ); +}