Skip to main content

bacnet_bip_core/node/
registration.rs

1//! A foreign device's registration lifecycle (Annex J.5.2): when to send
2//! the next Register-Foreign-Device, and what the answers prove.
3//!
4//! The state machine knows times and outcomes only, no addresses or wire
5//! types: the [`Node`](super::Node) sends the attempts and matches their
6//! answers.
7//!
8//! Two things are kept apart: the newest attempt's outcome, and the lease,
9//! which only a success proves. A pending attempt, a refusal or a timeout
10//! changes the outcome but never erases a proven lease.
11//!
12//! The lease is conservative. A BBMD keeps a registration for its
13//! time-to-live from when it took the request, plus a grace period (Annex
14//! J.5.2.3). Locally, a success can't say which request it answers, so the
15//! lease starts at the earliest send it could be for, and ends one
16//! time-to-live later, without the grace period. Every send precedes its
17//! arrival, so the local view never outlasts the BBMD's entry, on the
18//! assumption [`Lease`] states.
19
20use bacnet_bip_codec::BvlcResultCode;
21use bacnet_time::{Duration, Instant};
22
23use super::config::{Foreign, Retry};
24
25/// A registration the BBMD has proven: from `since` until `until`.
26///
27/// A success names no request, so `since` is the earliest send it could
28/// answer: any attempt sent since the last answer, and any sent less than
29/// the answer window before the success, in case it is a late or repeated
30/// copy. The answer window is three response timeouts, but no longer than
31/// the renewal interval, so never shorter than one response timeout.
32///
33/// `until` is never later than the end of the BBMD's entry, grace period
34/// left out, for every valid time-to-live, as long as every result, and
35/// every copy of one, reaches the node less than the answer window after
36/// its request was sent, and nothing else changes the entry (in foreign
37/// mode the node refuses manual requests that could). A BBMD that loses its
38/// table, as on a restart, ends the entry early; the
39/// Distribute-Broadcast-To-Network NAK that follows is taken as a hint to
40/// register again.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[non_exhaustive]
43pub struct Lease {
44    /// The earliest send the proving success could answer.
45    pub since: Instant,
46    /// `since` plus the time-to-live; the lease is live before it.
47    pub until: Instant,
48}
49
50impl Lease {
51    /// Whether the lease is live at `now`.
52    #[must_use]
53    pub fn is_live_at(&self, now: Instant) -> bool {
54        now < self.until
55    }
56}
57
58/// What the newest registration attempt came to.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60#[non_exhaustive]
61pub enum AttemptOutcome {
62    /// Admitted, and not answered yet.
63    Pending,
64    /// The BBMD answered with success.
65    Accepted,
66    /// The BBMD answered with the Register-Foreign-Device NAK.
67    Refused,
68    /// No answer by the response timeout.
69    TimedOut,
70    /// Encoding or sending it failed.
71    LocalError,
72    /// The caller gave it up.
73    Cancelled,
74}
75
76/// A change in a foreign device's registration, reported once.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78#[non_exhaustive]
79pub enum RegistrationEvent {
80    /// A success while no lease was live: registered from `since` until
81    /// `until`.
82    Registered {
83        /// The lease's start.
84        since: Instant,
85        /// The lease's end.
86        until: Instant,
87    },
88    /// A success while a lease was live: it now ends at `until`.
89    Renewed {
90        /// The lease's new end.
91        until: Instant,
92    },
93    /// The BBMD refused an attempt. A lease still live stays.
94    Refused {
95        /// The result code it refused with.
96        code: BvlcResultCode,
97    },
98    /// An attempt ended without an answer: it timed out, or sending it
99    /// failed.
100    Unanswered,
101    /// The lease ended with nothing newer proven.
102    Lapsed,
103}
104
105/// A foreign device's registration as the node sees it at one instant.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107#[non_exhaustive]
108pub struct RegistrationSnapshot {
109    /// The newest proven lease, live or not.
110    pub lease: Option<Lease>,
111    /// Whether that lease is live.
112    pub registered: bool,
113    /// Its time left, while live.
114    pub lease_remaining: Option<Duration>,
115    /// Whether an automatic attempt is waiting for its answer.
116    pub attempt_in_flight: bool,
117    /// When the next automatic attempt is due; `None` while one is in
118    /// flight.
119    pub next_attempt: Option<Instant>,
120    /// What the newest automatic attempt came to.
121    pub last_outcome: Option<AttemptOutcome>,
122    /// Automatic attempts in a row that ended unanswered or couldn't start.
123    pub consecutive_failures: u32,
124    /// Whether the first automatic attempt, of the start or since a move
125    /// to another BBMD, has ended, which ends the hold on broadcasts.
126    pub first_resolved: bool,
127    /// Broadcasts held for the first attempt.
128    pub held: u16,
129}
130
131/// What a foreign device does with a broadcast it is asked to send.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum Admission {
134    /// Send it now.
135    Send,
136    /// Keep it: the first registration attempt is outstanding. An
137    /// [`Action::ReleaseHeld`](super::Action::ReleaseHeld) or
138    /// [`Action::DiscardHeld`](super::Action::DiscardHeld) follows.
139    Hold,
140}
141
142/// What [`Lifecycle::admit_broadcast`] decided, for counting.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub(crate) enum BroadcastDecision {
145    Send { lapsed: bool },
146    Hold,
147    Full,
148}
149
150/// The fate of the broadcasts held for the first attempt.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub(crate) struct HeldVerdict {
153    pub(crate) release: bool,
154    pub(crate) count: u16,
155}
156
157/// Starts of recent sent attempts, newest last, in a small ring.
158#[derive(Debug, Clone, Copy)]
159struct Recent {
160    starts: [Instant; Recent::LEN],
161    len: u8,
162    next: u8,
163    /// The newest start pushed out of the ring.
164    evicted: Option<Instant>,
165}
166
167impl Recent {
168    const LEN: usize = 4;
169
170    const fn new() -> Self {
171        Self {
172            starts: [Instant::ZERO; Self::LEN],
173            len: 0,
174            next: 0,
175            evicted: None,
176        }
177    }
178
179    fn push(&mut self, start: Instant) {
180        let slot = &mut self.starts[usize::from(self.next)];
181        if usize::from(self.len) == Self::LEN {
182            self.evicted = Some(*slot);
183        } else {
184            self.len += 1;
185        }
186        *slot = start;
187        self.next = (self.next + 1) & (Self::LEN as u8 - 1);
188    }
189
190    /// The earliest start after `floor`, or of all with no floor. When one
191    /// that late may have left the ring, `floor` itself (or the epoch),
192    /// which is earlier than it.
193    fn earliest_after(&self, floor: Option<Instant>) -> Option<Instant> {
194        let inside = |start: Instant| floor.is_none_or(|floor| start > floor);
195        if self.evicted.is_some_and(inside) {
196            return Some(floor.unwrap_or(Instant::ZERO));
197        }
198        self.starts[..usize::from(self.len)]
199            .iter()
200            .copied()
201            .filter(|start| inside(*start))
202            .min()
203    }
204}
205
206/// The automatic attempt in flight.
207#[derive(Debug, Clone, Copy)]
208struct Attempt {
209    /// When it was admitted, before its send.
210    start: Instant,
211}
212
213/// The registration state machine.
214#[derive(Debug, Clone)]
215pub(crate) struct Lifecycle {
216    ttl: Duration,
217    renewal: Duration,
218    timeout: Duration,
219    retry: Retry,
220    hold_limit: u16,
221    lease: Option<Lease>,
222    /// The lease's end was reported.
223    lapsed: bool,
224    /// The earliest start among attempts sent since the last answer.
225    unanswered_since: Option<Instant>,
226    recent: Recent,
227    attempt: Option<Attempt>,
228    last_start: Option<Instant>,
229    last_outcome: Option<AttemptOutcome>,
230    failures: u32,
231    next: Option<Instant>,
232    first_resolved: bool,
233    held: u16,
234}
235
236impl Lifecycle {
237    /// The lifecycle of `foreign`, its first attempt due at `now`.
238    pub(crate) fn new(foreign: &Foreign, now: Instant) -> Self {
239        Self {
240            ttl: foreign.ttl_duration(),
241            renewal: foreign.effective_renewal(),
242            timeout: foreign.effective_response_timeout(),
243            retry: foreign.retry,
244            hold_limit: foreign.hold,
245            lease: None,
246            lapsed: false,
247            unanswered_since: None,
248            recent: Recent::new(),
249            attempt: None,
250            last_start: None,
251            last_outcome: None,
252            failures: 0,
253            next: Some(now),
254            first_resolved: false,
255            held: 0,
256        }
257    }
258
259    /// Register with `foreign`'s settings from `now` on, its first attempt
260    /// due at once; the caller has ended the attempt in flight. A lease
261    /// proven by the same BBMD (`same_bbmd`) still stands, as its entry
262    /// does; another BBMD's proves nothing here, so it goes, with the
263    /// sends that could have earned it. Broadcasts held for the first
264    /// attempt of the start stay held until the next attempt resolves.
265    ///
266    /// A move to another BBMD holds broadcasts again until that BBMD's
267    /// first answer, as at start: it has no registration for this device
268    /// yet, and would refuse to distribute them (Annex J.4.5). A hold
269    /// smaller than the broadcasts already held drops none of them: they
270    /// stay held and go out together when the next attempt resolves, and
271    /// further broadcasts are refused as a full hold until then.
272    pub(crate) fn retarget(&mut self, foreign: &Foreign, now: Instant, same_bbmd: bool) {
273        self.ttl = foreign.ttl_duration();
274        self.renewal = foreign.effective_renewal();
275        self.timeout = foreign.effective_response_timeout();
276        self.retry = foreign.retry;
277        self.hold_limit = foreign.hold;
278        if !same_bbmd {
279            self.lease = None;
280            self.lapsed = false;
281            self.unanswered_since = None;
282            self.recent = Recent::new();
283            // Once resolved, nothing is held, so `held` is already 0.
284            self.first_resolved = false;
285        }
286        self.attempt = None;
287        self.last_start = None;
288        self.failures = 0;
289        self.next = Some(now);
290    }
291
292    /// How long after its request a success, or a copy of it, may still
293    /// arrive: three response timeouts, but no longer than the renewal
294    /// interval. The response timeout is never longer than the renewal, so
295    /// this is at least one response timeout. Any attempt sent less than
296    /// this before a success could be the one it answers, so the lease
297    /// starts no later than the earliest of them. An attempt one renewal
298    /// back is never counted, so a renewal doesn't pull its lease back to
299    /// its predecessor's.
300    pub(crate) fn answer_window(&self) -> Duration {
301        let three = self.timeout.saturating_mul(2).saturating_add(self.timeout);
302        three.min(self.renewal)
303    }
304
305    /// When the next attempt is due, while none is in flight.
306    pub(crate) fn due_at(&self) -> Option<Instant> {
307        self.next.filter(|_| self.attempt.is_none())
308    }
309
310    /// When the lease ends, unless that was already reported.
311    pub(crate) fn lapse_at(&self) -> Option<Instant> {
312        self.lease.filter(|_| !self.lapsed).map(|lease| lease.until)
313    }
314
315    /// The lease's end has come: report it once.
316    pub(crate) fn lapse(&mut self) {
317        self.lapsed = true;
318    }
319
320    /// An attempt was admitted at `now`.
321    pub(crate) fn admitted(&mut self, now: Instant) {
322        self.attempt = Some(Attempt { start: now });
323        self.next = None;
324        self.last_start = Some(now);
325        self.last_outcome = Some(AttemptOutcome::Pending);
326    }
327
328    /// The attempt in flight was sent.
329    pub(crate) fn sent(&mut self) {
330        if let Some(attempt) = self.attempt {
331            self.unanswered_since.get_or_insert(attempt.start);
332            self.recent.push(attempt.start);
333        }
334    }
335
336    /// The attempt in flight was answered with success at `now`. The event
337    /// to report, unless the proven lease has already ended.
338    pub(crate) fn accepted(
339        &mut self,
340        now: Instant,
341    ) -> (Option<RegistrationEvent>, Option<HeldVerdict>) {
342        let start = self.attempt.take().map_or(now, |attempt| attempt.start);
343        // Sent less than the window before now: after the floor, or any
344        // when the window reaches back past the epoch.
345        let floor = now.checked_sub(self.answer_window());
346        let since = [self.unanswered_since, self.recent.earliest_after(floor)]
347            .into_iter()
348            .flatten()
349            .fold(start, Instant::min);
350        let until = since + self.ttl;
351        let was_live = self.lease.is_some_and(|lease| lease.is_live_at(now));
352        self.lease = Some(Lease { since, until });
353        self.lapsed = until <= now;
354        self.unanswered_since = None;
355        self.failures = 0;
356        // Renew one interval into the lease, which may already be due; at
357        // renewal, as today, one interval after this attempt.
358        self.next = Some(
359            match self.retry {
360                Retry::Backoff => since,
361                Retry::AtRenewal => start,
362            } + self.renewal,
363        );
364        self.last_outcome = Some(AttemptOutcome::Accepted);
365        let event = match (self.lapsed, was_live) {
366            (true, _) => None,
367            (false, true) => Some(RegistrationEvent::Renewed { until }),
368            (false, false) => Some(RegistrationEvent::Registered { since, until }),
369        };
370        (event, self.resolve_first(true))
371    }
372
373    /// The attempt in flight was refused at `now`. The lease stays, and the
374    /// next attempt comes one renewal interval after this one started.
375    pub(crate) fn refused(&mut self, now: Instant) -> Option<HeldVerdict> {
376        let start = self.attempt.take().map_or(now, |attempt| attempt.start);
377        self.unanswered_since = None;
378        self.failures = 0;
379        self.next = Some(start + self.renewal);
380        self.last_outcome = Some(AttemptOutcome::Refused);
381        self.resolve_first(false)
382    }
383
384    /// An attempt ended unanswered at `now`, or (with no attempt in flight)
385    /// couldn't start because its exchange was busy. `outcome` is `None`
386    /// for the latter, which leaves the newest outcome as it was.
387    pub(crate) fn failed(
388        &mut self,
389        now: Instant,
390        outcome: Option<AttemptOutcome>,
391    ) -> Option<HeldVerdict> {
392        let start = self.attempt.take().map_or(now, |attempt| attempt.start);
393        self.failures = self.failures.saturating_add(1);
394        let at_renewal = start + self.renewal;
395        self.next = Some(match self.retry {
396            Retry::AtRenewal => at_renewal,
397            Retry::Backoff => at_renewal.min(now + self.backoff()),
398        });
399        if outcome.is_some() {
400            self.last_outcome = outcome;
401        }
402        self.resolve_first(true)
403    }
404
405    /// One response timeout, doubled for each failure in a row after the
406    /// first, and no longer than the renewal interval.
407    fn backoff(&self) -> Duration {
408        let mut wait = self.timeout;
409        for _ in 1..self.failures {
410            if wait >= self.renewal {
411                break;
412            }
413            wait = wait.saturating_mul(2);
414        }
415        wait.min(self.renewal)
416    }
417
418    /// The BBMD refused a broadcast as from an unregistered device (Annex
419    /// J.4.5): register again soon, but no sooner than one response
420    /// timeout after the last attempt started. Nothing changes while an
421    /// attempt is in flight.
422    pub(crate) fn hint(&mut self, now: Instant) {
423        if self.attempt.is_some() {
424            return;
425        }
426        let earliest = self
427            .last_start
428            .map_or(now, |start| (start + self.timeout).max(now));
429        if self.next.is_some_and(|next| next > earliest) {
430            self.next = Some(earliest);
431        }
432    }
433
434    /// Whether to send, hold or refuse a broadcast at `now`.
435    pub(crate) fn admit_broadcast(&mut self, now: Instant) -> BroadcastDecision {
436        if !self.first_resolved && self.hold_limit > 0 {
437            if self.held >= self.hold_limit {
438                return BroadcastDecision::Full;
439            }
440            self.held += 1;
441            return BroadcastDecision::Hold;
442        }
443        let lapsed = !self.lease.is_some_and(|lease| lease.is_live_at(now));
444        BroadcastDecision::Send { lapsed }
445    }
446
447    /// The first attempt has ended: what becomes of the held broadcasts.
448    fn resolve_first(&mut self, release: bool) -> Option<HeldVerdict> {
449        if self.first_resolved {
450            return None;
451        }
452        self.first_resolved = true;
453        let count = core::mem::take(&mut self.held);
454        (count > 0).then_some(HeldVerdict { release, count })
455    }
456
457    pub(crate) fn snapshot(&self, now: Instant) -> RegistrationSnapshot {
458        let registered = self.lease.is_some_and(|lease| lease.is_live_at(now));
459        RegistrationSnapshot {
460            lease: self.lease,
461            registered,
462            lease_remaining: self
463                .lease
464                .filter(|_| registered)
465                .map(|lease| lease.until - now),
466            attempt_in_flight: self.attempt.is_some(),
467            next_attempt: self.due_at(),
468            last_outcome: self.last_outcome,
469            consecutive_failures: self.failures,
470            first_resolved: self.first_resolved,
471            held: self.held,
472        }
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::{Instant, Recent};
479
480    fn at(secs: u64) -> Instant {
481        Instant::from_secs(secs)
482    }
483
484    #[test]
485    fn the_ring_answers_with_the_earliest_start_after_the_floor() {
486        let mut recent = Recent::new();
487        assert_eq!(recent.earliest_after(None), None);
488        for start in [10, 20, 30] {
489            recent.push(at(start));
490        }
491        assert_eq!(recent.earliest_after(None), Some(at(10)));
492        assert_eq!(recent.earliest_after(Some(at(10))), Some(at(20)));
493        assert_eq!(recent.earliest_after(Some(at(9))), Some(at(10)));
494        assert_eq!(recent.earliest_after(Some(at(30))), None);
495    }
496
497    #[test]
498    fn a_start_pushed_out_of_the_ring_falls_back_to_the_floor() {
499        let mut recent = Recent::new();
500        for start in [10, 20, 30, 40, 50] {
501            recent.push(at(start));
502        }
503        // 10 left the ring. Inside the window, the floor stands in for it.
504        assert_eq!(recent.earliest_after(Some(at(5))), Some(at(5)));
505        assert_eq!(recent.earliest_after(None), Some(Instant::ZERO));
506        // Outside it, the ring answers alone.
507        assert_eq!(recent.earliest_after(Some(at(10))), Some(at(20)));
508        assert_eq!(recent.earliest_after(Some(at(25))), Some(at(30)));
509        recent.push(at(60));
510        assert_eq!(recent.earliest_after(Some(at(15))), Some(at(15)));
511        assert_eq!(recent.earliest_after(Some(at(20))), Some(at(30)));
512    }
513}