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
14fn 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
140fn 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}