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 poe_mode: Option<String>,
307 pub poe_class: Option<String>,
308 #[serde(default, deserialize_with = "deserialize_string_or_number_f64")]
309 pub poe_voltage: Option<f64>,
310 #[serde(default, deserialize_with = "deserialize_string_or_number_f64")]
311 pub poe_current: Option<f64>,
312 pub poe_good: Option<bool>,
313 pub autoneg: Option<bool>,
318 pub enable: Option<bool>,
321 pub is_uplink: Option<bool>,
324 pub stp_state: Option<String>,
325 pub tx_errors: Option<u64>,
326 pub rx_errors: Option<u64>,
327 pub last_connection: Option<LastConnection>,
329 pub tx_bytes: Option<u64>,
330 pub rx_bytes: Option<u64>,
331}
332
333#[derive(Debug, Deserialize)]
336pub struct LastConnection {
337 pub mac: Option<String>,
338 pub connected: Option<bool>,
339 pub last_seen: Option<u64>,
340}
341
342fn deserialize_string_or_number_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
343where
344 D: serde::Deserializer<'de>,
345{
346 use serde::de::Error;
347 #[derive(Deserialize)]
348 #[serde(untagged)]
349 enum StringOrNumber {
350 Number(f64),
351 String(String),
352 }
353 match Option::<StringOrNumber>::deserialize(deserializer)? {
354 None => Ok(None),
355 Some(StringOrNumber::Number(n)) => Ok(Some(n)),
356 Some(StringOrNumber::String(s)) => {
357 if s.is_empty() {
358 Ok(None)
359 } else {
360 s.parse::<f64>().map(Some).map_err(D::Error::custom)
361 }
362 }
363 }
364}
365
366#[derive(Debug, Deserialize)]
368pub struct DeviceWithPorts {
369 pub mac: Option<String>,
370 pub name: Option<String>,
371 pub model: Option<String>,
372 #[serde(default)]
373 pub port_table: Vec<PortEntry>,
374}
375
376#[derive(Debug, Deserialize)]
380#[serde(rename_all = "camelCase")]
381pub struct ProtectCamera {
382 pub id: String,
383 pub name: Option<String>,
384 pub mac: Option<String>,
385 pub state: Option<String>,
386 pub model_key: Option<String>,
387 #[serde(default)]
388 pub is_mic_enabled: bool,
389 pub video_mode: Option<String>,
390 pub feature_flags: Option<ProtectFeatureFlags>,
391}
392
393#[derive(Debug, Deserialize)]
394#[serde(rename_all = "camelCase")]
395pub struct ProtectFeatureFlags {
396 #[serde(default)]
397 pub has_hdr: bool,
398 #[serde(default)]
399 pub has_mic: bool,
400 #[serde(default)]
401 pub has_speaker: bool,
402 #[serde(default)]
403 pub has_led_status: bool,
404 #[serde(default)]
405 pub smart_detect_types: Vec<String>,
406 #[serde(default)]
407 pub video_modes: Vec<String>,
408}
409
410#[derive(Debug, Deserialize)]
412#[serde(rename_all = "camelCase")]
413pub struct ProtectCameraFull {
414 pub id: String,
415 pub name: Option<String>,
416 pub mac: Option<String>,
417 pub host: Option<String>,
418 pub state: Option<String>,
419 #[serde(rename = "type")]
420 pub camera_type: Option<String>,
421 pub market_name: Option<String>,
422 pub platform: Option<String>,
423 pub firmware_version: Option<String>,
424 pub hardware_revision: Option<String>,
425 pub uptime: Option<u64>,
426 pub up_since: Option<u64>,
427 pub last_seen: Option<u64>,
428 #[serde(default)]
429 pub is_recording: bool,
430 #[serde(default)]
431 pub is_motion_detected: bool,
432 #[serde(default)]
433 pub is_dark: bool,
434 pub video_codec: Option<String>,
435 pub current_resolution: Option<String>,
436 pub video_mode: Option<String>,
437 pub hdr_type: Option<String>,
438 pub phy_rate: Option<f64>,
439 #[serde(default)]
440 pub is_mic_enabled: bool,
441 #[serde(default)]
442 pub is_poor_network: bool,
443 pub last_motion: Option<u64>,
444 pub hq_bytes_per_day: Option<u64>,
445 pub lq_bytes_per_day: Option<u64>,
446 pub model_key: Option<String>,
447 #[serde(default)]
448 pub channels: Vec<CameraChannel>,
449 pub stats: Option<CameraStats>,
450 pub wifi_connection_state: Option<WifiConnectionState>,
451 pub feature_flags: Option<ProtectFeatureFlags>,
452 pub recording_settings: Option<RecordingSettings>,
453}
454
455#[derive(Debug, Deserialize)]
456#[serde(rename_all = "camelCase")]
457pub struct CameraChannel {
458 pub id: u32,
459 pub name: Option<String>,
460 #[serde(default)]
461 pub enabled: bool,
462 pub width: Option<u32>,
463 pub height: Option<u32>,
464 pub fps: Option<u32>,
465 pub bitrate: Option<u64>,
466 #[serde(default)]
467 pub is_rtsp_enabled: bool,
468 pub rtsp_alias: Option<String>,
469}
470
471#[derive(Debug, Deserialize)]
472#[serde(rename_all = "camelCase")]
473pub struct CameraStats {
474 pub wifi: Option<WifiStats>,
475 pub storage: Option<StorageStats>,
476}
477
478#[derive(Debug, Deserialize)]
479#[serde(rename_all = "camelCase")]
480pub struct WifiStats {
481 pub channel: Option<u32>,
482 pub frequency: Option<u32>,
483 pub signal_quality: Option<i32>,
484 pub signal_strength: Option<i32>,
485}
486
487#[derive(Debug, Deserialize)]
488#[serde(rename_all = "camelCase")]
489pub struct StorageStats {
490 pub used: Option<u64>,
491 pub rate: Option<f64>,
492}
493
494#[derive(Debug, Deserialize)]
495#[serde(rename_all = "camelCase")]
496pub struct WifiConnectionState {
497 pub channel: Option<u32>,
498 pub frequency: Option<u32>,
499 pub signal_quality: Option<i32>,
500 pub signal_strength: Option<i32>,
501 pub ssid: Option<String>,
502 pub ap_name: Option<String>,
503 pub connectivity: Option<String>,
504}
505
506#[derive(Debug, Deserialize)]
507#[serde(rename_all = "camelCase")]
508pub struct RecordingSettings {
509 pub mode: Option<String>,
510 #[serde(default)]
511 pub enable_motion_detection: bool,
512}
513
514pub type RtspsStreams = std::collections::HashMap<String, Option<String>>;
516
517#[derive(Debug)]
519pub enum ApiError {
520 Http(reqwest::Error),
521 Api {
522 status: u16,
523 message: String,
524 },
525 NotFound(String),
526 Auth(String),
527 Conflict(String),
531 Other(String),
532}
533
534fn text_indicates_cert_failure(s: &str) -> bool {
538 let s = s.to_lowercase();
539 s.contains("certificate") || s.contains("self-signed")
540}
541
542fn reqwest_is_cert_failure(e: &reqwest::Error) -> bool {
549 use std::error::Error;
550 let mut source: Option<&dyn std::error::Error> = e.source();
551 while let Some(err) = source {
552 if text_indicates_cert_failure(&err.to_string()) {
553 return true;
554 }
555 source = err.source();
556 }
557 false
558}
559
560impl ApiError {
561 pub fn is_tls_cert_error(&self) -> bool {
564 match self {
565 ApiError::Http(e) => reqwest_is_cert_failure(e),
566 other => text_indicates_cert_failure(&other.to_string()),
567 }
568 }
569}
570
571impl fmt::Display for ApiError {
572 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
573 match self {
574 ApiError::Http(e) => {
575 write!(f, "HTTP error: {e}")?;
576 if reqwest_is_cert_failure(e) {
579 write!(
580 f,
581 "\n Hint: TLS certificate verification failed. For a trusted controller \
582 with a self-signed cert, run 'unifi config init' to trust it \
583 interactively, or pass --accept-invalid-certs (or set \
584 UNIFI_ACCEPT_INVALID_CERTS=true or accept_invalid_certs = true in config)"
585 )?;
586 } else if e.is_connect() {
587 write!(
588 f,
589 "\n Hint: Check that the host is reachable and the URL is correct"
590 )?;
591 } else if e.is_timeout() {
592 write!(f, "\n Hint: Request timed out. Is the controller running?")?;
593 } else {
594 let msg = e.to_string().to_lowercase();
595 if msg.contains("dns") || msg.contains("resolve") {
596 write!(
597 f,
598 "\n Hint: Could not resolve hostname. Check the host value"
599 )?;
600 }
601 }
602 Ok(())
603 }
604 ApiError::Api { status, message } => write!(f, "API error ({status}): {message}"),
605 ApiError::NotFound(msg) => write!(f, "Not found: {msg}"),
606 ApiError::Auth(msg) => {
607 write!(f, "Authentication error: {msg}")?;
608 write!(
609 f,
610 "\n Hint: Check your API key. Generate one in UniFi Settings > API"
611 )
612 }
613 ApiError::Conflict(msg) => write!(f, "{msg}"),
614 ApiError::Other(msg) => write!(f, "{msg}"),
615 }
616 }
617}
618
619impl std::error::Error for ApiError {
620 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
621 match self {
622 ApiError::Http(e) => Some(e),
623 _ => None,
624 }
625 }
626}
627
628impl From<reqwest::Error> for ApiError {
629 fn from(e: reqwest::Error) -> Self {
630 if e.status()
631 .is_some_and(|s| s.as_u16() == 401 || s.as_u16() == 403)
632 {
633 ApiError::Auth(e.to_string())
634 } else if e.status().is_some_and(|s| s.as_u16() == 404) {
635 ApiError::NotFound(e.to_string())
636 } else {
637 ApiError::Http(e)
638 }
639 }
640}