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    /// Ordered list of candidate `service_target` values the edge can cycle
196    /// through at runtime. Empty = switching disabled.
197    #[serde(default)]
198    pub target_candidates: Vec<TargetCandidate>,
199    /// Input primitive (snake-case `InputType` name, e.g. `"long_press"`)
200    /// that enters selection mode on the device. `None` = feature disabled
201    /// for this mapping, regardless of `target_candidates`.
202    ///
203    /// MVP constraint (not enforced in-schema): at most one mapping per
204    /// `(edge_id, device_id)` should set this; the edge router picks the
205    /// first encountered if multiple are set.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub target_switch_on: Option<String>,
208}
209
210/// One entry in `Mapping::target_candidates`. During selection mode the
211/// device displays `glyph` and, on confirm, the mapping's `service_target`
212/// is replaced with `target`.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct TargetCandidate {
215    /// The `service_target` value to switch to (e.g. a Roon zone ID).
216    pub target: String,
217    /// Human-readable label for the UI only — the edge does not need it.
218    #[serde(default)]
219    pub label: String,
220    /// Name of a glyph in the edge's glyph registry to display while this
221    /// candidate is highlighted in selection mode.
222    pub glyph: String,
223}
224
225fn default_true() -> bool {
226    true
227}
228
229/// One input-to-intent route inside a mapping.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct Route {
232    pub input: String,
233    pub intent: String,
234    #[serde(default)]
235    pub params: BTreeMap<String, serde_json::Value>,
236}
237
238/// Feedback rule: service state → device visual feedback.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct FeedbackRule {
241    pub state: String,
242    pub feedback_type: String,
243    pub mapping: serde_json::Value,
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn server_to_edge_config_full_roundtrip() {
252        let msg = ServerToEdge::ConfigFull {
253            config: EdgeConfig {
254                edge_id: "living-room".into(),
255                mappings: vec![Mapping {
256                    mapping_id: Uuid::nil(),
257                    edge_id: "living-room".into(),
258                    device_type: "nuimo".into(),
259                    device_id: "C3:81:DF:4E:FF:6A".into(),
260                    service_type: "roon".into(),
261                    service_target: "zone-1".into(),
262                    routes: vec![Route {
263                        input: "rotate".into(),
264                        intent: "volume_change".into(),
265                        params: BTreeMap::from([("damping".into(), serde_json::json!(80))]),
266                    }],
267                    feedback: vec![],
268                    active: true,
269                    target_candidates: vec![],
270                    target_switch_on: None,
271                }],
272                glyphs: vec![Glyph {
273                    name: "play".into(),
274                    pattern: "    *    \n     **  ".into(),
275                    builtin: false,
276                }],
277            },
278        };
279        let json = serde_json::to_string(&msg).unwrap();
280        assert!(json.contains("\"type\":\"config_full\""));
281        assert!(json.contains("\"edge_id\":\"living-room\""));
282
283        let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
284        match parsed {
285            ServerToEdge::ConfigFull { config } => {
286                assert_eq!(config.edge_id, "living-room");
287                assert_eq!(config.mappings.len(), 1);
288            }
289            _ => panic!("wrong variant"),
290        }
291    }
292
293    #[test]
294    fn edge_to_server_state_with_optional_output_id() {
295        let msg = EdgeToServer::State {
296            service_type: "roon".into(),
297            target: "zone-1".into(),
298            property: "volume".into(),
299            output_id: Some("output-1".into()),
300            value: serde_json::json!(50),
301        };
302        let json = serde_json::to_string(&msg).unwrap();
303        assert!(json.contains("\"output_id\":\"output-1\""));
304
305        let msg2 = EdgeToServer::State {
306            service_type: "roon".into(),
307            target: "zone-1".into(),
308            property: "playback".into(),
309            output_id: None,
310            value: serde_json::json!("playing"),
311        };
312        let json2 = serde_json::to_string(&msg2).unwrap();
313        assert!(!json2.contains("output_id"));
314    }
315}