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 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#[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#[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#[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#[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#[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#[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#[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#[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#[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}