1use std::sync::Arc;
2
3use serde::{Deserialize, Serialize};
4use tokio::sync::broadcast;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct UnifiEvent {
12 pub key: String,
14
15 pub subsystem: String,
17
18 pub site_id: String,
20
21 #[serde(default, alias = "msg")]
24 pub message: Option<String>,
25
26 #[serde(default)]
28 pub datetime: Option<String>,
29
30 #[serde(flatten)]
32 pub extra: serde_json::Value,
33}
34
35#[derive(Debug, Deserialize)]
36struct WsEnvelope {
37 #[allow(dead_code)]
38 meta: WsMeta,
39 data: Vec<serde_json::Value>,
40}
41
42#[derive(Debug, Deserialize)]
43struct WsMeta {
44 #[allow(dead_code)]
45 rc: String,
46 #[serde(default)]
47 message: Option<String>,
48}
49
50pub(in crate::websocket) fn parse_and_broadcast(
51 text: &str,
52 event_tx: &broadcast::Sender<Arc<UnifiEvent>>,
53) {
54 let envelope: WsEnvelope = match serde_json::from_str(text) {
55 Ok(envelope) => envelope,
56 Err(error) => {
57 tracing::debug!(error = %error, "Failed to parse WebSocket envelope");
58 return;
59 }
60 };
61
62 let msg_type = envelope.meta.message.as_deref().unwrap_or("");
63
64 for data in envelope.data {
65 let event = match msg_type {
66 "events" => match serde_json::from_value::<UnifiEvent>(data.clone()) {
67 Ok(event) => event,
68 Err(error) => {
69 tracing::debug!(
70 error = %error,
71 msg_type,
72 "Could not deserialize event, constructing from raw data"
73 );
74 event_from_raw(msg_type, &data)
75 }
76 },
77 _ => event_from_raw(msg_type, &data),
78 };
79
80 let _ = event_tx.send(Arc::new(event));
81 }
82}
83
84fn event_from_raw(msg_type: &str, data: &serde_json::Value) -> UnifiEvent {
85 UnifiEvent {
86 key: data["key"].as_str().unwrap_or(msg_type).to_string(),
87 subsystem: data["subsystem"].as_str().unwrap_or("unknown").to_string(),
88 site_id: data["site_id"].as_str().unwrap_or("").to_string(),
89 message: data["msg"]
90 .as_str()
91 .or_else(|| data["message"].as_str())
92 .map(String::from),
93 datetime: data["datetime"].as_str().map(String::from),
94 extra: data.clone(),
95 }
96}
97
98#[cfg(test)]
99#[allow(clippy::unwrap_used)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn parse_event_from_raw_json() {
105 let data = serde_json::json!({
106 "key": "EVT_WU_Connected",
107 "subsystem": "wlan",
108 "site_id": "abc123",
109 "msg": "User[aa:bb:cc:dd:ee:ff] connected",
110 "datetime": "2026-02-10T12:00:00Z",
111 "user": "aa:bb:cc:dd:ee:ff",
112 "ssid": "MyNetwork"
113 });
114
115 let event = event_from_raw("events", &data);
116 assert_eq!(event.key, "EVT_WU_Connected");
117 assert_eq!(event.subsystem, "wlan");
118 assert_eq!(event.site_id, "abc123");
119 assert_eq!(
120 event.message.as_deref(),
121 Some("User[aa:bb:cc:dd:ee:ff] connected")
122 );
123 assert_eq!(event.datetime.as_deref(), Some("2026-02-10T12:00:00Z"));
124 }
125
126 #[test]
127 fn parse_sync_event_from_raw_json() {
128 let data = serde_json::json!({
129 "mac": "aa:bb:cc:dd:ee:ff",
130 "state": 1,
131 "site_id": "site1"
132 });
133
134 let event = event_from_raw("device:sync", &data);
135 assert_eq!(event.key, "device:sync");
136 assert_eq!(event.subsystem, "unknown");
137 assert_eq!(event.site_id, "site1");
138 }
139
140 #[test]
141 fn deserialize_unifi_event() {
142 let json = r#"{
143 "key": "EVT_SW_Disconnected",
144 "subsystem": "lan",
145 "site_id": "default",
146 "message": "Switch lost contact",
147 "datetime": "2026-02-10T13:00:00Z",
148 "sw": "aa:bb:cc:dd:ee:ff",
149 "port": 4
150 }"#;
151
152 let event: UnifiEvent = serde_json::from_str(json).unwrap();
153 assert_eq!(event.key, "EVT_SW_Disconnected");
154 assert_eq!(event.subsystem, "lan");
155 assert_eq!(event.site_id, "default");
156 assert_eq!(event.message.as_deref(), Some("Switch lost contact"));
157 assert_eq!(event.extra["sw"], "aa:bb:cc:dd:ee:ff");
158 assert_eq!(event.extra["port"], 4);
159 }
160
161 #[test]
162 fn deserialize_unifi_event_msg_alias() {
163 let json = r#"{
164 "key": "EVT_WU_Connected",
165 "subsystem": "wlan",
166 "site_id": "abc123",
167 "msg": "User[aa:bb:cc:dd:ee:ff] connected",
168 "datetime": "2026-02-10T12:00:00Z"
169 }"#;
170
171 let event: UnifiEvent = serde_json::from_str(json).unwrap();
172 assert_eq!(
173 event.message.as_deref(),
174 Some("User[aa:bb:cc:dd:ee:ff] connected")
175 );
176 }
177
178 #[test]
179 fn parse_and_broadcast_events_message() {
180 let (tx, mut rx) = broadcast::channel(16);
181
182 let raw = serde_json::json!({
183 "meta": { "rc": "ok", "message": "events" },
184 "data": [{
185 "key": "EVT_WU_Connected",
186 "subsystem": "wlan",
187 "site_id": "default",
188 "msg": "Client connected",
189 "user": "aa:bb:cc:dd:ee:ff"
190 }]
191 });
192
193 parse_and_broadcast(&raw.to_string(), &tx);
194
195 let event = rx.try_recv().unwrap();
196 assert_eq!(event.key, "EVT_WU_Connected");
197 assert_eq!(event.subsystem, "wlan");
198 }
199
200 #[test]
201 fn parse_and_broadcast_sync_message() {
202 let (tx, mut rx) = broadcast::channel(16);
203
204 let raw = serde_json::json!({
205 "meta": { "rc": "ok", "message": "device:sync" },
206 "data": [{
207 "mac": "aa:bb:cc:dd:ee:ff",
208 "state": 1,
209 "site_id": "site1"
210 }]
211 });
212
213 parse_and_broadcast(&raw.to_string(), &tx);
214
215 let event = rx.try_recv().unwrap();
216 assert_eq!(event.key, "device:sync");
217 assert_eq!(event.site_id, "site1");
218 }
219
220 #[test]
221 fn parse_and_broadcast_malformed_json() {
222 let (tx, mut rx) = broadcast::channel::<Arc<UnifiEvent>>(16);
223
224 parse_and_broadcast("not json at all", &tx);
225
226 assert!(rx.try_recv().is_err());
227 }
228}