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 Ping,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(tag = "type", rename_all = "snake_case")]
96pub enum EdgeToServer {
97 Hello {
99 edge_id: String,
100 version: String,
101 capabilities: Vec<String>,
102 },
103 State {
105 service_type: String,
106 target: String,
107 property: String,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 output_id: Option<String>,
110 value: serde_json::Value,
111 },
112 DeviceState {
114 device_type: String,
115 device_id: String,
116 property: String,
117 value: serde_json::Value,
118 },
119 Pong,
121 SwitchTarget {
127 mapping_id: Uuid,
128 service_target: String,
129 },
130 Command {
136 service_type: String,
137 target: String,
138 intent: String,
140 #[serde(default)]
143 params: serde_json::Value,
144 result: CommandResult,
145 #[serde(skip_serializing_if = "Option::is_none")]
146 latency_ms: Option<u32>,
147 #[serde(skip_serializing_if = "Option::is_none")]
148 output_id: Option<String>,
149 },
150 Error {
155 context: String,
156 message: String,
157 severity: ErrorSeverity,
158 },
159 EdgeStatus {
164 #[serde(skip_serializing_if = "Option::is_none")]
169 wifi: Option<u8>,
170 },
171 DispatchIntent {
182 service_type: String,
183 service_target: String,
184 intent: String,
185 #[serde(default)]
186 params: serde_json::Value,
187 #[serde(skip_serializing_if = "Option::is_none")]
188 output_id: Option<String>,
189 },
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
194#[serde(tag = "kind", rename_all = "snake_case")]
195pub enum CommandResult {
196 Ok,
197 Err { message: String },
198}
199
200#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum ErrorSeverity {
204 Warn,
205 Error,
206 Fatal,
207}
208
209#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum PatchOp {
212 Upsert,
213 Delete,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct EdgeConfig {
219 pub edge_id: String,
220 pub mappings: Vec<Mapping>,
221 #[serde(default)]
226 pub glyphs: Vec<Glyph>,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct Glyph {
234 pub name: String,
235 #[serde(default)]
236 pub pattern: String,
237 #[serde(default)]
238 pub builtin: bool,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
243#[serde(tag = "type", rename_all = "snake_case")]
244pub enum UiFrame {
245 Snapshot { snapshot: UiSnapshot },
247 EdgeOnline { edge: EdgeInfo },
249 EdgeOffline { edge_id: String },
251 ServiceState {
253 edge_id: String,
254 service_type: String,
255 target: String,
256 property: String,
257 #[serde(skip_serializing_if = "Option::is_none")]
258 output_id: Option<String>,
259 value: serde_json::Value,
260 },
261 DeviceState {
263 edge_id: String,
264 device_type: String,
265 device_id: String,
266 property: String,
267 value: serde_json::Value,
268 },
269 MappingChanged {
271 mapping_id: Uuid,
272 op: PatchOp,
273 mapping: Option<Mapping>,
274 },
275 GlyphsChanged { glyphs: Vec<Glyph> },
277 Command {
280 edge_id: String,
281 service_type: String,
282 target: String,
283 intent: String,
284 #[serde(default)]
285 params: serde_json::Value,
286 result: CommandResult,
287 #[serde(skip_serializing_if = "Option::is_none")]
288 latency_ms: Option<u32>,
289 #[serde(skip_serializing_if = "Option::is_none")]
290 output_id: Option<String>,
291 at: String,
293 },
294 Error {
296 edge_id: String,
297 context: String,
298 message: String,
299 severity: ErrorSeverity,
300 at: String,
302 },
303 EdgeStatus {
311 edge_id: String,
312 #[serde(skip_serializing_if = "Option::is_none")]
313 wifi: Option<u8>,
314 #[serde(skip_serializing_if = "Option::is_none")]
315 latency_ms: Option<u32>,
316 },
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct UiSnapshot {
323 pub edges: Vec<EdgeInfo>,
324 pub service_states: Vec<ServiceStateEntry>,
325 pub device_states: Vec<DeviceStateEntry>,
326 pub mappings: Vec<Mapping>,
327 pub glyphs: Vec<Glyph>,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct EdgeInfo {
333 pub edge_id: String,
334 pub online: bool,
335 pub version: String,
336 pub capabilities: Vec<String>,
337 pub last_seen: String,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct ServiceStateEntry {
343 pub edge_id: String,
344 pub service_type: String,
345 pub target: String,
346 pub property: String,
347 #[serde(skip_serializing_if = "Option::is_none")]
348 pub output_id: Option<String>,
349 pub value: serde_json::Value,
350 pub updated_at: String,
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct DeviceStateEntry {
356 pub edge_id: String,
357 pub device_type: String,
358 pub device_id: String,
359 pub property: String,
360 pub value: serde_json::Value,
361 pub updated_at: String,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct Mapping {
369 pub mapping_id: Uuid,
370 pub edge_id: String,
371 pub device_type: String,
372 pub device_id: String,
373 pub service_type: String,
374 pub service_target: String,
375 pub routes: Vec<Route>,
376 #[serde(default)]
377 pub feedback: Vec<FeedbackRule>,
378 #[serde(default = "default_true")]
379 pub active: bool,
380 #[serde(default)]
383 pub target_candidates: Vec<TargetCandidate>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub target_switch_on: Option<String>,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct TargetCandidate {
407 pub target: String,
409 #[serde(default)]
411 pub label: String,
412 pub glyph: String,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub service_type: Option<String>,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub routes: Option<Vec<Route>>,
425}
426
427impl Mapping {
428 pub fn effective_for<'a>(&'a self, target: &str) -> (&'a str, &'a [Route]) {
436 let candidate = self.target_candidates.iter().find(|c| c.target == target);
437 let service_type = candidate
438 .and_then(|c| c.service_type.as_deref())
439 .unwrap_or(self.service_type.as_str());
440 let routes = candidate
441 .and_then(|c| c.routes.as_deref())
442 .unwrap_or(self.routes.as_slice());
443 (service_type, routes)
444 }
445}
446
447fn default_true() -> bool {
448 true
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct Route {
454 pub input: String,
455 pub intent: String,
456 #[serde(default)]
457 pub params: BTreeMap<String, serde_json::Value>,
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct FeedbackRule {
463 pub state: String,
464 pub feedback_type: String,
465 pub mapping: serde_json::Value,
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471
472 #[test]
473 fn server_to_edge_config_full_roundtrip() {
474 let msg = ServerToEdge::ConfigFull {
475 config: EdgeConfig {
476 edge_id: "living-room".into(),
477 mappings: vec![Mapping {
478 mapping_id: Uuid::nil(),
479 edge_id: "living-room".into(),
480 device_type: "nuimo".into(),
481 device_id: "C3:81:DF:4E:FF:6A".into(),
482 service_type: "roon".into(),
483 service_target: "zone-1".into(),
484 routes: vec![Route {
485 input: "rotate".into(),
486 intent: "volume_change".into(),
487 params: BTreeMap::from([("damping".into(), serde_json::json!(80))]),
488 }],
489 feedback: vec![],
490 active: true,
491 target_candidates: vec![],
492 target_switch_on: None,
493 }],
494 glyphs: vec![Glyph {
495 name: "play".into(),
496 pattern: " * \n ** ".into(),
497 builtin: false,
498 }],
499 },
500 };
501 let json = serde_json::to_string(&msg).unwrap();
502 assert!(json.contains("\"type\":\"config_full\""));
503 assert!(json.contains("\"edge_id\":\"living-room\""));
504
505 let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
506 match parsed {
507 ServerToEdge::ConfigFull { config } => {
508 assert_eq!(config.edge_id, "living-room");
509 assert_eq!(config.mappings.len(), 1);
510 }
511 _ => panic!("wrong variant"),
512 }
513 }
514
515 #[test]
516 fn server_to_edge_display_glyph_roundtrip() {
517 let msg = ServerToEdge::DisplayGlyph {
518 device_type: "nuimo".into(),
519 device_id: "C3:81:DF:4E:FF:6A".into(),
520 pattern: " * \n ***** ".into(),
521 brightness: Some(0.5),
522 timeout_ms: Some(2000),
523 transition: Some("cross_fade".into()),
524 };
525 let json = serde_json::to_string(&msg).unwrap();
526 assert!(json.contains("\"type\":\"display_glyph\""));
527 assert!(json.contains("\"device_type\":\"nuimo\""));
528 assert!(json.contains("\"brightness\":0.5"));
529
530 let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
531 match parsed {
532 ServerToEdge::DisplayGlyph {
533 device_type,
534 device_id,
535 pattern,
536 brightness,
537 timeout_ms,
538 transition,
539 } => {
540 assert_eq!(device_type, "nuimo");
541 assert_eq!(device_id, "C3:81:DF:4E:FF:6A");
542 assert!(pattern.contains('*'));
543 assert_eq!(brightness, Some(0.5));
544 assert_eq!(timeout_ms, Some(2000));
545 assert_eq!(transition.as_deref(), Some("cross_fade"));
546 }
547 _ => panic!("wrong variant"),
548 }
549
550 let minimal = ServerToEdge::DisplayGlyph {
552 device_type: "nuimo".into(),
553 device_id: "dev-1".into(),
554 pattern: "*".into(),
555 brightness: None,
556 timeout_ms: None,
557 transition: None,
558 };
559 let json = serde_json::to_string(&minimal).unwrap();
560 assert!(!json.contains("brightness"));
561 assert!(!json.contains("timeout_ms"));
562 assert!(!json.contains("transition"));
563 }
564
565 #[test]
566 fn server_to_edge_device_connect_disconnect_roundtrip() {
567 let connect = ServerToEdge::DeviceConnect {
568 device_type: "nuimo".into(),
569 device_id: "dev-1".into(),
570 };
571 let json = serde_json::to_string(&connect).unwrap();
572 assert!(json.contains("\"type\":\"device_connect\""));
573 let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
574 match parsed {
575 ServerToEdge::DeviceConnect {
576 device_type,
577 device_id,
578 } => {
579 assert_eq!(device_type, "nuimo");
580 assert_eq!(device_id, "dev-1");
581 }
582 _ => panic!("wrong variant"),
583 }
584
585 let disconnect = ServerToEdge::DeviceDisconnect {
586 device_type: "nuimo".into(),
587 device_id: "dev-1".into(),
588 };
589 let json = serde_json::to_string(&disconnect).unwrap();
590 assert!(json.contains("\"type\":\"device_disconnect\""));
591 let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
592 match parsed {
593 ServerToEdge::DeviceDisconnect {
594 device_type,
595 device_id,
596 } => {
597 assert_eq!(device_type, "nuimo");
598 assert_eq!(device_id, "dev-1");
599 }
600 _ => panic!("wrong variant"),
601 }
602 }
603
604 #[test]
605 fn edge_to_server_command_roundtrip() {
606 let ok = EdgeToServer::Command {
607 service_type: "roon".into(),
608 target: "zone-1".into(),
609 intent: "volume_change".into(),
610 params: serde_json::json!({"delta": 3}),
611 result: CommandResult::Ok,
612 latency_ms: Some(42),
613 output_id: None,
614 };
615 let json = serde_json::to_string(&ok).unwrap();
616 assert!(json.contains("\"type\":\"command\""));
617 assert!(json.contains("\"kind\":\"ok\""));
618 assert!(json.contains("\"latency_ms\":42"));
619 assert!(!json.contains("output_id"));
620 let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
621 match parsed {
622 EdgeToServer::Command { intent, result, .. } => {
623 assert_eq!(intent, "volume_change");
624 assert!(matches!(result, CommandResult::Ok));
625 }
626 _ => panic!("wrong variant"),
627 }
628
629 let err = EdgeToServer::Command {
630 service_type: "hue".into(),
631 target: "light-1".into(),
632 intent: "on_off".into(),
633 params: serde_json::json!({"on": true}),
634 result: CommandResult::Err {
635 message: "bridge timeout".into(),
636 },
637 latency_ms: None,
638 output_id: None,
639 };
640 let json = serde_json::to_string(&err).unwrap();
641 assert!(json.contains("\"kind\":\"err\""));
642 assert!(json.contains("\"message\":\"bridge timeout\""));
643 }
644
645 #[test]
646 fn edge_to_server_error_roundtrip() {
647 let msg = EdgeToServer::Error {
648 context: "hue.bridge".into(),
649 message: "connection refused".into(),
650 severity: ErrorSeverity::Error,
651 };
652 let json = serde_json::to_string(&msg).unwrap();
653 assert!(json.contains("\"type\":\"error\""));
654 assert!(json.contains("\"severity\":\"error\""));
655 }
656
657 #[test]
658 fn ui_frame_edge_status_roundtrip() {
659 let full = UiFrame::EdgeStatus {
660 edge_id: "air".into(),
661 wifi: Some(82),
662 latency_ms: Some(15),
663 };
664 let json = serde_json::to_string(&full).unwrap();
665 assert!(json.contains("\"type\":\"edge_status\""));
666 assert!(json.contains("\"wifi\":82"));
667 assert!(json.contains("\"latency_ms\":15"));
668 let parsed: UiFrame = serde_json::from_str(&json).unwrap();
669 match parsed {
670 UiFrame::EdgeStatus {
671 edge_id,
672 wifi,
673 latency_ms,
674 } => {
675 assert_eq!(edge_id, "air");
676 assert_eq!(wifi, Some(82));
677 assert_eq!(latency_ms, Some(15));
678 }
679 _ => panic!("wrong variant"),
680 }
681
682 let empty = UiFrame::EdgeStatus {
684 edge_id: "air".into(),
685 wifi: None,
686 latency_ms: None,
687 };
688 let json = serde_json::to_string(&empty).unwrap();
689 assert!(json.contains("\"edge_id\":\"air\""));
690 assert!(!json.contains("wifi"));
691 assert!(!json.contains("latency_ms"));
692 }
693
694 #[test]
695 fn ui_frame_command_and_error_roundtrip() {
696 let cmd = UiFrame::Command {
697 edge_id: "air".into(),
698 service_type: "roon".into(),
699 target: "zone-1".into(),
700 intent: "play_pause".into(),
701 params: serde_json::json!({}),
702 result: CommandResult::Ok,
703 latency_ms: Some(18),
704 output_id: None,
705 at: "2026-04-23T12:00:00Z".into(),
706 };
707 let json = serde_json::to_string(&cmd).unwrap();
708 assert!(json.contains("\"type\":\"command\""));
709 let _: UiFrame = serde_json::from_str(&json).unwrap();
710
711 let err = UiFrame::Error {
712 edge_id: "air".into(),
713 context: "roon.client".into(),
714 message: "pair lost".into(),
715 severity: ErrorSeverity::Warn,
716 at: "2026-04-23T12:00:00Z".into(),
717 };
718 let json = serde_json::to_string(&err).unwrap();
719 assert!(json.contains("\"type\":\"error\""));
720 assert!(json.contains("\"severity\":\"warn\""));
721 let _: UiFrame = serde_json::from_str(&json).unwrap();
722 }
723
724 #[test]
725 fn edge_to_server_edge_status_roundtrip() {
726 let with_wifi = EdgeToServer::EdgeStatus { wifi: Some(73) };
727 let json = serde_json::to_string(&with_wifi).unwrap();
728 assert!(json.contains("\"type\":\"edge_status\""));
729 assert!(json.contains("\"wifi\":73"));
730 let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
731 match parsed {
732 EdgeToServer::EdgeStatus { wifi } => assert_eq!(wifi, Some(73)),
733 _ => panic!("wrong variant"),
734 }
735
736 let no_wifi = EdgeToServer::EdgeStatus { wifi: None };
738 let json = serde_json::to_string(&no_wifi).unwrap();
739 assert!(json.contains("\"type\":\"edge_status\""));
740 assert!(!json.contains("wifi"));
741 let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
742 match parsed {
743 EdgeToServer::EdgeStatus { wifi } => assert_eq!(wifi, None),
744 _ => panic!("wrong variant"),
745 }
746 }
747
748 #[test]
749 fn edge_to_server_state_with_optional_output_id() {
750 let msg = EdgeToServer::State {
751 service_type: "roon".into(),
752 target: "zone-1".into(),
753 property: "volume".into(),
754 output_id: Some("output-1".into()),
755 value: serde_json::json!(50),
756 };
757 let json = serde_json::to_string(&msg).unwrap();
758 assert!(json.contains("\"output_id\":\"output-1\""));
759
760 let msg2 = EdgeToServer::State {
761 service_type: "roon".into(),
762 target: "zone-1".into(),
763 property: "playback".into(),
764 output_id: None,
765 value: serde_json::json!("playing"),
766 };
767 let json2 = serde_json::to_string(&msg2).unwrap();
768 assert!(!json2.contains("output_id"));
769 }
770}