use std::collections::HashMap;
use std::time::Instant;
use bacnet_types::enums::PropertyIdentifier;
use bacnet_types::primitives::ObjectIdentifier;
use bacnet_types::MacAddr;
#[derive(Debug, Clone)]
pub struct CovSubscription {
pub subscriber_mac: MacAddr,
pub subscriber_process_identifier: u32,
pub monitored_object_identifier: ObjectIdentifier,
pub issue_confirmed_notifications: bool,
pub expires_at: Option<Instant>,
pub last_notified_value: Option<f32>,
pub monitored_property: Option<PropertyIdentifier>,
pub monitored_property_array_index: Option<u32>,
pub cov_increment: Option<f32>,
}
type SubKey = (MacAddr, u32, ObjectIdentifier, Option<PropertyIdentifier>);
#[derive(Debug, Default)]
pub struct CovSubscriptionTable {
subs: HashMap<SubKey, CovSubscription>,
}
impl CovSubscriptionTable {
pub fn new() -> Self {
Self {
subs: HashMap::new(),
}
}
pub fn subscribe(&mut self, sub: CovSubscription) {
let key = (
sub.subscriber_mac.clone(),
sub.subscriber_process_identifier,
sub.monitored_object_identifier,
sub.monitored_property,
);
self.subs.insert(key, sub);
}
pub fn unsubscribe(
&mut self,
mac: &[u8],
process_id: u32,
monitored_object: ObjectIdentifier,
) -> bool {
let key = (MacAddr::from_slice(mac), process_id, monitored_object, None);
self.subs.remove(&key).is_some()
}
pub fn unsubscribe_property(
&mut self,
mac: &[u8],
process_id: u32,
monitored_object: ObjectIdentifier,
monitored_property: PropertyIdentifier,
) -> bool {
let key = (
MacAddr::from_slice(mac),
process_id,
monitored_object,
Some(monitored_property),
);
self.subs.remove(&key).is_some()
}
pub fn remove_for_object(&mut self, oid: ObjectIdentifier) {
self.subs.retain(|k, _| k.2 != oid);
}
pub fn subscriptions_for(&mut self, oid: &ObjectIdentifier) -> Vec<&CovSubscription> {
let now = Instant::now();
self.subs
.retain(|_, sub| sub.expires_at.is_none_or(|exp| exp > now));
self.subs
.values()
.filter(|sub| sub.monitored_object_identifier == *oid)
.collect()
}
pub fn set_last_notified_value(
&mut self,
mac: &[u8],
process_id: u32,
monitored_object: ObjectIdentifier,
monitored_property: Option<PropertyIdentifier>,
value: f32,
) {
let key = (
MacAddr::from_slice(mac),
process_id,
monitored_object,
monitored_property,
);
if let Some(sub) = self.subs.get_mut(&key) {
sub.last_notified_value = Some(value);
}
}
pub fn should_notify(
sub: &CovSubscription,
current_value: Option<f32>,
cov_increment: Option<f32>,
) -> bool {
match (cov_increment, current_value) {
(Some(increment), Some(current)) => {
match sub.last_notified_value {
None => true, Some(last) => (current - last).abs() >= increment,
}
}
_ => true, }
}
pub fn len(&self) -> usize {
self.subs.len()
}
pub fn is_empty(&self) -> bool {
self.subs.is_empty()
}
pub fn purge_expired(&mut self) -> usize {
let before = self.subs.len();
let now = Instant::now();
self.subs
.retain(|_, sub| sub.expires_at.is_none_or(|exp| exp > now));
before - self.subs.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use bacnet_types::enums::ObjectType;
use std::time::Duration;
fn ai1() -> ObjectIdentifier {
ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap()
}
fn ai2() -> ObjectIdentifier {
ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 2).unwrap()
}
fn make_sub(mac: &[u8], process_id: u32, oid: ObjectIdentifier) -> CovSubscription {
CovSubscription {
subscriber_mac: MacAddr::from_slice(mac),
subscriber_process_identifier: process_id,
monitored_object_identifier: oid,
issue_confirmed_notifications: false,
expires_at: None,
last_notified_value: None,
monitored_property: None,
monitored_property_array_index: None,
cov_increment: None,
}
}
#[test]
fn subscribe_and_lookup() {
let mut table = CovSubscriptionTable::new();
table.subscribe(make_sub(&[1, 2, 3], 1, ai1()));
assert_eq!(table.len(), 1);
assert_eq!(table.subscriptions_for(&ai1()).len(), 1);
assert_eq!(table.subscriptions_for(&ai2()).len(), 0);
}
#[test]
fn unsubscribe() {
let mut table = CovSubscriptionTable::new();
table.subscribe(make_sub(&[1, 2, 3], 1, ai1()));
assert!(table.unsubscribe(&[1, 2, 3], 1, ai1()));
assert!(!table.unsubscribe(&[1, 2, 3], 1, ai1())); assert!(table.is_empty());
}
#[test]
fn expired_subscriptions_purged_on_lookup() {
let mut table = CovSubscriptionTable::new();
let mut sub = make_sub(&[1, 2, 3], 1, ai1());
sub.expires_at = Some(Instant::now() - Duration::from_secs(1)); table.subscribe(sub);
assert_eq!(table.subscriptions_for(&ai1()).len(), 0);
assert!(table.is_empty());
}
#[test]
fn multiple_subscribers_same_object() {
let mut table = CovSubscriptionTable::new();
table.subscribe(make_sub(&[1, 2, 3], 1, ai1()));
table.subscribe(make_sub(&[4, 5, 6], 2, ai1()));
assert_eq!(table.subscriptions_for(&ai1()).len(), 2);
}
#[test]
fn should_notify_no_increment_always_fires() {
let sub = make_sub(&[1, 2, 3], 1, ai1());
assert!(CovSubscriptionTable::should_notify(&sub, Some(1.0), None));
}
#[test]
fn should_notify_first_notification_always_fires() {
let sub = make_sub(&[1, 2, 3], 1, ai1());
assert!(CovSubscriptionTable::should_notify(
&sub,
Some(72.5),
Some(1.0)
));
}
#[test]
fn should_notify_change_exceeds_increment() {
let mut sub = make_sub(&[1, 2, 3], 1, ai1());
sub.last_notified_value = Some(70.0);
assert!(CovSubscriptionTable::should_notify(
&sub,
Some(72.5),
Some(1.0)
));
}
#[test]
fn should_notify_change_below_increment() {
let mut sub = make_sub(&[1, 2, 3], 1, ai1());
sub.last_notified_value = Some(72.0);
assert!(!CovSubscriptionTable::should_notify(
&sub,
Some(72.3),
Some(1.0)
));
}
#[test]
fn should_notify_exact_increment() {
let mut sub = make_sub(&[1, 2, 3], 1, ai1());
sub.last_notified_value = Some(70.0);
assert!(CovSubscriptionTable::should_notify(
&sub,
Some(71.0),
Some(1.0)
));
}
#[test]
fn should_notify_zero_increment_always_fires() {
let mut sub = make_sub(&[1, 2, 3], 1, ai1());
sub.last_notified_value = Some(72.0);
assert!(CovSubscriptionTable::should_notify(
&sub,
Some(72.001),
Some(0.0)
));
}
#[test]
fn set_last_notified_value_updates() {
let mut table = CovSubscriptionTable::new();
table.subscribe(make_sub(&[1, 2, 3], 1, ai1()));
table.set_last_notified_value(&[1, 2, 3], 1, ai1(), None, 72.5);
let subs = table.subscriptions_for(&ai1());
assert_eq!(subs[0].last_notified_value, Some(72.5));
}
#[test]
fn upsert_replaces_existing() {
let mut table = CovSubscriptionTable::new();
let mut sub = make_sub(&[1, 2, 3], 1, ai1());
sub.issue_confirmed_notifications = false;
table.subscribe(sub);
let mut sub2 = make_sub(&[1, 2, 3], 1, ai1());
sub2.issue_confirmed_notifications = true;
table.subscribe(sub2);
assert_eq!(table.len(), 1);
let subs = table.subscriptions_for(&ai1());
assert!(subs[0].issue_confirmed_notifications);
}
#[test]
fn same_subscriber_different_objects_both_exist() {
let mut table = CovSubscriptionTable::new();
table.subscribe(make_sub(&[1, 2, 3], 1, ai1()));
table.subscribe(make_sub(&[1, 2, 3], 1, ai2()));
assert_eq!(table.len(), 2);
assert_eq!(table.subscriptions_for(&ai1()).len(), 1);
assert_eq!(table.subscriptions_for(&ai2()).len(), 1);
}
#[test]
fn purge_expired_removes_stale_subscriptions() {
let mut table = CovSubscriptionTable::new();
let mut sub1 = make_sub(&[1, 2, 3], 1, ai1());
sub1.expires_at = Some(Instant::now() - Duration::from_secs(10));
table.subscribe(sub1);
let mut sub2 = make_sub(&[4, 5, 6], 2, ai1());
sub2.expires_at = None; table.subscribe(sub2);
let purged = table.purge_expired();
assert_eq!(purged, 1);
assert_eq!(table.len(), 1);
}
#[test]
fn purge_expired_returns_zero_when_none_expired() {
let mut table = CovSubscriptionTable::new();
table.subscribe(make_sub(&[1, 2, 3], 1, ai1()));
let purged = table.purge_expired();
assert_eq!(purged, 0);
assert_eq!(table.len(), 1);
}
}