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 Ping,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "type", rename_all = "snake_case")]
37pub enum EdgeToServer {
38 Hello {
40 edge_id: String,
41 version: String,
42 capabilities: Vec<String>,
43 },
44 State {
46 service_type: String,
47 target: String,
48 property: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 output_id: Option<String>,
51 value: serde_json::Value,
52 },
53 DeviceState {
55 device_type: String,
56 device_id: String,
57 property: String,
58 value: serde_json::Value,
59 },
60 Pong,
62 SwitchTarget {
68 mapping_id: Uuid,
69 service_target: String,
70 },
71 Command {
77 service_type: String,
78 target: String,
79 intent: String,
81 #[serde(default)]
84 params: serde_json::Value,
85 result: CommandResult,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 latency_ms: Option<u32>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 output_id: Option<String>,
90 },
91 Error {
96 context: String,
97 message: String,
98 severity: ErrorSeverity,
99 },
100 EdgeStatus {
105 #[serde(skip_serializing_if = "Option::is_none")]
110 wifi: Option<u8>,
111 },
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(tag = "kind", rename_all = "snake_case")]
117pub enum CommandResult {
118 Ok,
119 Err { message: String },
120}
121
122#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub enum ErrorSeverity {
126 Warn,
127 Error,
128 Fatal,
129}
130
131#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
132#[serde(rename_all = "snake_case")]
133pub enum PatchOp {
134 Upsert,
135 Delete,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct EdgeConfig {
141 pub edge_id: String,
142 pub mappings: Vec<Mapping>,
143 #[serde(default)]
148 pub glyphs: Vec<Glyph>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct Glyph {
156 pub name: String,
157 #[serde(default)]
158 pub pattern: String,
159 #[serde(default)]
160 pub builtin: bool,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165#[serde(tag = "type", rename_all = "snake_case")]
166pub enum UiFrame {
167 Snapshot { snapshot: UiSnapshot },
169 EdgeOnline { edge: EdgeInfo },
171 EdgeOffline { edge_id: String },
173 ServiceState {
175 edge_id: String,
176 service_type: String,
177 target: String,
178 property: String,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 output_id: Option<String>,
181 value: serde_json::Value,
182 },
183 DeviceState {
185 edge_id: String,
186 device_type: String,
187 device_id: String,
188 property: String,
189 value: serde_json::Value,
190 },
191 MappingChanged {
193 mapping_id: Uuid,
194 op: PatchOp,
195 mapping: Option<Mapping>,
196 },
197 GlyphsChanged { glyphs: Vec<Glyph> },
199 Command {
202 edge_id: String,
203 service_type: String,
204 target: String,
205 intent: String,
206 #[serde(default)]
207 params: serde_json::Value,
208 result: CommandResult,
209 #[serde(skip_serializing_if = "Option::is_none")]
210 latency_ms: Option<u32>,
211 #[serde(skip_serializing_if = "Option::is_none")]
212 output_id: Option<String>,
213 at: String,
215 },
216 Error {
218 edge_id: String,
219 context: String,
220 message: String,
221 severity: ErrorSeverity,
222 at: String,
224 },
225 EdgeStatus {
233 edge_id: String,
234 #[serde(skip_serializing_if = "Option::is_none")]
235 wifi: Option<u8>,
236 #[serde(skip_serializing_if = "Option::is_none")]
237 latency_ms: Option<u32>,
238 },
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct UiSnapshot {
245 pub edges: Vec<EdgeInfo>,
246 pub service_states: Vec<ServiceStateEntry>,
247 pub device_states: Vec<DeviceStateEntry>,
248 pub mappings: Vec<Mapping>,
249 pub glyphs: Vec<Glyph>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct EdgeInfo {
255 pub edge_id: String,
256 pub online: bool,
257 pub version: String,
258 pub capabilities: Vec<String>,
259 pub last_seen: String,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct ServiceStateEntry {
265 pub edge_id: String,
266 pub service_type: String,
267 pub target: String,
268 pub property: String,
269 #[serde(skip_serializing_if = "Option::is_none")]
270 pub output_id: Option<String>,
271 pub value: serde_json::Value,
272 pub updated_at: String,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct DeviceStateEntry {
278 pub edge_id: String,
279 pub device_type: String,
280 pub device_id: String,
281 pub property: String,
282 pub value: serde_json::Value,
283 pub updated_at: String,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct Mapping {
291 pub mapping_id: Uuid,
292 pub edge_id: String,
293 pub device_type: String,
294 pub device_id: String,
295 pub service_type: String,
296 pub service_target: String,
297 pub routes: Vec<Route>,
298 #[serde(default)]
299 pub feedback: Vec<FeedbackRule>,
300 #[serde(default = "default_true")]
301 pub active: bool,
302 #[serde(default)]
305 pub target_candidates: Vec<TargetCandidate>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub target_switch_on: Option<String>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct TargetCandidate {
329 pub target: String,
331 #[serde(default)]
333 pub label: String,
334 pub glyph: String,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub service_type: Option<String>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub routes: Option<Vec<Route>>,
347}
348
349impl Mapping {
350 pub fn effective_for<'a>(&'a self, target: &str) -> (&'a str, &'a [Route]) {
358 let candidate = self.target_candidates.iter().find(|c| c.target == target);
359 let service_type = candidate
360 .and_then(|c| c.service_type.as_deref())
361 .unwrap_or(self.service_type.as_str());
362 let routes = candidate
363 .and_then(|c| c.routes.as_deref())
364 .unwrap_or(self.routes.as_slice());
365 (service_type, routes)
366 }
367}
368
369fn default_true() -> bool {
370 true
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct Route {
376 pub input: String,
377 pub intent: String,
378 #[serde(default)]
379 pub params: BTreeMap<String, serde_json::Value>,
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct FeedbackRule {
385 pub state: String,
386 pub feedback_type: String,
387 pub mapping: serde_json::Value,
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
395 fn server_to_edge_config_full_roundtrip() {
396 let msg = ServerToEdge::ConfigFull {
397 config: EdgeConfig {
398 edge_id: "living-room".into(),
399 mappings: vec![Mapping {
400 mapping_id: Uuid::nil(),
401 edge_id: "living-room".into(),
402 device_type: "nuimo".into(),
403 device_id: "C3:81:DF:4E:FF:6A".into(),
404 service_type: "roon".into(),
405 service_target: "zone-1".into(),
406 routes: vec![Route {
407 input: "rotate".into(),
408 intent: "volume_change".into(),
409 params: BTreeMap::from([("damping".into(), serde_json::json!(80))]),
410 }],
411 feedback: vec![],
412 active: true,
413 target_candidates: vec![],
414 target_switch_on: None,
415 }],
416 glyphs: vec![Glyph {
417 name: "play".into(),
418 pattern: " * \n ** ".into(),
419 builtin: false,
420 }],
421 },
422 };
423 let json = serde_json::to_string(&msg).unwrap();
424 assert!(json.contains("\"type\":\"config_full\""));
425 assert!(json.contains("\"edge_id\":\"living-room\""));
426
427 let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
428 match parsed {
429 ServerToEdge::ConfigFull { config } => {
430 assert_eq!(config.edge_id, "living-room");
431 assert_eq!(config.mappings.len(), 1);
432 }
433 _ => panic!("wrong variant"),
434 }
435 }
436
437 #[test]
438 fn edge_to_server_command_roundtrip() {
439 let ok = EdgeToServer::Command {
440 service_type: "roon".into(),
441 target: "zone-1".into(),
442 intent: "volume_change".into(),
443 params: serde_json::json!({"delta": 3}),
444 result: CommandResult::Ok,
445 latency_ms: Some(42),
446 output_id: None,
447 };
448 let json = serde_json::to_string(&ok).unwrap();
449 assert!(json.contains("\"type\":\"command\""));
450 assert!(json.contains("\"kind\":\"ok\""));
451 assert!(json.contains("\"latency_ms\":42"));
452 assert!(!json.contains("output_id"));
453 let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
454 match parsed {
455 EdgeToServer::Command { intent, result, .. } => {
456 assert_eq!(intent, "volume_change");
457 assert!(matches!(result, CommandResult::Ok));
458 }
459 _ => panic!("wrong variant"),
460 }
461
462 let err = EdgeToServer::Command {
463 service_type: "hue".into(),
464 target: "light-1".into(),
465 intent: "on_off".into(),
466 params: serde_json::json!({"on": true}),
467 result: CommandResult::Err {
468 message: "bridge timeout".into(),
469 },
470 latency_ms: None,
471 output_id: None,
472 };
473 let json = serde_json::to_string(&err).unwrap();
474 assert!(json.contains("\"kind\":\"err\""));
475 assert!(json.contains("\"message\":\"bridge timeout\""));
476 }
477
478 #[test]
479 fn edge_to_server_error_roundtrip() {
480 let msg = EdgeToServer::Error {
481 context: "hue.bridge".into(),
482 message: "connection refused".into(),
483 severity: ErrorSeverity::Error,
484 };
485 let json = serde_json::to_string(&msg).unwrap();
486 assert!(json.contains("\"type\":\"error\""));
487 assert!(json.contains("\"severity\":\"error\""));
488 }
489
490 #[test]
491 fn ui_frame_edge_status_roundtrip() {
492 let full = UiFrame::EdgeStatus {
493 edge_id: "air".into(),
494 wifi: Some(82),
495 latency_ms: Some(15),
496 };
497 let json = serde_json::to_string(&full).unwrap();
498 assert!(json.contains("\"type\":\"edge_status\""));
499 assert!(json.contains("\"wifi\":82"));
500 assert!(json.contains("\"latency_ms\":15"));
501 let parsed: UiFrame = serde_json::from_str(&json).unwrap();
502 match parsed {
503 UiFrame::EdgeStatus {
504 edge_id,
505 wifi,
506 latency_ms,
507 } => {
508 assert_eq!(edge_id, "air");
509 assert_eq!(wifi, Some(82));
510 assert_eq!(latency_ms, Some(15));
511 }
512 _ => panic!("wrong variant"),
513 }
514
515 let empty = UiFrame::EdgeStatus {
517 edge_id: "air".into(),
518 wifi: None,
519 latency_ms: None,
520 };
521 let json = serde_json::to_string(&empty).unwrap();
522 assert!(json.contains("\"edge_id\":\"air\""));
523 assert!(!json.contains("wifi"));
524 assert!(!json.contains("latency_ms"));
525 }
526
527 #[test]
528 fn ui_frame_command_and_error_roundtrip() {
529 let cmd = UiFrame::Command {
530 edge_id: "air".into(),
531 service_type: "roon".into(),
532 target: "zone-1".into(),
533 intent: "play_pause".into(),
534 params: serde_json::json!({}),
535 result: CommandResult::Ok,
536 latency_ms: Some(18),
537 output_id: None,
538 at: "2026-04-23T12:00:00Z".into(),
539 };
540 let json = serde_json::to_string(&cmd).unwrap();
541 assert!(json.contains("\"type\":\"command\""));
542 let _: UiFrame = serde_json::from_str(&json).unwrap();
543
544 let err = UiFrame::Error {
545 edge_id: "air".into(),
546 context: "roon.client".into(),
547 message: "pair lost".into(),
548 severity: ErrorSeverity::Warn,
549 at: "2026-04-23T12:00:00Z".into(),
550 };
551 let json = serde_json::to_string(&err).unwrap();
552 assert!(json.contains("\"type\":\"error\""));
553 assert!(json.contains("\"severity\":\"warn\""));
554 let _: UiFrame = serde_json::from_str(&json).unwrap();
555 }
556
557 #[test]
558 fn edge_to_server_edge_status_roundtrip() {
559 let with_wifi = EdgeToServer::EdgeStatus { wifi: Some(73) };
560 let json = serde_json::to_string(&with_wifi).unwrap();
561 assert!(json.contains("\"type\":\"edge_status\""));
562 assert!(json.contains("\"wifi\":73"));
563 let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
564 match parsed {
565 EdgeToServer::EdgeStatus { wifi } => assert_eq!(wifi, Some(73)),
566 _ => panic!("wrong variant"),
567 }
568
569 let no_wifi = EdgeToServer::EdgeStatus { wifi: None };
571 let json = serde_json::to_string(&no_wifi).unwrap();
572 assert!(json.contains("\"type\":\"edge_status\""));
573 assert!(!json.contains("wifi"));
574 let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
575 match parsed {
576 EdgeToServer::EdgeStatus { wifi } => assert_eq!(wifi, None),
577 _ => panic!("wrong variant"),
578 }
579 }
580
581 #[test]
582 fn edge_to_server_state_with_optional_output_id() {
583 let msg = EdgeToServer::State {
584 service_type: "roon".into(),
585 target: "zone-1".into(),
586 property: "volume".into(),
587 output_id: Some("output-1".into()),
588 value: serde_json::json!(50),
589 };
590 let json = serde_json::to_string(&msg).unwrap();
591 assert!(json.contains("\"output_id\":\"output-1\""));
592
593 let msg2 = EdgeToServer::State {
594 service_type: "roon".into(),
595 target: "zone-1".into(),
596 property: "playback".into(),
597 output_id: None,
598 value: serde_json::json!("playing"),
599 };
600 let json2 = serde_json::to_string(&msg2).unwrap();
601 assert!(!json2.contains("output_id"));
602 }
603}