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    /// Incremental cycle change for one device. Sent to the edge that owns
90    /// the device whenever the server applies a cycle CRUD operation.
91    /// `op == Delete` removes the cycle on the edge (mappings revert to the
92    /// no-cycle "fire all matching" behavior).
93    DeviceCyclePatch { cycle: DeviceCycle, op: PatchOp },
94    /// Server-initiated active-connection switch for a device's cycle. The
95    /// receiving edge updates its local cycle snapshot and routes input
96    /// only through `active_mapping_id` going forward (until the next
97    /// switch). Originates from a REST `POST .../cycle/switch`, an
98    /// `EdgeToServer::SwitchActiveConnection` from a peer edge that
99    /// observed the cycle gesture, or an automatic advance.
100    SwitchActiveConnection {
101        device_type: String,
102        device_id: String,
103        active_mapping_id: Uuid,
104    },
105    /// Periodic keepalive to keep NAT/proxies open and detect half-open TCP.
106    Ping,
107}
108
109/// Frames sent from an `edge-agent` to `weave-server`.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(tag = "type", rename_all = "snake_case")]
112pub enum EdgeToServer {
113    /// First frame after connect. Declares identity and adapter capabilities.
114    Hello {
115        edge_id: String,
116        version: String,
117        capabilities: Vec<String>,
118    },
119    /// State update for a service target (e.g. Roon zone playback / volume).
120    State {
121        service_type: String,
122        target: String,
123        property: String,
124        #[serde(skip_serializing_if = "Option::is_none")]
125        output_id: Option<String>,
126        value: serde_json::Value,
127    },
128    /// State update for a device (battery, RSSI, connected).
129    DeviceState {
130        device_type: String,
131        device_id: String,
132        property: String,
133        value: serde_json::Value,
134    },
135    /// Reply to server `Ping`.
136    Pong,
137    /// The edge committed a target switch via on-device selection mode.
138    /// Server replies by calling the same code path as `POST
139    /// /api/mappings/:id/target`: persist the new `service_target`, then
140    /// broadcast a `ConfigPatch` upsert back to all edges (including the
141    /// sender) and a `MappingChanged` to UI subscribers.
142    SwitchTarget {
143        mapping_id: Uuid,
144        service_target: String,
145    },
146    /// A command that the edge's adapter emitted to an external service
147    /// (Roon MOO RPC, Hue REST, …). One frame per `adapter.send_intent`
148    /// call, carrying the outcome and measured latency so the UI live
149    /// stream can show "sent → ok (42ms)" rows alongside input and
150    /// state-echo rows.
151    Command {
152        service_type: String,
153        target: String,
154        /// Snake-case intent name (`volume_change`, `play_pause`, …).
155        intent: String,
156        /// Intent parameters serialized as JSON. Shape matches the
157        /// `weave-engine::Intent` discriminant's payload.
158        #[serde(default)]
159        params: serde_json::Value,
160        result: CommandResult,
161        #[serde(skip_serializing_if = "Option::is_none")]
162        latency_ms: Option<u32>,
163        #[serde(skip_serializing_if = "Option::is_none")]
164        output_id: Option<String>,
165    },
166    /// Adapter-level or routing-level error not tied to a specific
167    /// command (bridge disconnect, auth token expired, pairing lost).
168    /// Command-level failures use `Command { result: Err { .. } }`
169    /// instead — `Error` is for ambient conditions.
170    Error {
171        context: String,
172        message: String,
173        severity: ErrorSeverity,
174    },
175    /// Periodic edge-side metrics. Emitted on a fixed cadence (typically
176    /// every 10 s) so the server can surface edge health in `/ws/ui`
177    /// dashboards. Server-side latency is measured separately from
178    /// `Ping`/`Pong` round trips and is not carried here.
179    EdgeStatus {
180        /// Wifi signal strength normalized to 0..=100 percent. `None`
181        /// when the platform doesn't expose a signal-strength API to
182        /// user code, when the host has no wifi adapter, or when the
183        /// API call failed (entitlement missing, permission denied).
184        #[serde(skip_serializing_if = "Option::is_none")]
185        wifi: Option<u8>,
186    },
187    /// Edge routed an input locally but has no adapter for the resulting
188    /// `service_type` and asks the server to forward to a capable peer.
189    /// The server resolves a target edge from `Hello` capabilities and
190    /// re-emits as `ServerToEdge::DispatchIntent`. Wire shape mirrors
191    /// `Command` (intent name + params) so the same reassembly logic
192    /// works on both ends.
193    ///
194    /// The originating edge does NOT emit a `Command` frame for
195    /// forwarded intents — the executing edge does that after running
196    /// the adapter, so latency measurement reflects the full path.
197    DispatchIntent {
198        service_type: String,
199        service_target: String,
200        intent: String,
201        #[serde(default)]
202        params: serde_json::Value,
203        #[serde(skip_serializing_if = "Option::is_none")]
204        output_id: Option<String>,
205    },
206    /// The edge advanced its local cycle (typically via `cycle_gesture`
207    /// firing on the device) and asks the server to persist the new
208    /// active. Server applies the change, broadcasts
209    /// `UiFrame::DeviceCycleChanged` to web UIs, and echoes
210    /// `ServerToEdge::SwitchActiveConnection` to other edges that observe
211    /// the same device so they stay in sync.
212    SwitchActiveConnection {
213        device_type: String,
214        device_id: String,
215        active_mapping_id: Uuid,
216    },
217}
218
219/// Outcome of an `EdgeToServer::Command`.
220#[derive(Debug, Clone, Serialize, Deserialize)]
221#[serde(tag = "kind", rename_all = "snake_case")]
222pub enum CommandResult {
223    Ok,
224    Err { message: String },
225}
226
227/// Severity classification for `EdgeToServer::Error` and `UiFrame::Error`.
228#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
229#[serde(rename_all = "snake_case")]
230pub enum ErrorSeverity {
231    Warn,
232    Error,
233    Fatal,
234}
235
236#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
237#[serde(rename_all = "snake_case")]
238pub enum PatchOp {
239    Upsert,
240    Delete,
241}
242
243/// Complete config for one edge, pushed as a `ConfigFull` frame.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct EdgeConfig {
246    pub edge_id: String,
247    pub mappings: Vec<Mapping>,
248    /// Named glyph patterns the edge should use when rendering feedback.
249    /// Consumers look up by `name`. Entries with `builtin == true` have an
250    /// empty `pattern` and are expected to be rendered programmatically by
251    /// the consumer (e.g. `volume_bar` scales with percentage).
252    #[serde(default)]
253    pub glyphs: Vec<Glyph>,
254    /// Device-level cycle rows for this edge's devices. Empty when no
255    /// device under this edge has an active cycle. Older edge-agents
256    /// receiving this field as unknown deserialize an empty vec via the
257    /// default annotation.
258    #[serde(default)]
259    pub device_cycles: Vec<DeviceCycle>,
260}
261
262/// A named Nuimo LED glyph. `pattern` is a 9x9 ASCII grid compatible with
263/// `nuimo::Glyph::from_str` (`*` = LED on, anything else = off, rows
264/// separated by `\n`).
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct Glyph {
267    pub name: String,
268    #[serde(default)]
269    pub pattern: String,
270    #[serde(default)]
271    pub builtin: bool,
272}
273
274/// Frames sent from `weave-server` to a Web UI client on `/ws/ui`.
275#[derive(Debug, Clone, Serialize, Deserialize)]
276#[serde(tag = "type", rename_all = "snake_case")]
277pub enum UiFrame {
278    /// Initial full snapshot, pushed once on connect.
279    Snapshot { snapshot: UiSnapshot },
280    /// An edge completed its `Hello` handshake or has otherwise come online.
281    EdgeOnline { edge: EdgeInfo },
282    /// An edge has disconnected (ws closed).
283    EdgeOffline { edge_id: String },
284    /// One service-state update from a connected edge.
285    ServiceState {
286        edge_id: String,
287        service_type: String,
288        target: String,
289        property: String,
290        #[serde(skip_serializing_if = "Option::is_none")]
291        output_id: Option<String>,
292        value: serde_json::Value,
293    },
294    /// One device-state update from a connected edge (battery, RSSI, etc.).
295    DeviceState {
296        edge_id: String,
297        device_type: String,
298        device_id: String,
299        property: String,
300        value: serde_json::Value,
301    },
302    /// Mapping CRUD happened on the server. UIs replace their copy.
303    MappingChanged {
304        mapping_id: Uuid,
305        op: PatchOp,
306        mapping: Option<Mapping>,
307    },
308    /// The glyph set changed. UIs refresh their registry.
309    GlyphsChanged { glyphs: Vec<Glyph> },
310    /// Fan-out of an edge-emitted `Command`. Transient — never stored in
311    /// `UiSnapshot`; dashboards that open after the fact will not see it.
312    Command {
313        edge_id: String,
314        service_type: String,
315        target: String,
316        intent: String,
317        #[serde(default)]
318        params: serde_json::Value,
319        result: CommandResult,
320        #[serde(skip_serializing_if = "Option::is_none")]
321        latency_ms: Option<u32>,
322        #[serde(skip_serializing_if = "Option::is_none")]
323        output_id: Option<String>,
324        /// RFC3339 timestamp assigned by the server on fan-out.
325        at: String,
326    },
327    /// Fan-out of an edge-emitted `Error`. Transient.
328    Error {
329        edge_id: String,
330        context: String,
331        message: String,
332        severity: ErrorSeverity,
333        /// RFC3339 timestamp assigned by the server on fan-out.
334        at: String,
335    },
336    /// Periodic edge metrics. Carries the latest known wifi signal
337    /// strength (edge-reported) and round-trip latency (server-measured
338    /// from `Ping`/`Pong`). Each field is `None` when unknown:
339    /// either because no measurement has arrived yet, or because the
340    /// edge cannot read the value on its platform. Emitted whenever
341    /// either field changes; UIs apply it as a partial update on the
342    /// matching `edge_id` row.
343    EdgeStatus {
344        edge_id: String,
345        #[serde(skip_serializing_if = "Option::is_none")]
346        wifi: Option<u8>,
347        #[serde(skip_serializing_if = "Option::is_none")]
348        latency_ms: Option<u32>,
349    },
350    /// Device-cycle CRUD broadcast. UIs replace their copy. `cycle` is
351    /// `None` when `op == Delete` (the cycle row was removed and the
352    /// device's mappings revert to the all-fire default).
353    DeviceCycleChanged {
354        device_type: String,
355        device_id: String,
356        op: PatchOp,
357        cycle: Option<DeviceCycle>,
358    },
359}
360
361/// Initial full state sent on `/ws/ui` connect. Subsequent changes arrive
362/// as `UiFrame` variants.
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct UiSnapshot {
365    pub edges: Vec<EdgeInfo>,
366    pub service_states: Vec<ServiceStateEntry>,
367    pub device_states: Vec<DeviceStateEntry>,
368    pub mappings: Vec<Mapping>,
369    pub glyphs: Vec<Glyph>,
370    /// All device cycles known to the server. Empty when no device has
371    /// a cycle. `#[serde(default)]` so older clients without the field
372    /// deserialize as an empty vec.
373    #[serde(default)]
374    pub device_cycles: Vec<DeviceCycle>,
375}
376
377/// Identity + status for one connected (or previously-seen) edge.
378#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct EdgeInfo {
380    pub edge_id: String,
381    pub online: bool,
382    pub version: String,
383    pub capabilities: Vec<String>,
384    /// RFC3339 timestamp.
385    pub last_seen: String,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct ServiceStateEntry {
390    pub edge_id: String,
391    pub service_type: String,
392    pub target: String,
393    pub property: String,
394    #[serde(skip_serializing_if = "Option::is_none")]
395    pub output_id: Option<String>,
396    pub value: serde_json::Value,
397    /// RFC3339 timestamp of last update.
398    pub updated_at: String,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct DeviceStateEntry {
403    pub edge_id: String,
404    pub device_type: String,
405    pub device_id: String,
406    pub property: String,
407    pub value: serde_json::Value,
408    pub updated_at: String,
409}
410
411/// A device-to-service mapping. Mirrors the structure already used by
412/// `weave-server`'s REST API. `edge_id` is new; all other fields retain
413/// their existing semantics.
414#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct Mapping {
416    pub mapping_id: Uuid,
417    pub edge_id: String,
418    pub device_type: String,
419    pub device_id: String,
420    pub service_type: String,
421    pub service_target: String,
422    pub routes: Vec<Route>,
423    #[serde(default)]
424    pub feedback: Vec<FeedbackRule>,
425    #[serde(default = "default_true")]
426    pub active: bool,
427    /// Ordered list of candidate `service_target` values the edge can cycle
428    /// through at runtime. Empty = switching disabled.
429    #[serde(default)]
430    pub target_candidates: Vec<TargetCandidate>,
431    /// Input primitive (snake-case `InputType` name, e.g. `"long_press"`)
432    /// that enters selection mode on the device. `None` = feature disabled
433    /// for this mapping, regardless of `target_candidates`.
434    ///
435    /// MVP constraint (not enforced in-schema): at most one mapping per
436    /// `(edge_id, device_id)` should set this; the edge router picks the
437    /// first encountered if multiple are set.
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub target_switch_on: Option<String>,
440}
441
442/// One entry in `Mapping::target_candidates`. During selection mode the
443/// device displays `glyph` and, on confirm, the mapping's `service_target`
444/// is replaced with `target`.
445///
446/// Optional `service_type` and `routes` overrides let a single mapping's
447/// candidates straddle services — e.g. `long_press` cycles between a Roon
448/// zone (rotate→volume_change) and a Hue light (rotate→brightness_change),
449/// each with its own route table. When absent, the candidate inherits the
450/// mapping's `service_type` / `routes`, which matches pre-override behavior
451/// so historical mappings deserialize unchanged.
452#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct TargetCandidate {
454    /// The `service_target` value to switch to (e.g. a Roon zone ID).
455    pub target: String,
456    /// Human-readable label for the UI only — the edge does not need it.
457    #[serde(default)]
458    pub label: String,
459    /// Name of a glyph in the edge's glyph registry to display while this
460    /// candidate is highlighted in selection mode.
461    pub glyph: String,
462    /// Override the mapping's `service_type` when this candidate is active.
463    /// `None` = inherit from the parent `Mapping::service_type`.
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub service_type: Option<String>,
466    /// Override the mapping's `routes` when this candidate is active. Required
467    /// in practice whenever `service_type` differs from the mapping's, because
468    /// intents are service-specific (Roon `volume_change` won't work against
469    /// a Hue target). `None` = inherit from the parent `Mapping::routes`.
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub routes: Option<Vec<Route>>,
472}
473
474impl Mapping {
475    /// Resolve the effective `(service_type, routes)` for a given target.
476    /// If `target` matches a `target_candidates` entry with overrides,
477    /// those win; otherwise the mapping's own fields are returned.
478    ///
479    /// Callers on the routing hot path should pass the currently active
480    /// `service_target` to get the right adapter + intent table for the
481    /// next emitted `RoutedIntent`.
482    pub fn effective_for<'a>(&'a self, target: &str) -> (&'a str, &'a [Route]) {
483        let candidate = self.target_candidates.iter().find(|c| c.target == target);
484        let service_type = candidate
485            .and_then(|c| c.service_type.as_deref())
486            .unwrap_or(self.service_type.as_str());
487        let routes = candidate
488            .and_then(|c| c.routes.as_deref())
489            .unwrap_or(self.routes.as_slice());
490        (service_type, routes)
491    }
492}
493
494/// Device-level Connection cycle. When a row exists for `(device_type,
495/// device_id)`, only the mapping identified by `active_mapping_id` routes
496/// input for that device — the other mappings in `mapping_ids` sit dormant
497/// until cycled in. Mappings outside the cycle (i.e. not in `mapping_ids`)
498/// are unaffected and continue to fire normally.
499///
500/// `cycle_gesture`, if set, is the input primitive that advances the active
501/// pointer to the next entry in `mapping_ids` order. Both edge and server
502/// emit `SwitchActiveConnection` to keep state in sync; the receiver applies
503/// the change idempotently.
504#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
505pub struct DeviceCycle {
506    pub device_type: String,
507    pub device_id: String,
508    /// Mappings to rotate through, in cycle order. The cycle gesture
509    /// advances active to the next entry, wrapping at the end.
510    pub mapping_ids: Vec<Uuid>,
511    /// Currently-active mapping (must be one of `mapping_ids`). `None`
512    /// when the cycle is empty (transient — the server normally clears
513    /// the cycle row in that case).
514    #[serde(default)]
515    pub active_mapping_id: Option<Uuid>,
516    /// Snake-case `InputType` name (e.g. `"swipe_up"`, `"long_press"`)
517    /// that advances active. `None` = the cycle exists but only switches
518    /// via API; no on-device gesture binding.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub cycle_gesture: Option<String>,
521}
522
523fn default_true() -> bool {
524    true
525}
526
527/// One input-to-intent route inside a mapping.
528#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct Route {
530    pub input: String,
531    pub intent: String,
532    #[serde(default)]
533    pub params: BTreeMap<String, serde_json::Value>,
534}
535
536/// Feedback rule: service state → device visual feedback.
537#[derive(Debug, Clone, Serialize, Deserialize)]
538pub struct FeedbackRule {
539    pub state: String,
540    pub feedback_type: String,
541    pub mapping: serde_json::Value,
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    #[test]
549    fn server_to_edge_config_full_roundtrip() {
550        let msg = ServerToEdge::ConfigFull {
551            config: EdgeConfig {
552                edge_id: "living-room".into(),
553                mappings: vec![Mapping {
554                    mapping_id: Uuid::nil(),
555                    edge_id: "living-room".into(),
556                    device_type: "nuimo".into(),
557                    device_id: "C3:81:DF:4E:FF:6A".into(),
558                    service_type: "roon".into(),
559                    service_target: "zone-1".into(),
560                    routes: vec![Route {
561                        input: "rotate".into(),
562                        intent: "volume_change".into(),
563                        params: BTreeMap::from([("damping".into(), serde_json::json!(80))]),
564                    }],
565                    feedback: vec![],
566                    active: true,
567                    target_candidates: vec![],
568                    target_switch_on: None,
569                }],
570                glyphs: vec![Glyph {
571                    name: "play".into(),
572                    pattern: "    *    \n     **  ".into(),
573                    builtin: false,
574                }],
575                device_cycles: vec![],
576            },
577        };
578        let json = serde_json::to_string(&msg).unwrap();
579        assert!(json.contains("\"type\":\"config_full\""));
580        assert!(json.contains("\"edge_id\":\"living-room\""));
581
582        let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
583        match parsed {
584            ServerToEdge::ConfigFull { config } => {
585                assert_eq!(config.edge_id, "living-room");
586                assert_eq!(config.mappings.len(), 1);
587            }
588            _ => panic!("wrong variant"),
589        }
590    }
591
592    #[test]
593    fn server_to_edge_display_glyph_roundtrip() {
594        let msg = ServerToEdge::DisplayGlyph {
595            device_type: "nuimo".into(),
596            device_id: "C3:81:DF:4E:FF:6A".into(),
597            pattern: "    *    \n  *****  ".into(),
598            brightness: Some(0.5),
599            timeout_ms: Some(2000),
600            transition: Some("cross_fade".into()),
601        };
602        let json = serde_json::to_string(&msg).unwrap();
603        assert!(json.contains("\"type\":\"display_glyph\""));
604        assert!(json.contains("\"device_type\":\"nuimo\""));
605        assert!(json.contains("\"brightness\":0.5"));
606
607        let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
608        match parsed {
609            ServerToEdge::DisplayGlyph {
610                device_type,
611                device_id,
612                pattern,
613                brightness,
614                timeout_ms,
615                transition,
616            } => {
617                assert_eq!(device_type, "nuimo");
618                assert_eq!(device_id, "C3:81:DF:4E:FF:6A");
619                assert!(pattern.contains('*'));
620                assert_eq!(brightness, Some(0.5));
621                assert_eq!(timeout_ms, Some(2000));
622                assert_eq!(transition.as_deref(), Some("cross_fade"));
623            }
624            _ => panic!("wrong variant"),
625        }
626
627        // Optional fields elided when None.
628        let minimal = ServerToEdge::DisplayGlyph {
629            device_type: "nuimo".into(),
630            device_id: "dev-1".into(),
631            pattern: "*".into(),
632            brightness: None,
633            timeout_ms: None,
634            transition: None,
635        };
636        let json = serde_json::to_string(&minimal).unwrap();
637        assert!(!json.contains("brightness"));
638        assert!(!json.contains("timeout_ms"));
639        assert!(!json.contains("transition"));
640    }
641
642    #[test]
643    fn server_to_edge_device_connect_disconnect_roundtrip() {
644        let connect = ServerToEdge::DeviceConnect {
645            device_type: "nuimo".into(),
646            device_id: "dev-1".into(),
647        };
648        let json = serde_json::to_string(&connect).unwrap();
649        assert!(json.contains("\"type\":\"device_connect\""));
650        let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
651        match parsed {
652            ServerToEdge::DeviceConnect {
653                device_type,
654                device_id,
655            } => {
656                assert_eq!(device_type, "nuimo");
657                assert_eq!(device_id, "dev-1");
658            }
659            _ => panic!("wrong variant"),
660        }
661
662        let disconnect = ServerToEdge::DeviceDisconnect {
663            device_type: "nuimo".into(),
664            device_id: "dev-1".into(),
665        };
666        let json = serde_json::to_string(&disconnect).unwrap();
667        assert!(json.contains("\"type\":\"device_disconnect\""));
668        let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
669        match parsed {
670            ServerToEdge::DeviceDisconnect {
671                device_type,
672                device_id,
673            } => {
674                assert_eq!(device_type, "nuimo");
675                assert_eq!(device_id, "dev-1");
676            }
677            _ => panic!("wrong variant"),
678        }
679    }
680
681    #[test]
682    fn edge_to_server_command_roundtrip() {
683        let ok = EdgeToServer::Command {
684            service_type: "roon".into(),
685            target: "zone-1".into(),
686            intent: "volume_change".into(),
687            params: serde_json::json!({"delta": 3}),
688            result: CommandResult::Ok,
689            latency_ms: Some(42),
690            output_id: None,
691        };
692        let json = serde_json::to_string(&ok).unwrap();
693        assert!(json.contains("\"type\":\"command\""));
694        assert!(json.contains("\"kind\":\"ok\""));
695        assert!(json.contains("\"latency_ms\":42"));
696        assert!(!json.contains("output_id"));
697        let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
698        match parsed {
699            EdgeToServer::Command { intent, result, .. } => {
700                assert_eq!(intent, "volume_change");
701                assert!(matches!(result, CommandResult::Ok));
702            }
703            _ => panic!("wrong variant"),
704        }
705
706        let err = EdgeToServer::Command {
707            service_type: "hue".into(),
708            target: "light-1".into(),
709            intent: "on_off".into(),
710            params: serde_json::json!({"on": true}),
711            result: CommandResult::Err {
712                message: "bridge timeout".into(),
713            },
714            latency_ms: None,
715            output_id: None,
716        };
717        let json = serde_json::to_string(&err).unwrap();
718        assert!(json.contains("\"kind\":\"err\""));
719        assert!(json.contains("\"message\":\"bridge timeout\""));
720    }
721
722    #[test]
723    fn edge_to_server_error_roundtrip() {
724        let msg = EdgeToServer::Error {
725            context: "hue.bridge".into(),
726            message: "connection refused".into(),
727            severity: ErrorSeverity::Error,
728        };
729        let json = serde_json::to_string(&msg).unwrap();
730        assert!(json.contains("\"type\":\"error\""));
731        assert!(json.contains("\"severity\":\"error\""));
732    }
733
734    #[test]
735    fn ui_frame_edge_status_roundtrip() {
736        let full = UiFrame::EdgeStatus {
737            edge_id: "air".into(),
738            wifi: Some(82),
739            latency_ms: Some(15),
740        };
741        let json = serde_json::to_string(&full).unwrap();
742        assert!(json.contains("\"type\":\"edge_status\""));
743        assert!(json.contains("\"wifi\":82"));
744        assert!(json.contains("\"latency_ms\":15"));
745        let parsed: UiFrame = serde_json::from_str(&json).unwrap();
746        match parsed {
747            UiFrame::EdgeStatus {
748                edge_id,
749                wifi,
750                latency_ms,
751            } => {
752                assert_eq!(edge_id, "air");
753                assert_eq!(wifi, Some(82));
754                assert_eq!(latency_ms, Some(15));
755            }
756            _ => panic!("wrong variant"),
757        }
758
759        // Both metrics absent → only edge_id on the wire.
760        let empty = UiFrame::EdgeStatus {
761            edge_id: "air".into(),
762            wifi: None,
763            latency_ms: None,
764        };
765        let json = serde_json::to_string(&empty).unwrap();
766        assert!(json.contains("\"edge_id\":\"air\""));
767        assert!(!json.contains("wifi"));
768        assert!(!json.contains("latency_ms"));
769    }
770
771    #[test]
772    fn ui_frame_command_and_error_roundtrip() {
773        let cmd = UiFrame::Command {
774            edge_id: "air".into(),
775            service_type: "roon".into(),
776            target: "zone-1".into(),
777            intent: "play_pause".into(),
778            params: serde_json::json!({}),
779            result: CommandResult::Ok,
780            latency_ms: Some(18),
781            output_id: None,
782            at: "2026-04-23T12:00:00Z".into(),
783        };
784        let json = serde_json::to_string(&cmd).unwrap();
785        assert!(json.contains("\"type\":\"command\""));
786        let _: UiFrame = serde_json::from_str(&json).unwrap();
787
788        let err = UiFrame::Error {
789            edge_id: "air".into(),
790            context: "roon.client".into(),
791            message: "pair lost".into(),
792            severity: ErrorSeverity::Warn,
793            at: "2026-04-23T12:00:00Z".into(),
794        };
795        let json = serde_json::to_string(&err).unwrap();
796        assert!(json.contains("\"type\":\"error\""));
797        assert!(json.contains("\"severity\":\"warn\""));
798        let _: UiFrame = serde_json::from_str(&json).unwrap();
799    }
800
801    #[test]
802    fn edge_to_server_edge_status_roundtrip() {
803        let with_wifi = EdgeToServer::EdgeStatus { wifi: Some(73) };
804        let json = serde_json::to_string(&with_wifi).unwrap();
805        assert!(json.contains("\"type\":\"edge_status\""));
806        assert!(json.contains("\"wifi\":73"));
807        let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
808        match parsed {
809            EdgeToServer::EdgeStatus { wifi } => assert_eq!(wifi, Some(73)),
810            _ => panic!("wrong variant"),
811        }
812
813        // None should be elided from the wire form.
814        let no_wifi = EdgeToServer::EdgeStatus { wifi: None };
815        let json = serde_json::to_string(&no_wifi).unwrap();
816        assert!(json.contains("\"type\":\"edge_status\""));
817        assert!(!json.contains("wifi"));
818        let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
819        match parsed {
820            EdgeToServer::EdgeStatus { wifi } => assert_eq!(wifi, None),
821            _ => panic!("wrong variant"),
822        }
823    }
824
825    #[test]
826    fn edge_to_server_state_with_optional_output_id() {
827        let msg = EdgeToServer::State {
828            service_type: "roon".into(),
829            target: "zone-1".into(),
830            property: "volume".into(),
831            output_id: Some("output-1".into()),
832            value: serde_json::json!(50),
833        };
834        let json = serde_json::to_string(&msg).unwrap();
835        assert!(json.contains("\"output_id\":\"output-1\""));
836
837        let msg2 = EdgeToServer::State {
838            service_type: "roon".into(),
839            target: "zone-1".into(),
840            property: "playback".into(),
841            output_id: None,
842            value: serde_json::json!("playing"),
843        };
844        let json2 = serde_json::to_string(&msg2).unwrap();
845        assert!(!json2.contains("output_id"));
846    }
847
848    #[test]
849    fn device_cycle_roundtrip() {
850        let m1 = Uuid::new_v4();
851        let m2 = Uuid::new_v4();
852        let cycle = DeviceCycle {
853            device_type: "nuimo".into(),
854            device_id: "C3:81:DF:4E:FF:6A".into(),
855            mapping_ids: vec![m1, m2],
856            active_mapping_id: Some(m1),
857            cycle_gesture: Some("swipe_up".into()),
858        };
859        let json = serde_json::to_string(&cycle).unwrap();
860        assert!(json.contains("\"device_type\":\"nuimo\""));
861        assert!(json.contains("\"cycle_gesture\":\"swipe_up\""));
862        let parsed: DeviceCycle = serde_json::from_str(&json).unwrap();
863        assert_eq!(parsed, cycle);
864
865        // Optional cycle_gesture elided when None.
866        let no_gesture = DeviceCycle {
867            cycle_gesture: None,
868            ..cycle.clone()
869        };
870        let json = serde_json::to_string(&no_gesture).unwrap();
871        assert!(!json.contains("cycle_gesture"));
872    }
873
874    #[test]
875    fn server_to_edge_device_cycle_patch_roundtrip() {
876        let m1 = Uuid::new_v4();
877        let msg = ServerToEdge::DeviceCyclePatch {
878            cycle: DeviceCycle {
879                device_type: "nuimo".into(),
880                device_id: "dev-1".into(),
881                mapping_ids: vec![m1],
882                active_mapping_id: Some(m1),
883                cycle_gesture: Some("swipe_up".into()),
884            },
885            op: PatchOp::Upsert,
886        };
887        let json = serde_json::to_string(&msg).unwrap();
888        assert!(json.contains("\"type\":\"device_cycle_patch\""));
889        assert!(json.contains("\"op\":\"upsert\""));
890        let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
891        match parsed {
892            ServerToEdge::DeviceCyclePatch { cycle, op } => {
893                assert_eq!(cycle.device_type, "nuimo");
894                assert!(matches!(op, PatchOp::Upsert));
895            }
896            _ => panic!("wrong variant"),
897        }
898    }
899
900    #[test]
901    fn server_to_edge_switch_active_connection_roundtrip() {
902        let m1 = Uuid::new_v4();
903        let msg = ServerToEdge::SwitchActiveConnection {
904            device_type: "nuimo".into(),
905            device_id: "dev-1".into(),
906            active_mapping_id: m1,
907        };
908        let json = serde_json::to_string(&msg).unwrap();
909        assert!(json.contains("\"type\":\"switch_active_connection\""));
910        let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
911        match parsed {
912            ServerToEdge::SwitchActiveConnection {
913                device_type,
914                device_id,
915                active_mapping_id,
916            } => {
917                assert_eq!(device_type, "nuimo");
918                assert_eq!(device_id, "dev-1");
919                assert_eq!(active_mapping_id, m1);
920            }
921            _ => panic!("wrong variant"),
922        }
923    }
924
925    #[test]
926    fn edge_to_server_switch_active_connection_roundtrip() {
927        let m1 = Uuid::new_v4();
928        let msg = EdgeToServer::SwitchActiveConnection {
929            device_type: "nuimo".into(),
930            device_id: "dev-1".into(),
931            active_mapping_id: m1,
932        };
933        let json = serde_json::to_string(&msg).unwrap();
934        assert!(json.contains("\"type\":\"switch_active_connection\""));
935        let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
936        match parsed {
937            EdgeToServer::SwitchActiveConnection {
938                active_mapping_id, ..
939            } => assert_eq!(active_mapping_id, m1),
940            _ => panic!("wrong variant"),
941        }
942    }
943
944    #[test]
945    fn ui_frame_device_cycle_changed_roundtrip() {
946        let m1 = Uuid::new_v4();
947        let upsert = UiFrame::DeviceCycleChanged {
948            device_type: "nuimo".into(),
949            device_id: "dev-1".into(),
950            op: PatchOp::Upsert,
951            cycle: Some(DeviceCycle {
952                device_type: "nuimo".into(),
953                device_id: "dev-1".into(),
954                mapping_ids: vec![m1],
955                active_mapping_id: Some(m1),
956                cycle_gesture: Some("swipe_up".into()),
957            }),
958        };
959        let json = serde_json::to_string(&upsert).unwrap();
960        assert!(json.contains("\"type\":\"device_cycle_changed\""));
961        assert!(json.contains("\"op\":\"upsert\""));
962        let _: UiFrame = serde_json::from_str(&json).unwrap();
963
964        let delete = UiFrame::DeviceCycleChanged {
965            device_type: "nuimo".into(),
966            device_id: "dev-1".into(),
967            op: PatchOp::Delete,
968            cycle: None,
969        };
970        let json = serde_json::to_string(&delete).unwrap();
971        assert!(json.contains("\"op\":\"delete\""));
972        assert!(json.contains("\"cycle\":null"));
973    }
974
975    #[test]
976    fn ui_snapshot_device_cycles_default_empty() {
977        // Older server payloads without the device_cycles field still parse.
978        let json = r#"{
979            "edges": [],
980            "service_states": [],
981            "device_states": [],
982            "mappings": [],
983            "glyphs": []
984        }"#;
985        let snap: UiSnapshot = serde_json::from_str(json).unwrap();
986        assert!(snap.device_cycles.is_empty());
987    }
988
989    #[test]
990    fn edge_config_device_cycles_default_empty() {
991        // Older ConfigFull payloads without device_cycles still parse.
992        let json = r#"{
993            "edge_id": "air",
994            "mappings": []
995        }"#;
996        let cfg: EdgeConfig = serde_json::from_str(json).unwrap();
997        assert!(cfg.device_cycles.is_empty());
998    }
999}