1use 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
26const HIK_NS: &str = "http://www.hikvision.com/ver20/XMLSchema";
28const OSD_PATH: &str = "/ISAPI/System/Video/inputs/channels/1/overlays";
30const ONVIF_USERS_PATH: &str = "/ISAPI/Security/ONVIF/users";
32
33pub struct HikVisionIsapiClient {
35 base_url: String,
36 username: String,
37 password: String,
38 http: reqwest::Client,
39 timeout: Duration,
40}
41
42impl HikVisionIsapiClient {
43 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 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 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 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 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 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 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 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 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
411fn 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
433fn 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
467fn 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
476fn 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
483fn 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
492fn bool_text(b: bool) -> &'static str {
494 if b {
495 "true"
496 } else {
497 "false"
498 }
499}
500
501fn parse_bool_text(s: &str) -> bool {
503 matches!(s.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes")
504}
505
506fn 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
528fn 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
551fn 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
586fn 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
603fn 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
614fn 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
626fn 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
649fn xml_unescape(s: &str) -> String {
651 s.replace("<", "<")
652 .replace(">", ">")
653 .replace(""", "\"")
654 .replace("'", "'")
655 .replace("&", "&")
656}
657
658fn xml_escape(s: &str) -> String {
660 s.replace('&', "&")
661 .replace('<', "<")
662 .replace('>', ">")
663 .replace('"', """)
664 .replace('\'', "'")
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 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 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 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&b</hostName></NTPServer>"
764 );
765 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}