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    /// Number of APDU retries (default 3).
17    pub apdu_retries: u8,
18}
19
20impl Default for TsmConfig {
21    fn default() -> Self {
22        Self {
23            apdu_timeout_ms: 6000,
24            apdu_retries: 3,
25        }
26    }
27}
28
29/// Response types that complete a transaction.
30#[derive(Debug)]
31pub enum TsmResponse {
32    /// SimpleACK — confirmed service completed with no return data.
33    SimpleAck,
34    /// ComplexACK — confirmed service returned data.
35    ComplexAck { service_data: Bytes },
36    /// Error PDU.
37    Error { class: u32, code: u32 },
38    /// Reject PDU.
39    Reject { reason: u8 },
40    /// Abort PDU.
41    Abort { reason: u8 },
42}
43
44/// Per-destination invoke ID allocator.
45struct InvokeIdAllocator {
46    next_id: u8,
47    in_use: [bool; 256],
48}
49
50impl InvokeIdAllocator {
51    fn new() -> Self {
52        Self {
53            next_id: 0,
54            in_use: [false; 256],
55        }
56    }
57
58    fn allocate(&mut self) -> Option<u8> {
59        let start = self.next_id;
60        loop {
61            let id = self.next_id;
62            self.next_id = self.next_id.wrapping_add(1);
63            if !self.in_use[id as usize] {
64                self.in_use[id as usize] = true;
65                return Some(id);
66            }
67            if self.next_id == start {
68                return None; // All 256 IDs exhausted
69            }
70        }
71    }
72
73    fn release(&mut self, id: u8) {
74        self.in_use[id as usize] = false;
75    }
76
77    fn all_free(&self) -> bool {
78        !self.in_use.iter().any(|&used| used)
79    }
80}
81
82/// Maximum number of distinct destination MACs tracked by the TSM.
83/// Prevents unbounded memory growth from spoofed source addresses.
84const MAX_TSM_DESTINATIONS: usize = 1024;
85
86/// Transaction State Machine.
87///
88/// Tracks pending confirmed requests and correlates responses by
89/// `(destination_mac, invoke_id)`.
90pub struct Tsm {
91    config: TsmConfig,
92    /// Per-destination invoke ID allocators.
93    allocators: HashMap<MacAddr, InvokeIdAllocator>,
94    /// Pending transactions: (mac, invoke_id) -> oneshot sender.
95    pending: HashMap<(MacAddr, u8), oneshot::Sender<TsmResponse>>,
96}
97
98impl Tsm {
99    /// Create a new TSM with the given configuration.
100    pub fn new(config: TsmConfig) -> Self {
101        Self {
102            config,
103            allocators: HashMap::new(),
104            pending: HashMap::new(),
105        }
106    }
107
108    /// Get the TSM configuration.
109    pub fn config(&self) -> &TsmConfig {
110        &self.config
111    }
112
113    /// Allocate an invoke ID for the given destination MAC.
114    /// Returns `None` if all 256 IDs are in use for this destination,
115    /// or if the maximum number of tracked destinations has been reached.
116    pub fn allocate_invoke_id(&mut self, destination_mac: &[u8]) -> Option<u8> {
117        let key = MacAddr::from_slice(destination_mac);
118        if !self.allocators.contains_key(&key) && self.allocators.len() >= MAX_TSM_DESTINATIONS {
119            return None; // Reject new destinations when at capacity
120        }
121        let allocator = self
122            .allocators
123            .entry(key)
124            .or_insert_with(InvokeIdAllocator::new);
125        allocator.allocate()
126    }
127
128    /// Release an invoke ID back to the pool for the given destination.
129    /// Removes the allocator entry if all IDs are now free (prevents unbounded growth).
130    pub fn release_invoke_id(&mut self, destination_mac: &[u8], invoke_id: u8) {
131        let key = MacAddr::from_slice(destination_mac);
132        if let Some(allocator) = self.allocators.get_mut(&key) {
133            allocator.release(invoke_id);
134            if allocator.all_free() {
135                self.allocators.remove(&key);
136            }
137        }
138    }
139
140    /// Register a pending transaction. Returns a receiver that will deliver
141    /// the response when it arrives.
142    pub fn register_transaction(
143        &mut self,
144        destination_mac: MacAddr,
145        invoke_id: u8,
146    ) -> oneshot::Receiver<TsmResponse> {
147        let (tx, rx) = oneshot::channel();
148        self.pending.insert((destination_mac, invoke_id), tx);
149        rx
150    }
151
152    /// Complete a pending transaction by delivering the response.
153    /// Returns `true` if the transaction was found and completed.
154    pub fn complete_transaction(
155        &mut self,
156        source_mac: &[u8],
157        invoke_id: u8,
158        response: TsmResponse,
159    ) -> bool {
160        let key = (MacAddr::from_slice(source_mac), invoke_id);
161        if let Some(tx) = self.pending.remove(&key) {
162            // Release the invoke ID
163            self.release_invoke_id(source_mac, invoke_id);
164            // Ignore send error (receiver may have been dropped/timed out)
165            let _ = tx.send(response);
166            true
167        } else {
168            false
169        }
170    }
171
172    /// Cancel a pending transaction (e.g., on timeout). Returns `true` if found.
173    pub fn cancel_transaction(&mut self, destination_mac: &[u8], invoke_id: u8) -> bool {
174        let key = (MacAddr::from_slice(destination_mac), invoke_id);
175        if self.pending.remove(&key).is_some() {
176            self.release_invoke_id(destination_mac, invoke_id);
177            true
178        } else {
179            false
180        }
181    }
182
183    /// Number of pending transactions.
184    pub fn pending_count(&self) -> usize {
185        self.pending.len()
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn allocate_invoke_id_sequential() {
195        let mut tsm = Tsm::new(TsmConfig::default());
196        let mac = [127, 0, 0, 1, 0xBA, 0xC0];
197        let id1 = tsm.allocate_invoke_id(&mac);
198        let id2 = tsm.allocate_invoke_id(&mac);
199        assert_eq!(id1, Some(0));
200        assert_eq!(id2, Some(1));
201    }
202
203    #[test]
204    fn allocate_invoke_id_per_destination() {
205        let mut tsm = Tsm::new(TsmConfig::default());
206        let mac_a = [10, 0, 0, 1, 0xBA, 0xC0];
207        let mac_b = [10, 0, 0, 2, 0xBA, 0xC0];
208        let id_a = tsm.allocate_invoke_id(&mac_a);
209        let id_b = tsm.allocate_invoke_id(&mac_b);
210        // Both destinations start at 0
211        assert_eq!(id_a, Some(0));
212        assert_eq!(id_b, Some(0));
213    }
214
215    #[test]
216    fn allocate_invoke_id_wraps() {
217        let mut tsm = Tsm::new(TsmConfig::default());
218        let mac = [127, 0, 0, 1, 0xBA, 0xC0];
219        // Exhaust all 256 IDs
220        for i in 0..256 {
221            assert_eq!(tsm.allocate_invoke_id(&mac), Some(i as u8));
222        }
223        // 257th should fail
224        assert_eq!(tsm.allocate_invoke_id(&mac), None);
225    }
226
227    #[test]
228    fn release_makes_id_available() {
229        let mut tsm = Tsm::new(TsmConfig::default());
230        let mac = [127, 0, 0, 1, 0xBA, 0xC0];
231        let id0 = tsm.allocate_invoke_id(&mac).unwrap();
232        let id1 = tsm.allocate_invoke_id(&mac).unwrap();
233        assert_eq!(id0, 0);
234        assert_eq!(id1, 1);
235        // Release id0 — allocator still has id1 in use, so it persists
236        tsm.release_invoke_id(&mac, id0);
237        // Next allocation wraps around and finds id0 free
238        let id2 = tsm.allocate_invoke_id(&mac).unwrap();
239        assert_eq!(id2, 2); // sequential, skips in-use id1
240        tsm.release_invoke_id(&mac, id1);
241        tsm.release_invoke_id(&mac, id2);
242        // All released — allocator cleaned up, next alloc starts fresh
243        let id3 = tsm.allocate_invoke_id(&mac).unwrap();
244        assert_eq!(id3, 0);
245    }
246
247    #[tokio::test]
248    async fn register_and_complete_transaction() {
249        let mut tsm = Tsm::new(TsmConfig::default());
250        let mac = MacAddr::from_slice(&[127, 0, 0, 1, 0xBA, 0xC0]);
251        let invoke_id = tsm.allocate_invoke_id(&mac).unwrap();
252
253        let rx = tsm.register_transaction(mac.clone(), invoke_id);
254
255        // Simulate receiving a ComplexACK
256        let response = TsmResponse::ComplexAck {
257            service_data: Bytes::from_static(&[0xDE, 0xAD]),
258        };
259        let completed = tsm.complete_transaction(&mac, invoke_id, response);
260        assert!(completed);
261
262        // The receiver should get the response
263        let result = rx.await.unwrap();
264        match result {
265            TsmResponse::ComplexAck { service_data } => {
266                assert_eq!(service_data, vec![0xDE, 0xAD]);
267            }
268            _ => panic!("Expected ComplexAck"),
269        }
270    }
271
272    #[tokio::test]
273    async fn complete_unknown_transaction_returns_false() {
274        let mut tsm = Tsm::new(TsmConfig::default());
275        let mac = MacAddr::from_slice(&[127, 0, 0, 1, 0xBA, 0xC0]);
276        let completed = tsm.complete_transaction(&mac, 42, TsmResponse::SimpleAck);
277        assert!(!completed);
278    }
279
280    #[test]
281    fn cancel_transaction() {
282        let mut tsm = Tsm::new(TsmConfig::default());
283        let mac = MacAddr::from_slice(&[127, 0, 0, 1, 0xBA, 0xC0]);
284        let invoke_id = tsm.allocate_invoke_id(&mac).unwrap();
285        let _rx = tsm.register_transaction(mac.clone(), invoke_id);
286        assert_eq!(tsm.pending_count(), 1);
287
288        let cancelled = tsm.cancel_transaction(&mac, invoke_id);
289        assert!(cancelled);
290        assert_eq!(tsm.pending_count(), 0);
291    }
292}