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///
223/// Optional `service_type` and `routes` overrides let a single mapping's
224/// candidates straddle services — e.g. `long_press` cycles between a Roon
225/// zone (rotate→volume_change) and a Hue light (rotate→brightness_change),
226/// each with its own route table. When absent, the candidate inherits the
227/// mapping's `service_type` / `routes`, which matches pre-override behavior
228/// so historical mappings deserialize unchanged.
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct TargetCandidate {
231    /// The `service_target` value to switch to (e.g. a Roon zone ID).
232    pub target: String,
233    /// Human-readable label for the UI only — the edge does not need it.
234    #[serde(default)]
235    pub label: String,
236    /// Name of a glyph in the edge's glyph registry to display while this
237    /// candidate is highlighted in selection mode.
238    pub glyph: String,
239    /// Override the mapping's `service_type` when this candidate is active.
240    /// `None` = inherit from the parent `Mapping::service_type`.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub service_type: Option<String>,
243    /// Override the mapping's `routes` when this candidate is active. Required
244    /// in practice whenever `service_type` differs from the mapping's, because
245    /// intents are service-specific (Roon `volume_change` won't work against
246    /// a Hue target). `None` = inherit from the parent `Mapping::routes`.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub routes: Option<Vec<Route>>,
249}
250
251impl Mapping {
252    /// Resolve the effective `(service_type, routes)` for a given target.
253    /// If `target` matches a `target_candidates` entry with overrides,
254    /// those win; otherwise the mapping's own fields are returned.
255    ///
256    /// Callers on the routing hot path should pass the currently active
257    /// `service_target` to get the right adapter + intent table for the
258    /// next emitted `RoutedIntent`.
259    pub fn effective_for<'a>(&'a self, target: &str) -> (&'a str, &'a [Route]) {
260        let candidate = self.target_candidates.iter().find(|c| c.target == target);
261        let service_type = candidate
262            .and_then(|c| c.service_type.as_deref())
263            .unwrap_or(self.service_type.as_str());
264        let routes = candidate
265            .and_then(|c| c.routes.as_deref())
266            .unwrap_or(self.routes.as_slice());
267        (service_type, routes)
268    }
269}
270
271fn default_true() -> bool {
272    true
273}
274
275/// One input-to-intent route inside a mapping.
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct Route {
278    pub input: String,
279    pub intent: String,
280    #[serde(default)]
281    pub params: BTreeMap<String, serde_json::Value>,
282}
283
284/// Feedback rule: service state → device visual feedback.
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct FeedbackRule {
287    pub state: String,
288    pub feedback_type: String,
289    pub mapping: serde_json::Value,
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn server_to_edge_config_full_roundtrip() {
298        let msg = ServerToEdge::ConfigFull {
299            config: EdgeConfig {
300                edge_id: "living-room".into(),
301                mappings: vec![Mapping {
302                    mapping_id: Uuid::nil(),
303                    edge_id: "living-room".into(),
304                    device_type: "nuimo".into(),
305                    device_id: "C3:81:DF:4E:FF:6A".into(),
306                    service_type: "roon".into(),
307                    service_target: "zone-1".into(),
308                    routes: vec![Route {
309                        input: "rotate".into(),
310                        intent: "volume_change".into(),
311                        params: BTreeMap::from([("damping".into(), serde_json::json!(80))]),
312                    }],
313                    feedback: vec![],
314                    active: true,
315                    target_candidates: vec![],
316                    target_switch_on: None,
317                }],
318                glyphs: vec![Glyph {
319                    name: "play".into(),
320                    pattern: "    *    \n     **  ".into(),
321                    builtin: false,
322                }],
323            },
324        };
325        let json = serde_json::to_string(&msg).unwrap();
326        assert!(json.contains("\"type\":\"config_full\""));
327        assert!(json.contains("\"edge_id\":\"living-room\""));
328
329        let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
330        match parsed {
331            ServerToEdge::ConfigFull { config } => {
332                assert_eq!(config.edge_id, "living-room");
333                assert_eq!(config.mappings.len(), 1);
334            }
335            _ => panic!("wrong variant"),
336        }
337    }
338
339    #[test]
340    fn edge_to_server_state_with_optional_output_id() {
341        let msg = EdgeToServer::State {
342            service_type: "roon".into(),
343            target: "zone-1".into(),
344            property: "volume".into(),
345            output_id: Some("output-1".into()),
346            value: serde_json::json!(50),
347        };
348        let json = serde_json::to_string(&msg).unwrap();
349        assert!(json.contains("\"output_id\":\"output-1\""));
350
351        let msg2 = EdgeToServer::State {
352            service_type: "roon".into(),
353            target: "zone-1".into(),
354            property: "playback".into(),
355            output_id: None,
356            value: serde_json::json!("playing"),
357        };
358        let json2 = serde_json::to_string(&msg2).unwrap();
359        assert!(!json2.contains("output_id"));
360    }
361}