Skip to main content

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}