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    /// Render a glyph on a specific device immediately. Used by the
31    /// weave-web "Test LED" affordance to verify a device's display path
32    /// without waiting for a service-state event.
33    DisplayGlyph {
34        device_type: String,
35        device_id: String,
36        /// 9-line ASCII grid (`*` = on, anything else = off). Matches
37        /// the `Glyph::pattern` shape used in `GlyphsUpdate`.
38        pattern: String,
39        /// Brightness 0.0..=1.0. Defaults to 1.0 when absent.
40        #[serde(skip_serializing_if = "Option::is_none")]
41        brightness: Option<f32>,
42        /// Auto-clear timeout in milliseconds. Defaults to a short value
43        /// when absent so test renders don't linger.
44        #[serde(skip_serializing_if = "Option::is_none")]
45        timeout_ms: Option<u32>,
46        /// Transition kind (`"immediate"` or `"cross_fade"`). Defaults to
47        /// cross-fade when absent.
48        #[serde(skip_serializing_if = "Option::is_none")]
49        transition: Option<String>,
50    },
51    /// Server-initiated request to (re)connect a specific device. Idempotent
52    /// — already-connected devices are a no-op aside from clearing any
53    /// "paused" state that previously suppressed reconnect attempts.
54    DeviceConnect {
55        device_type: String,
56        device_id: String,
57    },
58    /// Server-initiated request to disconnect a specific device. Sets a
59    /// paused flag so the auto-reconnect loop does not immediately
60    /// re-establish the link.
61    DeviceDisconnect {
62        device_type: String,
63        device_id: String,
64    },
65    /// Periodic keepalive to keep NAT/proxies open and detect half-open TCP.
66    Ping,
67}
68
69/// Frames sent from an `edge-agent` to `weave-server`.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(tag = "type", rename_all = "snake_case")]
72pub enum EdgeToServer {
73    /// First frame after connect. Declares identity and adapter capabilities.
74    Hello {
75        edge_id: String,
76        version: String,
77        capabilities: Vec<String>,
78    },
79    /// State update for a service target (e.g. Roon zone playback / volume).
80    State {
81        service_type: String,
82        target: String,
83        property: String,
84        #[serde(skip_serializing_if = "Option::is_none")]
85        output_id: Option<String>,
86        value: serde_json::Value,
87    },
88    /// State update for a device (battery, RSSI, connected).
89    DeviceState {
90        device_type: String,
91        device_id: String,
92        property: String,
93        value: serde_json::Value,
94    },
95    /// Reply to server `Ping`.
96    Pong,
97    /// The edge committed a target switch via on-device selection mode.
98    /// Server replies by calling the same code path as `POST
99    /// /api/mappings/:id/target`: persist the new `service_target`, then
100    /// broadcast a `ConfigPatch` upsert back to all edges (including the
101    /// sender) and a `MappingChanged` to UI subscribers.
102    SwitchTarget {
103        mapping_id: Uuid,
104        service_target: String,
105    },
106    /// A command that the edge's adapter emitted to an external service
107    /// (Roon MOO RPC, Hue REST, …). One frame per `adapter.send_intent`
108    /// call, carrying the outcome and measured latency so the UI live
109    /// stream can show "sent → ok (42ms)" rows alongside input and
110    /// state-echo rows.
111    Command {
112        service_type: String,
113        target: String,
114        /// Snake-case intent name (`volume_change`, `play_pause`, …).
115        intent: String,
116        /// Intent parameters serialized as JSON. Shape matches the
117        /// `weave-engine::Intent` discriminant's payload.
118        #[serde(default)]
119        params: serde_json::Value,
120        result: CommandResult,
121        #[serde(skip_serializing_if = "Option::is_none")]
122        latency_ms: Option<u32>,
123        #[serde(skip_serializing_if = "Option::is_none")]
124        output_id: Option<String>,
125    },
126    /// Adapter-level or routing-level error not tied to a specific
127    /// command (bridge disconnect, auth token expired, pairing lost).
128    /// Command-level failures use `Command { result: Err { .. } }`
129    /// instead — `Error` is for ambient conditions.
130    Error {
131        context: String,
132        message: String,
133        severity: ErrorSeverity,
134    },
135    /// Periodic edge-side metrics. Emitted on a fixed cadence (typically
136    /// every 10 s) so the server can surface edge health in `/ws/ui`
137    /// dashboards. Server-side latency is measured separately from
138    /// `Ping`/`Pong` round trips and is not carried here.
139    EdgeStatus {
140        /// Wifi signal strength normalized to 0..=100 percent. `None`
141        /// when the platform doesn't expose a signal-strength API to
142        /// user code, when the host has no wifi adapter, or when the
143        /// API call failed (entitlement missing, permission denied).
144        #[serde(skip_serializing_if = "Option::is_none")]
145        wifi: Option<u8>,
146    },
147}
148
149/// Outcome of an `EdgeToServer::Command`.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(tag = "kind", rename_all = "snake_case")]
152pub enum CommandResult {
153    Ok,
154    Err { message: String },
155}
156
157/// Severity classification for `EdgeToServer::Error` and `UiFrame::Error`.
158#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum ErrorSeverity {
161    Warn,
162    Error,
163    Fatal,
164}
165
166#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum PatchOp {
169    Upsert,
170    Delete,
171}
172
173/// Complete config for one edge, pushed as a `ConfigFull` frame.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct EdgeConfig {
176    pub edge_id: String,
177    pub mappings: Vec<Mapping>,
178    /// Named glyph patterns the edge should use when rendering feedback.
179    /// Consumers look up by `name`. Entries with `builtin == true` have an
180    /// empty `pattern` and are expected to be rendered programmatically by
181    /// the consumer (e.g. `volume_bar` scales with percentage).
182    #[serde(default)]
183    pub glyphs: Vec<Glyph>,
184}
185
186/// A named Nuimo LED glyph. `pattern` is a 9x9 ASCII grid compatible with
187/// `nuimo::Glyph::from_str` (`*` = LED on, anything else = off, rows
188/// separated by `\n`).
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct Glyph {
191    pub name: String,
192    #[serde(default)]
193    pub pattern: String,
194    #[serde(default)]
195    pub builtin: bool,
196}
197
198/// Frames sent from `weave-server` to a Web UI client on `/ws/ui`.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200#[serde(tag = "type", rename_all = "snake_case")]
201pub enum UiFrame {
202    /// Initial full snapshot, pushed once on connect.
203    Snapshot { snapshot: UiSnapshot },
204    /// An edge completed its `Hello` handshake or has otherwise come online.
205    EdgeOnline { edge: EdgeInfo },
206    /// An edge has disconnected (ws closed).
207    EdgeOffline { edge_id: String },
208    /// One service-state update from a connected edge.
209    ServiceState {
210        edge_id: String,
211        service_type: String,
212        target: String,
213        property: String,
214        #[serde(skip_serializing_if = "Option::is_none")]
215        output_id: Option<String>,
216        value: serde_json::Value,
217    },
218    /// One device-state update from a connected edge (battery, RSSI, etc.).
219    DeviceState {
220        edge_id: String,
221        device_type: String,
222        device_id: String,
223        property: String,
224        value: serde_json::Value,
225    },
226    /// Mapping CRUD happened on the server. UIs replace their copy.
227    MappingChanged {
228        mapping_id: Uuid,
229        op: PatchOp,
230        mapping: Option<Mapping>,
231    },
232    /// The glyph set changed. UIs refresh their registry.
233    GlyphsChanged { glyphs: Vec<Glyph> },
234    /// Fan-out of an edge-emitted `Command`. Transient — never stored in
235    /// `UiSnapshot`; dashboards that open after the fact will not see it.
236    Command {
237        edge_id: String,
238        service_type: String,
239        target: String,
240        intent: String,
241        #[serde(default)]
242        params: serde_json::Value,
243        result: CommandResult,
244        #[serde(skip_serializing_if = "Option::is_none")]
245        latency_ms: Option<u32>,
246        #[serde(skip_serializing_if = "Option::is_none")]
247        output_id: Option<String>,
248        /// RFC3339 timestamp assigned by the server on fan-out.
249        at: String,
250    },
251    /// Fan-out of an edge-emitted `Error`. Transient.
252    Error {
253        edge_id: String,
254        context: String,
255        message: String,
256        severity: ErrorSeverity,
257        /// RFC3339 timestamp assigned by the server on fan-out.
258        at: String,
259    },
260    /// Periodic edge metrics. Carries the latest known wifi signal
261    /// strength (edge-reported) and round-trip latency (server-measured
262    /// from `Ping`/`Pong`). Each field is `None` when unknown:
263    /// either because no measurement has arrived yet, or because the
264    /// edge cannot read the value on its platform. Emitted whenever
265    /// either field changes; UIs apply it as a partial update on the
266    /// matching `edge_id` row.
267    EdgeStatus {
268        edge_id: String,
269        #[serde(skip_serializing_if = "Option::is_none")]
270        wifi: Option<u8>,
271        #[serde(skip_serializing_if = "Option::is_none")]
272        latency_ms: Option<u32>,
273    },
274}
275
276/// Initial full state sent on `/ws/ui` connect. Subsequent changes arrive
277/// as `UiFrame` variants.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct UiSnapshot {
280    pub edges: Vec<EdgeInfo>,
281    pub service_states: Vec<ServiceStateEntry>,
282    pub device_states: Vec<DeviceStateEntry>,
283    pub mappings: Vec<Mapping>,
284    pub glyphs: Vec<Glyph>,
285}
286
287/// Identity + status for one connected (or previously-seen) edge.
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct EdgeInfo {
290    pub edge_id: String,
291    pub online: bool,
292    pub version: String,
293    pub capabilities: Vec<String>,
294    /// RFC3339 timestamp.
295    pub last_seen: String,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct ServiceStateEntry {
300    pub edge_id: String,
301    pub service_type: String,
302    pub target: String,
303    pub property: String,
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub output_id: Option<String>,
306    pub value: serde_json::Value,
307    /// RFC3339 timestamp of last update.
308    pub updated_at: String,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct DeviceStateEntry {
313    pub edge_id: String,
314    pub device_type: String,
315    pub device_id: String,
316    pub property: String,
317    pub value: serde_json::Value,
318    pub updated_at: String,
319}
320
321/// A device-to-service mapping. Mirrors the structure already used by
322/// `weave-server`'s REST API. `edge_id` is new; all other fields retain
323/// their existing semantics.
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct Mapping {
326    pub mapping_id: Uuid,
327    pub edge_id: String,
328    pub device_type: String,
329    pub device_id: String,
330    pub service_type: String,
331    pub service_target: String,
332    pub routes: Vec<Route>,
333    #[serde(default)]
334    pub feedback: Vec<FeedbackRule>,
335    #[serde(default = "default_true")]
336    pub active: bool,
337    /// Ordered list of candidate `service_target` values the edge can cycle
338    /// through at runtime. Empty = switching disabled.
339    #[serde(default)]
340    pub target_candidates: Vec<TargetCandidate>,
341    /// Input primitive (snake-case `InputType` name, e.g. `"long_press"`)
342    /// that enters selection mode on the device. `None` = feature disabled
343    /// for this mapping, regardless of `target_candidates`.
344    ///
345    /// MVP constraint (not enforced in-schema): at most one mapping per
346    /// `(edge_id, device_id)` should set this; the edge router picks the
347    /// first encountered if multiple are set.
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub target_switch_on: Option<String>,
350}
351
352/// One entry in `Mapping::target_candidates`. During selection mode the
353/// device displays `glyph` and, on confirm, the mapping's `service_target`
354/// is replaced with `target`.
355///
356/// Optional `service_type` and `routes` overrides let a single mapping's
357/// candidates straddle services — e.g. `long_press` cycles between a Roon
358/// zone (rotate→volume_change) and a Hue light (rotate→brightness_change),
359/// each with its own route table. When absent, the candidate inherits the
360/// mapping's `service_type` / `routes`, which matches pre-override behavior
361/// so historical mappings deserialize unchanged.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct TargetCandidate {
364    /// The `service_target` value to switch to (e.g. a Roon zone ID).
365    pub target: String,
366    /// Human-readable label for the UI only — the edge does not need it.
367    #[serde(default)]
368    pub label: String,
369    /// Name of a glyph in the edge's glyph registry to display while this
370    /// candidate is highlighted in selection mode.
371    pub glyph: String,
372    /// Override the mapping's `service_type` when this candidate is active.
373    /// `None` = inherit from the parent `Mapping::service_type`.
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub service_type: Option<String>,
376    /// Override the mapping's `routes` when this candidate is active. Required
377    /// in practice whenever `service_type` differs from the mapping's, because
378    /// intents are service-specific (Roon `volume_change` won't work against
379    /// a Hue target). `None` = inherit from the parent `Mapping::routes`.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub routes: Option<Vec<Route>>,
382}
383
384impl Mapping {
385    /// Resolve the effective `(service_type, routes)` for a given target.
386    /// If `target` matches a `target_candidates` entry with overrides,
387    /// those win; otherwise the mapping's own fields are returned.
388    ///
389    /// Callers on the routing hot path should pass the currently active
390    /// `service_target` to get the right adapter + intent table for the
391    /// next emitted `RoutedIntent`.
392    pub fn effective_for<'a>(&'a self, target: &str) -> (&'a str, &'a [Route]) {
393        let candidate = self.target_candidates.iter().find(|c| c.target == target);
394        let service_type = candidate
395            .and_then(|c| c.service_type.as_deref())
396            .unwrap_or(self.service_type.as_str());
397        let routes = candidate
398            .and_then(|c| c.routes.as_deref())
399            .unwrap_or(self.routes.as_slice());
400        (service_type, routes)
401    }
402}
403
404fn default_true() -> bool {
405    true
406}
407
408/// One input-to-intent route inside a mapping.
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct Route {
411    pub input: String,
412    pub intent: String,
413    #[serde(default)]
414    pub params: BTreeMap<String, serde_json::Value>,
415}
416
417/// Feedback rule: service state → device visual feedback.
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct FeedbackRule {
420    pub state: String,
421    pub feedback_type: String,
422    pub mapping: serde_json::Value,
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn server_to_edge_config_full_roundtrip() {
431        let msg = ServerToEdge::ConfigFull {
432            config: EdgeConfig {
433                edge_id: "living-room".into(),
434                mappings: vec![Mapping {
435                    mapping_id: Uuid::nil(),
436                    edge_id: "living-room".into(),
437                    device_type: "nuimo".into(),
438                    device_id: "C3:81:DF:4E:FF:6A".into(),
439                    service_type: "roon".into(),
440                    service_target: "zone-1".into(),
441                    routes: vec![Route {
442                        input: "rotate".into(),
443                        intent: "volume_change".into(),
444                        params: BTreeMap::from([("damping".into(), serde_json::json!(80))]),
445                    }],
446                    feedback: vec![],
447                    active: true,
448                    target_candidates: vec![],
449                    target_switch_on: None,
450                }],
451                glyphs: vec![Glyph {
452                    name: "play".into(),
453                    pattern: "    *    \n     **  ".into(),
454                    builtin: false,
455                }],
456            },
457        };
458        let json = serde_json::to_string(&msg).unwrap();
459        assert!(json.contains("\"type\":\"config_full\""));
460        assert!(json.contains("\"edge_id\":\"living-room\""));
461
462        let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
463        match parsed {
464            ServerToEdge::ConfigFull { config } => {
465                assert_eq!(config.edge_id, "living-room");
466                assert_eq!(config.mappings.len(), 1);
467            }
468            _ => panic!("wrong variant"),
469        }
470    }
471
472    #[test]
473    fn server_to_edge_display_glyph_roundtrip() {
474        let msg = ServerToEdge::DisplayGlyph {
475            device_type: "nuimo".into(),
476            device_id: "C3:81:DF:4E:FF:6A".into(),
477            pattern: "    *    \n  *****  ".into(),
478            brightness: Some(0.5),
479            timeout_ms: Some(2000),
480            transition: Some("cross_fade".into()),
481        };
482        let json = serde_json::to_string(&msg).unwrap();
483        assert!(json.contains("\"type\":\"display_glyph\""));
484        assert!(json.contains("\"device_type\":\"nuimo\""));
485        assert!(json.contains("\"brightness\":0.5"));
486
487        let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
488        match parsed {
489            ServerToEdge::DisplayGlyph {
490                device_type,
491                device_id,
492                pattern,
493                brightness,
494                timeout_ms,
495                transition,
496            } => {
497                assert_eq!(device_type, "nuimo");
498                assert_eq!(device_id, "C3:81:DF:4E:FF:6A");
499                assert!(pattern.contains('*'));
500                assert_eq!(brightness, Some(0.5));
501                assert_eq!(timeout_ms, Some(2000));
502                assert_eq!(transition.as_deref(), Some("cross_fade"));
503            }
504            _ => panic!("wrong variant"),
505        }
506
507        // Optional fields elided when None.
508        let minimal = ServerToEdge::DisplayGlyph {
509            device_type: "nuimo".into(),
510            device_id: "dev-1".into(),
511            pattern: "*".into(),
512            brightness: None,
513            timeout_ms: None,
514            transition: None,
515        };
516        let json = serde_json::to_string(&minimal).unwrap();
517        assert!(!json.contains("brightness"));
518        assert!(!json.contains("timeout_ms"));
519        assert!(!json.contains("transition"));
520    }
521
522    #[test]
523    fn server_to_edge_device_connect_disconnect_roundtrip() {
524        let connect = ServerToEdge::DeviceConnect {
525            device_type: "nuimo".into(),
526            device_id: "dev-1".into(),
527        };
528        let json = serde_json::to_string(&connect).unwrap();
529        assert!(json.contains("\"type\":\"device_connect\""));
530        let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
531        match parsed {
532            ServerToEdge::DeviceConnect {
533                device_type,
534                device_id,
535            } => {
536                assert_eq!(device_type, "nuimo");
537                assert_eq!(device_id, "dev-1");
538            }
539            _ => panic!("wrong variant"),
540        }
541
542        let disconnect = ServerToEdge::DeviceDisconnect {
543            device_type: "nuimo".into(),
544            device_id: "dev-1".into(),
545        };
546        let json = serde_json::to_string(&disconnect).unwrap();
547        assert!(json.contains("\"type\":\"device_disconnect\""));
548        let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
549        match parsed {
550            ServerToEdge::DeviceDisconnect {
551                device_type,
552                device_id,
553            } => {
554                assert_eq!(device_type, "nuimo");
555                assert_eq!(device_id, "dev-1");
556            }
557            _ => panic!("wrong variant"),
558        }
559    }
560
561    #[test]
562    fn edge_to_server_command_roundtrip() {
563        let ok = EdgeToServer::Command {
564            service_type: "roon".into(),
565            target: "zone-1".into(),
566            intent: "volume_change".into(),
567            params: serde_json::json!({"delta": 3}),
568            result: CommandResult::Ok,
569            latency_ms: Some(42),
570            output_id: None,
571        };
572        let json = serde_json::to_string(&ok).unwrap();
573        assert!(json.contains("\"type\":\"command\""));
574        assert!(json.contains("\"kind\":\"ok\""));
575        assert!(json.contains("\"latency_ms\":42"));
576        assert!(!json.contains("output_id"));
577        let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
578        match parsed {
579            EdgeToServer::Command { intent, result, .. } => {
580                assert_eq!(intent, "volume_change");
581                assert!(matches!(result, CommandResult::Ok));
582            }
583            _ => panic!("wrong variant"),
584        }
585
586        let err = EdgeToServer::Command {
587            service_type: "hue".into(),
588            target: "light-1".into(),
589            intent: "on_off".into(),
590            params: serde_json::json!({"on": true}),
591            result: CommandResult::Err {
592                message: "bridge timeout".into(),
593            },
594            latency_ms: None,
595            output_id: None,
596        };
597        let json = serde_json::to_string(&err).unwrap();
598        assert!(json.contains("\"kind\":\"err\""));
599        assert!(json.contains("\"message\":\"bridge timeout\""));
600    }
601
602    #[test]
603    fn edge_to_server_error_roundtrip() {
604        let msg = EdgeToServer::Error {
605            context: "hue.bridge".into(),
606            message: "connection refused".into(),
607            severity: ErrorSeverity::Error,
608        };
609        let json = serde_json::to_string(&msg).unwrap();
610        assert!(json.contains("\"type\":\"error\""));
611        assert!(json.contains("\"severity\":\"error\""));
612    }
613
614    #[test]
615    fn ui_frame_edge_status_roundtrip() {
616        let full = UiFrame::EdgeStatus {
617            edge_id: "air".into(),
618            wifi: Some(82),
619            latency_ms: Some(15),
620        };
621        let json = serde_json::to_string(&full).unwrap();
622        assert!(json.contains("\"type\":\"edge_status\""));
623        assert!(json.contains("\"wifi\":82"));
624        assert!(json.contains("\"latency_ms\":15"));
625        let parsed: UiFrame = serde_json::from_str(&json).unwrap();
626        match parsed {
627            UiFrame::EdgeStatus {
628                edge_id,
629                wifi,
630                latency_ms,
631            } => {
632                assert_eq!(edge_id, "air");
633                assert_eq!(wifi, Some(82));
634                assert_eq!(latency_ms, Some(15));
635            }
636            _ => panic!("wrong variant"),
637        }
638
639        // Both metrics absent → only edge_id on the wire.
640        let empty = UiFrame::EdgeStatus {
641            edge_id: "air".into(),
642            wifi: None,
643            latency_ms: None,
644        };
645        let json = serde_json::to_string(&empty).unwrap();
646        assert!(json.contains("\"edge_id\":\"air\""));
647        assert!(!json.contains("wifi"));
648        assert!(!json.contains("latency_ms"));
649    }
650
651    #[test]
652    fn ui_frame_command_and_error_roundtrip() {
653        let cmd = UiFrame::Command {
654            edge_id: "air".into(),
655            service_type: "roon".into(),
656            target: "zone-1".into(),
657            intent: "play_pause".into(),
658            params: serde_json::json!({}),
659            result: CommandResult::Ok,
660            latency_ms: Some(18),
661            output_id: None,
662            at: "2026-04-23T12:00:00Z".into(),
663        };
664        let json = serde_json::to_string(&cmd).unwrap();
665        assert!(json.contains("\"type\":\"command\""));
666        let _: UiFrame = serde_json::from_str(&json).unwrap();
667
668        let err = UiFrame::Error {
669            edge_id: "air".into(),
670            context: "roon.client".into(),
671            message: "pair lost".into(),
672            severity: ErrorSeverity::Warn,
673            at: "2026-04-23T12:00:00Z".into(),
674        };
675        let json = serde_json::to_string(&err).unwrap();
676        assert!(json.contains("\"type\":\"error\""));
677        assert!(json.contains("\"severity\":\"warn\""));
678        let _: UiFrame = serde_json::from_str(&json).unwrap();
679    }
680
681    #[test]
682    fn edge_to_server_edge_status_roundtrip() {
683        let with_wifi = EdgeToServer::EdgeStatus { wifi: Some(73) };
684        let json = serde_json::to_string(&with_wifi).unwrap();
685        assert!(json.contains("\"type\":\"edge_status\""));
686        assert!(json.contains("\"wifi\":73"));
687        let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
688        match parsed {
689            EdgeToServer::EdgeStatus { wifi } => assert_eq!(wifi, Some(73)),
690            _ => panic!("wrong variant"),
691        }
692
693        // None should be elided from the wire form.
694        let no_wifi = EdgeToServer::EdgeStatus { wifi: None };
695        let json = serde_json::to_string(&no_wifi).unwrap();
696        assert!(json.contains("\"type\":\"edge_status\""));
697        assert!(!json.contains("wifi"));
698        let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
699        match parsed {
700            EdgeToServer::EdgeStatus { wifi } => assert_eq!(wifi, None),
701            _ => panic!("wrong variant"),
702        }
703    }
704
705    #[test]
706    fn edge_to_server_state_with_optional_output_id() {
707        let msg = EdgeToServer::State {
708            service_type: "roon".into(),
709            target: "zone-1".into(),
710            property: "volume".into(),
711            output_id: Some("output-1".into()),
712            value: serde_json::json!(50),
713        };
714        let json = serde_json::to_string(&msg).unwrap();
715        assert!(json.contains("\"output_id\":\"output-1\""));
716
717        let msg2 = EdgeToServer::State {
718            service_type: "roon".into(),
719            target: "zone-1".into(),
720            property: "playback".into(),
721            output_id: None,
722            value: serde_json::json!("playing"),
723        };
724        let json2 = serde_json::to_string(&msg2).unwrap();
725        assert!(!json2.contains("output_id"));
726    }
727}