Skip to main content

osdp/driver/
acu.rs

1//! ACU (Access Control Unit) driver — issues commands to PDs and consumes
2//! their replies. Manages SQN cycling, REPLY_DELAY enforcement, retry,
3//! `osdp_BUSY` handling, and off-line detection.
4//!
5//! # Spec: §5.7
6
7use crate::clock::Clock;
8use crate::command::Command;
9use crate::error::Error;
10use crate::packet::{Address, ControlByte, CtrlFlags, PacketBuilder, ParsedPacket, Sqn};
11use crate::reply::{Reply, ReplyCode};
12use crate::transport::Transport;
13use alloc::vec::Vec;
14
15/// Bytes pulled from the transport per `Transport::read` call. Sized so a
16/// minimal frame fits in a single read but small enough that the stack
17/// footprint stays modest.
18const RX_CHUNK_LEN: usize = 64;
19
20/// Number of consecutive `Transport::read(..) -> Ok(0)` returns we tolerate
21/// inside a per-attempt budget before yielding control back to the caller as
22/// `Error::Timeout`. This stops us from spinning on a non-blocking transport
23/// that has nothing to deliver.
24const MAX_EMPTY_READS: u8 = 4;
25
26/// Per-PD bookkeeping owned by the ACU driver.
27#[derive(Debug, Clone)]
28pub struct PdState {
29    /// Next SQN to send.
30    pub next_sqn: Sqn,
31    /// Whether the PD prefers CRC trailers.
32    pub use_crc: bool,
33    /// Last successful exchange (ms, from the [`Clock`]).
34    pub last_seen_ms: u64,
35    /// Strictly true once `last_seen_ms` has been set at least once.
36    seen_at_least_once: bool,
37}
38
39impl Default for PdState {
40    fn default() -> Self {
41        Self {
42            next_sqn: Sqn::ZERO,
43            use_crc: true,
44            last_seen_ms: 0,
45            seen_at_least_once: false,
46        }
47    }
48}
49
50impl PdState {
51    /// Advance SQN.
52    pub fn bump_sqn(&mut self) {
53        self.next_sqn = self.next_sqn.next();
54    }
55
56    /// Mark the PD as seen.
57    pub fn mark_seen(&mut self, now_ms: u64) {
58        self.last_seen_ms = now_ms;
59        self.seen_at_least_once = true;
60    }
61
62    /// `true` once we've ever heard from the PD and the last exchange is
63    /// older than [`crate::OFFLINE_THRESHOLD_MS`]. Returns `false` while we
64    /// are still in the initial-connect window.
65    pub fn is_offline(&self, now_ms: u64) -> bool {
66        self.seen_at_least_once
67            && now_ms.saturating_sub(self.last_seen_ms) >= crate::OFFLINE_THRESHOLD_MS as u64
68    }
69
70    /// Reset to the freshly-booted state. Call this when an off-line PD
71    /// should be re-discovered from scratch.
72    pub fn reset(&mut self) {
73        *self = Self::default();
74    }
75}
76
77/// Outcome of a single command/reply exchange.
78#[derive(Debug, Clone, PartialEq, Eq)]
79#[non_exhaustive]
80pub enum ExchangeOutcome {
81    /// PD answered with a typed reply.
82    Reply(Reply),
83    /// PD answered with `osdp_BUSY`. Caller may try again later.
84    Busy,
85    /// No reply within the configured budget after exhausting retries.
86    Timeout,
87    /// PD has been silent for ≥ [`crate::OFFLINE_THRESHOLD_MS`].
88    Offline,
89}
90
91/// Configuration for retry policy.
92#[derive(Debug, Clone, Copy)]
93pub struct RetryConfig {
94    /// Number of *additional* attempts after the first (so `0` = no retry).
95    pub max_retries: u8,
96    /// Optional cap on how long to spend retrying a single command (ms). `0`
97    /// disables the cap; the default REPLY_DELAY budget per attempt still
98    /// applies.
99    pub overall_budget_ms: u32,
100}
101
102impl Default for RetryConfig {
103    fn default() -> Self {
104        Self {
105            max_retries: 2,
106            overall_budget_ms: 0,
107        }
108    }
109}
110
111/// ACU driver.
112pub struct Acu<T: Transport, C: Clock> {
113    transport: T,
114    clock: C,
115    /// Reply-delay budget per attempt, in milliseconds.
116    pub reply_delay_ms: u32,
117    /// Retry policy applied by [`Acu::exchange`].
118    pub retry: RetryConfig,
119    rx_buf: Vec<u8>,
120}
121
122impl<T: Transport, C: Clock> Acu<T, C> {
123    /// New driver with default reply delay and retry policy.
124    pub fn new(transport: T, clock: C) -> Self {
125        Self {
126            transport,
127            clock,
128            reply_delay_ms: crate::REPLY_DELAY_MS,
129            retry: RetryConfig::default(),
130            rx_buf: Vec::with_capacity(crate::MAX_BUS_PACKET),
131        }
132    }
133
134    /// Borrow the underlying transport.
135    pub fn transport(&mut self) -> &mut T {
136        &mut self.transport
137    }
138
139    /// Borrow the clock.
140    pub fn clock(&self) -> &C {
141        &self.clock
142    }
143
144    /// Encode and send `command` to `pd_addr` once. Returns the bytes written.
145    pub fn send_to(
146        &mut self,
147        pd_addr: u8,
148        pd: &mut PdState,
149        command: &Command,
150    ) -> Result<Vec<u8>, Error> {
151        self.send_with_sqn(pd_addr, pd.use_crc, pd.next_sqn, command)
152    }
153
154    fn send_with_sqn(
155        &mut self,
156        pd_addr: u8,
157        use_crc: bool,
158        sqn: Sqn,
159        command: &Command,
160    ) -> Result<Vec<u8>, Error> {
161        let addr = Address::pd(pd_addr)?;
162        let mut flags = CtrlFlags::empty();
163        if use_crc {
164            flags |= CtrlFlags::USE_CRC;
165        }
166        let ctrl = ControlByte::new(sqn, flags);
167        let data = command.encode_data()?;
168        let bytes = PacketBuilder::plain(addr, ctrl, command.code().as_byte(), data).encode()?;
169        self.transport.write_all(&bytes)?;
170        Ok(bytes)
171    }
172
173    /// Drain whatever bytes the transport has, then attempt to parse one
174    /// reply. Returns `Err(Error::Timeout)` once the per-attempt
175    /// reply-delay budget elapses without a complete packet.
176    ///
177    /// The loop reads up to a small fixed number of times per call to
178    /// drain a transport that may return short reads. When the underlying
179    /// transport returns `Ok(0)` (no more bytes immediately ready), we
180    /// check the deadline and either return `Timeout` or immediately
181    /// return — the caller is expected to call us again later.
182    pub fn receive(&mut self, pd: &mut PdState) -> Result<Reply, Error> {
183        let (reply_code, _sqn, data) = self.recv_loop()?;
184        let now = self.clock.now_ms();
185        pd.mark_seen(now);
186        pd.bump_sqn();
187        Reply::decode(reply_code, &data)
188    }
189
190    /// Inner read/parse loop shared by [`Self::receive`] and
191    /// [`Self::recv_one_with_sqn`]. Drains the transport into `rx_buf` until a
192    /// complete packet can be parsed, the per-attempt reply-delay budget is
193    /// exhausted, or the transport has signalled "no data" too many times.
194    ///
195    /// Returns the parsed reply code, the SQN it carried, and the raw DATA
196    /// bytes. SQN policy is left to the caller.
197    fn recv_loop(&mut self) -> Result<(ReplyCode, Sqn, Vec<u8>), Error> {
198        let start = self.clock.now_ms();
199        let mut empty_reads = 0u8;
200        loop {
201            if let Some(packet) = self.try_parse_packet()? {
202                return Ok(packet);
203            }
204            let mut tmp = [0u8; RX_CHUNK_LEN];
205            let n = self.transport.read(&mut tmp)?;
206            if n > 0 {
207                self.rx_buf.extend_from_slice(&tmp[..n]);
208                empty_reads = 0;
209                continue;
210            }
211            if self.clock.now_ms().saturating_sub(start) >= self.reply_delay_ms as u64 {
212                return Err(Error::Timeout);
213            }
214            empty_reads = empty_reads.saturating_add(1);
215            if empty_reads >= MAX_EMPTY_READS {
216                return Err(Error::Timeout);
217            }
218        }
219    }
220
221    /// Run a command/reply round-trip with full retry & off-line policy.
222    ///
223    /// - On a clean reply, returns [`ExchangeOutcome::Reply`].
224    /// - On `osdp_BUSY`, returns [`ExchangeOutcome::Busy`] without bumping SQN
225    ///   (per Annex A.2: BUSY's SQN is always 0). The caller decides whether
226    ///   to retry now or service other PDs first.
227    /// - On timeout, retries up to [`RetryConfig::max_retries`] additional
228    ///   times *re-using the same SQN* — that asks the PD to repeat its
229    ///   prior reply, per §5.7 / Table 2.
230    /// - When the PD has been silent for ≥ [`crate::OFFLINE_THRESHOLD_MS`],
231    ///   returns [`ExchangeOutcome::Offline`].
232    ///
233    #[cfg_attr(feature = "_docs", aquamarine::aquamarine)]
234    /// ```mermaid
235    /// flowchart TD
236    ///     enter([exchange]) --> off{is_offline?}
237    ///     off -- yes --> O[Offline]
238    ///     off -- no --> send[send_with_sqn]
239    ///     send --> recv[recv_one_with_sqn]
240    ///     recv --> kind{reply?}
241    ///     kind -- BUSY --> bu["Busy<br/>(SQN unchanged)"]
242    ///     kind -- typed --> ok["Reply<br/>(bump_sqn)"]
243    ///     kind -- Timeout --> retry{"retries left?<br/>budget left?<br/>not offline?"}
244    ///     retry -- yes --> send
245    ///     retry -- no --> T[Timeout]
246    /// ```
247    pub fn exchange(
248        &mut self,
249        pd_addr: u8,
250        pd: &mut PdState,
251        command: &Command,
252    ) -> Result<ExchangeOutcome, Error> {
253        let now = self.clock.now_ms();
254        if pd.is_offline(now) {
255            return Ok(ExchangeOutcome::Offline);
256        }
257
258        let started = now;
259        let sqn = pd.next_sqn;
260        let mut attempts: u8 = 0;
261        let max = self.retry.max_retries;
262        let budget = self.retry.overall_budget_ms;
263
264        loop {
265            self.send_with_sqn(pd_addr, pd.use_crc, sqn, command)?;
266            match self.recv_one_with_sqn(sqn) {
267                Ok(reply) => {
268                    let now = self.clock.now_ms();
269                    pd.mark_seen(now);
270                    if matches!(reply, Reply::Busy(_)) {
271                        // BUSY does not advance the SQN.
272                        return Ok(ExchangeOutcome::Busy);
273                    }
274                    pd.bump_sqn();
275                    return Ok(ExchangeOutcome::Reply(reply));
276                }
277                Err(Error::Timeout) => {
278                    attempts += 1;
279                    let now = self.clock.now_ms();
280                    if pd.is_offline(now) {
281                        return Ok(ExchangeOutcome::Offline);
282                    }
283                    if attempts > max {
284                        return Ok(ExchangeOutcome::Timeout);
285                    }
286                    if budget != 0 && now.saturating_sub(started) >= budget as u64 {
287                        return Ok(ExchangeOutcome::Timeout);
288                    }
289                    // Re-loop with the SAME SQN to ask for a reply repeat.
290                    continue;
291                }
292                Err(other) => return Err(other),
293            }
294        }
295    }
296
297    /// Same as [`Self::receive`] but without mutating any [`PdState`] (used
298    /// inside [`Self::exchange`] which has its own bookkeeping).
299    ///
300    /// Enforces §5.7 / Table 2: the PD must echo the SQN we sent. `osdp_BUSY`
301    /// is the documented exception — it is always SQN=0 regardless of what
302    /// the ACU sent — so its SQN is not checked.
303    fn recv_one_with_sqn(&mut self, expected_sqn: Sqn) -> Result<Reply, Error> {
304        let (reply_code, parsed_sqn, data) = self.recv_loop()?;
305        if reply_code != ReplyCode::Busy && parsed_sqn != expected_sqn {
306            return Err(Error::SqnMismatch {
307                expected: expected_sqn.value(),
308                got: parsed_sqn.value(),
309            });
310        }
311        Reply::decode(reply_code, &data)
312    }
313
314    fn try_parse_packet(&mut self) -> Result<Option<(ReplyCode, Sqn, Vec<u8>)>, Error> {
315        while let Some(som_pos) = self.rx_buf.iter().position(|&b| b == crate::SOM) {
316            self.rx_buf.drain(..som_pos);
317            match ParsedPacket::parse(&self.rx_buf) {
318                Ok((parsed, used)) => {
319                    let code = ReplyCode::from_byte(parsed.code)?;
320                    let sqn = parsed.ctrl.sqn;
321                    let data = parsed.data.to_vec();
322                    self.rx_buf.drain(..used);
323                    return Ok(Some((code, sqn, data)));
324                }
325                Err(Error::Truncated { .. }) => return Ok(None),
326                Err(Error::BadSom(_)) => {
327                    self.rx_buf.remove(0);
328                    continue;
329                }
330                // CRC/checksum failures: skip just past the SOM and resync.
331                Err(Error::BadCrc { .. }) | Err(Error::BadChecksum { .. }) => {
332                    self.rx_buf.remove(0);
333                    continue;
334                }
335                Err(other) => return Err(other),
336            }
337        }
338        Ok(None)
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use crate::clock::MockClock;
346    use crate::command::Poll;
347    use crate::transport::VecTransport;
348
349    #[test]
350    fn send_poll_emits_correct_bytes() {
351        let clock = MockClock::new();
352        let transport = VecTransport::new();
353        let mut acu = Acu::new(transport, clock);
354        let mut pd = PdState::default();
355        let bytes = acu.send_to(0x05, &mut pd, &Command::Poll(Poll)).unwrap();
356        assert_eq!(bytes[0], crate::SOM);
357        assert_eq!(bytes[1], 0x05);
358        assert_eq!(bytes[4] & 0x0F, 0x04); // SQN=0, CRC=on
359    }
360
361    #[test]
362    fn exchange_offline_when_silent() {
363        let clock = MockClock::new();
364        let transport = VecTransport::new();
365        let mut acu = Acu::new(transport, clock.clone());
366        acu.retry = RetryConfig {
367            max_retries: 0,
368            overall_budget_ms: 0,
369        };
370        let mut pd = PdState::default();
371        // Pretend we'd seen the PD long ago.
372        pd.mark_seen(0);
373        clock.set(crate::OFFLINE_THRESHOLD_MS as u64 + 1);
374        let outcome = acu.exchange(0x05, &mut pd, &Command::Poll(Poll)).unwrap();
375        assert_eq!(outcome, ExchangeOutcome::Offline);
376    }
377
378    #[test]
379    fn timeout_then_no_more_retries_returns_timeout() {
380        let clock = MockClock::new();
381        let transport = VecTransport::new();
382        let mut acu = Acu::new(transport, clock.clone());
383        acu.retry = RetryConfig {
384            max_retries: 0,
385            overall_budget_ms: 0,
386        };
387        let mut pd = PdState::default();
388        pd.mark_seen(0);
389        // Advance just enough that we're hitting the per-attempt budget but
390        // not yet off-line.
391        clock.set(crate::REPLY_DELAY_MS as u64 + 1);
392        let outcome = acu.exchange(0x05, &mut pd, &Command::Poll(Poll)).unwrap();
393        assert_eq!(outcome, ExchangeOutcome::Timeout);
394    }
395
396    #[test]
397    fn exchange_rejects_reply_with_wrong_sqn() {
398        // The ACU sends with SQN=1; we feed it back an ACK that claims SQN=2.
399        // Per §5.7 / Table 2 this is a stale/desync'd PD and must be rejected.
400        let clock = MockClock::new();
401        let mut transport = VecTransport::new();
402        let stale = PacketBuilder::plain(
403            Address::reply(0x05).unwrap(),
404            ControlByte::new(Sqn::new(2).unwrap(), CtrlFlags::USE_CRC),
405            crate::reply::ReplyCode::Ack.as_byte(),
406            alloc::vec::Vec::new(),
407        )
408        .encode()
409        .unwrap();
410        transport.feed(&stale);
411
412        let mut acu = Acu::new(transport, clock);
413        acu.retry = RetryConfig {
414            max_retries: 0,
415            overall_budget_ms: 0,
416        };
417        let mut pd = PdState {
418            next_sqn: Sqn::new(1).unwrap(),
419            ..Default::default()
420        };
421        pd.mark_seen(0);
422
423        let err = acu
424            .exchange(0x05, &mut pd, &Command::Poll(Poll))
425            .unwrap_err();
426        assert!(matches!(
427            err,
428            Error::SqnMismatch {
429                expected: 1,
430                got: 2
431            }
432        ));
433    }
434
435    #[test]
436    fn exchange_accepts_busy_with_sqn_zero() {
437        // BUSY always carries SQN=0 regardless of what the ACU sent
438        // (Annex A.2). Verify we accept it instead of flagging SQN mismatch.
439        let clock = MockClock::new();
440        let mut transport = VecTransport::new();
441        let busy = PacketBuilder::plain(
442            Address::reply(0x05).unwrap(),
443            ControlByte::new(Sqn::ZERO, CtrlFlags::USE_CRC),
444            crate::reply::ReplyCode::Busy.as_byte(),
445            alloc::vec::Vec::new(),
446        )
447        .encode()
448        .unwrap();
449        transport.feed(&busy);
450
451        let mut acu = Acu::new(transport, clock);
452        acu.retry = RetryConfig {
453            max_retries: 0,
454            overall_budget_ms: 0,
455        };
456        let mut pd = PdState {
457            next_sqn: Sqn::new(2).unwrap(),
458            ..Default::default()
459        };
460        pd.mark_seen(0);
461
462        let outcome = acu.exchange(0x05, &mut pd, &Command::Poll(Poll)).unwrap();
463        assert_eq!(outcome, ExchangeOutcome::Busy);
464    }
465}