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