Skip to main content

bacnet_server/
cov.rs

1//! COV subscription engine — tracks SubscribeCOV subscriptions and their lifetimes.
2
3use std::collections::HashMap;
4use std::time::Instant;
5
6use bacnet_encoding::npdu::NpduAddress;
7use bacnet_types::enums::PropertyIdentifier;
8use bacnet_types::primitives::ObjectIdentifier;
9use bacnet_types::MacAddr;
10
11/// An active COV subscription.
12#[derive(Debug, Clone)]
13pub struct CovSubscription {
14    /// MAC address of the subscriber.
15    pub subscriber_mac: MacAddr,
16    /// Routed source address when the subscriber is behind a BACnet router.
17    pub subscriber_network: Option<NpduAddress>,
18    /// Process identifier chosen by the subscriber.
19    pub subscriber_process_identifier: u32,
20    /// The object being monitored.
21    pub monitored_object_identifier: ObjectIdentifier,
22    /// Whether to send ConfirmedCOVNotification (true) or Unconfirmed (false).
23    pub issue_confirmed_notifications: bool,
24    /// When this subscription expires (None = infinite lifetime).
25    pub expires_at: Option<Instant>,
26    /// Last present_value for which a COV notification was sent.
27    /// Used with COV_Increment to decide whether to fire again.
28    pub last_notified_value: Option<f32>,
29    /// Property-level filter (SubscribeCOVProperty only).
30    pub monitored_property: Option<PropertyIdentifier>,
31    /// Array index within monitored property (SubscribeCOVProperty only).
32    pub monitored_property_array_index: Option<u32>,
33    /// COV increment override (SubscribeCOVProperty only).
34    pub cov_increment: Option<f32>,
35    /// Notification service family used for this subscription.
36    pub notification_kind: CovNotificationKind,
37    /// Whether COVNotificationMultiple values should include timeOfChange.
38    pub timestamped: bool,
39}
40
41/// COV notification service family for a stored subscription.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum CovNotificationKind {
44    /// COVNotification / ConfirmedCOVNotification.
45    Single,
46    /// COVNotificationMultiple / ConfirmedCOVNotificationMultiple.
47    Multiple,
48}
49
50/// Key for uniquely identifying a subscription:
51/// (subscriber endpoint, process_id, monitored_object, monitored_property).
52/// Including monitored_property ensures SubscribeCOV (whole-object) and
53/// SubscribeCOVProperty (per-property) coexist as independent subscriptions.
54type SubKey = (
55    MacAddr,
56    Option<NpduAddress>,
57    u32,
58    ObjectIdentifier,
59    Option<PropertyIdentifier>,
60);
61
62/// Table of active COV subscriptions.
63#[derive(Debug, Default)]
64pub struct CovSubscriptionTable {
65    subs: HashMap<SubKey, CovSubscription>,
66}
67
68impl CovSubscriptionTable {
69    pub fn new() -> Self {
70        Self {
71            subs: HashMap::new(),
72        }
73    }
74
75    /// Add or update a subscription.
76    pub fn subscribe(&mut self, sub: CovSubscription) {
77        let key = (
78            sub.subscriber_mac.clone(),
79            sub.subscriber_network.clone(),
80            sub.subscriber_process_identifier,
81            sub.monitored_object_identifier,
82            sub.monitored_property,
83        );
84        self.subs.insert(key, sub);
85    }
86
87    /// Whether a subscription key is already present.
88    pub fn contains(
89        &self,
90        mac: &MacAddr,
91        network: Option<&NpduAddress>,
92        process_id: u32,
93        monitored_object: ObjectIdentifier,
94        monitored_property: Option<PropertyIdentifier>,
95    ) -> bool {
96        self.subs.contains_key(&(
97            mac.clone(),
98            network.cloned(),
99            process_id,
100            monitored_object,
101            monitored_property,
102        ))
103    }
104
105    /// Remove a subscription by subscriber MAC, process identifier, and monitored object.
106    pub fn unsubscribe(
107        &mut self,
108        mac: &[u8],
109        process_id: u32,
110        monitored_object: ObjectIdentifier,
111    ) -> bool {
112        self.unsubscribe_at(mac, None, process_id, monitored_object)
113    }
114
115    /// Remove a whole-object subscription for a specific subscriber endpoint.
116    pub fn unsubscribe_at(
117        &mut self,
118        mac: &[u8],
119        network: Option<&NpduAddress>,
120        process_id: u32,
121        monitored_object: ObjectIdentifier,
122    ) -> bool {
123        let key = (
124            MacAddr::from_slice(mac),
125            network.cloned(),
126            process_id,
127            monitored_object,
128            None,
129        );
130        self.subs.remove(&key).is_some()
131    }
132
133    /// Unsubscribe a per-property subscription.
134    pub fn unsubscribe_property(
135        &mut self,
136        mac: &[u8],
137        process_id: u32,
138        monitored_object: ObjectIdentifier,
139        monitored_property: PropertyIdentifier,
140    ) -> bool {
141        self.unsubscribe_property_at(mac, None, process_id, monitored_object, monitored_property)
142    }
143
144    /// Unsubscribe a per-property subscription for a specific subscriber endpoint.
145    pub fn unsubscribe_property_at(
146        &mut self,
147        mac: &[u8],
148        network: Option<&NpduAddress>,
149        process_id: u32,
150        monitored_object: ObjectIdentifier,
151        monitored_property: PropertyIdentifier,
152    ) -> bool {
153        let key = (
154            MacAddr::from_slice(mac),
155            network.cloned(),
156            process_id,
157            monitored_object,
158            Some(monitored_property),
159        );
160        self.subs.remove(&key).is_some()
161    }
162
163    /// Remove every COV-multiple subscription in a subscriber context.
164    pub fn unsubscribe_cov_multiple_context(
165        &mut self,
166        mac: &[u8],
167        network: Option<&NpduAddress>,
168        process_id: u32,
169        confirmed: bool,
170    ) {
171        let mac = MacAddr::from_slice(mac);
172        self.subs.retain(|_, sub| {
173            !(sub.notification_kind == CovNotificationKind::Multiple
174                && sub.subscriber_mac == mac
175                && sub.subscriber_network == network.cloned()
176                && sub.subscriber_process_identifier == process_id
177                && sub.issue_confirmed_notifications == confirmed)
178        });
179    }
180
181    /// Remove one property from a COV-multiple subscriber context.
182    pub fn unsubscribe_cov_multiple_property_at(
183        &mut self,
184        mac: &[u8],
185        network: Option<&NpduAddress>,
186        process_id: u32,
187        confirmed: bool,
188        monitored_object: ObjectIdentifier,
189        monitored_property: PropertyIdentifier,
190    ) {
191        let mac = MacAddr::from_slice(mac);
192        self.subs.retain(|_, sub| {
193            !(sub.notification_kind == CovNotificationKind::Multiple
194                && sub.subscriber_mac == mac
195                && sub.subscriber_network == network.cloned()
196                && sub.subscriber_process_identifier == process_id
197                && sub.issue_confirmed_notifications == confirmed
198                && sub.monitored_object_identifier == monitored_object
199                && sub.monitored_property == Some(monitored_property))
200        });
201    }
202
203    /// Refresh the lifetime for an existing COV-multiple subscriber context.
204    pub fn refresh_cov_multiple_context_lifetime(
205        &mut self,
206        mac: &[u8],
207        network: Option<&NpduAddress>,
208        process_id: u32,
209        confirmed: bool,
210        expires_at: Option<Instant>,
211    ) {
212        let mac = MacAddr::from_slice(mac);
213        for sub in self.subs.values_mut() {
214            if sub.notification_kind == CovNotificationKind::Multiple
215                && sub.subscriber_mac == mac
216                && sub.subscriber_network == network.cloned()
217                && sub.subscriber_process_identifier == process_id
218                && sub.issue_confirmed_notifications == confirmed
219            {
220                sub.expires_at = expires_at;
221            }
222        }
223    }
224
225    /// Remove all subscriptions for a given object (used on DeleteObject).
226    pub fn remove_for_object(&mut self, oid: ObjectIdentifier) {
227        self.subs.retain(|k, _| k.3 != oid);
228    }
229
230    /// Get all active (non-expired) subscriptions for a given object.
231    pub fn subscriptions_for(&mut self, oid: &ObjectIdentifier) -> Vec<&CovSubscription> {
232        let now = Instant::now();
233        self.subs
234            .retain(|_, sub| sub.expires_at.is_none_or(|exp| exp > now));
235        self.subs
236            .values()
237            .filter(|sub| sub.monitored_object_identifier == *oid)
238            .collect()
239    }
240
241    /// Update the last-notified value for a subscription.
242    pub fn set_last_notified_value(
243        &mut self,
244        mac: &[u8],
245        network: Option<&NpduAddress>,
246        process_id: u32,
247        monitored_object: ObjectIdentifier,
248        monitored_property: Option<PropertyIdentifier>,
249        value: f32,
250    ) {
251        let key = (
252            MacAddr::from_slice(mac),
253            network.cloned(),
254            process_id,
255            monitored_object,
256            monitored_property,
257        );
258        if let Some(sub) = self.subs.get_mut(&key) {
259            sub.last_notified_value = Some(value);
260        }
261    }
262
263    /// Check if a COV notification should fire for a subscription given
264    /// the current present_value and the object's COV_Increment.
265    ///
266    /// Returns `true` if:
267    /// - No COV_Increment (binary/multi-state objects — always notify)
268    /// - No previous notified value (first notification)
269    /// - `|current - last_notified| >= cov_increment`
270    pub fn should_notify(
271        sub: &CovSubscription,
272        current_value: Option<f32>,
273        cov_increment: Option<f32>,
274    ) -> bool {
275        match (cov_increment, current_value) {
276            (Some(increment), Some(current)) => {
277                match sub.last_notified_value {
278                    None => true, // First notification — always fire
279                    Some(last) => (current - last).abs() >= increment,
280                }
281            }
282            _ => true, // No increment or no numeric value — always notify
283        }
284    }
285
286    /// Number of active subscriptions.
287    pub fn len(&self) -> usize {
288        self.subs.len()
289    }
290
291    /// Whether the table is empty.
292    pub fn is_empty(&self) -> bool {
293        self.subs.is_empty()
294    }
295
296    /// Remove all expired subscriptions. Returns the number removed.
297    pub fn purge_expired(&mut self) -> usize {
298        let before = self.subs.len();
299        let now = Instant::now();
300        self.subs
301            .retain(|_, sub| sub.expires_at.is_none_or(|exp| exp > now));
302        before - self.subs.len()
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use bacnet_types::enums::ObjectType;
310    use std::time::Duration;
311
312    fn ai1() -> ObjectIdentifier {
313        ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap()
314    }
315
316    fn ai2() -> ObjectIdentifier {
317        ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 2).unwrap()
318    }
319
320    fn make_sub(mac: &[u8], process_id: u32, oid: ObjectIdentifier) -> CovSubscription {
321        CovSubscription {
322            subscriber_mac: MacAddr::from_slice(mac),
323            subscriber_network: None,
324            subscriber_process_identifier: process_id,
325            monitored_object_identifier: oid,
326            issue_confirmed_notifications: false,
327            expires_at: None,
328            last_notified_value: None,
329            monitored_property: None,
330            monitored_property_array_index: None,
331            cov_increment: None,
332            notification_kind: CovNotificationKind::Single,
333            timestamped: false,
334        }
335    }
336
337    #[test]
338    fn subscribe_and_lookup() {
339        let mut table = CovSubscriptionTable::new();
340        table.subscribe(make_sub(&[1, 2, 3], 1, ai1()));
341        assert_eq!(table.len(), 1);
342        assert_eq!(table.subscriptions_for(&ai1()).len(), 1);
343        assert_eq!(table.subscriptions_for(&ai2()).len(), 0);
344    }
345
346    #[test]
347    fn unsubscribe() {
348        let mut table = CovSubscriptionTable::new();
349        table.subscribe(make_sub(&[1, 2, 3], 1, ai1()));
350        assert!(table.unsubscribe(&[1, 2, 3], 1, ai1()));
351        assert!(!table.unsubscribe(&[1, 2, 3], 1, ai1())); // already removed
352        assert!(table.is_empty());
353    }
354
355    #[test]
356    fn expired_subscriptions_purged_on_lookup() {
357        let mut table = CovSubscriptionTable::new();
358        let mut sub = make_sub(&[1, 2, 3], 1, ai1());
359        sub.expires_at = Some(Instant::now() - Duration::from_secs(1)); // already expired
360        table.subscribe(sub);
361        assert_eq!(table.subscriptions_for(&ai1()).len(), 0);
362        assert!(table.is_empty());
363    }
364
365    #[test]
366    fn multiple_subscribers_same_object() {
367        let mut table = CovSubscriptionTable::new();
368        table.subscribe(make_sub(&[1, 2, 3], 1, ai1()));
369        table.subscribe(make_sub(&[4, 5, 6], 2, ai1()));
370        assert_eq!(table.subscriptions_for(&ai1()).len(), 2);
371    }
372
373    #[test]
374    fn should_notify_no_increment_always_fires() {
375        let sub = make_sub(&[1, 2, 3], 1, ai1());
376        // Binary/multi-state objects have no COV_Increment
377        assert!(CovSubscriptionTable::should_notify(&sub, Some(1.0), None));
378    }
379
380    #[test]
381    fn should_notify_first_notification_always_fires() {
382        let sub = make_sub(&[1, 2, 3], 1, ai1());
383        // First notification (last_notified_value = None)
384        assert!(CovSubscriptionTable::should_notify(
385            &sub,
386            Some(72.5),
387            Some(1.0)
388        ));
389    }
390
391    #[test]
392    fn should_notify_change_exceeds_increment() {
393        let mut sub = make_sub(&[1, 2, 3], 1, ai1());
394        sub.last_notified_value = Some(70.0);
395        // Change of 2.5 >= increment of 1.0
396        assert!(CovSubscriptionTable::should_notify(
397            &sub,
398            Some(72.5),
399            Some(1.0)
400        ));
401    }
402
403    #[test]
404    fn should_notify_change_below_increment() {
405        let mut sub = make_sub(&[1, 2, 3], 1, ai1());
406        sub.last_notified_value = Some(72.0);
407        // Change of 0.3 < increment of 1.0
408        assert!(!CovSubscriptionTable::should_notify(
409            &sub,
410            Some(72.3),
411            Some(1.0)
412        ));
413    }
414
415    #[test]
416    fn should_notify_exact_increment() {
417        let mut sub = make_sub(&[1, 2, 3], 1, ai1());
418        sub.last_notified_value = Some(70.0);
419        // Change of exactly 1.0 == increment of 1.0 → fires
420        assert!(CovSubscriptionTable::should_notify(
421            &sub,
422            Some(71.0),
423            Some(1.0)
424        ));
425    }
426
427    #[test]
428    fn should_notify_zero_increment_always_fires() {
429        let mut sub = make_sub(&[1, 2, 3], 1, ai1());
430        sub.last_notified_value = Some(72.0);
431        // COV_Increment = 0.0 means any change fires
432        assert!(CovSubscriptionTable::should_notify(
433            &sub,
434            Some(72.001),
435            Some(0.0)
436        ));
437    }
438
439    #[test]
440    fn set_last_notified_value_updates() {
441        let mut table = CovSubscriptionTable::new();
442        table.subscribe(make_sub(&[1, 2, 3], 1, ai1()));
443        table.set_last_notified_value(&[1, 2, 3], None, 1, ai1(), None, 72.5);
444
445        let subs = table.subscriptions_for(&ai1());
446        assert_eq!(subs[0].last_notified_value, Some(72.5));
447    }
448
449    #[test]
450    fn upsert_replaces_existing() {
451        let mut table = CovSubscriptionTable::new();
452        let mut sub = make_sub(&[1, 2, 3], 1, ai1());
453        sub.issue_confirmed_notifications = false;
454        table.subscribe(sub);
455        // Same (mac, process_id, object) key — replaces the existing entry
456        let mut sub2 = make_sub(&[1, 2, 3], 1, ai1());
457        sub2.issue_confirmed_notifications = true;
458        table.subscribe(sub2);
459        assert_eq!(table.len(), 1);
460        let subs = table.subscriptions_for(&ai1());
461        assert!(subs[0].issue_confirmed_notifications);
462    }
463
464    #[test]
465    fn same_subscriber_different_objects_both_exist() {
466        let mut table = CovSubscriptionTable::new();
467        // Same (mac, process_id) but different monitored objects
468        table.subscribe(make_sub(&[1, 2, 3], 1, ai1()));
469        table.subscribe(make_sub(&[1, 2, 3], 1, ai2()));
470        assert_eq!(table.len(), 2);
471        assert_eq!(table.subscriptions_for(&ai1()).len(), 1);
472        assert_eq!(table.subscriptions_for(&ai2()).len(), 1);
473    }
474
475    #[test]
476    fn purge_expired_removes_stale_subscriptions() {
477        let mut table = CovSubscriptionTable::new();
478        let mut sub1 = make_sub(&[1, 2, 3], 1, ai1());
479        sub1.expires_at = Some(Instant::now() - Duration::from_secs(10));
480        table.subscribe(sub1);
481
482        let mut sub2 = make_sub(&[4, 5, 6], 2, ai1());
483        sub2.expires_at = None; // infinite lifetime
484        table.subscribe(sub2);
485
486        let purged = table.purge_expired();
487        assert_eq!(purged, 1);
488        assert_eq!(table.len(), 1);
489    }
490
491    #[test]
492    fn cov_multiple_context_lifetime_refreshes_and_expires() {
493        let mut table = CovSubscriptionTable::new();
494        let original_expiry = Instant::now() + Duration::from_secs(30);
495        let refreshed_expiry = Instant::now() + Duration::from_secs(60);
496
497        let mut present_value = make_sub(&[1, 2, 3], 1, ai1());
498        present_value.notification_kind = CovNotificationKind::Multiple;
499        present_value.monitored_property = Some(PropertyIdentifier::PRESENT_VALUE);
500        present_value.expires_at = Some(original_expiry);
501        table.subscribe(present_value);
502
503        let mut status_flags = make_sub(&[1, 2, 3], 1, ai1());
504        status_flags.notification_kind = CovNotificationKind::Multiple;
505        status_flags.monitored_property = Some(PropertyIdentifier::STATUS_FLAGS);
506        status_flags.expires_at = Some(original_expiry);
507        table.subscribe(status_flags);
508
509        let mut single = make_sub(&[1, 2, 3], 1, ai1());
510        single.expires_at = Some(original_expiry);
511        table.subscribe(single);
512
513        table.refresh_cov_multiple_context_lifetime(
514            &[1, 2, 3],
515            None,
516            1,
517            false,
518            Some(refreshed_expiry),
519        );
520
521        let multiple_expiries: Vec<_> = table
522            .subs
523            .values()
524            .filter(|sub| sub.notification_kind == CovNotificationKind::Multiple)
525            .map(|sub| sub.expires_at)
526            .collect();
527        assert_eq!(
528            multiple_expiries,
529            vec![Some(refreshed_expiry), Some(refreshed_expiry)]
530        );
531        assert!(table
532            .subs
533            .values()
534            .any(|sub| sub.notification_kind == CovNotificationKind::Single
535                && sub.expires_at == Some(original_expiry)));
536
537        table.refresh_cov_multiple_context_lifetime(
538            &[1, 2, 3],
539            None,
540            1,
541            false,
542            Some(Instant::now() - Duration::from_secs(1)),
543        );
544
545        assert_eq!(table.purge_expired(), 2);
546        assert_eq!(table.len(), 1);
547        assert!(table.subs.values().all(|sub| {
548            sub.notification_kind == CovNotificationKind::Single
549                && sub.expires_at == Some(original_expiry)
550        }));
551    }
552
553    #[test]
554    fn purge_expired_returns_zero_when_none_expired() {
555        let mut table = CovSubscriptionTable::new();
556        table.subscribe(make_sub(&[1, 2, 3], 1, ai1()));
557        let purged = table.purge_expired();
558        assert_eq!(purged, 0);
559        assert_eq!(table.len(), 1);
560    }
561}