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> },
DisplayGlyph {
device_type: String,
device_id: String,
pattern: String,
#[serde(skip_serializing_if = "Option::is_none")]
brightness: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
timeout_ms: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
transition: Option<String>,
},
DeviceConnect {
device_type: String,
device_id: String,
},
DeviceDisconnect {
device_type: String,
device_id: String,
},
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,
},
EdgeStatus {
#[serde(skip_serializing_if = "Option::is_none")]
wifi: Option<u8>,
},
}
#[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,
},
EdgeStatus {
edge_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
wifi: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
latency_ms: Option<u32>,
},
}
#[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 server_to_edge_display_glyph_roundtrip() {
let msg = ServerToEdge::DisplayGlyph {
device_type: "nuimo".into(),
device_id: "C3:81:DF:4E:FF:6A".into(),
pattern: " * \n ***** ".into(),
brightness: Some(0.5),
timeout_ms: Some(2000),
transition: Some("cross_fade".into()),
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"type\":\"display_glyph\""));
assert!(json.contains("\"device_type\":\"nuimo\""));
assert!(json.contains("\"brightness\":0.5"));
let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
match parsed {
ServerToEdge::DisplayGlyph {
device_type,
device_id,
pattern,
brightness,
timeout_ms,
transition,
} => {
assert_eq!(device_type, "nuimo");
assert_eq!(device_id, "C3:81:DF:4E:FF:6A");
assert!(pattern.contains('*'));
assert_eq!(brightness, Some(0.5));
assert_eq!(timeout_ms, Some(2000));
assert_eq!(transition.as_deref(), Some("cross_fade"));
}
_ => panic!("wrong variant"),
}
let minimal = ServerToEdge::DisplayGlyph {
device_type: "nuimo".into(),
device_id: "dev-1".into(),
pattern: "*".into(),
brightness: None,
timeout_ms: None,
transition: None,
};
let json = serde_json::to_string(&minimal).unwrap();
assert!(!json.contains("brightness"));
assert!(!json.contains("timeout_ms"));
assert!(!json.contains("transition"));
}
#[test]
fn server_to_edge_device_connect_disconnect_roundtrip() {
let connect = ServerToEdge::DeviceConnect {
device_type: "nuimo".into(),
device_id: "dev-1".into(),
};
let json = serde_json::to_string(&connect).unwrap();
assert!(json.contains("\"type\":\"device_connect\""));
let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
match parsed {
ServerToEdge::DeviceConnect {
device_type,
device_id,
} => {
assert_eq!(device_type, "nuimo");
assert_eq!(device_id, "dev-1");
}
_ => panic!("wrong variant"),
}
let disconnect = ServerToEdge::DeviceDisconnect {
device_type: "nuimo".into(),
device_id: "dev-1".into(),
};
let json = serde_json::to_string(&disconnect).unwrap();
assert!(json.contains("\"type\":\"device_disconnect\""));
let parsed: ServerToEdge = serde_json::from_str(&json).unwrap();
match parsed {
ServerToEdge::DeviceDisconnect {
device_type,
device_id,
} => {
assert_eq!(device_type, "nuimo");
assert_eq!(device_id, "dev-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_edge_status_roundtrip() {
let full = UiFrame::EdgeStatus {
edge_id: "air".into(),
wifi: Some(82),
latency_ms: Some(15),
};
let json = serde_json::to_string(&full).unwrap();
assert!(json.contains("\"type\":\"edge_status\""));
assert!(json.contains("\"wifi\":82"));
assert!(json.contains("\"latency_ms\":15"));
let parsed: UiFrame = serde_json::from_str(&json).unwrap();
match parsed {
UiFrame::EdgeStatus {
edge_id,
wifi,
latency_ms,
} => {
assert_eq!(edge_id, "air");
assert_eq!(wifi, Some(82));
assert_eq!(latency_ms, Some(15));
}
_ => panic!("wrong variant"),
}
let empty = UiFrame::EdgeStatus {
edge_id: "air".into(),
wifi: None,
latency_ms: None,
};
let json = serde_json::to_string(&empty).unwrap();
assert!(json.contains("\"edge_id\":\"air\""));
assert!(!json.contains("wifi"));
assert!(!json.contains("latency_ms"));
}
#[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_edge_status_roundtrip() {
let with_wifi = EdgeToServer::EdgeStatus { wifi: Some(73) };
let json = serde_json::to_string(&with_wifi).unwrap();
assert!(json.contains("\"type\":\"edge_status\""));
assert!(json.contains("\"wifi\":73"));
let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
match parsed {
EdgeToServer::EdgeStatus { wifi } => assert_eq!(wifi, Some(73)),
_ => panic!("wrong variant"),
}
let no_wifi = EdgeToServer::EdgeStatus { wifi: None };
let json = serde_json::to_string(&no_wifi).unwrap();
assert!(json.contains("\"type\":\"edge_status\""));
assert!(!json.contains("wifi"));
let parsed: EdgeToServer = serde_json::from_str(&json).unwrap();
match parsed {
EdgeToServer::EdgeStatus { wifi } => assert_eq!(wifi, None),
_ => panic!("wrong variant"),
}
}
#[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"));
}
}