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    /// A command that the edge's adapter emitted to an external service
72    /// (Roon MOO RPC, Hue REST, …). One frame per `adapter.send_intent`
73    /// call, carrying the outcome and measured latency so the UI live
74    /// stream can show "sent → ok (42ms)" rows alongside input and
75    /// state-echo rows.
76    Command {
77        service_type: String,
78        target: String,
79        /// Snake-case intent name (`volume_change`, `play_pause`, …).
80        intent: String,
81        /// Intent parameters serialized as JSON. Shape matches the
82        /// `weave-engine::Intent` discriminant's payload.
83        #[serde(default)]
84        params: serde_json::Value,
85        result: CommandResult,
86        #[serde(skip_serializing_if = "Option::is_none")]
87        latency_ms: Option<u32>,
88        #[serde(skip_serializing_if = "Option::is_none")]
89        output_id: Option<String>,
90    },
91    /// Adapter-level or routing-level error not tied to a specific
92    /// command (bridge disconnect, auth token expired, pairing lost).
93    /// Command-level failures use `Command { result: Err { .. } }`
94    /// instead — `Error` is for ambient conditions.
95    Error {
96        context: String,
97        message: String,
98        severity: ErrorSeverity,
99    },
100}
101
102/// Outcome of an `EdgeToServer::Command`.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(tag = "kind", rename_all = "snake_case")]
105pub enum CommandResult {
106    Ok,
107    Err { message: String },
108}
109
110/// Severity classification for `EdgeToServer::Error` and `UiFrame::Error`.
111#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum ErrorSeverity {
114    Warn,
115    Error,
116    Fatal,
117}
118
119#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum PatchOp {
122    Upsert,
123    Delete,
124}
125
126/// Complete config for one edge, pushed as a `ConfigFull` frame.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct EdgeConfig {
129    pub edge_id: String,
130    pub mappings: Vec<Mapping>,
131    /// Named glyph patterns the edge should use when rendering feedback.
132    /// Consumers look up by `name`. Entries with `builtin == true` have an
133    /// empty `pattern` and are expected to be rendered programmatically by
134    /// the consumer (e.g. `volume_bar` scales with percentage).
135    #[serde(default)]
136    pub glyphs: Vec<Glyph>,
137}
138
139/// A named Nuimo LED glyph. `pattern` is a 9x9 ASCII grid compatible with
140/// `nuimo::Glyph::from_str` (`*` = LED on, anything else = off, rows
141/// separated by `\n`).
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct Glyph {
144    pub name: String,
145    #[serde(default)]
146    pub pattern: String,
147    #[serde(default)]
148    pub builtin: bool,
149}
150
151/// Frames sent from `weave-server` to a Web UI client on `/ws/ui`.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(tag = "type", rename_all = "snake_case")]
154pub enum UiFrame {
155    /// Initial full snapshot, pushed once on connect.
156    Snapshot { snapshot: UiSnapshot },
157    /// An edge completed its `Hello` handshake or has otherwise come online.
158    EdgeOnline { edge: EdgeInfo },
159    /// An edge has disconnected (ws closed).
160    EdgeOffline { edge_id: String },
161    /// One service-state update from a connected edge.
162    ServiceState {
163        edge_id: String,
164        service_type: String,
165        target: String,
166        property: String,
167        #[serde(skip_serializing_if = "Option::is_none")]
168        output_id: Option<String>,
169        value: serde_json::Value,
170    },
171    /// One device-state update from a connected edge (battery, RSSI, etc.).
172    DeviceState {
173        edge_id: String,
174        device_type: String,
175        device_id: String,
176        property: String,
177        value: serde_json::Value,
178    },
179    /// Mapping CRUD happened on the server. UIs replace their copy.
180    MappingChanged {
181        mapping_id: Uuid,
182        op: PatchOp,
183        mapping: Option<Mapping>,
184    },
185    /// The glyph set changed. UIs refresh their registry.
186    GlyphsChanged { glyphs: Vec<Glyph> },
187    /// Fan-out of an edge-emitted `Command`. Transient — never stored in
188    /// `UiSnapshot`; dashboards that open after the fact will not see it.
189    Command {
190        edge_id: String,
191        service_type: String,
192        target: String,
193        intent: String,
194        #[serde(default)]
195        params: serde_json::Value,
196        result: CommandResult,
197        #[serde(skip_serializing_if = "Option::is_none")]
198        latency_ms: Option<u32>,
199        #[serde(skip_serializing_if = "Option::is_none")]
200        output_id: Option<String>,
201        /// RFC3339 timestamp assigned by the server on fan-out.
202        at: String,
203    },
204    /// Fan-out of an edge-emitted `Error`. Transient.
205    Error {
206        edge_id: String,
207        context: String,
208        message: String,
209        severity: ErrorSeverity,
210        /// RFC3339 timestamp assigned by the server on fan-out.
211        at: String,
212    },
213}
214
215/// Initial full state sent on `/ws/ui` connect. Subsequent changes arrive
216/// as `UiFrame` variants.
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct UiSnapshot {
219    pub edges: Vec<EdgeInfo>,
220    pub service_states: Vec<ServiceStateEntry>,
221    pub device_states: Vec<DeviceStateEntry>,
222    pub mappings: Vec<Mapping>,
223    pub glyphs: Vec<Glyph>,
224}
225
226/// Identity + status for one connected (or previously-seen) edge.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct EdgeInfo {
229    pub edge_id: String,
230    pub online: bool,
231    pub version: String,
232    pub capabilities: Vec<String>,
233    /// RFC3339 timestamp.
234    pub last_seen: String,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct ServiceStateEntry {
239    pub edge_id: String,
240    pub service_type: String,
241    pub target: String,
242    pub property: String,
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub output_id: Option<String>,
245    pub value: serde_json::Value,
246    /// RFC3339 timestamp of last update.
247    pub updated_at: String,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct DeviceStateEntry {
252    pub edge_id: String,
253    pub device_type: String,
254    pub device_id: String,
255    pub property: String,
256    pub value: serde_json::Value,
257    pub updated_at: String,
258}
259
260/// A device-to-service mapping. Mirrors the structure already used by
261/// `weave-server`'s REST API. `edge_id` is new; all other fields retain
262/// their existing semantics.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct Mapping {
265    pub mapping_id: Uuid,
266    pub edge_id: String,
267    pub device_type: String,
268    pub device_id: String,
269    pub service_type: String,
270    pub service_target: String,
271    pub routes: Vec<Route>,
272    #[serde(default)]
273    pub feedback: Vec<FeedbackRule>,
274    #[serde(default = "default_true")]
275    pub active: bool,
276    /// Ordered list of candidate `service_target` values the edge can cycle
277    /// through at runtime. Empty = switching disabled.
278    #[serde(default)]
279    pub target_candidates: Vec<TargetCandidate>,
280    /// Input primitive (snake-case `InputType` name, e.g. `"long_press"`)
281    /// that enters selection mode on the device. `None` = feature disabled
282    /// for this mapping, regardless of `target_candidates`.
283    ///
284    /// MVP constraint (not enforced in-schema): at most one mapping per
285    /// `(edge_id, device_id)` should set this; the edge router picks the
286    /// first encountered if multiple are set.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub target_switch_on: Option<String>,
289}
290
291/// One entry in `Mapping::target_candidates`. During selection mode the
292/// device displays `glyph` and, on confirm, the mapping's `service_target`
293/// is replaced with `target`.
294///
295/// Optional `service_type` and `routes` overrides let a single mapping's
296/// candidates straddle services — e.g. `long_press` cycles between a Roon
297/// zone (rotate→volume_change) and a Hue light (rotate→brightness_change),
298/// each with its own route table. When absent, the candidate inherits the
299/// mapping's `service_type` / `routes`, which matches pre-override behavior
300/// so historical mappings deserialize unchanged.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct TargetCandidate {
303    /// The `service_target` value to switch to (e.g. a Roon zone ID).
304    pub target: String,
305    /// Human-readable label for the UI only — the edge does not need it.
306    #[serde(default)]
307    pub label: String,
308    /// Name of a glyph in the edge's glyph registry to display while this
309    /// candidate is highlighted in selection mode.
310    pub glyph: String,
311    /// Override the mapping's `service_type` when this candidate is active.
312    /// `None` = inherit from the parent `Mapping::service_type`.
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub service_type: Option<String>,
315    /// Override the mapping's `routes` when this candidate is active. Required
316    /// in practice whenever `service_type` differs from the mapping's, because
317    /// intents are service-specific (Roon `volume_change` won't work against
318    /// a Hue target). `None` = inherit from the parent `Mapping::routes`.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub routes: Option<Vec<Route>>,
321}
322
323impl Mapping {
324    /// Resolve the effective `(service_type, routes)` for a given target.
325    /// If `target` matches a `target_candidates` entry with overrides,
326    /// those win; otherwise the mapping's own fields are returned.
327    ///
328    /// Callers on the routing hot path should pass the currently active
329    /// `service_target` to get the right adapter + intent table for the
330    /// next emitted `RoutedIntent`.
331    pub fn effective_for<'a>(&'a self, target: &str) -> (&'a str, &'a [Route]) {
332        let candidate = self.target_candidates.iter().find(|c| c.target == target);
333        let service_type = candidate
334            .and_then(|c| c.service_type.as_deref())
335            .unwrap_or(self.service_type.as_str());
336        let routes = candidate
337            .and_then(|c| c.routes.as_deref())
338            .unwrap_or(self.routes.as_slice());
339        (service_type, routes)
340    }
341}
342
343fn default_true() -> bool {
344    true
345}
346
347/// One input-to-intent route inside a mapping.
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct Route {
350    pub input: String,
351    pub intent: String,
352    #[serde(default)]
353    pub params: BTreeMap<String, serde_json::Value>,
354}
355
356/// Feedback rule: service state → device visual feedback.
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct FeedbackRule {
359    pub state: String,
360    pub feedback_type: String,
361    pub mapping: serde_json::Value,
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn server_to_edge_config_full_roundtrip() {
370        let msg = ServerToEdge::ConfigFull {
371            config: EdgeConfig {
372                edge_id: "living-room".into(),
373                mappings: vec![Mapping {
374                    mapping_id: Uuid::nil(),
375                    edge_id: "living-room".into(),
376                    device_type: "nuimo".into(),
377                    device_id: "C3:81:DF:4E:FF:6A".into(),
378                    service_type: "roon".into(),
379                    service_target: "zone-1".into(),
380                    routes: vec![Route {
381                        input: "rotate".into(),
382                        intent: "volume_change".into(),
383                        params: BTreeMap::from([("damping".into(), serde_json::json!(80))]),
384                    }],
385                    feedback: vec![],
386                    active: true,
387                    target_candidates: vec![],
388                    target_switch_on: None,
389                }],
390                glyphs: vec![Glyph {
391                    name: "play".into(),
392                    pattern: "    *    \n     **  ".into(),
393                    builtin: false,
394                }],
395            },
396        };
397        let json = serde_json::to_string(&msg).unwrap();
398        assert!(json.contains("\"type\":\"config_full\""));
399        assert!(json.contains("\"edge_id\":\"living-room\""));
400
401        let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
402        match parsed {
403            ServerToEdge::ConfigFull { config } => {
404                assert_eq!(config.edge_id, "living-room");
405                assert_eq!(config.mappings.len(), 1);
406            }
407            _ => panic!("wrong variant"),
408        }
409    }
410
411    #[test]
412    fn edge_to_server_command_roundtrip() {
413        let ok = EdgeToServer::Command {
414            service_type: "roon".into(),
415            target: "zone-1".into(),
416            intent: "volume_change".into(),
417            params: serde_json::json!({"delta": 3}),
418            result: CommandResult::Ok,
419            latency_ms: Some(42),
420            output_id: None,
421        };
422        let json = serde_json::to_string(&ok).unwrap();
423        assert!(json.contains("\"type\":\"command\""));
424        assert!(json.contains("\"kind\":\"ok\""));
425        assert!(json.contains("\"latency_ms\":42"));
426        assert!(!json.contains("output_id"));
427        let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
428        match parsed {
429            EdgeToServer::Command { intent, result, .. } => {
430                assert_eq!(intent, "volume_change");
431                assert!(matches!(result, CommandResult::Ok));
432            }
433            _ => panic!("wrong variant"),
434        }
435
436        let err = EdgeToServer::Command {
437            service_type: "hue".into(),
438            target: "light-1".into(),
439            intent: "on_off".into(),
440            params: serde_json::json!({"on": true}),
441            result: CommandResult::Err {
442                message: "bridge timeout".into(),
443            },
444            latency_ms: None,
445            output_id: None,
446        };
447        let json = serde_json::to_string(&err).unwrap();
448        assert!(json.contains("\"kind\":\"err\""));
449        assert!(json.contains("\"message\":\"bridge timeout\""));
450    }
451
452    #[test]
453    fn edge_to_server_error_roundtrip() {
454        let msg = EdgeToServer::Error {
455            context: "hue.bridge".into(),
456            message: "connection refused".into(),
457            severity: ErrorSeverity::Error,
458        };
459        let json = serde_json::to_string(&msg).unwrap();
460        assert!(json.contains("\"type\":\"error\""));
461        assert!(json.contains("\"severity\":\"error\""));
462    }
463
464    #[test]
465    fn ui_frame_command_and_error_roundtrip() {
466        let cmd = UiFrame::Command {
467            edge_id: "air".into(),
468            service_type: "roon".into(),
469            target: "zone-1".into(),
470            intent: "play_pause".into(),
471            params: serde_json::json!({}),
472            result: CommandResult::Ok,
473            latency_ms: Some(18),
474            output_id: None,
475            at: "2026-04-23T12:00:00Z".into(),
476        };
477        let json = serde_json::to_string(&cmd).unwrap();
478        assert!(json.contains("\"type\":\"command\""));
479        let _: UiFrame = serde_json::from_str(&json).unwrap();
480
481        let err = UiFrame::Error {
482            edge_id: "air".into(),
483            context: "roon.client".into(),
484            message: "pair lost".into(),
485            severity: ErrorSeverity::Warn,
486            at: "2026-04-23T12:00:00Z".into(),
487        };
488        let json = serde_json::to_string(&err).unwrap();
489        assert!(json.contains("\"type\":\"error\""));
490        assert!(json.contains("\"severity\":\"warn\""));
491        let _: UiFrame = serde_json::from_str(&json).unwrap();
492    }
493
494    #[test]
495    fn edge_to_server_state_with_optional_output_id() {
496        let msg = EdgeToServer::State {
497            service_type: "roon".into(),
498            target: "zone-1".into(),
499            property: "volume".into(),
500            output_id: Some("output-1".into()),
501            value: serde_json::json!(50),
502        };
503        let json = serde_json::to_string(&msg).unwrap();
504        assert!(json.contains("\"output_id\":\"output-1\""));
505
506        let msg2 = EdgeToServer::State {
507            service_type: "roon".into(),
508            target: "zone-1".into(),
509            property: "playback".into(),
510            output_id: None,
511            value: serde_json::json!("playing"),
512        };
513        let json2 = serde_json::to_string(&msg2).unwrap();
514        assert!(!json2.contains("output_id"));
515    }
516}