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) -> Option<bool> {
205 self.device_state
206 .as_deref()
207 .map(|state| state == "updateAvailable")
208 }
209}
210
211pub fn strip_mac_suffix(name: &str, mac: Option<&str>) -> String {
214 if let Some(mac) = mac {
215 let clean_mac = normalize_mac(mac);
216 if clean_mac.len() >= 4 {
218 let last4 = &clean_mac[clean_mac.len() - 4..];
219 let suffix = format!(" {}:{}", &last4[..2], &last4[2..]);
220 if let Some(stripped) = name.strip_suffix(&suffix) {
221 return stripped.to_string();
222 }
223 let suffix_no_colon = format!(" {last4}");
225 if let Some(stripped) = name.strip_suffix(&suffix_no_colon) {
226 return stripped.to_string();
227 }
228 }
229 }
230 name.to_string()
231}
232
233pub fn normalize_mac(mac: &str) -> String {
234 mac.to_lowercase().replace([':', '-'], "")
235}
236
237pub fn format_mac(mac: &str) -> String {
238 let clean = normalize_mac(mac);
239 if clean.len() != 12 {
240 return mac.to_string();
241 }
242 format!(
243 "{}:{}:{}:{}:{}:{}",
244 &clean[0..2],
245 &clean[2..4],
246 &clean[4..6],
247 &clean[6..8],
248 &clean[8..10],
249 &clean[10..12]
250 )
251}
252
253pub fn format_bytes(bytes: u64) -> String {
254 const KB: u64 = 1024;
255 const MB: u64 = KB * 1024;
256 const GB: u64 = MB * 1024;
257
258 if bytes >= GB {
259 format!("{:.1} GB", bytes as f64 / GB as f64)
260 } else if bytes >= MB {
261 format!("{:.1} MB", bytes as f64 / MB as f64)
262 } else if bytes >= KB {
263 format!("{:.1} KB", bytes as f64 / KB as f64)
264 } else {
265 format!("{bytes} B")
266 }
267}
268
269pub fn format_uptime(seconds: u64) -> String {
270 let days = seconds / 86400;
271 let hours = (seconds % 86400) / 3600;
272 let minutes = (seconds % 3600) / 60;
273
274 if days > 0 {
275 format!("{days}d {hours}h {minutes}m")
276 } else if hours > 0 {
277 format!("{hours}h {minutes}m")
278 } else {
279 format!("{minutes}m")
280 }
281}
282
283#[derive(Debug, Deserialize)]
285pub struct Event {
286 pub key: Option<String>,
287 pub msg: Option<String>,
288 pub subsystem: Option<String>,
289 pub time: Option<u64>,
290 pub datetime: Option<String>,
291}
292
293#[derive(Debug, Deserialize)]
295pub struct PortEntry {
296 pub port_idx: Option<u32>,
297 pub name: Option<String>,
298 pub media: Option<String>,
299 #[serde(default)]
300 pub up: bool,
301 pub speed: Option<u32>,
302 #[serde(default)]
303 pub full_duplex: bool,
304 #[serde(default)]
305 pub poe_enable: bool,
306 #[serde(default, deserialize_with = "deserialize_string_or_number_f64")]
309 pub poe_power: Option<f64>,
310 #[serde(default)]
311 pub port_poe: bool,
312 pub poe_mode: Option<String>,
314 pub poe_class: Option<String>,
315 #[serde(default, deserialize_with = "deserialize_string_or_number_f64")]
316 pub poe_voltage: Option<f64>,
317 #[serde(default, deserialize_with = "deserialize_string_or_number_f64")]
318 pub poe_current: Option<f64>,
319 pub poe_good: Option<bool>,
320 pub autoneg: Option<bool>,
325 pub enable: Option<bool>,
328 pub is_uplink: Option<bool>,
331 pub stp_state: Option<String>,
332 pub tx_errors: Option<u64>,
333 pub rx_errors: Option<u64>,
334 pub last_connection: Option<LastConnection>,
336 pub tx_bytes: Option<u64>,
337 pub rx_bytes: Option<u64>,
338}
339
340#[derive(Debug, Deserialize)]
343pub struct LastConnection {
344 pub mac: Option<String>,
345 pub connected: Option<bool>,
346 pub last_seen: Option<u64>,
347}
348
349fn deserialize_string_or_number_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
350where
351 D: serde::Deserializer<'de>,
352{
353 use serde::de::Error;
354 #[derive(Deserialize)]
355 #[serde(untagged)]
356 enum StringOrNumber {
357 Number(f64),
358 String(String),
359 }
360 match Option::<StringOrNumber>::deserialize(deserializer)? {
361 None => Ok(None),
362 Some(StringOrNumber::Number(n)) => Ok(Some(n)),
363 Some(StringOrNumber::String(s)) => {
364 if s.is_empty() {
365 Ok(None)
366 } else {
367 s.parse::<f64>().map(Some).map_err(D::Error::custom)
368 }
369 }
370 }
371}
372
373#[derive(Debug, Deserialize)]
375pub struct DeviceWithPorts {
376 pub mac: Option<String>,
377 pub name: Option<String>,
378 pub model: Option<String>,
379 #[serde(default)]
380 pub port_table: Vec<PortEntry>,
381}
382
383#[derive(Debug, Deserialize)]
387#[serde(rename_all = "camelCase")]
388pub struct ProtectCamera {
389 pub id: String,
390 pub name: Option<String>,
391 pub mac: Option<String>,
392 pub state: Option<String>,
393 pub model_key: Option<String>,
394 pub is_mic_enabled: Option<bool>,
395 pub video_mode: Option<String>,
396 pub feature_flags: Option<ProtectFeatureFlags>,
397}
398
399#[derive(Debug, Deserialize)]
400#[serde(rename_all = "camelCase")]
401pub struct ProtectFeatureFlags {
402 #[serde(default)]
403 pub has_hdr: bool,
404 #[serde(default)]
405 pub has_mic: bool,
406 #[serde(default)]
407 pub has_speaker: bool,
408 #[serde(default)]
409 pub has_led_status: bool,
410 #[serde(default)]
411 pub smart_detect_types: Vec<String>,
412 #[serde(default)]
413 pub video_modes: Vec<String>,
414}
415
416#[derive(Debug, Deserialize)]
418#[serde(rename_all = "camelCase")]
419pub struct ProtectCameraFull {
420 pub id: String,
421 pub name: Option<String>,
422 pub mac: Option<String>,
423 pub host: Option<String>,
424 pub state: Option<String>,
425 #[serde(rename = "type")]
426 pub camera_type: Option<String>,
427 pub market_name: Option<String>,
428 pub platform: Option<String>,
429 pub firmware_version: Option<String>,
430 pub hardware_revision: Option<String>,
431 pub uptime: Option<u64>,
432 pub up_since: Option<u64>,
433 pub last_seen: Option<u64>,
434 pub is_recording: Option<bool>,
435 pub is_motion_detected: Option<bool>,
438 pub is_dark: Option<bool>,
439 pub video_codec: Option<String>,
440 pub current_resolution: Option<String>,
441 pub video_mode: Option<String>,
442 pub hdr_type: Option<String>,
443 pub phy_rate: Option<f64>,
444 pub is_mic_enabled: Option<bool>,
445 #[serde(default)]
446 pub is_poor_network: bool,
447 pub last_motion: Option<u64>,
448 pub hq_bytes_per_day: Option<u64>,
449 pub lq_bytes_per_day: Option<u64>,
450 pub model_key: Option<String>,
451 #[serde(default)]
452 pub channels: Vec<CameraChannel>,
453 pub stats: Option<CameraStats>,
454 pub wifi_connection_state: Option<WifiConnectionState>,
455 pub feature_flags: Option<ProtectFeatureFlags>,
456 pub recording_settings: Option<RecordingSettings>,
457}
458
459#[derive(Debug, Deserialize)]
460#[serde(rename_all = "camelCase")]
461pub struct CameraChannel {
462 pub id: u32,
463 pub name: Option<String>,
464 pub enabled: Option<bool>,
467 pub width: Option<u32>,
468 pub height: Option<u32>,
469 pub fps: Option<u32>,
470 pub bitrate: Option<u64>,
471 pub is_rtsp_enabled: Option<bool>,
472 pub rtsp_alias: Option<String>,
473}
474
475#[derive(Debug, Deserialize)]
476#[serde(rename_all = "camelCase")]
477pub struct CameraStats {
478 pub wifi: Option<WifiStats>,
479 pub storage: Option<StorageStats>,
480}
481
482#[derive(Debug, Deserialize)]
483#[serde(rename_all = "camelCase")]
484pub struct WifiStats {
485 pub channel: Option<u32>,
486 pub frequency: Option<u32>,
487 pub signal_quality: Option<i32>,
488 pub signal_strength: Option<i32>,
489}
490
491#[derive(Debug, Deserialize)]
492#[serde(rename_all = "camelCase")]
493pub struct StorageStats {
494 pub used: Option<u64>,
495 pub rate: Option<f64>,
496}
497
498#[derive(Debug, Deserialize)]
499#[serde(rename_all = "camelCase")]
500pub struct WifiConnectionState {
501 pub channel: Option<u32>,
502 pub frequency: Option<u32>,
503 pub signal_quality: Option<i32>,
504 pub signal_strength: Option<i32>,
505 pub ssid: Option<String>,
506 pub ap_name: Option<String>,
507 pub connectivity: Option<String>,
508}
509
510#[derive(Debug, Deserialize)]
511#[serde(rename_all = "camelCase")]
512pub struct RecordingSettings {
513 pub mode: Option<String>,
514 pub enable_motion_detection: Option<bool>,
517}
518
519pub type RtspsStreams = std::collections::HashMap<String, Option<String>>;
521
522#[derive(Debug)]
524pub enum ApiError {
525 Http(reqwest::Error),
526 Api {
527 status: u16,
528 message: String,
529 },
530 NotFound(String),
531 Auth(String),
532 Conflict(String),
536 Unsupported {
540 endpoint: String,
541 reason: UnsupportedReason,
542 },
543 Other(String),
544}
545
546#[derive(Debug)]
553pub enum UnsupportedReason {
554 NotJson { content_type: String },
558 Removed,
563}
564
565fn text_indicates_cert_failure(s: &str) -> bool {
569 let s = s.to_lowercase();
570 s.contains("certificate") || s.contains("self-signed")
571}
572
573fn reqwest_is_cert_failure(e: &reqwest::Error) -> bool {
580 use std::error::Error;
581 let mut source: Option<&dyn std::error::Error> = e.source();
582 while let Some(err) = source {
583 if text_indicates_cert_failure(&err.to_string()) {
584 return true;
585 }
586 source = err.source();
587 }
588 false
589}
590
591impl ApiError {
592 pub fn is_tls_cert_error(&self) -> bool {
595 match self {
596 ApiError::Http(e) => reqwest_is_cert_failure(e),
597 other => text_indicates_cert_failure(&other.to_string()),
598 }
599 }
600}
601
602impl fmt::Display for ApiError {
603 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
604 match self {
605 ApiError::Http(e) => {
606 write!(f, "HTTP error: {e}")?;
607 if reqwest_is_cert_failure(e) {
610 write!(
611 f,
612 "\n Hint: TLS certificate verification failed. For a trusted controller \
613 with a self-signed cert, run 'unifi config init' to trust it \
614 interactively, or pass --accept-invalid-certs (or set \
615 UNIFI_ACCEPT_INVALID_CERTS=true or accept_invalid_certs = true in config)"
616 )?;
617 } else if e.is_connect() {
618 write!(
619 f,
620 "\n Hint: Check that the host is reachable and the URL is correct"
621 )?;
622 } else if e.is_timeout() {
623 write!(f, "\n Hint: Request timed out. Is the controller running?")?;
624 } else {
625 let msg = e.to_string().to_lowercase();
626 if msg.contains("dns") || msg.contains("resolve") {
627 write!(
628 f,
629 "\n Hint: Could not resolve hostname. Check the host value"
630 )?;
631 }
632 }
633 Ok(())
634 }
635 ApiError::Api { status, message } => write!(f, "API error ({status}): {message}"),
636 ApiError::NotFound(msg) => write!(f, "Not found: {msg}"),
637 ApiError::Auth(msg) => {
638 write!(f, "Authentication error: {msg}")?;
639 write!(
640 f,
641 "\n Hint: Check your API key. Generate one in UniFi Settings > API"
642 )
643 }
644 ApiError::Conflict(msg) => write!(f, "{msg}"),
645 ApiError::Unsupported { endpoint, reason } => {
646 match reason {
647 UnsupportedReason::NotJson { content_type } => write!(
648 f,
649 "This controller does not serve {endpoint}: it answered with \
650 {content_type} instead of JSON"
651 )?,
652 UnsupportedReason::Removed => write!(
653 f,
654 "This controller does not serve {endpoint}: it rejected the endpoint \
655 itself, so no parameter or identifier would change the result"
656 )?,
657 }
658 if endpoint.contains("/protect/") {
659 write!(
660 f,
661 "\n Hint: UniFi OS proxies the request to its web UI when the Protect \
662 application is not installed on the controller"
663 )?;
664 } else if endpoint.contains("/stat/event") {
665 write!(
666 f,
667 "\n Hint: UniFi Network 9 removed the REST event log, and this \
668 controller does not serve /rest/alarm either. The remaining event \
669 stream is the events WebSocket, which this CLI does not consume"
670 )?;
671 }
672 Ok(())
673 }
674 ApiError::Other(msg) => write!(f, "{msg}"),
675 }
676 }
677}
678
679impl std::error::Error for ApiError {
680 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
681 match self {
682 ApiError::Http(e) => Some(e),
683 _ => None,
684 }
685 }
686}
687
688impl From<reqwest::Error> for ApiError {
689 fn from(e: reqwest::Error) -> Self {
690 if e.status()
691 .is_some_and(|s| s.as_u16() == 401 || s.as_u16() == 403)
692 {
693 ApiError::Auth(e.to_string())
694 } else if e.status().is_some_and(|s| s.as_u16() == 404) {
695 ApiError::NotFound(e.to_string())
696 } else {
697 ApiError::Http(e)
698 }
699 }
700}