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