Skip to main content

unifi_cli/api/
types.rs

1use serde::Deserialize;
2use std::fmt;
3
4#[cfg(test)]
5#[path = "tests.rs"]
6mod tests;
7
8// Integration API response wrapper (paginated)
9#[derive(Debug, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct PaginatedResponse<T> {
12    pub total_count: usize,
13    pub data: Vec<T>,
14}
15
16// Legacy API response wrapper (pub for standalone fetch in TUI)
17#[derive(Debug, Deserialize)]
18pub struct LegacyResponse<T> {
19    pub meta: LegacyMeta,
20    pub data: Vec<T>,
21}
22
23#[derive(Debug, Deserialize)]
24pub struct LegacyMeta {
25    pub rc: String,
26    pub msg: Option<String>,
27}
28
29// Site
30#[derive(Debug, Deserialize)]
31pub struct Site {
32    pub id: String,
33}
34
35// Client from Integration API
36#[derive(Debug, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct Client {
39    #[serde(alias = "macAddress")]
40    pub mac_address: Option<String>,
41    #[serde(alias = "ipAddress")]
42    pub ip_address: Option<String>,
43    pub name: Option<String>,
44    pub hostname: Option<String>,
45    #[serde(alias = "type")]
46    pub client_type: Option<String>,
47}
48
49impl Client {
50    pub fn display_name(&self) -> &str {
51        self.name
52            .as_deref()
53            .or(self.hostname.as_deref())
54            .unwrap_or("-")
55    }
56
57    pub fn clean_name(&self) -> String {
58        let name = self.display_name();
59        strip_mac_suffix(name, self.mac_address.as_deref())
60    }
61}
62
63// Client from Legacy stat/sta endpoint (richer data)
64#[derive(Debug, Deserialize)]
65pub struct LegacyClient {
66    #[serde(rename = "_id")]
67    pub id: String,
68    pub mac: Option<String>,
69    pub ip: Option<String>,
70    pub hostname: Option<String>,
71    pub name: Option<String>,
72    #[serde(default)]
73    pub is_wired: bool,
74    #[serde(default)]
75    pub blocked: bool,
76    #[serde(default)]
77    pub fixed_ap_enabled: bool,
78    pub fixed_ap_mac: Option<String>,
79    pub uptime: Option<u64>,
80    pub tx_bytes: Option<u64>,
81    pub rx_bytes: Option<u64>,
82    pub signal: Option<i32>,
83    pub ap_mac: Option<String>,
84    #[serde(rename = "essid")]
85    pub ssid: Option<String>,
86}
87
88impl LegacyClient {
89    pub fn display_name(&self) -> &str {
90        self.name
91            .as_deref()
92            .or(self.hostname.as_deref())
93            .unwrap_or("-")
94    }
95
96    pub fn clean_name(&self) -> String {
97        let name = self.display_name();
98        strip_mac_suffix(name, self.mac.as_deref())
99    }
100}
101
102// Device from Integration API
103#[derive(Debug, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct Device {
106    pub mac_address: Option<String>,
107    pub ip_address: Option<String>,
108    pub name: Option<String>,
109    pub model: Option<String>,
110    pub state: Option<String>,
111    pub firmware_version: Option<String>,
112}
113
114// Device from Legacy stat/device endpoint (richer data)
115#[derive(Debug, Deserialize)]
116pub struct LegacyDevice {
117    pub mac: Option<String>,
118    pub ip: Option<String>,
119    pub name: Option<String>,
120    pub model: Option<String>,
121    #[serde(rename = "type")]
122    pub device_type: Option<String>,
123    pub state: Option<u32>,
124    pub version: Option<String>,
125    pub uptime: Option<u64>,
126    pub num_sta: Option<u32>,
127    #[serde(default)]
128    pub upgradable: bool,
129    pub upgrade_to_firmware: Option<String>,
130}
131
132impl LegacyDevice {
133    pub fn state_str(&self) -> &str {
134        match self.state {
135            Some(1) => "ONLINE",
136            Some(0) => "OFFLINE",
137            Some(2) => "ADOPTING",
138            Some(4) => "UPGRADING",
139            Some(5) => "PROVISIONING",
140            _ => "UNKNOWN",
141        }
142    }
143}
144
145// Network from Integration API
146#[derive(Debug, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub struct Network {
149    pub name: Option<String>,
150    #[serde(default)]
151    pub enabled: bool,
152    pub vlan_id: Option<u16>,
153    #[serde(default)]
154    pub default: bool,
155}
156
157// Health subsystem from Legacy stat/health
158#[derive(Debug, Deserialize)]
159pub struct HealthSubsystem {
160    pub subsystem: String,
161    pub status: Option<String>,
162    pub num_sta: Option<u32>,
163    pub num_ap: Option<u32>,
164    #[serde(rename = "num_sw")]
165    pub num_switches: Option<u32>,
166    pub wan_ip: Option<String>,
167    pub isp_name: Option<String>,
168}
169
170// Sysinfo from Legacy stat/sysinfo
171#[derive(Debug, Deserialize)]
172pub struct SysInfo {
173    pub hostname: Option<String>,
174    pub version: Option<String>,
175    pub timezone: Option<String>,
176    pub uptime: Option<u64>,
177}
178
179// Host system info from /api/system (UniFi OS level)
180#[derive(Debug, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct HostSystem {
183    pub device_state: Option<String>,
184    pub name: Option<String>,
185}
186
187impl HostSystem {
188    pub fn update_available(&self) -> bool {
189        self.device_state.as_deref() == Some("updateAvailable")
190    }
191}
192
193/// Strip trailing MAC suffix from display names.
194/// UniFi appends " XX:XX" (last 2 bytes of MAC) to hostnames when no user name is set.
195pub fn strip_mac_suffix(name: &str, mac: Option<&str>) -> String {
196    if let Some(mac) = mac {
197        let clean_mac = normalize_mac(mac);
198        // Check for " XX:XX" suffix (last 4 hex chars of MAC with colon)
199        if clean_mac.len() >= 4 {
200            let last4 = &clean_mac[clean_mac.len() - 4..];
201            let suffix = format!(" {}:{}", &last4[..2], &last4[2..]);
202            if let Some(stripped) = name.strip_suffix(&suffix) {
203                return stripped.to_string();
204            }
205            // Also try without colon in suffix
206            let suffix_no_colon = format!(" {last4}");
207            if let Some(stripped) = name.strip_suffix(&suffix_no_colon) {
208                return stripped.to_string();
209            }
210        }
211    }
212    name.to_string()
213}
214
215pub fn normalize_mac(mac: &str) -> String {
216    mac.to_lowercase().replace([':', '-'], "")
217}
218
219pub fn format_mac(mac: &str) -> String {
220    let clean = normalize_mac(mac);
221    if clean.len() != 12 {
222        return mac.to_string();
223    }
224    format!(
225        "{}:{}:{}:{}:{}:{}",
226        &clean[0..2],
227        &clean[2..4],
228        &clean[4..6],
229        &clean[6..8],
230        &clean[8..10],
231        &clean[10..12]
232    )
233}
234
235pub fn format_bytes(bytes: u64) -> String {
236    const KB: u64 = 1024;
237    const MB: u64 = KB * 1024;
238    const GB: u64 = MB * 1024;
239
240    if bytes >= GB {
241        format!("{:.1} GB", bytes as f64 / GB as f64)
242    } else if bytes >= MB {
243        format!("{:.1} MB", bytes as f64 / MB as f64)
244    } else if bytes >= KB {
245        format!("{:.1} KB", bytes as f64 / KB as f64)
246    } else {
247        format!("{bytes} B")
248    }
249}
250
251pub fn format_uptime(seconds: u64) -> String {
252    let days = seconds / 86400;
253    let hours = (seconds % 86400) / 3600;
254    let minutes = (seconds % 3600) / 60;
255
256    if days > 0 {
257        format!("{days}d {hours}h {minutes}m")
258    } else if hours > 0 {
259        format!("{hours}h {minutes}m")
260    } else {
261        format!("{minutes}m")
262    }
263}
264
265// Event from Legacy stat/event endpoint
266#[derive(Debug, Deserialize)]
267pub struct Event {
268    pub key: Option<String>,
269    pub msg: Option<String>,
270    pub subsystem: Option<String>,
271    pub time: Option<u64>,
272    pub datetime: Option<String>,
273}
274
275// Port entry from Legacy stat/device port_table
276#[derive(Debug, Deserialize)]
277pub struct PortEntry {
278    pub port_idx: Option<u32>,
279    pub name: Option<String>,
280    pub media: Option<String>,
281    #[serde(default)]
282    pub up: bool,
283    pub speed: Option<u32>,
284    #[serde(default)]
285    pub full_duplex: bool,
286    #[serde(default)]
287    pub poe_enable: bool,
288    // The legacy /stat/device endpoint may return this as a JSON string
289    // (e.g. "0.00") or as a JSON number depending on firmware. Accept either form.
290    #[serde(default, deserialize_with = "deserialize_string_or_number_f64")]
291    pub poe_power: Option<f64>,
292    #[serde(default)]
293    pub port_poe: bool,
294    pub tx_bytes: Option<u64>,
295    pub rx_bytes: Option<u64>,
296}
297
298fn deserialize_string_or_number_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
299where
300    D: serde::Deserializer<'de>,
301{
302    use serde::de::Error;
303    #[derive(Deserialize)]
304    #[serde(untagged)]
305    enum StringOrNumber {
306        Number(f64),
307        String(String),
308    }
309    match Option::<StringOrNumber>::deserialize(deserializer)? {
310        None => Ok(None),
311        Some(StringOrNumber::Number(n)) => Ok(Some(n)),
312        Some(StringOrNumber::String(s)) => {
313            if s.is_empty() {
314                Ok(None)
315            } else {
316                s.parse::<f64>().map(Some).map_err(D::Error::custom)
317            }
318        }
319    }
320}
321
322// Device with port_table from Legacy stat/device endpoint
323#[derive(Debug, Deserialize)]
324pub struct DeviceWithPorts {
325    pub mac: Option<String>,
326    pub name: Option<String>,
327    pub model: Option<String>,
328    #[serde(default)]
329    pub port_table: Vec<PortEntry>,
330}
331
332// --- Protect API types ---
333
334/// Camera from Protect Integration API
335#[derive(Debug, Deserialize)]
336#[serde(rename_all = "camelCase")]
337pub struct ProtectCamera {
338    pub id: String,
339    pub name: Option<String>,
340    pub mac: Option<String>,
341    pub state: Option<String>,
342    pub model_key: Option<String>,
343    #[serde(default)]
344    pub is_mic_enabled: bool,
345    pub video_mode: Option<String>,
346    pub feature_flags: Option<ProtectFeatureFlags>,
347}
348
349#[derive(Debug, Deserialize)]
350#[serde(rename_all = "camelCase")]
351pub struct ProtectFeatureFlags {
352    #[serde(default)]
353    pub has_hdr: bool,
354    #[serde(default)]
355    pub has_mic: bool,
356    #[serde(default)]
357    pub has_speaker: bool,
358    #[serde(default)]
359    pub has_led_status: bool,
360    #[serde(default)]
361    pub smart_detect_types: Vec<String>,
362    #[serde(default)]
363    pub video_modes: Vec<String>,
364}
365
366/// Full camera from direct Protect API (cookie auth)
367#[derive(Debug, Deserialize)]
368#[serde(rename_all = "camelCase")]
369pub struct ProtectCameraFull {
370    pub id: String,
371    pub name: Option<String>,
372    pub mac: Option<String>,
373    pub host: Option<String>,
374    pub state: Option<String>,
375    #[serde(rename = "type")]
376    pub camera_type: Option<String>,
377    pub market_name: Option<String>,
378    pub platform: Option<String>,
379    pub firmware_version: Option<String>,
380    pub hardware_revision: Option<String>,
381    pub uptime: Option<u64>,
382    pub up_since: Option<u64>,
383    pub last_seen: Option<u64>,
384    #[serde(default)]
385    pub is_recording: bool,
386    #[serde(default)]
387    pub is_motion_detected: bool,
388    #[serde(default)]
389    pub is_dark: bool,
390    pub video_codec: Option<String>,
391    pub current_resolution: Option<String>,
392    pub video_mode: Option<String>,
393    pub hdr_type: Option<String>,
394    pub phy_rate: Option<f64>,
395    #[serde(default)]
396    pub is_mic_enabled: bool,
397    #[serde(default)]
398    pub is_poor_network: bool,
399    pub last_motion: Option<u64>,
400    pub hq_bytes_per_day: Option<u64>,
401    pub lq_bytes_per_day: Option<u64>,
402    pub model_key: Option<String>,
403    #[serde(default)]
404    pub channels: Vec<CameraChannel>,
405    pub stats: Option<CameraStats>,
406    pub wifi_connection_state: Option<WifiConnectionState>,
407    pub feature_flags: Option<ProtectFeatureFlags>,
408    pub recording_settings: Option<RecordingSettings>,
409}
410
411#[derive(Debug, Deserialize)]
412#[serde(rename_all = "camelCase")]
413pub struct CameraChannel {
414    pub id: u32,
415    pub name: Option<String>,
416    #[serde(default)]
417    pub enabled: bool,
418    pub width: Option<u32>,
419    pub height: Option<u32>,
420    pub fps: Option<u32>,
421    pub bitrate: Option<u64>,
422    #[serde(default)]
423    pub is_rtsp_enabled: bool,
424    pub rtsp_alias: Option<String>,
425}
426
427#[derive(Debug, Deserialize)]
428#[serde(rename_all = "camelCase")]
429pub struct CameraStats {
430    pub wifi: Option<WifiStats>,
431    pub storage: Option<StorageStats>,
432}
433
434#[derive(Debug, Deserialize)]
435#[serde(rename_all = "camelCase")]
436pub struct WifiStats {
437    pub channel: Option<u32>,
438    pub frequency: Option<u32>,
439    pub signal_quality: Option<i32>,
440    pub signal_strength: Option<i32>,
441}
442
443#[derive(Debug, Deserialize)]
444#[serde(rename_all = "camelCase")]
445pub struct StorageStats {
446    pub used: Option<u64>,
447    pub rate: Option<f64>,
448}
449
450#[derive(Debug, Deserialize)]
451#[serde(rename_all = "camelCase")]
452pub struct WifiConnectionState {
453    pub channel: Option<u32>,
454    pub frequency: Option<u32>,
455    pub signal_quality: Option<i32>,
456    pub signal_strength: Option<i32>,
457    pub ssid: Option<String>,
458    pub ap_name: Option<String>,
459    pub connectivity: Option<String>,
460}
461
462#[derive(Debug, Deserialize)]
463#[serde(rename_all = "camelCase")]
464pub struct RecordingSettings {
465    pub mode: Option<String>,
466    #[serde(default)]
467    pub enable_motion_detection: bool,
468}
469
470/// RTSPS stream URLs keyed by quality level
471pub type RtspsStreams = std::collections::HashMap<String, Option<String>>;
472
473// Error types
474#[derive(Debug)]
475pub enum ApiError {
476    Http(reqwest::Error),
477    Api { status: u16, message: String },
478    NotFound(String),
479    Auth(String),
480    Other(String),
481}
482
483/// Scan a single error string for TLS certificate failure markers. rustls
484/// reports these as "invalid peer certificate: <reason>", so "certificate" is
485/// the reliable marker; "self-signed" is matched defensively.
486fn text_indicates_cert_failure(s: &str) -> bool {
487    let s = s.to_lowercase();
488    s.contains("certificate") || s.contains("self-signed")
489}
490
491/// Walk a reqwest error's source chain looking for a TLS certificate failure.
492/// reqwest's own Display is only "error sending request for url (...)", so the
493/// cert cause must be read from the nested chain. The top-level Display and the
494/// Debug form are deliberately not scanned: both embed the request URL, so a
495/// controller hostname containing a word like "certificate" would otherwise be
496/// misread as a certificate failure on any unrelated network error.
497fn reqwest_is_cert_failure(e: &reqwest::Error) -> bool {
498    use std::error::Error;
499    let mut source: Option<&dyn std::error::Error> = e.source();
500    while let Some(err) = source {
501        if text_indicates_cert_failure(&err.to_string()) {
502            return true;
503        }
504        source = err.source();
505    }
506    false
507}
508
509impl ApiError {
510    /// True when the error indicates a TLS certificate verification failure, so
511    /// callers can offer the `--accept-invalid-certs` opt-out.
512    pub fn is_tls_cert_error(&self) -> bool {
513        match self {
514            ApiError::Http(e) => reqwest_is_cert_failure(e),
515            other => text_indicates_cert_failure(&other.to_string()),
516        }
517    }
518}
519
520impl fmt::Display for ApiError {
521    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
522        match self {
523            ApiError::Http(e) => {
524                write!(f, "HTTP error: {e}")?;
525                // Certificate failures are checked first because reqwest also
526                // classifies a failed TLS handshake as a connect error.
527                if reqwest_is_cert_failure(e) {
528                    write!(
529                        f,
530                        "\n  Hint: TLS certificate verification failed. For a trusted controller \
531                         with a self-signed cert, run 'unifi config init' to trust it \
532                         interactively, or pass --accept-invalid-certs (or set \
533                         UNIFI_ACCEPT_INVALID_CERTS=true or accept_invalid_certs = true in config)"
534                    )?;
535                } else if e.is_connect() {
536                    write!(
537                        f,
538                        "\n  Hint: Check that the host is reachable and the URL is correct"
539                    )?;
540                } else if e.is_timeout() {
541                    write!(f, "\n  Hint: Request timed out. Is the controller running?")?;
542                } else {
543                    let msg = e.to_string().to_lowercase();
544                    if msg.contains("dns") || msg.contains("resolve") {
545                        write!(
546                            f,
547                            "\n  Hint: Could not resolve hostname. Check the host value"
548                        )?;
549                    }
550                }
551                Ok(())
552            }
553            ApiError::Api { status, message } => write!(f, "API error ({status}): {message}"),
554            ApiError::NotFound(msg) => write!(f, "Not found: {msg}"),
555            ApiError::Auth(msg) => {
556                write!(f, "Authentication error: {msg}")?;
557                write!(
558                    f,
559                    "\n  Hint: Check your API key. Generate one in UniFi Settings > API"
560                )
561            }
562            ApiError::Other(msg) => write!(f, "{msg}"),
563        }
564    }
565}
566
567impl std::error::Error for ApiError {
568    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
569        match self {
570            ApiError::Http(e) => Some(e),
571            _ => None,
572        }
573    }
574}
575
576impl From<reqwest::Error> for ApiError {
577    fn from(e: reqwest::Error) -> Self {
578        if e.status()
579            .is_some_and(|s| s.as_u16() == 401 || s.as_u16() == 403)
580        {
581            ApiError::Auth(e.to_string())
582        } else if e.status().is_some_and(|s| s.as_u16() == 404) {
583            ApiError::NotFound(e.to_string())
584        } else {
585            ApiError::Http(e)
586        }
587    }
588}