Skip to main content

heldar_kernel/services/camera_config/
hikvision.rs

1//! HikVision ISAPI implementation of [`CameraConfigProvider`].
2//!
3//! ISAPI is a plain HTTP(S) request/response API whose bodies are XML in the
4//! `http://www.hikvision.com/ver20/XMLSchema` namespace. Authentication is HTTP Digest (RFC 2617):
5//! every request is sent once unauthenticated, and on the `401` challenge an `Authorization: Digest`
6//! header is built with [`super::digest::digest_auth_header`] and the request is retried once
7//! ([`HikVisionIsapiClient::isapi_request_raw`]).
8//!
9//! All XML is parsed by substring extraction (the kernel's offline-build constraint forbids an XML
10//! crate); the helpers below mirror `services/onvif.rs`. Writes are read-modify-write: GET the
11//! current element, splice in the changed sub-fields, and PUT the result back so device-managed
12//! fields (ids, namespaces, untouched sub-elements) are preserved verbatim.
13
14use std::time::Duration;
15
16use async_trait::async_trait;
17use reqwest::{Method, StatusCode};
18
19use super::types::{
20    DeviceInfo, NtpConfig, OnvifSettings, OnvifUserType, OsdConfig, TimeConfig, VideoConfig,
21};
22use super::CameraConfigProvider;
23use crate::error::{AppError, AppResult};
24use crate::models::Camera;
25
26/// XML namespace every HikVision ISAPI body carries.
27const HIK_NS: &str = "http://www.hikvision.com/ver20/XMLSchema";
28/// Overlay (OSD) endpoint for the primary video input channel.
29const OSD_PATH: &str = "/ISAPI/System/Video/inputs/channels/1/overlays";
30/// ONVIF user provisioning endpoint.
31const ONVIF_USERS_PATH: &str = "/ISAPI/Security/ONVIF/users";
32
33/// A HikVision camera reached over ISAPI with HTTP Digest authentication.
34pub struct HikVisionIsapiClient {
35    base_url: String,
36    username: String,
37    password: String,
38    http: reqwest::Client,
39    timeout: Duration,
40}
41
42impl HikVisionIsapiClient {
43    /// Build a client for `cam`. ISAPI is plain HTTP on port 80 unless the camera's `address` itself
44    /// carries an explicit `host:port`. Requires credentials (Digest auth has no anonymous mode).
45    pub fn for_camera(cam: &Camera, http: &reqwest::Client, timeout_ms: u64) -> AppResult<Self> {
46        let host = cam
47            .address
48            .as_deref()
49            .map(str::trim)
50            .filter(|s| !s.is_empty())
51            .ok_or_else(|| {
52                AppError::BadRequest(
53                    "camera has no address; set its address to configure it".into(),
54                )
55            })?;
56        let username = cam.username.clone().unwrap_or_default();
57        if username.is_empty() {
58            return Err(AppError::BadRequest(
59                "camera has no credentials; ISAPI configuration requires a username/password"
60                    .into(),
61            ));
62        }
63        let password = cam.password.clone().unwrap_or_default();
64        Ok(Self {
65            base_url: format!("http://{host}"),
66            username,
67            password,
68            http: http.clone(),
69            timeout: Duration::from_millis(timeout_ms.max(500)),
70        })
71    }
72
73    /// Perform the two-leg Digest dance and return the final `(status, body)` WITHOUT mapping a
74    /// non-2xx status to an error (callers that tolerate 4xx — e.g. duplicate-user creates — use this
75    /// directly). Send once unauthenticated; on `401`, build an `Authorization: Digest` from the
76    /// `WWW-Authenticate` challenge and retry exactly once.
77    async fn isapi_request_raw(
78        &self,
79        method: Method,
80        path: &str,
81        body: Option<String>,
82    ) -> AppResult<(StatusCode, String)> {
83        let url = format!("{}{}", self.base_url, path);
84
85        // Leg 1: unauthenticated probe (ISAPI answers 401 with a Digest challenge).
86        let mut req = self
87            .http
88            .request(method.clone(), url.as_str())
89            .timeout(self.timeout);
90        if let Some(b) = body.clone() {
91            req = req
92                .header(reqwest::header::CONTENT_TYPE, "application/xml")
93                .body(b);
94        }
95        let resp = req
96            .send()
97            .await
98            .map_err(|e| AppError::Other(anyhow::anyhow!("ISAPI {method} {path} failed: {e}")))?;
99
100        if resp.status() != StatusCode::UNAUTHORIZED {
101            let status = resp.status();
102            let text = resp.text().await.unwrap_or_default();
103            return Ok((status, text));
104        }
105
106        // Leg 2: answer the Digest challenge and retry once.
107        let www = resp
108            .headers()
109            .get(reqwest::header::WWW_AUTHENTICATE)
110            .and_then(|v| v.to_str().ok())
111            .ok_or_else(|| {
112                AppError::Other(anyhow::anyhow!(
113                    "ISAPI {method} {path}: 401 without a WWW-Authenticate header"
114                ))
115            })?
116            .to_string();
117        let auth = super::digest::digest_auth_header(
118            method.as_str(),
119            path,
120            &self.username,
121            &self.password,
122            &www,
123        )
124        .ok_or_else(|| {
125            AppError::Other(anyhow::anyhow!(
126                "ISAPI {method} {path}: unsupported Digest challenge"
127            ))
128        })?;
129
130        let mut req = self
131            .http
132            .request(method.clone(), url.as_str())
133            .timeout(self.timeout)
134            .header(reqwest::header::AUTHORIZATION, auth);
135        if let Some(b) = body {
136            req = req
137                .header(reqwest::header::CONTENT_TYPE, "application/xml")
138                .body(b);
139        }
140        let resp = req
141            .send()
142            .await
143            .map_err(|e| AppError::Other(anyhow::anyhow!("ISAPI {method} {path} failed: {e}")))?;
144        let status = resp.status();
145        let text = resp.text().await.unwrap_or_default();
146        Ok((status, text))
147    }
148
149    /// As [`Self::isapi_request_raw`] but a non-2xx status becomes an [`AppError`], surfacing the
150    /// ISAPI `<statusString>` (or `<errorMsg>`) when present.
151    async fn isapi_request(
152        &self,
153        method: Method,
154        path: &str,
155        body: Option<String>,
156    ) -> AppResult<String> {
157        let (status, text) = self.isapi_request_raw(method.clone(), path, body).await?;
158        if !status.is_success() {
159            let reason = first_text(&text, "statusString")
160                .or_else(|| first_text(&text, "errorMsg"))
161                .unwrap_or_else(|| format!("HTTP {status}"));
162            return Err(AppError::Other(anyhow::anyhow!(
163                "ISAPI {method} {path} failed: {reason}"
164            )));
165        }
166        Ok(text)
167    }
168}
169
170#[async_trait]
171impl CameraConfigProvider for HikVisionIsapiClient {
172    async fn get_device_info(&self) -> AppResult<DeviceInfo> {
173        let xml = self
174            .isapi_request(Method::GET, "/ISAPI/System/deviceInfo", None)
175            .await?;
176        Ok(DeviceInfo {
177            device_name: first_text(&xml, "deviceName"),
178            model: first_text(&xml, "model"),
179            firmware_version: first_text(&xml, "firmwareVersion"),
180            serial_number: first_text(&xml, "serialNumber"),
181        })
182    }
183
184    async fn list_video_configs(&self) -> AppResult<Vec<VideoConfig>> {
185        let xml = self
186            .isapi_request(Method::GET, "/ISAPI/Streaming/channels", None)
187            .await?;
188        let configs = elements(&xml, "StreamingChannel")
189            .into_iter()
190            .filter_map(|(_open, inner)| parse_streaming_channel(inner))
191            .collect();
192        Ok(configs)
193    }
194
195    async fn get_video_config(&self, channel: u32) -> AppResult<VideoConfig> {
196        let path = format!("/ISAPI/Streaming/channels/{channel}");
197        let xml = self.isapi_request(Method::GET, &path, None).await?;
198        parse_streaming_channel(&xml).ok_or_else(|| {
199            AppError::Other(anyhow::anyhow!(
200                "ISAPI: could not parse StreamingChannel {channel}"
201            ))
202        })
203    }
204
205    async fn put_video_config(&self, channel: u32, cfg: &VideoConfig) -> AppResult<()> {
206        let path = format!("/ISAPI/Streaming/channels/{channel}");
207        let original = self.isapi_request(Method::GET, &path, None).await?;
208        let body = build_video_put_body(&original, cfg)?;
209        self.isapi_request(Method::PUT, &path, Some(body)).await?;
210        Ok(())
211    }
212
213    async fn get_time_config(&self) -> AppResult<TimeConfig> {
214        let xml = self
215            .isapi_request(Method::GET, "/ISAPI/System/time", None)
216            .await?;
217        Ok(parse_time(&xml))
218    }
219
220    async fn put_time_config(&self, cfg: &TimeConfig) -> AppResult<()> {
221        let original = self
222            .isapi_request(Method::GET, "/ISAPI/System/time", None)
223            .await?;
224        let mut body = replace_first_text(&original, "timeMode", &cfg.time_mode);
225        body = replace_first_text(&body, "localTime", &cfg.local_time);
226        body = replace_first_text(&body, "timeZone", &cfg.time_zone);
227        self.isapi_request(Method::PUT, "/ISAPI/System/time", Some(body))
228            .await?;
229        Ok(())
230    }
231
232    async fn get_ntp_config(&self) -> AppResult<NtpConfig> {
233        let xml = self
234            .isapi_request(Method::GET, "/ISAPI/System/time/ntpServers/1", None)
235            .await?;
236        Ok(NtpConfig {
237            addressing_format: first_text(&xml, "addressingFormatType")
238                .unwrap_or_else(|| "hostname".to_string()),
239            host_name: first_text(&xml, "hostName")
240                .or_else(|| first_text(&xml, "ipAddress"))
241                .unwrap_or_default(),
242            port: first_text(&xml, "portNo")
243                .and_then(|s| s.parse().ok())
244                .unwrap_or(123),
245        })
246    }
247
248    async fn put_ntp_config(&self, cfg: &NtpConfig) -> AppResult<()> {
249        let original = self
250            .isapi_request(Method::GET, "/ISAPI/System/time/ntpServers/1", None)
251            .await?;
252        let mut body =
253            replace_first_text(&original, "addressingFormatType", &cfg.addressing_format);
254        body = replace_first_text(&body, "hostName", &cfg.host_name);
255        // Some firmwares carry a separate <ipAddress> element for the `ipaddress` format.
256        if cfg.addressing_format.eq_ignore_ascii_case("ipaddress") {
257            body = replace_first_text(&body, "ipAddress", &cfg.host_name);
258        }
259        body = replace_first_text(&body, "portNo", &cfg.port.to_string());
260        self.isapi_request(Method::PUT, "/ISAPI/System/time/ntpServers/1", Some(body))
261            .await?;
262        Ok(())
263    }
264
265    async fn sync_time_now(&self) -> AppResult<TimeConfig> {
266        let original = self
267            .isapi_request(Method::GET, "/ISAPI/System/time", None)
268            .await?;
269        if first_text(&original, "timeMode")
270            .unwrap_or_default()
271            .eq_ignore_ascii_case("manual")
272        {
273            let body = replace_first_text(&original, "timeMode", "NTP");
274            self.isapi_request(Method::PUT, "/ISAPI/System/time", Some(body))
275                .await?;
276            return self.get_time_config().await;
277        }
278        // Already on NTP (or an unknown mode): report the current clock unchanged.
279        Ok(parse_time(&original))
280    }
281
282    async fn get_onvif_settings(&self) -> AppResult<OnvifSettings> {
283        let xml = self
284            .isapi_request(Method::GET, "/ISAPI/System/Network/Integrate", None)
285            .await?;
286        Ok(OnvifSettings {
287            onvif_enabled: first_inner(&xml, "ONVIF")
288                .and_then(|b| first_text(b, "enable"))
289                .map(|s| parse_bool_text(&s))
290                .unwrap_or(false),
291            isapi_enabled: first_inner(&xml, "ISAPI")
292                .and_then(|b| first_text(b, "enable"))
293                .map(|s| parse_bool_text(&s))
294                .unwrap_or(false),
295        })
296    }
297
298    async fn put_onvif_settings(&self, cfg: &OnvifSettings) -> AppResult<()> {
299        let original = self
300            .isapi_request(Method::GET, "/ISAPI/System/Network/Integrate", None)
301            .await?;
302        let mut body = replace_in_block(&original, "ONVIF", "enable", bool_text(cfg.onvif_enabled));
303        body = replace_in_block(&body, "ISAPI", "enable", bool_text(cfg.isapi_enabled));
304        self.isapi_request(Method::PUT, "/ISAPI/System/Network/Integrate", Some(body))
305            .await?;
306        Ok(())
307    }
308
309    async fn ensure_onvif_user(
310        &self,
311        username: &str,
312        password: &str,
313        user_type: OnvifUserType,
314    ) -> AppResult<()> {
315        let xml = self
316            .isapi_request(Method::GET, ONVIF_USERS_PATH, None)
317            .await?;
318        let users = elements(&xml, "User");
319        let exists = users
320            .iter()
321            .any(|&(_open, inner)| first_text(inner, "userName").as_deref() == Some(username));
322        if exists {
323            return Ok(());
324        }
325        // Allocate the next id (max existing + 1) for the new user.
326        let next_id = users
327            .iter()
328            .filter_map(|&(_open, inner)| {
329                first_text(inner, "id").and_then(|s| s.parse::<i64>().ok())
330            })
331            .max()
332            .unwrap_or(0)
333            + 1;
334        let body = format!(
335            "<UserList version=\"2.0\" xmlns=\"{HIK_NS}\">\
336<User><id>{id}</id><userName>{user}</userName><password>{pass}</password>\
337<userType>{utype}</userType></User></UserList>",
338            id = next_id,
339            user = xml_escape(username),
340            pass = xml_escape(password),
341            utype = onvif_user_type_wire(user_type),
342        );
343        // POST creates the user; the device returns a 4xx if the user already exists — treat any 4xx
344        // on create as success (only a 5xx / transport failure is a real error).
345        let (status, text) = self
346            .isapi_request_raw(Method::POST, ONVIF_USERS_PATH, Some(body))
347            .await?;
348        if status.is_success() || status.is_client_error() {
349            Ok(())
350        } else {
351            let reason =
352                first_text(&text, "statusString").unwrap_or_else(|| format!("HTTP {status}"));
353            Err(AppError::Other(anyhow::anyhow!(
354                "ISAPI POST {ONVIF_USERS_PATH} failed: {reason}"
355            )))
356        }
357    }
358
359    async fn get_osd_config(&self) -> AppResult<OsdConfig> {
360        let xml = self.isapi_request(Method::GET, OSD_PATH, None).await?;
361        let dt = first_inner(&xml, "DateTimeOverlay").unwrap_or("");
362        let cn = first_inner(&xml, "channelNameOverlay").unwrap_or("");
363        Ok(OsdConfig {
364            datetime_enabled: first_text(dt, "enabled")
365                .map(|s| parse_bool_text(&s))
366                .unwrap_or(false),
367            channel_name_enabled: first_text(cn, "enabled")
368                .map(|s| parse_bool_text(&s))
369                .unwrap_or(false),
370            date_style: first_text(dt, "dateStyle"),
371            time_style: first_text(dt, "timeStyle"),
372            display_week: first_text(dt, "displayWeek").map(|s| parse_bool_text(&s)),
373        })
374    }
375
376    async fn put_osd_config(&self, cfg: &OsdConfig) -> AppResult<()> {
377        let original = self.isapi_request(Method::GET, OSD_PATH, None).await?;
378        let mut body = replace_in_block(
379            &original,
380            "DateTimeOverlay",
381            "enabled",
382            bool_text(cfg.datetime_enabled),
383        );
384        body = replace_in_block(
385            &body,
386            "channelNameOverlay",
387            "enabled",
388            bool_text(cfg.channel_name_enabled),
389        );
390        if let Some(ds) = &cfg.date_style {
391            body = replace_in_block(&body, "DateTimeOverlay", "dateStyle", ds);
392        }
393        if let Some(ts) = &cfg.time_style {
394            body = replace_in_block(&body, "DateTimeOverlay", "timeStyle", ts);
395        }
396        if let Some(dw) = cfg.display_week {
397            body = replace_in_block(&body, "DateTimeOverlay", "displayWeek", bool_text(dw));
398        }
399        self.isapi_request(Method::PUT, OSD_PATH, Some(body))
400            .await?;
401        Ok(())
402    }
403
404    async fn reboot(&self) -> AppResult<()> {
405        self.isapi_request(Method::PUT, "/ISAPI/System/reboot", None)
406            .await?;
407        Ok(())
408    }
409}
410
411// ========================= ISAPI body parsing / building =========================
412
413/// Parse a `<StreamingChannel>` element (the slice may be the element's inner XML or any XML that
414/// contains it) into a [`VideoConfig`]. Returns `None` when the channel id is missing/unparseable.
415fn parse_streaming_channel(xml: &str) -> Option<VideoConfig> {
416    let channel_id: i64 = first_text(xml, "id")?.parse().ok()?;
417    let channel_name = first_text(xml, "channelName");
418    let video = first_inner(xml, "Video")?;
419    Some(VideoConfig {
420        channel_id,
421        channel_name,
422        codec: first_text(video, "videoCodecType").unwrap_or_default(),
423        width: parse_i64(video, "videoResolutionWidth"),
424        height: parse_i64(video, "videoResolutionHeight"),
425        fps: parse_i64(video, "maxFrameRate"),
426        quality_control: first_text(video, "videoQualityControlType").unwrap_or_default(),
427        bitrate: parse_i64(video, "constantBitRate"),
428        vbr_upper_cap: parse_i64(video, "vbrUpperCap"),
429        gop: parse_i64(video, "GovLength"),
430    })
431}
432
433/// Read-modify-write the `<Video>` block of a `StreamingChannel` XML document, preserving the id,
434/// channel name, namespace, and every untouched sub-element.
435fn build_video_put_body(original: &str, cfg: &VideoConfig) -> AppResult<String> {
436    let (_lt, gt, self_closing) = find_open(original, "Video", 0).ok_or_else(|| {
437        AppError::Other(anyhow::anyhow!(
438            "ISAPI: StreamingChannel has no <Video> block"
439        ))
440    })?;
441    if self_closing {
442        return Err(AppError::Other(anyhow::anyhow!(
443            "ISAPI: StreamingChannel <Video> block is empty"
444        )));
445    }
446    let cs = gt + 1;
447    let close_rel = find_close(&original[cs..], "Video")
448        .ok_or_else(|| AppError::Other(anyhow::anyhow!("ISAPI: unterminated <Video> block")))?;
449    let ce = cs + close_rel;
450
451    let mut v = replace_first_text(&original[cs..ce], "videoCodecType", &cfg.codec);
452    v = replace_first_text(&v, "videoResolutionWidth", &cfg.width.to_string());
453    v = replace_first_text(&v, "videoResolutionHeight", &cfg.height.to_string());
454    v = replace_first_text(&v, "videoQualityControlType", &cfg.quality_control);
455    v = replace_first_text(&v, "constantBitRate", &cfg.bitrate.to_string());
456    v = replace_first_text(&v, "vbrUpperCap", &cfg.vbr_upper_cap.to_string());
457    v = replace_first_text(&v, "maxFrameRate", &cfg.fps.to_string());
458    v = replace_first_text(&v, "GovLength", &cfg.gop.to_string());
459
460    let mut out = String::with_capacity(original.len() + v.len());
461    out.push_str(&original[..cs]);
462    out.push_str(&v);
463    out.push_str(&original[ce..]);
464    Ok(out)
465}
466
467/// Parse a `<Time>` document into a [`TimeConfig`].
468fn parse_time(xml: &str) -> TimeConfig {
469    TimeConfig {
470        time_mode: first_text(xml, "timeMode").unwrap_or_default(),
471        local_time: first_text(xml, "localTime").unwrap_or_default(),
472        time_zone: first_text(xml, "timeZone").unwrap_or_default(),
473    }
474}
475
476/// Parse the integer text of the first `<local>` element, or `0`.
477fn parse_i64(xml: &str, local: &str) -> i64 {
478    first_text(xml, local)
479        .and_then(|s| s.parse().ok())
480        .unwrap_or(0)
481}
482
483/// Map an [`OnvifUserType`] to its verbatim ISAPI `userType` value.
484fn onvif_user_type_wire(t: OnvifUserType) -> &'static str {
485    match t {
486        OnvifUserType::Administrator => "administrator",
487        OnvifUserType::Operator => "operator",
488        OnvifUserType::MediaUser => "mediaUser",
489    }
490}
491
492/// ISAPI boolean text.
493fn bool_text(b: bool) -> &'static str {
494    if b {
495        "true"
496    } else {
497        "false"
498    }
499}
500
501/// Interpret ISAPI boolean text (`true`/`1`/`yes`, case-insensitive).
502fn parse_bool_text(s: &str) -> bool {
503    matches!(s.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes")
504}
505
506/// Replace the inner text of the FIRST `<local>...</local>` element with `new_value` (XML-escaped).
507/// A self-closing or absent element leaves `xml` unchanged (read-modify-write never adds elements).
508fn replace_first_text(xml: &str, local: &str, new_value: &str) -> String {
509    let Some((_lt, gt, self_closing)) = find_open(xml, local, 0) else {
510        return xml.to_string();
511    };
512    if self_closing {
513        return xml.to_string();
514    }
515    let cs = gt + 1;
516    let Some(close_rel) = find_close(&xml[cs..], local) else {
517        return xml.to_string();
518    };
519    let ce = cs + close_rel;
520    let escaped = xml_escape(new_value);
521    let mut out = String::with_capacity(xml.len() + escaped.len());
522    out.push_str(&xml[..cs]);
523    out.push_str(&escaped);
524    out.push_str(&xml[ce..]);
525    out
526}
527
528/// Replace the inner text of the first `<local>` element found INSIDE the first `<block>` element,
529/// so a name that repeats across sibling blocks (e.g. `<enable>` under both `<ONVIF>` and `<ISAPI>`)
530/// is disambiguated. Leaves `xml` unchanged when the block is absent/self-closing.
531fn replace_in_block(xml: &str, block: &str, local: &str, new_value: &str) -> String {
532    let Some((_lt, gt, self_closing)) = find_open(xml, block, 0) else {
533        return xml.to_string();
534    };
535    if self_closing {
536        return xml.to_string();
537    }
538    let cs = gt + 1;
539    let Some(close_rel) = find_close(&xml[cs..], block) else {
540        return xml.to_string();
541    };
542    let ce = cs + close_rel;
543    let modified = replace_first_text(&xml[cs..ce], local, new_value);
544    let mut out = String::with_capacity(xml.len() + modified.len());
545    out.push_str(&xml[..cs]);
546    out.push_str(&modified);
547    out.push_str(&xml[ce..]);
548    out
549}
550
551// ========================= XML helpers (substring extraction) =========================
552//
553// Copied from `services/onvif.rs`: these tolerate namespace prefixes and attributes on tags and
554// assume the small, well-formed XML bodies ISAPI returns (no same-name nesting in what we read).
555
556/// Locate the first element with local name `local` at/after byte `from`. Returns
557/// `(open_lt, open_gt, self_closing)`: index of the opening `<`, index of that tag's `>`, and whether
558/// the element is self-closing (`/>`). Comments, declarations, and closing tags are skipped.
559fn find_open(xml: &str, local: &str, from: usize) -> Option<(usize, usize, bool)> {
560    let bytes = xml.as_bytes();
561    let mut i = from.min(xml.len());
562    while let Some(rel) = xml[i..].find('<') {
563        let lt = i + rel;
564        match bytes.get(lt + 1).copied() {
565            Some(b'/') | Some(b'!') | Some(b'?') => {
566                i = lt + 1;
567                continue;
568            }
569            _ => {}
570        }
571        let name_start = lt + 1;
572        let gt_rel = xml[name_start..].find('>')?;
573        let gt = name_start + gt_rel;
574        let self_closing = gt > name_start && bytes.get(gt - 1).copied() == Some(b'/');
575        let tag = &xml[name_start..gt];
576        let head = tag.split([' ', '\t', '\n', '\r', '/']).next().unwrap_or("");
577        let local_name = head.rsplit(':').next().unwrap_or(head);
578        if local_name == local {
579            return Some((lt, gt, self_closing));
580        }
581        i = gt + 1;
582    }
583    None
584}
585
586/// Find the byte offset of the first closing tag `</...local>` in `xml`.
587fn find_close(xml: &str, local: &str) -> Option<usize> {
588    let mut i = 0;
589    while let Some(rel) = xml[i..].find("</") {
590        let pos = i + rel;
591        let after = &xml[pos + 2..];
592        let gt_rel = after.find('>')?;
593        let name = after[..gt_rel].trim();
594        let local_name = name.rsplit(':').next().unwrap_or(name);
595        if local_name == local {
596            return Some(pos);
597        }
598        i = pos + 2;
599    }
600    None
601}
602
603/// Inner XML (raw) of the first element with local name `local`.
604fn first_inner<'a>(xml: &'a str, local: &str) -> Option<&'a str> {
605    let (_lt, gt, self_closing) = find_open(xml, local, 0)?;
606    if self_closing {
607        return Some("");
608    }
609    let cs = gt + 1;
610    let close_rel = find_close(&xml[cs..], local)?;
611    Some(&xml[cs..cs + close_rel])
612}
613
614/// Trimmed, entity-decoded text content of the first element with local name `local`. Returns `None`
615/// when the element is absent or its text is empty.
616fn first_text(xml: &str, local: &str) -> Option<String> {
617    let inner = first_inner(xml, local)?;
618    let t = inner.trim();
619    if t.is_empty() {
620        None
621    } else {
622        Some(xml_unescape(t))
623    }
624}
625
626/// All elements with local name `local`, returned as `(opening_tag, inner_xml)` pairs.
627fn elements<'a>(xml: &'a str, local: &str) -> Vec<(&'a str, &'a str)> {
628    let mut out = Vec::new();
629    let mut from = 0;
630    while let Some((lt, gt, self_closing)) = find_open(xml, local, from) {
631        let open = &xml[lt..=gt];
632        if self_closing {
633            out.push((open, ""));
634            from = gt + 1;
635            continue;
636        }
637        let cs = gt + 1;
638        match find_close(&xml[cs..], local) {
639            Some(close_rel) => {
640                out.push((open, &xml[cs..cs + close_rel]));
641                from = cs + close_rel;
642            }
643            None => break,
644        }
645    }
646    out
647}
648
649/// Decode the five predefined XML entities.
650fn xml_unescape(s: &str) -> String {
651    s.replace("&lt;", "<")
652        .replace("&gt;", ">")
653        .replace("&quot;", "\"")
654        .replace("&apos;", "'")
655        .replace("&amp;", "&")
656}
657
658/// Escape the characters that are not safe in XML text / attribute values.
659fn xml_escape(s: &str) -> String {
660    s.replace('&', "&amp;")
661        .replace('<', "&lt;")
662        .replace('>', "&gt;")
663        .replace('"', "&quot;")
664        .replace('\'', "&apos;")
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670
671    const CHANNEL: &str = "<StreamingChannel version=\"2.0\" xmlns=\"http://www.hikvision.com/ver20/XMLSchema\">\
672<id>101</id><channelName>Front Door</channelName><enabled>true</enabled>\
673<Video><enabled>true</enabled><videoInputChannelID>1</videoInputChannelID>\
674<videoCodecType>H.265</videoCodecType><videoResolutionWidth>2560</videoResolutionWidth>\
675<videoResolutionHeight>1440</videoResolutionHeight><videoQualityControlType>VBR</videoQualityControlType>\
676<constantBitRate>4096</constantBitRate><vbrUpperCap>4096</vbrUpperCap>\
677<maxFrameRate>2000</maxFrameRate><GovLength>50</GovLength></Video></StreamingChannel>";
678
679    #[test]
680    fn parses_streaming_channel() {
681        let c = parse_streaming_channel(CHANNEL).expect("parsed");
682        assert_eq!(c.channel_id, 101);
683        assert_eq!(c.channel_name.as_deref(), Some("Front Door"));
684        assert_eq!(c.codec, "H.265");
685        assert_eq!(c.width, 2560);
686        assert_eq!(c.height, 1440);
687        assert_eq!(c.fps, 2000);
688        assert_eq!(c.quality_control, "VBR");
689        assert_eq!(c.bitrate, 4096);
690        assert_eq!(c.vbr_upper_cap, 4096);
691        assert_eq!(c.gop, 50);
692    }
693
694    #[test]
695    fn read_modify_write_preserves_untouched_fields() {
696        let cfg = VideoConfig {
697            channel_id: 101,
698            channel_name: Some("ignored".into()),
699            codec: "H.264".into(),
700            width: 1920,
701            height: 1080,
702            fps: 2500,
703            quality_control: "CBR".into(),
704            bitrate: 2048,
705            vbr_upper_cap: 2048,
706            gop: 25,
707        };
708        let body = build_video_put_body(CHANNEL, &cfg).expect("built");
709        // Changed fields.
710        assert!(body.contains("<videoCodecType>H.264</videoCodecType>"));
711        assert!(body.contains("<videoResolutionWidth>1920</videoResolutionWidth>"));
712        assert!(body.contains("<maxFrameRate>2500</maxFrameRate>"));
713        assert!(body.contains("<videoQualityControlType>CBR</videoQualityControlType>"));
714        assert!(body.contains("<GovLength>25</GovLength>"));
715        // Preserved id / channel name / namespace / untouched sub-elements.
716        assert!(body.contains("<id>101</id>"));
717        assert!(body.contains("<channelName>Front Door</channelName>"));
718        assert!(body.contains("xmlns=\"http://www.hikvision.com/ver20/XMLSchema\""));
719        assert!(body.contains("<videoInputChannelID>1</videoInputChannelID>"));
720    }
721
722    #[test]
723    fn replace_in_block_disambiguates_repeated_names() {
724        let xml = "<Integrate><ONVIF><enable>false</enable></ONVIF>\
725<ISAPI><enable>true</enable></ISAPI></Integrate>";
726        let out = replace_in_block(xml, "ONVIF", "enable", "true");
727        assert_eq!(
728            out,
729            "<Integrate><ONVIF><enable>true</enable></ONVIF>\
730<ISAPI><enable>true</enable></ISAPI></Integrate>"
731        );
732        // The ISAPI <enable> is untouched.
733        let out2 = replace_in_block(&out, "ISAPI", "enable", "false");
734        assert!(out2.contains("<ONVIF><enable>true</enable></ONVIF>"));
735        assert!(out2.contains("<ISAPI><enable>false</enable></ISAPI>"));
736    }
737
738    #[test]
739    fn parses_onvif_user_list() {
740        let xml = "<UserList version=\"2.0\" xmlns=\"http://www.hikvision.com/ver20/XMLSchema\">\
741<User><id>1</id><userName>admin</userName><userType>administrator</userType></User>\
742<User><id>2</id><userName>heldar_onvif</userName><userType>operator</userType></User>\
743</UserList>";
744        let users = elements(xml, "User");
745        assert_eq!(users.len(), 2);
746        let names: Vec<_> = users
747            .iter()
748            .filter_map(|&(_o, inner)| first_text(inner, "userName"))
749            .collect();
750        assert_eq!(names, vec!["admin", "heldar_onvif"]);
751        let max_id = users
752            .iter()
753            .filter_map(|&(_o, inner)| first_text(inner, "id").and_then(|s| s.parse::<i64>().ok()))
754            .max();
755        assert_eq!(max_id, Some(2));
756    }
757
758    #[test]
759    fn replace_first_text_escapes_and_no_ops_when_absent() {
760        let xml = "<NTPServer><hostName>old</hostName></NTPServer>";
761        assert_eq!(
762            replace_first_text(xml, "hostName", "a&b"),
763            "<NTPServer><hostName>a&amp;b</hostName></NTPServer>"
764        );
765        // Absent element -> unchanged.
766        assert_eq!(replace_first_text(xml, "portNo", "123"), xml);
767    }
768
769    #[test]
770    fn user_type_wire_values() {
771        assert_eq!(
772            onvif_user_type_wire(OnvifUserType::Administrator),
773            "administrator"
774        );
775        assert_eq!(onvif_user_type_wire(OnvifUserType::Operator), "operator");
776        assert_eq!(onvif_user_type_wire(OnvifUserType::MediaUser), "mediaUser");
777    }
778}