Skip to main content

unifi_cli/api/
client.rs

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
14/// Decode a successful response as JSON, naming what answered when it is not.
15///
16/// UniFi OS answers a request for an application the controller does not have
17/// by proxying it to the web UI, which returns 200 with an HTML page. Decoding
18/// that as JSON produces "error decoding response body", which names neither
19/// the endpoint nor the reason, so the caller cannot tell a missing application
20/// from a transport fault worth retrying.
21///
22/// The body is decoded first and the content type only chooses the error for a
23/// body that did not decode: a controller or proxy that serves JSON under
24/// `text/plain` or under no content type at all is still answering the request,
25/// so it must not be reported as an application that is not there.
26async 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        // A body that claims to be JSON and is not is a fault in a controller
40        // that does serve this endpoint, which is a different thing entirely.
41        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
57/// Valid RTSPS quality levels accepted by the Protect API.
58const VALID_QUALITIES: &[&str] = &["high", "medium", "low", "package"];
59
60/// Validate quality values against the Protect API allowlist.
61pub 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    // A bare host (no scheme) defaults to https; an explicit scheme is kept and
80    // validated after parsing so only http/https are accepted. Parsing also
81    // rejects an empty host, since the url crate requires one for http/https.
82    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    // Auto-discover site UUID from Integration API
145    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    // Paginate through all results from Integration API
237    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    // --- Public API ---
259
260    // Clients
261    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        // Find client _id from legacy stat/sta
291        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                // Client doesn't have a user entry yet, create one
317                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    // Devices
355    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    /// Power-cycle a single PoE port. `mac` is the **switch's** MAC, not the
387    /// attached device's.
388    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    // Networks
417    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    // Events
426    //
427    // Legacy `stat/event` was removed in UniFi Network 9+ (UniFi OS) and now
428    // returns api.err.NotFound (404). On those controllers the surviving REST
429    // surface for notable events is `rest/alarm`, whose records share this
430    // `Event` shape, so fall back to it. (The full live event stream on newer
431    // controllers is only exposed over the events WebSocket, which this REST
432    // client does not consume.)
433    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                // `rest/alarm` is neither time-ordered nor limited server-side;
442                // present the most recent `limit` records to match the
443                // semantics `stat/event?_limit=` provided on older controllers.
444                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    // Port table for a specific device
453    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    /// Every device that reports a port table, in one request. `/stat/device`
467    /// already returns all devices with their port tables, so the unfiltered
468    /// listing costs no more than the filtered one.
469    pub async fn list_all_device_ports(&self) -> Result<Vec<DeviceWithPorts>, ApiError> {
470        self.get_legacy("/stat/device").await
471    }
472
473    // All clients with bandwidth data (legacy endpoint for richer stats)
474    pub async fn list_clients_legacy(&self) -> Result<Vec<LegacyClient>, ApiError> {
475        self.get_legacy("/stat/sta").await
476    }
477
478    // All devices with full detail (legacy endpoint)
479    pub async fn get_legacy_devices(&self) -> Result<Vec<LegacyDevice>, ApiError> {
480        self.get_legacy("/stat/device").await
481    }
482
483    // --- Protect API ---
484
485    /// List all cameras from the Protect Integration API.
486    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    /// Get a single camera by ID from the Protect Integration API.
494    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    /// Get existing RTSPS stream URLs for a camera.
500    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    /// Create new RTSPS streams for a camera at the specified quality levels.
508    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    /// Delete RTSPS streams for a camera at the specified quality levels.
527    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    /// Resolve a camera identifier (ID or name) to a camera ID.
552    /// If the input is a 24-char hex string, treats it as an ID.
553    /// Otherwise, searches by name (case-insensitive).
554    pub async fn resolve_camera_id(&self, id_or_name: &str) -> Result<String, ApiError> {
555        // If it looks like a Protect camera ID (24 hex chars), use it directly
556        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        // Otherwise, search by name
560        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    // System
574    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
596/// Session-based client for the direct Protect API.
597///
598/// Uses username/password login to get a session cookie, then hits
599/// `/proxy/protect/api/` endpoints which return full camera objects
600/// (IP, firmware, channels, stats, WiFi, ISP settings, etc).
601pub struct ProtectSession {
602    http: reqwest::Client,
603    base_url: String,
604    token: String,
605    csrf_token: Option<String>,
606}
607
608impl ProtectSession {
609    /// Login to UniFi OS and return a session with cookie auth.
610    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        // Don't use cookie_provider: the `partitioned` cookie attribute
623        // isn't handled by reqwest's jar. We extract the token manually.
624        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        // Extract TOKEN from Set-Cookie header
645        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        // Extract CSRF token from response headers
663        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        // Consume body to finalize the response
670        let _ = resp.text().await;
671
672        Ok(Self {
673            http,
674            base_url,
675            token,
676            csrf_token,
677        })
678    }
679
680    /// GET from the direct Protect API (cookie-authenticated).
681    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    /// List all cameras from the direct Protect API (full objects).
701    pub async fn list_cameras_full(&self) -> Result<Vec<ProtectCameraFull>, ApiError> {
702        self.get("/cameras").await
703    }
704
705    /// Get a single camera by ID (full object).
706    pub async fn get_camera_full(&self, id: &str) -> Result<ProtectCameraFull, ApiError> {
707        self.get(&format!("/cameras/{id}")).await
708    }
709}