use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerToEdge {
ConfigFull { config: EdgeConfig },
ConfigPatch {
mapping_id: Uuid,
op: PatchOp,
mapping: Option<Mapping>,
},
TargetSwitch {
mapping_id: Uuid,
service_target: String,
},
GlyphsUpdate { glyphs: Vec<Glyph> },
Ping,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EdgeToServer {
Hello {
edge_id: String,
version: String,
capabilities: Vec<String>,
},
State {
service_type: String,
target: String,
property: String,
#[serde(skip_serializing_if = "Option::is_none")]
output_id: Option<String>,
value: serde_json::Value,
},
DeviceState {
device_type: String,
device_id: String,
property: String,
value: serde_json::Value,
},
Pong,
SwitchTarget {
mapping_id: Uuid,
service_target: String,
},
Command {
service_type: String,
target: String,
intent: String,
#[serde(default)]
params: serde_json::Value,
result: CommandResult,
#[serde(skip_serializing_if = "Option::is_none")]
latency_ms: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
output_id: Option<String>,
},
Error {
context: String,
message: String,
severity: ErrorSeverity,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CommandResult {
Ok,
Err { message: String },
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorSeverity {
Warn,
Error,
Fatal,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PatchOp {
Upsert,
Delete,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EdgeConfig {
pub edge_id: String,
pub mappings: Vec<Mapping>,
#[serde(default)]
pub glyphs: Vec<Glyph>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Glyph {
pub name: String,
#[serde(default)]
pub pattern: String,
#[serde(default)]
pub builtin: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum UiFrame {
Snapshot { snapshot: UiSnapshot },
EdgeOnline { edge: EdgeInfo },
EdgeOffline { edge_id: String },
ServiceState {
edge_id: String,
service_type: String,
target: String,
property: String,
#[serde(skip_serializing_if = "Option::is_none")]
output_id: Option<String>,
value: serde_json::Value,
},
DeviceState {
edge_id: String,
device_type: String,
device_id: String,
property: String,
value: serde_json::Value,
},
MappingChanged {
mapping_id: Uuid,
op: PatchOp,
mapping: Option<Mapping>,
},
GlyphsChanged { glyphs: Vec<Glyph> },
Command {
edge_id: String,
service_type: String,
target: String,
intent: String,
#[serde(default)]
params: serde_json::Value,
result: CommandResult,
#[serde(skip_serializing_if = "Option::is_none")]
latency_ms: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
output_id: Option<String>,
at: String,
},
Error {
edge_id: String,
context: String,
message: String,
severity: ErrorSeverity,
at: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiSnapshot {
pub edges: Vec<EdgeInfo>,
pub service_states: Vec<ServiceStateEntry>,
pub device_states: Vec<DeviceStateEntry>,
pub mappings: Vec<Mapping>,
pub glyphs: Vec<Glyph>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EdgeInfo {
pub edge_id: String,
pub online: bool,
pub version: String,
pub capabilities: Vec<String>,
pub last_seen: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceStateEntry {
pub edge_id: String,
pub service_type: String,
pub target: String,
pub property: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_id: Option<String>,
pub value: serde_json::Value,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceStateEntry {
pub edge_id: String,
pub device_type: String,
pub device_id: String,
pub property: String,
pub value: serde_json::Value,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Mapping {
pub mapping_id: Uuid,
pub edge_id: String,
pub device_type: String,
pub device_id: String,
pub service_type: String,
pub service_target: String,
pub routes: Vec<Route>,
#[serde(default)]
pub feedback: Vec<FeedbackRule>,
#[serde(default = "default_true")]
pub active: bool,
#[serde(default)]
pub target_candidates: Vec<TargetCandidate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_switch_on: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TargetCandidate {
pub target: String,
#[serde(default)]
pub label: String,
pub glyph: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub routes: Option<Vec<Route>>,
}
impl Mapping {
pub fn effective_for<'a>(&'a self, target: &str) -> (&'a str, &'a [Route]) {
let candidate = self.target_candidates.iter().find(|c| c.target == target);
let service_type = candidate
.and_then(|c| c.service_type.as_deref())
.unwrap_or(self.service_type.as_str());
let routes = candidate
.and_then(|c| c.routes.as_deref())
.unwrap_or(self.routes.as_slice());
(service_type, routes)
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Route {
pub input: String,
pub intent: String,
#[serde(default)]
pub params: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedbackRule {
pub state: String,
pub feedback_type: String,
pub mapping: serde_json::Value,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn server_to_edge_config_full_roundtrip() {
let msg = ServerToEdge::ConfigFull {
config: EdgeConfig {
edge_id: "living-room".into(),
mappings: vec![Mapping {
mapping_id: Uuid::nil(),
edge_id: "living-room".into(),
device_type: "nuimo".into(),
device_id: "C3:81:DF:4E:FF:6A".into(),
service_type: "roon".into(),
service_target: "zone-1".into(),
routes: vec![Route {
input: "rotate".into(),
intent: "volume_change".into(),
params: BTreeMap::from([("damping".into(), serde_json::json!(80))]),
}],
feedback: vec![],
active: true,
target_candidates: vec![],
target_switch_on: None,
}],
glyphs: vec![Glyph {
name: "play".into(),
pattern: " * \n ** ".into(),
builtin: false,
}],
},
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"type\":\"config_full\""));
assert!(json.contains("\"edge_id\":\"living-room\""));
let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
match parsed {
ServerToEdge::ConfigFull { config } => {
assert_eq!(config.edge_id, "living-room");
assert_eq!(config.mappings.len(), 1);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn edge_to_server_command_roundtrip() {
let ok = EdgeToServer::Command {
service_type: "roon".into(),
target: "zone-1".into(),
intent: "volume_change".into(),
params: serde_json::json!({"delta": 3}),
result: CommandResult::Ok,
latency_ms: Some(42),
output_id: None,
};
let json = serde_json::to_string(&ok).unwrap();
assert!(json.contains("\"type\":\"command\""));
assert!(json.contains("\"kind\":\"ok\""));
assert!(json.contains("\"latency_ms\":42"));
assert!(!json.contains("output_id"));
let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
match parsed {
EdgeToServer::Command { intent, result, .. } => {
assert_eq!(intent, "volume_change");
assert!(matches!(result, CommandResult::Ok));
}
_ => panic!("wrong variant"),
}
let err = EdgeToServer::Command {
service_type: "hue".into(),
target: "light-1".into(),
intent: "on_off".into(),
params: serde_json::json!({"on": true}),
result: CommandResult::Err {
message: "bridge timeout".into(),
},
latency_ms: None,
output_id: None,
};
let json = serde_json::to_string(&err).unwrap();
assert!(json.contains("\"kind\":\"err\""));
assert!(json.contains("\"message\":\"bridge timeout\""));
}
#[test]
fn edge_to_server_error_roundtrip() {
let msg = EdgeToServer::Error {
context: "hue.bridge".into(),
message: "connection refused".into(),
severity: ErrorSeverity::Error,
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"type\":\"error\""));
assert!(json.contains("\"severity\":\"error\""));
}
#[test]
fn ui_frame_command_and_error_roundtrip() {
let cmd = UiFrame::Command {
edge_id: "air".into(),
service_type: "roon".into(),
target: "zone-1".into(),
intent: "play_pause".into(),
params: serde_json::json!({}),
result: CommandResult::Ok,
latency_ms: Some(18),
output_id: None,
at: "2026-04-23T12:00:00Z".into(),
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("\"type\":\"command\""));
let _: UiFrame = serde_json::from_str(&json).unwrap();
let err = UiFrame::Error {
edge_id: "air".into(),
context: "roon.client".into(),
message: "pair lost".into(),
severity: ErrorSeverity::Warn,
at: "2026-04-23T12:00:00Z".into(),
};
let json = serde_json::to_string(&err).unwrap();
assert!(json.contains("\"type\":\"error\""));
assert!(json.contains("\"severity\":\"warn\""));
let _: UiFrame = serde_json::from_str(&json).unwrap();
}
#[test]
fn edge_to_server_state_with_optional_output_id() {
let msg = EdgeToServer::State {
service_type: "roon".into(),
target: "zone-1".into(),
property: "volume".into(),
output_id: Some("output-1".into()),
value: serde_json::json!(50),
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"output_id\":\"output-1\""));
let msg2 = EdgeToServer::State {
service_type: "roon".into(),
target: "zone-1".into(),
property: "playback".into(),
output_id: None,
value: serde_json::json!("playing"),
};
let json2 = serde_json::to_string(&msg2).unwrap();
assert!(!json2.contains("output_id"));
}
}