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> {
371 let events: Vec<Event> = self
372 .get_legacy(&format!("/stat/event?_limit={limit}"))
373 .await?;
374 Ok(events)
375 }
376
377 pub async fn get_device_ports(&self, mac: &str) -> Result<DeviceWithPorts, ApiError> {
379 let normalized = normalize_mac(mac);
380 let devices: Vec<DeviceWithPorts> = self.get_legacy("/stat/device").await?;
381 devices
382 .into_iter()
383 .find(|d| {
384 d.mac
385 .as_deref()
386 .is_some_and(|m| normalize_mac(m) == normalized)
387 })
388 .ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
389 }
390
391 pub async fn list_clients_legacy(&self) -> Result<Vec<LegacyClient>, ApiError> {
393 self.get_legacy("/stat/sta").await
394 }
395
396 pub async fn get_legacy_devices(&self) -> Result<Vec<LegacyDevice>, ApiError> {
398 self.get_legacy("/stat/device").await
399 }
400
401 pub async fn list_protect_cameras(&self) -> Result<Vec<ProtectCamera>, ApiError> {
405 let resp: Vec<ProtectCamera> = self
406 .get_integration("/proxy/protect/integration/v1/cameras")
407 .await?;
408 Ok(resp)
409 }
410
411 pub async fn get_protect_camera(&self, id: &str) -> Result<ProtectCamera, ApiError> {
413 self.get_integration(&format!("/proxy/protect/integration/v1/cameras/{id}"))
414 .await
415 }
416
417 pub async fn get_rtsps_streams(&self, camera_id: &str) -> Result<RtspsStreams, ApiError> {
419 self.get_integration(&format!(
420 "/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream"
421 ))
422 .await
423 }
424
425 pub async fn create_rtsps_streams(
427 &self,
428 camera_id: &str,
429 qualities: &[String],
430 ) -> Result<RtspsStreams, ApiError> {
431 validate_qualities(qualities)?;
432 let url = format!(
433 "{}/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream",
434 self.base_url
435 );
436 let body = serde_json::json!({ "qualities": qualities });
437 let resp = self.http.post(&url).json(&body).send().await?;
438 let status = resp.status().as_u16();
439 if !resp.status().is_success() {
440 let body = resp.text().await.unwrap_or_default();
441 return Err(error_for_status(status, body));
442 }
443 Ok(resp.json().await?)
444 }
445
446 pub async fn delete_rtsps_streams(
448 &self,
449 camera_id: &str,
450 qualities: &[String],
451 ) -> Result<(), ApiError> {
452 validate_qualities(qualities)?;
453 let query: String = qualities
454 .iter()
455 .map(|q| format!("qualities={q}"))
456 .collect::<Vec<_>>()
457 .join("&");
458 let url = format!(
459 "{}/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream?{query}",
460 self.base_url
461 );
462 let resp = self.http.delete(&url).send().await?;
463 let status = resp.status().as_u16();
464 if !resp.status().is_success() {
465 let body = resp.text().await.unwrap_or_default();
466 return Err(error_for_status(status, body));
467 }
468 Ok(())
469 }
470
471 pub async fn resolve_camera_id(&self, id_or_name: &str) -> Result<String, ApiError> {
475 if id_or_name.len() == 24 && id_or_name.chars().all(|c| c.is_ascii_hexdigit()) {
477 return Ok(id_or_name.to_string());
478 }
479 let cameras = self.list_protect_cameras().await?;
481 let needle = id_or_name.to_lowercase();
482 cameras
483 .into_iter()
484 .find(|c| {
485 c.name
486 .as_deref()
487 .is_some_and(|n| n.trim().to_lowercase() == needle)
488 })
489 .map(|c| c.id)
490 .ok_or_else(|| ApiError::NotFound(format!("Camera '{id_or_name}'")))
491 }
492
493 pub async fn get_health(&self) -> Result<Vec<HealthSubsystem>, ApiError> {
495 self.get_legacy("/stat/health").await
496 }
497
498 pub async fn get_sysinfo(&self) -> Result<SysInfo, ApiError> {
499 let mut data: Vec<SysInfo> = self.get_legacy("/stat/sysinfo").await?;
500 data.pop()
501 .ok_or_else(|| ApiError::Other("No sysinfo returned".into()))
502 }
503
504 pub async fn get_host_system(&self) -> Result<HostSystem, ApiError> {
505 let url = format!("{}/api/system", self.base_url);
506 let resp = self.http.get(&url).send().await?;
507 let status = resp.status().as_u16();
508 if !resp.status().is_success() {
509 let body = resp.text().await.unwrap_or_default();
510 return Err(error_for_status(status, body));
511 }
512 Ok(resp.json().await?)
513 }
514}
515
516pub struct ProtectSession {
522 http: reqwest::Client,
523 base_url: String,
524 token: String,
525 csrf_token: Option<String>,
526}
527
528impl ProtectSession {
529 pub async fn login(host: &str, username: &str, password: &str) -> Result<Self, ApiError> {
531 Self::login_with_options(host, username, password, ClientOptions::default()).await
532 }
533
534 pub async fn login_with_options(
535 host: &str,
536 username: &str,
537 password: &str,
538 options: ClientOptions,
539 ) -> Result<Self, ApiError> {
540 let base_url = normalize_base_url(host)?;
541
542 let http = reqwest::Client::builder()
545 .danger_accept_invalid_certs(options.accept_invalid_certs)
546 .timeout(std::time::Duration::from_secs(30))
547 .build()
548 .map_err(ApiError::Http)?;
549
550 let url = format!("{base_url}/api/auth/login");
551 let body = serde_json::json!({
552 "username": username,
553 "password": password,
554 });
555
556 let resp = http.post(&url).json(&body).send().await?;
557 let status = resp.status().as_u16();
558
559 if !resp.status().is_success() {
560 let body = resp.text().await.unwrap_or_default();
561 return Err(error_for_status(status, body));
562 }
563
564 let token = resp
566 .headers()
567 .get_all("set-cookie")
568 .iter()
569 .find_map(|v| {
570 let s = v.to_str().ok()?;
571 if s.starts_with("TOKEN=") {
572 s.split(';')
573 .next()?
574 .strip_prefix("TOKEN=")
575 .map(String::from)
576 } else {
577 None
578 }
579 })
580 .ok_or_else(|| ApiError::Auth("Login succeeded but no TOKEN cookie returned".into()))?;
581
582 let csrf_token = resp
584 .headers()
585 .get("x-csrf-token")
586 .and_then(|v| v.to_str().ok())
587 .map(String::from);
588
589 let _ = resp.text().await;
591
592 Ok(Self {
593 http,
594 base_url,
595 token,
596 csrf_token,
597 })
598 }
599
600 pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
602 let url = format!("{}/proxy/protect/api{path}", self.base_url);
603 let mut req = self
604 .http
605 .get(&url)
606 .header("cookie", format!("TOKEN={}", self.token));
607 if let Some(ref token) = self.csrf_token {
608 req = req.header("x-csrf-token", token);
609 }
610 let resp = req.send().await?;
611 let status = resp.status().as_u16();
612 if !resp.status().is_success() {
613 let body = resp.text().await.unwrap_or_default();
614 return Err(error_for_status(status, body));
615 }
616 let bytes = resp.bytes().await.map_err(ApiError::Http)?;
617 serde_json::from_slice(&bytes)
618 .map_err(|e| ApiError::Other(format!("JSON parse error: {e}")))
619 }
620
621 pub async fn list_cameras_full(&self) -> Result<Vec<ProtectCameraFull>, ApiError> {
623 self.get("/cameras").await
624 }
625
626 pub async fn get_camera_full(&self, id: &str) -> Result<ProtectCameraFull, ApiError> {
628 self.get(&format!("/cameras/{id}")).await
629 }
630}