1use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use uuid::Uuid;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum ServerToEdge {
15 ConfigFull { config: EdgeConfig },
17 ConfigPatch {
19 mapping_id: Uuid,
20 op: PatchOp,
21 mapping: Option<Mapping>,
22 },
23 TargetSwitch {
25 mapping_id: Uuid,
26 service_target: String,
27 },
28 GlyphsUpdate { glyphs: Vec<Glyph> },
30 DisplayGlyph {
34 device_type: String,
35 device_id: String,
36 pattern: String,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 brightness: Option<f32>,
42 #[serde(skip_serializing_if = "Option::is_none")]
45 timeout_ms: Option<u32>,
46 #[serde(skip_serializing_if = "Option::is_none")]
49 transition: Option<String>,
50 },
51 DeviceConnect {
55 device_type: String,
56 device_id: String,
57 },
58 DeviceDisconnect {
62 device_type: String,
63 device_id: String,
64 },
65 DispatchIntent {
77 service_type: String,
78 service_target: String,
79 intent: String,
81 #[serde(default)]
85 params: serde_json::Value,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 output_id: Option<String>,
88 },
89 DeviceCyclePatch { cycle: DeviceCycle, op: PatchOp },
94 SwitchActiveConnection {
101 device_type: String,
102 device_id: String,
103 active_mapping_id: Uuid,
104 },
105 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 Ping,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(tag = "type", rename_all = "snake_case")]
130pub enum EdgeToServer {
131 Hello {
133 edge_id: String,
134 version: String,
135 capabilities: Vec<String>,
136 },
137 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 DeviceState {
148 device_type: String,
149 device_id: String,
150 property: String,
151 value: serde_json::Value,
152 },
153 Pong,
155 SwitchTarget {
161 mapping_id: Uuid,
162 service_target: String,
163 },
164 Command {
170 service_type: String,
171 target: String,
172 intent: String,
174 #[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 Error {
189 context: String,
190 message: String,
191 severity: ErrorSeverity,
192 },
193 EdgeStatus {
198 #[serde(skip_serializing_if = "Option::is_none")]
203 wifi: Option<u8>,
204 },
205 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 SwitchActiveConnection {
231 device_type: String,
232 device_id: String,
233 active_mapping_id: Uuid,
234 },
235}
236
237#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct EdgeConfig {
264 pub edge_id: String,
265 pub mappings: Vec<Mapping>,
266 #[serde(default)]
271 pub glyphs: Vec<Glyph>,
272 #[serde(default)]
277 pub device_cycles: Vec<DeviceCycle>,
278}
279
280#[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#[derive(Debug, Clone, Serialize, Deserialize)]
294#[serde(tag = "type", rename_all = "snake_case")]
295pub enum UiFrame {
296 Snapshot { snapshot: UiSnapshot },
298 EdgeOnline { edge: EdgeInfo },
300 EdgeOffline { edge_id: String },
302 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 DeviceState {
314 edge_id: String,
315 device_type: String,
316 device_id: String,
317 property: String,
318 value: serde_json::Value,
319 },
320 MappingChanged {
322 mapping_id: Uuid,
323 op: PatchOp,
324 mapping: Option<Mapping>,
325 },
326 GlyphsChanged { glyphs: Vec<Glyph> },
328 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 at: String,
344 },
345 Error {
347 edge_id: String,
348 context: String,
349 message: String,
350 severity: ErrorSeverity,
351 at: String,
353 },
354 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 DeviceCycleChanged {
372 device_type: String,
373 device_id: String,
374 op: PatchOp,
375 cycle: Option<DeviceCycle>,
376 },
377}
378
379#[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 #[serde(default)]
392 pub device_cycles: Vec<DeviceCycle>,
393}
394
395#[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 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 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#[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 #[serde(default)]
448 pub target_candidates: Vec<TargetCandidate>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
457 pub target_switch_on: Option<String>,
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct TargetCandidate {
472 pub target: String,
474 #[serde(default)]
476 pub label: String,
477 pub glyph: String,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub service_type: Option<String>,
484 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub routes: Option<Vec<Route>>,
490}
491
492impl Mapping {
493 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
523pub struct DeviceCycle {
524 pub device_type: String,
525 pub device_id: String,
526 pub mapping_ids: Vec<Uuid>,
529 #[serde(default)]
533 pub active_mapping_id: Option<Uuid>,
534 #[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#[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#[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 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 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 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 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 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 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 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}