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")]
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 #[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#[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 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#[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#[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#[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#[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#[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#[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 pub fn update_available(&self) -> bool {
200 self.device_state.as_deref() == Some("updateAvailable")
201 }
202}
203
204pub fn strip_mac_suffix(name: &str, mac: Option<&str>) -> String {
207 if let Some(mac) = mac {
208 let clean_mac = normalize_mac(mac);
209 if clean_mac.len() >= 4 {
211 let last4 = &clean_mac[clean_mac.len() - 4..];
212 let suffix = format!(" {}:{}", &last4[..2], &last4[2..]);
213 if let Some(stripped) = name.strip_suffix(&suffix) {
214 return stripped.to_string();
215 }
216 let suffix_no_colon = format!(" {last4}");
218 if let Some(stripped) = name.strip_suffix(&suffix_no_colon) {
219 return stripped.to_string();
220 }
221 }
222 }
223 name.to_string()
224}
225
226pub fn normalize_mac(mac: &str) -> String {
227 mac.to_lowercase().replace([':', '-'], "")
228}
229
230pub fn format_mac(mac: &str) -> String {
231 let clean = normalize_mac(mac);
232 if clean.len() != 12 {
233 return mac.to_string();
234 }
235 format!(
236 "{}:{}:{}:{}:{}:{}",
237 &clean[0..2],
238 &clean[2..4],
239 &clean[4..6],
240 &clean[6..8],
241 &clean[8..10],
242 &clean[10..12]
243 )
244}
245
246pub fn format_bytes(bytes: u64) -> String {
247 const KB: u64 = 1024;
248 const MB: u64 = KB * 1024;
249 const GB: u64 = MB * 1024;
250
251 if bytes >= GB {
252 format!("{:.1} GB", bytes as f64 / GB as f64)
253 } else if bytes >= MB {
254 format!("{:.1} MB", bytes as f64 / MB as f64)
255 } else if bytes >= KB {
256 format!("{:.1} KB", bytes as f64 / KB as f64)
257 } else {
258 format!("{bytes} B")
259 }
260}
261
262pub fn format_uptime(seconds: u64) -> String {
263 let days = seconds / 86400;
264 let hours = (seconds % 86400) / 3600;
265 let minutes = (seconds % 3600) / 60;
266
267 if days > 0 {
268 format!("{days}d {hours}h {minutes}m")
269 } else if hours > 0 {
270 format!("{hours}h {minutes}m")
271 } else {
272 format!("{minutes}m")
273 }
274}
275
276#[derive(Debug, Deserialize)]
278pub struct Event {
279 pub key: Option<String>,
280 pub msg: Option<String>,
281 pub subsystem: Option<String>,
282 pub time: Option<u64>,
283 pub datetime: Option<String>,
284}
285
286#[derive(Debug, Deserialize)]
288pub struct PortEntry {
289 pub port_idx: Option<u32>,
290 pub name: Option<String>,
291 pub media: Option<String>,
292 #[serde(default)]
293 pub up: bool,
294 pub speed: Option<u32>,
295 #[serde(default)]
296 pub full_duplex: bool,
297 #[serde(default)]
298 pub poe_enable: bool,
299 #[serde(default, deserialize_with = "deserialize_string_or_number_f64")]
302 pub poe_power: Option<f64>,
303 #[serde(default)]
304 pub port_poe: bool,
305 pub tx_bytes: Option<u64>,
306 pub rx_bytes: Option<u64>,
307}
308
309fn deserialize_string_or_number_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
310where
311 D: serde::Deserializer<'de>,
312{
313 use serde::de::Error;
314 #[derive(Deserialize)]
315 #[serde(untagged)]
316 enum StringOrNumber {
317 Number(f64),
318 String(String),
319 }
320 match Option::<StringOrNumber>::deserialize(deserializer)? {
321 None => Ok(None),
322 Some(StringOrNumber::Number(n)) => Ok(Some(n)),
323 Some(StringOrNumber::String(s)) => {
324 if s.is_empty() {
325 Ok(None)
326 } else {
327 s.parse::<f64>().map(Some).map_err(D::Error::custom)
328 }
329 }
330 }
331}
332
333#[derive(Debug, Deserialize)]
335pub struct DeviceWithPorts {
336 pub mac: Option<String>,
337 pub name: Option<String>,
338 pub model: Option<String>,
339 #[serde(default)]
340 pub port_table: Vec<PortEntry>,
341}
342
343#[derive(Debug, Deserialize)]
347#[serde(rename_all = "camelCase")]
348pub struct ProtectCamera {
349 pub id: String,
350 pub name: Option<String>,
351 pub mac: Option<String>,
352 pub state: Option<String>,
353 pub model_key: Option<String>,
354 #[serde(default)]
355 pub is_mic_enabled: bool,
356 pub video_mode: Option<String>,
357 pub feature_flags: Option<ProtectFeatureFlags>,
358}
359
360#[derive(Debug, Deserialize)]
361#[serde(rename_all = "camelCase")]
362pub struct ProtectFeatureFlags {
363 #[serde(default)]
364 pub has_hdr: bool,
365 #[serde(default)]
366 pub has_mic: bool,
367 #[serde(default)]
368 pub has_speaker: bool,
369 #[serde(default)]
370 pub has_led_status: bool,
371 #[serde(default)]
372 pub smart_detect_types: Vec<String>,
373 #[serde(default)]
374 pub video_modes: Vec<String>,
375}
376
377#[derive(Debug, Deserialize)]
379#[serde(rename_all = "camelCase")]
380pub struct ProtectCameraFull {
381 pub id: String,
382 pub name: Option<String>,
383 pub mac: Option<String>,
384 pub host: Option<String>,
385 pub state: Option<String>,
386 #[serde(rename = "type")]
387 pub camera_type: Option<String>,
388 pub market_name: Option<String>,
389 pub platform: Option<String>,
390 pub firmware_version: Option<String>,
391 pub hardware_revision: Option<String>,
392 pub uptime: Option<u64>,
393 pub up_since: Option<u64>,
394 pub last_seen: Option<u64>,
395 #[serde(default)]
396 pub is_recording: bool,
397 #[serde(default)]
398 pub is_motion_detected: bool,
399 #[serde(default)]
400 pub is_dark: bool,
401 pub video_codec: Option<String>,
402 pub current_resolution: Option<String>,
403 pub video_mode: Option<String>,
404 pub hdr_type: Option<String>,
405 pub phy_rate: Option<f64>,
406 #[serde(default)]
407 pub is_mic_enabled: bool,
408 #[serde(default)]
409 pub is_poor_network: bool,
410 pub last_motion: Option<u64>,
411 pub hq_bytes_per_day: Option<u64>,
412 pub lq_bytes_per_day: Option<u64>,
413 pub model_key: Option<String>,
414 #[serde(default)]
415 pub channels: Vec<CameraChannel>,
416 pub stats: Option<CameraStats>,
417 pub wifi_connection_state: Option<WifiConnectionState>,
418 pub feature_flags: Option<ProtectFeatureFlags>,
419 pub recording_settings: Option<RecordingSettings>,
420}
421
422#[derive(Debug, Deserialize)]
423#[serde(rename_all = "camelCase")]
424pub struct CameraChannel {
425 pub id: u32,
426 pub name: Option<String>,
427 #[serde(default)]
428 pub enabled: bool,
429 pub width: Option<u32>,
430 pub height: Option<u32>,
431 pub fps: Option<u32>,
432 pub bitrate: Option<u64>,
433 #[serde(default)]
434 pub is_rtsp_enabled: bool,
435 pub rtsp_alias: Option<String>,
436}
437
438#[derive(Debug, Deserialize)]
439#[serde(rename_all = "camelCase")]
440pub struct CameraStats {
441 pub wifi: Option<WifiStats>,
442 pub storage: Option<StorageStats>,
443}
444
445#[derive(Debug, Deserialize)]
446#[serde(rename_all = "camelCase")]
447pub struct WifiStats {
448 pub channel: Option<u32>,
449 pub frequency: Option<u32>,
450 pub signal_quality: Option<i32>,
451 pub signal_strength: Option<i32>,
452}
453
454#[derive(Debug, Deserialize)]
455#[serde(rename_all = "camelCase")]
456pub struct StorageStats {
457 pub used: Option<u64>,
458 pub rate: Option<f64>,
459}
460
461#[derive(Debug, Deserialize)]
462#[serde(rename_all = "camelCase")]
463pub struct WifiConnectionState {
464 pub channel: Option<u32>,
465 pub frequency: Option<u32>,
466 pub signal_quality: Option<i32>,
467 pub signal_strength: Option<i32>,
468 pub ssid: Option<String>,
469 pub ap_name: Option<String>,
470 pub connectivity: Option<String>,
471}
472
473#[derive(Debug, Deserialize)]
474#[serde(rename_all = "camelCase")]
475pub struct RecordingSettings {
476 pub mode: Option<String>,
477 #[serde(default)]
478 pub enable_motion_detection: bool,
479}
480
481pub type RtspsStreams = std::collections::HashMap<String, Option<String>>;
483
484#[derive(Debug)]
486pub enum ApiError {
487 Http(reqwest::Error),
488 Api { status: u16, message: String },
489 NotFound(String),
490 Auth(String),
491 Other(String),
492}
493
494fn text_indicates_cert_failure(s: &str) -> bool {
498 let s = s.to_lowercase();
499 s.contains("certificate") || s.contains("self-signed")
500}
501
502fn reqwest_is_cert_failure(e: &reqwest::Error) -> bool {
509 use std::error::Error;
510 let mut source: Option<&dyn std::error::Error> = e.source();
511 while let Some(err) = source {
512 if text_indicates_cert_failure(&err.to_string()) {
513 return true;
514 }
515 source = err.source();
516 }
517 false
518}
519
520impl ApiError {
521 pub fn is_tls_cert_error(&self) -> bool {
524 match self {
525 ApiError::Http(e) => reqwest_is_cert_failure(e),
526 other => text_indicates_cert_failure(&other.to_string()),
527 }
528 }
529}
530
531impl fmt::Display for ApiError {
532 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
533 match self {
534 ApiError::Http(e) => {
535 write!(f, "HTTP error: {e}")?;
536 if reqwest_is_cert_failure(e) {
539 write!(
540 f,
541 "\n Hint: TLS certificate verification failed. For a trusted controller \
542 with a self-signed cert, run 'unifi config init' to trust it \
543 interactively, or pass --accept-invalid-certs (or set \
544 UNIFI_ACCEPT_INVALID_CERTS=true or accept_invalid_certs = true in config)"
545 )?;
546 } else if e.is_connect() {
547 write!(
548 f,
549 "\n Hint: Check that the host is reachable and the URL is correct"
550 )?;
551 } else if e.is_timeout() {
552 write!(f, "\n Hint: Request timed out. Is the controller running?")?;
553 } else {
554 let msg = e.to_string().to_lowercase();
555 if msg.contains("dns") || msg.contains("resolve") {
556 write!(
557 f,
558 "\n Hint: Could not resolve hostname. Check the host value"
559 )?;
560 }
561 }
562 Ok(())
563 }
564 ApiError::Api { status, message } => write!(f, "API error ({status}): {message}"),
565 ApiError::NotFound(msg) => write!(f, "Not found: {msg}"),
566 ApiError::Auth(msg) => {
567 write!(f, "Authentication error: {msg}")?;
568 write!(
569 f,
570 "\n Hint: Check your API key. Generate one in UniFi Settings > API"
571 )
572 }
573 ApiError::Other(msg) => write!(f, "{msg}"),
574 }
575 }
576}
577
578impl std::error::Error for ApiError {
579 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
580 match self {
581 ApiError::Http(e) => Some(e),
582 _ => None,
583 }
584 }
585}
586
587impl From<reqwest::Error> for ApiError {
588 fn from(e: reqwest::Error) -> Self {
589 if e.status()
590 .is_some_and(|s| s.as_u16() == 401 || s.as_u16() == 403)
591 {
592 ApiError::Auth(e.to_string())
593 } else if e.status().is_some_and(|s| s.as_u16() == 404) {
594 ApiError::NotFound(e.to_string())
595 } else {
596 ApiError::Http(e)
597 }
598 }
599}