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}
72
73#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum PatchOp {
76 Upsert,
77 Delete,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct EdgeConfig {
83 pub edge_id: String,
84 pub mappings: Vec<Mapping>,
85 #[serde(default)]
90 pub glyphs: Vec<Glyph>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Glyph {
98 pub name: String,
99 #[serde(default)]
100 pub pattern: String,
101 #[serde(default)]
102 pub builtin: bool,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(tag = "type", rename_all = "snake_case")]
108pub enum UiFrame {
109 Snapshot { snapshot: UiSnapshot },
111 EdgeOnline { edge: EdgeInfo },
113 EdgeOffline { edge_id: String },
115 ServiceState {
117 edge_id: String,
118 service_type: String,
119 target: String,
120 property: String,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 output_id: Option<String>,
123 value: serde_json::Value,
124 },
125 DeviceState {
127 edge_id: String,
128 device_type: String,
129 device_id: String,
130 property: String,
131 value: serde_json::Value,
132 },
133 MappingChanged {
135 mapping_id: Uuid,
136 op: PatchOp,
137 mapping: Option<Mapping>,
138 },
139 GlyphsChanged { glyphs: Vec<Glyph> },
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct UiSnapshot {
147 pub edges: Vec<EdgeInfo>,
148 pub service_states: Vec<ServiceStateEntry>,
149 pub device_states: Vec<DeviceStateEntry>,
150 pub mappings: Vec<Mapping>,
151 pub glyphs: Vec<Glyph>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct EdgeInfo {
157 pub edge_id: String,
158 pub online: bool,
159 pub version: String,
160 pub capabilities: Vec<String>,
161 pub last_seen: String,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct ServiceStateEntry {
167 pub edge_id: String,
168 pub service_type: String,
169 pub target: String,
170 pub property: String,
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub output_id: Option<String>,
173 pub value: serde_json::Value,
174 pub updated_at: String,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct DeviceStateEntry {
180 pub edge_id: String,
181 pub device_type: String,
182 pub device_id: String,
183 pub property: String,
184 pub value: serde_json::Value,
185 pub updated_at: String,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct Mapping {
193 pub mapping_id: Uuid,
194 pub edge_id: String,
195 pub device_type: String,
196 pub device_id: String,
197 pub service_type: String,
198 pub service_target: String,
199 pub routes: Vec<Route>,
200 #[serde(default)]
201 pub feedback: Vec<FeedbackRule>,
202 #[serde(default = "default_true")]
203 pub active: bool,
204 #[serde(default)]
207 pub target_candidates: Vec<TargetCandidate>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub target_switch_on: Option<String>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct TargetCandidate {
231 pub target: String,
233 #[serde(default)]
235 pub label: String,
236 pub glyph: String,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub service_type: Option<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub routes: Option<Vec<Route>>,
249}
250
251impl Mapping {
252 pub fn effective_for<'a>(&'a self, target: &str) -> (&'a str, &'a [Route]) {
260 let candidate = self.target_candidates.iter().find(|c| c.target == target);
261 let service_type = candidate
262 .and_then(|c| c.service_type.as_deref())
263 .unwrap_or(self.service_type.as_str());
264 let routes = candidate
265 .and_then(|c| c.routes.as_deref())
266 .unwrap_or(self.routes.as_slice());
267 (service_type, routes)
268 }
269}
270
271fn default_true() -> bool {
272 true
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct Route {
278 pub input: String,
279 pub intent: String,
280 #[serde(default)]
281 pub params: BTreeMap<String, serde_json::Value>,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct FeedbackRule {
287 pub state: String,
288 pub feedback_type: String,
289 pub mapping: serde_json::Value,
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[test]
297 fn server_to_edge_config_full_roundtrip() {
298 let msg = ServerToEdge::ConfigFull {
299 config: EdgeConfig {
300 edge_id: "living-room".into(),
301 mappings: vec![Mapping {
302 mapping_id: Uuid::nil(),
303 edge_id: "living-room".into(),
304 device_type: "nuimo".into(),
305 device_id: "C3:81:DF:4E:FF:6A".into(),
306 service_type: "roon".into(),
307 service_target: "zone-1".into(),
308 routes: vec![Route {
309 input: "rotate".into(),
310 intent: "volume_change".into(),
311 params: BTreeMap::from([("damping".into(), serde_json::json!(80))]),
312 }],
313 feedback: vec![],
314 active: true,
315 target_candidates: vec![],
316 target_switch_on: None,
317 }],
318 glyphs: vec![Glyph {
319 name: "play".into(),
320 pattern: " * \n ** ".into(),
321 builtin: false,
322 }],
323 },
324 };
325 let json = serde_json::to_string(&msg).unwrap();
326 assert!(json.contains("\"type\":\"config_full\""));
327 assert!(json.contains("\"edge_id\":\"living-room\""));
328
329 let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
330 match parsed {
331 ServerToEdge::ConfigFull { config } => {
332 assert_eq!(config.edge_id, "living-room");
333 assert_eq!(config.mappings.len(), 1);
334 }
335 _ => panic!("wrong variant"),
336 }
337 }
338
339 #[test]
340 fn edge_to_server_state_with_optional_output_id() {
341 let msg = EdgeToServer::State {
342 service_type: "roon".into(),
343 target: "zone-1".into(),
344 property: "volume".into(),
345 output_id: Some("output-1".into()),
346 value: serde_json::json!(50),
347 };
348 let json = serde_json::to_string(&msg).unwrap();
349 assert!(json.contains("\"output_id\":\"output-1\""));
350
351 let msg2 = EdgeToServer::State {
352 service_type: "roon".into(),
353 target: "zone-1".into(),
354 property: "playback".into(),
355 output_id: None,
356 value: serde_json::json!("playing"),
357 };
358 let json2 = serde_json::to_string(&msg2).unwrap();
359 assert!(!json2.contains("output_id"));
360 }
361}