diff --git a/README.md b/README.md
index 1812c397..95c6b920 100644
--- a/README.md
+++ b/README.md
@@ -732,6 +732,33 @@ 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 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-01-02
+
+# Override the configured logical channel and request the main recording stream
+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-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. 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.
+
### 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..a2c36f09 100644
--- a/crates/core/src/bc/de.rs
+++ b/crates/core/src/bc/de.rs
@@ -125,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())
);
@@ -217,19 +217,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) {
+ log::debug!(
+ "Payload Txt: ",
+ processed_payload_buf.len()
+ );
+ } else {
+ log::debug!(
+ "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 +315,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..c624274b 100644
--- a/crates/core/src/bc/model.rs
+++ b/crates/core/src/bc/model.rs
@@ -22,6 +22,21 @@ 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;
+
+/// 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_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.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_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_direct.xml b/crates/core/src/bc/samples/file_info_list_page_direct.xml
new file mode 100644
index 00000000..d3371af9
--- /dev/null
+++ b/crates/core/src/bc/samples/file_info_list_page_direct.xml
@@ -0,0 +1,24 @@
+
+
+
+
+/fixture/channel0/recording-a.mp4
+recording-a.mp4
+sched
+people
+vehicle
+1234
+202612123
+202612134
+ignored
+
+
+/fixture/channel0/recording-b.mp4
+vehicle
+5678
+202612234
+202612245
+
+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..146b247f
--- /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
+202612345
+202612356
+
+1
+
+
+
+
diff --git a/crates/core/src/bc/xml.rs b/crates/core/src/bc/xml.rs
index 8a4cd46a..2df6bbdb 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;
@@ -141,6 +144,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 +181,270 @@ 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,
+ /// 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,
+ /// 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",
+ alias = "ID",
+ alias = "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,
+ /// 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.
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Debug, 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,
+}
+
+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 {
@@ -2115,3 +2385,100 @@ 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_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 =
+ 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[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 =
+ 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!(nested.b_finished, Some(1));
+ assert_eq!(list.finished, None);
+}
+
+#[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);
+}
+
+#[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 60d1a8b5..1ae78fc5 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::*;
@@ -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::{
+ 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;
pub use stream::{StreamData, StreamKind};
@@ -80,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,
@@ -89,6 +98,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
@@ -389,10 +400,17 @@ 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(),
unsupported: Default::default(),
+ recording_search_lock: Default::default(),
};
me.keepalive().await?;
Ok(me)
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 757f3282..76377c17 100644
--- a/crates/core/src/bc_protocol/errors.rs
+++ b/crates/core/src/bc_protocol/errors.rs
@@ -246,6 +246,25 @@ 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: {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: {search}; cursor close also failed: {close}")]
+ RecordingSearchAndCloseFailed {
+ /// The original search error.
+ #[source]
+ 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..a0e6670c
--- /dev/null
+++ b/crates/core/src/bc_protocol/recordings.rs
@@ -0,0 +1,1232 @@
+use super::{BcCamera, Error, Result};
+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);
+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.
+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 {
+ /// 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 {
+ 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"
+ .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 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",
+ ));
+ }
+ 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
+ .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,
+ 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.
+ /// 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.
+ ///
+ /// 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 {
+ 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
+ }
+
+ 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;
+ 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,
+ _ => {
+ return Err(Error::Other(
+ "FileInfoList camera reply was not a modern message",
+ ))
+ }
+ };
+
+ classify_file_info_reply(msg_id, response_code, 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