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
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
58// Client from Legacy stat/sta endpoint (richer data)
59#[derive(Debug, Deserialize)]
60pub struct LegacyClient {
61    #[serde(rename = "_id")]
62    pub id: String,
63    pub mac: Option<String>,
64    pub ip: Option<String>,
65    pub hostname: Option<String>,
66    pub name: Option<String>,
67    #[serde(default)]
68    pub is_wired: bool,
69    pub uptime: Option<u64>,
70    pub tx_bytes: Option<u64>,
71    pub rx_bytes: Option<u64>,
72    pub signal: Option<i32>,
73    pub ap_mac: Option<String>,
74    #[serde(rename = "essid")]
75    pub ssid: Option<String>,
76}
77
78impl LegacyClient {
79    pub fn display_name(&self) -> &str {
80        self.name
81            .as_deref()
82            .or(self.hostname.as_deref())
83            .unwrap_or("-")
84    }
85}
86
87// Device from Integration API
88#[derive(Debug, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct Device {
91    pub mac_address: Option<String>,
92    pub ip_address: Option<String>,
93    pub name: Option<String>,
94    pub model: Option<String>,
95    pub state: Option<String>,
96    pub firmware_version: Option<String>,
97}
98
99// Device from Legacy stat/device endpoint (richer data)
100#[derive(Debug, Deserialize)]
101pub struct LegacyDevice {
102    pub mac: Option<String>,
103    pub ip: Option<String>,
104    pub name: Option<String>,
105    pub model: Option<String>,
106    #[serde(rename = "type")]
107    pub device_type: Option<String>,
108    pub state: Option<u32>,
109    pub version: Option<String>,
110    pub uptime: Option<u64>,
111    pub num_sta: Option<u32>,
112}
113
114impl LegacyDevice {
115    pub fn state_str(&self) -> &str {
116        match self.state {
117            Some(1) => "ONLINE",
118            Some(0) => "OFFLINE",
119            Some(2) => "ADOPTING",
120            Some(4) => "UPGRADING",
121            Some(5) => "PROVISIONING",
122            _ => "UNKNOWN",
123        }
124    }
125}
126
127// Network from Integration API
128#[derive(Debug, Deserialize)]
129#[serde(rename_all = "camelCase")]
130pub struct Network {
131    pub name: Option<String>,
132    #[serde(default)]
133    pub enabled: bool,
134    pub vlan_id: Option<u16>,
135    #[serde(default)]
136    pub default: bool,
137}
138
139// Health subsystem from Legacy stat/health
140#[derive(Debug, Deserialize)]
141pub struct HealthSubsystem {
142    pub subsystem: String,
143    pub status: Option<String>,
144    pub num_sta: Option<u32>,
145    pub num_ap: Option<u32>,
146    #[serde(rename = "num_sw")]
147    pub num_switches: Option<u32>,
148    pub wan_ip: Option<String>,
149    pub isp_name: Option<String>,
150}
151
152// Sysinfo from Legacy stat/sysinfo
153#[derive(Debug, Deserialize)]
154pub struct SysInfo {
155    pub hostname: Option<String>,
156    pub version: Option<String>,
157    pub timezone: Option<String>,
158    pub uptime: Option<u64>,
159}
160
161pub fn normalize_mac(mac: &str) -> String {
162    mac.to_lowercase().replace([':', '-'], "")
163}
164
165pub fn format_mac(mac: &str) -> String {
166    let clean = normalize_mac(mac);
167    if clean.len() != 12 {
168        return mac.to_string();
169    }
170    format!(
171        "{}:{}:{}:{}:{}:{}",
172        &clean[0..2],
173        &clean[2..4],
174        &clean[4..6],
175        &clean[6..8],
176        &clean[8..10],
177        &clean[10..12]
178    )
179}
180
181pub fn format_bytes(bytes: u64) -> String {
182    const KB: u64 = 1024;
183    const MB: u64 = KB * 1024;
184    const GB: u64 = MB * 1024;
185
186    if bytes >= GB {
187        format!("{:.1} GB", bytes as f64 / GB as f64)
188    } else if bytes >= MB {
189        format!("{:.1} MB", bytes as f64 / MB as f64)
190    } else if bytes >= KB {
191        format!("{:.1} KB", bytes as f64 / KB as f64)
192    } else {
193        format!("{bytes} B")
194    }
195}
196
197pub fn format_uptime(seconds: u64) -> String {
198    let days = seconds / 86400;
199    let hours = (seconds % 86400) / 3600;
200    let minutes = (seconds % 3600) / 60;
201
202    if days > 0 {
203        format!("{days}d {hours}h {minutes}m")
204    } else if hours > 0 {
205        format!("{hours}h {minutes}m")
206    } else {
207        format!("{minutes}m")
208    }
209}
210
211// Event from Legacy stat/event endpoint
212#[derive(Debug, Deserialize)]
213pub struct Event {
214    pub key: Option<String>,
215    pub msg: Option<String>,
216    pub subsystem: Option<String>,
217    pub time: Option<u64>,
218    pub datetime: Option<String>,
219}
220
221// Port entry from Legacy stat/device port_table
222#[derive(Debug, Deserialize)]
223pub struct PortEntry {
224    pub port_idx: Option<u32>,
225    pub name: Option<String>,
226    pub media: Option<String>,
227    #[serde(default)]
228    pub up: bool,
229    pub speed: Option<u32>,
230    #[serde(default)]
231    pub full_duplex: bool,
232    #[serde(default)]
233    pub poe_enable: bool,
234    pub poe_power: Option<f64>,
235    #[serde(default)]
236    pub port_poe: bool,
237    pub tx_bytes: Option<u64>,
238    pub rx_bytes: Option<u64>,
239}
240
241// Device with port_table from Legacy stat/device endpoint
242#[derive(Debug, Deserialize)]
243pub struct DeviceWithPorts {
244    pub mac: Option<String>,
245    pub name: Option<String>,
246    pub model: Option<String>,
247    #[serde(default)]
248    pub port_table: Vec<PortEntry>,
249}
250
251// Error types
252#[derive(Debug)]
253pub enum ApiError {
254    Http(reqwest::Error),
255    Api { status: u16, message: String },
256    NotFound(String),
257    Auth(String),
258    Other(String),
259}
260
261impl fmt::Display for ApiError {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        match self {
264            ApiError::Http(e) => {
265                let msg = e.to_string();
266                write!(f, "HTTP error: {e}")?;
267                if msg.contains("connect") || msg.contains("Connection refused") {
268                    write!(
269                        f,
270                        "\n  Hint: Check that the host is reachable and the URL is correct"
271                    )?;
272                } else if msg.contains("dns") || msg.contains("resolve") {
273                    write!(
274                        f,
275                        "\n  Hint: Could not resolve hostname. Check the host value"
276                    )?;
277                } else if msg.contains("timed out") || msg.contains("timeout") {
278                    write!(f, "\n  Hint: Request timed out. Is the controller running?")?;
279                } else if msg.contains("certificate") || msg.contains("SSL") {
280                    write!(
281                        f,
282                        "\n  Hint: TLS/certificate error. The CLI accepts self-signed certs by default"
283                    )?;
284                }
285                Ok(())
286            }
287            ApiError::Api { status, message } => write!(f, "API error ({status}): {message}"),
288            ApiError::NotFound(msg) => write!(f, "Not found: {msg}"),
289            ApiError::Auth(msg) => {
290                write!(f, "Authentication error: {msg}")?;
291                write!(
292                    f,
293                    "\n  Hint: Check your API key. Generate one in UniFi Settings > API"
294                )
295            }
296            ApiError::Other(msg) => write!(f, "{msg}"),
297        }
298    }
299}
300
301impl std::error::Error for ApiError {}
302
303impl From<reqwest::Error> for ApiError {
304    fn from(e: reqwest::Error) -> Self {
305        if e.status()
306            .is_some_and(|s| s.as_u16() == 401 || s.as_u16() == 403)
307        {
308            ApiError::Auth(e.to_string())
309        } else if e.status().is_some_and(|s| s.as_u16() == 404) {
310            ApiError::NotFound(e.to_string())
311        } else {
312            ApiError::Http(e)
313        }
314    }
315}