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