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