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