Skip to main content

unifly_api/convert/
device.rs

1use chrono::Utc;
2
3use crate::integration_types;
4use crate::model::common::{Bandwidth, DataSource};
5use crate::model::device::{Device, DeviceState, DeviceStats, DeviceType};
6use crate::model::entity_id::{EntityId, MacAddress};
7use crate::session::models::SessionDevice;
8
9use super::helpers::{epoch_to_datetime, parse_ip, parse_iso, parse_legacy_wan_ipv6};
10use super::interface::{
11    parse_integration_ports, parse_integration_radios, parse_session_ports, parse_session_radios,
12};
13
14// ── Session API ──────────────────────────────────────────────────
15
16fn infer_device_type(device_type: &str, model: Option<&String>) -> DeviceType {
17    match device_type {
18        "uap" => DeviceType::AccessPoint,
19        "usw" => DeviceType::Switch,
20        "ugw" | "udm" => DeviceType::Gateway,
21        _ => {
22            if let Some(m) = model {
23                let upper = m.to_uppercase();
24                if upper.starts_with("UAP") || upper.starts_with("U6") || upper.starts_with("U7") {
25                    DeviceType::AccessPoint
26                } else if upper.starts_with("USW") || upper.starts_with("USL") {
27                    DeviceType::Switch
28                } else if upper.starts_with("UGW")
29                    || upper.starts_with("UDM")
30                    || upper.starts_with("UDR")
31                    || upper.starts_with("UXG")
32                    || upper.starts_with("UCG")
33                    || upper.starts_with("UCK")
34                {
35                    DeviceType::Gateway
36                } else {
37                    DeviceType::Other
38                }
39            } else {
40                DeviceType::Other
41            }
42        }
43    }
44}
45
46fn map_device_state(code: i32) -> DeviceState {
47    match code {
48        0 => DeviceState::Offline,
49        1 => DeviceState::Online,
50        2 => DeviceState::PendingAdoption,
51        4 => DeviceState::Updating,
52        5 => DeviceState::GettingReady,
53        _ => DeviceState::Unknown,
54    }
55}
56
57fn parse_session_uplink(
58    extra: &serde_json::Map<String, serde_json::Value>,
59) -> (Option<MacAddress>, Option<u32>) {
60    let Some(uplink) = extra.get("uplink").and_then(|v| v.as_object()) else {
61        return (None, None);
62    };
63    let mac = uplink
64        .get("uplink_mac")
65        .and_then(|v| v.as_str())
66        .filter(|s| !s.is_empty())
67        .map(MacAddress::new);
68    let port = uplink
69        .get("uplink_remote_port")
70        .and_then(serde_json::Value::as_u64)
71        .and_then(|n| u32::try_from(n).ok());
72    (mac, port)
73}
74
75impl From<SessionDevice> for Device {
76    fn from(d: SessionDevice) -> Self {
77        let device_type = infer_device_type(&d.device_type, d.model.as_ref());
78        let state = map_device_state(d.state);
79        let entity_id = if d.id.is_empty() {
80            d.mac.clone()
81        } else {
82            d.id.clone()
83        };
84        let (uplink_device_mac, uplink_port_idx) = parse_session_uplink(&d.extra);
85
86        let device_stats = {
87            let mut s = DeviceStats {
88                uptime_secs: d.uptime.and_then(|u| u.try_into().ok()),
89                ..Default::default()
90            };
91            if let Some(ref sys) = d.sys_stats {
92                s.load_average_1m = sys.load_1.as_deref().and_then(|v| v.parse().ok());
93                s.load_average_5m = sys.load_5.as_deref().and_then(|v| v.parse().ok());
94                s.load_average_15m = sys.load_15.as_deref().and_then(|v| v.parse().ok());
95                s.cpu_utilization_pct = sys.cpu.as_deref().and_then(|v| v.parse().ok());
96                s.memory_utilization_pct = match (sys.mem_used, sys.mem_total) {
97                    (Some(used), Some(total)) if total > 0 =>
98                    {
99                        #[allow(clippy::as_conversions, clippy::cast_precision_loss)]
100                        Some((used as f64 / total as f64) * 100.0)
101                    }
102                    _ => None,
103                };
104            }
105            s
106        };
107
108        Device {
109            id: EntityId::from(entity_id),
110            mac: MacAddress::new(&d.mac),
111            ip: parse_ip(d.ip.as_ref()),
112            wan_ipv6: parse_legacy_wan_ipv6(&d.extra),
113            name: d.name,
114            model: d.model,
115            device_type,
116            state,
117            firmware_version: d.version,
118            firmware_updatable: d.upgradable.unwrap_or(false),
119            adopted_at: None,
120            provisioned_at: None,
121            last_seen: epoch_to_datetime(d.last_seen),
122            serial: d.serial,
123            supported: true,
124            ports: parse_session_ports(&d.extra),
125            radios: parse_session_radios(&d.extra),
126            uplink_device_id: None,
127            uplink_device_mac,
128            uplink_port_idx,
129            has_switching: device_type == DeviceType::Switch || device_type == DeviceType::Gateway,
130            has_access_point: device_type == DeviceType::AccessPoint,
131            stats: device_stats,
132            client_count: d.num_sta.and_then(|n| n.try_into().ok()),
133            origin: None,
134            source: DataSource::SessionApi,
135            updated_at: Utc::now(),
136        }
137    }
138}
139
140// ── Integration API ──────────────────────────────────────────────
141
142fn map_integration_device_state(state: &str) -> DeviceState {
143    match state {
144        "ONLINE" => DeviceState::Online,
145        "OFFLINE" => DeviceState::Offline,
146        "PENDING_ADOPTION" => DeviceState::PendingAdoption,
147        "UPDATING" => DeviceState::Updating,
148        "GETTING_READY" => DeviceState::GettingReady,
149        "ADOPTING" => DeviceState::Adopting,
150        "DELETING" => DeviceState::Deleting,
151        "CONNECTION_INTERRUPTED" => DeviceState::ConnectionInterrupted,
152        "ISOLATED" => DeviceState::Isolated,
153        _ => DeviceState::Unknown,
154    }
155}
156
157fn infer_device_type_integration(features: &[String], model: &str) -> DeviceType {
158    let has = |f: &str| features.iter().any(|s| s == f);
159
160    let upper = model.to_uppercase();
161    let is_gateway_model = upper.starts_with("UGW")
162        || upper.starts_with("UDM")
163        || upper.starts_with("UDR")
164        || upper.starts_with("UXG")
165        || upper.starts_with("UCG")
166        || upper.starts_with("UCK");
167
168    if is_gateway_model || (has("switching") && has("routing")) || has("gateway") {
169        DeviceType::Gateway
170    } else if has("accessPoint") {
171        DeviceType::AccessPoint
172    } else if has("switching") {
173        DeviceType::Switch
174    } else {
175        let model_owned = model.to_owned();
176        infer_device_type("", Some(&model_owned))
177    }
178}
179
180impl From<integration_types::DeviceResponse> for Device {
181    fn from(d: integration_types::DeviceResponse) -> Self {
182        let device_type = infer_device_type_integration(&d.features, &d.model);
183        let state = map_integration_device_state(&d.state);
184
185        Device {
186            id: EntityId::Uuid(d.id),
187            mac: MacAddress::new(&d.mac_address),
188            ip: d.ip_address.as_deref().and_then(|s| s.parse().ok()),
189            wan_ipv6: None,
190            name: Some(d.name),
191            model: Some(d.model),
192            device_type,
193            state,
194            firmware_version: d.firmware_version,
195            firmware_updatable: d.firmware_updatable,
196            adopted_at: None,
197            provisioned_at: None,
198            last_seen: None,
199            serial: None,
200            supported: d.supported,
201            ports: parse_integration_ports(&d.interfaces),
202            radios: parse_integration_radios(&d.interfaces),
203            uplink_device_id: None,
204            uplink_device_mac: None,
205            uplink_port_idx: None,
206            has_switching: d.features.iter().any(|f| f == "switching"),
207            has_access_point: d.features.iter().any(|f| f == "accessPoint"),
208            stats: DeviceStats::default(),
209            client_count: None,
210            origin: None,
211            source: DataSource::IntegrationApi,
212            updated_at: Utc::now(),
213        }
214    }
215}
216
217pub(crate) fn device_stats_from_integration(
218    resp: &integration_types::DeviceStatisticsResponse,
219) -> DeviceStats {
220    DeviceStats {
221        uptime_secs: resp.uptime_sec.and_then(|u| u.try_into().ok()),
222        cpu_utilization_pct: resp.cpu_utilization_pct,
223        memory_utilization_pct: resp.memory_utilization_pct,
224        load_average_1m: resp.load_average_1_min,
225        load_average_5m: resp.load_average_5_min,
226        load_average_15m: resp.load_average_15_min,
227        last_heartbeat: resp.last_heartbeat_at.as_deref().and_then(parse_iso),
228        next_heartbeat: resp.next_heartbeat_at.as_deref().and_then(parse_iso),
229        uplink_bandwidth: resp.uplink.as_ref().and_then(|u| {
230            let tx = u
231                .get("txRateBps")
232                .or_else(|| u.get("txBytesPerSecond"))
233                .or_else(|| u.get("tx_bytes-r"))
234                .and_then(serde_json::Value::as_u64)
235                .unwrap_or(0);
236            let rx = u
237                .get("rxRateBps")
238                .or_else(|| u.get("rxBytesPerSecond"))
239                .or_else(|| u.get("rx_bytes-r"))
240                .and_then(serde_json::Value::as_u64)
241                .unwrap_or(0);
242            if tx == 0 && rx == 0 {
243                None
244            } else {
245                Some(Bandwidth {
246                    tx_bytes_per_sec: tx,
247                    rx_bytes_per_sec: rx,
248                })
249            }
250        }),
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use serde_json::json;
258
259    #[test]
260    fn device_type_from_legacy_type_field() {
261        assert_eq!(infer_device_type("uap", None), DeviceType::AccessPoint);
262        assert_eq!(infer_device_type("usw", None), DeviceType::Switch);
263        assert_eq!(infer_device_type("ugw", None), DeviceType::Gateway);
264        assert_eq!(infer_device_type("udm", None), DeviceType::Gateway);
265    }
266
267    #[test]
268    fn device_type_from_model_fallback() {
269        assert_eq!(
270            infer_device_type("unknown", Some(&"UAP-AC-Pro".into())),
271            DeviceType::AccessPoint
272        );
273        assert_eq!(
274            infer_device_type("unknown", Some(&"U6-LR".into())),
275            DeviceType::AccessPoint
276        );
277        assert_eq!(
278            infer_device_type("unknown", Some(&"USW-24-PoE".into())),
279            DeviceType::Switch
280        );
281        assert_eq!(
282            infer_device_type("unknown", Some(&"UDM-Pro".into())),
283            DeviceType::Gateway
284        );
285        assert_eq!(
286            infer_device_type("unknown", Some(&"UCG-Max".into())),
287            DeviceType::Gateway
288        );
289    }
290
291    #[test]
292    fn integration_device_type_gateway_by_model() {
293        assert_eq!(
294            infer_device_type_integration(&["switching".into()], "UCG-Max"),
295            DeviceType::Gateway
296        );
297        assert_eq!(
298            infer_device_type_integration(&["switching".into(), "routing".into()], "UDM-Pro"),
299            DeviceType::Gateway
300        );
301    }
302
303    #[test]
304    fn device_state_mapping() {
305        assert_eq!(map_device_state(0), DeviceState::Offline);
306        assert_eq!(map_device_state(1), DeviceState::Online);
307        assert_eq!(map_device_state(2), DeviceState::PendingAdoption);
308        assert_eq!(map_device_state(4), DeviceState::Updating);
309        assert_eq!(map_device_state(5), DeviceState::GettingReady);
310        assert_eq!(map_device_state(99), DeviceState::Unknown);
311    }
312
313    #[test]
314    fn legacy_device_falls_back_to_mac_when_id_missing() {
315        let raw = json!({
316            "mac": "dc:9f:db:00:00:01",
317            "type": "ugw",
318            "name": "USG 3P",
319            "state": 2
320        });
321
322        let session_device: SessionDevice =
323            serde_json::from_value(raw).expect("session device should deserialize");
324        let device: Device = session_device.into();
325
326        assert_eq!(device.id.to_string(), "dc:9f:db:00:00:01");
327        assert_eq!(device.mac.to_string(), "dc:9f:db:00:00:01");
328        assert_eq!(device.state, DeviceState::PendingAdoption);
329    }
330
331    #[test]
332    fn session_device_carries_wired_uplink_mac_and_remote_port() {
333        let raw = json!({
334            "mac": "6c:63:f8:36:53:42",
335            "type": "uap",
336            "name": "U7-Pro-Wall",
337            "state": 1,
338            "uplink": {
339                "type": "wire",
340                "uplink_mac": "58:d6:1f:62:33:01",
341                "uplink_remote_port": 4
342            }
343        });
344
345        let session_device: SessionDevice = serde_json::from_value(raw).expect("deserialize");
346        let device: Device = session_device.into();
347
348        assert_eq!(
349            device.uplink_device_mac.as_ref().map(ToString::to_string),
350            Some("58:d6:1f:62:33:01".into())
351        );
352        assert_eq!(device.uplink_port_idx, Some(4));
353    }
354
355    #[test]
356    fn session_device_with_no_uplink_block_has_none() {
357        let raw = json!({
358            "mac": "58:d6:1f:62:33:01",
359            "type": "usw",
360            "state": 1
361        });
362
363        let session_device: SessionDevice = serde_json::from_value(raw).expect("deserialize");
364        let device: Device = session_device.into();
365
366        assert!(device.uplink_device_mac.is_none());
367        assert!(device.uplink_port_idx.is_none());
368    }
369}