1use serde::Deserialize;
2use std::fmt;
3
4#[cfg(test)]
5#[path = "tests.rs"]
6mod tests;
7
8#[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#[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#[derive(Debug, Deserialize)]
31pub struct Site {
32 pub id: String,
33}
34
35#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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}