From c6e3a113ba839926f5b31950387d12fe59418dd1 Mon Sep 17 00:00:00 2001 From: Sandeep Date: Fri, 31 Jul 2026 01:22:53 -0700 Subject: [PATCH 1/4] feat: list stored recording metadata --- README.md | 26 + crates/core/src/bc/de.rs | 49 +- crates/core/src/bc/model.rs | 6 + .../src/bc/samples/file_info_list_open.xml | 8 + .../bc/samples/file_info_list_page_direct.xml | 22 + .../bc/samples/file_info_list_page_empty.xml | 8 + .../bc/samples/file_info_list_page_nested.xml | 17 + crates/core/src/bc/xml.rs | 163 ++++ crates/core/src/bc_protocol.rs | 6 + crates/core/src/bc_protocol/errors.rs | 17 + crates/core/src/bc_protocol/recordings.rs | 842 ++++++++++++++++++ docs/bc-protocol.md | 16 + src/cmdline.rs | 1 + src/main.rs | 4 + src/recordings/cmdline.rs | 39 + src/recordings/mod.rs | 201 +++++ 16 files changed, 1415 insertions(+), 10 deletions(-) create mode 100644 crates/core/src/bc/samples/file_info_list_open.xml create mode 100644 crates/core/src/bc/samples/file_info_list_page_direct.xml create mode 100644 crates/core/src/bc/samples/file_info_list_page_empty.xml create mode 100644 crates/core/src/bc/samples/file_info_list_page_nested.xml create mode 100644 crates/core/src/bc_protocol/recordings.rs create mode 100644 src/recordings/cmdline.rs create mode 100644 src/recordings/mod.rs diff --git a/README.md b/README.md index 1812c397..e53aaf59 100644 --- a/README.md +++ b/README.md @@ -732,6 +732,32 @@ neolink ptz --config=config.toml CameraName zoom 2.5 With 1.0 being normal and 2.5 being 2.5x zoom +### Stored recording metadata + +You can list recording metadata from a camera's SD card or an NVR channel without +downloading footage: + +```bash +# Privacy-conscious summary only (no filenames or recording paths) +neolink recordings --config=config.toml CameraName --date 2026-07-30 + +# Override the configured logical channel and request the main recording stream +neolink recordings --config=config.toml CameraName --date 2026-07-30 \ + --channel 1 --stream main + +# Machine-readable results, including camera-provided recording identifiers +neolink recordings --config=config.toml CameraName --date 2026-07-30 --json +``` + +Queries use the camera's local calendar date and are deliberately bounded. Use +`--max-pages` and `--max-entries` to lower the defaults; both also have hard +safety ceilings. The default text output reports only counts, pagination state, +and the earliest/latest timestamps. JSON never includes the camera UID, +credentials, or raw protocol XML, but it does include recording names/paths +needed by later playback integrations. + +This command only lists metadata. It does not replay or download recordings. + ### Services (camera ports) You can inspect and change the camera's service ports (`baichuan`, `http`, diff --git a/crates/core/src/bc/de.rs b/crates/core/src/bc/de.rs index b6b87aee..6427f37c 100644 --- a/crates/core/src/bc/de.rs +++ b/crates/core/src/bc/de.rs @@ -20,6 +20,13 @@ type IResult> = Result<(I, O), no /// malicious lengths. const MAX_BODY_LEN: u32 = 16 * 1024 * 1024; +fn is_file_info_list_message(msg_id: u32) -> bool { + matches!( + msg_id, + MSG_ID_FILE_INFO_LIST_OPEN | MSG_ID_FILE_INFO_LIST_GET | MSG_ID_FILE_INFO_LIST_CLOSE + ) +} + impl Bc { /// Returns Ok(deserialized data, the amount of data consumed) /// Can then use this as the amount that should be remove from a buffer @@ -217,19 +224,33 @@ fn bc_modern_msg<'a>( }; } else { if context.debug { - println!( - "Payload Txt: {:?}", - String::from_utf8(processed_payload_buf.to_vec()) - .unwrap_or("Not Text".to_string()) - ); + if is_file_info_list_message(header.msg_id) { + println!( + "Payload Txt: ", + processed_payload_buf.len() + ); + } else { + println!( + "Payload Txt: {:?}", + String::from_utf8(processed_payload_buf.to_vec()) + .unwrap_or("Not Text".to_string()) + ); + } } let xml = BcXml::try_parse(processed_payload_buf.as_slice()).map_err(|e| { error!("header.msg_id: {}", header.msg_id); - error!( - "processed_payload_buf: {:X?}::{:?}", - processed_payload_buf, - std::str::from_utf8(&processed_payload_buf) - ); + if is_file_info_list_message(header.msg_id) { + error!( + "FileInfoList XML payload redacted ({} bytes)", + processed_payload_buf.len() + ); + } else { + error!( + "processed_payload_buf: {:X?}::{:?}", + processed_payload_buf, + std::str::from_utf8(&processed_payload_buf) + ); + } log::error!("e: {:?}", e); Err::Error(make_error( buf, @@ -301,6 +322,14 @@ mod tests { .try_init(); } + #[test] + fn file_info_list_messages_are_classified_as_private() { + assert!(is_file_info_list_message(MSG_ID_FILE_INFO_LIST_OPEN)); + assert!(is_file_info_list_message(MSG_ID_FILE_INFO_LIST_GET)); + assert!(is_file_info_list_message(MSG_ID_FILE_INFO_LIST_CLOSE)); + assert!(!is_file_info_list_message(MSG_ID_LOGIN)); + } + #[test] fn test_bc_modern_login() { init(); diff --git a/crates/core/src/bc/model.rs b/crates/core/src/bc/model.rs index 1145876f..12daa8f7 100644 --- a/crates/core/src/bc/model.rs +++ b/crates/core/src/bc/model.rs @@ -22,6 +22,12 @@ pub const MSG_ID_VIDEO_STOP: u32 = 4; pub const MSG_ID_TALKABILITY: u32 = 10; /// TalkReset messages have this ID pub const MSG_ID_TALKRESET: u32 = 11; +/// Open a FileInfoList recording metadata search +pub const MSG_ID_FILE_INFO_LIST_OPEN: u32 = 14; +/// Read one page from a FileInfoList recording metadata search +pub const MSG_ID_FILE_INFO_LIST_GET: u32 = 15; +/// Close a FileInfoList recording metadata search +pub const MSG_ID_FILE_INFO_LIST_CLOSE: u32 = 16; /// PtzControl messages have this ID pub const MSG_ID_PTZ_CONTROL: u32 = 18; /// PTZ goto preset position diff --git a/crates/core/src/bc/samples/file_info_list_open.xml b/crates/core/src/bc/samples/file_info_list_open.xml new file mode 100644 index 00000000..c3b162c8 --- /dev/null +++ b/crates/core/src/bc/samples/file_info_list_open.xml @@ -0,0 +1,8 @@ + + + + +17 + + + diff --git a/crates/core/src/bc/samples/file_info_list_page_direct.xml b/crates/core/src/bc/samples/file_info_list_page_direct.xml new file mode 100644 index 00000000..9c38dc4d --- /dev/null +++ b/crates/core/src/bc/samples/file_info_list_page_direct.xml @@ -0,0 +1,22 @@ + + + + +/fixture/channel0/recording-a.mp4 +recording-a.mp4 +people +1234 +2026730123 +2026730134 +ignored + + +/fixture/channel0/recording-b.mp4 +vehicle +5678 +2026730234 +2026730245 + +1 + + diff --git a/crates/core/src/bc/samples/file_info_list_page_empty.xml b/crates/core/src/bc/samples/file_info_list_page_empty.xml new file mode 100644 index 00000000..c3b162c8 --- /dev/null +++ b/crates/core/src/bc/samples/file_info_list_page_empty.xml @@ -0,0 +1,8 @@ + + + + +17 + + + diff --git a/crates/core/src/bc/samples/file_info_list_page_nested.xml b/crates/core/src/bc/samples/file_info_list_page_nested.xml new file mode 100644 index 00000000..2f96ee97 --- /dev/null +++ b/crates/core/src/bc/samples/file_info_list_page_nested.xml @@ -0,0 +1,17 @@ + + + + + + +/fixture/channel1/recording-c.mp4 +recording-c.mp4 +md +2026730345 +2026730356 + + + +1 + + diff --git a/crates/core/src/bc/xml.rs b/crates/core/src/bc/xml.rs index 8a4cd46a..13b67692 100644 --- a/crates/core/src/bc/xml.rs +++ b/crates/core/src/bc/xml.rs @@ -141,6 +141,9 @@ pub struct BcXml { /// Read and write users #[serde(rename = "UserList", skip_serializing_if = "Option::is_none")] pub user_list: Option, + /// FileInfoList recording metadata request/response + #[serde(rename = "FileInfoList", skip_serializing_if = "Option::is_none")] + pub file_info_list: Option, } impl BcXml { @@ -175,6 +178,122 @@ impl Extension { } } +/// FileInfoList request/response envelope used by recording search commands. +#[derive(Clone, PartialEq, Eq, Default, Debug, Deserialize, Serialize)] +pub struct FileInfoList { + /// XML schema version. + #[serde(rename = "@version", skip_serializing_if = "Option::is_none")] + pub version: Option, + /// FileInfo request or result entries. + #[serde(rename = "FileInfo", default, skip_serializing_if = "Vec::is_empty")] + pub file_info: Vec, + /// Some firmware returns direct File entries. + #[serde(rename = "File", default, skip_serializing_if = "Vec::is_empty")] + pub file: Vec, + /// Pagination completion marker. + #[serde(rename = "bFinished", skip_serializing_if = "Option::is_none")] + pub b_finished: Option, + /// Alternate pagination completion marker. + #[serde(rename = "finished", skip_serializing_if = "Option::is_none")] + pub finished: Option, +} + +/// A FileInfoList search request, cursor response, or recording result. +#[derive(Clone, PartialEq, Eq, Default, Debug, Deserialize, Serialize)] +pub struct FileInfo { + /// Device UID used in requests. + #[serde(skip_serializing_if = "Option::is_none")] + pub uid: Option, + /// Enable AI-track search. + #[serde(rename = "searchAITrack", skip_serializing_if = "Option::is_none")] + pub search_ai_track: Option, + /// Logical camera channel. + #[serde(rename = "channelId", skip_serializing_if = "Option::is_none")] + pub channel_id: Option, + /// Logical channel bitmap. + #[serde(rename = "logicChnBitmap", skip_serializing_if = "Option::is_none")] + pub logic_chn_bitmap: Option, + /// Requested stream type. + #[serde(rename = "streamType", skip_serializing_if = "Option::is_none")] + pub stream_type: Option, + /// Requested or returned recording class. + #[serde(rename = "recordType", skip_serializing_if = "Option::is_none")] + pub record_type: Option, + /// Alternate returned recording class. + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub type_: Option, + /// Alternate returned alarm class. + #[serde(rename = "alarmType", skip_serializing_if = "Option::is_none")] + pub alarm_type: Option, + /// Search/result start timestamp. + #[serde(rename = "startTime", skip_serializing_if = "Option::is_none")] + pub start_time: Option, + /// Search/result end timestamp. + #[serde(rename = "endTime", skip_serializing_if = "Option::is_none")] + pub end_time: Option, + /// Server-side search cursor. + #[serde(skip_serializing_if = "Option::is_none")] + pub handle: Option, + /// Recording identifier. + #[serde(rename = "Id", skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Recording name. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Recording filename/path. + #[serde(rename = "fileName", skip_serializing_if = "Option::is_none")] + pub file_name: Option, + /// Recording size. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + /// Alternate recording size. + #[serde(rename = "fileSize", skip_serializing_if = "Option::is_none")] + pub file_size: Option, + /// Direct nested result entries. + #[serde(rename = "File", default, skip_serializing_if = "Vec::is_empty")] + pub file: Vec, + /// Lowercase nested result-list wrapper. + #[serde(rename = "fileList", skip_serializing_if = "Option::is_none")] + pub file_list: Option, + /// Uppercase nested result-list wrapper. + #[serde(rename = "FileList", skip_serializing_if = "Option::is_none")] + pub file_list_upper: Option, + /// Pagination completion marker. + #[serde(rename = "bFinished", skip_serializing_if = "Option::is_none")] + pub b_finished: Option, + /// Alternate pagination completion marker. + #[serde(rename = "finished", skip_serializing_if = "Option::is_none")] + pub finished: Option, +} + +/// A nested FileInfoList recording result list. +#[derive(Clone, PartialEq, Eq, Default, Debug, Deserialize, Serialize)] +pub struct FileResultList { + /// File result entries. + #[serde(rename = "File", default, skip_serializing_if = "Vec::is_empty")] + pub file: Vec, + /// FileInfo result entries. + #[serde(rename = "FileInfo", default, skip_serializing_if = "Vec::is_empty")] + pub file_info: Vec, +} + +/// Camera-local date/time fields used by FileInfoList. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Debug, Deserialize, Serialize)] +pub struct FileDateTime { + /// Year. + pub year: u16, + /// Month, 1-12. + pub month: u8, + /// Day, 1-31. + pub day: u8, + /// Hour, 0-23. + pub hour: u8, + /// Minute, 0-59. + pub minute: u8, + /// Second, 0-59. + pub second: u8, +} + /// Encryption xml #[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize)] pub struct Encryption { @@ -2115,3 +2234,47 @@ fn test_empty_floodlight_status_list() { _ => panic!(), } } + +#[test] +fn test_file_info_list_open_fixture() { + let parsed = + BcXml::try_parse(include_bytes!("samples/file_info_list_open.xml").as_slice()).unwrap(); + let list = parsed.file_info_list.unwrap(); + assert_eq!(list.version.as_deref(), Some("1.1")); + assert_eq!(list.file_info[0].handle, Some(17)); +} + +#[test] +fn test_file_info_list_direct_fixture() { + let parsed = + BcXml::try_parse(include_bytes!("samples/file_info_list_page_direct.xml").as_slice()) + .unwrap(); + let list = parsed.file_info_list.unwrap(); + assert_eq!(list.file_info.len(), 2); + assert_eq!(list.file_info[0].size, Some(1234)); + assert_eq!(list.file_info[1].file_size, Some(5678)); + assert_eq!(list.file_info[1].start_time.unwrap().hour, 2); + assert_eq!(list.b_finished, Some(1)); +} + +#[test] +fn test_file_info_list_nested_fixture() { + let parsed = + BcXml::try_parse(include_bytes!("samples/file_info_list_page_nested.xml").as_slice()) + .unwrap(); + let list = parsed.file_info_list.unwrap(); + let nested = list.file_info[0].file_list.as_ref().unwrap(); + assert_eq!(nested.file.len(), 1); + assert_eq!(nested.file[0].type_.as_deref(), Some("md")); + assert_eq!(list.finished, Some(1)); +} + +#[test] +fn test_file_info_list_empty_fixture() { + let parsed = + BcXml::try_parse(include_bytes!("samples/file_info_list_page_empty.xml").as_slice()) + .unwrap(); + let list = parsed.file_info_list.unwrap(); + assert!(list.file.is_empty()); + assert_eq!(list.file_info.len(), 1); +} diff --git a/crates/core/src/bc_protocol.rs b/crates/core/src/bc_protocol.rs index 60d1a8b5..cb201ee5 100644 --- a/crates/core/src/bc_protocol.rs +++ b/crates/core/src/bc_protocol.rs @@ -30,6 +30,7 @@ mod ping; mod pirstate; mod ptz; mod reboot; +mod recordings; mod resolution; mod services; mod siren; @@ -52,6 +53,11 @@ pub use login::MaxEncryption; pub use motion::{MotionData, MotionStatus}; pub use pirstate::PirState; pub use ptz::Direction; +pub use recordings::{ + RecordingEntry, RecordingSearchEnd, RecordingSearchOptions, RecordingSearchResult, + RecordingStreamKind, DEFAULT_RECORDING_MAX_ENTRIES, DEFAULT_RECORDING_MAX_PAGES, + HARD_RECORDING_MAX_ENTRIES, HARD_RECORDING_MAX_PAGES, +}; pub use resolution::*; use std::sync::Arc; pub use stream::{StreamData, StreamKind}; diff --git a/crates/core/src/bc_protocol/errors.rs b/crates/core/src/bc_protocol/errors.rs index 757f3282..230be37a 100644 --- a/crates/core/src/bc_protocol/errors.rs +++ b/crates/core/src/bc_protocol/errors.rs @@ -246,6 +246,23 @@ pub enum Error { feature: &'static str, }, + /// A recording search succeeded, but its server-side cursor was not + /// acknowledged as closed. + #[error("Recording search cursor close failed")] + RecordingCloseFailed { + /// The close error, retained for programmatic inspection. + close: std::sync::Arc, + }, + + /// A recording search failed and its server-side cursor also failed to close. + #[error("Recording search failed and cursor close also failed")] + RecordingSearchAndCloseFailed { + /// The original search error. + search: std::sync::Arc, + /// The close error. + close: std::sync::Arc, + }, + /// Raised when a thread panics #[error("Thread panicked")] JoinError(#[from] std::sync::Arc), diff --git a/crates/core/src/bc_protocol/recordings.rs b/crates/core/src/bc_protocol/recordings.rs new file mode 100644 index 00000000..189c2801 --- /dev/null +++ b/crates/core/src/bc_protocol/recordings.rs @@ -0,0 +1,842 @@ +use super::{BcCamera, Error, Result}; +use crate::bc::{model::*, xml::*}; +use serde::Serialize; +use std::{collections::HashSet, future::Future, sync::Arc, time::Duration}; + +const FILE_INFO_LIST_VERSION: &str = "1.1"; +const FILE_INFO_LIST_HOST_CHANNEL: u8 = 250; +const RECORDING_REPLY_TIMEOUT: Duration = Duration::from_secs(15); +const TYPICAL_PAGE_SIZE: usize = 40; + +/// Default maximum number of FileInfoList pages requested in one search. +pub const DEFAULT_RECORDING_MAX_PAGES: usize = 50; +/// Hard safety ceiling for FileInfoList pages requested in one search. +pub const HARD_RECORDING_MAX_PAGES: usize = 250; +/// Default maximum number of unique entries returned in one search. +pub const DEFAULT_RECORDING_MAX_ENTRIES: usize = 2_000; +/// Hard safety ceiling for unique entries returned in one search. +pub const HARD_RECORDING_MAX_ENTRIES: usize = 10_000; + +/// Recording stream requested from the camera. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum RecordingStreamKind { + /// Main/high-quality recording stream. + Main, + /// Sub/fluent recording stream. + #[default] + Sub, +} + +impl RecordingStreamKind { + fn as_protocol_str(self) -> &'static str { + match self { + Self::Main => "mainStream", + Self::Sub => "subStream", + } + } +} + +/// Limits and filters for a single same-day recording metadata search. +#[derive(Clone, Debug)] +pub struct RecordingSearchOptions { + /// Logical camera channel. + pub channel: u8, + /// Camera-local inclusive start timestamp. + pub start: FileDateTime, + /// Camera-local inclusive end timestamp. + pub end: FileDateTime, + /// Recording stream to search. + pub stream: RecordingStreamKind, + /// Comma-separated recording classes accepted by the camera. + pub record_types: String, + /// Maximum page requests for this search. + pub max_pages: usize, + /// Maximum unique entries retained for this search. + pub max_entries: usize, +} + +impl Default for RecordingSearchOptions { + fn default() -> Self { + Self { + channel: 0, + start: FileDateTime::default(), + end: FileDateTime::default(), + stream: RecordingStreamKind::Sub, + record_types: + "manual, sched, io, md, people, face, vehicle, dog_cat, visitor, other, package" + .to_owned(), + max_pages: DEFAULT_RECORDING_MAX_PAGES, + max_entries: DEFAULT_RECORDING_MAX_ENTRIES, + } + } +} + +impl RecordingSearchOptions { + fn validate(&self) -> Result<()> { + if !valid_datetime(self.start) || !valid_datetime(self.end) { + return Err(Error::Other("Invalid recording search timestamp")); + } + if (self.start.year, self.start.month, self.start.day) + != (self.end.year, self.end.month, self.end.day) + { + return Err(Error::Other( + "Recording search start and end must be on the same camera-local day", + )); + } + if self.start > self.end { + return Err(Error::Other( + "Recording search start must not be after its end", + )); + } + if self.record_types.trim().is_empty() { + return Err(Error::Other("Recording search types must not be empty")); + } + if !(1..=HARD_RECORDING_MAX_PAGES).contains(&self.max_pages) { + return Err(Error::Other( + "Recording search max_pages is outside its safety ceiling", + )); + } + if !(1..=HARD_RECORDING_MAX_ENTRIES).contains(&self.max_entries) { + return Err(Error::Other( + "Recording search max_entries is outside its safety ceiling", + )); + } + Ok(()) + } +} + +/// Why a bounded recording search stopped. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RecordingSearchEnd { + /// Camera explicitly marked the result complete. + Finished, + /// Camera returned fewer entries than a normal full page. + ShortPage, + /// Camera returned an explicit empty/end response. + EndOfResults, + /// A page repeated only entries already seen. + Stalled, + /// Caller-provided page ceiling was reached. + PageLimit, + /// Caller-provided unique-entry ceiling was reached. + EntryLimit, +} + +/// One recording metadata entry. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecordingEntry { + /// Camera-provided stable identifier/path, when available. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Camera-provided display name, when available. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Camera-provided filename/path variant, when available. + #[serde(skip_serializing_if = "Option::is_none")] + pub file_name: Option, + /// Camera-provided recording class, when available. + #[serde(skip_serializing_if = "Option::is_none")] + pub record_type: Option, + /// Camera-provided size in bytes, when available. + #[serde(skip_serializing_if = "Option::is_none")] + pub size_bytes: Option, + /// Camera-local recording start, when available. + #[serde(skip_serializing_if = "Option::is_none")] + pub start: Option, + /// Camera-local recording end, when available. + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option, +} + +impl RecordingEntry { + fn from_file_info(value: &FileInfo) -> Option { + if value.id.is_none() && value.name.is_none() && value.file_name.is_none() { + return None; + } + Some(Self { + id: value.id.clone(), + name: value.name.clone(), + file_name: value.file_name.clone(), + record_type: value + .record_type + .clone() + .or_else(|| value.type_.clone()) + .or_else(|| value.alarm_type.clone()), + size_bytes: value.size.or(value.file_size), + start: value.start_time, + end: value.end_time, + }) + } + + fn unique_key(&self) -> &str { + self.id + .as_deref() + .or(self.file_name.as_deref()) + .or(self.name.as_deref()) + .expect("recording entries always have an identifier") + } +} + +/// Bounded recording metadata search result. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecordingSearchResult { + /// Unique recording entries. + pub entries: Vec, + /// Number of page responses consumed. + pub pages: usize, + /// Why pagination stopped. + pub end: RecordingSearchEnd, +} + +impl RecordingSearchResult { + /// Whether the camera indicated a natural end instead of a safety/stall stop. + pub fn complete(&self) -> bool { + matches!( + self.end, + RecordingSearchEnd::Finished + | RecordingSearchEnd::ShortPage + | RecordingSearchEnd::EndOfResults + ) + } + + /// Earliest explicit start timestamp among returned entries. + pub fn earliest(&self) -> Option { + self.entries.iter().filter_map(|entry| entry.start).min() + } + + /// Latest explicit end timestamp among returned entries. + pub fn latest(&self) -> Option { + self.entries.iter().filter_map(|entry| entry.end).max() + } +} + +impl std::fmt::Display for RecordingSearchEnd { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let value = match self { + Self::Finished => "finished", + Self::ShortPage => "short_page", + Self::EndOfResults => "end_of_results", + Self::Stalled => "stalled", + Self::PageLimit => "page_limit", + Self::EntryLimit => "entry_limit", + }; + f.write_str(value) + } +} + +#[derive(Debug)] +enum FileInfoCommandReply { + Xml(FileInfoList), + Empty, + End, +} + +#[derive(Clone)] +struct SearchRequest { + uid: String, + options: RecordingSearchOptions, +} + +impl BcCamera { + /// List stored recording metadata without downloading recording content. + /// + /// FileInfoList searches are scoped to one camera-local calendar day. + /// The server-side cursor is always closed after a successful OPEN, including + /// when pagination fails or reaches a configured safety ceiling. + pub async fn search_recordings( + &self, + uid: &str, + options: RecordingSearchOptions, + ) -> Result { + options.validate()?; + if uid.trim().is_empty() { + return Err(Error::Other("Recording search requires a camera UID")); + } + let request = SearchRequest { + uid: uid.to_owned(), + options, + }; + execute_search(request, |msg_id, payload| { + self.send_file_info_list(msg_id, payload) + }) + .await + } + + async fn send_file_info_list( + &self, + msg_id: u32, + file_info_list: FileInfoList, + ) -> Result { + let connection = self.get_connection(); + let msg_num = self.new_message_num(); + let mut subscription = connection.subscribe(msg_id, msg_num).await?; + subscription + .send(Bc { + meta: BcMeta { + msg_id, + channel_id: FILE_INFO_LIST_HOST_CHANNEL, + msg_num, + response_code: 0, + stream_type: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: Some(BcPayloads::BcXml(BcXml { + file_info_list: Some(file_info_list), + ..Default::default() + })), + }), + }) + .await?; + + let reply = tokio::time::timeout(RECORDING_REPLY_TIMEOUT, subscription.recv()) + .await + .map_err(|_| Error::TimeoutDisconnected)??; + let response_code = reply.meta.response_code; + let payload = match reply.body { + BcBody::ModernMsg(ModernMsg { payload, .. }) => payload, + _ => { + return Err(Error::Other( + "FileInfoList camera reply was not a modern message", + )) + } + }; + + if msg_id == MSG_ID_FILE_INFO_LIST_GET && response_code == 400 && payload.is_none() { + return Ok(FileInfoCommandReply::End); + } + if response_code != 200 { + return Err(Error::CameraServiceUnavailable { + id: msg_id, + code: response_code, + }); + } + + match payload { + Some(BcPayloads::BcXml(BcXml { + file_info_list: Some(list), + .. + })) => Ok(FileInfoCommandReply::Xml(list)), + None => Ok(FileInfoCommandReply::Empty), + _ => Err(Error::Other( + "FileInfoList camera reply had an unexpected payload", + )), + } + } +} + +async fn execute_search( + request: SearchRequest, + mut send: F, +) -> Result +where + F: FnMut(u32, FileInfoList) -> Fut, + Fut: Future>, +{ + let open_reply = send(MSG_ID_FILE_INFO_LIST_OPEN, build_open_request(&request)).await?; + let handle = match open_reply { + FileInfoCommandReply::Xml(list) => find_handle(&list), + FileInfoCommandReply::Empty | FileInfoCommandReply::End => None, + } + .ok_or(Error::Other( + "FileInfoList open response did not contain a cursor handle", + ))?; + let page_request = build_page_request(&request, handle); + + let search_result = paginate(&request.options, &page_request, &mut send).await; + let close_result = send(MSG_ID_FILE_INFO_LIST_CLOSE, page_request).await; + + match (search_result, close_result) { + (Ok(result), Ok(FileInfoCommandReply::Xml(_) | FileInfoCommandReply::Empty)) => Ok(result), + (Ok(_), Ok(FileInfoCommandReply::End)) => Err(Error::RecordingCloseFailed { + close: Arc::new(Error::Other( + "FileInfoList close returned an end-of-results response", + )), + }), + (Ok(_), Err(close)) => Err(Error::RecordingCloseFailed { + close: Arc::new(close), + }), + (Err(search), Ok(_)) => Err(search), + (Err(search), Err(close)) => Err(Error::RecordingSearchAndCloseFailed { + search: Arc::new(search), + close: Arc::new(close), + }), + } +} + +async fn paginate( + options: &RecordingSearchOptions, + page_request: &FileInfoList, + send: &mut F, +) -> Result +where + F: FnMut(u32, FileInfoList) -> Fut, + Fut: Future>, +{ + let mut entries = Vec::new(); + let mut seen = HashSet::new(); + + for page_index in 0..options.max_pages { + let response = send(MSG_ID_FILE_INFO_LIST_GET, page_request.clone()).await?; + let pages = page_index + 1; + let list = match response { + FileInfoCommandReply::End => { + return Ok(RecordingSearchResult { + entries, + pages, + end: RecordingSearchEnd::EndOfResults, + }) + } + FileInfoCommandReply::Empty => { + return Ok(RecordingSearchResult { + entries, + pages, + end: RecordingSearchEnd::ShortPage, + }) + } + FileInfoCommandReply::Xml(list) => list, + }; + + let finished = is_finished(&list); + let mut page_entries = Vec::new(); + collect_entries(&list, &mut page_entries); + let raw_page_len = page_entries.len(); + let before = entries.len(); + + for entry in page_entries { + if seen.insert(entry.unique_key().to_owned()) { + if entries.len() == options.max_entries { + return Ok(RecordingSearchResult { + entries, + pages, + end: RecordingSearchEnd::EntryLimit, + }); + } + entries.push(entry); + } + } + + if finished { + return Ok(RecordingSearchResult { + entries, + pages, + end: RecordingSearchEnd::Finished, + }); + } + if raw_page_len < TYPICAL_PAGE_SIZE { + return Ok(RecordingSearchResult { + entries, + pages, + end: RecordingSearchEnd::ShortPage, + }); + } + if entries.len() == before { + return Ok(RecordingSearchResult { + entries, + pages, + end: RecordingSearchEnd::Stalled, + }); + } + } + + Ok(RecordingSearchResult { + entries, + pages: options.max_pages, + end: RecordingSearchEnd::PageLimit, + }) +} + +fn build_open_request(request: &SearchRequest) -> FileInfoList { + FileInfoList { + version: Some(FILE_INFO_LIST_VERSION.to_owned()), + file_info: vec![FileInfo { + uid: Some(request.uid.clone()), + search_ai_track: Some(1), + channel_id: Some(request.options.channel), + logic_chn_bitmap: Some(255), + stream_type: Some(request.options.stream.as_protocol_str().to_owned()), + record_type: Some(request.options.record_types.clone()), + start_time: Some(request.options.start), + end_time: Some(request.options.end), + ..Default::default() + }], + ..Default::default() + } +} + +fn build_page_request(request: &SearchRequest, handle: u32) -> FileInfoList { + FileInfoList { + version: Some(FILE_INFO_LIST_VERSION.to_owned()), + file_info: vec![FileInfo { + uid: Some(request.uid.clone()), + search_ai_track: Some(1), + channel_id: Some(request.options.channel), + handle: Some(handle), + ..Default::default() + }], + ..Default::default() + } +} + +fn find_handle(list: &FileInfoList) -> Option { + list.file_info.iter().find_map(find_handle_in_entry) +} + +fn find_handle_in_entry(entry: &FileInfo) -> Option { + entry.handle.or_else(|| { + entry + .file + .iter() + .find_map(find_handle_in_entry) + .or_else(|| { + entry + .file_list + .as_ref() + .or(entry.file_list_upper.as_ref()) + .and_then(|nested| { + nested + .file + .iter() + .chain(nested.file_info.iter()) + .find_map(find_handle_in_entry) + }) + }) + }) +} + +fn collect_entries(list: &FileInfoList, entries: &mut Vec) { + for entry in list.file_info.iter().chain(list.file.iter()) { + collect_entry(entry, entries); + } +} + +fn collect_entry(value: &FileInfo, entries: &mut Vec) { + if let Some(entry) = RecordingEntry::from_file_info(value) { + entries.push(entry); + } + for child in &value.file { + collect_entry(child, entries); + } + for list in [value.file_list.as_ref(), value.file_list_upper.as_ref()] + .into_iter() + .flatten() + { + for child in list.file.iter().chain(list.file_info.iter()) { + collect_entry(child, entries); + } + } +} + +fn is_finished(list: &FileInfoList) -> bool { + list.b_finished == Some(1) + || list.finished == Some(1) + || list.file_info.iter().any(is_entry_finished) +} + +fn is_entry_finished(entry: &FileInfo) -> bool { + entry.b_finished == Some(1) + || entry.finished == Some(1) + || entry.file.iter().any(is_entry_finished) + || [entry.file_list.as_ref(), entry.file_list_upper.as_ref()] + .into_iter() + .flatten() + .flat_map(|list| list.file.iter().chain(list.file_info.iter())) + .any(is_entry_finished) +} + +fn valid_datetime(value: FileDateTime) -> bool { + value.year >= 2000 + && (1..=12).contains(&value.month) + && (1..=days_in_month(value.year, value.month)).contains(&value.day) + && value.hour <= 23 + && value.minute <= 59 + && value.second <= 59 +} + +// Keep the arithmetic form compatible with Neolink's existing Rust 2021 toolchain. +#[allow(clippy::manual_is_multiple_of)] +fn days_in_month(year: u16, month: u8) -> u8 { + match month { + 4 | 6 | 9 | 11 => 30, + 2 if year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) => 29, + 2 => 28, + _ => 31, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{cell::RefCell, collections::VecDeque, future::ready, rc::Rc}; + + fn timestamp(hour: u8) -> FileDateTime { + FileDateTime { + year: 2026, + month: 7, + day: 30, + hour, + minute: 0, + second: 0, + } + } + + fn options() -> RecordingSearchOptions { + RecordingSearchOptions { + channel: 0, + start: timestamp(0), + end: FileDateTime { + hour: 23, + minute: 59, + second: 59, + ..timestamp(0) + }, + stream: RecordingStreamKind::Sub, + record_types: "md, people".to_owned(), + max_pages: 4, + max_entries: 200, + } + } + + fn request() -> SearchRequest { + SearchRequest { + uid: "FIXTUREUID".to_owned(), + options: options(), + } + } + + fn open_reply() -> FileInfoCommandReply { + FileInfoCommandReply::Xml(FileInfoList { + version: Some("1.1".to_owned()), + file_info: vec![FileInfo { + handle: Some(17), + ..Default::default() + }], + ..Default::default() + }) + } + + fn page(first: usize, count: usize, finished: bool) -> FileInfoCommandReply { + FileInfoCommandReply::Xml(FileInfoList { + version: Some("1.1".to_owned()), + file_info: (first..first + count) + .map(|index| FileInfo { + id: Some(format!("/fixture/recording-{index}.mp4")), + name: Some(format!("recording-{index}.mp4")), + start_time: Some(timestamp((index % 24) as u8)), + end_time: Some(FileDateTime { + minute: 1, + ..timestamp((index % 24) as u8) + }), + ..Default::default() + }) + .collect(), + b_finished: finished.then_some(1), + ..Default::default() + }) + } + + async fn run_mock( + request: SearchRequest, + replies: Vec>, + ) -> (Result, Vec) { + let replies = Rc::new(RefCell::new(VecDeque::from(replies))); + let calls = Rc::new(RefCell::new(Vec::new())); + let result = execute_search(request, { + let replies = replies.clone(); + let calls = calls.clone(); + move |msg_id, _payload| { + calls.borrow_mut().push(msg_id); + ready( + replies + .borrow_mut() + .pop_front() + .expect("mock reply for every command"), + ) + } + }) + .await; + let calls = calls.borrow().clone(); + (result, calls) + } + + #[test] + fn query_validation_rejects_bad_dates_and_limits() { + let mut value = options(); + value.end.day = 31; + assert!(value.validate().is_err()); + + let mut value = options(); + value.max_pages = HARD_RECORDING_MAX_PAGES + 1; + assert!(value.validate().is_err()); + + let mut value = options(); + value.max_entries = 0; + assert!(value.validate().is_err()); + } + + #[test] + fn open_request_contains_channel_date_and_uid() { + let request = request(); + let open = build_open_request(&request); + let info = &open.file_info[0]; + assert_eq!(info.uid.as_deref(), Some("FIXTUREUID")); + assert_eq!(info.channel_id, Some(0)); + assert_eq!(info.stream_type.as_deref(), Some("subStream")); + assert_eq!(info.start_time, Some(timestamp(0))); + } + + #[tokio::test] + async fn empty_page_stops_and_closes_cursor() { + let (result, calls) = run_mock( + request(), + vec![ + Ok(open_reply()), + Ok(FileInfoCommandReply::Xml(FileInfoList::default())), + Ok(FileInfoCommandReply::Empty), + ], + ) + .await; + let result = result.unwrap(); + assert!(result.entries.is_empty()); + assert_eq!(result.pages, 1); + assert_eq!(result.end, RecordingSearchEnd::ShortPage); + assert_eq!( + calls, + vec![ + MSG_ID_FILE_INFO_LIST_OPEN, + MSG_ID_FILE_INFO_LIST_GET, + MSG_ID_FILE_INFO_LIST_CLOSE + ] + ); + } + + #[tokio::test] + async fn duplicate_full_page_stops_as_stalled() { + let (result, calls) = run_mock( + request(), + vec![ + Ok(open_reply()), + Ok(page(0, TYPICAL_PAGE_SIZE, false)), + Ok(page(0, TYPICAL_PAGE_SIZE, false)), + Ok(FileInfoCommandReply::Empty), + ], + ) + .await; + let result = result.unwrap(); + assert_eq!(result.entries.len(), TYPICAL_PAGE_SIZE); + assert_eq!(result.pages, 2); + assert_eq!(result.end, RecordingSearchEnd::Stalled); + assert_eq!(calls.last(), Some(&MSG_ID_FILE_INFO_LIST_CLOSE)); + } + + #[tokio::test] + async fn unfinished_page_continues_until_explicit_end() { + let (result, calls) = run_mock( + request(), + vec![ + Ok(open_reply()), + Ok(page(0, TYPICAL_PAGE_SIZE, false)), + Ok(FileInfoCommandReply::End), + Ok(FileInfoCommandReply::Empty), + ], + ) + .await; + let result = result.unwrap(); + assert_eq!(result.entries.len(), TYPICAL_PAGE_SIZE); + assert_eq!(result.pages, 2); + assert_eq!(result.end, RecordingSearchEnd::EndOfResults); + assert_eq!(calls.last(), Some(&MSG_ID_FILE_INFO_LIST_CLOSE)); + } + + #[tokio::test] + async fn page_ceiling_is_bounded_and_cursor_is_closed() { + let mut request = request(); + request.options.max_pages = 2; + let (result, calls) = run_mock( + request, + vec![ + Ok(open_reply()), + Ok(page(0, TYPICAL_PAGE_SIZE, false)), + Ok(page(TYPICAL_PAGE_SIZE, TYPICAL_PAGE_SIZE, false)), + Ok(FileInfoCommandReply::Empty), + ], + ) + .await; + let result = result.unwrap(); + assert_eq!(result.entries.len(), TYPICAL_PAGE_SIZE * 2); + assert_eq!(result.end, RecordingSearchEnd::PageLimit); + assert_eq!(calls.last(), Some(&MSG_ID_FILE_INFO_LIST_CLOSE)); + } + + #[tokio::test] + async fn entry_ceiling_is_bounded_and_cursor_is_closed() { + let mut request = request(); + request.options.max_entries = 3; + let (result, calls) = run_mock( + request, + vec![ + Ok(open_reply()), + Ok(page(0, TYPICAL_PAGE_SIZE, false)), + Ok(FileInfoCommandReply::Empty), + ], + ) + .await; + let result = result.unwrap(); + assert_eq!(result.entries.len(), 3); + assert_eq!(result.end, RecordingSearchEnd::EntryLimit); + assert_eq!(calls.last(), Some(&MSG_ID_FILE_INFO_LIST_CLOSE)); + } + + #[tokio::test] + async fn get_error_still_closes_cursor() { + let (result, calls) = run_mock( + request(), + vec![ + Ok(open_reply()), + Err(Error::Other("fixture GET error")), + Ok(FileInfoCommandReply::Empty), + ], + ) + .await; + assert!(matches!(result, Err(Error::Other("fixture GET error")))); + assert_eq!(calls.last(), Some(&MSG_ID_FILE_INFO_LIST_CLOSE)); + } + + #[tokio::test] + async fn close_error_after_success_is_reported() { + let (result, calls) = run_mock( + request(), + vec![ + Ok(open_reply()), + Ok(page(0, 1, true)), + Err(Error::Other("fixture CLOSE error")), + ], + ) + .await; + assert!(matches!(result, Err(Error::RecordingCloseFailed { .. }))); + assert_eq!(calls.last(), Some(&MSG_ID_FILE_INFO_LIST_CLOSE)); + } + + #[tokio::test] + async fn search_and_close_errors_are_both_preserved() { + let (result, calls) = run_mock( + request(), + vec![ + Ok(open_reply()), + Err(Error::Other("fixture GET error")), + Err(Error::Other("fixture CLOSE error")), + ], + ) + .await; + assert!(matches!( + result, + Err(Error::RecordingSearchAndCloseFailed { .. }) + )); + assert_eq!(calls.last(), Some(&MSG_ID_FILE_INFO_LIST_CLOSE)); + } +} diff --git a/docs/bc-protocol.md b/docs/bc-protocol.md index 110f6df5..35be2062 100644 --- a/docs/bc-protocol.md +++ b/docs/bc-protocol.md @@ -116,6 +116,7 @@ Implemented in `crates/core/src/bc/crypto.rs`. | 4 | VIDEO_STOP | 151 | ABILITY_INFO | | 10 | TALKABILITY | 201 | TALKCONFIG | | 11 | TALKRESET | 202 | TALK | +| 14/15/16 | FILE_INFO_LIST OPEN / GET / CLOSE | | | | 18/19 | PTZ_CONTROL / _PRESET | 208/209 | GET/SET_LED_STATUS | | 23 | REBOOT | 212/213 | GET/START_PIR_ALARM | | 31/33 | MOTION_REQUEST / MOTION | 234 | UDP_KEEP_ALIVE | @@ -127,6 +128,21 @@ Implemented in `crates/core/src/bc/crypto.rs`. (See `model.rs` for the full list.) +### `FILE_INFO_LIST` (14/15/16) — stored recording metadata + +Stored recording searches are a cursor flow: + +1. `OPEN` (14) submits one camera-local, same-day range and returns a handle. +2. `GET` (15) returns paginated `FileInfo`/`File` metadata. +3. `CLOSE` (16) releases the handle. + +The request keeps the logical channel inside the XML payload and uses host +channel `250` in the BC header. A cursor is always closed after a successful +OPEN, including when pagination fails or hits a configured page/entry ceiling. +A header-only `400` reply to GET is treated as end-of-results; other non-200 +responses remain errors. Recording XML is redacted from parse-error logs because +it can contain device UID and recording paths. + ### `GET_ENC` (56) — live encoder config Returns the camera's **current** per-stream encoder settings as a `` diff --git a/src/cmdline.rs b/src/cmdline.rs index 7937e75d..1eae47c6 100644 --- a/src/cmdline.rs +++ b/src/cmdline.rs @@ -23,6 +23,7 @@ pub enum Command { Reboot(super::reboot::Opt), Pir(super::pir::Opt), Ptz(super::ptz::Opt), + Recordings(super::recordings::Opt), #[cfg(feature = "gstreamer")] Talk(super::talk::Opt), Mqtt(super::mqtt::Opt), diff --git a/src/main.rs b/src/main.rs index ec22074a..6b5ca686 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,7 @@ mod mqtt; mod pir; mod ptz; mod reboot; +mod recordings; #[cfg(feature = "gstreamer")] mod rtsp; mod services; @@ -125,6 +126,9 @@ async fn main() -> Result<()> { Some(Command::Ptz(opts)) => { ptz::main(opts, neo_reactor.clone()).await?; } + Some(Command::Recordings(opts)) => { + recordings::main(opts, neo_reactor.clone()).await?; + } #[cfg(feature = "gstreamer")] Some(Command::Talk(opts)) => { talk::main(opts, neo_reactor.clone()).await?; diff --git a/src/recordings/cmdline.rs b/src/recordings/cmdline.rs new file mode 100644 index 00000000..9d6ac3d8 --- /dev/null +++ b/src/recordings/cmdline.rs @@ -0,0 +1,39 @@ +use clap::{Parser, ValueEnum}; + +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +pub enum CmdStream { + Main, + #[default] + Sub, +} + +/// List stored recording metadata without downloading footage. +#[derive(Parser, Debug)] +pub struct Opt { + /// Camera name from the Neolink configuration. + pub camera: String, + + /// Camera-local calendar date in YYYY-MM-DD format. + #[arg(long)] + pub date: String, + + /// Override the camera config's logical channel. + #[arg(long)] + pub channel: Option, + + /// Recording stream to search. + #[arg(long, value_enum, default_value_t)] + pub stream: CmdStream, + + /// Maximum FileInfoList pages requested. + #[arg(long, default_value_t = neolink_core::bc_protocol::DEFAULT_RECORDING_MAX_PAGES)] + pub max_pages: usize, + + /// Maximum unique entries retained. + #[arg(long, default_value_t = neolink_core::bc_protocol::DEFAULT_RECORDING_MAX_ENTRIES)] + pub max_entries: usize, + + /// Emit JSON including recording identifiers. Credentials and raw XML are never included. + #[arg(long)] + pub json: bool, +} diff --git a/src/recordings/mod.rs b/src/recordings/mod.rs new file mode 100644 index 00000000..15bc518a --- /dev/null +++ b/src/recordings/mod.rs @@ -0,0 +1,201 @@ +use anyhow::{bail, Context, Result}; +use neolink_core::{ + bc::xml::FileDateTime, + bc_protocol::{RecordingSearchOptions, RecordingSearchResult, RecordingStreamKind}, +}; +use serde_json::json; + +use crate::common::NeoReactor; + +mod cmdline; +use cmdline::CmdStream; +pub(crate) use cmdline::Opt; + +/// Run one bounded, read-only FileInfoList recording metadata search. +pub(crate) async fn main(opt: Opt, reactor: NeoReactor) -> Result<()> { + let (year, month, day) = parse_date(&opt.date)?; + let camera = reactor.get(&opt.camera).await?; + let camera_config = camera.config().await?.borrow().clone(); + let uid = camera_config + .camera_uid + .context("Recording search requires a camera UID")?; + let channel = opt.channel.unwrap_or(camera_config.channel_id); + if channel > 31 { + bail!("Recording channel must be between 0 and 31"); + } + + let options = RecordingSearchOptions { + channel, + start: FileDateTime { + year, + month, + day, + hour: 0, + minute: 0, + second: 0, + }, + end: FileDateTime { + year, + month, + day, + hour: 23, + minute: 59, + second: 59, + }, + stream: match opt.stream { + CmdStream::Main => RecordingStreamKind::Main, + CmdStream::Sub => RecordingStreamKind::Sub, + }, + max_pages: opt.max_pages, + max_entries: opt.max_entries, + ..Default::default() + }; + + let result = camera + .run_task(|cam| { + let uid = uid.clone(); + let options = options.clone(); + Box::pin(async move { Ok(cam.search_recordings(&uid, options).await?) }) + }) + .await + .context("Unable to list recording metadata")?; + + if opt.json { + print_json(&opt.camera, channel, &opt.date, result)?; + } else { + print_summary(&opt.camera, channel, &opt.date, &result); + } + Ok(()) +} + +fn print_summary(camera: &str, channel: u8, date: &str, result: &RecordingSearchResult) { + println!( + "recordings camera={camera} channel={channel} date={date} count={} pages={} complete={} end={} earliest={} latest={}", + result.entries.len(), + result.pages, + result.complete(), + result.end, + format_time(result.earliest()), + format_time(result.latest()) + ); +} + +fn print_json(camera: &str, channel: u8, date: &str, result: RecordingSearchResult) -> Result<()> { + println!( + "{}", + serde_json::to_string(&json_value(camera, channel, date, result))? + ); + Ok(()) +} + +fn json_value( + camera: &str, + channel: u8, + date: &str, + result: RecordingSearchResult, +) -> serde_json::Value { + json!({ + "camera": camera, + "channel": channel, + "date": date, + "count": result.entries.len(), + "pages": result.pages, + "complete": result.complete(), + "end": result.end, + "earliest": result.earliest(), + "latest": result.latest(), + "entries": result.entries, + }) +} + +fn format_time(value: Option) -> String { + match value { + Some(value) => format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", + value.year, value.month, value.day, value.hour, value.minute, value.second + ), + None => "none".to_owned(), + } +} + +fn parse_date(input: &str) -> Result<(u16, u8, u8)> { + let mut parts = input.split('-'); + let year = parts + .next() + .context("Date must be YYYY-MM-DD")? + .parse::() + .context("Invalid date year")?; + let month = parts + .next() + .context("Date must be YYYY-MM-DD")? + .parse::() + .context("Invalid date month")?; + let day = parts + .next() + .context("Date must be YYYY-MM-DD")? + .parse::() + .context("Invalid date day")?; + if parts.next().is_some() + || year < 2000 + || !(1..=12).contains(&month) + || !(1..=days_in_month(year, month)).contains(&day) + { + bail!("Date must be a valid YYYY-MM-DD value"); + } + Ok((year, month, day)) +} + +// Keep the arithmetic form compatible with Neolink's existing Rust 2021 toolchain. +#[allow(clippy::manual_is_multiple_of)] +fn days_in_month(year: u16, month: u8) -> u8 { + match month { + 4 | 6 | 9 | 11 => 30, + 2 if year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) => 29, + 2 => 28, + _ => 31, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use neolink_core::bc_protocol::{RecordingEntry, RecordingSearchEnd}; + + #[test] + fn date_parser_handles_leap_years() { + assert_eq!(parse_date("2024-02-29").unwrap(), (2024, 2, 29)); + assert!(parse_date("2026-02-29").is_err()); + } + + #[test] + fn date_parser_rejects_malformed_values() { + assert!(parse_date("2026-07").is_err()); + assert!(parse_date("2026-13-01").is_err()); + assert!(parse_date("anything").is_err()); + } + + #[test] + fn json_output_excludes_credentials_uid_and_raw_xml() { + let result = RecordingSearchResult { + entries: vec![RecordingEntry { + id: Some("fixture-id".to_owned()), + name: Some("fixture-name".to_owned()), + file_name: Some("/fixture/clip.mp4".to_owned()), + record_type: Some("md".to_owned()), + size_bytes: Some(123), + start: None, + end: None, + }], + pages: 1, + end: RecordingSearchEnd::Finished, + }; + let output = + serde_json::to_string(&json_value("fixture-camera", 0, "2026-01-02", result)).unwrap(); + + assert!(!output.contains("password")); + assert!(!output.contains("username")); + assert!(!output.contains("uid")); + assert!(!output.contains("rawXml")); + assert!(!output.contains(" Date: Fri, 31 Jul 2026 01:45:17 -0700 Subject: [PATCH 2/4] fix: harden recording metadata search --- README.md | 15 +- crates/core/src/bc/de.rs | 13 +- crates/core/src/bc/model.rs | 9 + .../samples/file_info_list_alias_datetime.xml | 15 ++ .../bc/samples/file_info_list_page_direct.xml | 10 +- .../bc/samples/file_info_list_page_nested.xml | 4 +- crates/core/src/bc/xml.rs | 184 +++++++++++++++++- .../core/src/bc_protocol/connection/bcconn.rs | 81 +++++++- crates/core/src/bc_protocol/errors.rs | 6 +- crates/core/src/bc_protocol/recordings.rs | 154 +++++++++++---- docs/bc-protocol.md | 11 +- src/main.rs | 4 +- src/recordings/mod.rs | 6 +- 13 files changed, 437 insertions(+), 75 deletions(-) create mode 100644 crates/core/src/bc/samples/file_info_list_alias_datetime.xml diff --git a/README.md b/README.md index e53aaf59..95c6b920 100644 --- a/README.md +++ b/README.md @@ -734,27 +734,28 @@ With 1.0 being normal and 2.5 being 2.5x zoom ### Stored recording metadata -You can list recording metadata from a camera's SD card or an NVR channel without -downloading footage: +You can list recording metadata reported by a camera for one logical channel +without downloading footage: ```bash # Privacy-conscious summary only (no filenames or recording paths) -neolink recordings --config=config.toml CameraName --date 2026-07-30 +neolink recordings --config=config.toml CameraName --date 2026-01-02 # Override the configured logical channel and request the main recording stream -neolink recordings --config=config.toml CameraName --date 2026-07-30 \ +neolink recordings --config=config.toml CameraName --date 2026-01-02 \ --channel 1 --stream main # Machine-readable results, including camera-provided recording identifiers -neolink recordings --config=config.toml CameraName --date 2026-07-30 --json +neolink recordings --config=config.toml CameraName --date 2026-01-02 --json ``` Queries use the camera's local calendar date and are deliberately bounded. Use `--max-pages` and `--max-entries` to lower the defaults; both also have hard safety ceilings. The default text output reports only counts, pagination state, and the earliest/latest timestamps. JSON never includes the camera UID, -credentials, or raw protocol XML, but it does include recording names/paths -needed by later playback integrations. +credentials, or raw protocol XML. It includes the camera-provided identifiers +and any name/path fields already present in the response; the command does not +perform separate filename enrichment. This command only lists metadata. It does not replay or download recordings. diff --git a/crates/core/src/bc/de.rs b/crates/core/src/bc/de.rs index 6427f37c..a2c36f09 100644 --- a/crates/core/src/bc/de.rs +++ b/crates/core/src/bc/de.rs @@ -20,13 +20,6 @@ type IResult> = Result<(I, O), no /// malicious lengths. const MAX_BODY_LEN: u32 = 16 * 1024 * 1024; -fn is_file_info_list_message(msg_id: u32) -> bool { - matches!( - msg_id, - MSG_ID_FILE_INFO_LIST_OPEN | MSG_ID_FILE_INFO_LIST_GET | MSG_ID_FILE_INFO_LIST_CLOSE - ) -} - impl Bc { /// Returns Ok(deserialized data, the amount of data consumed) /// Can then use this as the amount that should be remove from a buffer @@ -132,7 +125,7 @@ fn bc_modern_msg<'a>( // Now we'll take the buffer that Nom gave a ref to and parse it. let extension = if ext_len > 0 { if context.debug { - println!( + log::debug!( "Extension Txt: {:?}", String::from_utf8(processed_ext_buf.to_vec()).unwrap_or("Not Text".to_string()) ); @@ -225,12 +218,12 @@ fn bc_modern_msg<'a>( } else { if context.debug { if is_file_info_list_message(header.msg_id) { - println!( + log::debug!( "Payload Txt: ", processed_payload_buf.len() ); } else { - println!( + log::debug!( "Payload Txt: {:?}", String::from_utf8(processed_payload_buf.to_vec()) .unwrap_or("Not Text".to_string()) diff --git a/crates/core/src/bc/model.rs b/crates/core/src/bc/model.rs index 12daa8f7..c624274b 100644 --- a/crates/core/src/bc/model.rs +++ b/crates/core/src/bc/model.rs @@ -28,6 +28,15 @@ pub const MSG_ID_FILE_INFO_LIST_OPEN: u32 = 14; pub const MSG_ID_FILE_INFO_LIST_GET: u32 = 15; /// Close a FileInfoList recording metadata search pub const MSG_ID_FILE_INFO_LIST_CLOSE: u32 = 16; + +/// Whether a message ID belongs to the privacy-sensitive FileInfoList flow. +pub(crate) fn is_file_info_list_message(msg_id: u32) -> bool { + matches!( + msg_id, + MSG_ID_FILE_INFO_LIST_OPEN | MSG_ID_FILE_INFO_LIST_GET | MSG_ID_FILE_INFO_LIST_CLOSE + ) +} + /// PtzControl messages have this ID pub const MSG_ID_PTZ_CONTROL: u32 = 18; /// PTZ goto preset position diff --git a/crates/core/src/bc/samples/file_info_list_alias_datetime.xml b/crates/core/src/bc/samples/file_info_list_alias_datetime.xml new file mode 100644 index 00000000..3f45cc59 --- /dev/null +++ b/crates/core/src/bc/samples/file_info_list_alias_datetime.xml @@ -0,0 +1,15 @@ + + + + +/fixture/channel0/uppercase-id.mp4 +2026-01-02 03:04:05 +20260102030406 + + +/fixture/channel0/lowercase-id.mp4 +2026/01/02T04:05:06 +202612457 + + + diff --git a/crates/core/src/bc/samples/file_info_list_page_direct.xml b/crates/core/src/bc/samples/file_info_list_page_direct.xml index 9c38dc4d..d3371af9 100644 --- a/crates/core/src/bc/samples/file_info_list_page_direct.xml +++ b/crates/core/src/bc/samples/file_info_list_page_direct.xml @@ -4,18 +4,20 @@ /fixture/channel0/recording-a.mp4 recording-a.mp4 +sched people +vehicle 1234 -2026730123 -2026730134 +202612123 +202612134 ignored /fixture/channel0/recording-b.mp4 vehicle 5678 -2026730234 -2026730245 +202612234 +202612245 1 diff --git a/crates/core/src/bc/samples/file_info_list_page_nested.xml b/crates/core/src/bc/samples/file_info_list_page_nested.xml index 2f96ee97..eae91866 100644 --- a/crates/core/src/bc/samples/file_info_list_page_nested.xml +++ b/crates/core/src/bc/samples/file_info_list_page_nested.xml @@ -7,8 +7,8 @@ /fixture/channel1/recording-c.mp4 recording-c.mp4 md -2026730345 -2026730356 +202612345 +202612356 diff --git a/crates/core/src/bc/xml.rs b/crates/core/src/bc/xml.rs index 13b67692..c3276454 100644 --- a/crates/core/src/bc/xml.rs +++ b/crates/core/src/bc/xml.rs @@ -1,7 +1,10 @@ #![allow(non_snake_case)] -use serde::{Deserialize, Serialize}; -use std::{io::BufRead, io::Write}; +use serde::{ + de::{self, IgnoredAny, MapAccess, Visitor}, + Deserialize, Deserializer, Serialize, +}; +use std::{fmt, io::BufRead, io::Write}; #[cfg(test)] use indoc::indoc; @@ -235,7 +238,12 @@ pub struct FileInfo { #[serde(skip_serializing_if = "Option::is_none")] pub handle: Option, /// Recording identifier. - #[serde(rename = "Id", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "Id", + alias = "ID", + alias = "id", + skip_serializing_if = "Option::is_none" + )] pub id: Option, /// Recording name. #[serde(skip_serializing_if = "Option::is_none")] @@ -278,7 +286,7 @@ pub struct FileResultList { } /// Camera-local date/time fields used by FileInfoList. -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Debug, Deserialize, Serialize)] +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Debug, Serialize)] pub struct FileDateTime { /// Year. pub year: u16, @@ -294,6 +302,140 @@ pub struct FileDateTime { pub second: u8, } +impl FileDateTime { + fn from_text(value: &str) -> Result { + let digits = value + .chars() + .filter(|character| character.is_ascii_digit()) + .collect::(); + if digits.len() != 14 { + return Err("expected a 14-digit YYYYMMDDhhmmss timestamp"); + } + let parse = |range: std::ops::Range| { + digits + .get(range) + .ok_or("invalid timestamp boundaries")? + .parse() + .map_err(|_| "timestamp component was not numeric") + }; + Ok(Self { + year: parse(0..4)?, + month: parse(4..6)? + .try_into() + .map_err(|_| "month did not fit in an unsigned byte")?, + day: parse(6..8)? + .try_into() + .map_err(|_| "day did not fit in an unsigned byte")?, + hour: parse(8..10)? + .try_into() + .map_err(|_| "hour did not fit in an unsigned byte")?, + minute: parse(10..12)? + .try_into() + .map_err(|_| "minute did not fit in an unsigned byte")?, + second: parse(12..14)? + .try_into() + .map_err(|_| "second did not fit in an unsigned byte")?, + }) + } +} + +impl<'de> Deserialize<'de> for FileDateTime { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct FileDateTimeVisitor; + + impl<'de> Visitor<'de> for FileDateTimeVisitor { + type Value = FileDateTime; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .write_str("structured date/time fields or a YYYYMMDDhhmmss-style timestamp") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + FileDateTime::from_text(value).map_err(E::custom) + } + + fn visit_string(self, value: String) -> Result + where + E: de::Error, + { + self.visit_str(&value) + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut year = None; + let mut month = None; + let mut day = None; + let mut hour = None; + let mut minute = None; + let mut second = None; + let mut text: Option = None; + + while let Some(key) = map.next_key::()? { + match key.as_str() { + "year" => set_once(&mut year, map.next_value()?, "year")?, + "month" => set_once(&mut month, map.next_value()?, "month")?, + "day" => set_once(&mut day, map.next_value()?, "day")?, + "hour" => set_once(&mut hour, map.next_value()?, "hour")?, + "minute" => set_once(&mut minute, map.next_value()?, "minute")?, + "second" => set_once(&mut second, map.next_value()?, "second")?, + "$text" => set_once(&mut text, map.next_value()?, "$text")?, + _ => { + map.next_value::()?; + } + } + } + + if let Some(text) = text { + if year.is_some() + || month.is_some() + || day.is_some() + || hour.is_some() + || minute.is_some() + || second.is_some() + { + return Err(de::Error::custom( + "date/time cannot mix text and structured fields", + )); + } + return FileDateTime::from_text(&text).map_err(de::Error::custom); + } + + Ok(FileDateTime { + year: year.ok_or_else(|| de::Error::missing_field("year"))?, + month: month.ok_or_else(|| de::Error::missing_field("month"))?, + day: day.ok_or_else(|| de::Error::missing_field("day"))?, + hour: hour.ok_or_else(|| de::Error::missing_field("hour"))?, + minute: minute.ok_or_else(|| de::Error::missing_field("minute"))?, + second: second.ok_or_else(|| de::Error::missing_field("second"))?, + }) + } + } + + fn set_once(slot: &mut Option, value: T, field: &'static str) -> Result<(), E> + where + E: de::Error, + { + if slot.replace(value).is_some() { + Err(E::duplicate_field(field)) + } else { + Ok(()) + } + } + + deserializer.deserialize_any(FileDateTimeVisitor) + } +} + /// Encryption xml #[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize)] pub struct Encryption { @@ -2252,11 +2394,45 @@ fn test_file_info_list_direct_fixture() { let list = parsed.file_info_list.unwrap(); assert_eq!(list.file_info.len(), 2); assert_eq!(list.file_info[0].size, Some(1234)); + assert_eq!(list.file_info[0].type_.as_deref(), Some("sched")); + assert_eq!(list.file_info[0].record_type.as_deref(), Some("people")); + assert_eq!(list.file_info[0].alarm_type.as_deref(), Some("vehicle")); assert_eq!(list.file_info[1].file_size, Some(5678)); assert_eq!(list.file_info[1].start_time.unwrap().hour, 2); assert_eq!(list.b_finished, Some(1)); } +#[test] +fn test_file_info_list_identifier_aliases_and_text_datetimes() { + let parsed = + BcXml::try_parse(include_bytes!("samples/file_info_list_alias_datetime.xml").as_slice()) + .unwrap(); + let list = parsed.file_info_list.unwrap(); + + assert_eq!( + list.file_info[0].id.as_deref(), + Some("/fixture/channel0/uppercase-id.mp4") + ); + assert_eq!( + list.file_info[0].start_time, + Some(FileDateTime { + year: 2026, + month: 1, + day: 2, + hour: 3, + minute: 4, + second: 5, + }) + ); + assert_eq!(list.file_info[0].end_time.unwrap().second, 6); + assert_eq!( + list.file_info[1].id.as_deref(), + Some("/fixture/channel0/lowercase-id.mp4") + ); + assert_eq!(list.file_info[1].start_time.unwrap().hour, 4); + assert_eq!(list.file_info[1].end_time.unwrap().second, 7); +} + #[test] fn test_file_info_list_nested_fixture() { let parsed = diff --git a/crates/core/src/bc_protocol/connection/bcconn.rs b/crates/core/src/bc_protocol/connection/bcconn.rs index b4d61da3..64ec8c43 100644 --- a/crates/core/src/bc_protocol/connection/bcconn.rs +++ b/crates/core/src/bc_protocol/connection/bcconn.rs @@ -237,6 +237,32 @@ struct Poller { dropped_full: u64, } +struct TraceResponse<'a>(&'a Bc); + +impl std::fmt::Debug for TraceResponse<'_> { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if contains_file_info_list(self.0) { + formatter.write_str("") + } else { + std::fmt::Debug::fmt(self.0, formatter) + } + } +} + +fn contains_file_info_list(response: &Bc) -> bool { + is_file_info_list_message(response.meta.msg_id) + || matches!( + &response.body, + BcBody::ModernMsg(ModernMsg { + payload: Some(BcPayloads::BcXml(BcXml { + file_info_list: Some(_), + .. + })), + .. + }) + ) +} + impl Poller { async fn run(&mut self) -> Result<()> { let cancel = CancellationToken::new(); @@ -378,7 +404,7 @@ impl Poller { msg_id, msg_num ); - trace!("Contents: {:?}", response); + trace!("Contents: {:?}", TraceResponse(&response)); } } (None, None) => { @@ -387,7 +413,7 @@ impl Poller { msg_id, msg_num ); - trace!("Contents: {:?}", response); + trace!("Contents: {:?}", TraceResponse(&response)); } } } @@ -451,7 +477,10 @@ impl Poller { #[cfg(test)] mod tests { use super::*; - use crate::bc::model::{Bc, BcBody, BcMeta, ModernMsg}; + use crate::bc::{ + model::{Bc, BcBody, BcMeta, BcPayloads, ModernMsg}, + xml::{BcXml, FileInfo, FileInfoList}, + }; use tokio::time::{timeout, Duration}; fn make_bc(msg_id: u32, msg_num: u16) -> Bc { @@ -468,6 +497,52 @@ mod tests { } } + #[test] + fn unmatched_recording_responses_are_redacted() { + let response = Bc { + meta: BcMeta { + msg_id: MSG_ID_FILE_INFO_LIST_GET, + ..make_bc(0, 0).meta + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: Some(BcPayloads::BcXml(BcXml { + file_info_list: Some(FileInfoList { + file_info: vec![FileInfo { + id: Some("/fixture/private-recording-id".to_owned()), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + })), + }), + }; + let output = format!("{:?}", TraceResponse(&response)); + + assert_eq!(output, ""); + assert!(!output.contains("private-recording-id")); + } + + #[test] + fn typed_recording_payload_is_redacted_even_with_an_unknown_message_id() { + let response = Bc { + meta: make_bc(999, 0).meta, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: Some(BcPayloads::BcXml(BcXml { + file_info_list: Some(FileInfoList::default()), + ..Default::default() + })), + }), + }; + + assert_eq!( + format!("{:?}", TraceResponse(&response)), + "" + ); + } + /// Regression test for the keepalive-starvation bug (upstream #399): a /// subscriber whose channel is full must NOT block the poll loop. We feed /// many messages to an undrained (capacity-1) subscriber and assert that diff --git a/crates/core/src/bc_protocol/errors.rs b/crates/core/src/bc_protocol/errors.rs index 230be37a..76377c17 100644 --- a/crates/core/src/bc_protocol/errors.rs +++ b/crates/core/src/bc_protocol/errors.rs @@ -248,16 +248,18 @@ pub enum Error { /// A recording search succeeded, but its server-side cursor was not /// acknowledged as closed. - #[error("Recording search cursor close failed")] + #[error("Recording search cursor close failed: {close}")] RecordingCloseFailed { /// The close error, retained for programmatic inspection. + #[source] close: std::sync::Arc, }, /// A recording search failed and its server-side cursor also failed to close. - #[error("Recording search failed and cursor close also failed")] + #[error("Recording search failed: {search}; cursor close also failed: {close}")] RecordingSearchAndCloseFailed { /// The original search error. + #[source] search: std::sync::Arc, /// The close error. close: std::sync::Arc, diff --git a/crates/core/src/bc_protocol/recordings.rs b/crates/core/src/bc_protocol/recordings.rs index 189c2801..52742212 100644 --- a/crates/core/src/bc_protocol/recordings.rs +++ b/crates/core/src/bc_protocol/recordings.rs @@ -92,6 +92,11 @@ impl RecordingSearchOptions { if self.record_types.trim().is_empty() { return Err(Error::Other("Recording search types must not be empty")); } + if self.channel > 31 { + return Err(Error::Other( + "Recording search channel must be between 0 and 31", + )); + } if !(1..=HARD_RECORDING_MAX_PAGES).contains(&self.max_pages) { return Err(Error::Other( "Recording search max_pages is outside its safety ceiling", @@ -161,9 +166,9 @@ impl RecordingEntry { name: value.name.clone(), file_name: value.file_name.clone(), record_type: value - .record_type + .type_ .clone() - .or_else(|| value.type_.clone()) + .or_else(|| value.record_type.clone()) .or_else(|| value.alarm_type.clone()), size_bytes: value.size.or(value.file_size), start: value.start_time, @@ -245,21 +250,22 @@ impl BcCamera { /// List stored recording metadata without downloading recording content. /// /// FileInfoList searches are scoped to one camera-local calendar day. - /// The server-side cursor is always closed after a successful OPEN, including - /// when pagination fails or reaches a configured safety ceiling. + /// After a successful OPEN, a best-effort CLOSE is attempted on every + /// completed code path, including pagination errors and configured safety + /// ceilings. Cancelling the future can interrupt that cleanup. + /// + /// The device UID is resolved through [`Self::uid`]; callers select the + /// logical device channel through [`RecordingSearchOptions::channel`]. pub async fn search_recordings( &self, - uid: &str, options: RecordingSearchOptions, ) -> Result { options.validate()?; + let uid = self.uid().await?; if uid.trim().is_empty() { - return Err(Error::Other("Recording search requires a camera UID")); + return Err(Error::Other("Camera returned an empty UID")); } - let request = SearchRequest { - uid: uid.to_owned(), - options, - }; + let request = SearchRequest { uid, options }; execute_search(request, |msg_id, payload| { self.send_file_info_list(msg_id, payload) }) @@ -402,7 +408,7 @@ where FileInfoCommandReply::Xml(list) => list, }; - let finished = is_finished(&list); + let completion = completion_marker(&list); let mut page_entries = Vec::new(); collect_entries(&list, &mut page_entries); let raw_page_len = page_entries.len(); @@ -421,14 +427,14 @@ where } } - if finished { + if completion == Some(true) { return Ok(RecordingSearchResult { entries, pages, end: RecordingSearchEnd::Finished, }); } - if raw_page_len < TYPICAL_PAGE_SIZE { + if completion.is_none() && raw_page_len < TYPICAL_PAGE_SIZE { return Ok(RecordingSearchResult { entries, pages, @@ -532,21 +538,39 @@ fn collect_entry(value: &FileInfo, entries: &mut Vec) { } } -fn is_finished(list: &FileInfoList) -> bool { - list.b_finished == Some(1) - || list.finished == Some(1) - || list.file_info.iter().any(is_entry_finished) +fn completion_marker(list: &FileInfoList) -> Option { + let mut state = None; + merge_completion_marker(&mut state, list.b_finished); + merge_completion_marker(&mut state, list.finished); + for entry in list.file_info.iter().chain(list.file.iter()) { + merge_entry_completion_marker(&mut state, entry); + } + state +} + +fn merge_entry_completion_marker(state: &mut Option, entry: &FileInfo) { + merge_completion_marker(state, entry.b_finished); + merge_completion_marker(state, entry.finished); + for child in &entry.file { + merge_entry_completion_marker(state, child); + } + for list in [entry.file_list.as_ref(), entry.file_list_upper.as_ref()] + .into_iter() + .flatten() + { + for child in list.file.iter().chain(list.file_info.iter()) { + merge_entry_completion_marker(state, child); + } + } } -fn is_entry_finished(entry: &FileInfo) -> bool { - entry.b_finished == Some(1) - || entry.finished == Some(1) - || entry.file.iter().any(is_entry_finished) - || [entry.file_list.as_ref(), entry.file_list_upper.as_ref()] - .into_iter() - .flatten() - .flat_map(|list| list.file.iter().chain(list.file_info.iter())) - .any(is_entry_finished) +fn merge_completion_marker(state: &mut Option, marker: Option) { + if let Some(marker) = marker { + let finished = marker == 1; + if finished || state.is_none() { + *state = Some(finished); + } + } } fn valid_datetime(value: FileDateTime) -> bool { @@ -577,8 +601,8 @@ mod tests { fn timestamp(hour: u8) -> FileDateTime { FileDateTime { year: 2026, - month: 7, - day: 30, + month: 1, + day: 2, hour, minute: 0, second: 0, @@ -620,7 +644,11 @@ mod tests { }) } - fn page(first: usize, count: usize, finished: bool) -> FileInfoCommandReply { + fn page_with_marker( + first: usize, + count: usize, + completion_marker: Option, + ) -> FileInfoCommandReply { FileInfoCommandReply::Xml(FileInfoList { version: Some("1.1".to_owned()), file_info: (first..first + count) @@ -635,11 +663,15 @@ mod tests { ..Default::default() }) .collect(), - b_finished: finished.then_some(1), + b_finished: completion_marker, ..Default::default() }) } + fn page(first: usize, count: usize, finished: bool) -> FileInfoCommandReply { + page_with_marker(first, count, finished.then_some(1)) + } + async fn run_mock( request: SearchRequest, replies: Vec>, @@ -677,6 +709,10 @@ mod tests { let mut value = options(); value.max_entries = 0; assert!(value.validate().is_err()); + + let mut value = options(); + value.channel = 32; + assert!(value.validate().is_err()); } #[test] @@ -690,6 +726,28 @@ mod tests { assert_eq!(info.start_time, Some(timestamp(0))); } + #[test] + fn recording_type_prefers_type_then_record_type_then_alarm_type() { + let entry = RecordingEntry::from_file_info(&FileInfo { + type_: Some("sched".to_owned()), + record_type: Some("people".to_owned()), + alarm_type: Some("vehicle".to_owned()), + id: Some("/fixture/preference.mp4".to_owned()), + ..Default::default() + }) + .unwrap(); + assert_eq!(entry.record_type.as_deref(), Some("sched")); + + let entry = RecordingEntry::from_file_info(&FileInfo { + record_type: Some("people".to_owned()), + alarm_type: Some("vehicle".to_owned()), + id: Some("/fixture/fallback.mp4".to_owned()), + ..Default::default() + }) + .unwrap(); + assert_eq!(entry.record_type.as_deref(), Some("people")); + } + #[tokio::test] async fn empty_page_stops_and_closes_cursor() { let (result, calls) = run_mock( @@ -753,6 +811,25 @@ mod tests { assert_eq!(calls.last(), Some(&MSG_ID_FILE_INFO_LIST_CLOSE)); } + #[tokio::test] + async fn explicit_unfinished_short_page_continues_until_finished_marker() { + let (result, calls) = run_mock( + request(), + vec![ + Ok(open_reply()), + Ok(page_with_marker(0, 1, Some(0))), + Ok(page_with_marker(1, 1, Some(1))), + Ok(FileInfoCommandReply::Empty), + ], + ) + .await; + let result = result.unwrap(); + assert_eq!(result.entries.len(), 2); + assert_eq!(result.pages, 2); + assert_eq!(result.end, RecordingSearchEnd::Finished); + assert_eq!(calls.last(), Some(&MSG_ID_FILE_INFO_LIST_CLOSE)); + } + #[tokio::test] async fn page_ceiling_is_bounded_and_cursor_is_closed() { let mut request = request(); @@ -818,7 +895,10 @@ mod tests { ], ) .await; - assert!(matches!(result, Err(Error::RecordingCloseFailed { .. }))); + let error = result.unwrap_err(); + assert!(matches!(&error, Error::RecordingCloseFailed { .. })); + assert!(error.to_string().contains("fixture CLOSE error")); + assert!(std::error::Error::source(&error).is_some()); assert_eq!(calls.last(), Some(&MSG_ID_FILE_INFO_LIST_CLOSE)); } @@ -833,10 +913,18 @@ mod tests { ], ) .await; + let error = result.unwrap_err(); assert!(matches!( - result, - Err(Error::RecordingSearchAndCloseFailed { .. }) + &error, + Error::RecordingSearchAndCloseFailed { .. } )); + let display = error.to_string(); + assert!(display.contains("fixture GET error")); + assert!(display.contains("fixture CLOSE error")); + assert!(std::error::Error::source(&error) + .unwrap() + .to_string() + .contains("fixture GET error")); assert_eq!(calls.last(), Some(&MSG_ID_FILE_INFO_LIST_CLOSE)); } } diff --git a/docs/bc-protocol.md b/docs/bc-protocol.md index 35be2062..c882713b 100644 --- a/docs/bc-protocol.md +++ b/docs/bc-protocol.md @@ -137,11 +137,14 @@ Stored recording searches are a cursor flow: 3. `CLOSE` (16) releases the handle. The request keeps the logical channel inside the XML payload and uses host -channel `250` in the BC header. A cursor is always closed after a successful -OPEN, including when pagination fails or hits a configured page/entry ceiling. +channel `250` in the BC header. After a successful OPEN, the client makes a +best-effort CLOSE attempt on every completed path, including when pagination +fails or hits a configured page/entry ceiling. Cancelling the asynchronous +search can interrupt that cleanup, so this is not a cancellation-safe guarantee. A header-only `400` reply to GET is treated as end-of-results; other non-200 -responses remain errors. Recording XML is redacted from parse-error logs because -it can contain device UID and recording paths. +responses remain errors. Recording XML is redacted from protocol-debug, +parse-error, and unmatched-response logs because it can contain device UID and +recording paths. ### `GET_ENC` (56) — live encoder config diff --git a/src/main.rs b/src/main.rs index 6b5ca686..db6a45eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,7 +64,9 @@ pub(crate) type AnyResult = Result; #[tokio::main] async fn main() -> Result<()> { - env_logger::Builder::from_env(Env::default().default_filter_or("info")).init(); + env_logger::Builder::from_env(Env::default().default_filter_or("info")) + .target(env_logger::Target::Stderr) + .init(); info!( "Neolink {} {}", diff --git a/src/recordings/mod.rs b/src/recordings/mod.rs index 15bc518a..08aaba1f 100644 --- a/src/recordings/mod.rs +++ b/src/recordings/mod.rs @@ -16,9 +16,6 @@ pub(crate) async fn main(opt: Opt, reactor: NeoReactor) -> Result<()> { let (year, month, day) = parse_date(&opt.date)?; let camera = reactor.get(&opt.camera).await?; let camera_config = camera.config().await?.borrow().clone(); - let uid = camera_config - .camera_uid - .context("Recording search requires a camera UID")?; let channel = opt.channel.unwrap_or(camera_config.channel_id); if channel > 31 { bail!("Recording channel must be between 0 and 31"); @@ -53,9 +50,8 @@ pub(crate) async fn main(opt: Opt, reactor: NeoReactor) -> Result<()> { let result = camera .run_task(|cam| { - let uid = uid.clone(); let options = options.clone(); - Box::pin(async move { Ok(cam.search_recordings(&uid, options).await?) }) + Box::pin(async move { Ok(cam.search_recordings(options).await?) }) }) .await .context("Unable to list recording metadata")?; From 80f4bfc494ff38300c8d75eb8e2180813bd33ce6 Mon Sep 17 00:00:00 2001 From: Sandeep Date: Fri, 31 Jul 2026 01:51:14 -0700 Subject: [PATCH 3/4] fix: cover recording cursor compatibility --- .../samples/file_info_list_generic_empty.xml | 2 + .../samples/file_info_list_open_top_level.xml | 6 + .../bc/samples/file_info_list_page_nested.xml | 2 +- crates/core/src/bc/xml.rs | 30 ++- crates/core/src/bc_protocol.rs | 11 +- crates/core/src/bc_protocol/recordings.rs | 210 +++++++++++++++--- docs/bc-protocol.md | 20 +- src/recordings/mod.rs | 5 +- 8 files changed, 235 insertions(+), 51 deletions(-) create mode 100644 crates/core/src/bc/samples/file_info_list_generic_empty.xml create mode 100644 crates/core/src/bc/samples/file_info_list_open_top_level.xml diff --git a/crates/core/src/bc/samples/file_info_list_generic_empty.xml b/crates/core/src/bc/samples/file_info_list_generic_empty.xml new file mode 100644 index 00000000..355cc5a7 --- /dev/null +++ b/crates/core/src/bc/samples/file_info_list_generic_empty.xml @@ -0,0 +1,2 @@ + + diff --git a/crates/core/src/bc/samples/file_info_list_open_top_level.xml b/crates/core/src/bc/samples/file_info_list_open_top_level.xml new file mode 100644 index 00000000..1b51e3bb --- /dev/null +++ b/crates/core/src/bc/samples/file_info_list_open_top_level.xml @@ -0,0 +1,6 @@ + + + +23 + + diff --git a/crates/core/src/bc/samples/file_info_list_page_nested.xml b/crates/core/src/bc/samples/file_info_list_page_nested.xml index eae91866..146b247f 100644 --- a/crates/core/src/bc/samples/file_info_list_page_nested.xml +++ b/crates/core/src/bc/samples/file_info_list_page_nested.xml @@ -10,8 +10,8 @@ 202612345 202612356 +1 -1 diff --git a/crates/core/src/bc/xml.rs b/crates/core/src/bc/xml.rs index c3276454..2df6bbdb 100644 --- a/crates/core/src/bc/xml.rs +++ b/crates/core/src/bc/xml.rs @@ -187,6 +187,9 @@ pub struct FileInfoList { /// XML schema version. #[serde(rename = "@version", skip_serializing_if = "Option::is_none")] pub version: Option, + /// Top-level server-side search cursor used by some firmware. + #[serde(skip_serializing_if = "Option::is_none")] + pub handle: Option, /// FileInfo request or result entries. #[serde(rename = "FileInfo", default, skip_serializing_if = "Vec::is_empty")] pub file_info: Vec, @@ -283,6 +286,12 @@ pub struct FileResultList { /// FileInfo result entries. #[serde(rename = "FileInfo", default, skip_serializing_if = "Vec::is_empty")] pub file_info: Vec, + /// Pagination completion marker used by some nested response layouts. + #[serde(rename = "bFinished", skip_serializing_if = "Option::is_none")] + pub b_finished: Option, + /// Alternate nested pagination completion marker. + #[serde(rename = "finished", skip_serializing_if = "Option::is_none")] + pub finished: Option, } /// Camera-local date/time fields used by FileInfoList. @@ -2386,6 +2395,16 @@ fn test_file_info_list_open_fixture() { assert_eq!(list.file_info[0].handle, Some(17)); } +#[test] +fn test_file_info_list_top_level_handle_fixture() { + let parsed = + BcXml::try_parse(include_bytes!("samples/file_info_list_open_top_level.xml").as_slice()) + .unwrap(); + let list = parsed.file_info_list.unwrap(); + assert_eq!(list.handle, Some(23)); + assert!(list.file_info.is_empty()); +} + #[test] fn test_file_info_list_direct_fixture() { let parsed = @@ -2442,7 +2461,8 @@ fn test_file_info_list_nested_fixture() { let nested = list.file_info[0].file_list.as_ref().unwrap(); assert_eq!(nested.file.len(), 1); assert_eq!(nested.file[0].type_.as_deref(), Some("md")); - assert_eq!(list.finished, Some(1)); + assert_eq!(nested.b_finished, Some(1)); + assert_eq!(list.finished, None); } #[test] @@ -2454,3 +2474,11 @@ fn test_file_info_list_empty_fixture() { assert!(list.file.is_empty()); assert_eq!(list.file_info.len(), 1); } + +#[test] +fn test_generic_empty_body_fixture() { + let parsed = + BcXml::try_parse(include_bytes!("samples/file_info_list_generic_empty.xml").as_slice()) + .unwrap(); + assert_eq!(parsed, BcXml::default()); +} diff --git a/crates/core/src/bc_protocol.rs b/crates/core/src/bc_protocol.rs index cb201ee5..35261ba9 100644 --- a/crates/core/src/bc_protocol.rs +++ b/crates/core/src/bc_protocol.rs @@ -8,7 +8,7 @@ use std::{ sync::atomic::{AtomicBool, AtomicU16, Ordering}, time::Duration, }; -use tokio::sync::RwLock; +use tokio::sync::{Mutex, RwLock}; use Md5Trunc::*; @@ -54,9 +54,9 @@ pub use motion::{MotionData, MotionStatus}; pub use pirstate::PirState; pub use ptz::Direction; pub use recordings::{ - RecordingEntry, RecordingSearchEnd, RecordingSearchOptions, RecordingSearchResult, - RecordingStreamKind, DEFAULT_RECORDING_MAX_ENTRIES, DEFAULT_RECORDING_MAX_PAGES, - HARD_RECORDING_MAX_ENTRIES, HARD_RECORDING_MAX_PAGES, + FileDateTime, RecordingEntry, RecordingSearchEnd, RecordingSearchOptions, + RecordingSearchResult, RecordingStreamKind, DEFAULT_RECORDING_MAX_ENTRIES, + DEFAULT_RECORDING_MAX_PAGES, HARD_RECORDING_MAX_ENTRIES, HARD_RECORDING_MAX_PAGES, }; pub use resolution::*; use std::sync::Arc; @@ -95,6 +95,8 @@ pub struct BcCamera { /// Features (e.g. "battery", "floodlight_tasks") the camera has rejected as /// unsupported, so the doomed request is not re-sent on this connection. unsupported: RwLock>, + /// Serializes FileInfoList cursors, which are stateful on the camera. + recording_search_lock: Mutex<()>, } /// Options used to construct a camera @@ -399,6 +401,7 @@ impl BcCamera { credentials: Credentials::new(username, passwd), abilities: Default::default(), unsupported: Default::default(), + recording_search_lock: Default::default(), }; me.keepalive().await?; Ok(me) diff --git a/crates/core/src/bc_protocol/recordings.rs b/crates/core/src/bc_protocol/recordings.rs index 52742212..fcbe9dd0 100644 --- a/crates/core/src/bc_protocol/recordings.rs +++ b/crates/core/src/bc_protocol/recordings.rs @@ -1,8 +1,13 @@ use super::{BcCamera, Error, Result}; -use crate::bc::{model::*, xml::*}; +use crate::bc::{ + model::*, + xml::{FileInfo, FileInfoList}, +}; use serde::Serialize; use std::{collections::HashSet, future::Future, sync::Arc, time::Duration}; +pub use crate::bc::xml::FileDateTime; + const FILE_INFO_LIST_VERSION: &str = "1.1"; const FILE_INFO_LIST_HOST_CHANNEL: u8 = 250; const RECORDING_REPLY_TIMEOUT: Duration = Duration::from_secs(15); @@ -57,11 +62,28 @@ pub struct RecordingSearchOptions { } impl Default for RecordingSearchOptions { + /// Return a valid, bounded full-day placeholder query for 2000-01-01. + /// + /// Callers should replace the date/channel fields for their intended search. fn default() -> Self { Self { channel: 0, - start: FileDateTime::default(), - end: FileDateTime::default(), + start: FileDateTime { + year: 2000, + month: 1, + day: 1, + hour: 0, + minute: 0, + second: 0, + }, + end: FileDateTime { + year: 2000, + month: 1, + day: 1, + hour: 23, + minute: 59, + second: 59, + }, stream: RecordingStreamKind::Sub, record_types: "manual, sched, io, md, people, face, vehicle, dog_cat, visitor, other, package" @@ -261,13 +283,17 @@ impl BcCamera { options: RecordingSearchOptions, ) -> Result { options.validate()?; - let uid = self.uid().await?; - if uid.trim().is_empty() { - return Err(Error::Other("Camera returned an empty UID")); - } - let request = SearchRequest { uid, options }; - execute_search(request, |msg_id, payload| { - self.send_file_info_list(msg_id, payload) + with_recording_lock(&self.recording_search_lock, async { + let uid = self.uid().await?; + let uid = uid.trim().to_owned(); + if uid.is_empty() { + return Err(Error::Other("Camera returned an empty UID")); + } + let request = SearchRequest { uid, options }; + execute_search(request, |msg_id, payload| { + self.send_file_info_list(msg_id, payload) + }) + .await }) .await } @@ -304,6 +330,9 @@ impl BcCamera { .await .map_err(|_| Error::TimeoutDisconnected)??; let response_code = reply.meta.response_code; + if msg_id == MSG_ID_FILE_INFO_LIST_CLOSE && response_code == 200 { + return Ok(FileInfoCommandReply::Empty); + } let payload = match reply.body { BcBody::ModernMsg(ModernMsg { payload, .. }) => payload, _ => { @@ -313,29 +342,48 @@ impl BcCamera { } }; - if msg_id == MSG_ID_FILE_INFO_LIST_GET && response_code == 400 && payload.is_none() { - return Ok(FileInfoCommandReply::End); - } - if response_code != 200 { - return Err(Error::CameraServiceUnavailable { - id: msg_id, - code: response_code, - }); - } + classify_file_info_reply(msg_id, response_code, payload) + } +} - match payload { - Some(BcPayloads::BcXml(BcXml { - file_info_list: Some(list), - .. - })) => Ok(FileInfoCommandReply::Xml(list)), - None => Ok(FileInfoCommandReply::Empty), - _ => Err(Error::Other( - "FileInfoList camera reply had an unexpected payload", - )), - } +fn classify_file_info_reply( + msg_id: u32, + response_code: u16, + payload: Option, +) -> Result { + if msg_id == MSG_ID_FILE_INFO_LIST_GET && response_code == 400 && payload.is_none() { + return Ok(FileInfoCommandReply::End); + } + if response_code != 200 { + return Err(Error::CameraServiceUnavailable { + id: msg_id, + code: response_code, + }); + } + if msg_id == MSG_ID_FILE_INFO_LIST_CLOSE { + return Ok(FileInfoCommandReply::Empty); + } + + match payload { + Some(BcPayloads::BcXml(BcXml { + file_info_list: Some(list), + .. + })) => Ok(FileInfoCommandReply::Xml(list)), + None | Some(BcPayloads::BcXml(_)) => Ok(FileInfoCommandReply::Empty), + Some(BcPayloads::Binary(_)) => Err(Error::Other( + "FileInfoList camera reply had an unexpected binary payload", + )), } } +async fn with_recording_lock( + lock: &tokio::sync::Mutex<()>, + operation: impl Future, +) -> T { + let _guard = lock.lock().await; + operation.await +} + async fn execute_search( request: SearchRequest, mut send: F, @@ -461,7 +509,7 @@ fn build_open_request(request: &SearchRequest) -> FileInfoList { FileInfoList { version: Some(FILE_INFO_LIST_VERSION.to_owned()), file_info: vec![FileInfo { - uid: Some(request.uid.clone()), + uid: Some(request.uid.trim().to_owned()), search_ai_track: Some(1), channel_id: Some(request.options.channel), logic_chn_bitmap: Some(255), @@ -479,7 +527,7 @@ fn build_page_request(request: &SearchRequest, handle: u32) -> FileInfoList { FileInfoList { version: Some(FILE_INFO_LIST_VERSION.to_owned()), file_info: vec![FileInfo { - uid: Some(request.uid.clone()), + uid: Some(request.uid.trim().to_owned()), search_ai_track: Some(1), channel_id: Some(request.options.channel), handle: Some(handle), @@ -490,7 +538,12 @@ fn build_page_request(request: &SearchRequest, handle: u32) -> FileInfoList { } fn find_handle(list: &FileInfoList) -> Option { - list.file_info.iter().find_map(find_handle_in_entry) + list.handle.or_else(|| { + list.file_info + .iter() + .chain(list.file.iter()) + .find_map(find_handle_in_entry) + }) } fn find_handle_in_entry(entry: &FileInfo) -> Option { @@ -558,6 +611,8 @@ fn merge_entry_completion_marker(state: &mut Option, entry: &FileInfo) { .into_iter() .flatten() { + merge_completion_marker(state, list.b_finished); + merge_completion_marker(state, list.finished); for child in list.file.iter().chain(list.file_info.iter()) { merge_entry_completion_marker(state, child); } @@ -596,7 +651,14 @@ fn days_in_month(year: u16, month: u8) -> u8 { #[cfg(test)] mod tests { use super::*; - use std::{cell::RefCell, collections::VecDeque, future::ready, rc::Rc}; + use crate::bc::xml::FileResultList; + use std::{ + cell::RefCell, + collections::VecDeque, + future::ready, + rc::Rc, + sync::atomic::{AtomicBool, AtomicUsize, Ordering}, + }; fn timestamp(hour: u8) -> FileDateTime { FileDateTime { @@ -628,7 +690,7 @@ mod tests { fn request() -> SearchRequest { SearchRequest { - uid: "FIXTUREUID".to_owned(), + uid: " FIXTUREUID ".to_owned(), options: options(), } } @@ -698,6 +760,8 @@ mod tests { #[test] fn query_validation_rejects_bad_dates_and_limits() { + assert!(RecordingSearchOptions::default().validate().is_ok()); + let mut value = options(); value.end.day = 31; assert!(value.validate().is_err()); @@ -724,6 +788,84 @@ mod tests { assert_eq!(info.channel_id, Some(0)); assert_eq!(info.stream_type.as_deref(), Some("subStream")); assert_eq!(info.start_time, Some(timestamp(0))); + + let page = build_page_request(&request, 17); + assert_eq!(page.file_info[0].uid.as_deref(), Some("FIXTUREUID")); + } + + #[test] + fn top_level_cursor_and_nested_completion_markers_are_supported() { + let open = FileInfoList { + handle: Some(23), + ..Default::default() + }; + assert_eq!(find_handle(&open), Some(23)); + + let page = FileInfoList { + file_info: vec![FileInfo { + file_list: Some(FileResultList { + b_finished: Some(1), + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }; + assert_eq!(completion_marker(&page), Some(true)); + } + + #[test] + fn generic_success_get_is_empty_and_any_success_close_body_is_accepted() { + let generic = BcXml::try_parse( + include_bytes!("../bc/samples/file_info_list_generic_empty.xml").as_slice(), + ) + .unwrap(); + assert!(matches!( + classify_file_info_reply( + MSG_ID_FILE_INFO_LIST_GET, + 200, + Some(BcPayloads::BcXml(generic)) + ), + Ok(FileInfoCommandReply::Empty) + )); + assert!(matches!( + classify_file_info_reply(MSG_ID_FILE_INFO_LIST_GET, 200, None), + Ok(FileInfoCommandReply::Empty) + )); + assert!(matches!( + classify_file_info_reply( + MSG_ID_FILE_INFO_LIST_CLOSE, + 200, + Some(BcPayloads::Binary(vec![1, 2, 3])) + ), + Ok(FileInfoCommandReply::Empty) + )); + } + + #[tokio::test] + async fn recording_lock_serializes_cursor_operations() { + let lock = tokio::sync::Mutex::new(()); + let active = AtomicUsize::new(0); + let overlapped = AtomicBool::new(false); + + let first = with_recording_lock(&lock, async { + if active.fetch_add(1, Ordering::SeqCst) != 0 { + overlapped.store(true, Ordering::SeqCst); + } + tokio::time::sleep(Duration::from_millis(10)).await; + active.fetch_sub(1, Ordering::SeqCst); + }); + let second = with_recording_lock(&lock, async { + if active.fetch_add(1, Ordering::SeqCst) != 0 { + overlapped.store(true, Ordering::SeqCst); + } + tokio::time::sleep(Duration::from_millis(10)).await; + active.fetch_sub(1, Ordering::SeqCst); + }); + + tokio::join!(first, second); + assert!(!overlapped.load(Ordering::SeqCst)); + assert_eq!(active.load(Ordering::SeqCst), 0); } #[test] diff --git a/docs/bc-protocol.md b/docs/bc-protocol.md index c882713b..e266faa0 100644 --- a/docs/bc-protocol.md +++ b/docs/bc-protocol.md @@ -137,14 +137,18 @@ Stored recording searches are a cursor flow: 3. `CLOSE` (16) releases the handle. The request keeps the logical channel inside the XML payload and uses host -channel `250` in the BC header. After a successful OPEN, the client makes a -best-effort CLOSE attempt on every completed path, including when pagination -fails or hits a configured page/entry ceiling. Cancelling the asynchronous -search can interrupt that cleanup, so this is not a cancellation-safe guarantee. -A header-only `400` reply to GET is treated as end-of-results; other non-200 -responses remain errors. Recording XML is redacted from protocol-debug, -parse-error, and unmatched-response logs because it can contain device UID and -recording paths. +channel `250` in the BC header. The device UID is obtained with the existing UID +command, and one FileInfoList cursor flow is allowed at a time per `BcCamera`. +After a successful OPEN, the client makes a best-effort CLOSE attempt on every +completed path, including when pagination fails or hits a configured page/entry +ceiling. Cancelling the asynchronous search can interrupt that cleanup, so this +is not a cancellation-safe guarantee. + +A header-only `400` reply to GET is treated as end-of-results. An HTTP-style +`200` GET with no FileInfoList payload is an empty page, and any `200` CLOSE +acknowledges cleanup regardless of its payload layout; other non-200 responses +remain errors. Recording XML is redacted from protocol-debug, parse-error, and +unmatched-response logs because it can contain device UID and recording paths. ### `GET_ENC` (56) — live encoder config diff --git a/src/recordings/mod.rs b/src/recordings/mod.rs index 08aaba1f..5231b81b 100644 --- a/src/recordings/mod.rs +++ b/src/recordings/mod.rs @@ -1,7 +1,6 @@ use anyhow::{bail, Context, Result}; -use neolink_core::{ - bc::xml::FileDateTime, - bc_protocol::{RecordingSearchOptions, RecordingSearchResult, RecordingStreamKind}, +use neolink_core::bc_protocol::{ + FileDateTime, RecordingSearchOptions, RecordingSearchResult, RecordingStreamKind, }; use serde_json::json; From 3e7696e843b3bdd27373e93dc6cfc56535ac7fc2 Mon Sep 17 00:00:00 2001 From: Sandeep Date: Fri, 31 Jul 2026 02:25:23 -0700 Subject: [PATCH 4/4] fix: bound recording UID resolution --- crates/core/src/bc_protocol.rs | 9 + crates/core/src/bc_protocol/recordings.rs | 198 +++++++++++++++++++--- crates/core/src/bc_protocol/uid.rs | 63 ++++--- src/recordings/mod.rs | 8 +- 4 files changed, 237 insertions(+), 41 deletions(-) diff --git a/crates/core/src/bc_protocol.rs b/crates/core/src/bc_protocol.rs index 35261ba9..1ae78fc5 100644 --- a/crates/core/src/bc_protocol.rs +++ b/crates/core/src/bc_protocol.rs @@ -86,6 +86,9 @@ enum ReadKind { /// pub struct BcCamera { channel_id: u8, + /// UID supplied when the camera was constructed, normalized for reuse by + /// commands that would otherwise need to query it from the device. + configured_uid: Option, connection: Arc, logged_in: AtomicBool, message_num: AtomicU16, @@ -397,6 +400,12 @@ impl BcCamera { connection: Arc::new(conn), message_num: AtomicU16::new(0), channel_id: options.channel_id, + configured_uid: options + .uid + .as_deref() + .map(str::trim) + .filter(|uid| !uid.is_empty()) + .map(str::to_owned), logged_in: AtomicBool::new(false), credentials: Credentials::new(username, passwd), abilities: Default::default(), diff --git a/crates/core/src/bc_protocol/recordings.rs b/crates/core/src/bc_protocol/recordings.rs index fcbe9dd0..a0e6670c 100644 --- a/crates/core/src/bc_protocol/recordings.rs +++ b/crates/core/src/bc_protocol/recordings.rs @@ -11,6 +11,7 @@ pub use crate::bc::xml::FileDateTime; const FILE_INFO_LIST_VERSION: &str = "1.1"; const FILE_INFO_LIST_HOST_CHANNEL: u8 = 250; const RECORDING_REPLY_TIMEOUT: Duration = Duration::from_secs(15); +const RECORDING_UID_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(15); const TYPICAL_PAGE_SIZE: usize = 40; /// Default maximum number of FileInfoList pages requested in one search. @@ -276,25 +277,21 @@ impl BcCamera { /// completed code path, including pagination errors and configured safety /// ceilings. Cancelling the future can interrupt that cleanup. /// - /// The device UID is resolved through [`Self::uid`]; callers select the - /// logical device channel through [`RecordingSearchOptions::channel`]. + /// A non-empty UID supplied through [`super::BcCameraOpt`] is reused + /// directly. Otherwise the UID is queried for + /// [`RecordingSearchOptions::channel`] with a fixed timeout. UID resolution + /// completes before the camera's stateful recording cursor lock is taken. pub async fn search_recordings( &self, options: RecordingSearchOptions, ) -> Result { - options.validate()?; - with_recording_lock(&self.recording_search_lock, async { - let uid = self.uid().await?; - let uid = uid.trim().to_owned(); - if uid.is_empty() { - return Err(Error::Other("Camera returned an empty UID")); - } - let request = SearchRequest { uid, options }; - execute_search(request, |msg_id, payload| { - self.send_file_info_list(msg_id, payload) - }) - .await - }) + search_recordings_with( + self.configured_uid.as_deref(), + options, + &self.recording_search_lock, + |channel| self.uid_for_channel(channel), + |msg_id, payload| self.send_file_info_list(msg_id, payload), + ) .await } @@ -384,6 +381,57 @@ async fn with_recording_lock( operation.await } +async fn search_recordings_with( + configured_uid: Option<&str>, + options: RecordingSearchOptions, + recording_search_lock: &tokio::sync::Mutex<()>, + discover_uid: Resolve, + send: Send, +) -> Result +where + Resolve: FnOnce(u8) -> ResolveFut, + ResolveFut: Future>, + Send: FnMut(u32, FileInfoList) -> SendFut, + SendFut: Future>, +{ + options.validate()?; + let channel = options.channel; + let uid = resolve_recording_uid( + configured_uid, + channel, + RECORDING_UID_DISCOVERY_TIMEOUT, + discover_uid, + ) + .await?; + let request = SearchRequest { uid, options }; + + with_recording_lock(recording_search_lock, execute_search(request, send)).await +} + +async fn resolve_recording_uid( + configured_uid: Option<&str>, + channel: u8, + timeout: Duration, + discover_uid: Resolve, +) -> Result +where + Resolve: FnOnce(u8) -> ResolveFut, + ResolveFut: Future>, +{ + if let Some(uid) = configured_uid.map(str::trim).filter(|uid| !uid.is_empty()) { + return Ok(uid.to_owned()); + } + + let discovered_uid = tokio::time::timeout(timeout, discover_uid(channel)) + .await + .map_err(|_| Error::TimeoutDisconnected)??; + let discovered_uid = discovered_uid.trim(); + if discovered_uid.is_empty() { + return Err(Error::Other("Camera returned an empty UID")); + } + Ok(discovered_uid.to_owned()) +} + async fn execute_search( request: SearchRequest, mut send: F, @@ -637,12 +685,14 @@ fn valid_datetime(value: FileDateTime) -> bool { && value.second <= 59 } -// Keep the arithmetic form compatible with Neolink's existing Rust 2021 toolchain. -#[allow(clippy::manual_is_multiple_of)] fn days_in_month(year: u16, month: u8) -> u8 { match month { 4 | 6 | 9 | 11 => 30, - 2 if year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) => 29, + 2 if year.rem_euclid(400) == 0 + || (year.rem_euclid(4) == 0 && year.rem_euclid(100) != 0) => + { + 29 + } 2 => 28, _ => 31, } @@ -655,7 +705,7 @@ mod tests { use std::{ cell::RefCell, collections::VecDeque, - future::ready, + future::{pending, ready}, rc::Rc, sync::atomic::{AtomicBool, AtomicUsize, Ordering}, }; @@ -793,6 +843,116 @@ mod tests { assert_eq!(page.file_info[0].uid.as_deref(), Some("FIXTUREUID")); } + #[tokio::test] + async fn configured_uid_is_trimmed_and_avoids_discovery() { + let discovery_calls = AtomicUsize::new(0); + let uid = resolve_recording_uid( + Some(" CONFIGUREDUID "), + 9, + Duration::from_millis(1), + |_: u8| { + discovery_calls.fetch_add(1, Ordering::SeqCst); + ready(Ok("DISCOVEREDUID".to_owned())) + }, + ) + .await + .unwrap(); + + assert_eq!(uid, "CONFIGUREDUID"); + assert_eq!(discovery_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn uid_discovery_uses_query_channel_and_trims_reply() { + let resolved_channel = AtomicUsize::new(usize::MAX); + let uid = resolve_recording_uid(None, 17, Duration::from_secs(1), |channel| { + resolved_channel.store(usize::from(channel), Ordering::SeqCst); + ready(Ok(" DISCOVEREDUID ".to_owned())) + }) + .await + .unwrap(); + + assert_eq!(uid, "DISCOVEREDUID"); + assert_eq!(resolved_channel.load(Ordering::SeqCst), 17); + } + + #[tokio::test] + async fn uid_discovery_is_bounded_and_preserves_camera_errors() { + let timed_out = resolve_recording_uid(None, 3, Duration::from_millis(1), |_| { + pending::>() + }) + .await; + assert!(matches!(timed_out, Err(Error::TimeoutDisconnected))); + + let camera_error = resolve_recording_uid(None, 3, Duration::from_secs(1), |_| { + ready(Err(Error::CameraServiceUnavailable { + id: MSG_ID_UID, + code: 500, + })) + }) + .await; + assert!(matches!( + camera_error, + Err(Error::CameraServiceUnavailable { + id: MSG_ID_UID, + code: 500 + }) + )); + } + + #[tokio::test] + async fn uid_discovery_completes_before_cursor_lock_is_taken() { + let lock = tokio::sync::Mutex::new(()); + let discovery_started = Rc::new(tokio::sync::Notify::new()); + let release_discovery = Rc::new(tokio::sync::Notify::new()); + let replies = Rc::new(RefCell::new(VecDeque::from(vec![ + Ok(open_reply()), + Ok(FileInfoCommandReply::Empty), + Ok(FileInfoCommandReply::Empty), + ]))); + + let search = search_recordings_with( + None, + options(), + &lock, + { + let discovery_started = discovery_started.clone(); + let release_discovery = release_discovery.clone(); + move |channel| async move { + assert_eq!(channel, 0); + discovery_started.notify_one(); + release_discovery.notified().await; + Ok("DISCOVEREDUID".to_owned()) + } + }, + { + let replies = replies.clone(); + move |_msg_id, _payload| { + ready( + replies + .borrow_mut() + .pop_front() + .expect("mock reply for every command"), + ) + } + }, + ); + let lock_probe = async { + discovery_started.notified().await; + let guard = tokio::time::timeout(Duration::from_millis(100), lock.lock()) + .await + .expect("UID discovery must not hold the recording cursor lock"); + drop(guard); + release_discovery.notify_one(); + }; + + let (result, ()) = tokio::join!(search, lock_probe); + let result = result.unwrap(); + assert!(result.entries.is_empty()); + assert_eq!(result.end, RecordingSearchEnd::ShortPage); + assert!(replies.borrow().is_empty()); + } + #[test] fn top_level_cursor_and_nested_completion_markers_are_supported() { let open = FileInfoList { diff --git a/crates/core/src/bc_protocol/uid.rs b/crates/core/src/bc_protocol/uid.rs index 67868b5b..3cf02834 100644 --- a/crates/core/src/bc_protocol/uid.rs +++ b/crates/core/src/bc_protocol/uid.rs @@ -1,28 +1,31 @@ use super::{BcCamera, Error, Result}; use crate::bc::{model::*, xml::*}; +fn uid_request(channel_id: u8, msg_num: u16) -> Bc { + Bc { + meta: BcMeta { + msg_id: MSG_ID_UID, + channel_id, + msg_num, + response_code: 0, + stream_type: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: None, + }), + } +} + impl BcCamera { - /// Get the [Uid] xml which contains the uid of the camera - pub async fn get_uid(&self) -> Result { + /// Get the [Uid] XML for one logical camera channel. + pub async fn get_uid_for_channel(&self, channel_id: u8) -> Result { let connection = self.get_connection(); let msg_num = self.new_message_num(); let mut sub_get = connection.subscribe(MSG_ID_UID, msg_num).await?; - let get = Bc { - meta: BcMeta { - msg_id: MSG_ID_UID, - channel_id: self.channel_id, - msg_num, - response_code: 0, - stream_type: 0, - class: 0x6414, - }, - body: BcBody::ModernMsg(ModernMsg { - extension: None, - payload: None, - }), - }; - - sub_get.send(get).await?; + + sub_get.send(uid_request(channel_id, msg_num)).await?; let msg = sub_get.recv().await?; if msg.meta.response_code != 200 { return Err(Error::CameraServiceUnavailable { @@ -48,8 +51,30 @@ impl BcCamera { } } + /// Get the [Uid] XML for the camera's configured logical channel. + pub async fn get_uid(&self) -> Result { + self.get_uid_for_channel(self.channel_id).await + } + + /// Get the UID for one logical camera channel. + pub async fn uid_for_channel(&self, channel_id: u8) -> Result { + Ok(self.get_uid_for_channel(channel_id).await?.uid) + } + /// Get the UID pub async fn uid(&self) -> Result { - Ok(self.get_uid().await?.uid) + self.uid_for_channel(self.channel_id).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uid_request_uses_explicit_logical_channel() { + let request = uid_request(17, 42); + assert_eq!(request.meta.channel_id, 17); + assert_eq!(request.meta.msg_num, 42); } } diff --git a/src/recordings/mod.rs b/src/recordings/mod.rs index 5231b81b..aad46643 100644 --- a/src/recordings/mod.rs +++ b/src/recordings/mod.rs @@ -140,12 +140,14 @@ fn parse_date(input: &str) -> Result<(u16, u8, u8)> { Ok((year, month, day)) } -// Keep the arithmetic form compatible with Neolink's existing Rust 2021 toolchain. -#[allow(clippy::manual_is_multiple_of)] fn days_in_month(year: u16, month: u8) -> u8 { match month { 4 | 6 | 9 | 11 => 30, - 2 if year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) => 29, + 2 if year.rem_euclid(400) == 0 + || (year.rem_euclid(4) == 0 && year.rem_euclid(100) != 0) => + { + 29 + } 2 => 28, _ => 31, }