Skip to main content

autocore_std/
command_client.rs

1//! Client for sending IPC commands to external modules via WebSocket.
2//!
3//! `CommandClient` allows control programs to send [`CommandMessage`] requests
4//! (e.g., `labelit.translate_check`) to any external module through the existing
5//! WebSocket connection and poll for responses non-blockingly from `process_tick`.
6//!
7//! # Multi-consumer pattern
8//!
9//! The framework passes a `&mut CommandClient` to the control program each cycle
10//! via [`TickContext`](crate::TickContext). The framework calls [`poll()`](CommandClient::poll)
11//! before `process_tick`, so incoming responses are already buffered. Each
12//! subsystem sends requests via [`send()`](CommandClient::send) and retrieves
13//! its own responses by transaction ID via
14//! [`take_response()`](CommandClient::take_response).
15//!
16//! ```ignore
17//! fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
18//!     // poll() is already called by the framework before process_tick
19//!
20//!     // Each subsystem checks for its own responses by transaction_id
21//!     self.labelit.tick(ctx.client);
22//!     self.other_camera.tick(ctx.client);
23//!
24//!     // Clean up stale requests
25//!     ctx.client.drain_stale(Duration::from_secs(10));
26//! }
27//! ```
28
29use std::collections::{HashMap, VecDeque};
30use std::time::{Duration, Instant};
31
32use mechutil::ipc::{CommandMessage, MessageType};
33use serde_json::Value;
34use tokio::sync::mpsc;
35
36struct PendingRequest {
37    topic: String,
38    sent_at: Instant,
39}
40
41/// A non-blocking client for sending IPC commands to external modules.
42///
43/// `CommandClient` is constructed by [`ControlRunner`](crate::ControlRunner) during
44/// startup and passed to the control program each cycle via
45/// [`TickContext::client`](crate::TickContext::client).
46///
47/// All methods are non-blocking and safe to call from `process_tick`.
48///
49/// Multiple subsystems (state machines, modules) can share a single `CommandClient`.
50/// Each subsystem calls [`send()`](Self::send) to issue requests and
51/// [`take_response()`](Self::take_response) to claim its own responses by
52/// `transaction_id`. The framework calls [`poll()`](Self::poll) once per tick
53/// before `process_tick`, so incoming messages are already buffered.
54pub struct CommandClient {
55    /// Channel to send serialized messages for the WS write task.
56    write_tx: mpsc::UnboundedSender<String>,
57    /// Channel to receive response + broadcast CommandMessages from the
58    /// WS read task. Broadcasts arrive when the server's `tx_broadcast`
59    /// fires (e.g., `ams.asset_updated.tsdr_fx`); responses arrive
60    /// matched to outgoing requests by transaction_id.
61    response_rx: mpsc::UnboundedReceiver<CommandMessage>,
62    /// Track pending requests by transaction_id for timeout/diagnostics.
63    pending: HashMap<u32, PendingRequest>,
64    /// Buffered responses keyed by transaction_id, ready for consumers to claim.
65    responses: HashMap<u32, CommandMessage>,
66    /// Topics this client has registered interest in. Broadcasts whose
67    /// topic matches an entry here are buffered in `broadcasts` rather
68    /// than silently discarded. Subscribers drain their topic's queue
69    /// each tick via `take_broadcasts`.
70    subscribed_topics: HashMap<String, VecDeque<CommandMessage>>,
71}
72
73impl CommandClient {
74    /// Create a new `CommandClient` from channels created by `ControlRunner`.
75    pub fn new(
76        write_tx: mpsc::UnboundedSender<String>,
77        response_rx: mpsc::UnboundedReceiver<CommandMessage>,
78    ) -> Self {
79        Self {
80            write_tx,
81            response_rx,
82            pending: HashMap::new(),
83            responses: HashMap::new(),
84            subscribed_topics: HashMap::new(),
85        }
86    }
87
88    /// Send a command request to an external module.
89    ///
90    /// Creates a [`CommandMessage::request`] with the given topic and data,
91    /// serializes it, and pushes it into the WebSocket write channel.
92    ///
93    /// Returns the `transaction_id` which can be used to match the response.
94    ///
95    /// # Arguments
96    ///
97    /// * `topic` - Fully-qualified topic name (e.g., `"labelit.translate_check"`)
98    /// * `data` - JSON payload for the request
99    pub fn send(&mut self, topic: &str, data: Value) -> u32 {
100        let msg = CommandMessage::request(topic, data);
101        self.send_message(msg)
102    }
103
104    /// Send an arbitrary `CommandMessage`.
105    /// 
106    /// Useful for sending specific `MessageType` requests like `Read` or `Write`.
107    /// Returns the `transaction_id` which can be used to match the response.
108    pub fn send_message(&mut self, msg: CommandMessage) -> u32 {
109        let transaction_id = msg.transaction_id;
110
111        if let Ok(json) = serde_json::to_string(&msg) {
112            let _ = self.write_tx.send(json);
113        }
114
115        self.pending.insert(transaction_id, PendingRequest {
116            topic: msg.topic.clone(),
117            sent_at: Instant::now(),
118        });
119
120        transaction_id
121    }
122
123    /// Drain all available responses from the WebSocket channel into the
124    /// internal buffer.
125    ///
126    /// Call this **once per tick** at the top of `process_tick`, before any
127    /// subsystem calls [`take_response()`](Self::take_response). This ensures
128    /// every subsystem sees responses that arrived since the last cycle.
129    pub fn poll(&mut self) {
130        while let Ok(msg) = self.response_rx.try_recv() {
131            if msg.message_type == MessageType::Broadcast {
132                if let Some(queue) = self.subscribed_topics.get_mut(&msg.topic) {
133                    queue.push_back(msg);
134                }
135                // Broadcasts on un-subscribed topics are silently discarded.
136                continue;
137            }
138            let tid = msg.transaction_id;
139            if self.pending.remove(&tid).is_some() {
140                //log::info!("poll: matched response tid={} topic='{}'", tid, msg.topic);
141                self.responses.insert(tid, msg);
142            }
143            // Unsolicited responses (gm.write, ethercat.write) are silently discarded
144        }
145    }
146
147    /// Register interest in a broadcast topic. After this call, any
148    /// incoming `CommandMessage` with this exact topic is buffered for
149    /// retrieval via [`take_broadcasts`](Self::take_broadcasts).
150    /// Idempotent — calling `subscribe` for an already-subscribed topic
151    /// does not reset the buffer.
152    ///
153    /// Topics are matched as exact strings. The AMS asset-update wire
154    /// uses one topic per location (`ams.asset_updated.tsdr_fx`), so a
155    /// control program that cares about three load cells calls
156    /// `subscribe` three times.
157    pub fn subscribe(&mut self, topic: &str) {
158        self.subscribed_topics
159            .entry(topic.to_string())
160            .or_insert_with(VecDeque::new);
161    }
162
163    /// Drain every buffered broadcast on the given topic, in arrival
164    /// order. Returns an empty Vec if the topic isn't subscribed or
165    /// nothing has arrived since the last drain.
166    ///
167    /// Recommended cadence: call once per tick from the subsystem
168    /// that owns the topic, right after the framework's `poll()`.
169    pub fn take_broadcasts(&mut self, topic: &str) -> Vec<CommandMessage> {
170        match self.subscribed_topics.get_mut(topic) {
171            Some(queue) => queue.drain(..).collect(),
172            None => Vec::new(),
173        }
174    }
175
176    /// Number of buffered broadcasts across every subscribed topic.
177    /// Useful for diagnostics; the normal consumption path is
178    /// `take_broadcasts` per topic.
179    pub fn buffered_broadcast_count(&self) -> usize {
180        self.subscribed_topics.values().map(|q| q.len()).sum()
181    }
182
183    /// Take a response for a specific `transaction_id` from the buffer.
184    ///
185    /// Returns `Some(response)` if a response with that ID has been received,
186    /// or `None` if it hasn't arrived yet. The response is removed from the
187    /// buffer on retrieval.
188    ///
189    /// This is the recommended way for subsystems to retrieve their responses,
190    /// since each subsystem only claims its own `transaction_id` and cannot
191    /// accidentally consume another subsystem's response.
192    ///
193    /// # Example
194    ///
195    /// ```ignore
196    /// // In a subsystem's tick method:
197    /// if let Some(response) = client.take_response(self.my_tid) {
198    ///     if response.success {
199    ///         // handle success
200    ///     } else {
201    ///         // handle error
202    ///     }
203    /// }
204    /// ```
205    pub fn take_response(&mut self, transaction_id: u32) -> Option<CommandMessage> {
206        self.responses.remove(&transaction_id)
207    }
208
209    /// Check if a request is still awaiting a response.
210    ///
211    /// Returns `true` if the request has been sent but no response has arrived
212    /// yet. Returns `false` if the response is already buffered (even if not
213    /// yet claimed via [`take_response()`](Self::take_response)) or if the
214    /// transaction ID is unknown.
215    pub fn is_pending(&self, transaction_id: u32) -> bool {
216        self.pending.contains_key(&transaction_id)
217    }
218
219    /// Number of outstanding requests (sent but no response received yet).
220    pub fn pending_count(&self) -> usize {
221        self.pending.len()
222    }
223
224    /// Number of responses buffered and ready to be claimed.
225    pub fn response_count(&self) -> usize {
226        self.responses.len()
227    }
228
229    /// Remove and return transaction IDs that have been pending longer than `timeout`.
230    pub fn drain_stale(&mut self, timeout: Duration) -> Vec<u32> {
231        let now = Instant::now();
232        let stale: Vec<u32> = self.pending.iter()
233            .filter(|(_, req)| now.duration_since(req.sent_at) > timeout)
234            .map(|(&tid, _)| tid)
235            .collect();
236
237        for tid in &stale {
238            if let Some(req) = self.pending.remove(tid) {
239                log::warn!("Command request {} ('{}') timed out after {:?}",
240                    tid, req.topic, timeout);
241            }
242        }
243
244        stale
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use mechutil::ipc::MessageType;
252    use serde_json::json;
253
254    #[test]
255    fn test_send_pushes_to_channel() {
256        let (write_tx, mut write_rx) = mpsc::unbounded_channel();
257        let (_response_tx, response_rx) = mpsc::unbounded_channel();
258        let mut client = CommandClient::new(write_tx, response_rx);
259
260        let tid = client.send("test.command", json!({"key": "value"}));
261
262        // Should have pushed a message to the write channel
263        let msg_json = write_rx.try_recv().expect("should have a message");
264        let msg: CommandMessage = serde_json::from_str(&msg_json).unwrap();
265        assert_eq!(msg.transaction_id, tid);
266        assert_eq!(msg.topic, "test.command");
267        assert_eq!(msg.message_type, MessageType::Request);
268        assert_eq!(msg.data, json!({"key": "value"}));
269
270        // Should be tracked as pending
271        assert!(client.is_pending(tid));
272        assert_eq!(client.pending_count(), 1);
273    }
274
275    #[test]
276    fn test_poll_and_take_response() {
277        let (write_tx, _write_rx) = mpsc::unbounded_channel();
278        let (response_tx, response_rx) = mpsc::unbounded_channel();
279        let mut client = CommandClient::new(write_tx, response_rx);
280
281        let tid = client.send("test.command", json!(null));
282        assert!(client.is_pending(tid));
283
284        // Simulate response arriving
285        response_tx.send(CommandMessage::response(tid, json!("ok"))).unwrap();
286
287        // Before poll, take_response finds nothing
288        assert!(client.take_response(tid).is_none());
289
290        // After poll, take_response returns the response
291        client.poll();
292        assert!(!client.is_pending(tid));
293        assert_eq!(client.response_count(), 1);
294
295        let recv = client.take_response(tid).unwrap();
296        assert_eq!(recv.transaction_id, tid);
297        assert_eq!(client.response_count(), 0);
298    }
299
300    #[test]
301    fn test_multi_consumer_isolation() {
302        let (write_tx, _write_rx) = mpsc::unbounded_channel();
303        let (response_tx, response_rx) = mpsc::unbounded_channel();
304        let mut client = CommandClient::new(write_tx, response_rx);
305
306        // Two subsystems send requests
307        let tid_a = client.send("labelit.inspect", json!(null));
308        let tid_b = client.send("other.status", json!(null));
309
310        // Both responses arrive
311        response_tx.send(CommandMessage::response(tid_b, json!("b_result"))).unwrap();
312        response_tx.send(CommandMessage::response(tid_a, json!("a_result"))).unwrap();
313
314        // Single poll drains both
315        client.poll();
316        assert_eq!(client.response_count(), 2);
317
318        // Each subsystem claims only its own response
319        let resp_a = client.take_response(tid_a).unwrap();
320        assert_eq!(resp_a.data, json!("a_result"));
321
322        let resp_b = client.take_response(tid_b).unwrap();
323        assert_eq!(resp_b.data, json!("b_result"));
324
325        // Neither response is available again
326        assert!(client.take_response(tid_a).is_none());
327        assert!(client.take_response(tid_b).is_none());
328        assert_eq!(client.response_count(), 0);
329    }
330
331    #[test]
332    fn test_drain_stale() {
333        let (write_tx, _write_rx) = mpsc::unbounded_channel();
334        let (_response_tx, response_rx) = mpsc::unbounded_channel();
335        let mut client = CommandClient::new(write_tx, response_rx);
336
337        let tid = client.send("test.command", json!(null));
338        assert_eq!(client.pending_count(), 1);
339
340        // With a zero timeout, the request should be immediately stale
341        let stale = client.drain_stale(Duration::from_secs(0));
342        assert_eq!(stale, vec![tid]);
343        assert_eq!(client.pending_count(), 0);
344    }
345
346    #[test]
347    fn test_drain_stale_keeps_fresh() {
348        let (write_tx, _write_rx) = mpsc::unbounded_channel();
349        let (_response_tx, response_rx) = mpsc::unbounded_channel();
350        let mut client = CommandClient::new(write_tx, response_rx);
351
352        let tid = client.send("test.command", json!(null));
353
354        // With a long timeout, nothing should be stale
355        let stale = client.drain_stale(Duration::from_secs(3600));
356        assert!(stale.is_empty());
357        assert!(client.is_pending(tid));
358    }
359
360    #[test]
361    fn test_drain_stale_ignores_received() {
362        let (write_tx, _write_rx) = mpsc::unbounded_channel();
363        let (response_tx, response_rx) = mpsc::unbounded_channel();
364        let mut client = CommandClient::new(write_tx, response_rx);
365
366        let tid = client.send("test.command", json!(null));
367
368        // Response arrives before we drain
369        response_tx.send(CommandMessage::response(tid, json!("ok"))).unwrap();
370        client.poll();
371
372        // drain_stale should not report it since it's no longer pending
373        let stale = client.drain_stale(Duration::from_secs(0));
374        assert!(stale.is_empty());
375
376        // But it's still in the response buffer
377        assert!(client.take_response(tid).is_some());
378    }
379
380    #[test]
381    fn test_multiple_pending() {
382        let (write_tx, _write_rx) = mpsc::unbounded_channel();
383        let (response_tx, response_rx) = mpsc::unbounded_channel();
384        let mut client = CommandClient::new(write_tx, response_rx);
385
386        let tid1 = client.send("cmd.first", json!(1));
387        let tid2 = client.send("cmd.second", json!(2));
388        let tid3 = client.send("cmd.third", json!(3));
389        assert_eq!(client.pending_count(), 3);
390
391        // Respond to the second one
392        response_tx.send(CommandMessage::response(tid2, json!("ok"))).unwrap();
393        client.poll();
394
395        assert_eq!(client.pending_count(), 2);
396        assert!(client.is_pending(tid1));
397        assert!(!client.is_pending(tid2));
398        assert!(client.is_pending(tid3));
399
400        let recv = client.take_response(tid2).unwrap();
401        assert_eq!(recv.transaction_id, tid2);
402    }
403
404    #[test]
405    fn test_unsolicited_responses_discarded() {
406        let (write_tx, _write_rx) = mpsc::unbounded_channel();
407        let (response_tx, response_rx) = mpsc::unbounded_channel();
408        let mut client = CommandClient::new(write_tx, response_rx);
409
410        // Simulate responses arriving for transaction IDs that were never
411        // registered via send() (e.g. gm.write sent directly via ws_write_tx).
412        response_tx.send(CommandMessage::response(99999, json!("stale1"))).unwrap();
413        response_tx.send(CommandMessage::response(99998, json!("stale2"))).unwrap();
414
415        client.poll();
416
417        // Unsolicited responses must NOT accumulate in the responses HashMap
418        assert_eq!(client.response_count(), 0);
419        assert!(client.take_response(99999).is_none());
420        assert!(client.take_response(99998).is_none());
421    }
422
423    #[test]
424    fn test_mix_of_solicited_and_unsolicited() {
425        let (write_tx, _write_rx) = mpsc::unbounded_channel();
426        let (response_tx, response_rx) = mpsc::unbounded_channel();
427        let mut client = CommandClient::new(write_tx, response_rx);
428
429        // One real request
430        let tid = client.send("real.command", json!(null));
431
432        // One unsolicited + one solicited response
433        response_tx.send(CommandMessage::response(77777, json!("unsolicited"))).unwrap();
434        response_tx.send(CommandMessage::response(tid, json!("real_result"))).unwrap();
435
436        client.poll();
437
438        // Only the solicited response should be buffered
439        assert_eq!(client.response_count(), 1);
440        let resp = client.take_response(tid).unwrap();
441        assert_eq!(resp.data, json!("real_result"));
442        assert!(client.take_response(77777).is_none());
443    }
444}