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    /// The edge committed a target switch via on-device selection mode.
63    /// Server replies by calling the same code path as `POST
64    /// /api/mappings/:id/target`: persist the new `service_target`, then
65    /// broadcast a `ConfigPatch` upsert back to all edges (including the
66    /// sender) and a `MappingChanged` to UI subscribers.
67    SwitchTarget {
68        mapping_id: Uuid,
69        service_target: String,
70    },
71}
72
73#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum PatchOp {
76    Upsert,
77    Delete,
78}
79
80/// Complete config for one edge, pushed as a `ConfigFull` frame.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct EdgeConfig {
83    pub edge_id: String,
84    pub mappings: Vec<Mapping>,
85    /// Named glyph patterns the edge should use when rendering feedback.
86    /// Consumers look up by `name`. Entries with `builtin == true` have an
87    /// empty `pattern` and are expected to be rendered programmatically by
88    /// the consumer (e.g. `volume_bar` scales with percentage).
89    #[serde(default)]
90    pub glyphs: Vec<Glyph>,
91}
92
93/// A named Nuimo LED glyph. `pattern` is a 9x9 ASCII grid compatible with
94/// `nuimo::Glyph::from_str` (`*` = LED on, anything else = off, rows
95/// separated by `\n`).
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Glyph {
98    pub name: String,
99    #[serde(default)]
100    pub pattern: String,
101    #[serde(default)]
102    pub builtin: bool,
103}
104
105/// Frames sent from `weave-server` to a Web UI client on `/ws/ui`.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(tag = "type", rename_all = "snake_case")]
108pub enum UiFrame {
109    /// Initial full snapshot, pushed once on connect.
110    Snapshot { snapshot: UiSnapshot },
111    /// An edge completed its `Hello` handshake or has otherwise come online.
112    EdgeOnline { edge: EdgeInfo },
113    /// An edge has disconnected (ws closed).
114    EdgeOffline { edge_id: String },
115    /// One service-state update from a connected edge.
116    ServiceState {
117        edge_id: String,
118        service_type: String,
119        target: String,
120        property: String,
121        #[serde(skip_serializing_if = "Option::is_none")]
122        output_id: Option<String>,
123        value: serde_json::Value,
124    },
125    /// One device-state update from a connected edge (battery, RSSI, etc.).
126    DeviceState {
127        edge_id: String,
128        device_type: String,
129        device_id: String,
130        property: String,
131        value: serde_json::Value,
132    },
133    /// Mapping CRUD happened on the server. UIs replace their copy.
134    MappingChanged {
135        mapping_id: Uuid,
136        op: PatchOp,
137        mapping: Option<Mapping>,
138    },
139    /// The glyph set changed. UIs refresh their registry.
140    GlyphsChanged { glyphs: Vec<Glyph> },
141}
142
143/// Initial full state sent on `/ws/ui` connect. Subsequent changes arrive
144/// as `UiFrame` variants.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct UiSnapshot {
147    pub edges: Vec<EdgeInfo>,
148    pub service_states: Vec<ServiceStateEntry>,
149    pub device_states: Vec<DeviceStateEntry>,
150    pub mappings: Vec<Mapping>,
151    pub glyphs: Vec<Glyph>,
152}
153
154/// Identity + status for one connected (or previously-seen) edge.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct EdgeInfo {
157    pub edge_id: String,
158    pub online: bool,
159    pub version: String,
160    pub capabilities: Vec<String>,
161    /// RFC3339 timestamp.
162    pub last_seen: String,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct ServiceStateEntry {
167    pub edge_id: String,
168    pub service_type: String,
169    pub target: String,
170    pub property: String,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub output_id: Option<String>,
173    pub value: serde_json::Value,
174    /// RFC3339 timestamp of last update.
175    pub updated_at: String,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct DeviceStateEntry {
180    pub edge_id: String,
181    pub device_type: String,
182    pub device_id: String,
183    pub property: String,
184    pub value: serde_json::Value,
185    pub updated_at: String,
186}
187
188/// A device-to-service mapping. Mirrors the structure already used by
189/// `weave-server`'s REST API. `edge_id` is new; all other fields retain
190/// their existing semantics.
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct Mapping {
193    pub mapping_id: Uuid,
194    pub edge_id: String,
195    pub device_type: String,
196    pub device_id: String,
197    pub service_type: String,
198    pub service_target: String,
199    pub routes: Vec<Route>,
200    #[serde(default)]
201    pub feedback: Vec<FeedbackRule>,
202    #[serde(default = "default_true")]
203    pub active: bool,
204    /// Ordered list of candidate `service_target` values the edge can cycle
205    /// through at runtime. Empty = switching disabled.
206    #[serde(default)]
207    pub target_candidates: Vec<TargetCandidate>,
208    /// Input primitive (snake-case `InputType` name, e.g. `"long_press"`)
209    /// that enters selection mode on the device. `None` = feature disabled
210    /// for this mapping, regardless of `target_candidates`.
211    ///
212    /// MVP constraint (not enforced in-schema): at most one mapping per
213    /// `(edge_id, device_id)` should set this; the edge router picks the
214    /// first encountered if multiple are set.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub target_switch_on: Option<String>,
217}
218
219/// One entry in `Mapping::target_candidates`. During selection mode the
220/// device displays `glyph` and, on confirm, the mapping's `service_target`
221/// is replaced with `target`.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct TargetCandidate {
224    /// The `service_target` value to switch to (e.g. a Roon zone ID).
225    pub target: String,
226    /// Human-readable label for the UI only — the edge does not need it.
227    #[serde(default)]
228    pub label: String,
229    /// Name of a glyph in the edge's glyph registry to display while this
230    /// candidate is highlighted in selection mode.
231    pub glyph: String,
232}
233
234fn default_true() -> bool {
235    true
236}
237
238/// One input-to-intent route inside a mapping.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct Route {
241    pub input: String,
242    pub intent: String,
243    #[serde(default)]
244    pub params: BTreeMap<String, serde_json::Value>,
245}
246
247/// Feedback rule: service state → device visual feedback.
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct FeedbackRule {
250    pub state: String,
251    pub feedback_type: String,
252    pub mapping: serde_json::Value,
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn server_to_edge_config_full_roundtrip() {
261        let msg = ServerToEdge::ConfigFull {
262            config: EdgeConfig {
263                edge_id: "living-room".into(),
264                mappings: vec![Mapping {
265                    mapping_id: Uuid::nil(),
266                    edge_id: "living-room".into(),
267                    device_type: "nuimo".into(),
268                    device_id: "C3:81:DF:4E:FF:6A".into(),
269                    service_type: "roon".into(),
270                    service_target: "zone-1".into(),
271                    routes: vec![Route {
272                        input: "rotate".into(),
273                        intent: "volume_change".into(),
274                        params: BTreeMap::from([("damping".into(), serde_json::json!(80))]),
275                    }],
276                    feedback: vec![],
277                    active: true,
278                    target_candidates: vec![],
279                    target_switch_on: None,
280                }],
281                glyphs: vec![Glyph {
282                    name: "play".into(),
283                    pattern: "    *    \n     **  ".into(),
284                    builtin: false,
285                }],
286            },
287        };
288        let json = serde_json::to_string(&msg).unwrap();
289        assert!(json.contains("\"type\":\"config_full\""));
290        assert!(json.contains("\"edge_id\":\"living-room\""));
291
292        let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
293        match parsed {
294            ServerToEdge::ConfigFull { config } => {
295                assert_eq!(config.edge_id, "living-room");
296                assert_eq!(config.mappings.len(), 1);
297            }
298            _ => panic!("wrong variant"),
299        }
300    }
301
302    #[test]
303    fn edge_to_server_state_with_optional_output_id() {
304        let msg = EdgeToServer::State {
305            service_type: "roon".into(),
306            target: "zone-1".into(),
307            property: "volume".into(),
308            output_id: Some("output-1".into()),
309            value: serde_json::json!(50),
310        };
311        let json = serde_json::to_string(&msg).unwrap();
312        assert!(json.contains("\"output_id\":\"output-1\""));
313
314        let msg2 = EdgeToServer::State {
315            service_type: "roon".into(),
316            target: "zone-1".into(),
317            property: "playback".into(),
318            output_id: None,
319            value: serde_json::json!("playing"),
320        };
321        let json2 = serde_json::to_string(&msg2).unwrap();
322        assert!(!json2.contains("output_id"));
323    }
324}