ados_sdk/context.rs
1//! Plugin-facing facade over the IPC client.
2//!
3//! Ports `ados.plugins.ipc.context`. [`PluginContext`] is the object handed to
4//! every lifecycle hook. Each field is a thin capability-gated facade backed by
5//! one [`PluginIpcClient`] call: the facade shapes arguments and decodes the
6//! response; the host enforces capabilities. The facades share one client
7//! behind an `Arc` so the context can be passed by reference into hooks while
8//! the client owns the single connection.
9
10use std::collections::BTreeMap;
11use std::sync::Arc;
12
13use ados_protocol::camera_control::{CameraControlRequest, CameraControlResult};
14use rmpv::Value;
15
16use crate::client::{ClientError, EventCallback, PluginIpcClient};
17use crate::vision::VisionClient;
18
19mod media;
20mod network;
21pub use media::{SensorsClient, VideoClient};
22pub use network::{CloudClient, MdnsClient, NodeClient};
23
24/// `ctx.events` — the event bus facade.
25#[derive(Clone)]
26pub struct EventsClient {
27 ipc: Arc<PluginIpcClient>,
28}
29
30impl EventsClient {
31 /// Publish a payload on a topic. Returns the delivered count.
32 pub async fn publish(&self, topic: &str, payload: Value) -> Result<i64, ClientError> {
33 self.ipc.event_publish(topic, payload).await
34 }
35
36 /// Subscribe to a topic pattern; the callback fires for each matched
37 /// delivery with the event's `args` map (`topic`, `payload`, `publisher`,
38 /// `timestamp_ms`).
39 pub async fn subscribe(
40 &self,
41 topic_pattern: &str,
42 callback: EventCallback,
43 ) -> Result<(), ClientError> {
44 self.ipc.event_subscribe(topic_pattern, callback).await
45 }
46}
47
48/// `ctx.mavlink` — read and write through the host's MAVLink router.
49#[derive(Clone)]
50pub struct MavlinkClient {
51 ipc: Arc<PluginIpcClient>,
52}
53
54impl MavlinkClient {
55 /// Send a raw MAVLink frame, optionally from a registered component id.
56 pub async fn send(
57 &self,
58 msg_bytes: &[u8],
59 component_id: Option<i64>,
60 ) -> Result<Value, ClientError> {
61 self.ipc.mavlink_send(msg_bytes, component_id).await
62 }
63
64 /// Subscribe to a MAVLink message name; the callback fires for each matched
65 /// delivery with the `msg_name`, `frame`, and `timestamp_ms` map.
66 pub async fn subscribe(
67 &self,
68 msg_name: &str,
69 callback: EventCallback,
70 ) -> Result<(), ClientError> {
71 self.ipc.mavlink_subscribe(msg_name, callback).await
72 }
73
74 /// Register this plugin as a MAVLink component of the given kind.
75 pub async fn register_component(&self, comp_id: i64, kind: &str) -> Result<Value, ClientError> {
76 self.ipc.mavlink_register_component(comp_id, kind).await
77 }
78}
79
80/// Pack normalized sticks into AETR PWM channels `[roll, pitch, throttle, yaw]`,
81/// applying the codec's bipolar (centre 1500) and throttle (idle 1000) scaling.
82/// Separated from [`MspClient::send_sticks`] so the scaling is unit-testable
83/// without an IPC transport — the whole point of the fix is that the scaling is
84/// actually applied.
85fn sticks_to_channels(roll: f32, pitch: f32, yaw: f32, throttle: f32) -> [u16; 4] {
86 use ados_protocol::msp::{bipolar_to_pwm, throttle_to_pwm};
87 [
88 bipolar_to_pwm(roll),
89 bipolar_to_pwm(pitch),
90 throttle_to_pwm(throttle),
91 bipolar_to_pwm(yaw),
92 ]
93}
94
95/// `ctx.msp` — read and write raw MSP to a Betaflight / iNav / KISS FC through
96/// the host's MSP byte plane. The sibling of [`MavlinkClient`] for an FC that
97/// speaks MSP; the codec that builds/parses the bytes is
98/// [`ados_protocol::msp`].
99#[derive(Clone)]
100pub struct MspClient {
101 ipc: Arc<PluginIpcClient>,
102}
103
104impl MspClient {
105 /// Send already-framed MSP bytes (build them with `ados_protocol::msp`).
106 /// Gated on `msp.write`.
107 pub async fn send(&self, msg_bytes: &[u8]) -> Result<Value, ClientError> {
108 self.ipc.msp_send(msg_bytes).await
109 }
110
111 /// Convenience: send an `MSP_SET_RAW_RC` with the given PWM channel values
112 /// (v2 framing). Builds the frame with the codec and sends it. Returns an
113 /// error if a v1 encode were requested with too many channels (v2 never
114 /// overflows). This is the command that flies the FC in a rate mode.
115 pub async fn send_raw_rc(&self, channels: &[u16]) -> Result<Value, ClientError> {
116 let frame = ados_protocol::msp::set_raw_rc(channels, true)
117 .ok_or_else(|| ClientError::Rpc("too many RC channels for the frame".to_string()))?;
118 self.ipc.msp_send(&frame).await
119 }
120
121 /// Send an `MSP_SET_RAW_RC` from NORMALIZED stick inputs, applying the codec's
122 /// stick scaling. `roll`/`pitch`/`yaw` are bipolar `-1.0..=1.0` (centre `0` →
123 /// `1500` µs); `throttle` is `0.0..=1.0` (idle `0` → `1000` µs, NOT centre).
124 /// Channels are packed AETR `[roll, pitch, throttle, yaw]`, the codebase
125 /// convention (the `ados-crsf` bank + the GCS mapping).
126 ///
127 /// This is the safe way to fly an MSP FC from a control loop: [`send_raw_rc`]
128 /// takes RAW PWM and applies NO scaling, so handing it normalized sticks
129 /// sends near-centre garbage (and a `0.0` throttle would sit at `0`, not
130 /// idle). Prefer this whenever the loop produces normalized commands.
131 ///
132 /// [`send_raw_rc`]: Self::send_raw_rc
133 pub async fn send_sticks(
134 &self,
135 roll: f32,
136 pitch: f32,
137 yaw: f32,
138 throttle: f32,
139 ) -> Result<Value, ClientError> {
140 self.send_raw_rc(&sticks_to_channels(roll, pitch, yaw, throttle))
141 .await
142 }
143
144 /// Subscribe to the raw FC->host MSP byte stream. The callback fires for each
145 /// delivered chunk with the `{bytes, timestamp_ms}` map; the plugin's own
146 /// codec parses `bytes`. Gated on `msp.read`.
147 pub async fn subscribe(&self, callback: EventCallback) -> Result<(), ClientError> {
148 self.ipc.msp_subscribe(callback).await
149 }
150}
151
152/// `ctx.telemetry` — read the vehicle state and extend the heartbeat schema.
153#[derive(Clone)]
154pub struct TelemetryClient {
155 ipc: Arc<PluginIpcClient>,
156}
157
158impl TelemetryClient {
159 /// Receive the vehicle-state snapshot (armed, mode, position, attitude,
160 /// battery, ...), the newest at most ten times a second. The callback gets
161 /// the state object itself, a map keyed as the agent's state wire. A node
162 /// with no flight controller never fires it. Needs `telemetry.read`.
163 pub async fn subscribe(&self, callback: EventCallback) -> Result<(), ClientError> {
164 let on_deliver = move |args: Value| {
165 let Value::Map(entries) = args else {
166 return;
167 };
168 if let Some((_, state)) = entries
169 .into_iter()
170 .find(|(k, _)| k.as_str() == Some("payload"))
171 {
172 callback(state);
173 }
174 };
175 self.ipc.telemetry_subscribe(Arc::new(on_deliver)).await
176 }
177
178 /// Add a channel of fields to the telemetry stream that ships to the GCS.
179 pub async fn extend(&self, channel: &str, payload: Value) -> Result<Value, ClientError> {
180 self.ipc.telemetry_extend(channel, payload).await
181 }
182}
183
184/// `ctx.peripheral_manager` — register driver instances and claim cameras.
185///
186/// A driver is registered by an opaque reference id; the driver itself keeps
187/// running in the plugin process. The host records the claim and routes the
188/// driver kind through its registry. This mirrors `_driver_ref`: the host never
189/// sees the live driver object. Each kind needs the catalog cap from
190/// [`crate::client::driver_kind_capability`]. A host with no consumer for
191/// registered drivers answers `not_implemented`, surfaced as `ClientError::Rpc`.
192#[derive(Clone)]
193pub struct PeripheralClient {
194 ipc: Arc<PluginIpcClient>,
195}
196
197impl PeripheralClient {
198 pub async fn register_camera_driver(&self, driver_ref: &str) -> Result<Value, ClientError> {
199 self.ipc
200 .peripheral_register_driver("camera", driver_ref)
201 .await
202 }
203 pub async fn register_lidar_driver(&self, driver_ref: &str) -> Result<Value, ClientError> {
204 self.ipc
205 .peripheral_register_driver("lidar", driver_ref)
206 .await
207 }
208 pub async fn register_gimbal_driver(&self, driver_ref: &str) -> Result<Value, ClientError> {
209 self.ipc
210 .peripheral_register_driver("gimbal", driver_ref)
211 .await
212 }
213 pub async fn register_gps_driver(&self, driver_ref: &str) -> Result<Value, ClientError> {
214 self.ipc.peripheral_register_driver("gps", driver_ref).await
215 }
216 pub async fn register_esc_driver(&self, driver_ref: &str) -> Result<Value, ClientError> {
217 self.ipc.peripheral_register_driver("esc", driver_ref).await
218 }
219 pub async fn register_payload_actuator_driver(
220 &self,
221 driver_ref: &str,
222 ) -> Result<Value, ClientError> {
223 self.ipc
224 .peripheral_register_driver("payload-actuator", driver_ref)
225 .await
226 }
227
228 /// Release a previously-registered driver by handle.
229 pub async fn unregister(&self, handle_id: &str) -> Result<Value, ClientError> {
230 self.ipc.peripheral_unregister_driver(handle_id).await
231 }
232
233 /// Claim a camera device path, optionally exclusive.
234 pub async fn claim_camera(
235 &self,
236 device_path: &str,
237 exclusive: bool,
238 ) -> Result<Value, ClientError> {
239 self.ipc.camera_claim(device_path, exclusive).await
240 }
241}
242
243/// `ctx.camera` — path-level claim/release, a frame-pull primitive, and the
244/// exposure and gain of cameras the vision engine owns.
245#[derive(Clone)]
246pub struct CameraClient {
247 ipc: Arc<PluginIpcClient>,
248}
249
250impl CameraClient {
251 /// Claim a `/dev/videoN` path. A second exclusive claim on the same path is
252 /// refused before any V4L2 handle is opened.
253 pub async fn claim(&self, device_path: &str, exclusive: bool) -> Result<Value, ClientError> {
254 self.ipc.camera_claim(device_path, exclusive).await
255 }
256
257 /// Release a claimed camera path.
258 pub async fn release(&self, device_path: &str) -> Result<Value, ClientError> {
259 self.ipc.camera_release(device_path).await
260 }
261
262 /// Pull the latest captured frame. The result map carries `frame_id`,
263 /// `width`, `height`, `format`, `data`, `ts_ns`, `stale`. Repeated `stale`
264 /// frames signal a stalled capture pipeline.
265 pub async fn get_frame(
266 &self,
267 device_path: &str,
268 format: &str,
269 timeout_ms: i64,
270 ) -> Result<Value, ClientError> {
271 self.ipc
272 .camera_get_frame(device_path, format, timeout_ms)
273 .await
274 }
275
276 /// Set exposure, gain or auto exposure on the engine-owned camera
277 /// `req.camera_id` and return the values it reads back. Needs
278 /// `camera.control.write`. See [`PluginIpcClient::camera_set_controls`].
279 pub async fn set_controls(
280 &self,
281 req: &CameraControlRequest,
282 ) -> Result<CameraControlResult, ClientError> {
283 self.ipc.camera_set_controls(req).await
284 }
285}
286
287/// `ctx.config` — live config kv plus the manifest-supplied static config.
288///
289/// `ctx.gpio` — host GPIO output.
290///
291/// Drive a status line or play a bounded buzzer pattern. The gpio service owns
292/// the safe bounds, so a plugin requests a pattern and the service clamps it —
293/// there is deliberately no client-side clamp to drift from the real ceiling.
294#[derive(Clone)]
295pub struct GpioClient {
296 ipc: Arc<PluginIpcClient>,
297}
298
299impl GpioClient {
300 /// Drive a line high or low.
301 pub async fn set(&self, chip: i64, pin: i64, high: bool) -> Result<Value, ClientError> {
302 self.ipc.gpio_output_set(chip, pin, high).await
303 }
304
305 /// Play a bounded beep. `off_ms`/`freq_hz` ride through when given.
306 pub async fn beep(
307 &self,
308 chip: i64,
309 pin: i64,
310 on_ms: i64,
311 cycles: i64,
312 off_ms: Option<i64>,
313 freq_hz: Option<i64>,
314 ) -> Result<Value, ClientError> {
315 self.ipc
316 .gpio_buzzer_beep(chip, pin, on_ms, cycles, off_ms, freq_hz)
317 .await
318 }
319}
320
321/// `ctx.display` — the reserved data-driven display page.
322///
323/// A plugin owns one page of title, `(label, value)` rows and touch zones
324/// without recompiling the display service; the host renders it from the sidecar
325/// this writes.
326#[derive(Clone)]
327pub struct DisplayClient {
328 ipc: Arc<PluginIpcClient>,
329}
330
331impl DisplayClient {
332 /// Set the page content. `zones` are `(x, y, w, h, key, label)` rectangles in
333 /// page-local content coordinates; a tap in one delivers `key` back to the
334 /// plugin.
335 pub async fn set_page(
336 &self,
337 title: &str,
338 rows: &[(String, String)],
339 zones: &[(i64, i64, i64, i64, String, String)],
340 ) -> Result<Value, ClientError> {
341 self.ipc.display_page_set(title, rows, zones).await
342 }
343}
344
345/// `ctx.radio` — the additive auxiliary application stream on the link.
346///
347/// Opens a plugin-owned lane alongside the existing telemetry/video planes,
348/// gated so it only exists while the plugin is active.
349#[derive(Clone)]
350pub struct RadioClient {
351 ipc: Arc<PluginIpcClient>,
352}
353
354impl RadioClient {
355 /// Open the auxiliary stream.
356 pub async fn open_aux_stream(&self) -> Result<Value, ClientError> {
357 self.ipc.radio_aux_stream_open().await
358 }
359
360 /// Close the auxiliary stream.
361 pub async fn close_aux_stream(&self) -> Result<Value, ClientError> {
362 self.ipc.radio_aux_stream_close().await
363 }
364
365 /// Send one application datagram on the open auxiliary stream.
366 ///
367 /// `channel` must be the reserved app channel this plugin's direction uses
368 /// (`AppStream` 8 or `AppCommand` 9); the host refuses any other. The payload
369 /// is opaque to the host; a payload over the aux-frame maximum is refused
370 /// before it is sent.
371 pub async fn send_aux(&self, channel: u8, payload: &[u8]) -> Result<Value, ClientError> {
372 self.ipc.radio_aux_stream_send(channel, payload).await
373 }
374
375 /// Subscribe to application datagrams received on the open auxiliary stream.
376 ///
377 /// The callback receives each `(channel, payload)` the host forwards; the
378 /// plugin's codec routes by channel. A stream with no inbound frames never
379 /// fires the callback — the resting state, not an error. Register before
380 /// subscribing so no early frame is missed.
381 pub async fn subscribe_aux<F: Fn(u8, Vec<u8>) + Send + Sync + 'static>(
382 &self,
383 cb: F,
384 ) -> Result<(), ClientError> {
385 let on_deliver = move |args: Value| {
386 let Some(m) = args.as_map() else { return };
387 let ch = m
388 .iter()
389 .find(|(k, _)| k.as_str() == Some("channel"))
390 .and_then(|(_, v)| v.as_u64())
391 .unwrap_or(0) as u8;
392 let pl = match m
393 .iter()
394 .find(|(k, _)| k.as_str() == Some("payload"))
395 .map(|(_, v)| v)
396 {
397 Some(Value::Binary(b)) => b.clone(),
398 _ => Vec::new(),
399 };
400 cb(ch, pl);
401 };
402 self.ipc.register_aux_callback(Arc::new(on_deliver));
403 self.ipc.radio_aux_stream_subscribe().await.map(|_| ())
404 }
405}
406
407/// `ctx.buttons` — front-panel presses.
408///
409/// The on-device input surface for a plugin whose operator has no screen and no
410/// ground station. The host owns the bus and the short/long decode, so a plugin
411/// never re-implements debounce or the action mapping and cannot drift from what
412/// the panel's own UI thinks a press means.
413///
414/// Read-only and non-exclusive: several consumers watch the same bus, so
415/// subscribing observes presses without consuming or remapping them.
416#[derive(Clone)]
417pub struct ButtonClient {
418 ipc: Arc<PluginIpcClient>,
419}
420
421impl ButtonClient {
422 /// Receive every front-panel press.
423 ///
424 /// The callback gets the decoded [`ados_protocol::buttons::ButtonPress`].
425 /// `action` is `None` for an unmapped button — the press is still delivered,
426 /// so a plugin can bind one the operator has not assigned.
427 ///
428 /// A board with no front panel never fires the callback. That is the resting
429 /// state, not an error, so this does not fail on a node without buttons.
430 pub async fn subscribe(
431 &self,
432 callback: Arc<dyn Fn(ados_protocol::buttons::ButtonPress) + Send + Sync>,
433 ) -> Result<(), ClientError> {
434 let on_deliver = move |args: Value| {
435 let Some(press) = args
436 .as_map()
437 .and_then(|m| m.iter().find(|(k, _)| k.as_str() == Some("press")))
438 .map(|(_, v)| v.clone())
439 else {
440 return;
441 };
442 // Round-trip through msgpack so the field mapping is the contract's
443 // single source of truth rather than a hand-written reader here.
444 let Ok(blob) = rmp_serde::to_vec_named(&press) else {
445 return;
446 };
447 let Ok(decoded) = rmp_serde::from_slice::<ados_protocol::buttons::ButtonPress>(&blob)
448 else {
449 return;
450 };
451 callback(decoded);
452 };
453 self.ipc.register_button_callback(Arc::new(on_deliver));
454 self.ipc.button_subscribe().await?;
455 Ok(())
456 }
457}
458
459/// `ctx.tools` — the plugin's MCP tools.
460///
461/// The host routes an MCP `tools/call` for one of the plugin's declared tools
462/// to the live plugin as a `tool.invoke` request (gated on `mcp.expose`); the
463/// registered handler runs it and its result is the reply.
464#[derive(Clone)]
465pub struct ToolsClient {
466 ipc: Arc<PluginIpcClient>,
467}
468
469impl ToolsClient {
470 /// Answer every `tool.invoke` with `handler`. Registration is local: no
471 /// host request is made.
472 pub fn serve(&self, handler: crate::client::ToolHandler) {
473 self.ipc.set_tool_handler(handler);
474 }
475}
476
477/// `ctx.flight` — the scoped guided-setpoint sender.
478///
479/// A flight-behaviour plugin commands the vehicle through this rather than raw
480/// MAVLink writes, so the host gates the whole flight-command surface with one
481/// capability. Single-shot by design: the host owns no flight mode and no
482/// schedule, so a caller holding a velocity must re-send above the autopilot's
483/// setpoint timeout or the vehicle brakes.
484#[derive(Clone)]
485pub struct FlightClient {
486 ipc: Arc<PluginIpcClient>,
487}
488
489impl FlightClient {
490 /// Send one guided-mode setpoint. `args` is the setpoint map the host
491 /// validates (`kind`, `coordinate_frame`, `type_mask`, axis fields).
492 pub async fn guided_setpoint(&self, args: Value) -> Result<Value, ClientError> {
493 self.ipc.flight_guided_setpoint(args).await
494 }
495
496 /// Send one attitude/body-rate + thrust setpoint (SET_ATTITUDE_TARGET).
497 /// `args` is the setpoint map the host validates (`type_mask`, `qw`/`qx`/
498 /// `qy`/`qz`, `body_*_rate`, `thrust`).
499 pub async fn rate_setpoint(&self, args: Value) -> Result<Value, ClientError> {
500 self.ipc.flight_rate_setpoint(args).await
501 }
502}
503
504/// The static config is the manifest dict read at runner start; `get`/`set`
505/// reach the host's live kv. Read order on the host side is drone scope (when
506/// bound) -> global -> default, mirroring `_ConfigClient`.
507#[derive(Clone)]
508pub struct ConfigClient {
509 ipc: Arc<PluginIpcClient>,
510 static_config: Arc<BTreeMap<String, Value>>,
511}
512
513impl ConfigClient {
514 /// Read a key from the manifest-supplied static config; synchronous.
515 pub fn static_get(&self, key: &str) -> Option<&Value> {
516 self.static_config.get(key)
517 }
518
519 /// Read a key from the host's live kv, falling back to `default`.
520 pub async fn get(&self, key: &str, default: Value) -> Result<Value, ClientError> {
521 self.ipc.config_get(key, default).await
522 }
523
524 /// Write a key to the host's live kv in the given scope (`drone`/`global`).
525 pub async fn set(&self, key: &str, value: Value, scope: &str) -> Result<Value, ClientError> {
526 self.ipc.config_set(key, value, scope).await
527 }
528}
529
530/// `ctx.process` — sandboxed vendor-binary spawn authorization.
531#[derive(Clone)]
532pub struct ProcessClient {
533 ipc: Arc<PluginIpcClient>,
534}
535
536impl ProcessClient {
537 /// Authorize a vendor-binary spawn. The host enforces the manifest
538 /// allowlist and returns the resolved install dir; the actual exec is the
539 /// plugin's to perform so the child inherits the runner's cgroup slice.
540 pub async fn spawn(
541 &self,
542 basename: &str,
543 args: Vec<String>,
544 env: Vec<(String, String)>,
545 ) -> Result<Value, ClientError> {
546 self.ipc.process_spawn(basename, args, env).await
547 }
548}
549
550/// `ctx.lifecycle` — subscribe to GCS-side mount events.
551///
552/// `on_pause` fires when the operator switches away from the drone hosting this
553/// plugin's UI; `on_resume` fires on switch-back. Both ride the plugin's own
554/// `plugin.<id>.lifecycle.*` namespace, mirroring `_LifecycleClient`.
555#[derive(Clone)]
556pub struct LifecycleClient {
557 ipc: Arc<PluginIpcClient>,
558}
559
560impl LifecycleClient {
561 pub async fn on_pause(&self, handler: EventCallback) -> Result<(), ClientError> {
562 let topic = format!("plugin.{}.lifecycle.pause", self.ipc.plugin_id());
563 self.ipc.event_subscribe(&topic, handler).await
564 }
565
566 pub async fn on_resume(&self, handler: EventCallback) -> Result<(), ClientError> {
567 let topic = format!("plugin.{}.lifecycle.resume", self.ipc.plugin_id());
568 self.ipc.event_subscribe(&topic, handler).await
569 }
570}
571
572/// The object handed to every lifecycle hook. Every host-facing surface is a
573/// capability-gated facade; the IPC client is an implementation detail.
574///
575/// Ports `ados.plugins.ipc.context.PluginContext`. The `peripherals` field is
576/// an alias for `peripheral_manager`, matching the Python back-compat alias.
577#[derive(Clone)]
578pub struct PluginContext {
579 pub plugin_id: String,
580 pub plugin_version: String,
581 pub agent_id: String,
582 /// The plugin's per-drone data directory, when the host set one on the unit.
583 /// `None` on a host that did not — a plugin must handle its absence rather
584 /// than assume a path.
585 pub data_dir: Option<std::path::PathBuf>,
586 pub events: EventsClient,
587 pub mavlink: MavlinkClient,
588 /// `ctx.msp` — raw MSP read/write for a Betaflight / iNav / KISS FC.
589 pub msp: MspClient,
590 pub telemetry: TelemetryClient,
591 pub peripheral_manager: PeripheralClient,
592 /// Alias for `peripheral_manager` (Python `ctx.peripherals`).
593 pub peripherals: PeripheralClient,
594 pub camera: CameraClient,
595 /// `ctx.vision` — subscribe to engine frames, register models, run
596 /// inference, publish detections, and inject visual-odometry pose.
597 pub vision: VisionClient,
598 pub config: ConfigClient,
599 /// Front-panel button presses; quiet on a board with no panel.
600 pub buttons: ButtonClient,
601 /// The scoped guided-setpoint sender.
602 pub flight: FlightClient,
603 /// Host GPIO output (status line, buzzer).
604 pub gpio: GpioClient,
605 /// The reserved data-driven display page.
606 pub display: DisplayClient,
607 /// The additive auxiliary radio stream.
608 pub radio: RadioClient,
609 /// The plugin's cloud stream and cloud records.
610 pub cloud: CloudClient,
611 /// Facts about this node: profile, board, ground-station role, camera.
612 pub node: NodeClient,
613 /// Local-network service discovery through the host's mDNS responder.
614 pub mdns: MdnsClient,
615 /// WebRTC playback sessions on the agent's camera legs.
616 pub video: VideoClient,
617 /// The sensor rig document.
618 pub sensors: SensorsClient,
619 pub process: ProcessClient,
620 pub lifecycle: LifecycleClient,
621 /// The plugin's MCP tool handler registration.
622 pub tools: ToolsClient,
623 ipc: Arc<PluginIpcClient>,
624}
625
626impl PluginContext {
627 /// Build a context over a connected client. `static_config` is the
628 /// manifest-supplied config dict the runner read at start.
629 pub fn new(
630 ipc: Arc<PluginIpcClient>,
631 plugin_version: impl Into<String>,
632 agent_id: impl Into<String>,
633 data_dir: Option<String>,
634 static_config: BTreeMap<String, Value>,
635 ) -> Self {
636 let plugin_id = ipc.plugin_id().to_string();
637 let peripheral_manager = PeripheralClient { ipc: ipc.clone() };
638 Self {
639 plugin_id,
640 plugin_version: plugin_version.into(),
641 agent_id: agent_id.into(),
642 data_dir: data_dir.map(std::path::PathBuf::from),
643 events: EventsClient { ipc: ipc.clone() },
644 mavlink: MavlinkClient { ipc: ipc.clone() },
645 msp: MspClient { ipc: ipc.clone() },
646 telemetry: TelemetryClient { ipc: ipc.clone() },
647 peripherals: peripheral_manager.clone(),
648 peripheral_manager,
649 camera: CameraClient { ipc: ipc.clone() },
650 vision: VisionClient::new(ipc.clone()),
651 buttons: ButtonClient { ipc: ipc.clone() },
652 flight: FlightClient { ipc: ipc.clone() },
653 gpio: GpioClient { ipc: ipc.clone() },
654 display: DisplayClient { ipc: ipc.clone() },
655 radio: RadioClient { ipc: ipc.clone() },
656 cloud: CloudClient { ipc: ipc.clone() },
657 node: NodeClient { ipc: ipc.clone() },
658 mdns: MdnsClient { ipc: ipc.clone() },
659 video: VideoClient { ipc: ipc.clone() },
660 sensors: SensorsClient { ipc: ipc.clone() },
661 config: ConfigClient {
662 ipc: ipc.clone(),
663 static_config: Arc::new(static_config),
664 },
665 process: ProcessClient { ipc: ipc.clone() },
666 lifecycle: LifecycleClient { ipc: ipc.clone() },
667 tools: ToolsClient { ipc: ipc.clone() },
668 ipc,
669 }
670 }
671
672 /// Health probe against the host. Mirrors `ping_supervisor`.
673 pub async fn ping_supervisor(&self) -> Result<Value, ClientError> {
674 self.ipc.ping().await
675 }
676
677 /// The shared client, for advanced callers that need a method not yet on a
678 /// facade.
679 pub fn client(&self) -> &Arc<PluginIpcClient> {
680 &self.ipc
681 }
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687
688 #[test]
689 fn static_config_is_read_synchronously() {
690 let ipc = Arc::new(PluginIpcClient::new(
691 "com.example.demo",
692 "tok",
693 "/tmp/x.sock",
694 ));
695 let mut cfg = BTreeMap::new();
696 cfg.insert("palette".to_string(), Value::from("ironbow"));
697 let ctx = PluginContext::new(ipc, "1.0.0", "agent-1", None, cfg);
698 assert_eq!(
699 ctx.config.static_get("palette"),
700 Some(&Value::from("ironbow"))
701 );
702 assert!(ctx.config.static_get("missing").is_none());
703 // peripherals is the same client as peripheral_manager.
704 assert_eq!(ctx.plugin_id, "com.example.demo");
705 assert_eq!(ctx.agent_id, "agent-1");
706 assert_eq!(ctx.plugin_version, "1.0.0");
707 }
708
709 /// `send_sticks` must apply the codec scaling and AETR order. The bug it
710 /// fixes is a control loop handing `send_raw_rc` normalized sticks, where a
711 /// centre stick (`0.0`) would land at PWM `0` — off the bottom of the range —
712 /// instead of `1500`. This asserts the scaling is actually applied.
713 #[test]
714 fn send_sticks_scales_and_orders_aetr() {
715 // Centre sticks + idle throttle: NOT zeros. AETR = [roll, pitch, thr, yaw].
716 assert_eq!(
717 sticks_to_channels(0.0, 0.0, 0.0, 0.0),
718 [1500, 1500, 1000, 1500]
719 );
720 // Full deflection each axis + full throttle, exercising the ordering.
721 assert_eq!(
722 sticks_to_channels(1.0, -1.0, 1.0, 1.0),
723 [2000, 1000, 2000, 2000]
724 );
725 // Out-of-range clamps rather than wrapping.
726 assert_eq!(
727 sticks_to_channels(5.0, -5.0, 0.0, 2.0),
728 [2000, 1000, 2000, 1500]
729 );
730 }
731}