Skip to main content

bacnet_server/cov/
lifetime.rs

1use super::*;
2use std::num::NonZeroU32;
3
4/// Monotonic remaining lifetime before a new COV notification is admitted.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum CovTimeRemaining {
7    /// No expiry; represented by zero in an owned ordinary COV notification.
8    Indefinite,
9    /// A live finite lifetime, rounded upward and bounded to the wire range.
10    Finite(NonZeroU32),
11    /// The deadline has been reached; it cannot authorize a new notification.
12    Expired,
13}
14
15impl CovTimeRemaining {
16    /// Project one supplied monotonic instant. Positive fractions round upward;
17    /// values beyond the wire range saturate. This is a local representation policy.
18    pub fn at(expires_at: Option<Instant>, now: Instant) -> Self {
19        let Some(expires_at) = expires_at else {
20            return Self::Indefinite;
21        };
22        let Some(remaining) = expires_at.checked_duration_since(now) else {
23            return Self::Expired;
24        };
25        if remaining.is_zero() {
26            return Self::Expired;
27        }
28        let seconds = remaining
29            .as_secs()
30            .saturating_add(u64::from(remaining.subsec_nanos() != 0));
31        Self::Finite(NonZeroU32::new(u32::try_from(seconds).unwrap_or(u32::MAX)).unwrap())
32    }
33
34    /// Wire seconds for an eligible owned subscription; expiry is never wire zero.
35    pub fn wire_seconds(self) -> Option<u32> {
36        match self {
37            Self::Indefinite => Some(0),
38            Self::Finite(seconds) => Some(seconds.get()),
39            Self::Expired => None,
40        }
41    }
42}
43
44impl CovSubscriptionTable {
45    /// Resolve captured owner/key/generation/route authority against live expiry.
46    /// An expiry-only Multiple context refresh does not replace the generation.
47    pub fn remaining_lifetime(
48        &self,
49        snapshot: &CovSubscriptionSnapshot,
50        now: Instant,
51    ) -> Option<CovTimeRemaining> {
52        if !Arc::ptr_eq(&self.owner, &snapshot.owner) {
53            return None;
54        }
55        self.subs.get(snapshot.key()).and_then(|entry| {
56            (entry.generation == snapshot.generation && entry.flight.same(&snapshot.flight))
57                .then(|| CovTimeRemaining::at(entry.expires_at, now))
58        })
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use std::time::Duration;
66
67    #[test]
68    fn cov_lifetime_projection_distinguishes_finite_indefinite_and_expired() {
69        let now = Instant::now();
70        assert_eq!(
71            CovTimeRemaining::at(None, now),
72            CovTimeRemaining::Indefinite
73        );
74        assert_eq!(CovTimeRemaining::at(None, now).wire_seconds(), Some(0));
75        for (duration, expected) in [
76            (Duration::from_nanos(1), 1),
77            (Duration::from_millis(999), 1),
78            (Duration::from_secs(1), 1),
79            (Duration::from_millis(1001), 2),
80            (Duration::from_secs(3), 3),
81            (Duration::from_secs(u64::from(u32::MAX)), u32::MAX),
82            (Duration::from_secs(u64::from(u32::MAX) + 1), u32::MAX),
83        ] {
84            let projected = CovTimeRemaining::at(Some(now + duration), now);
85            assert_eq!(
86                projected,
87                CovTimeRemaining::Finite(NonZeroU32::new(expected).unwrap())
88            );
89            assert_eq!(projected.wire_seconds(), Some(expected));
90        }
91        for expiry in [now, now - Duration::from_nanos(1)] {
92            assert_eq!(
93                CovTimeRemaining::at(Some(expiry), now),
94                CovTimeRemaining::Expired
95            );
96            assert_eq!(CovTimeRemaining::at(Some(expiry), now).wire_seconds(), None);
97        }
98    }
99
100    #[test]
101    fn cov_lifetime_live_query_follows_context_refresh_and_rejects_stale_owners() {
102        let now = Instant::now();
103        let proposal = CovSubscription {
104            subscriber_mac: MacAddr::from_slice(&[1]),
105            subscriber_network: None,
106            subscriber_process_identifier: 1,
107            monitored_object_identifier: ObjectIdentifier::new(
108                bacnet_types::enums::ObjectType::ANALOG_VALUE,
109                1,
110            )
111            .unwrap(),
112            issue_confirmed_notifications: false,
113            expires_at: Some(now + Duration::from_secs(1)),
114            last_notified_observation: None,
115            monitored_property: Some(PropertyIdentifier::PRESENT_VALUE),
116            monitored_property_array_index: None,
117            cov_increment: None,
118            notification_kind: CovNotificationKind::Multiple,
119            timestamped: false,
120        };
121        let mut table = CovSubscriptionTable::new();
122        let snapshot = table.admit_for_test(proposal.clone(), 0).unwrap();
123        let context = snapshot.key().multiple_context().unwrap().clone();
124        table
125            .subscribe_multiple(
126                &context,
127                &snapshot.endpoint(),
128                now + Duration::from_secs(10),
129                0,
130                None,
131                vec![],
132            )
133            .unwrap();
134        assert_eq!(
135            table
136                .remaining_lifetime(&snapshot, now + Duration::from_secs(2))
137                .unwrap()
138                .wire_seconds(),
139            Some(8)
140        );
141        assert_eq!(
142            table.remaining_lifetime(&snapshot, now + Duration::from_secs(10)),
143            Some(CovTimeRemaining::Expired)
144        );
145        assert_eq!(snapshot.expires_at, proposal.expires_at);
146        let foreign = CovSubscriptionTable::new()
147            .admit_for_test(proposal.clone(), 0)
148            .unwrap();
149        assert_eq!(table.remaining_lifetime(&foreign, now), None);
150        table.admit_for_test(proposal, 0).unwrap();
151        assert_eq!(table.remaining_lifetime(&snapshot, now), None);
152        table.unsubscribe(snapshot.key());
153        assert_eq!(table.remaining_lifetime(&snapshot, now), None);
154    }
155}