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 pub fn clean_name(&self) -> String {
58 let name = self.display_name();
59 strip_mac_suffix(name, self.mac_address.as_deref())
60 }
61}
62
63#[derive(Debug, Deserialize)]
65pub struct LegacyClient {
66 #[serde(rename = "_id")]
67 pub id: String,
68 pub mac: Option<String>,
69 pub ip: Option<String>,
70 pub hostname: Option<String>,
71 pub name: Option<String>,
72 #[serde(default)]
73 pub is_wired: bool,
74 #[serde(default)]
75 pub blocked: bool,
76 #[serde(default)]
77 pub fixed_ap_enabled: bool,
78 pub fixed_ap_mac: Option<String>,
79 pub uptime: Option<u64>,
80 pub tx_bytes: Option<u64>,
81 pub rx_bytes: Option<u64>,
82 pub signal: Option<i32>,
83 pub ap_mac: Option<String>,
84 #[serde(rename = "essid")]
85 pub ssid: Option<String>,
86}
87
88impl LegacyClient {
89 pub fn display_name(&self) -> &str {
90 self.name
91 .as_deref()
92 .or(self.hostname.as_deref())
93 .unwrap_or("-")
94 }
95
96 pub fn clean_name(&self) -> String {
97 let name = self.display_name();
98 strip_mac_suffix(name, self.mac.as_deref())
99 }
100}
101
102#[derive(Debug, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct Device {
106 pub mac_address: Option<String>,
107 pub ip_address: Option<String>,
108 pub name: Option<String>,
109 pub model: Option<String>,
110 pub state: Option<String>,
111 pub firmware_version: Option<String>,
112}
113
114#[derive(Debug, Deserialize)]
116pub struct LegacyDevice {
117 pub mac: Option<String>,
118 pub ip: Option<String>,
119 pub name: Option<String>,
120 pub model: Option<String>,
121 #[serde(rename = "type")]
122 pub device_type: Option<String>,
123 pub state: Option<u32>,
124 pub version: Option<String>,
125 pub uptime: Option<u64>,
126 pub num_sta: Option<u32>,
127 #[serde(default)]
128 pub upgradable: bool,
129 pub upgrade_to_firmware: Option<String>,
130}
131
132impl LegacyDevice {
133 pub fn state_str(&self) -> &str {
134 match self.state {
135 Some(1) => "ONLINE",
136 Some(0) => "OFFLINE",
137 Some(2) => "ADOPTING",
138 Some(4) => "UPGRADING",
139 Some(5) => "PROVISIONING",
140 _ => "UNKNOWN",
141 }
142 }
143}
144
145#[derive(Debug, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub struct Network {
149 pub name: Option<String>,
150 #[serde(default)]
151 pub enabled: bool,
152 pub vlan_id: Option<u16>,
153 #[serde(default)]
154 pub default: bool,
155}
156
157#[derive(Debug, Deserialize)]
159pub struct HealthSubsystem {
160 pub subsystem: String,
161 pub status: Option<String>,
162 pub num_sta: Option<u32>,
163 pub num_ap: Option<u32>,
164 #[serde(rename = "num_sw")]
165 pub num_switches: Option<u32>,
166 pub wan_ip: Option<String>,
167 pub isp_name: Option<String>,
168}
169
170#[derive(Debug, Deserialize)]
172pub struct SysInfo {
173 pub hostname: Option<String>,
174 pub version: Option<String>,
175 pub timezone: Option<String>,
176 pub uptime: Option<u64>,
177}
178
179#[derive(Debug, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct HostSystem {
183 pub device_state: Option<String>,
184 pub name: Option<String>,
185}
186
187impl HostSystem {
188 pub fn update_available(&self) -> bool {
189 self.device_state.as_deref() == Some("updateAvailable")
190 }
191}
192
193pub fn strip_mac_suffix(name: &str, mac: Option<&str>) -> String {
196 if let Some(mac) = mac {
197 let clean_mac = normalize_mac(mac);
198 if clean_mac.len() >= 4 {
200 let last4 = &clean_mac[clean_mac.len() - 4..];
201 let suffix = format!(" {}:{}", &last4[..2], &last4[2..]);
202 if let Some(stripped) = name.strip_suffix(&suffix) {
203 return stripped.to_string();
204 }
205 let suffix_no_colon = format!(" {last4}");
207 if let Some(stripped) = name.strip_suffix(&suffix_no_colon) {
208 return stripped.to_string();
209 }
210 }
211 }
212 name.to_string()
213}
214
215pub fn normalize_mac(mac: &str) -> String {
216 mac.to_lowercase().replace([':', '-'], "")
217}
218
219pub fn format_mac(mac: &str) -> String {
220 let clean = normalize_mac(mac);
221 if clean.len() != 12 {
222 return mac.to_string();
223 }
224 format!(
225 "{}:{}:{}:{}:{}:{}",
226 &clean[0..2],
227 &clean[2..4],
228 &clean[4..6],
229 &clean[6..8],
230 &clean[8..10],
231 &clean[10..12]
232 )
233}
234
235pub fn format_bytes(bytes: u64) -> String {
236 const KB: u64 = 1024;
237 const MB: u64 = KB * 1024;
238 const GB: u64 = MB * 1024;
239
240 if bytes >= GB {
241 format!("{:.1} GB", bytes as f64 / GB as f64)
242 } else if bytes >= MB {
243 format!("{:.1} MB", bytes as f64 / MB as f64)
244 } else if bytes >= KB {
245 format!("{:.1} KB", bytes as f64 / KB as f64)
246 } else {
247 format!("{bytes} B")
248 }
249}
250
251pub fn format_uptime(seconds: u64) -> String {
252 let days = seconds / 86400;
253 let hours = (seconds % 86400) / 3600;
254 let minutes = (seconds % 3600) / 60;
255
256 if days > 0 {
257 format!("{days}d {hours}h {minutes}m")
258 } else if hours > 0 {
259 format!("{hours}h {minutes}m")
260 } else {
261 format!("{minutes}m")
262 }
263}
264
265#[derive(Debug, Deserialize)]
267pub struct Event {
268 pub key: Option<String>,
269 pub msg: Option<String>,
270 pub subsystem: Option<String>,
271 pub time: Option<u64>,
272 pub datetime: Option<String>,
273}
274
275#[derive(Debug, Deserialize)]
277pub struct PortEntry {
278 pub port_idx: Option<u32>,
279 pub name: Option<String>,
280 pub media: Option<String>,
281 #[serde(default)]
282 pub up: bool,
283 pub speed: Option<u32>,
284 #[serde(default)]
285 pub full_duplex: bool,
286 #[serde(default)]
287 pub poe_enable: bool,
288 #[serde(default, deserialize_with = "deserialize_string_or_number_f64")]
291 pub poe_power: Option<f64>,
292 #[serde(default)]
293 pub port_poe: bool,
294 pub tx_bytes: Option<u64>,
295 pub rx_bytes: Option<u64>,
296}
297
298fn deserialize_string_or_number_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
299where
300 D: serde::Deserializer<'de>,
301{
302 use serde::de::Error;
303 #[derive(Deserialize)]
304 #[serde(untagged)]
305 enum StringOrNumber {
306 Number(f64),
307 String(String),
308 }
309 match Option::<StringOrNumber>::deserialize(deserializer)? {
310 None => Ok(None),
311 Some(StringOrNumber::Number(n)) => Ok(Some(n)),
312 Some(StringOrNumber::String(s)) => {
313 if s.is_empty() {
314 Ok(None)
315 } else {
316 s.parse::<f64>().map(Some).map_err(D::Error::custom)
317 }
318 }
319 }
320}
321
322#[derive(Debug, Deserialize)]
324pub struct DeviceWithPorts {
325 pub mac: Option<String>,
326 pub name: Option<String>,
327 pub model: Option<String>,
328 #[serde(default)]
329 pub port_table: Vec<PortEntry>,
330}
331
332#[derive(Debug, Deserialize)]
336#[serde(rename_all = "camelCase")]
337pub struct ProtectCamera {
338 pub id: String,
339 pub name: Option<String>,
340 pub mac: Option<String>,
341 pub state: Option<String>,
342 pub model_key: Option<String>,
343 #[serde(default)]
344 pub is_mic_enabled: bool,
345 pub video_mode: Option<String>,
346 pub feature_flags: Option<ProtectFeatureFlags>,
347}
348
349#[derive(Debug, Deserialize)]
350#[serde(rename_all = "camelCase")]
351pub struct ProtectFeatureFlags {
352 #[serde(default)]
353 pub has_hdr: bool,
354 #[serde(default)]
355 pub has_mic: bool,
356 #[serde(default)]
357 pub has_speaker: bool,
358 #[serde(default)]
359 pub has_led_status: bool,
360 #[serde(default)]
361 pub smart_detect_types: Vec<String>,
362 #[serde(default)]
363 pub video_modes: Vec<String>,
364}
365
366#[derive(Debug, Deserialize)]
368#[serde(rename_all = "camelCase")]
369pub struct ProtectCameraFull {
370 pub id: String,
371 pub name: Option<String>,
372 pub mac: Option<String>,
373 pub host: Option<String>,
374 pub state: Option<String>,
375 #[serde(rename = "type")]
376 pub camera_type: Option<String>,
377 pub market_name: Option<String>,
378 pub platform: Option<String>,
379 pub firmware_version: Option<String>,
380 pub hardware_revision: Option<String>,
381 pub uptime: Option<u64>,
382 pub up_since: Option<u64>,
383 pub last_seen: Option<u64>,
384 #[serde(default)]
385 pub is_recording: bool,
386 #[serde(default)]
387 pub is_motion_detected: bool,
388 #[serde(default)]
389 pub is_dark: bool,
390 pub video_codec: Option<String>,
391 pub current_resolution: Option<String>,
392 pub video_mode: Option<String>,
393 pub hdr_type: Option<String>,
394 pub phy_rate: Option<f64>,
395 #[serde(default)]
396 pub is_mic_enabled: bool,
397 #[serde(default)]
398 pub is_poor_network: bool,
399 pub last_motion: Option<u64>,
400 pub hq_bytes_per_day: Option<u64>,
401 pub lq_bytes_per_day: Option<u64>,
402 pub model_key: Option<String>,
403 #[serde(default)]
404 pub channels: Vec<CameraChannel>,
405 pub stats: Option<CameraStats>,
406 pub wifi_connection_state: Option<WifiConnectionState>,
407 pub feature_flags: Option<ProtectFeatureFlags>,
408 pub recording_settings: Option<RecordingSettings>,
409}
410
411#[derive(Debug, Deserialize)]
412#[serde(rename_all = "camelCase")]
413pub struct CameraChannel {
414 pub id: u32,
415 pub name: Option<String>,
416 #[serde(default)]
417 pub enabled: bool,
418 pub width: Option<u32>,
419 pub height: Option<u32>,
420 pub fps: Option<u32>,
421 pub bitrate: Option<u64>,
422 #[serde(default)]
423 pub is_rtsp_enabled: bool,
424 pub rtsp_alias: Option<String>,
425}
426
427#[derive(Debug, Deserialize)]
428#[serde(rename_all = "camelCase")]
429pub struct CameraStats {
430 pub wifi: Option<WifiStats>,
431 pub storage: Option<StorageStats>,
432}
433
434#[derive(Debug, Deserialize)]
435#[serde(rename_all = "camelCase")]
436pub struct WifiStats {
437 pub channel: Option<u32>,
438 pub frequency: Option<u32>,
439 pub signal_quality: Option<i32>,
440 pub signal_strength: Option<i32>,
441}
442
443#[derive(Debug, Deserialize)]
444#[serde(rename_all = "camelCase")]
445pub struct StorageStats {
446 pub used: Option<u64>,
447 pub rate: Option<f64>,
448}
449
450#[derive(Debug, Deserialize)]
451#[serde(rename_all = "camelCase")]
452pub struct WifiConnectionState {
453 pub channel: Option<u32>,
454 pub frequency: Option<u32>,
455 pub signal_quality: Option<i32>,
456 pub signal_strength: Option<i32>,
457 pub ssid: Option<String>,
458 pub ap_name: Option<String>,
459 pub connectivity: Option<String>,
460}
461
462#[derive(Debug, Deserialize)]
463#[serde(rename_all = "camelCase")]
464pub struct RecordingSettings {
465 pub mode: Option<String>,
466 #[serde(default)]
467 pub enable_motion_detection: bool,
468}
469
470pub type RtspsStreams = std::collections::HashMap<String, Option<String>>;
472
473#[derive(Debug)]
475pub enum ApiError {
476 Http(reqwest::Error),
477 Api { status: u16, message: String },
478 NotFound(String),
479 Auth(String),
480 Other(String),
481}
482
483fn text_indicates_cert_failure(s: &str) -> bool {
487 let s = s.to_lowercase();
488 s.contains("certificate") || s.contains("self-signed")
489}
490
491fn reqwest_is_cert_failure(e: &reqwest::Error) -> bool {
498 use std::error::Error;
499 let mut source: Option<&dyn std::error::Error> = e.source();
500 while let Some(err) = source {
501 if text_indicates_cert_failure(&err.to_string()) {
502 return true;
503 }
504 source = err.source();
505 }
506 false
507}
508
509impl ApiError {
510 pub fn is_tls_cert_error(&self) -> bool {
513 match self {
514 ApiError::Http(e) => reqwest_is_cert_failure(e),
515 other => text_indicates_cert_failure(&other.to_string()),
516 }
517 }
518}
519
520impl fmt::Display for ApiError {
521 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
522 match self {
523 ApiError::Http(e) => {
524 write!(f, "HTTP error: {e}")?;
525 if reqwest_is_cert_failure(e) {
528 write!(
529 f,
530 "\n Hint: TLS certificate verification failed. For a trusted controller \
531 with a self-signed cert, run 'unifi config init' to trust it \
532 interactively, or pass --accept-invalid-certs (or set \
533 UNIFI_ACCEPT_INVALID_CERTS=true or accept_invalid_certs = true in config)"
534 )?;
535 } else if e.is_connect() {
536 write!(
537 f,
538 "\n Hint: Check that the host is reachable and the URL is correct"
539 )?;
540 } else if e.is_timeout() {
541 write!(f, "\n Hint: Request timed out. Is the controller running?")?;
542 } else {
543 let msg = e.to_string().to_lowercase();
544 if msg.contains("dns") || msg.contains("resolve") {
545 write!(
546 f,
547 "\n Hint: Could not resolve hostname. Check the host value"
548 )?;
549 }
550 }
551 Ok(())
552 }
553 ApiError::Api { status, message } => write!(f, "API error ({status}): {message}"),
554 ApiError::NotFound(msg) => write!(f, "Not found: {msg}"),
555 ApiError::Auth(msg) => {
556 write!(f, "Authentication error: {msg}")?;
557 write!(
558 f,
559 "\n Hint: Check your API key. Generate one in UniFi Settings > API"
560 )
561 }
562 ApiError::Other(msg) => write!(f, "{msg}"),
563 }
564 }
565}
566
567impl std::error::Error for ApiError {
568 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
569 match self {
570 ApiError::Http(e) => Some(e),
571 _ => None,
572 }
573 }
574}
575
576impl From<reqwest::Error> for ApiError {
577 fn from(e: reqwest::Error) -> Self {
578 if e.status()
579 .is_some_and(|s| s.as_u16() == 401 || s.as_u16() == 403)
580 {
581 ApiError::Auth(e.to_string())
582 } else if e.status().is_some_and(|s| s.as_u16() == 404) {
583 ApiError::NotFound(e.to_string())
584 } else {
585 ApiError::Http(e)
586 }
587 }
588}