Skip to main content

ados_sdk/
client.rs

1//! Async IPC client: the plugin runner side of Contract C.
2//!
3//! Ports `ados.plugins.ipc_client.PluginIpcClient`. One instance per plugin
4//! process. It connects to the supervisor's per-plugin Unix socket, runs the
5//! `hello` handshake (echoing the capability token the supervisor minted),
6//! then serves two flows on the one connection:
7//!
8//! * request / response — every call mints a fresh `r<n>` request id, parks a
9//!   one-shot waiter in the pending map, writes the request frame, and awaits
10//!   the response the reader loop routes back by `request_id`.
11//! * event / MAVLink push — the reader loop dispatches `event` envelopes to the
12//!   topic-matched event callbacks and `mavlink.deliver` envelopes to the
13//!   message-name-matched MAVLink callbacks.
14//!
15//! The wire is `ados-protocol` unchanged: length-prefixed msgpack [`Envelope`]
16//! frames (plugin contract: reject zero length, 4 MiB cap) and the
17//! pipe-delimited HMAC [`ados_protocol::plugin::CapabilityToken`].
18
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicU64, Ordering};
22use std::sync::{Arc, Mutex};
23use std::time::Duration;
24
25use ados_protocol::frame::{decode_len, FrameError, HEADER_SIZE, PLUGIN_MAX_FRAME};
26use ados_protocol::node_info::NodeInfo;
27use ados_protocol::plugin::{
28    CapabilityToken, Envelope, PROTOCOL_VERSION, TELEMETRY_STATE_TOPIC, TOKEN_REFRESH_METHOD,
29};
30use ados_protocol::vision_rpc;
31use rmpv::Value;
32use thiserror::Error;
33use tokio::io::{AsyncReadExt, AsyncWriteExt};
34use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
35use tokio::net::UnixStream;
36use tokio::sync::{oneshot, watch, Mutex as AsyncMutex};
37use tokio::task::JoinHandle;
38
39/// Default per-request timeout. Mirrors `DEFAULT_REQUEST_TIMEOUT_S = 5.0`.
40pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
41
42/// Slack added on top of the relay's own record-write bound before the client
43/// gives up on a `cloud.records.put`. The host forwards the write and waits up
44/// to [`ados_protocol::cloud_publish::RECORD_REPLY_TIMEOUT`] for the cloud's
45/// answer; the client must outlast that wait or it reports a timeout for a
46/// write the cloud then accepts.
47const RECORD_REPLY_SLACK: Duration = Duration::from_secs(2);
48
49/// The offload link a plugin running perception offload reports through
50/// [`PluginIpcClient::offload_advertise`]. The host stamps the schema version
51/// and write time and publishes it as the offload-link sidecar
52/// ([`ados_protocol::offload_link::OffloadLink`]) the perception-tier decision
53/// reads.
54#[derive(Debug, Clone, Default, PartialEq, Eq)]
55pub struct OffloadAdvertisement {
56    /// A compute node resolved and is reachable.
57    pub paired: bool,
58    /// The bearer to that node is healthy enough to offload over.
59    pub bearer_acceptable: bool,
60    /// The node's job-API address (`host:port`).
61    pub target: Option<String>,
62    /// The node's device id, when discovery reported one.
63    pub device_id: Option<String>,
64    /// The detector model id the offload session runs.
65    pub model_id: Option<String>,
66}
67
68/// A callback invoked for each delivered event or MAVLink frame. The argument
69/// is the delivered envelope's `args` map (topic + payload for events; the
70/// `msg_name` + `frame` + `timestamp_ms` map for MAVLink). The callback runs on
71/// the reader task, so it should not block; offload heavy work to a channel.
72pub type EventCallback = Arc<dyn Fn(Value) + Send + Sync>;
73
74/// Errors surfaced to plugin code by the client.
75#[derive(Debug, Error)]
76pub enum ClientError {
77    #[error("ipc client not connected")]
78    NotConnected,
79    #[error("io error: {0}")]
80    Io(#[from] std::io::Error),
81    #[error("framing error: {0}")]
82    Frame(#[from] FrameError),
83    #[error("envelope codec error: {0}")]
84    Envelope(#[from] ados_protocol::plugin::PluginError),
85    #[error("request timed out after {0:?}")]
86    Timeout(Duration),
87    /// The reader loop ended before the response arrived (peer closed).
88    #[error("connection closed before response")]
89    ConnectionClosed,
90    /// The host gated the request: `capability_denied: <cap>`.
91    #[error("capability denied: {0}")]
92    CapabilityDenied(String),
93    /// The manifest spawn allowlist refused the basename:
94    /// `allowlist_violation: <basename>`.
95    #[error("allowlist violation: {0}")]
96    AllowlistViolation(String),
97    /// Any other error string the host returned in the envelope `error` field.
98    #[error("rpc error: {0}")]
99    Rpc(String),
100}
101
102/// A vision request this client could not build.
103pub(crate) fn vision_args_error(e: vision_rpc::VisionArgsError) -> ClientError {
104    ClientError::Rpc(format!("vision request encode failed: {e}"))
105}
106
107type PendingMap = Arc<Mutex<HashMap<String, oneshot::Sender<Envelope>>>>;
108
109/// Runs one host-to-plugin `tool.invoke`: `(tool name, arguments)` to the
110/// tool result, or an error string the host relays to the MCP caller. The
111/// host waits a bounded time (5 s) for the reply, so a long operation returns
112/// a job handle instead of blocking.
113pub type ToolHandler = Arc<
114    dyn Fn(
115            String,
116            Value,
117        )
118            -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value, String>> + Send>>
119        + Send
120        + Sync,
121>;
122
123/// The method name of a host-to-plugin tool invocation.
124pub const TOOL_INVOKE_METHOD: &str = "tool.invoke";
125type CallbackMap = Arc<Mutex<HashMap<String, Vec<EventCallback>>>>;
126
127/// Async client. One instance per plugin runner process.
128pub struct PluginIpcClient {
129    plugin_id: String,
130    /// The capability token this client presents, shared with the reader loop
131    /// because the host ROTATES it: a permission change or a pre-expiry
132    /// re-mint arrives as a `token.refresh` push, and the copy presented at
133    /// the next `hello` has to be the current one or the plugin cannot
134    /// reconnect.
135    token: Arc<Mutex<String>>,
136    socket_path: PathBuf,
137    /// The write half is shared behind an async mutex so concurrent request
138    /// senders serialize their frame writes.
139    writer: Arc<AsyncMutex<Option<OwnedWriteHalf>>>,
140    pending: PendingMap,
141    event_callbacks: CallbackMap,
142    mavlink_callbacks: CallbackMap,
143    /// Frame-descriptor callbacks for `vision.deliver` pushes, keyed by the
144    /// camera id a subscriber filtered on. The wildcard key `*` receives every
145    /// camera's frames. The reader loop routes a delivered descriptor by its
146    /// `camera_id` to the matching key plus the wildcard.
147    vision_callbacks: CallbackMap,
148    /// Detection-batch callbacks, keyed by camera id (plus the wildcard key).
149    /// Separate from `vision_callbacks` because a frame descriptor and a
150    /// detection batch are different payloads on different subscriptions.
151    detection_callbacks: CallbackMap,
152    /// Front-panel button callbacks. Keyed on the wildcard only: a press has no
153    /// subject to filter on, so every subscriber sees every press.
154    button_callbacks: CallbackMap,
155    /// MSP byte-plane callbacks. Keyed on the wildcard only: MSP has no per-
156    /// message topic, so every subscriber sees every FC->host chunk.
157    msp_callbacks: CallbackMap,
158    /// Auxiliary-stream application-datagram callbacks for
159    /// `radio.aux_stream.deliver` pushes. Keyed on the wildcard only: an app
160    /// datagram has no subject to filter on, so every subscriber sees every
161    /// frame (the plugin's own codec routes by channel).
162    aux_callbacks: CallbackMap,
163    /// The handler for host-to-plugin `tool.invoke` requests; `None` until the
164    /// plugin registers one, and such a request is then answered with an error.
165    tool_handler: Arc<Mutex<Option<ToolHandler>>>,
166    reader_task: Mutex<Option<JoinHandle<()>>>,
167    /// `true` once the reader loop ended on its own: the host closed the
168    /// session or the stream broke. [`Self::close`] aborts the loop instead, so
169    /// a deliberate close never reads as a loss.
170    session_lost: Arc<watch::Sender<bool>>,
171    next_id: AtomicU64,
172    request_timeout: Duration,
173}
174
175/// The wildcard vision-callback key: a subscriber that did not filter to a
176/// single camera registers here and receives every camera's frames.
177const VISION_ANY_CAMERA: &str = "*";
178
179impl PluginIpcClient {
180    /// Build a client for one plugin, bound to a socket path and the capability
181    /// token the supervisor minted for this process.
182    pub fn new(
183        plugin_id: impl Into<String>,
184        token: impl Into<String>,
185        socket_path: impl AsRef<Path>,
186    ) -> Self {
187        Self {
188            plugin_id: plugin_id.into(),
189            token: Arc::new(Mutex::new(token.into())),
190            socket_path: socket_path.as_ref().to_path_buf(),
191            writer: Arc::new(AsyncMutex::new(None)),
192            pending: Arc::new(Mutex::new(HashMap::new())),
193            event_callbacks: Arc::new(Mutex::new(HashMap::new())),
194            mavlink_callbacks: Arc::new(Mutex::new(HashMap::new())),
195            vision_callbacks: Arc::new(Mutex::new(HashMap::new())),
196            detection_callbacks: Arc::new(Mutex::new(HashMap::new())),
197            button_callbacks: Arc::new(Mutex::new(HashMap::new())),
198            msp_callbacks: Arc::new(Mutex::new(HashMap::new())),
199            aux_callbacks: Arc::new(Mutex::new(HashMap::new())),
200            tool_handler: Arc::new(Mutex::new(None)),
201            reader_task: Mutex::new(None),
202            session_lost: Arc::new(watch::channel(false).0),
203            next_id: AtomicU64::new(0),
204            request_timeout: DEFAULT_REQUEST_TIMEOUT,
205        }
206    }
207
208    /// Override the per-request timeout (default [`DEFAULT_REQUEST_TIMEOUT`]).
209    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
210        self.request_timeout = timeout;
211        self
212    }
213
214    /// Register the handler that answers host-to-plugin `tool.invoke`
215    /// requests (the plugin's MCP tools). Replaces any earlier handler.
216    pub fn set_tool_handler(&self, handler: ToolHandler) {
217        *self.tool_handler.lock().expect("tool handler lock") = Some(handler);
218    }
219
220    /// The plugin id this client carries.
221    pub fn plugin_id(&self) -> &str {
222        &self.plugin_id
223    }
224
225    /// Connect to the socket, spawn the reader loop, and run the `hello`
226    /// handshake. Mirrors `connect`: open the connection, start the reader,
227    /// then send the handshake request and await the `{"ready": true}` reply.
228    pub async fn connect(&self) -> Result<(), ClientError> {
229        let stream = UnixStream::connect(&self.socket_path).await?;
230        let (read_half, write_half) = stream.into_split();
231        {
232            let mut w = self.writer.lock().await;
233            *w = Some(write_half);
234        }
235        self.session_lost.send_replace(false);
236        let task = self.spawn_reader_loop(read_half);
237        {
238            let mut slot = self.reader_task.lock().expect("reader_task lock");
239            *slot = Some(task);
240        }
241        // Handshake: the host verifies the token then replies {"ready": true}.
242        self.send_request("hello", "", Value::Map(vec![])).await?;
243        tracing::info!(plugin_id = %self.plugin_id, "plugin ipc client connected");
244        Ok(())
245    }
246
247    /// Cancel the reader loop and drop the write half. Mirrors `close`.
248    pub async fn close(&self) {
249        if let Some(task) = self.reader_task.lock().expect("reader_task lock").take() {
250            task.abort();
251        }
252        let mut w = self.writer.lock().await;
253        *w = None;
254    }
255
256    /// Resolve once the host session is lost: the host closed the connection
257    /// (a plugin-host restart) or the stream broke. The client never
258    /// reconnects — a restarted host re-mints this plugin's socket and token
259    /// and hands them to a fresh process — so a plugin that sees this should
260    /// exit non-zero and let its unit's `Restart=` bring it back.
261    /// [`crate::run_plugin`] does exactly that. Pending until then, including
262    /// after a deliberate [`Self::close`].
263    pub async fn session_lost(&self) {
264        let mut rx = self.session_lost.subscribe();
265        // The sender lives as long as `self`, so the wait cannot error out.
266        let _ = rx.wait_for(|lost| *lost).await;
267    }
268
269    // ---- Health -------------------------------------------------------
270
271    /// Health probe. Mirrors `ping`: `{"pong": true, "plugin_id": <id>}`.
272    pub async fn ping(&self) -> Result<Value, ClientError> {
273        Ok(self
274            .send_request("ping", "", Value::Map(vec![]))
275            .await?
276            .args)
277    }
278
279    // ---- Events -------------------------------------------------------
280
281    /// Publish an event. Returns the delivered count. Mirrors `event_publish`.
282    pub async fn event_publish(&self, topic: &str, payload: Value) -> Result<i64, ClientError> {
283        let args = Value::Map(vec![
284            (Value::from("topic"), Value::from(topic)),
285            (Value::from("payload"), payload),
286        ]);
287        let resp = self
288            .send_request("event.publish", "event.publish", args)
289            .await?;
290        Ok(map_get_i64(&resp.args, "delivered").unwrap_or(0))
291    }
292
293    /// Subscribe to an event topic pattern and register a callback for matched
294    /// deliveries. Mirrors `event_subscribe`.
295    pub async fn event_subscribe(
296        &self,
297        topic_pattern: &str,
298        callback: EventCallback,
299    ) -> Result<(), ClientError> {
300        register_callback(&self.event_callbacks, topic_pattern, callback);
301        let args = Value::Map(vec![(Value::from("topic"), Value::from(topic_pattern))]);
302        self.send_request("event.subscribe", "event.subscribe", args)
303            .await?;
304        Ok(())
305    }
306}
307
308mod cloud;
309mod config;
310mod dispatch;
311mod mavlink;
312mod media;
313mod peripheral;
314mod transport;
315mod vision;
316pub use dispatch::driver_kind_capability;
317use dispatch::*;
318pub use media::WhepAnswer;
319
320#[cfg(test)]
321mod tests;
322#[cfg(test)]
323mod tests_host;