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
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
120fn 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}