Skip to main content

bacnet_client/
tsm.rs

1//! Transaction State Machine (TSM) per ASHRAE 135-2020 Clause 5.4.
2//!
3//! Tracks in-flight confirmed requests. Each request gets a unique invoke_id
4//! (0-255) scoped per destination MAC. Responses are delivered via oneshot channels.
5
6use bacnet_types::MacAddr;
7use bytes::Bytes;
8use std::collections::HashMap;
9use tokio::sync::oneshot;
10
11/// TSM configuration.
12#[derive(Debug, Clone)]
13pub struct TsmConfig {
14    /// APDU timeout in milliseconds (default 6000).
15    pub apdu_timeout_ms: u64,
16    /// APDU segment timeout in milliseconds (default = apdu_timeout_ms).
17    pub apdu_segment_timeout_ms: u64,
18    /// Number of APDU retries (default 3).
19    pub apdu_retries: u8,
20}
21
22impl Default for TsmConfig {
23    fn default() -> Self {
24        Self {
25            apdu_timeout_ms: 6000,
26            apdu_segment_timeout_ms: 6000,
27            apdu_retries: 3,
28        }
29    }
30}
31
32/// Response types that complete a transaction.
33#[derive(Debug)]
34pub enum TsmResponse {
35    /// SimpleACK — confirmed service completed with no return data.
36    SimpleAck,
37    /// ComplexACK — confirmed service returned data.
38    ComplexAck { service_data: Bytes },
39    /// Error PDU.
40    Error { class: u32, code: u32 },
41    /// Reject PDU.
42    Reject { reason: u8 },
43    /// Abort PDU.
44    Abort { reason: u8 },
45}
46
47/// Invoke ID allocator scoped to a single destination MAC.
48struct InvokeIdAllocator {
49    next_id: u8,
50    in_use: [bool; 256],
51}
52
53impl InvokeIdAllocator {
54    fn new() -> Self {
55        Self {
56            next_id: 0,
57            in_use: [false; 256],
58        }
59    }
60
61    fn allocate(&mut self) -> Option<u8> {
62        let start = self.next_id;
63        loop {
64            let id = self.next_id;
65            self.next_id = self.next_id.wrapping_add(1);
66            if !self.in_use[id as usize] {
67                self.in_use[id as usize] = true;
68                return Some(id);
69            }
70            if self.next_id == start {
71                return None;
72            }
73        }
74    }
75
76    fn release(&mut self, id: u8) {
77        self.in_use[id as usize] = false;
78    }
79
80    fn all_free(&self) -> bool {
81        !self.in_use.iter().any(|&used| used)
82    }
83}
84
85/// Maximum number of distinct destination MACs tracked by the TSM.
86/// Prevents unbounded memory growth from spoofed source addresses.
87const MAX_TSM_DESTINATIONS: usize = 1024;
88
89/// Transaction State Machine.
90///
91/// Tracks pending confirmed requests and correlates responses by
92/// `(destination_mac, invoke_id)`.
93pub struct Tsm {
94    config: TsmConfig,
95    allocators: HashMap<MacAddr, InvokeIdAllocator>,
96    pending: HashMap<(MacAddr, u8), oneshot::Sender<TsmResponse>>,
97}
98
99impl Tsm {
100    pub fn new(config: TsmConfig) -> Self {
101        Self {
102            config,
103            allocators: HashMap::new(),
104            pending: HashMap::new(),
105        }
106    }
107
108    pub fn config(&self) -> &TsmConfig {
109        &self.config
110    }
111
112    /// Allocate an invoke ID for the given destination MAC.
113    /// Returns `None` if all 256 IDs are in use for this destination,
114    /// or if the maximum number of tracked destinations has been reached.
115    pub fn allocate_invoke_id(&mut self, destination_mac: &[u8]) -> Option<u8> {
116        let key = MacAddr::from_slice(destination_mac);
117        if !self.allocators.contains_key(&key) && self.allocators.len() >= MAX_TSM_DESTINATIONS {
118            return None;
119        }
120        let allocator = self
121            .allocators
122            .entry(key)
123            .or_insert_with(InvokeIdAllocator::new);
124        allocator.allocate()
125    }
126
127    /// Release an invoke ID back to the pool for the given destination.
128    /// Removes the allocator entry if all IDs are now free (prevents unbounded growth).
129    pub fn release_invoke_id(&mut self, destination_mac: &[u8], invoke_id: u8) {
130        let key = MacAddr::from_slice(destination_mac);
131        if let Some(allocator) = self.allocators.get_mut(&key) {
132            allocator.release(invoke_id);
133            if allocator.all_free() {
134                self.allocators.remove(&key);
135            }
136        }
137    }
138
139    /// Register a pending transaction. Returns a receiver that will deliver
140    /// the response when it arrives.
141    pub fn register_transaction(
142        &mut self,
143        destination_mac: MacAddr,
144        invoke_id: u8,
145    ) -> oneshot::Receiver<TsmResponse> {
146        let (tx, rx) = oneshot::channel();
147        debug_assert!(
148            !self
149                .pending
150                .contains_key(&(destination_mac.clone(), invoke_id)),
151            "duplicate TSM registration for invoke_id {}",
152            invoke_id
153        );
154        self.pending.insert((destination_mac, invoke_id), tx);
155        rx
156    }
157
158    /// Deliver a response to a pending transaction. Returns `true` if found.
159    pub fn complete_transaction(
160        &mut self,
161        source_mac: &[u8],
162        invoke_id: u8,
163        response: TsmResponse,
164    ) -> bool {
165        let key = (MacAddr::from_slice(source_mac), invoke_id);
166        if let Some(tx) = self.pending.remove(&key) {
167            self.release_invoke_id(source_mac, invoke_id);
168            let _ = tx.send(response);
169            true
170        } else {
171            false
172        }
173    }
174
175    /// Cancel a pending transaction. Returns `true` if found.
176    pub fn cancel_transaction(&mut self, destination_mac: &[u8], invoke_id: u8) -> bool {
177        let key = (MacAddr::from_slice(destination_mac), invoke_id);
178        if self.pending.remove(&key).is_some() {
179            self.release_invoke_id(destination_mac, invoke_id);
180            true
181        } else {
182            false
183        }
184    }
185
186    pub fn pending_count(&self) -> usize {
187        self.pending.len()
188    }
189}
190
191/// Drop guard that cleans up invoke IDs if a confirmed request task is cancelled.
192///
193/// Uses `try_lock` in Drop — best-effort cleanup. If the mutex is contended
194/// at drop time, the invoke ID leaks (acceptable: blocking in Drop is worse).
195pub(crate) struct TsmGuard {
196    tsm: std::sync::Arc<tokio::sync::Mutex<Tsm>>,
197    mac: MacAddr,
198    invoke_id: u8,
199    completed: bool,
200}
201
202impl TsmGuard {
203    pub(crate) fn new(
204        tsm: std::sync::Arc<tokio::sync::Mutex<Tsm>>,
205        mac: MacAddr,
206        invoke_id: u8,
207    ) -> Self {
208        Self {
209            tsm,
210            mac,
211            invoke_id,
212            completed: false,
213        }
214    }
215
216    /// Mark the transaction as completed (prevents cleanup on drop).
217    pub(crate) fn mark_completed(&mut self) {
218        self.completed = true;
219    }
220}
221
222impl Drop for TsmGuard {
223    fn drop(&mut self) {
224        if !self.completed {
225            if let Ok(mut tsm) = self.tsm.try_lock() {
226                tsm.cancel_transaction(&self.mac, self.invoke_id);
227            }
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn allocate_invoke_id_sequential() {
238        let mut tsm = Tsm::new(TsmConfig::default());
239        let mac = [127, 0, 0, 1, 0xBA, 0xC0];
240        let id1 = tsm.allocate_invoke_id(&mac);
241        let id2 = tsm.allocate_invoke_id(&mac);
242        assert_eq!(id1, Some(0));
243        assert_eq!(id2, Some(1));
244    }
245
246    #[test]
247    fn allocate_invoke_id_per_destination() {
248        let mut tsm = Tsm::new(TsmConfig::default());
249        let mac_a = [10, 0, 0, 1, 0xBA, 0xC0];
250        let mac_b = [10, 0, 0, 2, 0xBA, 0xC0];
251        let id_a = tsm.allocate_invoke_id(&mac_a);
252        let id_b = tsm.allocate_invoke_id(&mac_b);
253        assert_eq!(id_a, Some(0));
254        assert_eq!(id_b, Some(0));
255    }
256
257    #[test]
258    fn allocate_invoke_id_wraps() {
259        let mut tsm = Tsm::new(TsmConfig::default());
260        let mac = [127, 0, 0, 1, 0xBA, 0xC0];
261        for i in 0..256 {
262            assert_eq!(tsm.allocate_invoke_id(&mac), Some(i as u8));
263        }
264        assert_eq!(tsm.allocate_invoke_id(&mac), None);
265    }
266
267    #[test]
268    fn release_makes_id_available() {
269        let mut tsm = Tsm::new(TsmConfig::default());
270        let mac = [127, 0, 0, 1, 0xBA, 0xC0];
271        let id0 = tsm.allocate_invoke_id(&mac).unwrap();
272        let id1 = tsm.allocate_invoke_id(&mac).unwrap();
273        assert_eq!(id0, 0);
274        assert_eq!(id1, 1);
275        tsm.release_invoke_id(&mac, id0);
276        let id2 = tsm.allocate_invoke_id(&mac).unwrap();
277        assert_eq!(id2, 2);
278        tsm.release_invoke_id(&mac, id1);
279        tsm.release_invoke_id(&mac, id2);
280        let id3 = tsm.allocate_invoke_id(&mac).unwrap();
281        assert_eq!(id3, 0);
282    }
283
284    #[tokio::test]
285    async fn register_and_complete_transaction() {
286        let mut tsm = Tsm::new(TsmConfig::default());
287        let mac = MacAddr::from_slice(&[127, 0, 0, 1, 0xBA, 0xC0]);
288        let invoke_id = tsm.allocate_invoke_id(&mac).unwrap();
289
290        let rx = tsm.register_transaction(mac.clone(), invoke_id);
291
292        let response = TsmResponse::ComplexAck {
293            service_data: Bytes::from_static(&[0xDE, 0xAD]),
294        };
295        let completed = tsm.complete_transaction(&mac, invoke_id, response);
296        assert!(completed);
297
298        let result = rx.await.unwrap();
299        match result {
300            TsmResponse::ComplexAck { service_data } => {
301                assert_eq!(service_data, vec![0xDE, 0xAD]);
302            }
303            _ => panic!("Expected ComplexAck"),
304        }
305    }
306
307    #[tokio::test]
308    async fn complete_unknown_transaction_returns_false() {
309        let mut tsm = Tsm::new(TsmConfig::default());
310        let mac = MacAddr::from_slice(&[127, 0, 0, 1, 0xBA, 0xC0]);
311        let completed = tsm.complete_transaction(&mac, 42, TsmResponse::SimpleAck);
312        assert!(!completed);
313    }
314
315    #[test]
316    fn cancel_transaction() {
317        let mut tsm = Tsm::new(TsmConfig::default());
318        let mac = MacAddr::from_slice(&[127, 0, 0, 1, 0xBA, 0xC0]);
319        let invoke_id = tsm.allocate_invoke_id(&mac).unwrap();
320        let _rx = tsm.register_transaction(mac.clone(), invoke_id);
321        assert_eq!(tsm.pending_count(), 1);
322
323        let cancelled = tsm.cancel_transaction(&mac, invoke_id);
324        assert!(cancelled);
325        assert_eq!(tsm.pending_count(), 0);
326    }
327}