Skip to main content

unifly_api/
convert.rs

1// ── API-to-domain type conversions ──
2//
3// Bridges raw `unifly_api` response types into canonical `unifly_core::model`
4// domain types. Each `From` impl normalizes field names, parses strings into
5// strong types, and fills sensible defaults for missing optional data.
6
7use std::collections::HashMap;
8use std::net::{IpAddr, Ipv4Addr};
9
10use chrono::{DateTime, Utc};
11use serde_json::Value;
12
13use crate::integration_types;
14use crate::legacy::models::{
15    LegacyAlarm, LegacyClientEntry, LegacyDevice, LegacyEvent, LegacySite,
16};
17use crate::websocket::UnifiEvent;
18
19use crate::model::{
20    client::{Client, ClientType, GuestAuth, WirelessInfo},
21    common::{Bandwidth, DataSource, EntityOrigin},
22    device::{Device, DeviceState, DeviceStats, DeviceType},
23    dns::{DnsPolicy, DnsPolicyType},
24    entity_id::{EntityId, MacAddress},
25    event::{Alarm, Event, EventCategory, EventSeverity},
26    firewall::{AclAction, AclRule, AclRuleType, FirewallAction, FirewallPolicy, FirewallZone},
27    hotspot::Voucher,
28    network::{DhcpConfig, Ipv6Mode, Network, NetworkManagement},
29    site::Site,
30    supporting::TrafficMatchingList,
31    wifi::{WifiBroadcast, WifiBroadcastType, WifiSecurityMode},
32};
33
34// ── Helpers ────────────────────────────────────────────────────────
35
36/// Parse an optional string to an `IpAddr`, silently dropping unparseable values.
37fn parse_ip(raw: Option<&String>) -> Option<IpAddr> {
38    raw.and_then(|s| s.parse().ok())
39}
40
41/// Convert an optional epoch-seconds timestamp to `DateTime<Utc>`.
42fn epoch_to_datetime(epoch: Option<i64>) -> Option<DateTime<Utc>> {
43    epoch.and_then(|ts| DateTime::from_timestamp(ts, 0))
44}
45
46/// Parse an ISO-8601 datetime string (as returned by the legacy event/alarm endpoints).
47fn parse_datetime(raw: Option<&String>) -> Option<DateTime<Utc>> {
48    raw.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
49        .map(|dt| dt.with_timezone(&Utc))
50}
51
52fn parse_ipv6_text(raw: &str) -> Option<std::net::Ipv6Addr> {
53    let candidate = raw.trim().split('/').next().unwrap_or(raw).trim();
54    candidate.parse::<std::net::Ipv6Addr>().ok()
55}
56
57fn pick_ipv6_from_value(value: &Value) -> Option<String> {
58    let mut first_link_local: Option<String> = None;
59
60    let iter: Box<dyn Iterator<Item = &Value> + '_> = match value {
61        Value::Array(items) => Box::new(items.iter()),
62        _ => Box::new(std::iter::once(value)),
63    };
64
65    for item in iter {
66        if let Some(ipv6) = item.as_str().and_then(parse_ipv6_text) {
67            let ip_text = ipv6.to_string();
68            if !ipv6.is_unicast_link_local() {
69                return Some(ip_text);
70            }
71            if first_link_local.is_none() {
72                first_link_local = Some(ip_text);
73            }
74        }
75    }
76
77    first_link_local
78}
79
80fn parse_legacy_wan_ipv6(extra: &serde_json::Map<String, Value>) -> Option<String> {
81    // Primary source on gateways: wan1.ipv6 = ["global", "link-local"].
82    if let Some(v) = extra
83        .get("wan1")
84        .and_then(|wan| wan.get("ipv6"))
85        .and_then(pick_ipv6_from_value)
86    {
87        return Some(v);
88    }
89
90    // Fallback source on some firmware: top-level ipv6 array.
91    extra.get("ipv6").and_then(pick_ipv6_from_value)
92}
93
94// ── Device ─────────────────────────────────────────────────────────
95
96/// Infer `DeviceType` from the legacy `type` field and optional `model` string.
97///
98/// The legacy API `type` field is typically: `"uap"`, `"usw"`, `"ugw"`, `"udm"`.
99/// We also check the `model` prefix for newer hardware that may not match cleanly.
100fn infer_device_type(device_type: &str, model: Option<&String>) -> DeviceType {
101    match device_type {
102        "uap" => DeviceType::AccessPoint,
103        "usw" => DeviceType::Switch,
104        "ugw" | "udm" => DeviceType::Gateway,
105        _ => {
106            // Fallback: check the model string prefix
107            if let Some(m) = model {
108                let upper = m.to_uppercase();
109                if upper.starts_with("UAP") || upper.starts_with("U6") || upper.starts_with("U7") {
110                    DeviceType::AccessPoint
111                } else if upper.starts_with("USW") || upper.starts_with("USL") {
112                    DeviceType::Switch
113                } else if upper.starts_with("UGW")
114                    || upper.starts_with("UDM")
115                    || upper.starts_with("UDR")
116                    || upper.starts_with("UXG")
117                    || upper.starts_with("UCG")
118                    || upper.starts_with("UCK")
119                {
120                    DeviceType::Gateway
121                } else {
122                    DeviceType::Other
123                }
124            } else {
125                DeviceType::Other
126            }
127        }
128    }
129}
130
131/// Map the legacy integer state code to `DeviceState`.
132///
133/// Known codes: 0=offline, 1=online, 2=pending adoption, 4=upgrading, 5=provisioning.
134fn map_device_state(code: i32) -> DeviceState {
135    match code {
136        0 => DeviceState::Offline,
137        1 => DeviceState::Online,
138        2 => DeviceState::PendingAdoption,
139        4 => DeviceState::Updating,
140        5 => DeviceState::GettingReady,
141        _ => DeviceState::Unknown,
142    }
143}
144
145impl From<LegacyDevice> for Device {
146    fn from(d: LegacyDevice) -> Self {
147        let device_type = infer_device_type(&d.device_type, d.model.as_ref());
148        let state = map_device_state(d.state);
149
150        // Build device_stats from sys_stats + uptime
151        let device_stats = {
152            let mut s = DeviceStats {
153                uptime_secs: d.uptime.and_then(|u| u.try_into().ok()),
154                ..Default::default()
155            };
156            if let Some(ref sys) = d.sys_stats {
157                s.load_average_1m = sys.load_1.as_deref().and_then(|v| v.parse().ok());
158                s.load_average_5m = sys.load_5.as_deref().and_then(|v| v.parse().ok());
159                s.load_average_15m = sys.load_15.as_deref().and_then(|v| v.parse().ok());
160                s.cpu_utilization_pct = sys.cpu.as_deref().and_then(|v| v.parse().ok());
161                // Memory utilization as a percentage
162                s.memory_utilization_pct = match (sys.mem_used, sys.mem_total) {
163                    (Some(used), Some(total)) if total > 0 =>
164                    {
165                        #[allow(clippy::as_conversions, clippy::cast_precision_loss)]
166                        Some((used as f64 / total as f64) * 100.0)
167                    }
168                    _ => None,
169                };
170            }
171            s
172        };
173
174        Device {
175            id: EntityId::from(d.id),
176            mac: MacAddress::new(&d.mac),
177            ip: parse_ip(d.ip.as_ref()),
178            wan_ipv6: parse_legacy_wan_ipv6(&d.extra),
179            name: d.name,
180            model: d.model,
181            device_type,
182            state,
183            firmware_version: d.version,
184            firmware_updatable: d.upgradable.unwrap_or(false),
185            adopted_at: None, // Legacy API doesn't provide adoption timestamp
186            provisioned_at: None,
187            last_seen: epoch_to_datetime(d.last_seen),
188            serial: d.serial,
189            supported: true, // Legacy API only returns adopted/supported devices
190            ports: Vec::new(),
191            radios: Vec::new(),
192            uplink_device_id: None,
193            uplink_device_mac: None,
194            has_switching: device_type == DeviceType::Switch || device_type == DeviceType::Gateway,
195            has_access_point: device_type == DeviceType::AccessPoint,
196            stats: device_stats,
197            client_count: d.num_sta.and_then(|n| n.try_into().ok()),
198            origin: None,
199            source: DataSource::LegacyApi,
200            updated_at: Utc::now(),
201        }
202    }
203}
204
205// ── Client ─────────────────────────────────────────────────────────
206
207impl From<LegacyClientEntry> for Client {
208    fn from(c: LegacyClientEntry) -> Self {
209        let is_wired = c.is_wired.unwrap_or(false);
210        let client_type = if is_wired {
211            ClientType::Wired
212        } else {
213            ClientType::Wireless
214        };
215
216        // Build wireless info for non-wired clients
217        let wireless = if is_wired {
218            None
219        } else {
220            Some(WirelessInfo {
221                ssid: c.essid.clone(),
222                bssid: c.bssid.as_deref().map(MacAddress::new),
223                channel: c.channel.and_then(|ch| ch.try_into().ok()),
224                frequency_ghz: channel_to_frequency(c.channel),
225                signal_dbm: c.signal.or(c.rssi),
226                noise_dbm: c.noise,
227                satisfaction: c.satisfaction.and_then(|s| s.try_into().ok()),
228                tx_rate_kbps: c.tx_rate.and_then(|r| r.try_into().ok()),
229                rx_rate_kbps: c.rx_rate.and_then(|r| r.try_into().ok()),
230            })
231        };
232
233        // Build guest auth if the client is a guest
234        let is_guest = c.is_guest.unwrap_or(false);
235        let guest_auth = if is_guest {
236            Some(GuestAuth {
237                authorized: c.authorized.unwrap_or(false),
238                method: None,
239                expires_at: None,
240                tx_bytes: c.tx_bytes.and_then(|b| b.try_into().ok()),
241                rx_bytes: c.rx_bytes.and_then(|b| b.try_into().ok()),
242                elapsed_minutes: None,
243            })
244        } else {
245            None
246        };
247
248        // Determine uplink device MAC based on connection type
249        let uplink_device_mac = if is_wired {
250            c.sw_mac.as_deref().map(MacAddress::new)
251        } else {
252            c.ap_mac.as_deref().map(MacAddress::new)
253        };
254
255        // Estimate connected_at from uptime
256        let connected_at = c.uptime.and_then(|secs| {
257            let duration = chrono::Duration::seconds(secs);
258            Utc::now().checked_sub_signed(duration)
259        });
260
261        Client {
262            id: EntityId::from(c.id),
263            mac: MacAddress::new(&c.mac),
264            ip: parse_ip(c.ip.as_ref()),
265            name: c.name,
266            hostname: c.hostname,
267            client_type,
268            connected_at,
269            uplink_device_id: None,
270            uplink_device_mac,
271            network_id: c.network_id.map(EntityId::from),
272            vlan: None,
273            wireless,
274            guest_auth,
275            is_guest,
276            tx_bytes: c.tx_bytes.and_then(|b| b.try_into().ok()),
277            rx_bytes: c.rx_bytes.and_then(|b| b.try_into().ok()),
278            bandwidth: None,
279            os_name: None,
280            device_class: None,
281            blocked: c.blocked.unwrap_or(false),
282            source: DataSource::LegacyApi,
283            updated_at: Utc::now(),
284        }
285    }
286}
287
288/// Rough channel-to-frequency mapping for common Wi-Fi channels.
289fn channel_to_frequency(channel: Option<i32>) -> Option<f32> {
290    channel.map(|ch| match ch {
291        1..=14 => 2.4,
292        32..=68 | 96..=177 => 5.0,
293        _ => 6.0, // Wi-Fi 6E / 7
294    })
295}
296
297// ── Site ───────────────────────────────────────────────────────────
298
299impl From<LegacySite> for Site {
300    fn from(s: LegacySite) -> Self {
301        // `desc` is the human-friendly label; `name` is the internal slug (e.g. "default").
302        let display_name = s
303            .desc
304            .filter(|d| !d.is_empty())
305            .unwrap_or_else(|| s.name.clone());
306
307        Site {
308            id: EntityId::from(s.id),
309            internal_name: s.name,
310            name: display_name,
311            device_count: None,
312            client_count: None,
313            source: DataSource::LegacyApi,
314        }
315    }
316}
317
318// ── Event ──────────────────────────────────────────────────────────
319
320/// Map legacy subsystem string to `EventCategory`.
321fn map_event_category(subsystem: Option<&String>) -> EventCategory {
322    match subsystem.map(String::as_str) {
323        Some("wlan" | "lan" | "wan") => EventCategory::Network,
324        Some("device") => EventCategory::Device,
325        Some("client") => EventCategory::Client,
326        Some("system") => EventCategory::System,
327        Some("admin") => EventCategory::Admin,
328        Some("firewall") => EventCategory::Firewall,
329        Some("vpn") => EventCategory::Vpn,
330        _ => EventCategory::Unknown,
331    }
332}
333
334impl From<LegacyEvent> for Event {
335    fn from(e: LegacyEvent) -> Self {
336        Event {
337            id: Some(EntityId::from(e.id)),
338            timestamp: parse_datetime(e.datetime.as_ref()).unwrap_or_else(Utc::now),
339            category: map_event_category(e.subsystem.as_ref()),
340            severity: EventSeverity::Info,
341            event_type: e.key.clone().unwrap_or_default(),
342            message: e.msg.unwrap_or_default(),
343            device_mac: None,
344            client_mac: None,
345            site_id: e.site_id.map(EntityId::from),
346            raw_key: e.key,
347            source: DataSource::LegacyApi,
348        }
349    }
350}
351
352// ── Alarm → Event ──────────────────────────────────────────────────
353
354impl From<LegacyAlarm> for Event {
355    fn from(a: LegacyAlarm) -> Self {
356        Event {
357            id: Some(EntityId::from(a.id)),
358            timestamp: parse_datetime(a.datetime.as_ref()).unwrap_or_else(Utc::now),
359            category: EventCategory::System,
360            severity: EventSeverity::Warning,
361            event_type: a.key.clone().unwrap_or_default(),
362            message: a.msg.unwrap_or_default(),
363            device_mac: None,
364            client_mac: None,
365            site_id: None,
366            raw_key: a.key,
367            source: DataSource::LegacyApi,
368        }
369    }
370}
371
372impl From<LegacyAlarm> for Alarm {
373    fn from(a: LegacyAlarm) -> Self {
374        Alarm {
375            id: EntityId::from(a.id),
376            timestamp: parse_datetime(a.datetime.as_ref()).unwrap_or_else(Utc::now),
377            category: EventCategory::System,
378            severity: EventSeverity::Warning,
379            message: a.msg.unwrap_or_default(),
380            archived: a.archived.unwrap_or(false),
381            device_mac: None,
382            site_id: None,
383        }
384    }
385}
386
387// ── WebSocket Event ──────────────────────────────────────────────
388
389/// Infer severity from a WebSocket event key.
390///
391/// Disconnect/Lost/Down keywords → Warning, Error/Fail → Error, else Info.
392fn infer_ws_severity(key: &str) -> EventSeverity {
393    let upper = key.to_uppercase();
394    if upper.contains("ERROR") || upper.contains("FAIL") {
395        EventSeverity::Error
396    } else if upper.contains("DISCONNECT") || upper.contains("LOST") || upper.contains("DOWN") {
397        EventSeverity::Warning
398    } else {
399        EventSeverity::Info
400    }
401}
402
403impl From<UnifiEvent> for Event {
404    fn from(e: UnifiEvent) -> Self {
405        let category = map_event_category(Some(&e.subsystem));
406        let severity = infer_ws_severity(&e.key);
407
408        // Extract device MAC from common extra fields
409        let device_mac = e
410            .extra
411            .get("mac")
412            .or_else(|| e.extra.get("sw"))
413            .or_else(|| e.extra.get("ap"))
414            .and_then(|v| v.as_str())
415            .map(MacAddress::new);
416
417        // Extract client MAC from common extra fields
418        let client_mac = e
419            .extra
420            .get("user")
421            .or_else(|| e.extra.get("sta"))
422            .and_then(|v| v.as_str())
423            .map(MacAddress::new);
424
425        let site_id = if e.site_id.is_empty() {
426            None
427        } else {
428            Some(EntityId::Legacy(e.site_id))
429        };
430
431        Event {
432            id: None,
433            timestamp: parse_datetime(e.datetime.as_ref()).unwrap_or_else(Utc::now),
434            category,
435            severity,
436            event_type: e.key.clone(),
437            message: e.message.unwrap_or_default(),
438            device_mac,
439            client_mac,
440            site_id,
441            raw_key: Some(e.key),
442            source: DataSource::LegacyApi,
443        }
444    }
445}
446
447// ━━ Integration API conversions ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
448
449// ── Helpers ────────────────────────────────────────────────────────
450
451/// Parse an ISO-8601 string (Integration API format) to `DateTime<Utc>`.
452fn parse_iso(raw: &str) -> Option<DateTime<Utc>> {
453    DateTime::parse_from_rfc3339(raw)
454        .ok()
455        .map(|dt| dt.with_timezone(&Utc))
456}
457
458/// Map Integration API management string to `EntityOrigin`.
459fn map_origin(management: &str) -> Option<EntityOrigin> {
460    match management {
461        "USER_DEFINED" => Some(EntityOrigin::UserDefined),
462        "SYSTEM_DEFINED" => Some(EntityOrigin::SystemDefined),
463        "ORCHESTRATED" => Some(EntityOrigin::Orchestrated),
464        _ => None,
465    }
466}
467
468/// Extract origin from a `metadata` JSON object.
469///
470/// Checks `metadata.origin` (real API) and `metadata.management` (spec)
471/// since the field name varies by firmware version.
472fn origin_from_metadata(metadata: &serde_json::Value) -> Option<EntityOrigin> {
473    metadata
474        .get("origin")
475        .or_else(|| metadata.get("management"))
476        .and_then(|v| v.as_str())
477        .and_then(map_origin)
478}
479
480/// Map Integration API device state string to `DeviceState`.
481fn map_integration_device_state(state: &str) -> DeviceState {
482    match state {
483        "ONLINE" => DeviceState::Online,
484        "OFFLINE" => DeviceState::Offline,
485        "PENDING_ADOPTION" => DeviceState::PendingAdoption,
486        "UPDATING" => DeviceState::Updating,
487        "GETTING_READY" => DeviceState::GettingReady,
488        "ADOPTING" => DeviceState::Adopting,
489        "DELETING" => DeviceState::Deleting,
490        "CONNECTION_INTERRUPTED" => DeviceState::ConnectionInterrupted,
491        "ISOLATED" => DeviceState::Isolated,
492        _ => DeviceState::Unknown,
493    }
494}
495
496/// Infer `DeviceType` from Integration API `features` list and `model` string.
497fn infer_device_type_integration(features: &[String], model: &str) -> DeviceType {
498    let has = |f: &str| features.iter().any(|s| s == f);
499
500    // Check model prefix first — some gateways (UCG Max) report "switching"
501    // without "routing", which would misclassify them as switches.
502    let upper = model.to_uppercase();
503    let is_gateway_model = upper.starts_with("UGW")
504        || upper.starts_with("UDM")
505        || upper.starts_with("UDR")
506        || upper.starts_with("UXG")
507        || upper.starts_with("UCG")
508        || upper.starts_with("UCK");
509
510    if is_gateway_model || (has("switching") && has("routing")) || has("gateway") {
511        DeviceType::Gateway
512    } else if has("accessPoint") {
513        DeviceType::AccessPoint
514    } else if has("switching") {
515        DeviceType::Switch
516    } else {
517        // Fallback to model prefix
518        let model_owned = model.to_owned();
519        infer_device_type("", Some(&model_owned))
520    }
521}
522
523// ── Device ────────────────────────────────────────────────────────
524
525impl From<integration_types::DeviceResponse> for Device {
526    fn from(d: integration_types::DeviceResponse) -> Self {
527        let device_type = infer_device_type_integration(&d.features, &d.model);
528        let state = map_integration_device_state(&d.state);
529
530        Device {
531            id: EntityId::Uuid(d.id),
532            mac: MacAddress::new(&d.mac_address),
533            ip: d.ip_address.as_deref().and_then(|s| s.parse().ok()),
534            wan_ipv6: None,
535            name: Some(d.name),
536            model: Some(d.model),
537            device_type,
538            state,
539            firmware_version: d.firmware_version,
540            firmware_updatable: d.firmware_updatable,
541            adopted_at: None,
542            provisioned_at: None,
543            last_seen: None,
544            serial: None,
545            supported: d.supported,
546            ports: Vec::new(),
547            radios: Vec::new(),
548            uplink_device_id: None,
549            uplink_device_mac: None,
550            has_switching: d.features.iter().any(|f| f == "switching"),
551            has_access_point: d.features.iter().any(|f| f == "accessPoint"),
552            stats: DeviceStats::default(),
553            client_count: None,
554            origin: None,
555            source: DataSource::IntegrationApi,
556            updated_at: Utc::now(),
557        }
558    }
559}
560
561/// Convert Integration API device statistics into domain `DeviceStats`.
562pub(crate) fn device_stats_from_integration(
563    resp: &integration_types::DeviceStatisticsResponse,
564) -> DeviceStats {
565    DeviceStats {
566        uptime_secs: resp.uptime_sec.and_then(|u| u.try_into().ok()),
567        cpu_utilization_pct: resp.cpu_utilization_pct,
568        memory_utilization_pct: resp.memory_utilization_pct,
569        load_average_1m: resp.load_average_1_min,
570        load_average_5m: resp.load_average_5_min,
571        load_average_15m: resp.load_average_15_min,
572        last_heartbeat: resp.last_heartbeat_at.as_deref().and_then(parse_iso),
573        next_heartbeat: resp.next_heartbeat_at.as_deref().and_then(parse_iso),
574        uplink_bandwidth: resp.uplink.as_ref().and_then(|u| {
575            let tx = u
576                .get("txRateBps")
577                .or_else(|| u.get("txBytesPerSecond"))
578                .or_else(|| u.get("tx_bytes-r"))
579                .and_then(serde_json::Value::as_u64)
580                .unwrap_or(0);
581            let rx = u
582                .get("rxRateBps")
583                .or_else(|| u.get("rxBytesPerSecond"))
584                .or_else(|| u.get("rx_bytes-r"))
585                .and_then(serde_json::Value::as_u64)
586                .unwrap_or(0);
587            if tx == 0 && rx == 0 {
588                None
589            } else {
590                Some(Bandwidth {
591                    tx_bytes_per_sec: tx,
592                    rx_bytes_per_sec: rx,
593                })
594            }
595        }),
596    }
597}
598
599// ── Client ────────────────────────────────────────────────────────
600
601impl From<integration_types::ClientResponse> for Client {
602    fn from(c: integration_types::ClientResponse) -> Self {
603        let client_type = match c.client_type.as_str() {
604            "WIRED" => ClientType::Wired,
605            "WIRELESS" => ClientType::Wireless,
606            "VPN" => ClientType::Vpn,
607            "TELEPORT" => ClientType::Teleport,
608            _ => ClientType::Unknown,
609        };
610
611        // Extract MAC from access object; fall back to UUID so clients
612        // without a macAddress still get unique store keys.
613        let mac_from_access = c
614            .access
615            .get("macAddress")
616            .and_then(|v| v.as_str())
617            .unwrap_or("")
618            .to_string();
619        let uuid_fallback = c.id.to_string();
620        let mac_str = if mac_from_access.is_empty() {
621            uuid_fallback.as_str()
622        } else {
623            mac_from_access.as_str()
624        };
625
626        Client {
627            id: EntityId::Uuid(c.id),
628            mac: MacAddress::new(mac_str),
629            ip: c.ip_address.as_deref().and_then(|s| s.parse().ok()),
630            name: Some(c.name),
631            hostname: None,
632            client_type,
633            connected_at: c.connected_at.as_deref().and_then(parse_iso),
634            uplink_device_id: None,
635            uplink_device_mac: None,
636            network_id: None,
637            vlan: None,
638            wireless: None,
639            guest_auth: None,
640            is_guest: false,
641            tx_bytes: None,
642            rx_bytes: None,
643            bandwidth: None,
644            os_name: None,
645            device_class: None,
646            blocked: false,
647            source: DataSource::IntegrationApi,
648            updated_at: Utc::now(),
649        }
650    }
651}
652
653// ── Site ──────────────────────────────────────────────────────────
654
655impl From<integration_types::SiteResponse> for Site {
656    fn from(s: integration_types::SiteResponse) -> Self {
657        Site {
658            id: EntityId::Uuid(s.id),
659            internal_name: s.internal_reference,
660            name: s.name,
661            device_count: None,
662            client_count: None,
663            source: DataSource::IntegrationApi,
664        }
665    }
666}
667
668// ── Network ──────────────────────────────────────────────────────
669
670/// Look up a field in `extra` first, then fall back to `metadata`.
671fn net_field<'a>(
672    extra: &'a HashMap<String, Value>,
673    metadata: &'a Value,
674    key: &str,
675) -> Option<&'a Value> {
676    extra.get(key).or_else(|| metadata.get(key))
677}
678
679/// Parse network configuration from API extra/metadata fields into a `Network`.
680#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
681fn parse_network_fields(
682    id: uuid::Uuid,
683    name: String,
684    enabled: bool,
685    management_str: &str,
686    vlan_id: i32,
687    is_default: bool,
688    metadata: &Value,
689    extra: &HashMap<String, Value>,
690) -> Network {
691    // ── Feature flags ───────────────────────────────────────────
692    let isolation_enabled = net_field(extra, metadata, "isolationEnabled")
693        .and_then(Value::as_bool)
694        .unwrap_or(false);
695    let internet_access_enabled = net_field(extra, metadata, "internetAccessEnabled")
696        .and_then(Value::as_bool)
697        .unwrap_or(true);
698    let mdns_forwarding_enabled = net_field(extra, metadata, "mdnsForwardingEnabled")
699        .and_then(Value::as_bool)
700        .unwrap_or(false);
701    let cellular_backup_enabled = net_field(extra, metadata, "cellularBackupEnabled")
702        .and_then(Value::as_bool)
703        .unwrap_or(false);
704
705    // ── Firewall zone ───────────────────────────────────────────
706    let firewall_zone_id = net_field(extra, metadata, "zoneId")
707        .and_then(Value::as_str)
708        .and_then(|s| uuid::Uuid::parse_str(s).ok())
709        .map(EntityId::Uuid);
710
711    // ── IPv4 configuration ──────────────────────────────────────
712    // Detail API uses: hostIpAddress, prefixLength, dhcpConfiguration
713    // Some firmware uses: host, prefix, dhcp.server
714    let ipv4 = net_field(extra, metadata, "ipv4Configuration");
715
716    let gateway_ip: Option<Ipv4Addr> = ipv4
717        .and_then(|v| v.get("hostIpAddress").or_else(|| v.get("host")))
718        .and_then(Value::as_str)
719        .and_then(|s| s.parse().ok());
720
721    let subnet = ipv4.and_then(|v| {
722        let host = v.get("hostIpAddress").or_else(|| v.get("host"))?.as_str()?;
723        let prefix = v
724            .get("prefixLength")
725            .or_else(|| v.get("prefix"))?
726            .as_u64()?;
727        Some(format!("{host}/{prefix}"))
728    });
729
730    // ── DHCP ────────────────────────────────────────────────────
731    // Detail API: dhcpConfiguration.mode/leaseTimeSeconds/ipAddressRange/dnsServerIpAddressesOverride
732    // Fallback:   dhcp.server.enabled/rangeStart/rangeStop/leaseTimeSec/dnsOverride.servers
733    let dhcp = ipv4.and_then(|v| {
734        // Try new-style dhcpConfiguration first
735        if let Some(dhcp_cfg) = v.get("dhcpConfiguration") {
736            let mode = dhcp_cfg.get("mode").and_then(Value::as_str).unwrap_or("");
737            let dhcp_enabled = mode == "SERVER";
738            let range = dhcp_cfg.get("ipAddressRange");
739            let range_start = range
740                .and_then(|r| r.get("start").or_else(|| r.get("rangeStart")))
741                .and_then(Value::as_str)
742                .and_then(|s| s.parse().ok());
743            let range_stop = range
744                .and_then(|r| r.get("end").or_else(|| r.get("rangeStop")))
745                .and_then(Value::as_str)
746                .and_then(|s| s.parse().ok());
747            let lease_time_secs = dhcp_cfg.get("leaseTimeSeconds").and_then(Value::as_u64);
748            let dns_servers = dhcp_cfg
749                .get("dnsServerIpAddressesOverride")
750                .and_then(Value::as_array)
751                .map(|arr| {
752                    arr.iter()
753                        .filter_map(|v| v.as_str()?.parse::<IpAddr>().ok())
754                        .collect()
755                })
756                .unwrap_or_default();
757            return Some(DhcpConfig {
758                enabled: dhcp_enabled,
759                range_start,
760                range_stop,
761                lease_time_secs,
762                dns_servers,
763                gateway: gateway_ip,
764            });
765        }
766
767        // Fallback: old-style dhcp.server
768        let server = v.get("dhcp")?.get("server")?;
769        let dhcp_enabled = server
770            .get("enabled")
771            .and_then(Value::as_bool)
772            .unwrap_or(false);
773        let range_start = server
774            .get("rangeStart")
775            .and_then(Value::as_str)
776            .and_then(|s| s.parse().ok());
777        let range_stop = server
778            .get("rangeStop")
779            .and_then(Value::as_str)
780            .and_then(|s| s.parse().ok());
781        let lease_time_secs = server.get("leaseTimeSec").and_then(Value::as_u64);
782        let dns_servers = server
783            .get("dnsOverride")
784            .and_then(|d| d.get("servers"))
785            .and_then(Value::as_array)
786            .map(|arr| {
787                arr.iter()
788                    .filter_map(|v| v.as_str()?.parse::<IpAddr>().ok())
789                    .collect()
790            })
791            .unwrap_or_default();
792        let gateway = server
793            .get("gateway")
794            .and_then(Value::as_str)
795            .and_then(|s| s.parse().ok())
796            .or(gateway_ip);
797        Some(DhcpConfig {
798            enabled: dhcp_enabled,
799            range_start,
800            range_stop,
801            lease_time_secs,
802            dns_servers,
803            gateway,
804        })
805    });
806
807    // ── PXE / NTP / TFTP ────────────────────────────────────────
808    let pxe_enabled = ipv4
809        .and_then(|v| v.get("pxe"))
810        .and_then(|v| v.get("enabled"))
811        .and_then(Value::as_bool)
812        .unwrap_or(false);
813    let ntp_server = ipv4
814        .and_then(|v| v.get("ntp"))
815        .and_then(|v| v.get("server"))
816        .and_then(Value::as_str)
817        .and_then(|s| s.parse::<IpAddr>().ok());
818    let tftp_server = ipv4
819        .and_then(|v| v.get("tftp"))
820        .and_then(|v| v.get("server"))
821        .and_then(Value::as_str)
822        .map(String::from);
823
824    // ── IPv6 ────────────────────────────────────────────────────
825    // Detail API: interfaceType, clientAddressAssignment.slaacEnabled, additionalHostIpSubnets
826    // Fallback:   type, slaac.enabled, dhcpv6.enabled, prefix
827    let ipv6 = net_field(extra, metadata, "ipv6Configuration");
828    let ipv6_enabled = ipv6.is_some();
829    let ipv6_mode = ipv6
830        .and_then(|v| v.get("interfaceType").or_else(|| v.get("type")))
831        .and_then(Value::as_str)
832        .and_then(|s| match s {
833            "PREFIX_DELEGATION" => Some(Ipv6Mode::PrefixDelegation),
834            "STATIC" => Some(Ipv6Mode::Static),
835            _ => None,
836        });
837    let slaac_enabled = ipv6
838        .and_then(|v| {
839            // New: clientAddressAssignment.slaacEnabled
840            v.get("clientAddressAssignment")
841                .and_then(|ca| ca.get("slaacEnabled"))
842                .and_then(Value::as_bool)
843                // Fallback: slaac.enabled
844                .or_else(|| v.get("slaac").and_then(|s| s.get("enabled")).and_then(Value::as_bool))
845        })
846        .unwrap_or(false);
847    let dhcpv6_enabled = ipv6
848        .and_then(|v| {
849            v.get("clientAddressAssignment")
850                .and_then(|ca| ca.get("dhcpv6Enabled"))
851                .and_then(Value::as_bool)
852                .or_else(|| {
853                    v.get("dhcpv6")
854                        .and_then(|d| d.get("enabled"))
855                        .and_then(Value::as_bool)
856                })
857        })
858        .unwrap_or(false);
859    let ipv6_prefix = ipv6.and_then(|v| {
860        // New: additionalHostIpSubnets[0]
861        v.get("additionalHostIpSubnets")
862                .and_then(Value::as_array)
863                .and_then(|a| a.first())
864                .and_then(Value::as_str)
865                .map(String::from)
866                // Fallback: prefix
867                .or_else(|| v.get("prefix").and_then(Value::as_str).map(String::from))
868    });
869
870    // ── Management type inference ───────────────────────────────
871    let has_ipv4_config = ipv4.is_some();
872    let has_device_id = extra.contains_key("deviceId");
873    let management = if has_ipv4_config && !has_device_id {
874        Some(NetworkManagement::Gateway)
875    } else if has_device_id {
876        Some(NetworkManagement::Switch)
877    } else if has_ipv4_config {
878        Some(NetworkManagement::Gateway)
879    } else {
880        None
881    };
882
883    Network {
884        id: EntityId::Uuid(id),
885        name,
886        enabled,
887        management,
888        purpose: None,
889        is_default,
890        #[allow(
891            clippy::as_conversions,
892            clippy::cast_possible_truncation,
893            clippy::cast_sign_loss
894        )]
895        vlan_id: Some(vlan_id as u16),
896        subnet,
897        gateway_ip,
898        dhcp,
899        ipv6_enabled,
900        ipv6_mode,
901        ipv6_prefix,
902        dhcpv6_enabled,
903        slaac_enabled,
904        ntp_server,
905        pxe_enabled,
906        tftp_server,
907        firewall_zone_id,
908        isolation_enabled,
909        internet_access_enabled,
910        mdns_forwarding_enabled,
911        cellular_backup_enabled,
912        origin: map_origin(management_str),
913        source: DataSource::IntegrationApi,
914    }
915}
916
917impl From<integration_types::NetworkResponse> for Network {
918    fn from(n: integration_types::NetworkResponse) -> Self {
919        parse_network_fields(
920            n.id,
921            n.name,
922            n.enabled,
923            &n.management,
924            n.vlan_id,
925            n.default,
926            &n.metadata,
927            &n.extra,
928        )
929    }
930}
931
932impl From<integration_types::NetworkDetailsResponse> for Network {
933    fn from(n: integration_types::NetworkDetailsResponse) -> Self {
934        parse_network_fields(
935            n.id,
936            n.name,
937            n.enabled,
938            &n.management,
939            n.vlan_id,
940            n.default,
941            &n.metadata,
942            &n.extra,
943        )
944    }
945}
946
947// ── WiFi Broadcast ───────────────────────────────────────────────
948
949impl From<integration_types::WifiBroadcastResponse> for WifiBroadcast {
950    fn from(w: integration_types::WifiBroadcastResponse) -> Self {
951        let broadcast_type = match w.broadcast_type.as_str() {
952            "IOT_OPTIMIZED" => WifiBroadcastType::IotOptimized,
953            _ => WifiBroadcastType::Standard,
954        };
955
956        let security = w
957            .security_configuration
958            .get("mode")
959            .and_then(|v| v.as_str())
960            .map_or(WifiSecurityMode::Open, |mode| match mode {
961                "WPA2_PERSONAL" => WifiSecurityMode::Wpa2Personal,
962                "WPA3_PERSONAL" => WifiSecurityMode::Wpa3Personal,
963                "WPA2_WPA3_PERSONAL" => WifiSecurityMode::Wpa2Wpa3Personal,
964                "WPA2_ENTERPRISE" => WifiSecurityMode::Wpa2Enterprise,
965                "WPA3_ENTERPRISE" => WifiSecurityMode::Wpa3Enterprise,
966                "WPA2_WPA3_ENTERPRISE" => WifiSecurityMode::Wpa2Wpa3Enterprise,
967                _ => WifiSecurityMode::Open,
968            });
969
970        WifiBroadcast {
971            id: EntityId::Uuid(w.id),
972            name: w.name,
973            enabled: w.enabled,
974            broadcast_type,
975            security,
976            network_id: w
977                .network
978                .as_ref()
979                .and_then(|v| v.get("id"))
980                .and_then(|v| v.as_str())
981                .and_then(|s| uuid::Uuid::parse_str(s).ok())
982                .map(EntityId::Uuid),
983            frequencies_ghz: Vec::new(),
984            hidden: false,
985            client_isolation: false,
986            band_steering: false,
987            mlo_enabled: false,
988            fast_roaming: false,
989            hotspot_enabled: false,
990            origin: origin_from_metadata(&w.metadata),
991            source: DataSource::IntegrationApi,
992        }
993    }
994}
995
996// ── Firewall Policy ──────────────────────────────────────────────
997
998impl From<integration_types::FirewallPolicyResponse> for FirewallPolicy {
999    fn from(p: integration_types::FirewallPolicyResponse) -> Self {
1000        let action = p.action.get("type").and_then(|v| v.as_str()).map_or(
1001            FirewallAction::Block,
1002            |a| match a {
1003                "ALLOW" => FirewallAction::Allow,
1004                "REJECT" => FirewallAction::Reject,
1005                _ => FirewallAction::Block,
1006            },
1007        );
1008
1009        #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
1010        let index = p
1011            .extra
1012            .get("index")
1013            .and_then(serde_json::Value::as_i64)
1014            .map(|i| i as i32);
1015
1016        // Zone IDs may be in flat fields (real API) or nested source/destination objects (spec)
1017        let source_zone_id = p
1018            .extra
1019            .get("sourceFirewallZoneId")
1020            .and_then(|v| v.as_str())
1021            .or_else(|| {
1022                p.extra
1023                    .get("source")
1024                    .and_then(|v| v.get("zoneId"))
1025                    .and_then(|v| v.as_str())
1026            })
1027            .and_then(|s| uuid::Uuid::parse_str(s).ok())
1028            .map(EntityId::Uuid);
1029
1030        let destination_zone_id = p
1031            .extra
1032            .get("destinationFirewallZoneId")
1033            .and_then(|v| v.as_str())
1034            .or_else(|| {
1035                p.extra
1036                    .get("destination")
1037                    .and_then(|v| v.get("zoneId"))
1038                    .and_then(|v| v.as_str())
1039            })
1040            .and_then(|s| uuid::Uuid::parse_str(s).ok())
1041            .map(EntityId::Uuid);
1042
1043        let ipsec_mode = p
1044            .extra
1045            .get("ipsecFilter")
1046            .and_then(|v| v.as_str())
1047            .map(String::from);
1048
1049        let connection_states = p
1050            .extra
1051            .get("connectionStateFilter")
1052            .and_then(|v| v.as_array())
1053            .map(|arr| {
1054                arr.iter()
1055                    .filter_map(|v| v.as_str().map(String::from))
1056                    .collect()
1057            })
1058            .unwrap_or_default();
1059
1060        FirewallPolicy {
1061            id: EntityId::Uuid(p.id),
1062            name: p.name,
1063            description: p.description,
1064            enabled: p.enabled,
1065            index,
1066            action,
1067            ip_version: crate::model::firewall::IpVersion::Both,
1068            source_zone_id,
1069            destination_zone_id,
1070            source_summary: None,
1071            destination_summary: None,
1072            protocol_summary: None,
1073            schedule: None,
1074            ipsec_mode,
1075            connection_states,
1076            logging_enabled: p.logging_enabled,
1077            origin: p.metadata.as_ref().and_then(origin_from_metadata),
1078            source: DataSource::IntegrationApi,
1079        }
1080    }
1081}
1082
1083// ── Firewall Zone ────────────────────────────────────────────────
1084
1085impl From<integration_types::FirewallZoneResponse> for FirewallZone {
1086    fn from(z: integration_types::FirewallZoneResponse) -> Self {
1087        FirewallZone {
1088            id: EntityId::Uuid(z.id),
1089            name: z.name,
1090            network_ids: z.network_ids.into_iter().map(EntityId::Uuid).collect(),
1091            origin: origin_from_metadata(&z.metadata),
1092            source: DataSource::IntegrationApi,
1093        }
1094    }
1095}
1096
1097// ── ACL Rule ─────────────────────────────────────────────────────
1098
1099impl From<integration_types::AclRuleResponse> for AclRule {
1100    fn from(r: integration_types::AclRuleResponse) -> Self {
1101        let rule_type = match r.rule_type.as_str() {
1102            "MAC" => AclRuleType::Mac,
1103            _ => AclRuleType::Ipv4,
1104        };
1105
1106        let action = match r.action.as_str() {
1107            "ALLOW" => AclAction::Allow,
1108            _ => AclAction::Block,
1109        };
1110
1111        AclRule {
1112            id: EntityId::Uuid(r.id),
1113            name: r.name,
1114            enabled: r.enabled,
1115            rule_type,
1116            action,
1117            source_summary: None,
1118            destination_summary: None,
1119            origin: origin_from_metadata(&r.metadata),
1120            source: DataSource::IntegrationApi,
1121        }
1122    }
1123}
1124
1125// ── DNS Policy ───────────────────────────────────────────────────
1126
1127impl From<integration_types::DnsPolicyResponse> for DnsPolicy {
1128    fn from(d: integration_types::DnsPolicyResponse) -> Self {
1129        let policy_type = match d.policy_type.as_str() {
1130            "A" => DnsPolicyType::ARecord,
1131            "AAAA" => DnsPolicyType::AaaaRecord,
1132            "CNAME" => DnsPolicyType::CnameRecord,
1133            "MX" => DnsPolicyType::MxRecord,
1134            "TXT" => DnsPolicyType::TxtRecord,
1135            "SRV" => DnsPolicyType::SrvRecord,
1136            _ => DnsPolicyType::ForwardDomain,
1137        };
1138
1139        DnsPolicy {
1140            id: EntityId::Uuid(d.id),
1141            policy_type,
1142            domain: d.domain.unwrap_or_default(),
1143            value: d
1144                .extra
1145                .get("value")
1146                .and_then(|v| v.as_str())
1147                .unwrap_or_default()
1148                .to_owned(),
1149            #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
1150            ttl_seconds: d
1151                .extra
1152                .get("ttl")
1153                .and_then(serde_json::Value::as_u64)
1154                .map(|t| t as u32),
1155            origin: None,
1156            source: DataSource::IntegrationApi,
1157        }
1158    }
1159}
1160
1161// ── Traffic Matching List ────────────────────────────────────────
1162
1163impl From<integration_types::TrafficMatchingListResponse> for TrafficMatchingList {
1164    fn from(t: integration_types::TrafficMatchingListResponse) -> Self {
1165        let items = t
1166            .extra
1167            .get("items")
1168            .and_then(|v| v.as_array())
1169            .map(|arr| {
1170                arr.iter()
1171                    .filter_map(|v| v.as_str().map(String::from))
1172                    .collect()
1173            })
1174            .unwrap_or_default();
1175
1176        TrafficMatchingList {
1177            id: EntityId::Uuid(t.id),
1178            name: t.name,
1179            list_type: t.list_type,
1180            items,
1181            origin: None,
1182        }
1183    }
1184}
1185
1186// ── Voucher ──────────────────────────────────────────────────────
1187
1188impl From<integration_types::VoucherResponse> for Voucher {
1189    fn from(v: integration_types::VoucherResponse) -> Self {
1190        #[allow(
1191            clippy::as_conversions,
1192            clippy::cast_possible_truncation,
1193            clippy::cast_sign_loss
1194        )]
1195        Voucher {
1196            id: EntityId::Uuid(v.id),
1197            code: v.code,
1198            name: Some(v.name),
1199            created_at: parse_iso(&v.created_at),
1200            activated_at: v.activated_at.as_deref().and_then(parse_iso),
1201            expires_at: v.expires_at.as_deref().and_then(parse_iso),
1202            expired: v.expired,
1203            time_limit_minutes: Some(v.time_limit_minutes as u32),
1204            data_usage_limit_mb: v.data_usage_limit_m_bytes.map(|b| b as u64),
1205            authorized_guest_limit: v.authorized_guest_limit.map(|l| l as u32),
1206            authorized_guest_count: Some(v.authorized_guest_count as u32),
1207            rx_rate_limit_kbps: v.rx_rate_limit_kbps.map(|r| r as u64),
1208            tx_rate_limit_kbps: v.tx_rate_limit_kbps.map(|r| r as u64),
1209            source: DataSource::IntegrationApi,
1210        }
1211    }
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216    use super::*;
1217
1218    #[test]
1219    fn device_type_from_legacy_type_field() {
1220        assert_eq!(infer_device_type("uap", None), DeviceType::AccessPoint);
1221        assert_eq!(infer_device_type("usw", None), DeviceType::Switch);
1222        assert_eq!(infer_device_type("ugw", None), DeviceType::Gateway);
1223        assert_eq!(infer_device_type("udm", None), DeviceType::Gateway);
1224    }
1225
1226    #[test]
1227    fn device_type_from_model_fallback() {
1228        assert_eq!(
1229            infer_device_type("unknown", Some(&"UAP-AC-Pro".into())),
1230            DeviceType::AccessPoint
1231        );
1232        assert_eq!(
1233            infer_device_type("unknown", Some(&"U6-LR".into())),
1234            DeviceType::AccessPoint
1235        );
1236        assert_eq!(
1237            infer_device_type("unknown", Some(&"USW-24-PoE".into())),
1238            DeviceType::Switch
1239        );
1240        assert_eq!(
1241            infer_device_type("unknown", Some(&"UDM-Pro".into())),
1242            DeviceType::Gateway
1243        );
1244        assert_eq!(
1245            infer_device_type("unknown", Some(&"UCG-Max".into())),
1246            DeviceType::Gateway
1247        );
1248    }
1249
1250    #[test]
1251    fn integration_device_type_gateway_by_model() {
1252        // UCG Max has "switching" but not "routing" — should still be Gateway
1253        assert_eq!(
1254            infer_device_type_integration(&["switching".into()], "UCG-Max"),
1255            DeviceType::Gateway
1256        );
1257        // UDM with both features
1258        assert_eq!(
1259            infer_device_type_integration(&["switching".into(), "routing".into()], "UDM-Pro"),
1260            DeviceType::Gateway
1261        );
1262    }
1263
1264    #[test]
1265    fn device_state_mapping() {
1266        assert_eq!(map_device_state(0), DeviceState::Offline);
1267        assert_eq!(map_device_state(1), DeviceState::Online);
1268        assert_eq!(map_device_state(2), DeviceState::PendingAdoption);
1269        assert_eq!(map_device_state(4), DeviceState::Updating);
1270        assert_eq!(map_device_state(5), DeviceState::GettingReady);
1271        assert_eq!(map_device_state(99), DeviceState::Unknown);
1272    }
1273
1274    #[test]
1275    fn legacy_site_uses_desc_as_display_name() {
1276        let site = LegacySite {
1277            id: "abc123".into(),
1278            name: "default".into(),
1279            desc: Some("Main Office".into()),
1280            role: None,
1281            extra: serde_json::Map::new(),
1282        };
1283        let converted: Site = site.into();
1284        assert_eq!(converted.internal_name, "default");
1285        assert_eq!(converted.name, "Main Office");
1286    }
1287
1288    #[test]
1289    fn legacy_site_falls_back_to_name_when_desc_empty() {
1290        let site = LegacySite {
1291            id: "abc123".into(),
1292            name: "branch-1".into(),
1293            desc: Some(String::new()),
1294            role: None,
1295            extra: serde_json::Map::new(),
1296        };
1297        let converted: Site = site.into();
1298        assert_eq!(converted.name, "branch-1");
1299    }
1300
1301    #[test]
1302    fn event_category_mapping() {
1303        assert_eq!(
1304            map_event_category(Some(&"wlan".into())),
1305            EventCategory::Network
1306        );
1307        assert_eq!(
1308            map_event_category(Some(&"device".into())),
1309            EventCategory::Device
1310        );
1311        assert_eq!(
1312            map_event_category(Some(&"admin".into())),
1313            EventCategory::Admin
1314        );
1315        assert_eq!(map_event_category(None), EventCategory::Unknown);
1316    }
1317
1318    #[test]
1319    fn channel_frequency_bands() {
1320        assert_eq!(channel_to_frequency(Some(6)), Some(2.4));
1321        assert_eq!(channel_to_frequency(Some(36)), Some(5.0));
1322        assert_eq!(channel_to_frequency(Some(149)), Some(5.0));
1323        assert_eq!(channel_to_frequency(None), None);
1324    }
1325}