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