1use reqwest::header::{HeaderMap, HeaderValue};
2use serde::de::DeserializeOwned;
3
4use super::types::*;
5
6pub fn error_for_status(status: u16, message: String) -> ApiError {
7 match status {
8 401 | 403 => ApiError::Auth(message),
9 404 => ApiError::NotFound(message),
10 _ => ApiError::Api { status, message },
11 }
12}
13
14const VALID_QUALITIES: &[&str] = &["high", "medium", "low", "package"];
16
17pub fn validate_qualities(qualities: &[String]) -> Result<(), ApiError> {
19 for q in qualities {
20 if !VALID_QUALITIES.contains(&q.as_str()) {
21 return Err(ApiError::Other(format!(
22 "Invalid quality '{q}'. Valid values: {}",
23 VALID_QUALITIES.join(", ")
24 )));
25 }
26 }
27 Ok(())
28}
29
30pub struct UnifiClient {
31 http: reqwest::Client,
32 base_url: String,
33 site_id: Option<String>,
34}
35
36impl UnifiClient {
37 pub fn new(host: &str, api_key: &str) -> Result<Self, ApiError> {
38 let mut headers = HeaderMap::new();
39 headers.insert(
40 "X-API-KEY",
41 HeaderValue::from_str(api_key).map_err(|e| ApiError::Other(e.to_string()))?,
42 );
43
44 let http = reqwest::Client::builder()
45 .danger_accept_invalid_certs(true)
46 .default_headers(headers)
47 .timeout(std::time::Duration::from_secs(30))
48 .build()
49 .map_err(ApiError::Http)?;
50
51 let base_url = if host.starts_with("http") {
52 host.trim_end_matches('/').to_string()
53 } else {
54 format!("https://{host}")
55 };
56
57 Ok(Self {
58 http,
59 base_url,
60 site_id: None,
61 })
62 }
63
64 pub fn clone_http(&self) -> reqwest::Client {
65 self.http.clone()
66 }
67
68 pub fn base_url(&self) -> &str {
69 &self.base_url
70 }
71
72 async fn ensure_site_id(&mut self) -> Result<&str, ApiError> {
74 if self.site_id.is_none() {
75 let resp: PaginatedResponse<Site> = self
76 .get_integration("/proxy/network/integration/v1/sites")
77 .await?;
78 let site = resp.data.into_iter().next().ok_or_else(|| {
79 ApiError::Other("No sites found — check that the API key has site access".into())
80 })?;
81 self.site_id = Some(site.id);
82 }
83 Ok(self.site_id.as_deref().unwrap())
84 }
85
86 async fn get_integration<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
87 let url = format!("{}{path}", self.base_url);
88 let resp = self.http.get(&url).send().await?;
89 let status = resp.status().as_u16();
90 if !resp.status().is_success() {
91 let body = resp.text().await.unwrap_or_default();
92 return Err(error_for_status(status, body));
93 }
94 Ok(resp.json().await?)
95 }
96
97 async fn get_legacy<T: DeserializeOwned>(&self, path: &str) -> Result<Vec<T>, ApiError> {
98 let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
99 let resp = self.http.get(&url).send().await?;
100 let status = resp.status().as_u16();
101 if !resp.status().is_success() {
102 let body = resp.text().await.unwrap_or_default();
103 return Err(error_for_status(status, body));
104 }
105 let legacy: LegacyResponse<T> = resp.json().await?;
106 if legacy.meta.rc != "ok" {
107 return Err(ApiError::Api {
108 status: 200,
109 message: legacy.meta.msg.unwrap_or_else(|| "unknown error".into()),
110 });
111 }
112 Ok(legacy.data)
113 }
114
115 async fn post_legacy_cmd(
116 &self,
117 manager: &str,
118 body: serde_json::Value,
119 ) -> Result<serde_json::Value, ApiError> {
120 let url = format!(
121 "{}/proxy/network/api/s/default/cmd/{manager}",
122 self.base_url
123 );
124 let resp = self.http.post(&url).json(&body).send().await?;
125 let status = resp.status().as_u16();
126 if !resp.status().is_success() {
127 let body = resp.text().await.unwrap_or_default();
128 return Err(error_for_status(status, body));
129 }
130 Ok(resp.json().await?)
131 }
132
133 async fn put_legacy<T: serde::Serialize>(
134 &self,
135 path: &str,
136 body: &T,
137 ) -> Result<serde_json::Value, ApiError> {
138 let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
139 let resp = self.http.put(&url).json(body).send().await?;
140 let status = resp.status().as_u16();
141 if !resp.status().is_success() {
142 let body = resp.text().await.unwrap_or_default();
143 return Err(error_for_status(status, body));
144 }
145 Ok(resp.json().await?)
146 }
147
148 async fn post_legacy<T: serde::Serialize>(
149 &self,
150 path: &str,
151 body: &T,
152 ) -> Result<serde_json::Value, ApiError> {
153 let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
154 let resp = self.http.post(&url).json(body).send().await?;
155 let status = resp.status().as_u16();
156 if !resp.status().is_success() {
157 let body = resp.text().await.unwrap_or_default();
158 return Err(error_for_status(status, body));
159 }
160 Ok(resp.json().await?)
161 }
162
163 async fn paginate_all<T: DeserializeOwned>(&self, base_path: &str) -> Result<Vec<T>, ApiError> {
165 let mut all = Vec::new();
166 let mut offset = 0;
167 let limit = 200;
168
169 loop {
170 let separator = if base_path.contains('?') { '&' } else { '?' };
171 let path = format!("{base_path}{separator}offset={offset}&limit={limit}");
172 let resp: PaginatedResponse<T> = self.get_integration(&path).await?;
173 let count = resp.data.len();
174 all.extend(resp.data);
175
176 if all.len() >= resp.total_count || count < limit {
177 break;
178 }
179 offset += count;
180 }
181
182 Ok(all)
183 }
184
185 pub async fn list_clients(&mut self) -> Result<Vec<Client>, ApiError> {
189 let site_id = self.ensure_site_id().await?.to_string();
190 self.paginate_all(&format!(
191 "/proxy/network/integration/v1/sites/{site_id}/clients"
192 ))
193 .await
194 }
195
196 pub async fn get_client_detail(&self, mac: &str) -> Result<LegacyClient, ApiError> {
197 let normalized = normalize_mac(mac);
198 let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
199 clients
200 .into_iter()
201 .find(|c| {
202 c.mac
203 .as_deref()
204 .is_some_and(|m| normalize_mac(m) == normalized)
205 })
206 .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))
207 }
208
209 pub async fn set_fixed_ip(
210 &self,
211 mac: &str,
212 ip: &str,
213 name: Option<&str>,
214 ) -> Result<(), ApiError> {
215 let normalized = normalize_mac(mac);
216
217 let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
219 let client = clients
220 .into_iter()
221 .find(|c| {
222 c.mac
223 .as_deref()
224 .is_some_and(|m| normalize_mac(m) == normalized)
225 })
226 .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))?;
227
228 let mut payload = serde_json::json!({
229 "mac": format_mac(&normalized),
230 "use_fixedip": true,
231 "fixed_ip": ip,
232 });
233
234 if let Some(n) = name {
235 payload["name"] = serde_json::Value::String(n.to_string());
236 payload["noted"] = serde_json::Value::Bool(true);
237 }
238
239 let path = format!("/rest/user/{}", client.id);
240 match self.put_legacy(&path, &payload).await {
241 Ok(_) => Ok(()),
242 Err(ApiError::NotFound(_)) => {
243 self.post_legacy("/rest/user", &payload).await?;
245 Ok(())
246 }
247 Err(e) => Err(e),
248 }
249 }
250
251 pub async fn block_client(&self, mac: &str) -> Result<(), ApiError> {
252 let formatted = format_mac(&normalize_mac(mac));
253 self.post_legacy_cmd(
254 "stamgr",
255 serde_json::json!({"cmd": "block-sta", "mac": formatted}),
256 )
257 .await?;
258 Ok(())
259 }
260
261 pub async fn unblock_client(&self, mac: &str) -> Result<(), ApiError> {
262 let formatted = format_mac(&normalize_mac(mac));
263 self.post_legacy_cmd(
264 "stamgr",
265 serde_json::json!({"cmd": "unblock-sta", "mac": formatted}),
266 )
267 .await?;
268 Ok(())
269 }
270
271 pub async fn kick_client(&self, mac: &str) -> Result<(), ApiError> {
272 let formatted = format_mac(&normalize_mac(mac));
273 self.post_legacy_cmd(
274 "stamgr",
275 serde_json::json!({"cmd": "kick-sta", "mac": formatted}),
276 )
277 .await?;
278 Ok(())
279 }
280
281 pub async fn list_devices(&mut self) -> Result<Vec<Device>, ApiError> {
283 let site_id = self.ensure_site_id().await?.to_string();
284 self.paginate_all(&format!(
285 "/proxy/network/integration/v1/sites/{site_id}/devices"
286 ))
287 .await
288 }
289
290 pub async fn get_device_detail(&self, mac: &str) -> Result<LegacyDevice, ApiError> {
291 let normalized = normalize_mac(mac);
292 let devices: Vec<LegacyDevice> = self.get_legacy("/stat/device").await?;
293 devices
294 .into_iter()
295 .find(|d| {
296 d.mac
297 .as_deref()
298 .is_some_and(|m| normalize_mac(m) == normalized)
299 })
300 .ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
301 }
302
303 pub async fn restart_device(&self, mac: &str) -> Result<(), ApiError> {
304 let formatted = format_mac(&normalize_mac(mac));
305 self.post_legacy_cmd(
306 "devmgr",
307 serde_json::json!({"cmd": "restart", "mac": formatted}),
308 )
309 .await?;
310 Ok(())
311 }
312
313 pub async fn upgrade_device(&self, mac: &str) -> Result<(), ApiError> {
314 let formatted = format_mac(&normalize_mac(mac));
315 self.post_legacy_cmd(
316 "devmgr",
317 serde_json::json!({"cmd": "upgrade", "mac": formatted}),
318 )
319 .await?;
320 Ok(())
321 }
322
323 pub async fn locate_device(&self, mac: &str, enable: bool) -> Result<(), ApiError> {
324 let formatted = format_mac(&normalize_mac(mac));
325 let cmd = if enable { "set-locate" } else { "unset-locate" };
326 self.post_legacy_cmd("devmgr", serde_json::json!({"cmd": cmd, "mac": formatted}))
327 .await?;
328 Ok(())
329 }
330
331 pub async fn list_networks(&mut self) -> Result<Vec<Network>, ApiError> {
333 let site_id = self.ensure_site_id().await?.to_string();
334 self.paginate_all(&format!(
335 "/proxy/network/integration/v1/sites/{site_id}/networks"
336 ))
337 .await
338 }
339
340 pub async fn list_events(&self, limit: usize) -> Result<Vec<Event>, ApiError> {
342 let events: Vec<Event> = self
343 .get_legacy(&format!("/stat/event?_limit={limit}"))
344 .await?;
345 Ok(events)
346 }
347
348 pub async fn get_device_ports(&self, mac: &str) -> Result<DeviceWithPorts, ApiError> {
350 let normalized = normalize_mac(mac);
351 let devices: Vec<DeviceWithPorts> = self.get_legacy("/stat/device").await?;
352 devices
353 .into_iter()
354 .find(|d| {
355 d.mac
356 .as_deref()
357 .is_some_and(|m| normalize_mac(m) == normalized)
358 })
359 .ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
360 }
361
362 pub async fn list_clients_legacy(&self) -> Result<Vec<LegacyClient>, ApiError> {
364 self.get_legacy("/stat/sta").await
365 }
366
367 pub async fn get_legacy_devices(&self) -> Result<Vec<LegacyDevice>, ApiError> {
369 self.get_legacy("/stat/device").await
370 }
371
372 pub async fn list_protect_cameras(&self) -> Result<Vec<ProtectCamera>, ApiError> {
376 let resp: Vec<ProtectCamera> = self
377 .get_integration("/proxy/protect/integration/v1/cameras")
378 .await?;
379 Ok(resp)
380 }
381
382 pub async fn get_protect_camera(&self, id: &str) -> Result<ProtectCamera, ApiError> {
384 self.get_integration(&format!("/proxy/protect/integration/v1/cameras/{id}"))
385 .await
386 }
387
388 pub async fn get_rtsps_streams(&self, camera_id: &str) -> Result<RtspsStreams, ApiError> {
390 self.get_integration(&format!(
391 "/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream"
392 ))
393 .await
394 }
395
396 pub async fn create_rtsps_streams(
398 &self,
399 camera_id: &str,
400 qualities: &[String],
401 ) -> Result<RtspsStreams, ApiError> {
402 validate_qualities(qualities)?;
403 let url = format!(
404 "{}/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream",
405 self.base_url
406 );
407 let body = serde_json::json!({ "qualities": qualities });
408 let resp = self.http.post(&url).json(&body).send().await?;
409 let status = resp.status().as_u16();
410 if !resp.status().is_success() {
411 let body = resp.text().await.unwrap_or_default();
412 return Err(error_for_status(status, body));
413 }
414 Ok(resp.json().await?)
415 }
416
417 pub async fn delete_rtsps_streams(
419 &self,
420 camera_id: &str,
421 qualities: &[String],
422 ) -> Result<(), ApiError> {
423 validate_qualities(qualities)?;
424 let query: String = qualities
425 .iter()
426 .map(|q| format!("qualities={q}"))
427 .collect::<Vec<_>>()
428 .join("&");
429 let url = format!(
430 "{}/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream?{query}",
431 self.base_url
432 );
433 let resp = self.http.delete(&url).send().await?;
434 let status = resp.status().as_u16();
435 if !resp.status().is_success() {
436 let body = resp.text().await.unwrap_or_default();
437 return Err(error_for_status(status, body));
438 }
439 Ok(())
440 }
441
442 pub async fn resolve_camera_id(&self, id_or_name: &str) -> Result<String, ApiError> {
446 if id_or_name.len() == 24 && id_or_name.chars().all(|c| c.is_ascii_hexdigit()) {
448 return Ok(id_or_name.to_string());
449 }
450 let cameras = self.list_protect_cameras().await?;
452 let needle = id_or_name.to_lowercase();
453 cameras
454 .into_iter()
455 .find(|c| {
456 c.name
457 .as_deref()
458 .is_some_and(|n| n.trim().to_lowercase() == needle)
459 })
460 .map(|c| c.id)
461 .ok_or_else(|| ApiError::NotFound(format!("Camera '{id_or_name}'")))
462 }
463
464 pub async fn get_health(&self) -> Result<Vec<HealthSubsystem>, ApiError> {
466 self.get_legacy("/stat/health").await
467 }
468
469 pub async fn get_sysinfo(&self) -> Result<SysInfo, ApiError> {
470 let mut data: Vec<SysInfo> = self.get_legacy("/stat/sysinfo").await?;
471 data.pop()
472 .ok_or_else(|| ApiError::Other("No sysinfo returned".into()))
473 }
474
475 pub async fn get_host_system(&self) -> Result<HostSystem, ApiError> {
476 let url = format!("{}/api/system", self.base_url);
477 let resp = self.http.get(&url).send().await?;
478 let status = resp.status().as_u16();
479 if !resp.status().is_success() {
480 let body = resp.text().await.unwrap_or_default();
481 return Err(error_for_status(status, body));
482 }
483 Ok(resp.json().await?)
484 }
485}
486
487pub struct ProtectSession {
493 http: reqwest::Client,
494 base_url: String,
495 token: String,
496 csrf_token: Option<String>,
497}
498
499impl ProtectSession {
500 pub async fn login(host: &str, username: &str, password: &str) -> Result<Self, ApiError> {
502 let base_url = if host.starts_with("http") {
503 host.trim_end_matches('/').to_string()
504 } else {
505 format!("https://{host}")
506 };
507
508 let http = reqwest::Client::builder()
511 .danger_accept_invalid_certs(true)
512 .timeout(std::time::Duration::from_secs(30))
513 .build()
514 .map_err(ApiError::Http)?;
515
516 let url = format!("{base_url}/api/auth/login");
517 let body = serde_json::json!({
518 "username": username,
519 "password": password,
520 });
521
522 let resp = http.post(&url).json(&body).send().await?;
523 let status = resp.status().as_u16();
524
525 if !resp.status().is_success() {
526 let body = resp.text().await.unwrap_or_default();
527 return Err(error_for_status(status, body));
528 }
529
530 let token = resp
532 .headers()
533 .get_all("set-cookie")
534 .iter()
535 .find_map(|v| {
536 let s = v.to_str().ok()?;
537 if s.starts_with("TOKEN=") {
538 s.split(';')
539 .next()?
540 .strip_prefix("TOKEN=")
541 .map(String::from)
542 } else {
543 None
544 }
545 })
546 .ok_or_else(|| ApiError::Auth("Login succeeded but no TOKEN cookie returned".into()))?;
547
548 let csrf_token = resp
550 .headers()
551 .get("x-csrf-token")
552 .and_then(|v| v.to_str().ok())
553 .map(String::from);
554
555 let _ = resp.text().await;
557
558 Ok(Self {
559 http,
560 base_url,
561 token,
562 csrf_token,
563 })
564 }
565
566 pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
568 let url = format!("{}/proxy/protect/api{path}", self.base_url);
569 let mut req = self
570 .http
571 .get(&url)
572 .header("cookie", format!("TOKEN={}", self.token));
573 if let Some(ref token) = self.csrf_token {
574 req = req.header("x-csrf-token", token);
575 }
576 let resp = req.send().await?;
577 let status = resp.status().as_u16();
578 if !resp.status().is_success() {
579 let body = resp.text().await.unwrap_or_default();
580 return Err(error_for_status(status, body));
581 }
582 let bytes = resp.bytes().await.map_err(ApiError::Http)?;
583 serde_json::from_slice(&bytes)
584 .map_err(|e| ApiError::Other(format!("JSON parse error: {e}")))
585 }
586
587 pub async fn list_cameras_full(&self) -> Result<Vec<ProtectCameraFull>, ApiError> {
589 self.get("/cameras").await
590 }
591
592 pub async fn get_camera_full(&self, id: &str) -> Result<ProtectCameraFull, ApiError> {
594 self.get(&format!("/cameras/{id}")).await
595 }
596}