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 Ping,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(tag = "type", rename_all = "snake_case")]
72pub enum EdgeToServer {
73 Hello {
75 edge_id: String,
76 version: String,
77 capabilities: Vec<String>,
78 },
79 State {
81 service_type: String,
82 target: String,
83 property: String,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 output_id: Option<String>,
86 value: serde_json::Value,
87 },
88 DeviceState {
90 device_type: String,
91 device_id: String,
92 property: String,
93 value: serde_json::Value,
94 },
95 Pong,
97 SwitchTarget {
103 mapping_id: Uuid,
104 service_target: String,
105 },
106 Command {
112 service_type: String,
113 target: String,
114 intent: String,
116 #[serde(default)]
119 params: serde_json::Value,
120 result: CommandResult,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 latency_ms: Option<u32>,
123 #[serde(skip_serializing_if = "Option::is_none")]
124 output_id: Option<String>,
125 },
126 Error {
131 context: String,
132 message: String,
133 severity: ErrorSeverity,
134 },
135 EdgeStatus {
140 #[serde(skip_serializing_if = "Option::is_none")]
145 wifi: Option<u8>,
146 },
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(tag = "kind", rename_all = "snake_case")]
152pub enum CommandResult {
153 Ok,
154 Err { message: String },
155}
156
157#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum ErrorSeverity {
161 Warn,
162 Error,
163 Fatal,
164}
165
166#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum PatchOp {
169 Upsert,
170 Delete,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct EdgeConfig {
176 pub edge_id: String,
177 pub mappings: Vec<Mapping>,
178 #[serde(default)]
183 pub glyphs: Vec<Glyph>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct Glyph {
191 pub name: String,
192 #[serde(default)]
193 pub pattern: String,
194 #[serde(default)]
195 pub builtin: bool,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
200#[serde(tag = "type", rename_all = "snake_case")]
201pub enum UiFrame {
202 Snapshot { snapshot: UiSnapshot },
204 EdgeOnline { edge: EdgeInfo },
206 EdgeOffline { edge_id: String },
208 ServiceState {
210 edge_id: String,
211 service_type: String,
212 target: String,
213 property: String,
214 #[serde(skip_serializing_if = "Option::is_none")]
215 output_id: Option<String>,
216 value: serde_json::Value,
217 },
218 DeviceState {
220 edge_id: String,
221 device_type: String,
222 device_id: String,
223 property: String,
224 value: serde_json::Value,
225 },
226 MappingChanged {
228 mapping_id: Uuid,
229 op: PatchOp,
230 mapping: Option<Mapping>,
231 },
232 GlyphsChanged { glyphs: Vec<Glyph> },
234 Command {
237 edge_id: String,
238 service_type: String,
239 target: String,
240 intent: String,
241 #[serde(default)]
242 params: serde_json::Value,
243 result: CommandResult,
244 #[serde(skip_serializing_if = "Option::is_none")]
245 latency_ms: Option<u32>,
246 #[serde(skip_serializing_if = "Option::is_none")]
247 output_id: Option<String>,
248 at: String,
250 },
251 Error {
253 edge_id: String,
254 context: String,
255 message: String,
256 severity: ErrorSeverity,
257 at: String,
259 },
260 EdgeStatus {
268 edge_id: String,
269 #[serde(skip_serializing_if = "Option::is_none")]
270 wifi: Option<u8>,
271 #[serde(skip_serializing_if = "Option::is_none")]
272 latency_ms: Option<u32>,
273 },
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct UiSnapshot {
280 pub edges: Vec<EdgeInfo>,
281 pub service_states: Vec<ServiceStateEntry>,
282 pub device_states: Vec<DeviceStateEntry>,
283 pub mappings: Vec<Mapping>,
284 pub glyphs: Vec<Glyph>,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct EdgeInfo {
290 pub edge_id: String,
291 pub online: bool,
292 pub version: String,
293 pub capabilities: Vec<String>,
294 pub last_seen: String,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct ServiceStateEntry {
300 pub edge_id: String,
301 pub service_type: String,
302 pub target: String,
303 pub property: String,
304 #[serde(skip_serializing_if = "Option::is_none")]
305 pub output_id: Option<String>,
306 pub value: serde_json::Value,
307 pub updated_at: String,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct DeviceStateEntry {
313 pub edge_id: String,
314 pub device_type: String,
315 pub device_id: String,
316 pub property: String,
317 pub value: serde_json::Value,
318 pub updated_at: String,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct Mapping {
326 pub mapping_id: Uuid,
327 pub edge_id: String,
328 pub device_type: String,
329 pub device_id: String,
330 pub service_type: String,
331 pub service_target: String,
332 pub routes: Vec<Route>,
333 #[serde(default)]
334 pub feedback: Vec<FeedbackRule>,
335 #[serde(default = "default_true")]
336 pub active: bool,
337 #[serde(default)]
340 pub target_candidates: Vec<TargetCandidate>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub target_switch_on: Option<String>,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct TargetCandidate {
364 pub target: String,
366 #[serde(default)]
368 pub label: String,
369 pub glyph: String,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub service_type: Option<String>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub routes: Option<Vec<Route>>,
382}
383
384impl Mapping {
385 pub fn effective_for<'a>(&'a self, target: &str) -> (&'a str, &'a [Route]) {
393 let candidate = self.target_candidates.iter().find(|c| c.target == target);
394 let service_type = candidate
395 .and_then(|c| c.service_type.as_deref())
396 .unwrap_or(self.service_type.as_str());
397 let routes = candidate
398 .and_then(|c| c.routes.as_deref())
399 .unwrap_or(self.routes.as_slice());
400 (service_type, routes)
401 }
402}
403
404fn default_true() -> bool {
405 true
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct Route {
411 pub input: String,
412 pub intent: String,
413 #[serde(default)]
414 pub params: BTreeMap<String, serde_json::Value>,
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct FeedbackRule {
420 pub state: String,
421 pub feedback_type: String,
422 pub mapping: serde_json::Value,
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 #[test]
430 fn server_to_edge_config_full_roundtrip() {
431 let msg = ServerToEdge::ConfigFull {
432 config: EdgeConfig {
433 edge_id: "living-room".into(),
434 mappings: vec![Mapping {
435 mapping_id: Uuid::nil(),
436 edge_id: "living-room".into(),
437 device_type: "nuimo".into(),
438 device_id: "C3:81:DF:4E:FF:6A".into(),
439 service_type: "roon".into(),
440 service_target: "zone-1".into(),
441 routes: vec![Route {
442 input: "rotate".into(),
443 intent: "volume_change".into(),
444 params: BTreeMap::from([("damping".into(), serde_json::json!(80))]),
445 }],
446 feedback: vec![],
447 active: true,
448 target_candidates: vec![],
449 target_switch_on: None,
450 }],
451 glyphs: vec![Glyph {
452 name: "play".into(),
453 pattern: " * \n ** ".into(),
454 builtin: false,
455 }],
456 },
457 };
458 let json = serde_json::to_string(&msg).unwrap();
459 assert!(json.contains("\"type\":\"config_full\""));
460 assert!(json.contains("\"edge_id\":\"living-room\""));
461
462 let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
463 match parsed {
464 ServerToEdge::ConfigFull { config } => {
465 assert_eq!(config.edge_id, "living-room");
466 assert_eq!(config.mappings.len(), 1);
467 }
468 _ => panic!("wrong variant"),
469 }
470 }
471
472 #[test]
473 fn server_to_edge_display_glyph_roundtrip() {
474 let msg = ServerToEdge::DisplayGlyph {
475 device_type: "nuimo".into(),
476 device_id: "C3:81:DF:4E:FF:6A".into(),
477 pattern: " * \n ***** ".into(),
478 brightness: Some(0.5),
479 timeout_ms: Some(2000),
480 transition: Some("cross_fade".into()),
481 };
482 let json = serde_json::to_string(&msg).unwrap();
483 assert!(json.contains("\"type\":\"display_glyph\""));
484 assert!(json.contains("\"device_type\":\"nuimo\""));
485 assert!(json.contains("\"brightness\":0.5"));
486
487 let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
488 match parsed {
489 ServerToEdge::DisplayGlyph {
490 device_type,
491 device_id,
492 pattern,
493 brightness,
494 timeout_ms,
495 transition,
496 } => {
497 assert_eq!(device_type, "nuimo");
498 assert_eq!(device_id, "C3:81:DF:4E:FF:6A");
499 assert!(pattern.contains('*'));
500 assert_eq!(brightness, Some(0.5));
501 assert_eq!(timeout_ms, Some(2000));
502 assert_eq!(transition.as_deref(), Some("cross_fade"));
503 }
504 _ => panic!("wrong variant"),
505 }
506
507 let minimal = ServerToEdge::DisplayGlyph {
509 device_type: "nuimo".into(),
510 device_id: "dev-1".into(),
511 pattern: "*".into(),
512 brightness: None,
513 timeout_ms: None,
514 transition: None,
515 };
516 let json = serde_json::to_string(&minimal).unwrap();
517 assert!(!json.contains("brightness"));
518 assert!(!json.contains("timeout_ms"));
519 assert!(!json.contains("transition"));
520 }
521
522 #[test]
523 fn server_to_edge_device_connect_disconnect_roundtrip() {
524 let connect = ServerToEdge::DeviceConnect {
525 device_type: "nuimo".into(),
526 device_id: "dev-1".into(),
527 };
528 let json = serde_json::to_string(&connect).unwrap();
529 assert!(json.contains("\"type\":\"device_connect\""));
530 let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
531 match parsed {
532 ServerToEdge::DeviceConnect {
533 device_type,
534 device_id,
535 } => {
536 assert_eq!(device_type, "nuimo");
537 assert_eq!(device_id, "dev-1");
538 }
539 _ => panic!("wrong variant"),
540 }
541
542 let disconnect = ServerToEdge::DeviceDisconnect {
543 device_type: "nuimo".into(),
544 device_id: "dev-1".into(),
545 };
546 let json = serde_json::to_string(&disconnect).unwrap();
547 assert!(json.contains("\"type\":\"device_disconnect\""));
548 let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
549 match parsed {
550 ServerToEdge::DeviceDisconnect {
551 device_type,
552 device_id,
553 } => {
554 assert_eq!(device_type, "nuimo");
555 assert_eq!(device_id, "dev-1");
556 }
557 _ => panic!("wrong variant"),
558 }
559 }
560
561 #[test]
562 fn edge_to_server_command_roundtrip() {
563 let ok = EdgeToServer::Command {
564 service_type: "roon".into(),
565 target: "zone-1".into(),
566 intent: "volume_change".into(),
567 params: serde_json::json!({"delta": 3}),
568 result: CommandResult::Ok,
569 latency_ms: Some(42),
570 output_id: None,
571 };
572 let json = serde_json::to_string(&ok).unwrap();
573 assert!(json.contains("\"type\":\"command\""));
574 assert!(json.contains("\"kind\":\"ok\""));
575 assert!(json.contains("\"latency_ms\":42"));
576 assert!(!json.contains("output_id"));
577 let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
578 match parsed {
579 EdgeToServer::Command { intent, result, .. } => {
580 assert_eq!(intent, "volume_change");
581 assert!(matches!(result, CommandResult::Ok));
582 }
583 _ => panic!("wrong variant"),
584 }
585
586 let err = EdgeToServer::Command {
587 service_type: "hue".into(),
588 target: "light-1".into(),
589 intent: "on_off".into(),
590 params: serde_json::json!({"on": true}),
591 result: CommandResult::Err {
592 message: "bridge timeout".into(),
593 },
594 latency_ms: None,
595 output_id: None,
596 };
597 let json = serde_json::to_string(&err).unwrap();
598 assert!(json.contains("\"kind\":\"err\""));
599 assert!(json.contains("\"message\":\"bridge timeout\""));
600 }
601
602 #[test]
603 fn edge_to_server_error_roundtrip() {
604 let msg = EdgeToServer::Error {
605 context: "hue.bridge".into(),
606 message: "connection refused".into(),
607 severity: ErrorSeverity::Error,
608 };
609 let json = serde_json::to_string(&msg).unwrap();
610 assert!(json.contains("\"type\":\"error\""));
611 assert!(json.contains("\"severity\":\"error\""));
612 }
613
614 #[test]
615 fn ui_frame_edge_status_roundtrip() {
616 let full = UiFrame::EdgeStatus {
617 edge_id: "air".into(),
618 wifi: Some(82),
619 latency_ms: Some(15),
620 };
621 let json = serde_json::to_string(&full).unwrap();
622 assert!(json.contains("\"type\":\"edge_status\""));
623 assert!(json.contains("\"wifi\":82"));
624 assert!(json.contains("\"latency_ms\":15"));
625 let parsed: UiFrame = serde_json::from_str(&json).unwrap();
626 match parsed {
627 UiFrame::EdgeStatus {
628 edge_id,
629 wifi,
630 latency_ms,
631 } => {
632 assert_eq!(edge_id, "air");
633 assert_eq!(wifi, Some(82));
634 assert_eq!(latency_ms, Some(15));
635 }
636 _ => panic!("wrong variant"),
637 }
638
639 let empty = UiFrame::EdgeStatus {
641 edge_id: "air".into(),
642 wifi: None,
643 latency_ms: None,
644 };
645 let json = serde_json::to_string(&empty).unwrap();
646 assert!(json.contains("\"edge_id\":\"air\""));
647 assert!(!json.contains("wifi"));
648 assert!(!json.contains("latency_ms"));
649 }
650
651 #[test]
652 fn ui_frame_command_and_error_roundtrip() {
653 let cmd = UiFrame::Command {
654 edge_id: "air".into(),
655 service_type: "roon".into(),
656 target: "zone-1".into(),
657 intent: "play_pause".into(),
658 params: serde_json::json!({}),
659 result: CommandResult::Ok,
660 latency_ms: Some(18),
661 output_id: None,
662 at: "2026-04-23T12:00:00Z".into(),
663 };
664 let json = serde_json::to_string(&cmd).unwrap();
665 assert!(json.contains("\"type\":\"command\""));
666 let _: UiFrame = serde_json::from_str(&json).unwrap();
667
668 let err = UiFrame::Error {
669 edge_id: "air".into(),
670 context: "roon.client".into(),
671 message: "pair lost".into(),
672 severity: ErrorSeverity::Warn,
673 at: "2026-04-23T12:00:00Z".into(),
674 };
675 let json = serde_json::to_string(&err).unwrap();
676 assert!(json.contains("\"type\":\"error\""));
677 assert!(json.contains("\"severity\":\"warn\""));
678 let _: UiFrame = serde_json::from_str(&json).unwrap();
679 }
680
681 #[test]
682 fn edge_to_server_edge_status_roundtrip() {
683 let with_wifi = EdgeToServer::EdgeStatus { wifi: Some(73) };
684 let json = serde_json::to_string(&with_wifi).unwrap();
685 assert!(json.contains("\"type\":\"edge_status\""));
686 assert!(json.contains("\"wifi\":73"));
687 let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
688 match parsed {
689 EdgeToServer::EdgeStatus { wifi } => assert_eq!(wifi, Some(73)),
690 _ => panic!("wrong variant"),
691 }
692
693 let no_wifi = EdgeToServer::EdgeStatus { wifi: None };
695 let json = serde_json::to_string(&no_wifi).unwrap();
696 assert!(json.contains("\"type\":\"edge_status\""));
697 assert!(!json.contains("wifi"));
698 let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
699 match parsed {
700 EdgeToServer::EdgeStatus { wifi } => assert_eq!(wifi, None),
701 _ => panic!("wrong variant"),
702 }
703 }
704
705 #[test]
706 fn edge_to_server_state_with_optional_output_id() {
707 let msg = EdgeToServer::State {
708 service_type: "roon".into(),
709 target: "zone-1".into(),
710 property: "volume".into(),
711 output_id: Some("output-1".into()),
712 value: serde_json::json!(50),
713 };
714 let json = serde_json::to_string(&msg).unwrap();
715 assert!(json.contains("\"output_id\":\"output-1\""));
716
717 let msg2 = EdgeToServer::State {
718 service_type: "roon".into(),
719 target: "zone-1".into(),
720 property: "playback".into(),
721 output_id: None,
722 value: serde_json::json!("playing"),
723 };
724 let json2 = serde_json::to_string(&msg2).unwrap();
725 assert!(!json2.contains("output_id"));
726 }
727}