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;