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    /// Whether a firmware update is waiting, or `None` when the host did not say.
200    ///
201    /// A host that reported no device state has not reported an up-to-date one,
202    /// so the answer is unknown rather than negative. Only a state the host did
203    /// report settles the question, either way.
204    pub fn update_available(&self) -> Option<bool> {
205        self.device_state
206            .as_deref()
207            .map(|state| state == "updateAvailable")
208    }
209}
210
211/// Strip trailing MAC suffix from display names.
212/// UniFi appends " XX:XX" (last 2 bytes of MAC) to hostnames when no user name is set.
213pub fn strip_mac_suffix(name: &str, mac: Option<&str>) -> String {
214    if let Some(mac) = mac {
215        let clean_mac = normalize_mac(mac);
216        // Check for " XX:XX" suffix (last 4 hex chars of MAC with colon)
217        if clean_mac.len() >= 4 {
218            let last4 = &clean_mac[clean_mac.len() - 4..];
219            let suffix = format!(" {}:{}", &last4[..2], &last4[2..]);
220            if let Some(stripped) = name.strip_suffix(&suffix) {
221                return stripped.to_string();
222            }
223            // Also try without colon in suffix
224            let suffix_no_colon = format!(" {last4}");
225            if let Some(stripped) = name.strip_suffix(&suffix_no_colon) {
226                return stripped.to_string();
227            }
228        }
229    }
230    name.to_string()
231}
232
233pub fn normalize_mac(mac: &str) -> String {
234    mac.to_lowercase().replace([':', '-'], "")
235}
236
237pub fn format_mac(mac: &str) -> String {
238    let clean = normalize_mac(mac);
239    if clean.len() != 12 {
240        return mac.to_string();
241    }
242    format!(
243        "{}:{}:{}:{}:{}:{}",
244        &clean[0..2],
245        &clean[2..4],
246        &clean[4..6],
247        &clean[6..8],
248        &clean[8..10],
249        &clean[10..12]
250    )
251}
252
253pub fn format_bytes(bytes: u64) -> String {
254    const KB: u64 = 1024;
255    const MB: u64 = KB * 1024;
256    const GB: u64 = MB * 1024;
257
258    if bytes >= GB {
259        format!("{:.1} GB", bytes as f64 / GB as f64)
260    } else if bytes >= MB {
261        format!("{:.1} MB", bytes as f64 / MB as f64)
262    } else if bytes >= KB {
263        format!("{:.1} KB", bytes as f64 / KB as f64)
264    } else {
265        format!("{bytes} B")
266    }
267}
268
269pub fn format_uptime(seconds: u64) -> String {
270    let days = seconds / 86400;
271    let hours = (seconds % 86400) / 3600;
272    let minutes = (seconds % 3600) / 60;
273
274    if days > 0 {
275        format!("{days}d {hours}h {minutes}m")
276    } else if hours > 0 {
277        format!("{hours}h {minutes}m")
278    } else {
279        format!("{minutes}m")
280    }
281}
282
283// Event from Legacy stat/event endpoint
284#[derive(Debug, Deserialize)]
285pub struct Event {
286    pub key: Option<String>,
287    pub msg: Option<String>,
288    pub subsystem: Option<String>,
289    pub time: Option<u64>,
290    pub datetime: Option<String>,
291}
292
293// Port entry from Legacy stat/device port_table
294#[derive(Debug, Deserialize)]
295pub struct PortEntry {
296    pub port_idx: Option<u32>,
297    pub name: Option<String>,
298    pub media: Option<String>,
299    #[serde(default)]
300    pub up: bool,
301    pub speed: Option<u32>,
302    #[serde(default)]
303    pub full_duplex: bool,
304    #[serde(default)]
305    pub poe_enable: bool,
306    // The legacy /stat/device endpoint may return this as a JSON string
307    // (e.g. "0.00") or as a JSON number depending on firmware. Accept either form.
308    #[serde(default, deserialize_with = "deserialize_string_or_number_f64")]
309    pub poe_power: Option<f64>,
310    #[serde(default)]
311    pub port_poe: bool,
312    /// "auto", "off", "passthrough", "passive24v". Absent on some firmware.
313    pub poe_mode: Option<String>,
314    pub poe_class: Option<String>,
315    #[serde(default, deserialize_with = "deserialize_string_or_number_f64")]
316    pub poe_voltage: Option<f64>,
317    #[serde(default, deserialize_with = "deserialize_string_or_number_f64")]
318    pub poe_current: Option<f64>,
319    pub poe_good: Option<bool>,
320    /// Auto-negotiation state. `Option`, not a defaulted bool, like `enable`
321    /// and `is_uplink` below: a firmware that omits this key must not be
322    /// reported as "auto-negotiation off". Matches `poe_good` above; contrast
323    /// `up`/`poe_enable`, where an absent key genuinely does mean false.
324    pub autoneg: Option<bool>,
325    /// Administrative enable state. Same tri-state rationale as `autoneg`: an
326    /// absent key must not be reported as "port administratively disabled".
327    pub enable: Option<bool>,
328    /// Whether this port is the switch's uplink. Same tri-state rationale as
329    /// `autoneg`: an absent key must not be reported as "not an uplink".
330    pub is_uplink: Option<bool>,
331    pub stp_state: Option<String>,
332    pub tx_errors: Option<u64>,
333    pub rx_errors: Option<u64>,
334    /// Absent entirely on a port nothing has linked to within retention.
335    pub last_connection: Option<LastConnection>,
336    pub tx_bytes: Option<u64>,
337    pub rx_bytes: Option<u64>,
338}
339
340/// The device most recently seen on a port. `connected` distinguishes a live
341/// attachment from a stale record of a device that has since moved.
342#[derive(Debug, Deserialize)]
343pub struct LastConnection {
344    pub mac: Option<String>,
345    pub connected: Option<bool>,
346    pub last_seen: Option<u64>,
347}
348
349fn deserialize_string_or_number_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
350where
351    D: serde::Deserializer<'de>,
352{
353    use serde::de::Error;
354    #[derive(Deserialize)]
355    #[serde(untagged)]
356    enum StringOrNumber {
357        Number(f64),
358        String(String),
359    }
360    match Option::<StringOrNumber>::deserialize(deserializer)? {
361        None => Ok(None),
362        Some(StringOrNumber::Number(n)) => Ok(Some(n)),
363        Some(StringOrNumber::String(s)) => {
364            if s.is_empty() {
365                Ok(None)
366            } else {
367                s.parse::<f64>().map(Some).map_err(D::Error::custom)
368            }
369        }
370    }
371}
372
373// Device with port_table from Legacy stat/device endpoint
374#[derive(Debug, Deserialize)]
375pub struct DeviceWithPorts {
376    pub mac: Option<String>,
377    pub name: Option<String>,
378    pub model: Option<String>,
379    #[serde(default)]
380    pub port_table: Vec<PortEntry>,
381}
382
383// --- Protect API types ---
384
385/// Camera from Protect Integration API
386#[derive(Debug, Deserialize)]
387#[serde(rename_all = "camelCase")]
388pub struct ProtectCamera {
389    pub id: String,
390    pub name: Option<String>,
391    pub mac: Option<String>,
392    pub state: Option<String>,
393    pub model_key: Option<String>,
394    pub is_mic_enabled: Option<bool>,
395    pub video_mode: Option<String>,
396    pub feature_flags: Option<ProtectFeatureFlags>,
397}
398
399#[derive(Debug, Deserialize)]
400#[serde(rename_all = "camelCase")]
401pub struct ProtectFeatureFlags {
402    #[serde(default)]
403    pub has_hdr: bool,
404    #[serde(default)]
405    pub has_mic: bool,
406    #[serde(default)]
407    pub has_speaker: bool,
408    #[serde(default)]
409    pub has_led_status: bool,
410    #[serde(default)]
411    pub smart_detect_types: Vec<String>,
412    #[serde(default)]
413    pub video_modes: Vec<String>,
414}
415
416/// Full camera from direct Protect API (cookie auth)
417#[derive(Debug, Deserialize)]
418#[serde(rename_all = "camelCase")]
419pub struct ProtectCameraFull {
420    pub id: String,
421    pub name: Option<String>,
422    pub mac: Option<String>,
423    pub host: Option<String>,
424    pub state: Option<String>,
425    #[serde(rename = "type")]
426    pub camera_type: Option<String>,
427    pub market_name: Option<String>,
428    pub platform: Option<String>,
429    pub firmware_version: Option<String>,
430    pub hardware_revision: Option<String>,
431    pub uptime: Option<u64>,
432    pub up_since: Option<u64>,
433    pub last_seen: Option<u64>,
434    pub is_recording: Option<bool>,
435    /// Tri-state for the same reason as `is_recording`: a camera that did not
436    /// report motion has not reported stillness.
437    pub is_motion_detected: Option<bool>,
438    pub is_dark: Option<bool>,
439    pub video_codec: Option<String>,
440    pub current_resolution: Option<String>,
441    pub video_mode: Option<String>,
442    pub hdr_type: Option<String>,
443    pub phy_rate: Option<f64>,
444    pub is_mic_enabled: Option<bool>,
445    #[serde(default)]
446    pub is_poor_network: bool,
447    pub last_motion: Option<u64>,
448    pub hq_bytes_per_day: Option<u64>,
449    pub lq_bytes_per_day: Option<u64>,
450    pub model_key: Option<String>,
451    #[serde(default)]
452    pub channels: Vec<CameraChannel>,
453    pub stats: Option<CameraStats>,
454    pub wifi_connection_state: Option<WifiConnectionState>,
455    pub feature_flags: Option<ProtectFeatureFlags>,
456    pub recording_settings: Option<RecordingSettings>,
457}
458
459#[derive(Debug, Deserialize)]
460#[serde(rename_all = "camelCase")]
461pub struct CameraChannel {
462    pub id: u32,
463    pub name: Option<String>,
464    /// Tri-state like `is_rtsp_enabled` below: a channel whose state the camera
465    /// did not report is not a channel reported as switched off.
466    pub enabled: Option<bool>,
467    pub width: Option<u32>,
468    pub height: Option<u32>,
469    pub fps: Option<u32>,
470    pub bitrate: Option<u64>,
471    pub is_rtsp_enabled: Option<bool>,
472    pub rtsp_alias: Option<String>,
473}
474
475#[derive(Debug, Deserialize)]
476#[serde(rename_all = "camelCase")]
477pub struct CameraStats {
478    pub wifi: Option<WifiStats>,
479    pub storage: Option<StorageStats>,
480}
481
482#[derive(Debug, Deserialize)]
483#[serde(rename_all = "camelCase")]
484pub struct WifiStats {
485    pub channel: Option<u32>,
486    pub frequency: Option<u32>,
487    pub signal_quality: Option<i32>,
488    pub signal_strength: Option<i32>,
489}
490
491#[derive(Debug, Deserialize)]
492#[serde(rename_all = "camelCase")]
493pub struct StorageStats {
494    pub used: Option<u64>,
495    pub rate: Option<f64>,
496}
497
498#[derive(Debug, Deserialize)]
499#[serde(rename_all = "camelCase")]
500pub struct WifiConnectionState {
501    pub channel: Option<u32>,
502    pub frequency: Option<u32>,
503    pub signal_quality: Option<i32>,
504    pub signal_strength: Option<i32>,
505    pub ssid: Option<String>,
506    pub ap_name: Option<String>,
507    pub connectivity: Option<String>,
508}
509
510#[derive(Debug, Deserialize)]
511#[serde(rename_all = "camelCase")]
512pub struct RecordingSettings {
513    pub mode: Option<String>,
514    /// Tri-state: settings that did not mention motion detection have not said
515    /// it is switched off.
516    pub enable_motion_detection: Option<bool>,
517}
518
519/// RTSPS stream URLs keyed by quality level
520pub type RtspsStreams = std::collections::HashMap<String, Option<String>>;
521
522// Error types
523#[derive(Debug)]
524pub enum ApiError {
525    Http(reqwest::Error),
526    Api {
527        status: u16,
528        message: String,
529    },
530    NotFound(String),
531    Auth(String),
532    /// A request that cannot succeed against the resource's current state,
533    /// rejected locally before any HTTP call. Published by `unifi schema`
534    /// as kind `conflict`, exit code 6.
535    Conflict(String),
536    /// The controller does not serve this endpoint at all. Distinct from
537    /// `NotFound` because the whole API is absent, not one record, so there is
538    /// no other identifier worth trying. Published as kind `unsupported`.
539    Unsupported {
540        endpoint: String,
541        reason: UnsupportedReason,
542    },
543    Other(String),
544}
545
546/// How the controller revealed that an endpoint is absent.
547///
548/// Both forms mean the same thing to a caller, so they share one error kind.
549/// They are kept apart because the message has to say what actually happened:
550/// guessing at the wrong one sends the reader looking for a fault that is not
551/// there.
552#[derive(Debug)]
553pub enum UnsupportedReason {
554    /// The endpoint answered with something other than JSON. UniFi OS proxies
555    /// a request for an application it does not have to its own web UI, so the
556    /// call returns 200 with an HTML page.
557    NotJson { content_type: String },
558    /// The controller rejected the endpoint itself rather than the request.
559    /// UniFi Network answers an unknown legacy resource this way, so a
560    /// firmware that has dropped an endpoint is indistinguishable from one
561    /// that never had it, and neither is worth retrying.
562    Removed,
563}
564
565/// Scan a single error string for TLS certificate failure markers. rustls
566/// reports these as "invalid peer certificate: <reason>", so "certificate" is
567/// the reliable marker; "self-signed" is matched defensively.
568fn text_indicates_cert_failure(s: &str) -> bool {
569    let s = s.to_lowercase();
570    s.contains("certificate") || s.contains("self-signed")
571}
572
573/// Walk a reqwest error's source chain looking for a TLS certificate failure.
574/// reqwest's own Display is only "error sending request for url (...)", so the
575/// cert cause must be read from the nested chain. The top-level Display and the
576/// Debug form are deliberately not scanned: both embed the request URL, so a
577/// controller hostname containing a word like "certificate" would otherwise be
578/// misread as a certificate failure on any unrelated network error.
579fn reqwest_is_cert_failure(e: &reqwest::Error) -> bool {
580    use std::error::Error;
581    let mut source: Option<&dyn std::error::Error> = e.source();
582    while let Some(err) = source {
583        if text_indicates_cert_failure(&err.to_string()) {
584            return true;
585        }
586        source = err.source();
587    }
588    false
589}
590
591impl ApiError {
592    /// True when the error indicates a TLS certificate verification failure, so
593    /// callers can offer the `--accept-invalid-certs` opt-out.
594    pub fn is_tls_cert_error(&self) -> bool {
595        match self {
596            ApiError::Http(e) => reqwest_is_cert_failure(e),
597            other => text_indicates_cert_failure(&other.to_string()),
598        }
599    }
600}
601
602impl fmt::Display for ApiError {
603    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
604        match self {
605            ApiError::Http(e) => {
606                write!(f, "HTTP error: {e}")?;
607                // Certificate failures are checked first because reqwest also
608                // classifies a failed TLS handshake as a connect error.
609                if reqwest_is_cert_failure(e) {
610                    write!(
611                        f,
612                        "\n  Hint: TLS certificate verification failed. For a trusted controller \
613                         with a self-signed cert, run 'unifi config init' to trust it \
614                         interactively, or pass --accept-invalid-certs (or set \
615                         UNIFI_ACCEPT_INVALID_CERTS=true or accept_invalid_certs = true in config)"
616                    )?;
617                } else if e.is_connect() {
618                    write!(
619                        f,
620                        "\n  Hint: Check that the host is reachable and the URL is correct"
621                    )?;
622                } else if e.is_timeout() {
623                    write!(f, "\n  Hint: Request timed out. Is the controller running?")?;
624                } else {
625                    let msg = e.to_string().to_lowercase();
626                    if msg.contains("dns") || msg.contains("resolve") {
627                        write!(
628                            f,
629                            "\n  Hint: Could not resolve hostname. Check the host value"
630                        )?;
631                    }
632                }
633                Ok(())
634            }
635            ApiError::Api { status, message } => write!(f, "API error ({status}): {message}"),
636            ApiError::NotFound(msg) => write!(f, "Not found: {msg}"),
637            ApiError::Auth(msg) => {
638                write!(f, "Authentication error: {msg}")?;
639                write!(
640                    f,
641                    "\n  Hint: Check your API key. Generate one in UniFi Settings > API"
642                )
643            }
644            ApiError::Conflict(msg) => write!(f, "{msg}"),
645            ApiError::Unsupported { endpoint, reason } => {
646                match reason {
647                    UnsupportedReason::NotJson { content_type } => write!(
648                        f,
649                        "This controller does not serve {endpoint}: it answered with \
650                         {content_type} instead of JSON"
651                    )?,
652                    UnsupportedReason::Removed => write!(
653                        f,
654                        "This controller does not serve {endpoint}: it rejected the endpoint \
655                         itself, so no parameter or identifier would change the result"
656                    )?,
657                }
658                if endpoint.contains("/protect/") {
659                    write!(
660                        f,
661                        "\n  Hint: UniFi OS proxies the request to its web UI when the Protect \
662                         application is not installed on the controller"
663                    )?;
664                } else if endpoint.contains("/stat/event") {
665                    write!(
666                        f,
667                        "\n  Hint: UniFi Network 9 removed the REST event log, and this \
668                         controller does not serve /rest/alarm either. The remaining event \
669                         stream is the events WebSocket, which this CLI does not consume"
670                    )?;
671                }
672                Ok(())
673            }
674            ApiError::Other(msg) => write!(f, "{msg}"),
675        }
676    }
677}
678
679impl std::error::Error for ApiError {
680    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
681        match self {
682            ApiError::Http(e) => Some(e),
683            _ => None,
684        }
685    }
686}
687
688impl From<reqwest::Error> for ApiError {
689    fn from(e: reqwest::Error) -> Self {
690        if e.status()
691            .is_some_and(|s| s.as_u16() == 401 || s.as_u16() == 403)
692        {
693            ApiError::Auth(e.to_string())
694        } else if e.status().is_some_and(|s| s.as_u16() == 404) {
695            ApiError::NotFound(e.to_string())
696        } else {
697            ApiError::Http(e)
698        }
699    }
700}