1use 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 pub robot_id: String,
21 pub signal_url: String,
24 #[serde(default = "default_credential_path")]
27 pub credential_path: PathBuf,
28 #[serde(default = "default_quic_port")]
31 pub quic_port: u16,
32 #[serde(default = "yes")]
34 pub ipv6: bool,
35 #[serde(default = "yes")]
38 pub port_mapping: bool,
39 #[serde(default = "default_profile")]
41 pub qos_profile: String,
42 #[serde(default = "default_max_sessions")]
44 pub max_sessions: u32,
45 #[serde(default)]
47 pub host_override: Option<String>,
48 #[serde(default = "yes")]
52 pub recovery_ladder: bool,
53 #[serde(default = "yes")]
58 pub relay: bool,
59 #[serde(default)]
63 pub enrolment_token: Option<String>,
64}
65
66impl Agent {
67 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#[derive(Debug, Clone, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct Layer {
106 pub name: String,
108 pub input: String,
110 #[serde(default)]
114 pub activate_above_kbps: u32,
115}
116
117#[derive(Debug, Clone, Deserialize)]
118pub struct Channel {
119 pub kind: ChannelKind,
121 pub name: String,
123 #[serde(default)]
124 pub input: Option<String>,
127 #[serde(default)]
128 pub output: Option<String>,
131 #[serde(default = "default_codec")]
132 pub codec: String,
135 #[serde(default)]
136 pub fps: u32,
138 #[serde(default, rename = "layer")]
141 pub layers: Vec<Layer>,
142 #[serde(default)]
149 pub max_bitrate_kbps: u32,
150}
151
152impl Channel {
153 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 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 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 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 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 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 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}