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}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(tag = "kind", rename_all = "snake_case")]
105pub enum CommandResult {
106 Ok,
107 Err { message: String },
108}
109
110#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum ErrorSeverity {
114 Warn,
115 Error,
116 Fatal,
117}
118
119#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum PatchOp {
122 Upsert,
123 Delete,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct EdgeConfig {
129 pub edge_id: String,
130 pub mappings: Vec<Mapping>,
131 #[serde(default)]
136 pub glyphs: Vec<Glyph>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct Glyph {
144 pub name: String,
145 #[serde(default)]
146 pub pattern: String,
147 #[serde(default)]
148 pub builtin: bool,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(tag = "type", rename_all = "snake_case")]
154pub enum UiFrame {
155 Snapshot { snapshot: UiSnapshot },
157 EdgeOnline { edge: EdgeInfo },
159 EdgeOffline { edge_id: String },
161 ServiceState {
163 edge_id: String,
164 service_type: String,
165 target: String,
166 property: String,
167 #[serde(skip_serializing_if = "Option::is_none")]
168 output_id: Option<String>,
169 value: serde_json::Value,
170 },
171 DeviceState {
173 edge_id: String,
174 device_type: String,
175 device_id: String,
176 property: String,
177 value: serde_json::Value,
178 },
179 MappingChanged {
181 mapping_id: Uuid,
182 op: PatchOp,
183 mapping: Option<Mapping>,
184 },
185 GlyphsChanged { glyphs: Vec<Glyph> },
187 Command {
190 edge_id: String,
191 service_type: String,
192 target: String,
193 intent: String,
194 #[serde(default)]
195 params: serde_json::Value,
196 result: CommandResult,
197 #[serde(skip_serializing_if = "Option::is_none")]
198 latency_ms: Option<u32>,
199 #[serde(skip_serializing_if = "Option::is_none")]
200 output_id: Option<String>,
201 at: String,
203 },
204 Error {
206 edge_id: String,
207 context: String,
208 message: String,
209 severity: ErrorSeverity,
210 at: String,
212 },
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct UiSnapshot {
219 pub edges: Vec<EdgeInfo>,
220 pub service_states: Vec<ServiceStateEntry>,
221 pub device_states: Vec<DeviceStateEntry>,
222 pub mappings: Vec<Mapping>,
223 pub glyphs: Vec<Glyph>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct EdgeInfo {
229 pub edge_id: String,
230 pub online: bool,
231 pub version: String,
232 pub capabilities: Vec<String>,
233 pub last_seen: String,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct ServiceStateEntry {
239 pub edge_id: String,
240 pub service_type: String,
241 pub target: String,
242 pub property: String,
243 #[serde(skip_serializing_if = "Option::is_none")]
244 pub output_id: Option<String>,
245 pub value: serde_json::Value,
246 pub updated_at: String,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct DeviceStateEntry {
252 pub edge_id: String,
253 pub device_type: String,
254 pub device_id: String,
255 pub property: String,
256 pub value: serde_json::Value,
257 pub updated_at: String,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct Mapping {
265 pub mapping_id: Uuid,
266 pub edge_id: String,
267 pub device_type: String,
268 pub device_id: String,
269 pub service_type: String,
270 pub service_target: String,
271 pub routes: Vec<Route>,
272 #[serde(default)]
273 pub feedback: Vec<FeedbackRule>,
274 #[serde(default = "default_true")]
275 pub active: bool,
276 #[serde(default)]
279 pub target_candidates: Vec<TargetCandidate>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub target_switch_on: Option<String>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct TargetCandidate {
303 pub target: String,
305 #[serde(default)]
307 pub label: String,
308 pub glyph: String,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub service_type: Option<String>,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub routes: Option<Vec<Route>>,
321}
322
323impl Mapping {
324 pub fn effective_for<'a>(&'a self, target: &str) -> (&'a str, &'a [Route]) {
332 let candidate = self.target_candidates.iter().find(|c| c.target == target);
333 let service_type = candidate
334 .and_then(|c| c.service_type.as_deref())
335 .unwrap_or(self.service_type.as_str());
336 let routes = candidate
337 .and_then(|c| c.routes.as_deref())
338 .unwrap_or(self.routes.as_slice());
339 (service_type, routes)
340 }
341}
342
343fn default_true() -> bool {
344 true
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct Route {
350 pub input: String,
351 pub intent: String,
352 #[serde(default)]
353 pub params: BTreeMap<String, serde_json::Value>,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct FeedbackRule {
359 pub state: String,
360 pub feedback_type: String,
361 pub mapping: serde_json::Value,
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn server_to_edge_config_full_roundtrip() {
370 let msg = ServerToEdge::ConfigFull {
371 config: EdgeConfig {
372 edge_id: "living-room".into(),
373 mappings: vec![Mapping {
374 mapping_id: Uuid::nil(),
375 edge_id: "living-room".into(),
376 device_type: "nuimo".into(),
377 device_id: "C3:81:DF:4E:FF:6A".into(),
378 service_type: "roon".into(),
379 service_target: "zone-1".into(),
380 routes: vec![Route {
381 input: "rotate".into(),
382 intent: "volume_change".into(),
383 params: BTreeMap::from([("damping".into(), serde_json::json!(80))]),
384 }],
385 feedback: vec![],
386 active: true,
387 target_candidates: vec![],
388 target_switch_on: None,
389 }],
390 glyphs: vec![Glyph {
391 name: "play".into(),
392 pattern: " * \n ** ".into(),
393 builtin: false,
394 }],
395 },
396 };
397 let json = serde_json::to_string(&msg).unwrap();
398 assert!(json.contains("\"type\":\"config_full\""));
399 assert!(json.contains("\"edge_id\":\"living-room\""));
400
401 let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
402 match parsed {
403 ServerToEdge::ConfigFull { config } => {
404 assert_eq!(config.edge_id, "living-room");
405 assert_eq!(config.mappings.len(), 1);
406 }
407 _ => panic!("wrong variant"),
408 }
409 }
410
411 #[test]
412 fn edge_to_server_command_roundtrip() {
413 let ok = EdgeToServer::Command {
414 service_type: "roon".into(),
415 target: "zone-1".into(),
416 intent: "volume_change".into(),
417 params: serde_json::json!({"delta": 3}),
418 result: CommandResult::Ok,
419 latency_ms: Some(42),
420 output_id: None,
421 };
422 let json = serde_json::to_string(&ok).unwrap();
423 assert!(json.contains("\"type\":\"command\""));
424 assert!(json.contains("\"kind\":\"ok\""));
425 assert!(json.contains("\"latency_ms\":42"));
426 assert!(!json.contains("output_id"));
427 let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
428 match parsed {
429 EdgeToServer::Command { intent, result, .. } => {
430 assert_eq!(intent, "volume_change");
431 assert!(matches!(result, CommandResult::Ok));
432 }
433 _ => panic!("wrong variant"),
434 }
435
436 let err = EdgeToServer::Command {
437 service_type: "hue".into(),
438 target: "light-1".into(),
439 intent: "on_off".into(),
440 params: serde_json::json!({"on": true}),
441 result: CommandResult::Err {
442 message: "bridge timeout".into(),
443 },
444 latency_ms: None,
445 output_id: None,
446 };
447 let json = serde_json::to_string(&err).unwrap();
448 assert!(json.contains("\"kind\":\"err\""));
449 assert!(json.contains("\"message\":\"bridge timeout\""));
450 }
451
452 #[test]
453 fn edge_to_server_error_roundtrip() {
454 let msg = EdgeToServer::Error {
455 context: "hue.bridge".into(),
456 message: "connection refused".into(),
457 severity: ErrorSeverity::Error,
458 };
459 let json = serde_json::to_string(&msg).unwrap();
460 assert!(json.contains("\"type\":\"error\""));
461 assert!(json.contains("\"severity\":\"error\""));
462 }
463
464 #[test]
465 fn ui_frame_command_and_error_roundtrip() {
466 let cmd = UiFrame::Command {
467 edge_id: "air".into(),
468 service_type: "roon".into(),
469 target: "zone-1".into(),
470 intent: "play_pause".into(),
471 params: serde_json::json!({}),
472 result: CommandResult::Ok,
473 latency_ms: Some(18),
474 output_id: None,
475 at: "2026-04-23T12:00:00Z".into(),
476 };
477 let json = serde_json::to_string(&cmd).unwrap();
478 assert!(json.contains("\"type\":\"command\""));
479 let _: UiFrame = serde_json::from_str(&json).unwrap();
480
481 let err = UiFrame::Error {
482 edge_id: "air".into(),
483 context: "roon.client".into(),
484 message: "pair lost".into(),
485 severity: ErrorSeverity::Warn,
486 at: "2026-04-23T12:00:00Z".into(),
487 };
488 let json = serde_json::to_string(&err).unwrap();
489 assert!(json.contains("\"type\":\"error\""));
490 assert!(json.contains("\"severity\":\"warn\""));
491 let _: UiFrame = serde_json::from_str(&json).unwrap();
492 }
493
494 #[test]
495 fn edge_to_server_state_with_optional_output_id() {
496 let msg = EdgeToServer::State {
497 service_type: "roon".into(),
498 target: "zone-1".into(),
499 property: "volume".into(),
500 output_id: Some("output-1".into()),
501 value: serde_json::json!(50),
502 };
503 let json = serde_json::to_string(&msg).unwrap();
504 assert!(json.contains("\"output_id\":\"output-1\""));
505
506 let msg2 = EdgeToServer::State {
507 service_type: "roon".into(),
508 target: "zone-1".into(),
509 property: "playback".into(),
510 output_id: None,
511 value: serde_json::json!("playing"),
512 };
513 let json2 = serde_json::to_string(&msg2).unwrap();
514 assert!(!json2.contains("output_id"));
515 }
516}