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 Ping,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(tag = "type", rename_all = "snake_case")]
112pub enum EdgeToServer {
113 Hello {
115 edge_id: String,
116 version: String,
117 capabilities: Vec<String>,
118 },
119 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 DeviceState {
130 device_type: String,
131 device_id: String,
132 property: String,
133 value: serde_json::Value,
134 },
135 Pong,
137 SwitchTarget {
143 mapping_id: Uuid,
144 service_target: String,
145 },
146 Command {
152 service_type: String,
153 target: String,
154 intent: String,
156 #[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 Error {
171 context: String,
172 message: String,
173 severity: ErrorSeverity,
174 },
175 EdgeStatus {
180 #[serde(skip_serializing_if = "Option::is_none")]
185 wifi: Option<u8>,
186 },
187 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 SwitchActiveConnection {
213 device_type: String,
214 device_id: String,
215 active_mapping_id: Uuid,
216 },
217}
218
219#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct EdgeConfig {
246 pub edge_id: String,
247 pub mappings: Vec<Mapping>,
248 #[serde(default)]
253 pub glyphs: Vec<Glyph>,
254 #[serde(default)]
259 pub device_cycles: Vec<DeviceCycle>,
260}
261
262#[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#[derive(Debug, Clone, Serialize, Deserialize)]
276#[serde(tag = "type", rename_all = "snake_case")]
277pub enum UiFrame {
278 Snapshot { snapshot: UiSnapshot },
280 EdgeOnline { edge: EdgeInfo },
282 EdgeOffline { edge_id: String },
284 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 DeviceState {
296 edge_id: String,
297 device_type: String,
298 device_id: String,
299 property: String,
300 value: serde_json::Value,
301 },
302 MappingChanged {
304 mapping_id: Uuid,
305 op: PatchOp,
306 mapping: Option<Mapping>,
307 },
308 GlyphsChanged { glyphs: Vec<Glyph> },
310 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 at: String,
326 },
327 Error {
329 edge_id: String,
330 context: String,
331 message: String,
332 severity: ErrorSeverity,
333 at: String,
335 },
336 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 DeviceCycleChanged {
354 device_type: String,
355 device_id: String,
356 op: PatchOp,
357 cycle: Option<DeviceCycle>,
358 },
359}
360
361#[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 #[serde(default)]
374 pub device_cycles: Vec<DeviceCycle>,
375}
376
377#[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 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 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#[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 #[serde(default)]
430 pub target_candidates: Vec<TargetCandidate>,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub target_switch_on: Option<String>,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct TargetCandidate {
454 pub target: String,
456 #[serde(default)]
458 pub label: String,
459 pub glyph: String,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub service_type: Option<String>,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub routes: Option<Vec<Route>>,
472}
473
474impl Mapping {
475 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
505pub struct DeviceCycle {
506 pub device_type: String,
507 pub device_id: String,
508 pub mapping_ids: Vec<Uuid>,
511 #[serde(default)]
515 pub active_mapping_id: Option<Uuid>,
516 #[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#[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#[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 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 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 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 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 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 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}