1use reqwest::header::{HeaderMap, HeaderValue};
2use serde::de::DeserializeOwned;
3
4use super::types::*;
5
6pub struct UnifiClient {
7 http: reqwest::Client,
8 base_url: String,
9 site_id: Option<String>,
10}
11
12impl UnifiClient {
13 pub fn new(host: &str, api_key: &str) -> Result<Self, ApiError> {
14 let mut headers = HeaderMap::new();
15 headers.insert(
16 "X-API-KEY",
17 HeaderValue::from_str(api_key).map_err(|e| ApiError::Other(e.to_string()))?,
18 );
19
20 let http = reqwest::Client::builder()
21 .danger_accept_invalid_certs(true)
22 .default_headers(headers)
23 .timeout(std::time::Duration::from_secs(30))
24 .build()
25 .map_err(ApiError::Http)?;
26
27 let base_url = if host.starts_with("http") {
28 host.trim_end_matches('/').to_string()
29 } else {
30 format!("https://{host}")
31 };
32
33 Ok(Self {
34 http,
35 base_url,
36 site_id: None,
37 })
38 }
39
40 async fn ensure_site_id(&mut self) -> Result<&str, ApiError> {
42 if self.site_id.is_none() {
43 let resp: PaginatedResponse<Site> = self
44 .get_integration("/proxy/network/integration/v1/sites")
45 .await?;
46 let site = resp
47 .data
48 .into_iter()
49 .next()
50 .ok_or_else(|| ApiError::Other("No sites found on controller".into()))?;
51 self.site_id = Some(site.id);
52 }
53 Ok(self.site_id.as_deref().unwrap())
54 }
55
56 async fn get_integration<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
57 let url = format!("{}{path}", self.base_url);
58 let resp = self.http.get(&url).send().await?;
59 let status = resp.status().as_u16();
60 if !resp.status().is_success() {
61 let body = resp.text().await.unwrap_or_default();
62 return Err(ApiError::Api {
63 status,
64 message: body,
65 });
66 }
67 Ok(resp.json().await?)
68 }
69
70 async fn get_legacy<T: DeserializeOwned>(&self, path: &str) -> Result<Vec<T>, ApiError> {
71 let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
72 let resp = self.http.get(&url).send().await?;
73 let status = resp.status().as_u16();
74 if !resp.status().is_success() {
75 let body = resp.text().await.unwrap_or_default();
76 return Err(ApiError::Api {
77 status,
78 message: body,
79 });
80 }
81 let legacy: LegacyResponse<T> = resp.json().await?;
82 if legacy.meta.rc != "ok" {
83 return Err(ApiError::Api {
84 status: 200,
85 message: legacy.meta.msg.unwrap_or_else(|| "unknown error".into()),
86 });
87 }
88 Ok(legacy.data)
89 }
90
91 async fn post_legacy_cmd(
92 &self,
93 manager: &str,
94 body: serde_json::Value,
95 ) -> Result<serde_json::Value, ApiError> {
96 let url = format!(
97 "{}/proxy/network/api/s/default/cmd/{manager}",
98 self.base_url
99 );
100 let resp = self.http.post(&url).json(&body).send().await?;
101 let status = resp.status().as_u16();
102 if !resp.status().is_success() {
103 let body = resp.text().await.unwrap_or_default();
104 return Err(ApiError::Api {
105 status,
106 message: body,
107 });
108 }
109 Ok(resp.json().await?)
110 }
111
112 async fn put_legacy<T: serde::Serialize>(
113 &self,
114 path: &str,
115 body: &T,
116 ) -> Result<serde_json::Value, ApiError> {
117 let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
118 let resp = self.http.put(&url).json(body).send().await?;
119 let status = resp.status().as_u16();
120 if !resp.status().is_success() {
121 let body = resp.text().await.unwrap_or_default();
122 return Err(ApiError::Api {
123 status,
124 message: body,
125 });
126 }
127 Ok(resp.json().await?)
128 }
129
130 async fn post_legacy<T: serde::Serialize>(
131 &self,
132 path: &str,
133 body: &T,
134 ) -> Result<serde_json::Value, ApiError> {
135 let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
136 let resp = self.http.post(&url).json(body).send().await?;
137 let status = resp.status().as_u16();
138 if !resp.status().is_success() {
139 let body = resp.text().await.unwrap_or_default();
140 return Err(ApiError::Api {
141 status,
142 message: body,
143 });
144 }
145 Ok(resp.json().await?)
146 }
147
148 async fn paginate_all<T: DeserializeOwned>(&self, base_path: &str) -> Result<Vec<T>, ApiError> {
150 let mut all = Vec::new();
151 let mut offset = 0;
152 let limit = 200;
153
154 loop {
155 let separator = if base_path.contains('?') { '&' } else { '?' };
156 let path = format!("{base_path}{separator}offset={offset}&limit={limit}");
157 let resp: PaginatedResponse<T> = self.get_integration(&path).await?;
158 let count = resp.data.len();
159 all.extend(resp.data);
160
161 if all.len() >= resp.total_count || count < limit {
162 break;
163 }
164 offset += count;
165 }
166
167 Ok(all)
168 }
169
170 pub async fn list_clients(&mut self) -> Result<Vec<Client>, ApiError> {
174 let site_id = self.ensure_site_id().await?.to_string();
175 self.paginate_all(&format!(
176 "/proxy/network/integration/v1/sites/{site_id}/clients"
177 ))
178 .await
179 }
180
181 pub async fn get_client_detail(&self, mac: &str) -> Result<LegacyClient, ApiError> {
182 let normalized = normalize_mac(mac);
183 let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
184 clients
185 .into_iter()
186 .find(|c| {
187 c.mac
188 .as_deref()
189 .is_some_and(|m| normalize_mac(m) == normalized)
190 })
191 .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))
192 }
193
194 pub async fn set_fixed_ip(
195 &self,
196 mac: &str,
197 ip: &str,
198 name: Option<&str>,
199 ) -> Result<(), ApiError> {
200 let normalized = normalize_mac(mac);
201
202 let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
204 let client = clients
205 .into_iter()
206 .find(|c| {
207 c.mac
208 .as_deref()
209 .is_some_and(|m| normalize_mac(m) == normalized)
210 })
211 .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))?;
212
213 let mut payload = serde_json::json!({
214 "mac": format_mac(&normalized),
215 "use_fixedip": true,
216 "fixed_ip": ip,
217 });
218
219 if let Some(n) = name {
220 payload["name"] = serde_json::Value::String(n.to_string());
221 payload["noted"] = serde_json::Value::Bool(true);
222 }
223
224 let path = format!("/rest/user/{}", client.id);
225 match self.put_legacy(&path, &payload).await {
226 Ok(_) => Ok(()),
227 Err(ApiError::Api { status: 404, .. }) => {
228 self.post_legacy("/rest/user", &payload).await?;
230 Ok(())
231 }
232 Err(e) => Err(e),
233 }
234 }
235
236 pub async fn block_client(&self, mac: &str) -> Result<(), ApiError> {
237 let formatted = format_mac(&normalize_mac(mac));
238 self.post_legacy_cmd(
239 "stamgr",
240 serde_json::json!({"cmd": "block-sta", "mac": formatted}),
241 )
242 .await?;
243 Ok(())
244 }
245
246 pub async fn unblock_client(&self, mac: &str) -> Result<(), ApiError> {
247 let formatted = format_mac(&normalize_mac(mac));
248 self.post_legacy_cmd(
249 "stamgr",
250 serde_json::json!({"cmd": "unblock-sta", "mac": formatted}),
251 )
252 .await?;
253 Ok(())
254 }
255
256 pub async fn kick_client(&self, mac: &str) -> Result<(), ApiError> {
257 let formatted = format_mac(&normalize_mac(mac));
258 self.post_legacy_cmd(
259 "stamgr",
260 serde_json::json!({"cmd": "kick-sta", "mac": formatted}),
261 )
262 .await?;
263 Ok(())
264 }
265
266 pub async fn list_devices(&mut self) -> Result<Vec<Device>, ApiError> {
268 let site_id = self.ensure_site_id().await?.to_string();
269 self.paginate_all(&format!(
270 "/proxy/network/integration/v1/sites/{site_id}/devices"
271 ))
272 .await
273 }
274
275 pub async fn restart_device(&self, mac: &str) -> Result<(), ApiError> {
276 let formatted = format_mac(&normalize_mac(mac));
277 self.post_legacy_cmd(
278 "devmgr",
279 serde_json::json!({"cmd": "restart", "mac": formatted}),
280 )
281 .await?;
282 Ok(())
283 }
284
285 pub async fn locate_device(&self, mac: &str, enable: bool) -> Result<(), ApiError> {
286 let formatted = format_mac(&normalize_mac(mac));
287 let cmd = if enable { "set-locate" } else { "unset-locate" };
288 self.post_legacy_cmd("devmgr", serde_json::json!({"cmd": cmd, "mac": formatted}))
289 .await?;
290 Ok(())
291 }
292
293 pub async fn list_networks(&mut self) -> Result<Vec<Network>, ApiError> {
295 let site_id = self.ensure_site_id().await?.to_string();
296 self.paginate_all(&format!(
297 "/proxy/network/integration/v1/sites/{site_id}/networks"
298 ))
299 .await
300 }
301
302 pub async fn get_health(&self) -> Result<Vec<HealthSubsystem>, ApiError> {
304 self.get_legacy("/stat/health").await
305 }
306
307 pub async fn get_sysinfo(&self) -> Result<SysInfo, ApiError> {
308 let mut data: Vec<SysInfo> = self.get_legacy("/stat/sysinfo").await?;
309 data.pop()
310 .ok_or_else(|| ApiError::Other("No sysinfo returned".into()))
311 }
312}