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
14async fn json_or_unsupported<T: DeserializeOwned>(
27 resp: reqwest::Response,
28 endpoint: &str,
29) -> Result<T, ApiError> {
30 let raw = resp
31 .headers()
32 .get(reqwest::header::CONTENT_TYPE)
33 .and_then(|v| v.to_str().ok())
34 .unwrap_or("")
35 .to_string();
36 let bytes = resp.bytes().await?;
37 match serde_json::from_slice(&bytes) {
38 Ok(value) => Ok(value),
39 Err(e) if raw.to_ascii_lowercase().contains("json") => Err(ApiError::Other(format!(
42 "Failed to decode the response from {endpoint}: {e}"
43 ))),
44 Err(_) => {
45 let content_type = match raw.split(';').next().map(str::trim) {
46 Some(t) if !t.is_empty() => t.to_string(),
47 _ => "no content type".to_string(),
48 };
49 Err(ApiError::Unsupported {
50 endpoint: endpoint.to_string(),
51 content_type,
52 })
53 }
54 }
55}
56
57const VALID_QUALITIES: &[&str] = &["high", "medium", "low", "package"];
59
60pub fn validate_qualities(qualities: &[String]) -> Result<(), ApiError> {
62 for q in qualities {
63 if !VALID_QUALITIES.contains(&q.as_str()) {
64 return Err(ApiError::Other(format!(
65 "Invalid quality '{q}'. Valid values: {}",
66 VALID_QUALITIES.join(", ")
67 )));
68 }
69 }
70 Ok(())
71}
72
73#[derive(Debug, Clone, Copy, Default)]
74pub struct ClientOptions {
75 pub accept_invalid_certs: bool,
76}
77
78fn normalize_base_url(host: &str) -> Result<String, ApiError> {
79 let candidate = if host.contains("://") {
83 host.trim_end_matches('/').to_string()
84 } else {
85 format!("https://{}", host.trim_end_matches('/'))
86 };
87
88 let url = reqwest::Url::parse(&candidate)
89 .map_err(|e| ApiError::Other(format!("Invalid controller host: {e}")))?;
90 if !matches!(url.scheme(), "http" | "https") {
91 return Err(ApiError::Other(
92 "Controller host must use http:// or https://".into(),
93 ));
94 }
95 Ok(candidate)
96}
97
98pub struct UnifiClient {
99 http: reqwest::Client,
100 base_url: String,
101 site_id: Option<String>,
102}
103
104impl UnifiClient {
105 pub fn new(host: &str, api_key: &str) -> Result<Self, ApiError> {
106 Self::new_with_options(host, api_key, ClientOptions::default())
107 }
108
109 pub fn new_with_options(
110 host: &str,
111 api_key: &str,
112 options: ClientOptions,
113 ) -> Result<Self, ApiError> {
114 let mut headers = HeaderMap::new();
115 headers.insert(
116 "X-API-KEY",
117 HeaderValue::from_str(api_key).map_err(|e| ApiError::Other(e.to_string()))?,
118 );
119
120 let http = reqwest::Client::builder()
121 .danger_accept_invalid_certs(options.accept_invalid_certs)
122 .default_headers(headers)
123 .timeout(std::time::Duration::from_secs(30))
124 .build()
125 .map_err(ApiError::Http)?;
126
127 let base_url = normalize_base_url(host)?;
128
129 Ok(Self {
130 http,
131 base_url,
132 site_id: None,
133 })
134 }
135
136 pub fn clone_http(&self) -> reqwest::Client {
137 self.http.clone()
138 }
139
140 pub fn base_url(&self) -> &str {
141 &self.base_url
142 }
143
144 async fn ensure_site_id(&mut self) -> Result<&str, ApiError> {
146 if self.site_id.is_none() {
147 let resp: PaginatedResponse<Site> = self
148 .get_integration("/proxy/network/integration/v1/sites")
149 .await?;
150 let site = resp.data.into_iter().next().ok_or_else(|| {
151 ApiError::Other("No sites found. Check that the API key has site access".into())
152 })?;
153 self.site_id = Some(site.id);
154 }
155 Ok(self.site_id.as_deref().unwrap())
156 }
157
158 async fn get_integration<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
159 let url = format!("{}{path}", self.base_url);
160 let resp = self.http.get(&url).send().await?;
161 let status = resp.status().as_u16();
162 if !resp.status().is_success() {
163 let body = resp.text().await.unwrap_or_default();
164 return Err(error_for_status(status, body));
165 }
166 json_or_unsupported(resp, path).await
167 }
168
169 async fn get_legacy<T: DeserializeOwned>(&self, path: &str) -> Result<Vec<T>, ApiError> {
170 let endpoint = format!("/proxy/network/api/s/default{path}");
171 let url = format!("{}{endpoint}", self.base_url);
172 let resp = self.http.get(&url).send().await?;
173 let status = resp.status().as_u16();
174 if !resp.status().is_success() {
175 let body = resp.text().await.unwrap_or_default();
176 return Err(error_for_status(status, body));
177 }
178 let legacy: LegacyResponse<T> = json_or_unsupported(resp, &endpoint).await?;
179 if legacy.meta.rc != "ok" {
180 return Err(ApiError::Api {
181 status: 200,
182 message: legacy.meta.msg.unwrap_or_else(|| "unknown error".into()),
183 });
184 }
185 Ok(legacy.data)
186 }
187
188 async fn post_legacy_cmd(
189 &self,
190 manager: &str,
191 body: serde_json::Value,
192 ) -> Result<serde_json::Value, ApiError> {
193 let endpoint = format!("/proxy/network/api/s/default/cmd/{manager}");
194 let url = format!("{}{endpoint}", self.base_url);
195 let resp = self.http.post(&url).json(&body).send().await?;
196 let status = resp.status().as_u16();
197 if !resp.status().is_success() {
198 let body = resp.text().await.unwrap_or_default();
199 return Err(error_for_status(status, body));
200 }
201 json_or_unsupported(resp, &endpoint).await
202 }
203
204 async fn put_legacy<T: serde::Serialize>(
205 &self,
206 path: &str,
207 body: &T,
208 ) -> Result<serde_json::Value, ApiError> {
209 let endpoint = format!("/proxy/network/api/s/default{path}");
210 let url = format!("{}{endpoint}", self.base_url);
211 let resp = self.http.put(&url).json(body).send().await?;
212 let status = resp.status().as_u16();
213 if !resp.status().is_success() {
214 let body = resp.text().await.unwrap_or_default();
215 return Err(error_for_status(status, body));
216 }
217 json_or_unsupported(resp, &endpoint).await
218 }
219
220 async fn post_legacy<T: serde::Serialize>(
221 &self,
222 path: &str,
223 body: &T,
224 ) -> Result<serde_json::Value, ApiError> {
225 let endpoint = format!("/proxy/network/api/s/default{path}");
226 let url = format!("{}{endpoint}", self.base_url);
227 let resp = self.http.post(&url).json(body).send().await?;
228 let status = resp.status().as_u16();
229 if !resp.status().is_success() {
230 let body = resp.text().await.unwrap_or_default();
231 return Err(error_for_status(status, body));
232 }
233 json_or_unsupported(resp, &endpoint).await
234 }
235
236 async fn paginate_all<T: DeserializeOwned>(&self, base_path: &str) -> Result<Vec<T>, ApiError> {
238 let mut all = Vec::new();
239 let mut offset = 0;
240 let limit = 200;
241
242 loop {
243 let separator = if base_path.contains('?') { '&' } else { '?' };
244 let path = format!("{base_path}{separator}offset={offset}&limit={limit}");
245 let resp: PaginatedResponse<T> = self.get_integration(&path).await?;
246 let count = resp.data.len();
247 all.extend(resp.data);
248
249 if all.len() >= resp.total_count || count < limit {
250 break;
251 }
252 offset += count;
253 }
254
255 Ok(all)
256 }
257
258 pub async fn list_clients(&mut self) -> Result<Vec<Client>, ApiError> {
262 let site_id = self.ensure_site_id().await?.to_string();
263 self.paginate_all(&format!(
264 "/proxy/network/integration/v1/sites/{site_id}/clients"
265 ))
266 .await
267 }
268
269 pub async fn get_client_detail(&self, mac: &str) -> Result<LegacyClient, ApiError> {
270 let normalized = normalize_mac(mac);
271 let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
272 clients
273 .into_iter()
274 .find(|c| {
275 c.mac
276 .as_deref()
277 .is_some_and(|m| normalize_mac(m) == normalized)
278 })
279 .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))
280 }
281
282 pub async fn set_fixed_ip(
283 &self,
284 mac: &str,
285 ip: &str,
286 name: Option<&str>,
287 ) -> Result<(), ApiError> {
288 let normalized = normalize_mac(mac);
289
290 let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
292 let client = clients
293 .into_iter()
294 .find(|c| {
295 c.mac
296 .as_deref()
297 .is_some_and(|m| normalize_mac(m) == normalized)
298 })
299 .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))?;
300
301 let mut payload = serde_json::json!({
302 "mac": format_mac(&normalized),
303 "use_fixedip": true,
304 "fixed_ip": ip,
305 });
306
307 if let Some(n) = name {
308 payload["name"] = serde_json::Value::String(n.to_string());
309 payload["noted"] = serde_json::Value::Bool(true);
310 }
311
312 let path = format!("/rest/user/{}", client.id);
313 match self.put_legacy(&path, &payload).await {
314 Ok(_) => Ok(()),
315 Err(ApiError::NotFound(_)) => {
316 self.post_legacy("/rest/user", &payload).await?;
318 Ok(())
319 }
320 Err(e) => Err(e),
321 }
322 }
323
324 pub async fn block_client(&self, mac: &str) -> Result<(), ApiError> {
325 let formatted = format_mac(&normalize_mac(mac));
326 self.post_legacy_cmd(
327 "stamgr",
328 serde_json::json!({"cmd": "block-sta", "mac": formatted}),
329 )
330 .await?;
331 Ok(())
332 }
333
334 pub async fn unblock_client(&self, mac: &str) -> Result<(), ApiError> {
335 let formatted = format_mac(&normalize_mac(mac));
336 self.post_legacy_cmd(
337 "stamgr",
338 serde_json::json!({"cmd": "unblock-sta", "mac": formatted}),
339 )
340 .await?;
341 Ok(())
342 }
343
344 pub async fn kick_client(&self, mac: &str) -> Result<(), ApiError> {
345 let formatted = format_mac(&normalize_mac(mac));
346 self.post_legacy_cmd(
347 "stamgr",
348 serde_json::json!({"cmd": "kick-sta", "mac": formatted}),
349 )
350 .await?;
351 Ok(())
352 }
353
354 pub async fn list_devices(&mut self) -> Result<Vec<Device>, ApiError> {
356 let site_id = self.ensure_site_id().await?.to_string();
357 self.paginate_all(&format!(
358 "/proxy/network/integration/v1/sites/{site_id}/devices"
359 ))
360 .await
361 }
362
363 pub async fn get_device_detail(&self, mac: &str) -> Result<LegacyDevice, ApiError> {
364 let normalized = normalize_mac(mac);
365 let devices: Vec<LegacyDevice> = self.get_legacy("/stat/device").await?;
366 devices
367 .into_iter()
368 .find(|d| {
369 d.mac
370 .as_deref()
371 .is_some_and(|m| normalize_mac(m) == normalized)
372 })
373 .ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
374 }
375
376 pub async fn restart_device(&self, mac: &str) -> Result<(), ApiError> {
377 let formatted = format_mac(&normalize_mac(mac));
378 self.post_legacy_cmd(
379 "devmgr",
380 serde_json::json!({"cmd": "restart", "mac": formatted}),
381 )
382 .await?;
383 Ok(())
384 }
385
386 pub async fn power_cycle_port(&self, mac: &str, port_idx: u32) -> Result<(), ApiError> {
389 let formatted = format_mac(&normalize_mac(mac));
390 self.post_legacy_cmd(
391 "devmgr",
392 serde_json::json!({"cmd": "power-cycle", "mac": formatted, "port_idx": port_idx}),
393 )
394 .await?;
395 Ok(())
396 }
397
398 pub async fn upgrade_device(&self, mac: &str) -> Result<(), ApiError> {
399 let formatted = format_mac(&normalize_mac(mac));
400 self.post_legacy_cmd(
401 "devmgr",
402 serde_json::json!({"cmd": "upgrade", "mac": formatted}),
403 )
404 .await?;
405 Ok(())
406 }
407
408 pub async fn locate_device(&self, mac: &str, enable: bool) -> Result<(), ApiError> {
409 let formatted = format_mac(&normalize_mac(mac));
410 let cmd = if enable { "set-locate" } else { "unset-locate" };
411 self.post_legacy_cmd("devmgr", serde_json::json!({"cmd": cmd, "mac": formatted}))
412 .await?;
413 Ok(())
414 }
415
416 pub async fn list_networks(&mut self) -> Result<Vec<Network>, ApiError> {
418 let site_id = self.ensure_site_id().await?.to_string();
419 self.paginate_all(&format!(
420 "/proxy/network/integration/v1/sites/{site_id}/networks"
421 ))
422 .await
423 }
424
425 pub async fn list_events(&self, limit: usize) -> Result<Vec<Event>, ApiError> {
434 match self
435 .get_legacy::<Event>(&format!("/stat/event?_limit={limit}"))
436 .await
437 {
438 Ok(events) => Ok(events),
439 Err(ApiError::NotFound(_)) => {
440 let mut alarms: Vec<Event> = self.get_legacy("/rest/alarm").await?;
441 alarms.sort_by_key(|e| std::cmp::Reverse(e.time));
445 alarms.truncate(limit);
446 Ok(alarms)
447 }
448 Err(e) => Err(e),
449 }
450 }
451
452 pub async fn get_device_ports(&self, mac: &str) -> Result<DeviceWithPorts, ApiError> {
454 let normalized = normalize_mac(mac);
455 let devices: Vec<DeviceWithPorts> = self.get_legacy("/stat/device").await?;
456 devices
457 .into_iter()
458 .find(|d| {
459 d.mac
460 .as_deref()
461 .is_some_and(|m| normalize_mac(m) == normalized)
462 })
463 .ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
464 }
465
466 pub async fn list_all_device_ports(&self) -> Result<Vec<DeviceWithPorts>, ApiError> {
470 self.get_legacy("/stat/device").await
471 }
472
473 pub async fn list_clients_legacy(&self) -> Result<Vec<LegacyClient>, ApiError> {
475 self.get_legacy("/stat/sta").await
476 }
477
478 pub async fn get_legacy_devices(&self) -> Result<Vec<LegacyDevice>, ApiError> {
480 self.get_legacy("/stat/device").await
481 }
482
483 pub async fn list_protect_cameras(&self) -> Result<Vec<ProtectCamera>, ApiError> {
487 let resp: Vec<ProtectCamera> = self
488 .get_integration("/proxy/protect/integration/v1/cameras")
489 .await?;
490 Ok(resp)
491 }
492
493 pub async fn get_protect_camera(&self, id: &str) -> Result<ProtectCamera, ApiError> {
495 self.get_integration(&format!("/proxy/protect/integration/v1/cameras/{id}"))
496 .await
497 }
498
499 pub async fn get_rtsps_streams(&self, camera_id: &str) -> Result<RtspsStreams, ApiError> {
501 self.get_integration(&format!(
502 "/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream"
503 ))
504 .await
505 }
506
507 pub async fn create_rtsps_streams(
509 &self,
510 camera_id: &str,
511 qualities: &[String],
512 ) -> Result<RtspsStreams, ApiError> {
513 validate_qualities(qualities)?;
514 let endpoint = format!("/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream");
515 let url = format!("{}{endpoint}", self.base_url);
516 let body = serde_json::json!({ "qualities": qualities });
517 let resp = self.http.post(&url).json(&body).send().await?;
518 let status = resp.status().as_u16();
519 if !resp.status().is_success() {
520 let body = resp.text().await.unwrap_or_default();
521 return Err(error_for_status(status, body));
522 }
523 json_or_unsupported(resp, &endpoint).await
524 }
525
526 pub async fn delete_rtsps_streams(
528 &self,
529 camera_id: &str,
530 qualities: &[String],
531 ) -> Result<(), ApiError> {
532 validate_qualities(qualities)?;
533 let query: String = qualities
534 .iter()
535 .map(|q| format!("qualities={q}"))
536 .collect::<Vec<_>>()
537 .join("&");
538 let url = format!(
539 "{}/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream?{query}",
540 self.base_url
541 );
542 let resp = self.http.delete(&url).send().await?;
543 let status = resp.status().as_u16();
544 if !resp.status().is_success() {
545 let body = resp.text().await.unwrap_or_default();
546 return Err(error_for_status(status, body));
547 }
548 Ok(())
549 }
550
551 pub async fn resolve_camera_id(&self, id_or_name: &str) -> Result<String, ApiError> {
555 if id_or_name.len() == 24 && id_or_name.chars().all(|c| c.is_ascii_hexdigit()) {
557 return Ok(id_or_name.to_string());
558 }
559 let cameras = self.list_protect_cameras().await?;
561 let needle = id_or_name.to_lowercase();
562 cameras
563 .into_iter()
564 .find(|c| {
565 c.name
566 .as_deref()
567 .is_some_and(|n| n.trim().to_lowercase() == needle)
568 })
569 .map(|c| c.id)
570 .ok_or_else(|| ApiError::NotFound(format!("Camera '{id_or_name}'")))
571 }
572
573 pub async fn get_health(&self) -> Result<Vec<HealthSubsystem>, ApiError> {
575 self.get_legacy("/stat/health").await
576 }
577
578 pub async fn get_sysinfo(&self) -> Result<SysInfo, ApiError> {
579 let mut data: Vec<SysInfo> = self.get_legacy("/stat/sysinfo").await?;
580 data.pop()
581 .ok_or_else(|| ApiError::Other("No sysinfo returned".into()))
582 }
583
584 pub async fn get_host_system(&self) -> Result<HostSystem, ApiError> {
585 let url = format!("{}/api/system", self.base_url);
586 let resp = self.http.get(&url).send().await?;
587 let status = resp.status().as_u16();
588 if !resp.status().is_success() {
589 let body = resp.text().await.unwrap_or_default();
590 return Err(error_for_status(status, body));
591 }
592 json_or_unsupported(resp, "/api/system").await
593 }
594}
595
596pub struct ProtectSession {
602 http: reqwest::Client,
603 base_url: String,
604 token: String,
605 csrf_token: Option<String>,
606}
607
608impl ProtectSession {
609 pub async fn login(host: &str, username: &str, password: &str) -> Result<Self, ApiError> {
611 Self::login_with_options(host, username, password, ClientOptions::default()).await
612 }
613
614 pub async fn login_with_options(
615 host: &str,
616 username: &str,
617 password: &str,
618 options: ClientOptions,
619 ) -> Result<Self, ApiError> {
620 let base_url = normalize_base_url(host)?;
621
622 let http = reqwest::Client::builder()
625 .danger_accept_invalid_certs(options.accept_invalid_certs)
626 .timeout(std::time::Duration::from_secs(30))
627 .build()
628 .map_err(ApiError::Http)?;
629
630 let url = format!("{base_url}/api/auth/login");
631 let body = serde_json::json!({
632 "username": username,
633 "password": password,
634 });
635
636 let resp = http.post(&url).json(&body).send().await?;
637 let status = resp.status().as_u16();
638
639 if !resp.status().is_success() {
640 let body = resp.text().await.unwrap_or_default();
641 return Err(error_for_status(status, body));
642 }
643
644 let token = resp
646 .headers()
647 .get_all("set-cookie")
648 .iter()
649 .find_map(|v| {
650 let s = v.to_str().ok()?;
651 if s.starts_with("TOKEN=") {
652 s.split(';')
653 .next()?
654 .strip_prefix("TOKEN=")
655 .map(String::from)
656 } else {
657 None
658 }
659 })
660 .ok_or_else(|| ApiError::Auth("Login succeeded but no TOKEN cookie returned".into()))?;
661
662 let csrf_token = resp
664 .headers()
665 .get("x-csrf-token")
666 .and_then(|v| v.to_str().ok())
667 .map(String::from);
668
669 let _ = resp.text().await;
671
672 Ok(Self {
673 http,
674 base_url,
675 token,
676 csrf_token,
677 })
678 }
679
680 pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
682 let endpoint = format!("/proxy/protect/api{path}");
683 let url = format!("{}{endpoint}", self.base_url);
684 let mut req = self
685 .http
686 .get(&url)
687 .header("cookie", format!("TOKEN={}", self.token));
688 if let Some(ref token) = self.csrf_token {
689 req = req.header("x-csrf-token", token);
690 }
691 let resp = req.send().await?;
692 let status = resp.status().as_u16();
693 if !resp.status().is_success() {
694 let body = resp.text().await.unwrap_or_default();
695 return Err(error_for_status(status, body));
696 }
697 json_or_unsupported(resp, &endpoint).await
698 }
699
700 pub async fn list_cameras_full(&self) -> Result<Vec<ProtectCameraFull>, ApiError> {
702 self.get("/cameras").await
703 }
704
705 pub async fn get_camera_full(&self, id: &str) -> Result<ProtectCameraFull, ApiError> {
707 self.get(&format!("/cameras/{id}")).await
708 }
709}