Skip to main content

unifi_cli/api/
types.rs

1use serde::Deserialize;
2use std::fmt;
3
4#[cfg(test)]
5#[path = "tests.rs"]
6mod tests;
7
8// Integration API response wrapper (paginated)
9#[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// Legacy API response wrapper
17#[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// Site
30#[derive(Debug, Deserialize)]
31pub struct Site {
32    pub id: String,
33}
34
35// Client from Integration API
36#[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// Client from Legacy stat/sta endpoint (richer data)
59#[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// Device from Integration API
88#[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// Network from Integration API
100#[derive(Debug, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct Network {
103    pub name: Option<String>,
104    #[serde(default)]
105    pub enabled: bool,
106    pub vlan_id: Option<u16>,
107    #[serde(default)]
108    pub default: bool,
109}
110
111// Health subsystem from Legacy stat/health
112#[derive(Debug, Deserialize)]
113pub struct HealthSubsystem {
114    pub subsystem: String,
115    pub status: Option<String>,
116    pub num_sta: Option<u32>,
117    pub num_ap: Option<u32>,
118    #[serde(rename = "num_sw")]
119    pub num_switches: Option<u32>,
120    pub wan_ip: Option<String>,
121    pub isp_name: Option<String>,
122}
123
124// Sysinfo from Legacy stat/sysinfo
125#[derive(Debug, Deserialize)]
126pub struct SysInfo {
127    pub hostname: Option<String>,
128    pub version: Option<String>,
129    pub timezone: Option<String>,
130    pub uptime: Option<u64>,
131}
132
133pub fn normalize_mac(mac: &str) -> String {
134    mac.to_lowercase().replace([':', '-'], "")
135}
136
137pub fn format_mac(mac: &str) -> String {
138    let clean = normalize_mac(mac);
139    if clean.len() != 12 {
140        return mac.to_string();
141    }
142    format!(
143        "{}:{}:{}:{}:{}:{}",
144        &clean[0..2],
145        &clean[2..4],
146        &clean[4..6],
147        &clean[6..8],
148        &clean[8..10],
149        &clean[10..12]
150    )
151}
152
153pub fn format_bytes(bytes: u64) -> String {
154    const KB: u64 = 1024;
155    const MB: u64 = KB * 1024;
156    const GB: u64 = MB * 1024;
157
158    if bytes >= GB {
159        format!("{:.1} GB", bytes as f64 / GB as f64)
160    } else if bytes >= MB {
161        format!("{:.1} MB", bytes as f64 / MB as f64)
162    } else if bytes >= KB {
163        format!("{:.1} KB", bytes as f64 / KB as f64)
164    } else {
165        format!("{bytes} B")
166    }
167}
168
169pub fn format_uptime(seconds: u64) -> String {
170    let days = seconds / 86400;
171    let hours = (seconds % 86400) / 3600;
172    let minutes = (seconds % 3600) / 60;
173
174    if days > 0 {
175        format!("{days}d {hours}h {minutes}m")
176    } else if hours > 0 {
177        format!("{hours}h {minutes}m")
178    } else {
179        format!("{minutes}m")
180    }
181}
182
183// Error types
184#[derive(Debug)]
185pub enum ApiError {
186    Http(reqwest::Error),
187    Api { status: u16, message: String },
188    NotFound(String),
189    Auth(String),
190    Other(String),
191}
192
193impl fmt::Display for ApiError {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        match self {
196            ApiError::Http(e) => write!(f, "HTTP error: {e}"),
197            ApiError::Api { status, message } => write!(f, "API error ({status}): {message}"),
198            ApiError::NotFound(msg) => write!(f, "Not found: {msg}"),
199            ApiError::Auth(msg) => write!(f, "Authentication error: {msg}"),
200            ApiError::Other(msg) => write!(f, "{msg}"),
201        }
202    }
203}
204
205impl std::error::Error for ApiError {}
206
207impl From<reqwest::Error> for ApiError {
208    fn from(e: reqwest::Error) -> Self {
209        if e.status()
210            .is_some_and(|s| s.as_u16() == 401 || s.as_u16() == 403)
211        {
212            ApiError::Auth(e.to_string())
213        } else if e.status().is_some_and(|s| s.as_u16() == 404) {
214            ApiError::NotFound(e.to_string())
215        } else {
216            ApiError::Http(e)
217        }
218    }
219}