Skip to main content

unifly_api/websocket/
parser.rs

1use std::sync::Arc;
2
3use serde::{Deserialize, Serialize};
4use tokio::sync::broadcast;
5
6/// A parsed event from the UniFi WebSocket stream.
7///
8/// Uses `#[serde(flatten)]` to capture all fields beyond the core set,
9/// so nothing from the controller is silently dropped.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct UnifiEvent {
12    /// Event key, e.g. `"EVT_WU_Connected"`, `"EVT_SW_Disconnected"`.
13    pub key: String,
14
15    /// Subsystem that emitted the event: `"wlan"`, `"lan"`, `"sta"`, `"gw"`, etc.
16    pub subsystem: String,
17
18    /// Site ID this event belongs to.
19    pub site_id: String,
20
21    /// Human-readable event message, if present.
22    /// The controller sends `"msg"` in most payloads; `"message"` is a rarer variant.
23    #[serde(default, alias = "msg")]
24    pub message: Option<String>,
25
26    /// ISO-8601 timestamp from the controller.
27    #[serde(default)]
28    pub datetime: Option<String>,
29
30    /// All remaining fields the controller sends.
31    #[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}