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/// Per-PD bookkeeping owned by the ACU driver.
16#[derive(Debug, Clone)]
17pub struct PdState {
18    /// Next SQN to send.
19    pub next_sqn: Sqn,
20    /// Whether the PD prefers CRC trailers.
21    pub use_crc: bool,
22    /// Last successful exchange (ms, from the [`Clock`]).
23    pub last_seen_ms: u64,
24    /// Strictly true once `last_seen_ms` has been set at least once.
25    seen_at_least_once: bool,
26}
27
28impl Default for PdState {
29    fn default() -> Self {
30        Self {
31            next_sqn: Sqn::ZERO,
32            use_crc: true,
33            last_seen_ms: 0,
34            seen_at_least_once: false,
35        }
36    }
37}
38
39impl PdState {
40    /// Advance SQN.
41    pub fn bump_sqn(&mut self) {
42        self.next_sqn = self.next_sqn.next();
43    }
44
45    /// Mark the PD as seen.
46    pub fn mark_seen(&mut self, now_ms: u64) {
47        self.last_seen_ms = now_ms;
48        self.seen_at_least_once = true;
49    }
50
51    /// `true` once we've ever heard from the PD and the last exchange is
52    /// older than [`crate::OFFLINE_THRESHOLD_MS`]. Returns `false` while we
53    /// are still in the initial-connect window.
54    pub fn is_offline(&self, now_ms: u64) -> bool {
55        self.seen_at_least_once
56            && now_ms.saturating_sub(self.last_seen_ms) >= crate::OFFLINE_THRESHOLD_MS as u64
57    }
58
59    /// Reset to the freshly-booted state. Call this when an off-line PD
60    /// should be re-discovered from scratch.
61    pub fn reset(&mut self) {
62        *self = Self::default();
63    }
64}
65
66/// Outcome of a single command/reply exchange.
67#[derive(Debug, Clone, PartialEq, Eq)]
68#[non_exhaustive]
69pub enum ExchangeOutcome {
70    /// PD answered with a typed reply.
71    Reply(Reply),
72    /// PD answered with `osdp_BUSY`. Caller may try again later.
73    Busy,
74    /// No reply within the configured budget after exhausting retries.
75    Timeout,
76    /// PD has been silent for ≥ [`crate::OFFLINE_THRESHOLD_MS`].
77    Offline,
78}
79
80/// Configuration for retry policy.
81#[derive(Debug, Clone, Copy)]
82pub struct RetryConfig {
83    /// Number of *additional* attempts after the first (so `0` = no retry).
84    pub max_retries: u8,
85    /// Optional cap on how long to spend retrying a single command (ms). `0`
86    /// disables the cap; the default REPLY_DELAY budget per attempt still
87    /// applies.
88    pub overall_budget_ms: u32,
89}
90
91impl Default for RetryConfig {
92    fn default() -> Self {
93        Self {
94            max_retries: 2,
95            overall_budget_ms: 0,
96        }
97    }
98}
99
100/// ACU driver.
101pub struct Acu<T: Transport, C: Clock> {
102    transport: T,
103    clock: C,
104    /// Reply-delay budget per attempt, in milliseconds.
105    pub reply_delay_ms: u32,
106    /// Retry policy applied by [`Acu::exchange`].
107    pub retry: RetryConfig,
108    rx_buf: Vec<u8>,
109}
110
111impl<T: Transport, C: Clock> Acu<T, C> {
112    /// New driver with default reply delay and retry policy.
113    pub fn new(transport: T, clock: C) -> Self {
114        Self {
115            transport,
116            clock,
117            reply_delay_ms: crate::REPLY_DELAY_MS,
118            retry: RetryConfig::default(),
119            rx_buf: Vec::with_capacity(crate::MAX_BUS_PACKET),
120        }
121    }
122
123    /// Borrow the underlying transport.
124    pub fn transport(&mut self) -> &mut T {
125        &mut self.transport
126    }
127
128    /// Borrow the clock.
129    pub fn clock(&self) -> &C {
130        &self.clock
131    }
132
133    /// Encode and send `command` to `pd_addr` once. Returns the bytes written.
134    pub fn send_to(
135        &mut self,
136        pd_addr: u8,
137        pd: &mut PdState,
138        command: &Command,
139    ) -> Result<Vec<u8>, Error> {
140        self.send_with_sqn(pd_addr, pd.use_crc, pd.next_sqn, command)
141    }
142
143    fn send_with_sqn(
144        &mut self,
145        pd_addr: u8,
146        use_crc: bool,
147        sqn: Sqn,
148        command: &Command,
149    ) -> Result<Vec<u8>, Error> {
150        let addr = Address::pd(pd_addr)?;
151        let mut flags = CtrlFlags::empty();
152        if use_crc {
153            flags |= CtrlFlags::USE_CRC;
154        }
155        let ctrl = ControlByte::new(sqn, flags);
156        let data = command.encode_data()?;
157        let bytes = PacketBuilder::plain(addr, ctrl, command.code().as_byte(), data).encode()?;
158        self.transport.write_all(&bytes)?;
159        Ok(bytes)
160    }
161
162    /// Drain whatever bytes the transport has, then attempt to parse one
163    /// reply. Returns `Err(Error::Timeout)` once the per-attempt
164    /// reply-delay budget elapses without a complete packet.
165    ///
166    /// The loop reads up to a small fixed number of times per call to
167    /// drain a transport that may return short reads. When the underlying
168    /// transport returns `Ok(0)` (no more bytes immediately ready), we
169    /// check the deadline and either return `Timeout` or immediately
170    /// return — the caller is expected to call us again later.
171    pub fn receive(&mut self, pd: &mut PdState) -> Result<Reply, Error> {
172        let start = self.clock.now_ms();
173        let mut empty_reads = 0u8;
174        loop {
175            if let Some((reply_code, data)) = self.try_parse_packet()? {
176                let now = self.clock.now_ms();
177                pd.mark_seen(now);
178                pd.bump_sqn();
179                return Reply::decode(reply_code, &data);
180            }
181            let mut tmp = [0u8; 64];
182            let n = self.transport.read(&mut tmp)?;
183            if n > 0 {
184                self.rx_buf.extend_from_slice(&tmp[..n]);
185                empty_reads = 0;
186                continue;
187            }
188            if self.clock.now_ms().saturating_sub(start) >= self.reply_delay_ms as u64 {
189                return Err(Error::Timeout);
190            }
191            empty_reads = empty_reads.saturating_add(1);
192            // Nonblocking transports signal "no data" via Ok(0); after a few
193            // such returns within budget we hand control back to the caller
194            // (still as Timeout) rather than spin.
195            if empty_reads >= 4 {
196                return Err(Error::Timeout);
197            }
198        }
199    }
200
201    /// Run a command/reply round-trip with full retry & off-line policy.
202    ///
203    /// - On a clean reply, returns [`ExchangeOutcome::Reply`].
204    /// - On `osdp_BUSY`, returns [`ExchangeOutcome::Busy`] without bumping SQN
205    ///   (per Annex A.2: BUSY's SQN is always 0). The caller decides whether
206    ///   to retry now or service other PDs first.
207    /// - On timeout, retries up to [`RetryConfig::max_retries`] additional
208    ///   times *re-using the same SQN* — that asks the PD to repeat its
209    ///   prior reply, per §5.7 / Table 2.
210    /// - When the PD has been silent for ≥ [`crate::OFFLINE_THRESHOLD_MS`],
211    ///   returns [`ExchangeOutcome::Offline`].
212    pub fn exchange(
213        &mut self,
214        pd_addr: u8,
215        pd: &mut PdState,
216        command: &Command,
217    ) -> Result<ExchangeOutcome, Error> {
218        let now = self.clock.now_ms();
219        if pd.is_offline(now) {
220            return Ok(ExchangeOutcome::Offline);
221        }
222
223        let started = now;
224        let sqn = pd.next_sqn;
225        let mut attempts: u8 = 0;
226        let max = self.retry.max_retries;
227        let budget = self.retry.overall_budget_ms;
228
229        loop {
230            self.send_with_sqn(pd_addr, pd.use_crc, sqn, command)?;
231            match self.recv_one_with_sqn(sqn) {
232                Ok(reply) => {
233                    let now = self.clock.now_ms();
234                    pd.mark_seen(now);
235                    if matches!(reply, Reply::Busy(_)) {
236                        // BUSY does not advance the SQN.
237                        return Ok(ExchangeOutcome::Busy);
238                    }
239                    pd.bump_sqn();
240                    return Ok(ExchangeOutcome::Reply(reply));
241                }
242                Err(Error::Timeout) => {
243                    attempts += 1;
244                    let now = self.clock.now_ms();
245                    if pd.is_offline(now) {
246                        return Ok(ExchangeOutcome::Offline);
247                    }
248                    if attempts > max {
249                        return Ok(ExchangeOutcome::Timeout);
250                    }
251                    if budget != 0 && now.saturating_sub(started) >= budget as u64 {
252                        return Ok(ExchangeOutcome::Timeout);
253                    }
254                    // Re-loop with the SAME SQN to ask for a reply repeat.
255                    continue;
256                }
257                Err(other) => return Err(other),
258            }
259        }
260    }
261
262    /// Same as [`Self::receive`] but without mutating any [`PdState`] (used
263    /// inside [`Self::exchange`] which has its own bookkeeping).
264    fn recv_one_with_sqn(&mut self, _expected_sqn: Sqn) -> Result<Reply, Error> {
265        let start = self.clock.now_ms();
266        let mut empty_reads = 0u8;
267        loop {
268            if let Some((reply_code, data)) = self.try_parse_packet()? {
269                return Reply::decode(reply_code, &data);
270            }
271            let mut tmp = [0u8; 64];
272            let n = self.transport.read(&mut tmp)?;
273            if n > 0 {
274                self.rx_buf.extend_from_slice(&tmp[..n]);
275                empty_reads = 0;
276                continue;
277            }
278            if self.clock.now_ms().saturating_sub(start) >= self.reply_delay_ms as u64 {
279                return Err(Error::Timeout);
280            }
281            empty_reads = empty_reads.saturating_add(1);
282            if empty_reads >= 4 {
283                return Err(Error::Timeout);
284            }
285        }
286    }
287
288    fn try_parse_packet(&mut self) -> Result<Option<(ReplyCode, Vec<u8>)>, Error> {
289        while let Some(som_pos) = self.rx_buf.iter().position(|&b| b == crate::SOM) {
290            self.rx_buf.drain(..som_pos);
291            match ParsedPacket::parse(&self.rx_buf) {
292                Ok((parsed, used)) => {
293                    let code = ReplyCode::from_byte(parsed.code)?;
294                    let data = parsed.data.to_vec();
295                    self.rx_buf.drain(..used);
296                    return Ok(Some((code, data)));
297                }
298                Err(Error::Truncated { .. }) => return Ok(None),
299                Err(Error::BadSom(_)) => {
300                    self.rx_buf.remove(0);
301                    continue;
302                }
303                // CRC/checksum failures: skip just past the SOM and resync.
304                Err(Error::BadCrc { .. }) | Err(Error::BadChecksum { .. }) => {
305                    self.rx_buf.remove(0);
306                    continue;
307                }
308                Err(other) => return Err(other),
309            }
310        }
311        Ok(None)
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::clock::MockClock;
319    use crate::command::Poll;
320    use crate::transport::VecTransport;
321
322    #[test]
323    fn send_poll_emits_correct_bytes() {
324        let clock = MockClock::new();
325        let transport = VecTransport::new();
326        let mut acu = Acu::new(transport, clock);
327        let mut pd = PdState::default();
328        let bytes = acu.send_to(0x05, &mut pd, &Command::Poll(Poll)).unwrap();
329        assert_eq!(bytes[0], crate::SOM);
330        assert_eq!(bytes[1], 0x05);
331        assert_eq!(bytes[4] & 0x0F, 0x04); // SQN=0, CRC=on
332    }
333
334    #[test]
335    fn exchange_offline_when_silent() {
336        let clock = MockClock::new();
337        let transport = VecTransport::new();
338        let mut acu = Acu::new(transport, clock.clone());
339        acu.retry = RetryConfig {
340            max_retries: 0,
341            overall_budget_ms: 0,
342        };
343        let mut pd = PdState::default();
344        // Pretend we'd seen the PD long ago.
345        pd.mark_seen(0);
346        clock.set(crate::OFFLINE_THRESHOLD_MS as u64 + 1);
347        let outcome = acu.exchange(0x05, &mut pd, &Command::Poll(Poll)).unwrap();
348        assert_eq!(outcome, ExchangeOutcome::Offline);
349    }
350
351    #[test]
352    fn timeout_then_no_more_retries_returns_timeout() {
353        let clock = MockClock::new();
354        let transport = VecTransport::new();
355        let mut acu = Acu::new(transport, clock.clone());
356        acu.retry = RetryConfig {
357            max_retries: 0,
358            overall_budget_ms: 0,
359        };
360        let mut pd = PdState::default();
361        pd.mark_seen(0);
362        // Advance just enough that we're hitting the per-attempt budget but
363        // not yet off-line.
364        clock.set(crate::REPLY_DELAY_MS as u64 + 1);
365        let outcome = acu.exchange(0x05, &mut pd, &Command::Poll(Poll)).unwrap();
366        assert_eq!(outcome, ExchangeOutcome::Timeout);
367    }
368}