Skip to main content

weave_contracts/
lib.rs

1//! WebSocket protocol types shared between `edge-agent` and `weave-server`.
2//!
3//! Wire format: JSON text frames. Each frame is a single `ServerToEdge` or
4//! `EdgeToServer` value serialized as JSON. The runtime binds to a LAN IP
5//! and performs no authentication.
6
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use uuid::Uuid;
10
11/// Frames sent from `weave-server` to an `edge-agent`.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum ServerToEdge {
15    /// Full config snapshot. Sent on (re)connect and on bulk reload.
16    ConfigFull { config: EdgeConfig },
17    /// Incremental mapping change.
18    ConfigPatch {
19        mapping_id: Uuid,
20        op: PatchOp,
21        mapping: Option<Mapping>,
22    },
23    /// Server-initiated active-target switch for an existing mapping.
24    TargetSwitch {
25        mapping_id: Uuid,
26        service_target: String,
27    },
28    /// Replace the edge's glyph set. Sent after any glyph CRUD on the server.
29    GlyphsUpdate { glyphs: Vec<Glyph> },
30    /// Periodic keepalive to keep NAT/proxies open and detect half-open TCP.
31    Ping,
32}
33
34/// Frames sent from an `edge-agent` to `weave-server`.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "type", rename_all = "snake_case")]
37pub enum EdgeToServer {
38    /// First frame after connect. Declares identity and adapter capabilities.
39    Hello {
40        edge_id: String,
41        version: String,
42        capabilities: Vec<String>,
43    },
44    /// State update for a service target (e.g. Roon zone playback / volume).
45    State {
46        service_type: String,
47        target: String,
48        property: String,
49        #[serde(skip_serializing_if = "Option::is_none")]
50        output_id: Option<String>,
51        value: serde_json::Value,
52    },
53    /// State update for a device (battery, RSSI, connected).
54    DeviceState {
55        device_type: String,
56        device_id: String,
57        property: String,
58        value: serde_json::Value,
59    },
60    /// Reply to server `Ping`.
61    Pong,
62}
63
64#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum PatchOp {
67    Upsert,
68    Delete,
69}
70
71/// Complete config for one edge, pushed as a `ConfigFull` frame.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct EdgeConfig {
74    pub edge_id: String,
75    pub mappings: Vec<Mapping>,
76    /// Named glyph patterns the edge should use when rendering feedback.
77    /// Consumers look up by `name`. Entries with `builtin == true` have an
78    /// empty `pattern` and are expected to be rendered programmatically by
79    /// the consumer (e.g. `volume_bar` scales with percentage).
80    #[serde(default)]
81    pub glyphs: Vec<Glyph>,
82}
83
84/// A named Nuimo LED glyph. `pattern` is a 9x9 ASCII grid compatible with
85/// `nuimo::Glyph::from_str` (`*` = LED on, anything else = off, rows
86/// separated by `\n`).
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct Glyph {
89    pub name: String,
90    #[serde(default)]
91    pub pattern: String,
92    #[serde(default)]
93    pub builtin: bool,
94}
95
96/// Frames sent from `weave-server` to a Web UI client on `/ws/ui`.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(tag = "type", rename_all = "snake_case")]
99pub enum UiFrame {
100    /// Initial full snapshot, pushed once on connect.
101    Snapshot { snapshot: UiSnapshot },
102    /// An edge completed its `Hello` handshake or has otherwise come online.
103    EdgeOnline { edge: EdgeInfo },
104    /// An edge has disconnected (ws closed).
105    EdgeOffline { edge_id: String },
106    /// One service-state update from a connected edge.
107    ServiceState {
108        edge_id: String,
109        service_type: String,
110        target: String,
111        property: String,
112        #[serde(skip_serializing_if = "Option::is_none")]
113        output_id: Option<String>,
114        value: serde_json::Value,
115    },
116    /// One device-state update from a connected edge (battery, RSSI, etc.).
117    DeviceState {
118        edge_id: String,
119        device_type: String,
120        device_id: String,
121        property: String,
122        value: serde_json::Value,
123    },
124    /// Mapping CRUD happened on the server. UIs replace their copy.
125    MappingChanged {
126        mapping_id: Uuid,
127        op: PatchOp,
128        mapping: Option<Mapping>,
129    },
130    /// The glyph set changed. UIs refresh their registry.
131    GlyphsChanged { glyphs: Vec<Glyph> },
132}
133
134/// Initial full state sent on `/ws/ui` connect. Subsequent changes arrive
135/// as `UiFrame` variants.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct UiSnapshot {
138    pub edges: Vec<EdgeInfo>,
139    pub service_states: Vec<ServiceStateEntry>,
140    pub device_states: Vec<DeviceStateEntry>,
141    pub mappings: Vec<Mapping>,
142    pub glyphs: Vec<Glyph>,
143}
144
145/// Identity + status for one connected (or previously-seen) edge.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct EdgeInfo {
148    pub edge_id: String,
149    pub online: bool,
150    pub version: String,
151    pub capabilities: Vec<String>,
152    /// RFC3339 timestamp.
153    pub last_seen: String,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct ServiceStateEntry {
158    pub edge_id: String,
159    pub service_type: String,
160    pub target: String,
161    pub property: String,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub output_id: Option<String>,
164    pub value: serde_json::Value,
165    /// RFC3339 timestamp of last update.
166    pub updated_at: String,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct DeviceStateEntry {
171    pub edge_id: String,
172    pub device_type: String,
173    pub device_id: String,
174    pub property: String,
175    pub value: serde_json::Value,
176    pub updated_at: String,
177}
178
179/// A device-to-service mapping. Mirrors the structure already used by
180/// `weave-server`'s REST API. `edge_id` is new; all other fields retain
181/// their existing semantics.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct Mapping {
184    pub mapping_id: Uuid,
185    pub edge_id: String,
186    pub device_type: String,
187    pub device_id: String,
188    pub service_type: String,
189    pub service_target: String,
190    pub routes: Vec<Route>,
191    #[serde(default)]
192    pub feedback: Vec<FeedbackRule>,
193    #[serde(default = "default_true")]
194    pub active: bool,
195}
196
197fn default_true() -> bool {
198    true
199}
200
201/// One input-to-intent route inside a mapping.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct Route {
204    pub input: String,
205    pub intent: String,
206    #[serde(default)]
207    pub params: BTreeMap<String, serde_json::Value>,
208}
209
210/// Feedback rule: service state → device visual feedback.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct FeedbackRule {
213    pub state: String,
214    pub feedback_type: String,
215    pub mapping: serde_json::Value,
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn server_to_edge_config_full_roundtrip() {
224        let msg = ServerToEdge::ConfigFull {
225            config: EdgeConfig {
226                edge_id: "living-room".into(),
227                mappings: vec![Mapping {
228                    mapping_id: Uuid::nil(),
229                    edge_id: "living-room".into(),
230                    device_type: "nuimo".into(),
231                    device_id: "C3:81:DF:4E:FF:6A".into(),
232                    service_type: "roon".into(),
233                    service_target: "zone-1".into(),
234                    routes: vec![Route {
235                        input: "rotate".into(),
236                        intent: "volume_change".into(),
237                        params: BTreeMap::from([("damping".into(), serde_json::json!(80))]),
238                    }],
239                    feedback: vec![],
240                    active: true,
241                }],
242                glyphs: vec![Glyph {
243                    name: "play".into(),
244                    pattern: "    *    \n     **  ".into(),
245                    builtin: false,
246                }],
247            },
248        };
249        let json = serde_json::to_string(&msg).unwrap();
250        assert!(json.contains("\"type\":\"config_full\""));
251        assert!(json.contains("\"edge_id\":\"living-room\""));
252
253        let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
254        match parsed {
255            ServerToEdge::ConfigFull { config } => {
256                assert_eq!(config.edge_id, "living-room");
257                assert_eq!(config.mappings.len(), 1);
258            }
259            _ => panic!("wrong variant"),
260        }
261    }
262
263    #[test]
264    fn edge_to_server_state_with_optional_output_id() {
265        let msg = EdgeToServer::State {
266            service_type: "roon".into(),
267            target: "zone-1".into(),
268            property: "volume".into(),
269            output_id: Some("output-1".into()),
270            value: serde_json::json!(50),
271        };
272        let json = serde_json::to_string(&msg).unwrap();
273        assert!(json.contains("\"output_id\":\"output-1\""));
274
275        let msg2 = EdgeToServer::State {
276            service_type: "roon".into(),
277            target: "zone-1".into(),
278            property: "playback".into(),
279            output_id: None,
280            value: serde_json::json!("playing"),
281        };
282        let json2 = serde_json::to_string(&msg2).unwrap();
283        assert!(!json2.contains("output_id"));
284    }
285}