Skip to main content

seydd/
config.rs

1// Copyright 2026 Anton Gravestam
2// SPDX-License-Identifier: Apache-2.0
3//! `seydd.toml` — see docs/protocol/seydd.md.
4
5use serde::Deserialize;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, Deserialize)]
9pub struct Config {
10    pub agent: Agent,
11    #[serde(default, rename = "channel")]
12    pub channels: Vec<Channel>,
13    #[serde(default)]
14    pub publisher_control: Option<PublisherControl>,
15}
16
17#[derive(Debug, Clone, Deserialize)]
18pub struct Agent {
19    /// The robot's id as enrolled with the cloud; what pilots and the console see.
20    pub robot_id: String,
21    /// The signal server's WebSocket URL, `wss://host/ws`. Enrolment derives
22    /// its REST address from it.
23    pub signal_url: String,
24    /// Ed25519 seed file, created on first run. The private half never leaves
25    /// the robot; enrolment registers the public half (ADR 0007).
26    #[serde(default = "default_credential_path")]
27    pub credential_path: PathBuf,
28    /// UDP port the QUIC endpoint binds; the port a router forward or pinhole
29    /// must open.
30    #[serde(default = "default_quic_port")]
31    pub quic_port: u16,
32    /// Bind and advertise IPv6 as well as IPv4.
33    #[serde(default = "yes")]
34    pub ipv6: bool,
35    /// Ask the router for a port mapping (PCP, NAT-PMP, UPnP) during discovery,
36    /// so a robot behind a consumer router is reachable without a manual forward.
37    #[serde(default = "yes")]
38    pub port_mapping: bool,
39    /// The QoS ceiling: `latency`, `balanced` or `quality`.
40    #[serde(default = "default_profile")]
41    pub qos_profile: String,
42    /// Concurrent pilot sessions (one driver, the rest observers).
43    #[serde(default = "default_max_sessions")]
44    pub max_sessions: u32,
45    /// Skip discovery and advertise this host only (LAN testing).
46    #[serde(default)]
47    pub host_override: Option<String>,
48    /// Ask for the cheapest useful recovery point (LTR → intra-refresh → IDR)
49    /// rather than always demanding a keyframe. Set false for a publisher that
50    /// mishandles any `kind` but `idr`.
51    #[serde(default = "yes")]
52    pub recovery_ladder: bool,
53    /// Serve a session through the cloud relay when the pilot could not
54    /// connect directly (ADR 0010). The pilot only asks after its direct
55    /// attempts fail, and its HUD says so; set false to refuse relayed
56    /// sessions altogether.
57    #[serde(default = "yes")]
58    pub relay: bool,
59    /// One-time enrolment token, redeemed on first start when no credential
60    /// exists yet. `SEYD_ENROLMENT_TOKEN` overrides it — prefer the
61    /// environment, so a provisioning secret need not be written to disk.
62    #[serde(default)]
63    pub enrolment_token: Option<String>,
64}
65
66impl Agent {
67    /// The enrolment token to use, environment first.
68    pub fn enrolment_token(&self) -> Option<String> {
69        std::env::var("SEYD_ENROLMENT_TOKEN")
70            .ok()
71            .filter(|t| !t.is_empty())
72            .or_else(|| self.enrolment_token.clone())
73    }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum ChannelKind {
79    Video,
80    Sensor,
81    Command,
82}
83
84/// One simulcast layer of a video channel: the same picture, encoded again at a
85/// different operating point (ADR 0008). Declared as `[[channel.layer]]`.
86///
87/// ```toml
88/// [[channel]]
89/// kind  = "video"
90/// name  = "main"
91/// codec = "avc1.42001f"   # must admit the *highest* layer
92/// fps   = 25
93///
94///   [[channel.layer]]
95///   name  = "low"
96///   input = "rtsp://cam:554/Streaming/Channels/102"
97///
98///   [[channel.layer]]
99///   name  = "high"
100///   input = "rtsp://cam:554/Streaming/Channels/101"
101///   activate_above_kbps = 1800
102/// ```
103#[derive(Debug, Clone, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct Layer {
106    /// The layer's name, reported in `layer` messages and the HUD.
107    pub name: String,
108    /// The stream carrying this encoding, `rtsp://` or `rtp://`.
109    pub input: String,
110    /// The ABR target at or above which this layer is the right choice. The
111    /// lowest layer is the base and is used whenever nothing better is
112    /// affordable, so leaving it at 0 is correct.
113    #[serde(default)]
114    pub activate_above_kbps: u32,
115}
116
117#[derive(Debug, Clone, Deserialize)]
118pub struct Channel {
119    /// `video`, `sensor` or `command`.
120    pub kind: ChannelKind,
121    /// The channel's name as shown to the pilot and used in `SeydSession.send`.
122    pub name: String,
123    #[serde(default)]
124    /// Where the bytes come from: `rtsp://` or `rtp://` for video, `udp://` for
125    /// a sensor. Required for `video` and `sensor` channels without layers.
126    pub input: Option<String>,
127    #[serde(default)]
128    /// Where a `command` channel delivers each driver message, as one UDP
129    /// datagram per message.
130    pub output: Option<String>,
131    #[serde(default = "default_codec")]
132    /// A WebCodecs codec string for video (`avc1.42001f`, `hev1.…`, `mjpeg`);
133    /// `json` or `octet-stream` for messages.
134    pub codec: String,
135    #[serde(default)]
136    /// Nominal frame rate of a video channel; 0 for messages.
137    pub fps: u32,
138    /// Simulcast layers. Empty means the single `input` above — the ordinary
139    /// case, and what every pre-simulcast config keeps doing.
140    #[serde(default, rename = "layer")]
141    pub layers: Vec<Layer>,
142    /// The most this video channel's publisher can encode, in kbps; 0 (the
143    /// default) means it has no limit of its own. Set it when the encoder
144    /// tops out below the QoS profile's ceiling — a drone whose highest
145    /// setting is 4 Mbps under the `quality` profile's 6 — so the rate
146    /// controller's range, floor and `video-config` requests stay within
147    /// what the publisher can deliver.
148    #[serde(default)]
149    pub max_bitrate_kbps: u32,
150}
151
152impl Channel {
153    /// The channel's inputs as `(layer_id, url)`, lowest layer first. A
154    /// single-`input` channel yields exactly one, at layer 0, so callers need no
155    /// special case.
156    pub fn inputs(&self) -> Vec<(u8, String)> {
157        if self.layers.is_empty() {
158            return self.input.iter().map(|u| (0u8, u.clone())).collect();
159        }
160        let mut ls: Vec<&Layer> = self.layers.iter().collect();
161        ls.sort_by_key(|l| l.activate_above_kbps);
162        ls.iter()
163            .enumerate()
164            .map(|(i, l)| (i as u8, l.input.clone()))
165            .collect()
166    }
167
168    /// The channel's layers as the core declares them, lowest first.
169    pub fn layer_specs(&self) -> Vec<seyd_qos::simulcast::VideoLayer> {
170        let mut ls: Vec<&Layer> = self.layers.iter().collect();
171        ls.sort_by_key(|l| l.activate_above_kbps);
172        ls.iter()
173            .enumerate()
174            .map(|(i, l)| seyd_qos::simulcast::VideoLayer {
175                id: i as u8,
176                name: l.name.clone(),
177                activate_above_kbps: l.activate_above_kbps,
178            })
179            .collect()
180    }
181}
182
183#[derive(Debug, Clone, Deserialize)]
184pub struct PublisherControl {
185    /// `host:port` the daemon sends its JSON control messages to, one per datagram.
186    pub udp: String,
187}
188
189fn default_credential_path() -> PathBuf {
190    PathBuf::from("/var/lib/seyd/robot.key")
191}
192fn default_quic_port() -> u16 {
193    4433
194}
195fn yes() -> bool {
196    true
197}
198fn default_profile() -> String {
199    "balanced".into()
200}
201fn default_max_sessions() -> u32 {
202    4
203}
204fn default_codec() -> String {
205    "json".into()
206}
207
208impl Config {
209    pub fn load(path: &Path) -> anyhow::Result<Self> {
210        let text = std::fs::read_to_string(path)
211            .map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
212        let cfg: Config = toml::from_str(&text)?;
213        cfg.validate()?;
214        Ok(cfg)
215    }
216
217    fn validate(&self) -> anyhow::Result<()> {
218        if self.channels.len() > 255 {
219            anyhow::bail!("at most 255 channels");
220        }
221        if seyd_qos::get(&self.agent.qos_profile).is_none() {
222            anyhow::bail!("unknown qos_profile {:?}", self.agent.qos_profile);
223        }
224        for c in &self.channels {
225            if !c.layers.is_empty() {
226                if c.kind != ChannelKind::Video {
227                    anyhow::bail!(
228                        "channel {:?}: [[channel.layer]] is for video channels only — \
229                         simulcast selects between encodings of the same picture",
230                        c.name
231                    );
232                }
233                if c.input.is_some() {
234                    anyhow::bail!(
235                        "channel {:?}: has both `input` and [[channel.layer]]. Give the \
236                         single stream as `input`, or every stream as a layer — not both",
237                        c.name
238                    );
239                }
240                if c.layers.len() > u8::MAX as usize {
241                    anyhow::bail!("channel {:?}: at most 255 layers", c.name);
242                }
243                let mut seen = std::collections::HashSet::new();
244                for l in &c.layers {
245                    if !seen.insert(&l.name) {
246                        anyhow::bail!(
247                            "channel {:?}: two layers named {:?}; names identify them in \
248                             logs and the pilot HUD, so they must differ",
249                            c.name,
250                            l.name
251                        );
252                    }
253                }
254                // Two layers that activate at the same target can never both be
255                // chosen, which is a config bug that would otherwise present as
256                // a rung that is silently never used.
257                let mut points: Vec<u32> = c.layers.iter().map(|l| l.activate_above_kbps).collect();
258                points.sort_unstable();
259                if points.windows(2).any(|w| w[0] == w[1]) {
260                    anyhow::bail!(
261                        "channel {:?}: two layers share an `activate_above_kbps`; one of \
262                         them could never be selected",
263                        c.name
264                    );
265                }
266                if points[0] != 0 {
267                    anyhow::bail!(
268                        "channel {:?}: the lowest layer must have `activate_above_kbps = 0` \
269                         (it is the base, used when nothing better is affordable); the \
270                         lowest here is {}",
271                        c.name,
272                        points[0]
273                    );
274                }
275                continue;
276            }
277            match c.kind {
278                ChannelKind::Video if c.input.is_none() => anyhow::bail!(
279                    "channel {:?}: video needs `input`, or [[channel.layer]] entries for \
280                     simulcast",
281                    c.name
282                ),
283                ChannelKind::Sensor if c.input.is_none() => {
284                    anyhow::bail!("channel {:?}: {:?} needs `input`", c.name, c.kind)
285                }
286                ChannelKind::Command if c.output.is_none() => {
287                    anyhow::bail!("channel {:?}: command needs `output`", c.name)
288                }
289                _ => {}
290            }
291        }
292        Ok(())
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn parses_reference_config() {
302        let cfg: Config = toml::from_str(
303            r#"
304[agent]
305robot_id = "seyd-demo"
306signal_url = "ws://localhost:8080/ws"
307
308[[channel]]
309kind = "video"
310name = "main"
311input = "rtsp://cam/x"
312codec = "avc1.42001f"
313fps = 25
314
315[[channel]]
316kind = "command"
317name = "ptz"
318output = "udp://127.0.0.1:5004"
319
320[publisher_control]
321udp = "127.0.0.1:5003"
322"#,
323        )
324        .unwrap();
325        cfg.validate().unwrap();
326        assert_eq!(cfg.agent.quic_port, 4433);
327        assert_eq!(cfg.channels[0].kind, ChannelKind::Video);
328        assert_eq!(cfg.channels[1].codec, "json");
329        // A single-input channel is layer 0 and nothing else.
330        assert_eq!(
331            cfg.channels[0].inputs(),
332            vec![(0, "rtsp://cam/x".to_string())]
333        );
334        assert!(cfg.channels[0].layer_specs().is_empty());
335    }
336
337    fn simulcast_cfg(layers: &str) -> Config {
338        toml::from_str(&format!(
339            r#"
340[agent]
341robot_id = "seyd-demo"
342signal_url = "ws://localhost:8080/ws"
343
344[[channel]]
345kind = "video"
346name = "main"
347codec = "avc1.420028"
348fps = 25
349{layers}
350"#
351        ))
352        .unwrap()
353    }
354
355    const LADDER: &str = r#"
356  [[channel.layer]]
357  name = "high"
358  input = "rtsp://cam/101"
359  activate_above_kbps = 1800
360
361  [[channel.layer]]
362  name = "low"
363  input = "rtsp://cam/102"
364"#;
365
366    #[test]
367    fn layers_are_ordered_lowest_first_whatever_the_file_says() {
368        // Declaration order must not decide layer ids: the ladder is defined by
369        // the bitrate each rung needs.
370        let cfg = simulcast_cfg(LADDER);
371        cfg.validate().unwrap();
372        let c = &cfg.channels[0];
373        assert_eq!(
374            c.inputs(),
375            vec![
376                (0, "rtsp://cam/102".to_string()),
377                (1, "rtsp://cam/101".to_string())
378            ]
379        );
380        let specs = c.layer_specs();
381        assert_eq!((specs[0].id, specs[0].name.as_str()), (0, "low"));
382        assert_eq!((specs[1].id, specs[1].activate_above_kbps), (1, 1800));
383    }
384
385    #[test]
386    fn input_and_layers_together_is_refused() {
387        let cfg = simulcast_cfg(&format!("input = \"rtsp://cam/x\"\n{LADDER}"));
388        let e = cfg.validate().unwrap_err().to_string();
389        assert!(e.contains("not both"), "{e}");
390    }
391
392    #[test]
393    fn a_ladder_without_a_base_layer_is_refused() {
394        // Every rung gated behind a bitrate means a bad link gets no video at
395        // all, which would present as a dead channel rather than a config error.
396        let cfg = simulcast_cfg(
397            r#"
398  [[channel.layer]]
399  name = "low"
400  input = "rtsp://cam/102"
401  activate_above_kbps = 500
402
403  [[channel.layer]]
404  name = "high"
405  input = "rtsp://cam/101"
406  activate_above_kbps = 1800
407"#,
408        );
409        let e = cfg.validate().unwrap_err().to_string();
410        assert!(e.contains("activate_above_kbps = 0"), "{e}");
411    }
412
413    #[test]
414    fn layers_that_can_never_both_be_chosen_are_refused() {
415        let cfg = simulcast_cfg(
416            r#"
417  [[channel.layer]]
418  name = "a"
419  input = "rtsp://cam/102"
420
421  [[channel.layer]]
422  name = "b"
423  input = "rtsp://cam/101"
424"#,
425        );
426        let e = cfg.validate().unwrap_err().to_string();
427        assert!(e.contains("could never be selected"), "{e}");
428    }
429
430    #[test]
431    fn layers_on_a_command_channel_are_refused() {
432        let cfg: Config = toml::from_str(
433            r#"
434[agent]
435robot_id = "r"
436signal_url = "ws://localhost:8080/ws"
437
438[[channel]]
439kind = "command"
440name = "ptz"
441output = "udp://127.0.0.1:5004"
442
443  [[channel.layer]]
444  name = "low"
445  input = "rtsp://cam/102"
446"#,
447        )
448        .unwrap();
449        let e = cfg.validate().unwrap_err().to_string();
450        assert!(e.contains("video channels only"), "{e}");
451    }
452
453    #[test]
454    fn a_video_channel_with_neither_input_nor_layers_says_both_options() {
455        let cfg = simulcast_cfg("");
456        let e = cfg.validate().unwrap_err().to_string();
457        assert!(e.contains("input") && e.contains("channel.layer"), "{e}");
458    }
459}