ados_protocol/media_relay.rs
1//! The live-video and rig relay plugin methods: `video.whep.offer`,
2//! `video.whep.close` and `sensors.rig.write`.
3//!
4//! A sandboxed plugin cannot reach the loopback media server or the control
5//! plane's socket. The host can, so it relays on the plugin's behalf:
6//!
7//! * `video.whep.offer` posts a viewer's SDP offer to the media server's WHEP
8//! endpoint for one configured camera and returns the answer plus an opaque
9//! `location` naming the session. The host resolves the camera id to the
10//! media leg serving it from the node's own camera roster; the plugin never
11//! names a media path. The location is the agent's own WHEP form
12//! (`/whep/<session>?camera=<leg>`), never a host address.
13//! * `video.whep.close` tears down a session, and only one whose location the
14//! host returned to the same plugin.
15//! * `sensors.rig.write` stores a rig document through the control plane's
16//! `PUT /api/v1/sensors/rig`, so it gets the operator route's validation and
17//! hub reload; the reply carries that route's HTTP status and JSON body.
18//!
19//! The types are the wire shape: the host decodes the request and serializes
20//! the reply with them, and the SDK does the reverse, so the two cannot drift.
21
22use serde::{Deserialize, Serialize};
23
24/// The capability both WHEP methods are gated on.
25pub const WHEP_CAPABILITY: &str = "video.whep.relay";
26/// Open a playback session.
27pub const WHEP_OFFER_METHOD: &str = "video.whep.offer";
28/// Close a playback session this plugin opened.
29pub const WHEP_CLOSE_METHOD: &str = "video.whep.close";
30/// The capability `sensors.rig.write` is gated on.
31pub const RIG_CAPABILITY: &str = "sensors.rig.write";
32/// Store a rig document.
33pub const RIG_WRITE_METHOD: &str = "sensors.rig.write";
34
35/// Error prefix when the media server cannot serve the exchange (not running,
36/// the offer refused, an unknown location).
37pub const WHEP_UNAVAILABLE: &str = "whep_unavailable";
38/// Error prefix when `video.whep.offer` names anything that is not a
39/// configured, enabled camera of this node.
40pub const UNKNOWN_CAMERA: &str = "unknown_camera";
41/// Error prefix when the control plane's rig route cannot be reached.
42pub const RIG_UNAVAILABLE: &str = "rig_unavailable";
43
44/// The largest SDP offer relayed, matching the agent's own WHEP front.
45pub const MAX_SDP_BYTES: usize = 64 * 1024;
46/// The largest rig document relayed.
47pub const MAX_RIG_BYTES: usize = 256 * 1024;
48
49/// `video.whep.offer` arguments.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct WhepOfferRequest {
52 /// A configured, enabled camera's roster id. The host serves it from the
53 /// leg that publishes it (`main` for the primary camera).
54 pub camera_id: String,
55 /// The viewer's SDP offer.
56 pub sdp: String,
57}
58
59/// `video.whep.offer` reply.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct WhepOfferReply {
62 /// The SDP answer.
63 pub sdp: String,
64 /// The opaque session location `video.whep.close` takes; `None` when the
65 /// media server named none (the session then ends with the peer).
66 pub location: Option<String>,
67}
68
69/// `video.whep.close` arguments.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct WhepCloseRequest {
72 /// A location a previous `video.whep.offer` returned to this plugin.
73 pub location: String,
74}
75
76/// `sensors.rig.write` arguments. The document travels as JSON text so the
77/// control plane validates exactly what the plugin wrote.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct RigWriteRequest {
80 pub rig_json: String,
81}
82
83/// `sensors.rig.write` reply: the control plane's HTTP status and JSON body
84/// (`{"ok":true,"rig_id","calib_version"}` or `{"error":"E_RIG_INVALID",
85/// "detail"}`), the body as JSON text.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct RigWriteReply {
88 pub status: u16,
89 pub body_json: String,
90}
91
92/// `[A-Za-z0-9_-]+`: the roster's leg-id rule and what a media-server session
93/// id is made of. Anything else could address another media-server route.
94pub fn is_path_segment(s: &str) -> bool {
95 !s.is_empty()
96 && s.bytes()
97 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-'))
98}
99
100/// The location handed to the plugin for session `session` on leg `camera`.
101pub fn whep_location(camera: &str, session: &str) -> String {
102 format!("/whep/{session}?camera={camera}")
103}
104
105/// Parse a location [`whep_location`] built back into `(camera, session)`.
106/// Anything else, including a segment outside [`is_path_segment`], is `None`.
107pub fn parse_whep_location(location: &str) -> Option<(String, String)> {
108 let rest = location.strip_prefix("/whep/")?;
109 let (session, query) = rest.split_once("?camera=")?;
110 (is_path_segment(session) && is_path_segment(query))
111 .then(|| (query.to_string(), session.to_string()))
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn a_location_round_trips() {
120 let loc = whep_location("main", "a1-b_2");
121 assert_eq!(loc, "/whep/a1-b_2?camera=main");
122 assert_eq!(
123 parse_whep_location(&loc),
124 Some(("main".into(), "a1-b_2".into()))
125 );
126 }
127
128 #[test]
129 fn a_foreign_location_is_refused() {
130 for bad in [
131 "/whep/s",
132 "/main/whep/s",
133 "http://127.0.0.1:8889/main/whep/s",
134 "/whep/../x?camera=main",
135 "/whep/s?camera=main&x=1",
136 "/whep/s?camera=",
137 "/whep/?camera=main",
138 ] {
139 assert_eq!(parse_whep_location(bad), None, "{bad}");
140 }
141 }
142}