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
57impl From<SessionDevice> for Device {
58    fn from(d: SessionDevice) -> Self {
59        let device_type = infer_device_type(&d.device_type, d.model.as_ref());
60        let state = map_device_state(d.state);
61        let entity_id = if d.id.is_empty() {
62            d.mac.clone()
63        } else {
64            d.id.clone()
65        };
66
67        let device_stats = {
68            let mut s = DeviceStats {
69                uptime_secs: d.uptime.and_then(|u| u.try_into().ok()),
70                ..Default::default()
71            };
72            if let Some(ref sys) = d.sys_stats {
73                s.load_average_1m = sys.load_1.as_deref().and_then(|v| v.parse().ok());
74                s.load_average_5m = sys.load_5.as_deref().and_then(|v| v.parse().ok());
75                s.load_average_15m = sys.load_15.as_deref().and_then(|v| v.parse().ok());
76                s.cpu_utilization_pct = sys.cpu.as_deref().and_then(|v| v.parse().ok());
77                s.memory_utilization_pct = match (sys.mem_used, sys.mem_total) {
78                    (Some(used), Some(total)) if total > 0 =>
79                    {
80                        #[allow(clippy::as_conversions, clippy::cast_precision_loss)]
81                        Some((used as f64 / total as f64) * 100.0)
82                    }
83                    _ => None,
84                };
85            }
86            s
87        };
88
89        Device {
90            id: EntityId::from(entity_id),
91            mac: MacAddress::new(&d.mac),
92            ip: parse_ip(d.ip.as_ref()),
93            wan_ipv6: parse_legacy_wan_ipv6(&d.extra),
94            name: d.name,
95            model: d.model,
96            device_type,
97            state,
98            firmware_version: d.version,
99            firmware_updatable: d.upgradable.unwrap_or(false),
100            adopted_at: None,
101            provisioned_at: None,
102            last_seen: epoch_to_datetime(d.last_seen),
103            serial: d.serial,
104            supported: true,
105            ports: parse_session_ports(&d.extra),
106            radios: parse_session_radios(&d.extra),
107            uplink_device_id: None,
108            uplink_device_mac: None,
109            has_switching: device_type == DeviceType::Switch || device_type == DeviceType::Gateway,
110            has_access_point: device_type == DeviceType::AccessPoint,
111            stats: device_stats,
112            client_count: d.num_sta.and_then(|n| n.try_into().ok()),
113            origin: None,
114            source: DataSource::SessionApi,
115            updated_at: Utc::now(),
116        }
117    }
118}
119
120// ── Integration API ──────────────────────────────────────────────
121
122fn map_integration_device_state(state: &str) -> DeviceState {
123    match state {
124        "ONLINE" => DeviceState::Online,
125        "OFFLINE" => DeviceState::Offline,
126        "PENDING_ADOPTION" => DeviceState::PendingAdoption,
127        "UPDATING" => DeviceState::Updating,
128        "GETTING_READY" => DeviceState::GettingReady,
129        "ADOPTING" => DeviceState::Adopting,
130        "DELETING" => DeviceState::Deleting,
131        "CONNECTION_INTERRUPTED" => DeviceState::ConnectionInterrupted,
132        "ISOLATED" => DeviceState::Isolated,
133        _ => DeviceState::Unknown,
134    }
135}
136
137fn infer_device_type_integration(features: &[String], model: &str) -> DeviceType {
138    let has = |f: &str| features.iter().any(|s| s == f);
139
140    let upper = model.to_uppercase();
141    let is_gateway_model = upper.starts_with("UGW")
142        || upper.starts_with("UDM")
143        || upper.starts_with("UDR")
144        || upper.starts_with("UXG")
145        || upper.starts_with("UCG")
146        || upper.starts_with("UCK");
147
148    if is_gateway_model || (has("switching") && has("routing")) || has("gateway") {
149        DeviceType::Gateway
150    } else if has("accessPoint") {
151        DeviceType::AccessPoint
152    } else if has("switching") {
153        DeviceType::Switch
154    } else {
155        let model_owned = model.to_owned();
156        infer_device_type("", Some(&model_owned))
157    }
158}
159
160impl From<integration_types::DeviceResponse> for Device {
161    fn from(d: integration_types::DeviceResponse) -> Self {
162        let device_type = infer_device_type_integration(&d.features, &d.model);
163        let state = map_integration_device_state(&d.state);
164
165        Device {
166            id: EntityId::Uuid(d.id),
167            mac: MacAddress::new(&d.mac_address),
168            ip: d.ip_address.as_deref().and_then(|s| s.parse().ok()),
169            wan_ipv6: None,
170            name: Some(d.name),
171            model: Some(d.model),
172            device_type,
173            state,
174            firmware_version: d.firmware_version,
175            firmware_updatable: d.firmware_updatable,
176            adopted_at: None,
177            provisioned_at: None,
178            last_seen: None,
179            serial: None,
180            supported: d.supported,
181            ports: parse_integration_ports(&d.interfaces),
182            radios: parse_integration_radios(&d.interfaces),
183            uplink_device_id: None,
184            uplink_device_mac: None,
185            has_switching: d.features.iter().any(|f| f == "switching"),
186            has_access_point: d.features.iter().any(|f| f == "accessPoint"),
187            stats: DeviceStats::default(),
188            client_count: None,
189            origin: None,
190            source: DataSource::IntegrationApi,
191            updated_at: Utc::now(),
192        }
193    }
194}
195
196pub(crate) fn device_stats_from_integration(
197    resp: &integration_types::DeviceStatisticsResponse,
198) -> DeviceStats {
199    DeviceStats {
200        uptime_secs: resp.uptime_sec.and_then(|u| u.try_into().ok()),
201        cpu_utilization_pct: resp.cpu_utilization_pct,
202        memory_utilization_pct: resp.memory_utilization_pct,
203        load_average_1m: resp.load_average_1_min,
204        load_average_5m: resp.load_average_5_min,
205        load_average_15m: resp.load_average_15_min,
206        last_heartbeat: resp.last_heartbeat_at.as_deref().and_then(parse_iso),
207        next_heartbeat: resp.next_heartbeat_at.as_deref().and_then(parse_iso),
208        uplink_bandwidth: resp.uplink.as_ref().and_then(|u| {
209            let tx = u
210                .get("txRateBps")
211                .or_else(|| u.get("txBytesPerSecond"))
212                .or_else(|| u.get("tx_bytes-r"))
213                .and_then(serde_json::Value::as_u64)
214                .unwrap_or(0);
215            let rx = u
216                .get("rxRateBps")
217                .or_else(|| u.get("rxBytesPerSecond"))
218                .or_else(|| u.get("rx_bytes-r"))
219                .and_then(serde_json::Value::as_u64)
220                .unwrap_or(0);
221            if tx == 0 && rx == 0 {
222                None
223            } else {
224                Some(Bandwidth {
225                    tx_bytes_per_sec: tx,
226                    rx_bytes_per_sec: rx,
227                })
228            }
229        }),
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use serde_json::json;
237
238    #[test]
239    fn device_type_from_legacy_type_field() {
240        assert_eq!(infer_device_type("uap", None), DeviceType::AccessPoint);
241        assert_eq!(infer_device_type("usw", None), DeviceType::Switch);
242        assert_eq!(infer_device_type("ugw", None), DeviceType::Gateway);
243        assert_eq!(infer_device_type("udm", None), DeviceType::Gateway);
244    }
245
246    #[test]
247    fn device_type_from_model_fallback() {
248        assert_eq!(
249            infer_device_type("unknown", Some(&"UAP-AC-Pro".into())),
250            DeviceType::AccessPoint
251        );
252        assert_eq!(
253            infer_device_type("unknown", Some(&"U6-LR".into())),
254            DeviceType::AccessPoint
255        );
256        assert_eq!(
257            infer_device_type("unknown", Some(&"USW-24-PoE".into())),
258            DeviceType::Switch
259        );
260        assert_eq!(
261            infer_device_type("unknown", Some(&"UDM-Pro".into())),
262            DeviceType::Gateway
263        );
264        assert_eq!(
265            infer_device_type("unknown", Some(&"UCG-Max".into())),
266            DeviceType::Gateway
267        );
268    }
269
270    #[test]
271    fn integration_device_type_gateway_by_model() {
272        assert_eq!(
273            infer_device_type_integration(&["switching".into()], "UCG-Max"),
274            DeviceType::Gateway
275        );
276        assert_eq!(
277            infer_device_type_integration(&["switching".into(), "routing".into()], "UDM-Pro"),
278            DeviceType::Gateway
279        );
280    }
281
282    #[test]
283    fn device_state_mapping() {
284        assert_eq!(map_device_state(0), DeviceState::Offline);
285        assert_eq!(map_device_state(1), DeviceState::Online);
286        assert_eq!(map_device_state(2), DeviceState::PendingAdoption);
287        assert_eq!(map_device_state(4), DeviceState::Updating);
288        assert_eq!(map_device_state(5), DeviceState::GettingReady);
289        assert_eq!(map_device_state(99), DeviceState::Unknown);
290    }
291
292    #[test]
293    fn legacy_device_falls_back_to_mac_when_id_missing() {
294        let raw = json!({
295            "mac": "dc:9f:db:00:00:01",
296            "type": "ugw",
297            "name": "USG 3P",
298            "state": 2
299        });
300
301        let session_device: SessionDevice =
302            serde_json::from_value(raw).expect("session device should deserialize");
303        let device: Device = session_device.into();
304
305        assert_eq!(device.id.to_string(), "dc:9f:db:00:00:01");
306        assert_eq!(device.mac.to_string(), "dc:9f:db:00:00:01");
307        assert_eq!(device.state, DeviceState::PendingAdoption);
308    }
309}