Skip to main content

seyd_signal_client/
messages.rs

1// Copyright 2026 Anton Gravestam
2// SPDX-License-Identifier: Apache-2.0
3//! Signal v2 message types (docs/protocol/signal-v2.md), robot side.
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "kebab-case")]
9pub enum Role {
10    Driver,
11    Observer,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum Direction {
17    RobotListens,
18    PilotListens,
19    Both,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct Candidate {
24    pub url: String,
25    pub label: String,
26    pub priority: u32,
27    pub needs_probe: bool,
28    pub family: u8,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct ChannelInfo {
33    pub id: u8,
34    pub kind: String,
35    pub name: String,
36    pub codec: String,
37    #[serde(default)]
38    pub fps: u32,
39}
40
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42pub struct Announce {
43    pub candidates: Vec<Candidate>,
44    pub cert_fingerprints: Vec<String>,
45    pub alpns: Vec<String>,
46    pub nat_report: serde_json::Value,
47    pub channels: Vec<ChannelInfo>,
48    pub p2p_hint: String,
49    pub max_sessions: u32,
50    /// Whether this robot will accept a relayed session when no direct path
51    /// connects (ADR 0010). The cloud offers a relay to pilots only if so.
52    #[serde(default)]
53    pub relay: bool,
54}
55
56#[derive(Debug, Clone, Serialize)]
57#[serde(tag = "type", rename_all = "kebab-case")]
58pub enum Outbound {
59    Auth {
60        v: u8,
61        role: String,
62        robot_id: String,
63        public_key: String,
64        sig: String,
65        agent_version: String,
66    },
67    Announce(Announce),
68    Heartbeat {
69        sessions: Vec<String>,
70        #[serde(skip_serializing_if = "Option::is_none")]
71        status: Option<serde_json::Value>,
72    },
73    SessionAccepted {
74        session_id: String,
75        path_label: String,
76    },
77    SessionEnded {
78        session_id: String,
79        reason: String,
80    },
81}
82
83#[derive(Debug, Clone, Deserialize)]
84#[serde(tag = "type", rename_all = "kebab-case")]
85pub enum Inbound {
86    Challenge {
87        nonce: String,
88    },
89    AuthOk {
90        #[serde(default)]
91        robot_id: String,
92    },
93    Denied {
94        reason: String,
95    },
96    PilotConnecting {
97        session_id: String,
98        #[serde(default)]
99        pilot_ip: Option<String>,
100        #[serde(default = "driver")]
101        role: Role,
102        #[serde(default = "robot_listens")]
103        direction: Direction,
104    },
105    Punch {
106        session_id: String,
107        #[serde(default)]
108        pilot_ip: Option<String>,
109    },
110    SessionRevoked {
111        session_id: String,
112        #[serde(default)]
113        reason: String,
114    },
115    /// The pilot could not connect directly and has attached to the cloud
116    /// relay; dial `url`, attach with `token`, and serve the session there.
117    RelayOpen {
118        session_id: String,
119        url: String,
120        token: String,
121        #[serde(default = "driver")]
122        role: Role,
123        #[serde(default)]
124        pilot_ip: Option<String>,
125    },
126    #[serde(other)]
127    Unknown,
128}
129
130fn driver() -> Role {
131    Role::Driver
132}
133fn robot_listens() -> Direction {
134    Direction::RobotListens
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn announce_tags_as_type_announce() {
143        let a = Announce {
144            candidates: vec![],
145            cert_fingerprints: vec!["ab".into()],
146            alpns: vec!["h3".into()],
147            nat_report: serde_json::json!({}),
148            channels: vec![],
149            p2p_hint: "likely".into(),
150            max_sessions: 4,
151            relay: true,
152        };
153        let v = serde_json::to_value(Outbound::Announce(a)).unwrap();
154        assert_eq!(v["type"], "announce");
155        assert_eq!(v["alpns"][0], "h3");
156    }
157
158    #[test]
159    fn inbound_parses_and_tolerates_unknown() {
160        let m: Inbound = serde_json::from_str(r#"{"type":"pilot-connecting","session_id":"abc","pilot_ip":"1.2.3.4","role":"observer","direction":"robot-listens"}"#).unwrap();
161        match m {
162            Inbound::PilotConnecting { role, .. } => assert_eq!(role, Role::Observer),
163            _ => panic!(),
164        }
165        let m: Inbound = serde_json::from_str(r#"{"type":"something-new","x":1}"#).unwrap();
166        assert!(matches!(m, Inbound::Unknown));
167    }
168}