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