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