diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e20f6b71a..eb5a366cf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -336,6 +336,46 @@ jobs: env: DOCKER_BUILDKIT: 1 + dev_release: + name: Dev Pre-release + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/feature/disk-subcommand' && github.event_name == 'push' + permissions: + contents: write + needs: + - "cross" + - "native" + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Prepare zips + run: | + cd artifacts + for d in */; do + chmod +x "${d}/neolink"* 2>/dev/null || true + zip -r "../${d%/}.zip" "${d}" + done + cd .. + - name: Publish rolling pre-release + uses: softprops/action-gh-release@v1 + with: + tag_name: dev-feature-disk-subcommand + name: "Dev build: feature/disk-subcommand" + body: | + Automated build from the `feature/disk-subcommand` branch (commit ${{ github.sha }}). + Includes disk management, SD card replay/download, alarm search, and Argus 2 fix. + + **This is a test build — not for production use.** + + Download the zip for your platform, extract, and run `neolink`. + draft: false + prerelease: true + files: "*.zip" + create_release: name: Create Release runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index 218de72be..cf55efb8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ regex = "1.7.3" rumqttc = "0.24.0" serde = { version = "1.0.160", features = ["derive"] } serde_json = "1.0.96" -tokio = { version = "1.27.0", features = ["rt-multi-thread", "macros", "io-util", "tracing"] } +tokio = { version = "1.27.0", features = ["rt-multi-thread", "macros", "io-util", "tracing", "signal"] } tokio-stream = "0.1.12" tokio-util = { version = "0.7.7", features = ["full", "tracing"] } toml = "0.8.2" diff --git a/crates/core/src/bc/codex.rs b/crates/core/src/bc/codex.rs index 6c24266c3..69fe84165 100644 --- a/crates/core/src/bc/codex.rs +++ b/crates/core/src/bc/codex.rs @@ -6,7 +6,7 @@ use crate::bc::model::*; use crate::bc::xml::*; use crate::{Credentials, Error, Result}; -use bytes::BytesMut; +use bytes::{Buf, BytesMut}; use nom::AsBytes; use tokio_util::codec::{Decoder, Encoder}; @@ -84,12 +84,25 @@ impl Decoder for BcCodex { } fn decode(&mut self, src: &mut BytesMut) -> Result> { - // trace!("Decoding: {:X?}", src); - let bc = Bc::deserialize(&self.context, src); - // trace!("As: {:?}", bc); - let bc = match bc { + const MAGIC_LE: [u8; 4] = [0xf0, 0xde, 0xbc, 0x0a]; + const MAGIC_REV_LE: [u8; 4] = [0xa0, 0xcb, 0xed, 0x0f]; + // Use the current read window (chunk); after a previous decode the Buf cursor advanced. + let chunk = src.chunk(); + // Strip 4-byte prefix before BC frame. E1 sends 4 bytes (sometimes zeros, sometimes not) then magic. + if chunk.len() >= 8 && (chunk[4..8] == MAGIC_LE || chunk[4..8] == MAGIC_REV_LE) { + src.advance(4); + } + let bc = match Bc::deserialize(&self.context, src) { Ok(bc) => bc, Err(Error::NomIncomplete(_)) => return Ok(None), + Err(Error::NomError(ref msg)) if msg.contains("Magic invalid") => { + let chunk = src.chunk(); + // Fewer than 8 bytes: might be partial prefix or partial frame, wait for more + if chunk.len() < 8 { + return Ok(None); + } + return Err(Error::NomError(msg.clone())); + } Err(e) => return Err(e), }; // Update context diff --git a/crates/core/src/bc/crypto.rs b/crates/core/src/bc/crypto.rs index 4ae05ad5a..6fa28dcd5 100644 --- a/crates/core/src/bc/crypto.rs +++ b/crates/core/src/bc/crypto.rs @@ -1,5 +1,5 @@ use aes::{ - cipher::{AsyncStreamCipher, KeyIvInit}, + cipher::{AsyncStreamCipher, BlockEncrypt, KeyInit, KeyIvInit}, Aes128, }; use cfb_mode::{Decryptor, Encryptor}; @@ -7,8 +7,56 @@ use cfb_mode::{Decryptor, Encryptor}; type Aes128CfbEnc = Encryptor; type Aes128CfbDec = Decryptor; +/// Decrypt with explicit CFB state (iv, num) and return updated state. +/// Not used for replay: SDK resets num=0 every packet (per-packet fresh IV only). +/// num is in 0..16 (bytes already consumed from current keystream block). +pub fn decrypt_cfb_with_state( + key: &[u8; 16], + iv: &mut [u8; 16], + num: &mut u8, + ciphertext: &[u8], +) -> Vec { + use aes::cipher::generic_array::GenericArray; + use aes::cipher::typenum::U16; + let cipher = Aes128::new_from_slice(key).expect("key length"); + let mut out = Vec::with_capacity(ciphertext.len()); + let mut i = 0; + while i < ciphertext.len() { + let mut block: GenericArray = GenericArray::clone_from_slice(iv); + cipher.encrypt_block(&mut block); + let keystream = block.as_slice(); + let n = (16 - (*num as usize)).min(ciphertext.len() - i); + for j in 0..n { + out.push(ciphertext[i + j] ^ keystream[(*num as usize) + j]); + } + // CFB feedback: iv is the last 16 bytes of ciphertext; shift and append each new ct byte + for k in 0..n { + iv[0] = iv[1]; + iv[1] = iv[2]; + iv[2] = iv[3]; + iv[3] = iv[4]; + iv[4] = iv[5]; + iv[5] = iv[6]; + iv[6] = iv[7]; + iv[7] = iv[8]; + iv[8] = iv[9]; + iv[9] = iv[10]; + iv[10] = iv[11]; + iv[11] = iv[12]; + iv[12] = iv[13]; + iv[13] = iv[14]; + iv[14] = iv[15]; + iv[15] = ciphertext[i + k]; + } + i += n; + *num = (*num as usize + n) as u8 % 16; + } + out +} + const XML_KEY: [u8; 8] = [0x1F, 0x2D, 0x3C, 0x4B, 0x5A, 0x69, 0x78, 0xFF]; -const IV: &[u8] = b"0123456789abcdef"; +/// Default IV when no custom IV is set (SDK: BaichuanEncryptor uses this when *(this+0x1c) != 0x10). +const IV: [u8; 16] = *b"0123456789abcdef"; /// These are the encyption modes supported by the camera /// @@ -22,18 +70,18 @@ pub enum EncryptionProtocol { /// Latest cameras/firmwares use Aes with the key derived from /// the camera's password and the negotiated NONCE Aes { - /// The encryptor - enc: Aes128CfbEnc, - /// The decryptor - dec: Aes128CfbDec, + /// AES-128 key (16 bytes) + key: [u8; 16], + /// Master IV (16 bytes) - reset to this value for each packet + iv: [u8; 16], }, /// Same as Aes but the media stream is also encrypted and not just /// the control commands FullAes { - /// The encryptor - enc: Aes128CfbEnc, - /// The decryptor - dec: Aes128CfbDec, + /// AES-128 key (16 bytes) + key: [u8; 16], + /// Master IV (16 bytes) - reset to this value for each packet + iv: [u8; 16], }, } @@ -49,15 +97,15 @@ impl EncryptionProtocol { /// Helper to make aes pub fn aes(key: [u8; 16]) -> Self { EncryptionProtocol::Aes { - enc: Aes128CfbEnc::new(key.as_slice().into(), IV.into()), - dec: Aes128CfbDec::new(key.as_slice().into(), IV.into()), + key, + iv: IV, } } /// Helper to make full aes pub fn full_aes(key: [u8; 16]) -> Self { EncryptionProtocol::FullAes { - enc: Aes128CfbEnc::new(key.as_slice().into(), IV.into()), - dec: Aes128CfbDec::new(key.as_slice().into(), IV.into()), + key, + iv: IV, } } @@ -72,16 +120,34 @@ impl EncryptionProtocol { .map(|(key, i)| *i ^ key ^ (offset as u8)) .collect() } - EncryptionProtocol::Aes { dec, .. } | EncryptionProtocol::FullAes { dec, .. } => { + EncryptionProtocol::Aes { key, iv } | EncryptionProtocol::FullAes { key, iv } => { // AES decryption - + // CRITICAL: Based on Ghidra analysis, the app resets CFB state per packet. + // Each packet starts fresh with Master IV and num=0. + // We create a new decryptor for each packet to match this behavior. + // CFB state is still preserved WITHIN a packet (for multi-block packets). + let decryptor = Aes128CfbDec::new(key.as_slice().into(), iv.as_slice().into()); let mut decrypted = buf.to_vec(); - dec.clone().decrypt(&mut decrypted); + decryptor.decrypt(&mut decrypted); decrypted } } } + /// Decrypt with an explicit IV (e.g. per-packet IV from first 16 bytes of payload). + /// Only supported for Aes/FullAes; returns None for other protocols. + pub fn decrypt_with_iv(&self, packet_iv: &[u8; 16], buf: &[u8]) -> Option> { + match self { + EncryptionProtocol::Aes { key, .. } | EncryptionProtocol::FullAes { key, .. } => { + let decryptor = Aes128CfbDec::new(key.as_slice().into(), packet_iv.as_slice().into()); + let mut decrypted = buf.to_vec(); + decryptor.decrypt(&mut decrypted); + Some(decrypted) + } + _ => None, + } + } + /// Encrypt the data, offset comes from the header of the packet pub fn encrypt(&self, offset: u32, buf: &[u8]) -> Vec { match self { @@ -93,10 +159,15 @@ impl EncryptionProtocol { // Encrypt is the same as decrypt self.decrypt(offset, buf) } - EncryptionProtocol::Aes { enc, .. } | EncryptionProtocol::FullAes { enc, .. } => { + EncryptionProtocol::Aes { key, iv } | EncryptionProtocol::FullAes { key, iv } => { // AES encryption + // CRITICAL: Based on Ghidra analysis, the app resets CFB state per packet. + // Each packet starts fresh with Master IV and num=0. + // We create a new encryptor for each packet to match this behavior. + // CFB state is still preserved WITHIN a packet (for multi-block packets). + let encryptor = Aes128CfbEnc::new(key.as_slice().into(), iv.as_slice().into()); let mut encrypted = buf.to_vec(); - enc.clone().encrypt(&mut encrypted); + encryptor.encrypt(&mut encrypted); encrypted } } @@ -120,3 +191,74 @@ fn test_xml_crypto_roundtrip() { let encrypted = EncryptionProtocol::BCEncrypt.decrypt(0, &decrypted[..]); assert_eq!(encrypted, &zeros[..]); } + +#[test] +fn test_aes_cfb_per_packet_reset() { + // Test that CFB state resets per packet (matches app behavior from Ghidra analysis) + // Each packet should decrypt independently, starting fresh with Master IV + let key = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10]; + + let encryptor = EncryptionProtocol::aes(key); + let decryptor = EncryptionProtocol::aes(key); + + // Encrypt multiple packets sequentially (each starts fresh) + let packet1 = b"Packet 1 data"; + let packet2 = b"Packet 2 data"; + let packet3 = b"Packet 3 data"; + + let enc1 = encryptor.encrypt(0, packet1); + let enc2 = encryptor.encrypt(0, packet2); + let enc3 = encryptor.encrypt(0, packet3); + + // Decrypt packets sequentially (each starts fresh) + let dec1 = decryptor.decrypt(0, &enc1); + let dec2 = decryptor.decrypt(0, &enc2); + let dec3 = decryptor.decrypt(0, &enc3); + + // Verify all packets decrypt correctly (each packet is independent) + assert_eq!(dec1, packet1, "Packet 1 should decrypt correctly with per-packet reset"); + assert_eq!(dec2, packet2, "Packet 2 should decrypt correctly with per-packet reset"); + assert_eq!(dec3, packet3, "Packet 3 should decrypt correctly with per-packet reset"); +} + +#[test] +fn test_aes_cfb_within_packet() { + // Test that CFB state is preserved WITHIN a packet (for multi-block packets) + // A single large packet should decrypt correctly, even though packets are independent + let key = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10]; + + let encryptor = EncryptionProtocol::aes(key); + let decryptor = EncryptionProtocol::aes(key); + + // Large packet (multiple blocks - ~62 blocks for 1000 bytes) + let large_packet = vec![0xAA; 1000]; + + let encrypted = encryptor.encrypt(0, &large_packet); + let decrypted = decryptor.decrypt(0, &encrypted); + + assert_eq!(decrypted, large_packet, "Large packet should decrypt correctly with CFB state preserved within packet"); +} + +#[test] +fn test_full_aes_cfb_per_packet_reset() { + // Test FullAes variant - per-packet reset behavior + let key = [0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, + 0x90, 0xa0, 0xb0, 0xc0, 0xd0, 0xe0, 0xf0, 0x00]; + + let encryptor = EncryptionProtocol::full_aes(key); + let decryptor = EncryptionProtocol::full_aes(key); + + let packet1 = b"FullAes packet 1"; + let packet2 = b"FullAes packet 2"; + + let enc1 = encryptor.encrypt(0, packet1); + let enc2 = encryptor.encrypt(0, packet2); + + let dec1 = decryptor.decrypt(0, &enc1); + let dec2 = decryptor.decrypt(0, &enc2); + + assert_eq!(dec1, packet1, "FullAes packet 1 should decrypt correctly with per-packet reset"); + assert_eq!(dec2, packet2, "FullAes packet 2 should decrypt correctly with per-packet reset"); +} diff --git a/crates/core/src/bc/de.rs b/crates/core/src/bc/de.rs index 182bcc7ce..8ef4e68cf 100644 --- a/crates/core/src/bc/de.rs +++ b/crates/core/src/bc/de.rs @@ -34,8 +34,25 @@ impl<'a> Parser<&'a [u8], Bc, nom::error::VerboseError<&'a [u8]>> for BcParser<' } } +/// E1/v2 cameras always send 24-byte headers (20-byte standard + 4-byte payload_offset) for +/// replay binary (MSG 5/8), even when the class field doesn't match the values that trigger +/// `has_payload_offset`. The SDK determines header size from protocol version (device-level), +/// not from class (per-packet). When payload_offset is missing from bc_header, read the 4 bytes +/// here and set it — this gives bc_modern_msg the correct ext_len to separate Extension XML +/// from binary payload, enabling encryptPos/encryptLen extraction. fn bc_msg<'a>(context: &BcContext, buf: &'a [u8]) -> IResult<&'a [u8], Bc> { let (buf, header) = bc_header(buf)?; + let (buf, header) = if (header.msg_id == MSG_ID_REPLAY_START + || header.msg_id == MSG_ID_REPLAY_START_ALT) + && header.payload_offset.is_none() + { + let (buf, ext_len) = le_u32(buf)?; + let mut header = header; + header.payload_offset = Some(ext_len); + (buf, header) + } else { + (buf, header) + }; let (buf, body) = bc_body(context, &header, buf)?; let bc = Bc { @@ -107,7 +124,9 @@ fn bc_modern_msg<'a>( }; let mut in_binary = false; - let mut encrypted_len = None; + // E1/replay: when set with encrypt_region_len, only this region of the payload is decrypted (Ghidra: netc_query_param_t encryptPos/encryptLen). + let mut encrypt_region_start: Option = None; + let mut encrypt_region_len: Option = None; // Now we'll take the buffer that Nom gave a ref to and parse it. let extension = if ext_len > 0 { if context.debug { @@ -128,13 +147,25 @@ fn bc_modern_msg<'a>( })?; if let Extension { binary_data: Some(1), + encrypt_pos, encrypt_len, .. } = parsed { // In binary so tell the current context that we need to treat the payload as binary in_binary = true; - encrypted_len = encrypt_len; + // So continuation packets (same msg_num, no Extension) are also treated as binary and decrypted + context.binary_on_shared(header.msg_num); + encrypt_region_start = encrypt_pos; + encrypt_region_len = encrypt_len; + log::debug!( + "E1 Extension: msg_id={} msg_num={} ext_len={} encryptPos={:?} encryptLen={:?}", + header.msg_id, + header.msg_num, + ext_len, + encrypt_pos, + encrypt_len + ); } Some(parsed) } else { @@ -147,6 +178,13 @@ fn bc_modern_msg<'a>( // As binary let payload; if payload_len > 0 { + // E1 replay: camera may send extension+media in one payload with no separate extension block (ext_len=0). + // Treat as binary so two-stage decrypt (extension then media, IV reset) is attempted. + if ext_len == 0 + && (header.msg_id == MSG_ID_REPLAY_START || header.msg_id == MSG_ID_REPLAY_START_ALT) + { + in_binary = true; + } // Extract remainder of message as binary, if it exists const UNENCRYPTED: EncryptionProtocol = EncryptionProtocol::Unencrypted; const BC_ENCRYPTED: EncryptionProtocol = EncryptionProtocol::BCEncrypt; @@ -176,19 +214,130 @@ fn bc_modern_msg<'a>( _ => context.get_encrypted(), }; - let processed_payload_buf = - encryption_protocol.decrypt(header.channel_id as u32, payload_buf); - if context.in_bin_mode.contains(&(header.msg_num)) || in_binary { - payload = match (context.get_encrypted(), encrypted_len) { - (EncryptionProtocol::FullAes { .. }, Some(encrypted_len)) => { - // if if context.debug { - // log::trace!("Binary: {:X?}", &processed_payload_buf[0..30]); - // } - Some(BcPayloads::Binary( - processed_payload_buf[0..(encrypted_len as usize)].to_vec(), - )) + // Determine if this packet needs payload decryption. + // SDK behavior (handleResponseV20): when encryptLen is absent from the Extension, + // it stays at init value 0xFFFFFFFF; cast to signed = -1; the check + // `0 < (int)encryptLen` fails → decrypt is skipped → payload is plaintext. + // This applies to BOTH: + // (a) continuation packets (ext_len=0, no Extension at all), AND + // (b) packets with Extension containing binaryData=1 but no encryptLen tag. + // Only decrypt when encryptLen is explicitly present and > 0. + let is_continuation_binary = ext_len == 0 + && encrypt_region_len.is_none() + && context.in_bin_mode.borrow().contains(&(header.msg_num as u16)); + + // Replay diagnostic: log ext_len and branch so we can see if camera sends extension and if we treat as continuation. + if (header.msg_id == MSG_ID_REPLAY_START || header.msg_id == MSG_ID_REPLAY_START_ALT) + && payload_len > 0 + { + log::debug!( + "E1 replay branch: msg_num={} ext_len={} payload_len={} in_binary={} encrypt_region={} is_continuation={}", + header.msg_num, + ext_len, + payload_len, + in_binary, + encrypt_region_len.is_some(), + is_continuation_binary + ); + } + + let processed_payload_buf = if is_continuation_binary { + // SDK (handleResponseV20): for binary messages (MSG 3/5/8), only bytes + // [encryptPos, encryptPos+encryptLen) are decrypted. Continuation packets + // have no Extension XML so encryptLen stays at init value 0 → SDK skips + // decrypt entirely. Pass through as plaintext. + log::debug!( + "E1 replay: continuation packet msg_num={} len={} — no Extension, passing as plaintext (SDK: encryptLen=0)", + header.msg_num, + payload_buf.len() + ); + payload_buf.to_vec() + } else if let Some(len) = encrypt_region_len { + // SDK: only [encryptPos, encryptPos+encryptLen) of the binary payload is decrypted (payload_buf = after extension). + let start = encrypt_region_start.unwrap_or(0) as usize; + let len = len as usize; + if start <= payload_buf.len() && start.saturating_add(len) <= payload_buf.len() { + let mut out = payload_buf.to_vec(); + let decrypted = encryption_protocol.decrypt( + header.channel_id as u32, + &payload_buf[start..start + len], + ); + out[start..start + len].copy_from_slice(&decrypted); + out + } else { + // SDK errors when encryptPos+encryptLen > payload; does NOT fallback + // to full decrypt. Pass through as-is. + log::warn!( + "E1: encryptPos({})+encryptLen({}) exceeds payload({}), skipping binary decrypt", + start, len, payload_buf.len() + ); + payload_buf.to_vec() + } + } else if in_binary && encrypt_region_len.is_none() { + // SDK: Extension has binaryData=1 but no encryptLen → absent encryptLen + // defaults to 0xFFFFFFFF, (int)0xFFFFFFFF = -1, `0 < -1` fails → plaintext. + // E1 sends large video continuation packets this way (only the first packet + // of a BcMedia group carries explicit encryptLen for the encrypted header region). + log::debug!( + "E1 replay: binary packet with no encryptLen msg_num={} len={} — passing as plaintext", + header.msg_num, + payload_buf.len() + ); + payload_buf.to_vec() + } else { + encryption_protocol.decrypt(header.channel_id as u32, payload_buf) + }; + + // E1 replay: log first binary packet result so we can verify 00dc appears (key/IV/region correct). + if (header.msg_id == MSG_ID_REPLAY_START || header.msg_id == MSG_ID_REPLAY_START_ALT) + && (encrypt_region_len.is_some() || in_binary) + { + let has_00dc = processed_payload_buf.len() >= 4 + && (processed_payload_buf[..4] == *b"00dc" + || (processed_payload_buf.len() > 32 && processed_payload_buf[32..36] == *b"00dc")); + log::debug!( + "E1 replay first/region packet: msg_num={} encrypt_region={:?} payload_len={} processed_len={} has_00dc={} first_32={:02x?}", + header.msg_num, + encrypt_region_len.map(|l| (encrypt_region_start.unwrap_or(0), l)), + payload_buf.len(), + processed_payload_buf.len(), + has_00dc, + &processed_payload_buf[..processed_payload_buf.len().min(32)] + ); + } + + if context.in_bin_mode.borrow().contains(&(header.msg_num)) || in_binary { + payload = if context.replay_raw_binary + && (header.msg_id == MSG_ID_REPLAY_START + || header.msg_id == MSG_ID_REPLAY_START_ALT + || header.msg_id == MSG_ID_REPLAY_DESKTOP) + { + // Skip decryption for replay (test: some cameras send replay in plaintext). + // Set NEOLINK_REPLAY_RAW=1 to enable. + log::debug!("Replay: passing binary payload without decryption (NEOLINK_REPLAY_RAW)"); + Some(BcPayloads::Binary(payload_buf.to_vec())) + } else { + match (context.get_encrypted(), encrypt_region_len) { + (EncryptionProtocol::FullAes { .. }, Some(_)) => { + // E1 or FullAes with encryptLen: we decrypted only the region; rest is plaintext. + Some(BcPayloads::Binary(processed_payload_buf.to_vec())) + } + (EncryptionProtocol::Unencrypted, _) => { + Some(BcPayloads::Binary(payload_buf.to_vec())) + } + (EncryptionProtocol::Aes { .. }, None) => { + // Aes cameras encrypt control messages only; binary media streams are plaintext. + // Applying AES decrypt to plaintext BcMedia produces garbled output (e.g. Argus 2). + log::debug!( + "Aes (non-FullAes) camera: binary payload without explicit encryptLen — passing as plaintext (media not encrypted)" + ); + Some(BcPayloads::Binary(payload_buf.to_vec())) + } + _ => { + // FullAes without encryptLen (plaintext passthrough), or BCEncrypt. + Some(BcPayloads::Binary(processed_payload_buf.to_vec())) + } } - _ => Some(BcPayloads::Binary(payload_buf.to_vec())), }; } else { if context.debug { @@ -416,7 +565,7 @@ mod tests { let sample1 = include_bytes!("samples/modern_video_start1.bin"); let sample2 = include_bytes!("samples/modern_video_start2.bin"); - let mut context = BcContext::new_with_encryption(EncryptionProtocol::BCEncrypt); + let context = BcContext::new_with_encryption(EncryptionProtocol::BCEncrypt); let msg1 = Bc::deserialize(&context, &mut BytesMut::from(&sample1[..])).unwrap(); match msg1.body { @@ -433,7 +582,7 @@ mod tests { _ => panic!(), } - context.in_bin_mode.insert(msg1.meta.msg_num); + context.in_bin_mode.borrow_mut().insert(msg1.meta.msg_num); let msg2 = Bc::deserialize(&context, &mut BytesMut::from(&sample2[..])).unwrap(); match msg2.body { BcBody::ModernMsg(ModernMsg { @@ -577,4 +726,70 @@ mod tests { }) if version == "1.1" && stream_type == Some("mainStream".to_string()) ); } + + #[test] + fn test_bc_e1_mixed_replay() { + init(); + // Use BCEncrypt (default AES-128-CFB) + let context = BcContext::new_with_encryption(EncryptionProtocol::BCEncrypt); + + // 1. Enable in_bin_mode for msg_num=100 + let msg_num = 100u16; + context.in_bin_mode.borrow_mut().insert(msg_num); + + // 2. Test XML Packet (Plaintext Passthrough) + // E1 sends plaintext XML even though it's a "continuation binary" packet (no extension). + // Our fix should detect "Plaintext"; + let mut buf_b = Vec::new(); + // Header: Magic, MsgID=5 (Replay), BodyLen, Chan=0, Stream=0, MsgNum=100, Class=0, PayloadOffset=0 + buf_b.extend_from_slice(&0x0abcdef0u32.to_le_bytes()); // MAGIC_HEADER + buf_b.extend_from_slice(&5u32.to_le_bytes()); // MsgID 5 + buf_b.extend_from_slice(&(xml_payload.len() as u32).to_le_bytes()); // BodyLen + buf_b.push(0); // Chan + buf_b.push(0); // Stream + buf_b.extend_from_slice(&msg_num.to_le_bytes()); // MsgNum + buf_b.extend_from_slice(&0u16.to_le_bytes()); // Response + buf_b.extend_from_slice(&0u16.to_le_bytes()); // Class=0 → has_payload_offset=true + buf_b.extend_from_slice(&0u32.to_le_bytes()); // PayloadOffset=0 (ext_len=0) + + buf_b.extend_from_slice(xml_payload); + + let msg_b = Bc::deserialize(&context, &mut BytesMut::from(&buf_b[..])).unwrap(); + match msg_b.body { + BcBody::ModernMsg(ModernMsg { payload: Some(BcPayloads::Binary(data)), .. }) => { + // Should be Passthrough (Plaintext) + assert_eq!(data, xml_payload, "XML payload should be passed through plaintext"); + }, + _ => panic!("Expected Binary payload for XML packet, got {:?}", msg_b.body), + } + + // 3. Test Binary Packet (Encrypted) + // E1 sends encrypted binary data (e.g. video) in continuation packets. + // Our fix should decrypt this. + let raw_bin_payload = b"\xca\x75\xbe\x31\x81\x78\xbd\x31"; + let mut buf_c = Vec::new(); + // Header: Same as B but different payload + buf_c.extend_from_slice(&0x0abcdef0u32.to_le_bytes()); + buf_c.extend_from_slice(&5u32.to_le_bytes()); + buf_c.extend_from_slice(&(raw_bin_payload.len() as u32).to_le_bytes()); + buf_c.push(0); + buf_c.push(0); + buf_c.extend_from_slice(&msg_num.to_le_bytes()); + buf_c.extend_from_slice(&0u16.to_le_bytes()); + buf_c.extend_from_slice(&0u16.to_le_bytes()); + buf_c.extend_from_slice(&0u32.to_le_bytes()); // PayloadOffset=0 + + buf_c.extend_from_slice(raw_bin_payload); + + let msg_c = Bc::deserialize(&context, &mut BytesMut::from(&buf_c[..])).unwrap(); + match msg_c.body { + BcBody::ModernMsg(ModernMsg { payload: Some(BcPayloads::Binary(data)), .. }) => { + // SDK: continuation binary (no Extension) has encryptLen=0 → not decrypted. + // Data is passed through as plaintext. + assert_eq!(data, raw_bin_payload, "Continuation binary should pass through as plaintext (SDK: no encryptLen)"); + }, + _ => panic!("Expected Binary payload for Binary packet, got {:?}", msg_c.body), + } + } } diff --git a/crates/core/src/bc/e1.rs b/crates/core/src/bc/e1.rs new file mode 100644 index 000000000..5bd116750 --- /dev/null +++ b/crates/core/src/bc/e1.rs @@ -0,0 +1,72 @@ +//! E1 replay envelope helpers: find end of Extension XML so we can split off BcMedia payload. +//! Handles \n, \r\n, (no newline), and fallback to first "00dc" (BcMedia magic). + +/// BcMedia video magic (ASCII "00dc" LE) – fallback to find media start when XML closing varies. +const BCMEDIA_MAGIC_00DC: &[u8] = b"00dc"; + +/// Find the byte offset of the first byte *after* the E1 Extension XML (start of media payload). +/// Tries: `` (case-insensitive) then optional `\n` or `\r\n`; then fallback: first "00dc". +/// `search_from` is the offset in `data` at which XML starts (e.g. 0 or 32 if 32-byte prefix). +/// Returns None if no end found; otherwise Some(offset) with offset <= data.len(). +pub(crate) fn e1_xml_end_offset(data: &[u8], search_from: usize) -> Option { + if search_from >= data.len() { + return None; + } + let tail = &data[search_from..]; + let first_00dc = tail + .windows(BCMEDIA_MAGIC_00DC.len()) + .position(|w| w == BCMEDIA_MAGIC_00DC) + .map(|i| search_from + i); + + const EXT_END: &[u8] = b""; + // Match case-insensitively so or from camera still work. + let ext_tag_pos = tail + .windows(EXT_END.len()) + .position(|w| w.eq_ignore_ascii_case(EXT_END)); + + let payload_start = match ext_tag_pos { + Some(pos) => { + let after_tag = search_from + pos + EXT_END.len(); + let mut ps = after_tag; + while ps < data.len() && (data[ps] == b'\n' || data[ps] == b'\r') { + ps += 1; + } + // If "00dc" appears before this, use it so we never skip into media. + match first_00dc { + Some(off) if off < ps => off, + _ => ps, + } + } + None => { + // No — use first "00dc" as media start if present (some cameras omit tag). + return first_00dc; + } + }; + Some(payload_start) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn e1_xml_end_offset_case_insensitive() { + // (canonical) + let a = b"\n\n00dc"; + assert_eq!(e1_xml_end_offset(a, 0), Some(46)); // after \n + // (lowercase) — camera may send this + let b = b"\n\n00dc"; + assert_eq!(e1_xml_end_offset(b, 0), Some(46)); + // + let c = b"\n\r\n00dc"; + assert_eq!(e1_xml_end_offset(c, 0), Some(47)); + } + + #[test] + fn e1_xml_end_offset_no_tag_uses_00dc() { + let d = b"\nbar00dcH264"; + assert_eq!(e1_xml_end_offset(d, 0), Some(36)); // first 00dc + let e = b"no xml 00dc"; + assert_eq!(e1_xml_end_offset(e, 0), Some(7)); // "00dc" at index 7 + } +} diff --git a/crates/core/src/bc/mod.rs b/crates/core/src/bc/mod.rs index 61dff921b..433581e20 100644 --- a/crates/core/src/bc/mod.rs +++ b/crates/core/src/bc/mod.rs @@ -42,3 +42,4 @@ pub mod xml; pub mod crypto; pub(crate) mod codex; +pub(crate) mod e1; \ No newline at end of file diff --git a/crates/core/src/bc/model.rs b/crates/core/src/bc/model.rs index a1e1cf27a..7276d8b2e 100644 --- a/crates/core/src/bc/model.rs +++ b/crates/core/src/bc/model.rs @@ -2,6 +2,7 @@ use crate::Credentials; pub use super::crypto::EncryptionProtocol; pub use super::xml::{BcPayloads, BcXml, Extension}; +use std::cell::RefCell; use std::collections::HashSet; pub(super) const MAGIC_HEADER: u32 = 0x0abcdef0; @@ -104,6 +105,43 @@ pub const MSG_ID_GET_ZOOM_FOCUS: u32 = 294; pub const MSG_ID_SET_ZOOM_FOCUS: u32 = 295; /// Get the floodlight task xml pub const MSG_ID_FLOODLIGHT_TASKS_READ: u32 = 438; +/// Get HDD/SD disk list (HDDInfoList) +pub const MSG_ID_HDD_INFO_LIST: u32 = 102; +/// Format disk(s) (HddInitList) +pub const MSG_ID_HDD_INIT_LIST: u32 = 103; +/// Replay: start playback (FileInfoList / BCMedia stream) +pub const MSG_ID_REPLAY_START: u32 = 5; +/// Replay: alternate start (some cameras accept this instead of MSG 5 for replay) +/// NOTE: This is the same wire ID as MSG_ID_DOWNLOAD_FILE_BY_NAME (NET_DOWNLOAD_V20 in Android SDK). +/// The camera dispatches on payload format: XML FileInfoList = replay; binary BC_DOWNLOAD_BY_NAME_INFO = file download. +pub const MSG_ID_REPLAY_START_ALT: u32 = 8; +/// Download a specific SD card file by name (NET_DOWNLOAD_V20, binary BC_DOWNLOAD_BY_NAME_INFO payload 0xD48 bytes). +/// Confirmed from Android SDK Ghidra analysis: table entry key=8, BaichuanDownloader::downloadFileByName sends MSG 8. +pub const MSG_ID_DOWNLOAD_FILE_BY_NAME: u32 = 8; +/// Stop download-file-by-name (NET_DOWNLOAD_STOP_V20, key=9). No payload. +pub const MSG_ID_DOWNLOAD_FILE_STOP: u32 = 9; +/// Replay: stop playback +pub const MSG_ID_REPLAY_STOP: u32 = 7; +/// Replay: file details by name (FileInfoList) +pub const MSG_ID_REPLAY_FILE_BY_NAME: u32 = 13; +/// Replay: get file list handle for day (FileInfoList) +pub const MSG_ID_REPLAY_FILE_LIST_HANDLE: u32 = 14; +/// Replay: list files by handle (FileInfoList) +pub const MSG_ID_REPLAY_FILE_LIST: u32 = 15; +/// Replay: FileInfoList variant (camera replies 200 OK only) +pub const MSG_ID_REPLAY_FILE_LIST_16: u32 = 16; +/// Replay: seek to position (ReplaySeek) +pub const MSG_ID_REPLAY_SEEK: u32 = 123; +/// Replay: desktop app binary replay (CMD 0x17d, payload 0x944 = 20-byte inner + 0x930 body) +pub const MSG_ID_REPLAY_DESKTOP: u32 = 0x17d; +/// Replay: get day records in range (DayRecords) +pub const MSG_ID_DAY_RECORDS: u32 = 142; +/// Download by time range (BC_DOWNLOAD_BY_TIME_INFO binary 0x480 bytes). End-of-stream = response 331. +pub const MSG_ID_DOWNLOAD_BY_TIME: u32 = 143; +/// Stop download-by-time (no payload) +pub const MSG_ID_DOWNLOAD_STOP: u32 = 144; +/// Alarm video search: find recordings by alarm/AI type (findAlarmVideo). 0xAF = 175. +pub const MSG_ID_ALARM_VIDEO_SEARCH: u32 = 175; /// An empty password in legacy format pub const EMPTY_LEGACY_PASSWORD: &str = @@ -223,9 +261,12 @@ pub struct BcMeta { #[derive(Debug)] pub(crate) struct BcContext { pub(crate) credentials: Credentials, - pub(crate) in_bin_mode: HashSet, + pub(crate) in_bin_mode: RefCell>, pub(crate) encryption_protocol: EncryptionProtocol, pub(crate) debug: bool, + /// If true, replay binary payloads (MSG 5/8) are passed through without decryption. + /// Set via env NEOLINK_REPLAY_RAW=1 to test cameras that send replay in plaintext. + pub(crate) replay_raw_binary: bool, } impl Bc { @@ -263,11 +304,15 @@ impl Bc { impl BcContext { pub(crate) fn new(credentials: Credentials) -> BcContext { + let replay_raw_binary = std::env::var("NEOLINK_REPLAY_RAW") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); BcContext { credentials, - in_bin_mode: HashSet::new(), + in_bin_mode: RefCell::new(HashSet::new()), encryption_protocol: EncryptionProtocol::Unencrypted, debug: false, + replay_raw_binary, } } @@ -275,9 +320,10 @@ impl BcContext { pub(crate) fn new_with_encryption(encryption_protocol: EncryptionProtocol) -> BcContext { BcContext { credentials: Default::default(), - in_bin_mode: HashSet::new(), + in_bin_mode: RefCell::new(HashSet::new()), encryption_protocol, debug: false, + replay_raw_binary: false, } } @@ -290,12 +336,17 @@ impl BcContext { } pub(crate) fn binary_on(&mut self, msg_id: u16) { - self.in_bin_mode.insert(msg_id); + self.in_bin_mode.borrow_mut().insert(msg_id); } #[allow(unused)] // Used in tests pub(crate) fn binary_off(&mut self, msg_id: u16) { - self.in_bin_mode.remove(&msg_id); + self.in_bin_mode.borrow_mut().remove(&msg_id); + } + + /// Enable binary mode for msg_id (used from parser with &context). + pub(crate) fn binary_on_shared(&self, msg_id: u16) { + self.in_bin_mode.borrow_mut().insert(msg_id); } pub(crate) fn debug_on(&mut self) { diff --git a/crates/core/src/bc/xml.rs b/crates/core/src/bc/xml.rs index 16048f585..6003f15a4 100644 --- a/crates/core/src/bc/xml.rs +++ b/crates/core/src/bc/xml.rs @@ -141,6 +141,24 @@ pub struct BcXml { /// Read and write users #[serde(rename = "UserList", skip_serializing_if = "Option::is_none")] pub user_list: Option, + /// HDD/SD disk list (MSG 102 response) + #[serde(rename = "HddInfoList", skip_serializing_if = "Option::is_none")] + pub hdd_info_list: Option, + /// Format disk request wrapper (MSG 103 request) + #[serde(rename = "formatExpandCfg", skip_serializing_if = "Option::is_none")] + pub format_expand_cfg: Option, + /// Day records in range (MSG 142 request/response) + #[serde(rename = "DayRecords", skip_serializing_if = "Option::is_none")] + pub day_records: Option, + /// File list / file info (MSG 13, 14, 15, 16, 5, 7 request/response) + #[serde(rename = "FileInfoList", skip_serializing_if = "Option::is_none")] + pub file_info_list: Option, + /// Replay seek (MSG 123 request) + #[serde(rename = "ReplaySeek", skip_serializing_if = "Option::is_none")] + pub replay_seek: Option, + /// Alarm video search (MSG 175 request/response) + #[serde(rename = "findAlarmVideo", skip_serializing_if = "Option::is_none")] + pub find_alarm_video: Option, } impl BcXml { @@ -329,7 +347,10 @@ pub struct Extension { /// Encrypted binary has this to verify successful decryption #[serde(rename = "checkValue", skip_serializing_if = "Option::is_none")] pub check_value: Option, - /// Used in newer encrypted payload packets + /// E1/replay: start offset in the following binary payload that is encrypted (Ghidra: netc_query_param_t +0x138). + #[serde(rename = "encryptPos", skip_serializing_if = "Option::is_none")] + pub encrypt_pos: Option, + /// E1/replay: length of the encrypted region (Ghidra: netc_query_param_t +0x13c). With encrypt_pos, only this region is decrypted. #[serde(rename = "encryptLen", skip_serializing_if = "Option::is_none")] pub encrypt_len: Option, } @@ -345,6 +366,7 @@ impl Default for Extension { rf_id: None, check_pos: None, check_value: None, + encrypt_pos: None, encrypt_len: None, } } @@ -1615,6 +1637,274 @@ pub struct User { pub user_set_state: String, } +/// HDD/SD disk list (MSG 102 response) +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize)] +pub struct HddInfoList { + /// XML Version + #[serde(rename = "@version")] + pub version: String, + /// List of disk info entries + #[serde(default, rename = "HddInfo")] + pub hdd_info: Vec, +} + +/// Single disk/SD slot info +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize)] +pub struct HddInfo { + /// Disk/slot number + pub number: u8, + /// Capacity in GB (optional in some cameras) + #[serde(skip_serializing_if = "Option::is_none")] + pub capacity: Option, + /// Format status (e.g. 1 = formatted) + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, + /// Mount status (e.g. 1 = mounted) + #[serde(skip_serializing_if = "Option::is_none")] + pub mount: Option, + /// Remaining size in GB + #[serde(rename = "remainSize", skip_serializing_if = "Option::is_none")] + pub remain_size: Option, + /// Remaining size in MB + #[serde(rename = "remainSizeM", skip_serializing_if = "Option::is_none")] + pub remain_size_m: Option, + /// Used flag/count (from RE notes) + #[serde(skip_serializing_if = "Option::is_none")] + pub used: Option, + /// Type (from RE: 0→1, 1→2, else 0) + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub type_: Option, +} + +/// Wrapper for format-disk request (MSG 103) +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize)] +pub struct FormatExpandCfg { + /// List of disks to format with version and HddInit entries + #[serde(rename = "HddInitList")] + pub hdd_init_list: HddInitList, +} + +/// Format disk list (request body for MSG 103) +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize)] +pub struct HddInitList { + /// XML Version + #[serde(rename = "@version")] + pub version: String, + /// Disks to format + #[serde(rename = "HddInit")] + pub hdd_init: Vec, +} + +/// Single disk format request +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize)] +pub struct HddInit { + /// Disk/slot index (from HddInfo.number) + #[serde(rename = "initId")] + pub init_id: u8, + /// 0 = quick format, 1 = full format + #[serde(rename = "type")] + pub type_: u8, +} + +// ---------- Replay (SD playback) ---------- + +/// Replay time: year, month, day, hour, minute, second (used in startTime, endTime, seekTime). +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize, Clone)] +pub struct ReplayDateTime { + /// Year + pub year: i32, + /// Month (1–12) + pub month: u8, + /// Day of month (1–31) + pub day: u8, + /// Hour (0–23) + pub hour: u8, + /// Minute (0–59) + pub minute: u8, + /// Second (0–59) + pub second: u8, +} + +/// Day records request/response (MSG 142). +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize, Clone)] +pub struct DayRecords { + /// XML version + #[serde(rename = "@version")] + pub version: String, + /// Start of query range + #[serde(rename = "startTime")] + pub start_time: ReplayDateTime, + /// End of query range + #[serde(rename = "endTime")] + pub end_time: ReplayDateTime, + /// List of day records (request: one entry; response: may be extended) + #[serde(rename = "DayRecordList")] + pub day_record_list: DayRecordList, + /// Present in response: which days have recordings (e.g. type "normal") + #[serde(rename = "dayTypeList", skip_serializing_if = "Option::is_none")] + pub day_type_list: Option, +} + +/// List of day record entries for DayRecords. +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize, Clone)] +pub struct DayRecordList { + /// Day record entries + #[serde(rename = "DayRecord")] + pub day_record: Vec, +} + +/// Single day record (index + channel). +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize, Clone)] +pub struct DayRecord { + /// Day index in range (0 = first day) + pub index: u8, + /// Channel ID + #[serde(rename = "channelId")] + pub channel_id: u8, +} + +/// Response list of day types (which days have recordings). +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize, Clone)] +pub struct DayTypeList { + /// Day type entries + #[serde(rename = "dayType")] + pub day_type: Vec, +} + +/// Day type in DayTypeList (e.g. "normal"). +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize, Clone)] +pub struct DayType { + /// Day index + pub index: u8, + /// Type string (e.g. "normal") + #[serde(rename = "type")] + pub type_: String, +} + +/// FileInfoList wrapper (MSG 13, 14, 15, 16, 5, 7). +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize)] +pub struct FileInfoList { + /// XML version (optional in request) + #[serde(rename = "@version", skip_serializing_if = "Option::is_none")] + pub version: Option, + /// File info entries + #[serde(rename = "FileInfo", default)] + pub file_info: Vec, +} + +/// Single file info (request/response; fields vary by message). +/// +/// Field order matches Android app MSG 8 start-replay (BaichuanDownloader): uid, Id, name, +/// channelId, supportSub, streamType, startTime. Other fields follow for file-list/stop etc. +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize, Clone)] +pub struct FileInfo { + /// UID (optional; Android sends when non-empty; use e.g. 0 for MSG 8 start replay on E1) + #[serde(rename = "uid", skip_serializing_if = "Option::is_none")] + pub uid: Option, + /// File/list id string (optional; Android "Id" at iVar2+0x424 for MSG 8) + #[serde(rename = "Id", skip_serializing_if = "Option::is_none")] + pub id: Option, + /// File name (e.g. from file list or for stop) + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Channel ID + #[serde(rename = "channelId", skip_serializing_if = "Option::is_none")] + pub channel_id: Option, + /// Support sub stream (0 or 1) + #[serde(rename = "supportSub", skip_serializing_if = "Option::is_none")] + pub support_sub: Option, + /// Stream type (e.g. mainStream, subStream) + #[serde(rename = "streamType", skip_serializing_if = "Option::is_none")] + pub stream_type: Option, + /// Start time of recording + #[serde(rename = "startTime", skip_serializing_if = "Option::is_none")] + pub start_time: Option, + /// Handle from get-file-list (used in list-by-handle) + #[serde(skip_serializing_if = "Option::is_none")] + pub handle: Option, + /// Record type (e.g. manual,sched,md) + #[serde(rename = "recordType", skip_serializing_if = "Option::is_none")] + pub record_type: Option, + /// Size low 32 bits (bytes) + #[serde(rename = "sizeL", skip_serializing_if = "Option::is_none")] + pub size_l: Option, + /// Size high 32 bits (bytes) + #[serde(rename = "sizeH", skip_serializing_if = "Option::is_none")] + pub size_h: Option, + /// End time of recording + #[serde(rename = "endTime", skip_serializing_if = "Option::is_none")] + pub end_time: Option, + /// File type (e.g. mp4) + #[serde(rename = "fileType", skip_serializing_if = "Option::is_none")] + pub file_type: Option, + /// Contains audio flag + #[serde(rename = "containsAudio", skip_serializing_if = "Option::is_none")] + pub contains_audio: Option, + /// Play speed (for playback) + #[serde(rename = "playSpeed", skip_serializing_if = "Option::is_none")] + pub play_speed: Option, +} + +/// Replay seek request (MSG 123). +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize)] +pub struct ReplaySeek { + /// XML version + #[serde(rename = "@version")] + pub version: String, + /// Channel ID + #[serde(rename = "channelId")] + pub channel_id: u8, + /// Sequence (epoch seconds) + pub seq: u32, + /// Seek target time + #[serde(rename = "seekTime")] + pub seek_time: ReplayDateTime, +} + +/// Alarm video search request/response (MSG 175). +/// +/// START (search): channelId + streamType + startTime + endTime + alarmType + eventAlarmType items. +/// DO (paginate): just fileHandle from previous response. +/// Response: channelId + fileHandle + streamType + alarmType + startTime + endTime. +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize, Clone)] +pub struct FindAlarmVideo { + /// XML version + #[serde(rename = "@version", skip_serializing_if = "Option::is_none")] + pub version: Option, + /// Channel ID + #[serde(rename = "channelId", skip_serializing_if = "Option::is_none")] + pub channel_id: Option, + /// File handle (for pagination; omit in initial search) + #[serde(rename = "fileHandle", skip_serializing_if = "Option::is_none")] + pub file_handle: Option, + /// Stream type (0 = mainStream, 1 = subStream) + #[serde(rename = "streamType", skip_serializing_if = "Option::is_none")] + pub stream_type: Option, + /// Don't search video (0 = search video, 1 = events only) + #[serde(rename = "notSearchVideo", skip_serializing_if = "Option::is_none")] + pub not_search_video: Option, + /// Start of search range + #[serde(rename = "startTime", skip_serializing_if = "Option::is_none")] + pub start_time: Option, + /// End of search range + #[serde(rename = "endTime", skip_serializing_if = "Option::is_none")] + pub end_time: Option, + /// Alarm type summary string (e.g. "md, people, vehicle") + #[serde(rename = "alarmType", skip_serializing_if = "Option::is_none")] + pub alarm_type: Option, + /// Individual alarm type items (each `tag`) + #[serde(rename = "eventAlarmType", skip_serializing_if = "Option::is_none")] + pub event_alarm_type: Option, +} + +/// List of alarm type items for findAlarmVideo ``. +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize, Clone)] +pub struct EventAlarmType { + /// Individual alarm type items + #[serde(rename = "i", default)] + pub items: Vec, +} + /// Convience function to return the xml version used throughout the library pub fn xml_ver() -> String { "1.1".to_string() @@ -1894,6 +2184,7 @@ fn test_enc3_extension() { match b { Extension { encrypt_len: Some(1024), + encrypt_pos: None, binary_data: Some(1), check_pos: Some(0), check_value: Some(1667510320), @@ -1902,6 +2193,27 @@ fn test_enc3_extension() { _ => panic!(), } + // E1: encryptPos + encryptLen (region decrypt) + let sample_pos = indoc!( + r#" + + 1 + 0 + 32 + + "# + ); + let b = Extension::try_parse(sample_pos.as_bytes()).unwrap(); + match b { + Extension { + binary_data: Some(1), + encrypt_pos: Some(0), + encrypt_len: Some(32), + .. + } => {} + _ => panic!(), + } + let sample = indoc!( r#" diff --git a/crates/core/src/bc_protocol.rs b/crates/core/src/bc_protocol.rs index b3ad9d90e..c92f0514d 100644 --- a/crates/core/src/bc_protocol.rs +++ b/crates/core/src/bc_protocol.rs @@ -16,7 +16,9 @@ mod abilityinfo; mod battery; mod connection; mod credentials; +mod disk; mod email; +mod replay; mod errors; mod floodlight; mod keepalive; diff --git a/crates/core/src/bc_protocol/abilityinfo.rs b/crates/core/src/bc_protocol/abilityinfo.rs index 40115b395..d89c9a786 100644 --- a/crates/core/src/bc_protocol/abilityinfo.rs +++ b/crates/core/src/bc_protocol/abilityinfo.rs @@ -48,7 +48,7 @@ impl BcCamera { Ok(ability_info) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Expected AbilityInfo xml but it was not recieved", }) } diff --git a/crates/core/src/bc_protocol/battery.rs b/crates/core/src/bc_protocol/battery.rs index 6fd1f1caf..29a5ca09e 100644 --- a/crates/core/src/bc_protocol/battery.rs +++ b/crates/core/src/bc_protocol/battery.rs @@ -123,7 +123,7 @@ impl BcCamera { Ok(battery_info) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not accept the battery info (maybe no battery) command.", }) } diff --git a/crates/core/src/bc_protocol/connection/bcconn.rs b/crates/core/src/bc_protocol/connection/bcconn.rs index 06a5d0597..7a8e4192f 100644 --- a/crates/core/src/bc_protocol/connection/bcconn.rs +++ b/crates/core/src/bc_protocol/connection/bcconn.rs @@ -113,7 +113,7 @@ impl BcConnection { Ok(()) } - pub async fn subscribe(&self, msg_id: u32, msg_num: u16) -> Result { + pub async fn subscribe(&self, msg_id: u32, msg_num: u16) -> Result> { let (tx, rx) = channel(100); self.poll_commander .send(PollCommand::AddSubscriber(msg_id, Some(msg_num), tx)) @@ -150,7 +150,7 @@ impl BcConnection { /// The command Snap that grabs a jpeg payload is an example of this /// /// This function creates a temporary handle to grab this single message - pub async fn subscribe_to_id(&self, msg_id: u32) -> Result { + pub async fn subscribe_to_id(&self, msg_id: u32) -> Result> { let (tx, rx) = channel(100); self.poll_commander .send(PollCommand::AddSubscriber(msg_id, None, tx)) diff --git a/crates/core/src/bc_protocol/disk.rs b/crates/core/src/bc_protocol/disk.rs new file mode 100644 index 000000000..d7549ec00 --- /dev/null +++ b/crates/core/src/bc_protocol/disk.rs @@ -0,0 +1,122 @@ +//! HDD/SD disk list and format (MSG 102, MSG 103). + +use super::{BcCamera, Error, Result}; +use crate::bc::{ + model::*, + xml::{xml_ver, BcPayloads, BcXml, FormatExpandCfg, HddInit, HddInitList, HddInfoList}, +}; + +impl BcCamera { + /// Get the HDD/SD disk list (MSG 102, HddInfoList). + /// Returns the list of disks/slots with capacity, mount and remain size. + pub async fn get_hdd_list(&self) -> Result { + let connection = self.get_connection(); + let msg_num = self.new_message_num(); + let mut sub = connection + .subscribe(MSG_ID_HDD_INFO_LIST, msg_num) + .await?; + + let msg = Bc { + meta: BcMeta { + msg_id: MSG_ID_HDD_INFO_LIST, + channel_id: self.channel_id, + msg_num, + stream_type: 0, + response_code: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: None, + }), + }; + + sub.send(msg).await?; + let msg = sub.recv().await?; + + if msg.meta.response_code != 200 { + return Err(Error::CameraServiceUnavailable { + id: msg.meta.msg_id, + code: msg.meta.response_code, + }); + } + + if let BcBody::ModernMsg(ModernMsg { + payload: + Some(BcPayloads::BcXml(BcXml { + hdd_info_list: Some(data), + .. + })), + .. + }) = msg.body + { + Ok(data) + } else { + Err(Error::UnintelligibleReply { + _reply: std::sync::Arc::new(Box::new(msg)), + why: "Expected HddInfoList xml but it was not received", + }) + } + } + + /// Format one or more disks (MSG 103, HddInitList). + /// `init_ids`: disk/slot numbers (from [get_hdd_list](Self::get_hdd_list), e.g. `HddInfo.number`). + /// `full_format`: `false` = quick format, `true` = full format. + pub async fn format_disk(&self, init_ids: &[u8], full_format: bool) -> Result<()> { + if init_ids.is_empty() { + return Err(Error::InvalidArgument { + argument: "init_ids".to_string(), + value: "must specify at least one disk to format".to_string(), + }); + } + + let connection = self.get_connection(); + let msg_num = self.new_message_num(); + let mut sub = connection + .subscribe(MSG_ID_HDD_INIT_LIST, msg_num) + .await?; + + let hdd_init_list = HddInitList { + version: xml_ver(), + hdd_init: init_ids + .iter() + .map(|&init_id| HddInit { + init_id, + type_: if full_format { 1 } else { 0 }, + }) + .collect(), + }; + + let xml = BcXml { + format_expand_cfg: Some(FormatExpandCfg { hdd_init_list }), + ..Default::default() + }; + + let msg = Bc { + meta: BcMeta { + msg_id: MSG_ID_HDD_INIT_LIST, + channel_id: self.channel_id, + msg_num, + stream_type: 0, + response_code: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: Some(BcPayloads::BcXml(xml)), + }), + }; + + sub.send(msg).await?; + let msg = sub.recv().await?; + + if msg.meta.response_code == 200 { + Ok(()) + } else { + Err(Error::UnintelligibleReply { + _reply: std::sync::Arc::new(Box::new(msg)), + why: "Camera did not accept the format command", + }) + } + } +} diff --git a/crates/core/src/bc_protocol/email.rs b/crates/core/src/bc_protocol/email.rs index d855a47a9..d7ac63f20 100644 --- a/crates/core/src/bc_protocol/email.rs +++ b/crates/core/src/bc_protocol/email.rs @@ -44,7 +44,7 @@ impl BcCamera { Ok(email) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Expected Email xml but it was not recieved", }) } @@ -162,7 +162,7 @@ impl BcCamera { Ok(email_task) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Expected EmailTask xml but it was not recieved", }) } diff --git a/crates/core/src/bc_protocol/errors.rs b/crates/core/src/bc_protocol/errors.rs index 8f16a1319..559000a9d 100644 --- a/crates/core/src/bc_protocol/errors.rs +++ b/crates/core/src/bc_protocol/errors.rs @@ -27,19 +27,21 @@ pub enum Error { TimeTryFrom(#[from] time::error::TryFromParsed), /// Raised when a Bc reply was not understood - #[error("Communication error")] + #[error("Communication error: {why}")] UnintelligibleReply { - /// The Bc packet that was not understood - reply: std::sync::Arc>, + /// The Bc packet that was not understood (use .unintelligible_reply() or match _reply) + #[allow(dead_code)] + _reply: std::sync::Arc>, /// The message attached to the error why: &'static str, }, /// Raised when a BcXml reply was not understood - #[error("Communication error")] + #[error("Communication error: {why}")] UnintelligibleXml { /// The Bc packet that was not understood - reply: std::sync::Arc>, + #[allow(dead_code)] + _reply: std::sync::Arc>, /// The message attached to the error why: &'static str, }, @@ -158,6 +160,15 @@ pub enum Error { msg_id: u32, }, + /// Raised when an invalid argument is passed to a command + #[error("Invalid argument {argument}: {value}")] + InvalidArgument { + /// Argument name + argument: String, + /// Description of the problem + value: String, + }, + /// Raised when a new encyrption byte is observed #[error("Unknown encryption: {0:x?}")] UnknownEncryption(usize), diff --git a/crates/core/src/bc_protocol/floodlight.rs b/crates/core/src/bc_protocol/floodlight.rs index 6ccea20a6..2dabaf716 100644 --- a/crates/core/src/bc_protocol/floodlight.rs +++ b/crates/core/src/bc_protocol/floodlight.rs @@ -91,7 +91,7 @@ impl BcCamera { Ok(()) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not accept the Floodlight manual state", }) } @@ -147,7 +147,7 @@ impl BcCamera { Ok(xml) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Expected FloodlightTask xml but it was not recieved", }) } diff --git a/crates/core/src/bc_protocol/ledstate.rs b/crates/core/src/bc_protocol/ledstate.rs index f2ec09f3a..4b1a732e6 100644 --- a/crates/core/src/bc_protocol/ledstate.rs +++ b/crates/core/src/bc_protocol/ledstate.rs @@ -47,7 +47,7 @@ impl BcCamera { Ok(ledstate) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Expected LEDState xml but it was not recieved", }) } @@ -98,7 +98,7 @@ impl BcCamera { Ok(()) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not except the LEDState xml", }) } diff --git a/crates/core/src/bc_protocol/link.rs b/crates/core/src/bc_protocol/link.rs index 7d885a8ea..16ac92790 100644 --- a/crates/core/src/bc_protocol/link.rs +++ b/crates/core/src/bc_protocol/link.rs @@ -45,7 +45,7 @@ impl BcCamera { Ok(link_type) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Expected LinkType xml but it was not recieved", }) } diff --git a/crates/core/src/bc_protocol/login.rs b/crates/core/src/bc_protocol/login.rs index c87ab72d5..b264f1b7f 100644 --- a/crates/core/src/bc_protocol/login.rs +++ b/crates/core/src/bc_protocol/login.rs @@ -90,7 +90,7 @@ impl BcCamera { } _ => { return Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(legacy_reply)), + _reply: std::sync::Arc::new(Box::new(legacy_reply)), why: "Expected an Encryption message back", }) } @@ -133,6 +133,11 @@ impl BcCamera { sub_login.send(modern_login).await?; let modern_reply = sub_login.recv().await?; if modern_reply.meta.response_code != 200 { + log::warn!( + "Camera login rejected: response_code={} (expected 200). \ + Check username/password and try max_encryption = \"BcEncrypt\" in config.", + modern_reply.meta.response_code + ); return Err(Error::CameraLoginFail); } @@ -155,7 +160,7 @@ impl BcCamera { }) => return Err(Error::AuthFailed), _ => { return Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(legacy_reply)), + _reply: std::sync::Arc::new(Box::new(legacy_reply)), why: "Expected a DeviceInfo message back from login", }) } diff --git a/crates/core/src/bc_protocol/motion.rs b/crates/core/src/bc_protocol/motion.rs index d910ee49b..bf72cefa3 100644 --- a/crates/core/src/bc_protocol/motion.rs +++ b/crates/core/src/bc_protocol/motion.rs @@ -195,7 +195,7 @@ impl BcCamera { Ok(msg_num) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not accept the request to start motion", }) } diff --git a/crates/core/src/bc_protocol/pirstate.rs b/crates/core/src/bc_protocol/pirstate.rs index 20f9a6d9c..5068bfd08 100644 --- a/crates/core/src/bc_protocol/pirstate.rs +++ b/crates/core/src/bc_protocol/pirstate.rs @@ -63,7 +63,7 @@ impl BcCamera { return Ok(pirstate); } else { return Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Expected PirSate xml but it was not recieved", }); } @@ -120,7 +120,7 @@ impl BcCamera { Ok(()) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not except the RfAlarmCfg xml", }) } diff --git a/crates/core/src/bc_protocol/ptz.rs b/crates/core/src/bc_protocol/ptz.rs index 8d3657bfc..d5069dd68 100644 --- a/crates/core/src/bc_protocol/ptz.rs +++ b/crates/core/src/bc_protocol/ptz.rs @@ -68,7 +68,7 @@ impl BcCamera { Ok(()) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not accept the PtzControl xml", }) } @@ -114,7 +114,7 @@ impl BcCamera { Ok(ptz_preset) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not return a valid PtzPreset xml", }) } @@ -173,7 +173,7 @@ impl BcCamera { Ok(()) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not accept the PtzPreset xml", }) } @@ -230,7 +230,7 @@ impl BcCamera { Ok(()) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not accept the PtzPreset xml", }) } @@ -284,7 +284,7 @@ impl BcCamera { Ok(()) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not accept the StartZoomFocus xml", }) } @@ -335,7 +335,7 @@ impl BcCamera { Ok(xml) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Expected PtzZoomFocus xml but it was not recieved", }) } diff --git a/crates/core/src/bc_protocol/reboot.rs b/crates/core/src/bc_protocol/reboot.rs index 6e95c0c6d..a63ee5b7c 100644 --- a/crates/core/src/bc_protocol/reboot.rs +++ b/crates/core/src/bc_protocol/reboot.rs @@ -33,7 +33,7 @@ impl BcCamera { Ok(()) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not accept the reboot command", }) } diff --git a/crates/core/src/bc_protocol/replay.rs b/crates/core/src/bc_protocol/replay.rs new file mode 100644 index 000000000..872ccd795 --- /dev/null +++ b/crates/core/src/bc_protocol/replay.rs @@ -0,0 +1,1792 @@ +//! SD card replay: day records, file list, seek, start/stop playback (MSG 142, 14, 15, 13, 123, 5, 7). +//! +//! Desktop app (FUN_180177b80) sends CMD 0x17d with a 0x944-byte payload: 20-byte inner header +//! then 0x930 body (channel, 32 zeros, path 0x3ff). Use MSG_ID_REPLAY_DESKTOP when MSG 8 returns 400. +//! +//! Response codes (header offset 0x10): 200 = accept, 300 = end (by-name), 331 = end (by-time), +//! 400 = reject. App only writes binary when response_code matches stored expected (e.g. Android +0x1c). +//! 32-byte metadata skip: app does it only for msg_id 5 (not 0x17d); see docs/BCMEDIA_REPLAY_FORMAT.md. + +use super::stream::StreamData; +use super::{BcCamera, Error, Result}; +use std::collections::HashSet; +use std::convert::{TryFrom, TryInto}; +use crate::bc::{ + model::*, + xml::{ + xml_ver, BcPayloads, BcXml, DayRecord, DayRecordList, DayRecords, EventAlarmType, + FileInfo, FileInfoList, FindAlarmVideo, ReplayDateTime, ReplaySeek, + }, +}; +use crate::bcmedia::codex::BcMediaCodex; +use crate::bcmedia::model::BcMedia; +use bytes::BytesMut; +use tokio_util::codec::Decoder; +use log::debug; +use std::time::SystemTime; +use time::{Date, Month, PrimitiveDateTime, Time}; +use tokio::io::AsyncWriteExt; +use tokio::sync::mpsc::channel; +use tokio::task; +use tokio_util::sync::CancellationToken; + +/// Desktop replay payload size: 20-byte inner header + 0x930 body (channel, 32 zeros, path). +pub const DESKTOP_REPLAY_PAYLOAD_LEN: usize = 0x944; +const DESKTOP_INNER_HEADER_LEN: usize = 20; +const DESKTOP_PATH_MAX: usize = 0x3ff; + +/// E1 (Extension 1.1) per-packet envelope: optional 32-byte prefix then Extension XML, then decrypted payload. +const E1_REPLAY_PREFIX_LEN: usize = 32; + +/// Returns true if `data` begins with a known BcMedia frame magic (INFO, IFRAME, PFRAME, AAC). +/// Used to distinguish BcMedia streams (non-E1 cameras) from container formats when detecting replay mode. +fn is_bcmedia_start(data: &[u8]) -> bool { + if data.len() < 4 { + return false; + } + let magic = u32::from_le_bytes(data[0..4].try_into().unwrap()); + matches!(magic, + 0x31303031 | // INFO_V1 + 0x32303031 | // INFO_V2 + 0x63643030..=0x63643039 | // IFRAME range + 0x63643130..=0x63643139 | // PFRAME range + 0x62773530 // AAC + ) +} + +/// Strip E1 replay envelope from a packet body. +/// Layouts: (1) 32 bytes + `[\r]\n` + payload, or (2) `...` + payload. +/// Also supports any closing after `` (e.g. ``); uses first `00dc` as fallback for media start. +/// Special case: if packet ends with `\n`, return empty (payload starts in next packet). +/// Returns the payload slice (decrypted media); if the body doesn't match, returns the whole slice. +fn strip_e1_replay_envelope(data: &[u8]) -> &[u8] { + // Check if packet ends with \n or \r\n (XML spans entire packet, payload in next packet) + if data.len() >= 14 && (data.ends_with(b"\n") || data.ends_with(b"\r\n")) { + if data.starts_with(b" E1_REPLAY_PREFIX_LEN && data[E1_REPLAY_PREFIX_LEN..].starts_with(b" E1_REPLAY_PREFIX_LEN && data[E1_REPLAY_PREFIX_LEN..].starts_with(b"= 12 && data.starts_with(b" Vec { + let mut out = vec![0u8; DESKTOP_REPLAY_PAYLOAD_LEN]; + // Inner 20-byte header (from Ghidra FUN_180177b80 local_b08) + out[0..8].copy_from_slice(&2u64.to_le_bytes()); + out[8..12].copy_from_slice(&0x82fu32.to_le_bytes()); + out[12..16].copy_from_slice(&8u32.to_le_bytes()); + out[16..20].copy_from_slice(&500u32.to_le_bytes()); + // 0x930 body: offset 0 = channel (u32 LE) + out[DESKTOP_INNER_HEADER_LEN..DESKTOP_INNER_HEADER_LEN + 4] + .copy_from_slice(&(channel_id as u32).to_le_bytes()); + // offset 4..36 = 32 zeros (already zeroed) + // offset 36..36+1023 = path, null-padded + let path_start = DESKTOP_INNER_HEADER_LEN + 4 + 32; + let path_len = path.as_bytes().len().min(DESKTOP_PATH_MAX); + out[path_start..path_start + path_len].copy_from_slice(&path.as_bytes()[..path_len]); + out +} + +/// BC_DOWNLOAD_BY_TIME_INFO size (0x480 = 1152 bytes). Layout from APK JNA + native path at +0x60. +pub const BC_DOWNLOAD_BY_TIME_INFO_SIZE: usize = 0x480; +const SAVE_PATH_MAX: usize = 1024; // cSaveFileName at +0x60, max 1024 bytes + +/// Build the 0x480-byte BC_DOWNLOAD_BY_TIME_INFO payload for download-by-time (MSG 143). +/// Layout: iChannel (4), cUID (32), logicChnBitmap (8), startTime (6×i32), endTime (6×i32), +/// padding (4), cSaveFileName (1024), fileSize/curSize/processed (8 each), streamType (4), padding (4). +pub fn build_download_by_time_payload( + channel_id: u8, + uid: &[u8], + logic_chn_bitmap: u64, + start_time: &ReplayDateTime, + end_time: &ReplayDateTime, + save_path: &str, + stream_type: u32, +) -> Vec { + let mut out = vec![0u8; BC_DOWNLOAD_BY_TIME_INFO_SIZE]; + let mut off = 0; + out[off..off + 4].copy_from_slice(&(channel_id as u32).to_le_bytes()); + off += 4; + let uid_len = uid.len().min(32); + out[off..off + uid_len].copy_from_slice(&uid[..uid_len]); + off += 32; + out[off..off + 8].copy_from_slice(&logic_chn_bitmap.to_le_bytes()); + off += 8; + out[off..off + 4].copy_from_slice(&(start_time.year as i32).to_le_bytes()); + out[off + 4..off + 8].copy_from_slice(&(start_time.month as i32).to_le_bytes()); + out[off + 8..off + 12].copy_from_slice(&(start_time.day as i32).to_le_bytes()); + out[off + 12..off + 16].copy_from_slice(&(start_time.hour as i32).to_le_bytes()); + out[off + 16..off + 20].copy_from_slice(&(start_time.minute as i32).to_le_bytes()); + out[off + 20..off + 24].copy_from_slice(&(start_time.second as i32).to_le_bytes()); + off += 24; + out[off..off + 4].copy_from_slice(&(end_time.year as i32).to_le_bytes()); + out[off + 4..off + 8].copy_from_slice(&(end_time.month as i32).to_le_bytes()); + out[off + 8..off + 12].copy_from_slice(&(end_time.day as i32).to_le_bytes()); + out[off + 12..off + 16].copy_from_slice(&(end_time.hour as i32).to_le_bytes()); + out[off + 16..off + 20].copy_from_slice(&(end_time.minute as i32).to_le_bytes()); + out[off + 20..off + 24].copy_from_slice(&(end_time.second as i32).to_le_bytes()); + off += 24; + off += 4; // +0x5c: 4 bytes padding (already zeroed) + let path_len = save_path.as_bytes().len().min(SAVE_PATH_MAX); + out[off..off + path_len].copy_from_slice(&save_path.as_bytes()[..path_len]); + // +0x478: streamType (4 bytes) + out[0x478..0x47c].copy_from_slice(&stream_type.to_le_bytes()); + out +} + +/// BC_DOWNLOAD_BY_NAME_INFO size (0xD48 = 3400 bytes). Layout from Ghidra analysis of +/// BaichuanDownloader::downloadFileByName in libBCSDKWrapper.so (Android SDK). +pub const BC_DOWNLOAD_BY_NAME_INFO_SIZE: usize = 0xD48; +const FILE_NAME_OFFSET: usize = 0x24; // cFileName at +0x24 (SDK_LOG confirms: "download file with name: %s", param_2 + 0x24) +const FILE_NAME_MAX: usize = 0x100; // 256 bytes (conservative; 0x500 bytes available between 0x24 and 0x524) + +/// Build the 0xD48-byte BC_DOWNLOAD_BY_NAME_INFO payload for download-file-by-name (MSG 8). +/// Layout (from Ghidra): iChannel (4), cUID (32, at 0x04), cFileName (256, at 0x24), rest zeroed. +/// The camera reads channel + filename and streams the raw file back. Output path at 0x524 is +/// SDK-internal (local filesystem path) and not meaningful to send; left zeroed. +pub fn build_download_by_name_payload(channel_id: u8, uid: &str, file_name: &str) -> Vec { + let mut out = vec![0u8; BC_DOWNLOAD_BY_NAME_INFO_SIZE]; + out[0..4].copy_from_slice(&(channel_id as u32).to_le_bytes()); + let uid_bytes = uid.as_bytes(); + let uid_len = uid_bytes.len().min(32); + out[4..4 + uid_len].copy_from_slice(&uid_bytes[..uid_len]); + let name_bytes = file_name.as_bytes(); + let name_len = name_bytes.len().min(FILE_NAME_MAX - 1); // reserve 1 byte for null terminator + out[FILE_NAME_OFFSET..FILE_NAME_OFFSET + name_len].copy_from_slice(&name_bytes[..name_len]); + out +} + +/// Duration in seconds between two ReplayDateTimes (end − start). Returns None if invalid or negative. +pub fn replay_datetime_duration_secs(start: &ReplayDateTime, end: &ReplayDateTime) -> Option { + let sm = Month::try_from(start.month).ok()?; + let em = Month::try_from(end.month).ok()?; + let sd = Date::from_calendar_date(start.year, sm, start.day).ok()?; + let ed = Date::from_calendar_date(end.year, em, end.day).ok()?; + let st = Time::from_hms(start.hour, start.minute, start.second).ok()?; + let et = Time::from_hms(end.hour, end.minute, end.second).ok()?; + let start_dt = PrimitiveDateTime::new(sd, st).assume_utc(); + let end_dt = PrimitiveDateTime::new(ed, et).assume_utc(); + let secs = (end_dt - start_dt).whole_seconds(); + if secs < 0 { + None + } else { + Some(secs as u64) + } +} + +/// Parse ReplayDateTime from a filename like `01_20260204120000` (channel_YYYYMMDDHHmmss). +fn parse_seek_time_from_name(name: &str) -> Option { + let digits: Vec = name + .bytes() + .filter(|b| b.is_ascii_digit()) + .map(|b| b - b'0') + .collect(); + if digits.len() < 14 { + return None; + } + let d = &digits[digits.len() - 14..]; + let year = (d[0] as i32) * 1000 + (d[1] as i32) * 100 + (d[2] as i32) * 10 + (d[3] as i32); + let month = d[4] * 10 + d[5]; + let day = d[6] * 10 + d[7]; + let hour = d[8] * 10 + d[9]; + let minute = d[10] * 10 + d[11]; + let second = d[12] * 10 + d[13]; + if month == 0 || month > 12 || day == 0 || day > 31 || hour > 23 || minute > 59 || second > 59 { + return None; + } + Some(ReplayDateTime { + year, + month, + day, + hour, + minute, + second, + }) +} + +impl BcCamera { + /// Get day records in a time range (MSG 142). Returns which days have recordings. + pub async fn get_day_records( + &self, + start_time: ReplayDateTime, + end_time: ReplayDateTime, + ) -> Result { + let connection = self.get_connection(); + let msg_num = self.new_message_num(); + let mut sub = connection + .subscribe(MSG_ID_DAY_RECORDS, msg_num) + .await?; + + let day_records = DayRecords { + version: xml_ver(), + start_time: start_time.clone(), + end_time: end_time.clone(), + day_record_list: DayRecordList { + day_record: vec![DayRecord { + index: 0, + channel_id: self.channel_id, + }], + }, + day_type_list: None, + }; + + let msg = Bc { + meta: BcMeta { + msg_id: MSG_ID_DAY_RECORDS, + channel_id: self.channel_id, + msg_num, + stream_type: 0, + response_code: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: Some(BcPayloads::BcXml(BcXml { + day_records: Some(day_records), + ..Default::default() + })), + }), + }; + + sub.send(msg).await?; + let msg = sub.recv().await?; + + if msg.meta.response_code != 200 { + return Err(Error::CameraServiceUnavailable { + id: msg.meta.msg_id, + code: msg.meta.response_code, + }); + } + + if let BcBody::ModernMsg(ModernMsg { + payload: + Some(BcPayloads::BcXml(BcXml { + day_records: Some(ref data), + .. + })), + .. + }) = msg.body + { + debug!( + "DayRecords response: day_type_list present = {}, entries = {}; full response: {:?}", + data.day_type_list.is_some(), + data.day_type_list + .as_ref() + .map(|l| l.day_type.len()) + .unwrap_or(0), + data + ); + Ok(data.clone()) + } else { + Err(Error::UnintelligibleReply { + _reply: std::sync::Arc::new(Box::new(msg)), + why: "Expected DayRecords xml in response", + }) + } + } + + /// Get file list handle for a day (MSG 14). Returns a handle to use with get_file_list_by_handle. + pub async fn get_file_list_handle( + &self, + stream_type: &str, + record_type: &str, + start_time: ReplayDateTime, + end_time: ReplayDateTime, + ) -> Result { + let connection = self.get_connection(); + let msg_num = self.new_message_num(); + let mut sub = connection + .subscribe(MSG_ID_REPLAY_FILE_LIST_HANDLE, msg_num) + .await?; + + let file_info = FileInfo { + channel_id: Some(self.channel_id), + record_type: Some(record_type.to_string()), + support_sub: Some(0), + start_time: Some(start_time.clone()), + end_time: Some(end_time.clone()), + stream_type: Some(stream_type.to_string()), + ..Default::default() + }; + + let xml = BcXml { + file_info_list: Some(FileInfoList { + version: Some(xml_ver()), + file_info: vec![file_info], + }), + ..Default::default() + }; + + let msg = Bc { + meta: BcMeta { + msg_id: MSG_ID_REPLAY_FILE_LIST_HANDLE, + channel_id: self.channel_id, + msg_num, + stream_type: 0, + response_code: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: Some(BcPayloads::BcXml(xml)), + }), + }; + + sub.send(msg).await?; + let msg = sub.recv().await?; + + if msg.meta.response_code != 200 { + return Err(Error::CameraServiceUnavailable { + id: msg.meta.msg_id, + code: msg.meta.response_code, + }); + } + + if let BcBody::ModernMsg(ModernMsg { + payload: + Some(BcPayloads::BcXml(BcXml { + file_info_list: Some(ref list), + .. + })), + .. + }) = msg.body + { + if list.file_info.is_empty() { + return Err(Error::UnintelligibleReply { + _reply: std::sync::Arc::new(Box::new(msg)), + why: "FileInfoList response had no FileInfo", + }); + } + Ok(list.file_info[0].clone()) + } else { + Err(Error::UnintelligibleReply { + _reply: std::sync::Arc::new(Box::new(msg)), + why: "Expected FileInfoList xml in response", + }) + } + } + + /// Fetch one page of files by handle (MSG 15). Returns the file list for this page. + /// An empty vec means no more pages. + async fn get_file_list_page(&self, handle: u32) -> Result> { + let connection = self.get_connection(); + let msg_num = self.new_message_num(); + let mut sub = connection + .subscribe(MSG_ID_REPLAY_FILE_LIST, msg_num) + .await?; + + let file_info = FileInfo { + channel_id: Some(self.channel_id), + handle: Some(handle), + ..Default::default() + }; + + let xml = BcXml { + file_info_list: Some(FileInfoList { + version: Some(xml_ver()), + file_info: vec![file_info], + }), + ..Default::default() + }; + + let msg = Bc { + meta: BcMeta { + msg_id: MSG_ID_REPLAY_FILE_LIST, + channel_id: self.channel_id, + msg_num, + stream_type: 0, + response_code: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: Some(BcPayloads::BcXml(xml)), + }), + }; + + sub.send(msg).await?; + let msg = sub.recv().await?; + + if msg.meta.response_code != 200 { + // Non-200 signals end of results (same pattern as alarm_video_search_next) + log::debug!( + "get_file_list_page: response {} — treating as end of list", + msg.meta.response_code + ); + return Ok(vec![]); + } + + if let BcBody::ModernMsg(ModernMsg { + payload: + Some(BcPayloads::BcXml(BcXml { + file_info_list: Some(ref list), + .. + })), + .. + }) = msg.body + { + Ok(list.file_info.clone()) + } else { + Err(Error::UnintelligibleReply { + _reply: std::sync::Arc::new(Box::new(msg)), + why: "Expected FileInfoList xml in response", + }) + } + } + + /// List all files by handle from get_file_list_handle (MSG 15), paginating until empty. + pub async fn get_file_list_by_handle(&self, handle: u32) -> Result> { + let mut all_files = Vec::new(); + let mut page = 0usize; + loop { + let batch = self.get_file_list_page(handle).await?; + if batch.is_empty() { + log::debug!("get_file_list_by_handle: {} total files in {} pages", all_files.len(), page); + break; + } + page += 1; + log::debug!("get_file_list_by_handle: page {} returned {} files", page, batch.len()); + all_files.extend(batch); + } + Ok(all_files) + } + + /// Alarm video search: START (MSG 175). Returns the response containing a fileHandle. + /// `alarm_types` is a list of alarm/AI tags (e.g. ["md", "people", "vehicle"]). + /// `stream_type_num` is 0 for mainStream, 1 for subStream. + pub async fn alarm_video_search_start( + &self, + stream_type_num: u8, + alarm_types: &[&str], + start_time: ReplayDateTime, + end_time: ReplayDateTime, + ) -> Result { + let connection = self.get_connection(); + let msg_num = self.new_message_num(); + let mut sub = connection + .subscribe(MSG_ID_ALARM_VIDEO_SEARCH, msg_num) + .await?; + + let alarm_type_str = alarm_types.join(", "); + let xml = BcXml { + find_alarm_video: Some(FindAlarmVideo { + version: Some(xml_ver()), + channel_id: Some(self.channel_id), + file_handle: None, + stream_type: Some(stream_type_num), + not_search_video: Some(0), + start_time: Some(start_time), + end_time: Some(end_time), + alarm_type: Some(alarm_type_str), + event_alarm_type: Some(EventAlarmType { + items: alarm_types.iter().map(|s| s.to_string()).collect(), + }), + }), + ..Default::default() + }; + + let msg = Bc { + meta: BcMeta { + msg_id: MSG_ID_ALARM_VIDEO_SEARCH, + channel_id: self.channel_id, + msg_num, + stream_type: 0, + response_code: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: Some(BcPayloads::BcXml(xml)), + }), + }; + + sub.send(msg).await?; + let msg = sub.recv().await?; + + if msg.meta.response_code != 200 { + return Err(Error::CameraServiceUnavailable { + id: msg.meta.msg_id, + code: msg.meta.response_code, + }); + } + + if let BcBody::ModernMsg(ModernMsg { + payload: + Some(BcPayloads::BcXml(BcXml { + find_alarm_video: Some(ref fav), + .. + })), + .. + }) = msg.body + { + Ok(fav.clone()) + } else { + Err(Error::UnintelligibleReply { + _reply: std::sync::Arc::new(Box::new(msg)), + why: "Expected findAlarmVideo xml in response", + }) + } + } + + /// Alarm video search: DO/paginate (MSG 175). Send a fileHandle to get the next batch of events. + /// Returns the AlarmEventList from the response. + pub async fn alarm_video_search_next( + &self, + file_handle: i32, + ) -> Result { + let connection = self.get_connection(); + let msg_num = self.new_message_num(); + let mut sub = connection + .subscribe(MSG_ID_ALARM_VIDEO_SEARCH, msg_num) + .await?; + + let xml = BcXml { + find_alarm_video: Some(FindAlarmVideo { + version: Some(xml_ver()), + file_handle: Some(file_handle), + ..Default::default() + }), + ..Default::default() + }; + + let msg = Bc { + meta: BcMeta { + msg_id: MSG_ID_ALARM_VIDEO_SEARCH, + channel_id: self.channel_id, + msg_num, + stream_type: 0, + response_code: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: Some(BcPayloads::BcXml(xml)), + }), + }; + + sub.send(msg).await?; + let msg = sub.recv().await?; + + if msg.meta.response_code != 200 { + return Err(Error::CameraServiceUnavailable { + id: msg.meta.msg_id, + code: msg.meta.response_code, + }); + } + + if let BcBody::ModernMsg(ModernMsg { + payload: + Some(BcPayloads::BcXml(BcXml { + find_alarm_video: Some(ref fav), + .. + })), + .. + }) = msg.body + { + Ok(fav.clone()) + } else { + Err(Error::UnintelligibleReply { + _reply: std::sync::Arc::new(Box::new(msg)), + why: "Expected findAlarmVideo xml in response", + }) + } + } + + /// Get file details by name (MSG 13). Use name from get_file_list_by_handle. + /// App (FUN_1801740c0) sends supportSub=1 for subStream and only includes playSpeed when in [1,32]. + pub async fn get_file_by_name( + &self, + name: &str, + support_sub: u8, + play_speed: u32, + stream_type: &str, + ) -> Result { + let connection = self.get_connection(); + let msg_num = self.new_message_num(); + let mut sub = connection + .subscribe(MSG_ID_REPLAY_FILE_BY_NAME, msg_num) + .await?; + + let support_sub = if stream_type == "subStream" { 1 } else { support_sub }; + let file_info = FileInfo { + channel_id: Some(self.channel_id), + name: Some(name.to_string()), + support_sub: Some(support_sub), + play_speed: (1..=32).contains(&play_speed).then_some(play_speed), + stream_type: Some(stream_type.to_string()), + ..Default::default() + }; + + let xml = BcXml { + file_info_list: Some(FileInfoList { + version: Some(xml_ver()), + file_info: vec![file_info], + }), + ..Default::default() + }; + + let msg = Bc { + meta: BcMeta { + msg_id: MSG_ID_REPLAY_FILE_BY_NAME, + channel_id: self.channel_id, + msg_num, + stream_type: 0, + response_code: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: Some(BcPayloads::BcXml(xml)), + }), + }; + + sub.send(msg).await?; + let msg = sub.recv().await?; + + if msg.meta.response_code != 200 { + return Err(Error::CameraServiceUnavailable { + id: msg.meta.msg_id, + code: msg.meta.response_code, + }); + } + + if let BcBody::ModernMsg(ModernMsg { + payload: + Some(BcPayloads::BcXml(BcXml { + file_info_list: Some(ref list), + .. + })), + .. + }) = msg.body + { + if list.file_info.is_empty() { + return Err(Error::UnintelligibleReply { + _reply: std::sync::Arc::new(Box::new(msg)), + why: "FileInfoList response had no FileInfo", + }); + } + Ok(list.file_info[0].clone()) + } else { + Err(Error::UnintelligibleReply { + _reply: std::sync::Arc::new(Box::new(msg)), + why: "Expected FileInfoList xml in response", + }) + } + } + + /// Get file metadata (duration, size) from the file list for a given replay file name. + /// Does seek for the file's day, then fetches the file list and finds the matching entry. + /// Returns None if the name cannot be parsed (no date) or the file is not in the list. + pub async fn get_replay_file_metadata( + &self, + name: &str, + stream_type: &str, + record_type: &str, + ) -> Result> { + let seek_time = match parse_seek_time_from_name(name) { + Some(t) => t, + None => return Ok(None), + }; + let day_start = ReplayDateTime { + hour: 0, + minute: 0, + second: 0, + ..seek_time.clone() + }; + let day_end = ReplayDateTime { + hour: 23, + minute: 59, + second: 59, + ..seek_time + }; + let seq = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs() as u32) + .unwrap_or(0); + self.replay_seek(seq, day_start.clone()).await?; + let handle_info = self + .get_file_list_handle(stream_type, record_type, day_start, day_end) + .await?; + let handle = match handle_info.handle { + Some(h) => h, + None => return Ok(None), + }; + let files = self.get_file_list_by_handle(handle).await?; + let found = files + .into_iter() + .find(|f| f.name.as_deref() == Some(name)); + Ok(found) + } + + /// Get replay file duration in seconds from the file list (start_time..end_time). + /// Returns None if metadata cannot be obtained or start/end time are missing. + pub async fn get_replay_file_duration_secs( + &self, + name: &str, + stream_type: &str, + record_type: &str, + ) -> Result> { + let meta = self + .get_replay_file_metadata(name, stream_type, record_type) + .await?; + let duration = meta.and_then(|info| { + let start = info.start_time.as_ref()?; + let end = info.end_time.as_ref()?; + replay_datetime_duration_secs(start, end) + }); + Ok(duration) + } + + /// Replay seek: prepare playback position (MSG 123). seq is epoch seconds; seek_time from file list. + pub async fn replay_seek( + &self, + seq: u32, + seek_time: ReplayDateTime, + ) -> Result<()> { + let connection = self.get_connection(); + let msg_num = self.new_message_num(); + let mut sub = connection + .subscribe(MSG_ID_REPLAY_SEEK, msg_num) + .await?; + + let replay_seek = ReplaySeek { + version: xml_ver(), + channel_id: self.channel_id, + seq, + seek_time, + }; + + let msg = Bc { + meta: BcMeta { + msg_id: MSG_ID_REPLAY_SEEK, + channel_id: self.channel_id, + msg_num, + stream_type: 0, + response_code: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: Some(BcPayloads::BcXml(BcXml { + replay_seek: Some(replay_seek), + ..Default::default() + })), + }), + }; + + sub.send(msg).await?; + let msg = sub.recv().await?; + + if msg.meta.response_code == 200 { + Ok(()) + } else { + Err(Error::CameraServiceUnavailable { + id: msg.meta.msg_id, + code: msg.meta.response_code, + }) + } + } + + /// Start replay playback (MSG 5). Returns a stream of BCMedia; when dropped, sends stop (MSG 7). + /// + /// Caller should have obtained `name` from `get_file_list_by_handle`. The protocol requires + /// ReplaySeek (MSG 123) before get_file_by_name (MSG 13); we parse seek time from the + /// filename (e.g. `01_20260204120000` → 2026-02-04 12:00:00) when possible. + /// + /// If `expected_size` is set (e.g. from file list size_l/size_h), or the 32-byte replay + /// header contains a plausible size at +0x10/+0x18 (per BaichuanDownloader RE), we stop when + /// payload bytes received reach that size, since some cameras (e.g. E1) never send response 300. + pub async fn start_replay( + &self, + name: &str, + stream_type: &str, + play_speed: u32, + strict: bool, + buffer_size: usize, + dump_replay: Option, + dump_replay_limit: Option, + expected_size: Option, + ) -> Result { + let seq = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs() as u32) + .unwrap_or(0); + + // Protocol order: ReplaySeek (123) before get_file_by_name (13). Use seek time from + // filename (e.g. 01_20260204120000 → 2026-02-04 12:00:00) or start of today. + let seek_time = parse_seek_time_from_name(name).unwrap_or_else(|| { + let now = time::OffsetDateTime::now_utc(); + ReplayDateTime { + year: now.year(), + month: now.month() as u8, + day: now.day() as u8, + hour: 0, + minute: 0, + second: 0, + } + }); + self.replay_seek(seq, seek_time.clone()).await?; + log::info!("Replay: seek (MSG 123) done"); + + // Use parsed time as file start_time for MSG 5 when possible, to avoid MSG 13 (get_file_by_name) + // which some cameras (e.g. E1) reject with 400. + let start_time = if let Some(parsed) = parse_seek_time_from_name(name) { + parsed + } else { + let file_info = self + .get_file_by_name(name, 0, play_speed, stream_type) + .await?; + file_info.start_time.clone().ok_or_else(|| { + Error::UnintelligibleReply { + _reply: std::sync::Arc::new(Box::new(Bc::new_from_meta(BcMeta { + msg_id: 0, + channel_id: 0, + stream_type: 0, + response_code: 0, + msg_num: 0, + class: 0, + }))), + why: "FileInfo from camera had no startTime", + } + })? + }; + + log::info!("Replay: start_time {:04}-{:02}-{:02} {:02}:{:02}:{:02}", start_time.year, start_time.month, start_time.day, start_time.hour, start_time.minute, start_time.second); + let connection = self.get_connection(); + let start_msg_num = self.new_message_num(); + let channel_id = self.channel_id; + let name = name.to_string(); + let stream_type = stream_type.to_string(); + let dump_path = dump_replay; + let expected_size = expected_size; + + let buffer_size = if buffer_size == 0 { 100 } else { buffer_size }; + const DEFAULT_DUMP_LIMIT: usize = 131072; + let dump_limit = dump_replay_limit.unwrap_or(DEFAULT_DUMP_LIMIT); + let (tx, rx) = channel(buffer_size); + let abort_handle = CancellationToken::new(); + let abort_handle_thread = abort_handle.clone(); + + let handle = task::spawn(async move { + // Optional dump of replay stream (after 32-byte header). limit 0 = full stream. + let mut dump_file = None::<(tokio::fs::File, Option)>; + if let Some(ref p) = dump_path { + match tokio::fs::File::create(p).await { + Ok(f) => { + let limit_str = if dump_limit == 0 { + "full stream".to_string() + } else { + format!("first {} bytes", dump_limit) + }; + log::info!( + "Replay: will dump {} of raw replay data (after 32-byte header) to {}", + limit_str, + p.display() + ); + dump_file = Some((f, if dump_limit == 0 { None } else { Some(dump_limit) })); + } + Err(e) => log::warn!("Replay: could not create dump file {}: {}", p.display(), e), + } + } + + // Try MSG 5 first (replay file download as in app pcap: 123 + 5). Some cameras (e.g. E1) + // return 400 for MSG 5; then try MSG 8 (same FileInfoList body). If MSG 8 returns 400, try desktop (0x17d). + // Body: FileInfoList version="1.1" → FileInfo with uid, name, channelId, supportSub, streamType, startTime. + let mut msg_id = MSG_ID_REPLAY_START; // 5 = try first (pcap flow) + let mut sub = connection + .subscribe(msg_id, start_msg_num) + .await?; + + let support_sub = if stream_type.as_str() == "subStream" { 1 } else { 0 }; + let file_info = FileInfo { + uid: Some(0), + id: None, + name: Some(name.clone()), + channel_id: Some(channel_id), + support_sub: Some(support_sub), + stream_type: Some(stream_type.clone()), + start_time: Some(start_time), + ..Default::default() + }; + let xml = BcXml { + file_info_list: Some(FileInfoList { + version: Some(xml_ver()), + file_info: vec![file_info.clone()], + }), + ..Default::default() + }; + let msg = Bc { + meta: BcMeta { + msg_id, + channel_id, + msg_num: start_msg_num, + stream_type: 0, + response_code: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: Some(BcPayloads::BcXml(xml)), + }), + }; + log::info!( + "Replay: sending MSG {} start_replay name={} channel={} streamType={}", + msg_id, + name, + channel_id, + stream_type + ); + sub.send(msg).await?; + log::info!("Replay: MSG {} sent, waiting for first response...", msg_id); + + let mut first = true; + let mut codec = BcMediaCodex::new(strict); + let mut buf = BytesMut::new(); + // When set, camera sends MP4 (or other container) instead of BcMedia; forward raw bytes. + let mut raw_replay_mode = false; + // First packet (32 bytes) may be replay header or start of file; keep and send when entering raw mode so client has full stream. + let mut pending_first_chunk: Option> = None; + + let mut packet_count = 0u32; + let mut total_binary_bytes = 0usize; + // App only writes binary when response_code matches expected. Some cameras (e.g. E1) use 200 for accept then 54778 (0xD5DA) for streaming; accept any code we've seen with binary. + let mut accepted_stream_response_codes: HashSet = HashSet::new(); + // End when payload (excluding 32-byte header) reaches this size. From file list or 32-byte header (RE: BaichuanDownloader 0xe48 / header +0x10, +0x18). + let mut expected_payload_size = expected_size; + const RECV_TIMEOUT_SECS: u64 = 15; + let recv_timeout = tokio::time::Duration::from_secs(RECV_TIMEOUT_SECS); + 'recv_loop: loop { + tokio::select! { + _ = abort_handle_thread.cancelled() => break 'recv_loop, + msg_res = tokio::time::timeout(recv_timeout, sub.recv()) => { + let msg_res = match msg_res { + Err(_) => { + log::info!( + "Replay: no data for {}s (camera may have stopped without 300), finishing ({} packets, {} bytes)", + RECV_TIMEOUT_SECS, packet_count, total_binary_bytes + ); + let _ = tx.send(Ok(BcMedia::StreamEnd)).await; + break 'recv_loop; + } + Ok(r) => r, + }; + let msg = match msg_res { + Err(e) => { + log::info!( + "Replay stream ended: recv error ({} packets, {} bytes binary): {:?}", + packet_count, total_binary_bytes, e + ); + log::info!( + "Replay failed: verify protocol in Ghidra via MCP (see GHIDRA_CHECKLIST.md), do not guess" + ); + return Err(e); + } + Ok(m) => m, + }; + let body_type = match &msg.body { + BcBody::ModernMsg(ModernMsg { payload: Some(BcPayloads::Binary(d)), .. }) => { + format!("binary({} bytes)", d.len()) + } + BcBody::ModernMsg(ModernMsg { payload: Some(BcPayloads::BcXml(_)), .. }) => "xml".to_string(), + _ => "other".to_string(), + }; + log::debug!( + "Replay: recv msg_id={} msg_num={} response_code={} body={}", + msg.meta.msg_id, + msg.meta.msg_num, + msg.meta.response_code, + body_type + ); + if first { + first = false; + if msg.meta.response_code == 400 && msg_id == MSG_ID_REPLAY_START { + // Camera rejected MSG 5; try MSG 8 (e.g. E1). + log::info!( + "Replay: camera returned 400 for MSG 5, trying MSG 8..." + ); + drop(sub); + msg_id = MSG_ID_REPLAY_START_ALT; + sub = connection.subscribe(msg_id, start_msg_num).await?; + let xml8 = BcXml { + file_info_list: Some(FileInfoList { + version: Some(xml_ver()), + file_info: vec![file_info.clone()], + }), + ..Default::default() + }; + let msg8 = Bc { + meta: BcMeta { + msg_id, + channel_id, + msg_num: start_msg_num, + stream_type: 0, + response_code: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: Some(BcPayloads::BcXml(xml8)), + }), + }; + sub.send(msg8).await?; + log::info!("Replay: MSG 8 sent, waiting for first response..."); + first = true; + continue 'recv_loop; + } + if msg.meta.response_code == 400 && msg_id == MSG_ID_REPLAY_START_ALT { + // Camera rejected MSG 8; try desktop binary replay (CMD 0x17d, 0x944 payload). + log::info!( + "Replay: camera returned 400 for MSG 8, trying desktop replay (0x17d)..." + ); + msg_id = MSG_ID_REPLAY_DESKTOP; // prevent re-entry if 0x17d also returns 400 + drop(sub); + let sub_desktop = connection + .subscribe(MSG_ID_REPLAY_DESKTOP, start_msg_num) + .await?; + let payload = build_desktop_replay_payload(channel_id, &name); + let desktop_msg = Bc { + meta: BcMeta { + msg_id: MSG_ID_REPLAY_DESKTOP, + channel_id, + msg_num: start_msg_num, + stream_type: 0, + response_code: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: Some(BcPayloads::Binary(payload)), + }), + }; + sub_desktop.send(desktop_msg).await?; + log::info!("Replay: desktop 0x17d sent, waiting for response..."); + sub = sub_desktop; + first = true; + continue 'recv_loop; + } + if msg.meta.response_code != 200 { + log::info!( + "Replay start rejected by camera: response_code={}", + msg.meta.response_code + ); + return Err(Error::UnintelligibleReply { + _reply: std::sync::Arc::new(Box::new(msg)), + why: "Camera did not accept replay start", + }); + } + log::info!("Replay: camera accepted (200), streaming..."); + accepted_stream_response_codes.insert(200); + // Tell consumer which msg_id was accepted so it can skip first 32 bytes only for MSG 5 (app parity). + let _ = tx.send(Ok(BcMedia::ReplayStarted(msg_id as u16))).await; + } else if msg.meta.response_code == 300 || msg.meta.response_code == 331 { + // Camera signals end of file (300 = by-name; 331 = by-time). Notify consumer then stop. + log::info!( + "Replay/Download: response {} (end of file), stopping ({} packets, {} bytes)", + msg.meta.response_code, packet_count, total_binary_bytes + ); + let _ = tx.send(Ok(BcMedia::StreamEnd)).await; + break 'recv_loop; + } + if let BcBody::ModernMsg(ModernMsg { + payload: Some(BcPayloads::Binary(ref data)), + .. + }) = msg.body + { + // Accept binary when response_code is in accepted set (200 from accept). E1 and others use a different code (e.g. 54778) for streaming; add it when first seen. + let code = msg.meta.response_code; + if !accepted_stream_response_codes.contains(&code) { + if code == 300 || code == 331 { + // End codes handled above + } else { + accepted_stream_response_codes.insert(code); + log::debug!("Replay: accepting streaming response_code {} (added to accepted set)", code); + } + } + if accepted_stream_response_codes.contains(&code) { + packet_count += 1; + total_binary_bytes += data.len(); + + // Dump stream (after 32-byte header). remaining None = unlimited. + if let Some((ref mut f, ref mut remaining)) = dump_file { + if packet_count >= 2 { + let to_write = match *remaining { + None => data.len(), + Some(r) if r > 0 => data.len().min(r), + _ => 0, + }; + if to_write > 0 { + if let Err(e) = f.write_all(&data[..to_write]).await { + log::warn!("Replay: dump write error: {}", e); + dump_file = None; + } else if let Some(ref mut r) = remaining { + *r -= to_write; + if *r == 0 { + let _ = f.flush().await; + log::info!("Replay: reached dump limit, closed dump file"); + dump_file = None; + } + } + } + } + } + + if packet_count == 1 { + // Some cameras (e.g. E1) send a 32-byte replay header first; BcMedia + // stream starts in the next packet. Skip the header so the codec + // sees the real stream start. Keep it for raw mode so client gets full stream (may be ftyp). + const REPLAY_HEADER_LEN: usize = 32; + if data.len() == REPLAY_HEADER_LEN { + log::info!( + "Replay: skipping {} byte replay header (first packet)", + REPLAY_HEADER_LEN + ); + log::debug!( + "Replay: header hex: {:02x?}", + &data[..data.len().min(64)] + ); + pending_first_chunk = Some(data.to_vec()); + // Parse possible file size from header (RE: 32-byte layout +0x10 uint64, +0x18 uint64; BaichuanDownloader uses fileSize at 0xe48). + if expected_payload_size.is_none() && data.len() >= 32 { + let size_at_10 = u64::from_le_bytes(data[16..24].try_into().unwrap()); + let size_at_18 = u64::from_le_bytes(data[24..32].try_into().unwrap()); + const MAX_PLAUSIBLE: u64 = 500_000_000; + if size_at_10 > 0 && size_at_10 <= MAX_PLAUSIBLE { + expected_payload_size = Some(size_at_10); + log::info!("Replay: using expected size from header +0x10: {} bytes", size_at_10); + } else if size_at_18 > 0 && size_at_18 <= MAX_PLAUSIBLE { + expected_payload_size = Some(size_at_18); + log::info!("Replay: using expected size from header +0x18: {} bytes", size_at_18); + } + } + } else { + buf.extend_from_slice(&data); + } + } else { + // Detect MP4 (ISO Base Media): first payload after header has "ftyp" at 4..8. + if packet_count == 2 && data.len() >= 8 && data[4..8] == *b"ftyp" { + raw_replay_mode = true; + log::info!( + "Replay: stream is MP4/container format, forwarding raw bytes" + ); + } + // Some cameras (e.g. E1 with MSG 5) send MP4 that doesn't start with ftyp after + // decrypt, or use a different container; continuation packets can have + // response_code != 200. Treat as raw replay to avoid codec Nom errors and connection loss. + // Exception: don't trigger for BcMedia streams (non-E1 cameras like Argus 2). + if packet_count == 2 && data.len() >= 8 && data[4..8] != *b"ftyp" && data.len() > 512 + && !is_bcmedia_start(data) + { + raw_replay_mode = true; + log::info!( + "Replay: packet 2 not ftyp (decrypted: {:02x?}), treating as container/raw", + &data[0..8.min(data.len())] + ); + } + if raw_replay_mode { + if let Some(chunk) = pending_first_chunk.take() { + if tx.send(Ok(BcMedia::RawReplayChunk(chunk))).await.is_err() { + break 'recv_loop; + } + } + let payload = strip_e1_replay_envelope(&data); + // E1 fix: raw replay (raw_replay_mode=true) often lacks start codes for NAL units (SPS/PPS/IDR/SLICE) + // if the camera sends them as individual messages (common for E1). + // If payload looks like a NAL (starts with valid NAL type) but has no start code, prepend one. + let mut chunk_data = Vec::with_capacity(payload.len() + 4); + let nal_type = if !payload.is_empty() { payload[0] & 0x1f } else { 0 }; + // 1=Slice, 5=IDR, 6=SEI, 7=SPS, 8=PPS, 9=AUD + let is_video_nal = matches!(nal_type, 1 | 5 | 6 | 7 | 8 | 9); + let has_start_code = (payload.len() >= 4 && payload[0..4] == [0, 0, 0, 1]) + || (payload.len() >= 3 && payload[0..3] == [0, 0, 1]); + + if is_video_nal && !has_start_code { + // Insert Annex B start code so the raw stream is playable + chunk_data.extend_from_slice(&[0, 0, 0, 1]); + } + chunk_data.extend_from_slice(payload); + + if tx.send(Ok(BcMedia::RawReplayChunk(chunk_data))).await.is_err() { + break 'recv_loop; + } + } else { + if packet_count == 2 && data.len() >= 8 { + log::info!( + "Replay: packet 2 first 8 bytes (decrypted): {:02x?}", + &data[0..8] + ); + } + buf.extend_from_slice(&data); + while let Some(bc_media) = codec.decode(&mut buf)? { + if tx.send(Ok(bc_media)).await.is_err() { + break 'recv_loop; + } + } + } + } + if packet_count <= 3 || packet_count % 200 == 0 { + log::info!( + "Replay: {} packets, {} KB received", + packet_count, + total_binary_bytes / 1024 + ); + } + // Size-based end: some cameras never send 300; stop when we have expected payload (RE: BaichuanDownloader curSize >= fileSize). + let payload_bytes = total_binary_bytes.saturating_sub(32) as u64; + if let Some(exp) = expected_payload_size { + if payload_bytes >= exp { + log::info!( + "Replay: received {} bytes (expected {}), finishing", + payload_bytes, exp + ); + let _ = tx.send(Ok(BcMedia::StreamEnd)).await; + break 'recv_loop; + } + } + } + } else { + log::info!( + "Replay: message has no binary payload (response_code={})", + msg.meta.response_code + ); + } + } + } + } + // Flush dump file so any written bytes are persisted (e.g. when stream ends before 128KB). + if let Some((ref mut f, _)) = dump_file { + let _ = f.flush().await; + } + log::info!( + "Replay stream ended: {} packets, {} bytes binary", + packet_count, total_binary_bytes + ); + + // Do not send MSG 7 (stop) here. The caller (replay play command) sends it via + // replay_stop() after receiving StreamEnd. Sending it here would compete for the same + // connection and can deadlock: main holds the connection in replay_stop (waiting for + // response) while this task blocks on subscribe(), so this task never exits and the + // process hangs when joining the stream handle. + + Ok(()) + }); + + Ok(StreamData::from_parts(handle, rx, abort_handle)) + } + + /// Start download by time range (MSG 143). Returns a stream of BCMedia; when dropped, sends stop (MSG 144). + /// Same stream behaviour as replay: first packet 32 bytes = stream info (skipped), then binary data; end on response 331 (or 300). + pub async fn start_download_by_time( + &self, + start_time: ReplayDateTime, + end_time: ReplayDateTime, + save_path: &str, + stream_type: u32, + strict: bool, + buffer_size: usize, + dump_replay: Option, + dump_replay_limit: Option, + ) -> Result { + let connection = self.get_connection(); + let start_msg_num = self.new_message_num(); + let stop_msg_num = self.new_message_num(); + let channel_id = self.channel_id; + let uid = [0u8; 32]; + let payload = build_download_by_time_payload( + channel_id, + &uid[..], + 0, + &start_time, + &end_time, + save_path, + stream_type, + ); + let buffer_size = if buffer_size == 0 { 100 } else { buffer_size }; + const DEFAULT_DUMP_LIMIT: usize = 131072; + let dump_limit = dump_replay_limit.unwrap_or(DEFAULT_DUMP_LIMIT); + let (tx, rx) = channel(buffer_size); + let abort_handle = CancellationToken::new(); + let abort_handle_thread = abort_handle.clone(); + + let handle = task::spawn(async move { + let mut dump_file = None::<(tokio::fs::File, Option)>; + if let Some(ref p) = dump_replay { + if let Ok(f) = tokio::fs::File::create(p).await { + let limit_str = if dump_limit == 0 { + "full stream".to_string() + } else { + format!("first {} bytes", dump_limit) + }; + log::info!( + "DownloadByTime: will dump {} (after 32-byte header) to {}", + limit_str, + p.display() + ); + dump_file = Some((f, if dump_limit == 0 { None } else { Some(dump_limit) })); + } + } + let mut sub = connection + .subscribe(MSG_ID_DOWNLOAD_BY_TIME, start_msg_num) + .await?; + let msg = Bc { + meta: BcMeta { + msg_id: MSG_ID_DOWNLOAD_BY_TIME, + channel_id, + msg_num: start_msg_num, + stream_type: 0, + response_code: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: Some(BcPayloads::Binary(payload)), + }), + }; + log::info!( + "DownloadByTime: sending MSG 143 start_time {:?} end_time {:?}", + start_time, end_time + ); + sub.send(msg).await?; + + let mut first = true; + let mut codec = BcMediaCodex::new(strict); + let mut buf = BytesMut::new(); + let mut raw_replay_mode = false; + let mut packet_count = 0u32; + let mut total_binary_bytes = 0usize; + let mut accepted_stream_response_codes: HashSet = HashSet::new(); + 'recv_loop: loop { + tokio::select! { + _ = abort_handle_thread.cancelled() => break 'recv_loop, + msg_res = sub.recv() => { + let msg = match msg_res { + Err(e) => { + log::info!( + "DownloadByTime stream ended: recv error ({} packets, {} bytes): {:?}", + packet_count, total_binary_bytes, e + ); + log::info!( + "Download failed: verify protocol in Ghidra via MCP (see GHIDRA_CHECKLIST.md), do not guess" + ); + return Err(e); + } + Ok(m) => m, + }; + if first { + first = false; + if msg.meta.response_code != 200 { + log::info!( + "DownloadByTime rejected: response_code={}", + msg.meta.response_code + ); + return Err(Error::UnintelligibleReply { + _reply: std::sync::Arc::new(Box::new(msg)), + why: "Camera did not accept download-by-time start", + }); + } + accepted_stream_response_codes.insert(200); + log::info!("DownloadByTime: camera accepted (200), streaming..."); + } else if msg.meta.response_code == 300 || msg.meta.response_code == 331 { + log::info!( + "DownloadByTime: response {} (end), stopping ({} packets, {} bytes)", + msg.meta.response_code, packet_count, total_binary_bytes + ); + let _ = tx.send(Ok(BcMedia::StreamEnd)).await; + break 'recv_loop; + } + if let BcBody::ModernMsg(ModernMsg { + payload: Some(BcPayloads::Binary(ref data)), + .. + }) = msg.body + { + let code = msg.meta.response_code; + if !accepted_stream_response_codes.contains(&code) { + if code != 300 && code != 331 { + accepted_stream_response_codes.insert(code); + log::debug!("DownloadByTime: accepting streaming response_code {} (added to accepted set)", code); + } + } + if accepted_stream_response_codes.contains(&code) { + packet_count += 1; + total_binary_bytes += data.len(); + if let Some((ref mut f, ref mut remaining)) = dump_file { + if packet_count >= 2 { + let to_write = match *remaining { + None => data.len(), + Some(r) if r > 0 => data.len().min(r), + _ => 0, + }; + if to_write > 0 { + let _ = f.write_all(&data[..to_write]).await; + if let Some(ref mut r) = remaining { + *r = r.saturating_sub(to_write); + if *r == 0 { + log::info!("DownloadByTime: reached dump limit, closed dump file"); + dump_file = None; + } + } + } + } + } + if packet_count == 1 { + const REPLAY_HEADER_LEN: usize = 32; + if data.len() == REPLAY_HEADER_LEN { + log::info!("DownloadByTime: skipping 32-byte stream info"); + } else { + buf.extend_from_slice(data); + } + } else { + if packet_count == 2 && data.len() >= 8 && data[4..8] == *b"ftyp" { + raw_replay_mode = true; + log::info!("DownloadByTime: stream is MP4, forwarding raw bytes"); + } + if raw_replay_mode { + let payload = strip_e1_replay_envelope(data); + if packet_count <= 3 { + log::debug!( + "E1 strip (DownloadByTime): pkt={} data_len={} payload_len={} data_first_64={:02x?} payload_first_64={:02x?}", + packet_count, + data.len(), + payload.len(), + &data[..data.len().min(64)], + &payload[..payload.len().min(64)] + ); + } + if tx.send(Ok(BcMedia::RawReplayChunk(payload.to_vec()))).await.is_err() { + break 'recv_loop; + } + } else { + buf.extend_from_slice(data); + while let Some(bc_media) = codec.decode(&mut buf)? { + if tx.send(Ok(bc_media)).await.is_err() { + break 'recv_loop; + } + } + } + } + } + } + } + } + } + if let Some((ref mut f, _)) = dump_file { + let _ = f.flush().await; + } + let sub_stop = connection.subscribe(MSG_ID_DOWNLOAD_STOP, stop_msg_num).await?; + let stop_msg = Bc { + meta: BcMeta { + msg_id: MSG_ID_DOWNLOAD_STOP, + channel_id, + msg_num: stop_msg_num, + stream_type: 0, + response_code: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: None, + }), + }; + let _ = sub_stop.send(stop_msg).await; + Ok(()) + }); + + Ok(StreamData::from_parts(handle, rx, abort_handle)) + } + + /// Start direct file download by name (MSG 8, NET_DOWNLOAD_V20). + /// + /// Sends a binary `BC_DOWNLOAD_BY_NAME_INFO` payload (0xD48 bytes) to the camera. + /// The camera streams the raw file content back (typically MP4). Stream ends on + /// response 300 or 331. Stop is sent as MSG 9 (NET_DOWNLOAD_STOP_V20) when stream drops. + /// + /// Layout confirmed from Android SDK Ghidra analysis: + /// offset 0x00: iChannel (uint32_t) + /// offset 0x04: cUID (32 bytes, camera UID as ASCII string) + /// offset 0x24: cFileName (null-terminated C string, file name from file list) + /// total: 0xD48 = 3400 bytes (remaining fields zeroed) + pub async fn start_download_file_by_name( + &self, + file_name: &str, + strict: bool, + buffer_size: usize, + dump_replay: Option, + dump_replay_limit: Option, + ) -> Result { + let connection = self.get_connection(); + let start_msg_num = self.new_message_num(); + let stop_msg_num = self.new_message_num(); + let channel_id = self.channel_id; + let uid = self.uid().await.unwrap_or_default(); + let payload = build_download_by_name_payload(channel_id, &uid, file_name); + let buffer_size = if buffer_size == 0 { 100 } else { buffer_size }; + const DEFAULT_DUMP_LIMIT: usize = 131072; + let dump_limit = dump_replay_limit.unwrap_or(DEFAULT_DUMP_LIMIT); + let file_name = file_name.to_string(); + let (tx, rx) = channel(buffer_size); + let abort_handle = CancellationToken::new(); + let abort_handle_thread = abort_handle.clone(); + + let handle = task::spawn(async move { + let mut dump_file = None::<(tokio::fs::File, Option)>; + if let Some(ref p) = dump_replay { + if let Ok(f) = tokio::fs::File::create(p).await { + let limit_str = if dump_limit == 0 { + "full stream".to_string() + } else { + format!("first {} bytes", dump_limit) + }; + log::info!( + "DownloadByName: will dump {} to {}", + limit_str, + p.display() + ); + dump_file = Some((f, if dump_limit == 0 { None } else { Some(dump_limit) })); + } + } + let mut sub = connection + .subscribe(MSG_ID_DOWNLOAD_FILE_BY_NAME, start_msg_num) + .await?; + let msg = Bc { + meta: BcMeta { + msg_id: MSG_ID_DOWNLOAD_FILE_BY_NAME, + channel_id, + msg_num: start_msg_num, + stream_type: 0, + response_code: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: Some(BcPayloads::Binary(payload)), + }), + }; + log::info!("DownloadByName: sending MSG 8 for file '{}'", file_name); + sub.send(msg).await?; + + let mut first = true; + let mut codec = BcMediaCodex::new(strict); + let mut buf = BytesMut::new(); + let mut raw_replay_mode = false; + let mut packet_count = 0u32; + let mut total_binary_bytes = 0usize; + let mut accepted_stream_response_codes: HashSet = HashSet::new(); + 'recv_loop: loop { + tokio::select! { + _ = abort_handle_thread.cancelled() => break 'recv_loop, + msg_res = sub.recv() => { + let msg = match msg_res { + Err(e) => { + log::info!( + "DownloadByName stream ended: recv error ({} packets, {} bytes): {:?}", + packet_count, total_binary_bytes, e + ); + return Err(e); + } + Ok(m) => m, + }; + if first { + first = false; + if msg.meta.response_code != 200 { + log::info!( + "DownloadByName rejected: response_code={} (camera may not support MSG 8 download-by-name)", + msg.meta.response_code + ); + return Err(Error::UnintelligibleReply { + _reply: std::sync::Arc::new(Box::new(msg)), + why: "Camera did not accept download-file-by-name (MSG 8)", + }); + } + accepted_stream_response_codes.insert(200); + log::info!("DownloadByName: camera accepted (200), streaming file..."); + } else if msg.meta.response_code == 300 || msg.meta.response_code == 331 { + log::info!( + "DownloadByName: response {} (end), file complete ({} packets, {} bytes)", + msg.meta.response_code, packet_count, total_binary_bytes + ); + let _ = tx.send(Ok(BcMedia::StreamEnd)).await; + break 'recv_loop; + } + if let BcBody::ModernMsg(ModernMsg { payload: Some(BcPayloads::Binary(data)), .. }) = &msg.body { + let code = msg.meta.response_code; + if !accepted_stream_response_codes.contains(&code) { + if code != 300 && code != 331 { + accepted_stream_response_codes.insert(code); + log::debug!("DownloadByName: accepting streaming response_code {} (added to accepted set)", code); + } + } + if accepted_stream_response_codes.contains(&code) { + packet_count += 1; + total_binary_bytes += data.len(); + if let Some((ref mut f, ref mut remaining)) = dump_file { + let to_write = if let Some(r) = remaining { + data.len().min(*r) + } else { + data.len() + }; + if to_write > 0 { + let _ = f.write_all(&data[..to_write]).await; + if let Some(ref mut r) = remaining { + *r = r.saturating_sub(to_write); + if *r == 0 { + log::info!("DownloadByName: reached dump limit, closed dump file"); + dump_file = None; + } + } + } + } + if packet_count == 1 { + const REPLAY_HEADER_LEN: usize = 32; + if data.len() == REPLAY_HEADER_LEN { + log::info!("DownloadByName: skipping 32-byte stream info header"); + continue; + } + } + if packet_count == 2 && data.len() >= 8 && data[4..8] == *b"ftyp" { + raw_replay_mode = true; + log::info!("DownloadByName: stream is raw MP4 (ftyp), forwarding bytes directly"); + } + if raw_replay_mode { + let _ = tx.send(Ok(BcMedia::RawReplayChunk(data.clone()))).await; + } else { + buf.extend_from_slice(data); + loop { + match codec.decode(&mut buf) { + Ok(Some(frame)) => { + if tx.send(Ok(frame)).await.is_err() { + break 'recv_loop; + } + } + Ok(None) => break, + Err(_e) => break, + } + } + } + } + } + } + } + } + if let Some((ref mut f, _)) = dump_file { + let _ = f.flush().await; + } + let sub_stop = connection.subscribe(MSG_ID_DOWNLOAD_FILE_STOP, stop_msg_num).await?; + let stop_msg = Bc { + meta: BcMeta { + msg_id: MSG_ID_DOWNLOAD_FILE_STOP, + channel_id, + msg_num: stop_msg_num, + stream_type: 0, + response_code: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: None, + }), + }; + let _ = sub_stop.send(stop_msg).await; + Ok(()) + }); + + Ok(StreamData::from_parts(handle, rx, abort_handle)) + } + + /// Stop replay (MSG 7). Pass channel and file name from the playing file. + pub async fn replay_stop(&self, name: &str) -> Result<()> { + let connection = self.get_connection(); + let msg_num = self.new_message_num(); + let mut sub = connection + .subscribe(MSG_ID_REPLAY_STOP, msg_num) + .await?; + + let file_info = FileInfo { + channel_id: Some(self.channel_id), + name: Some(name.to_string()), + ..Default::default() + }; + + let xml = BcXml { + file_info_list: Some(FileInfoList { + version: Some(xml_ver()), + file_info: vec![file_info], + }), + ..Default::default() + }; + + let msg = Bc { + meta: BcMeta { + msg_id: MSG_ID_REPLAY_STOP, + channel_id: self.channel_id, + msg_num, + stream_type: 0, + response_code: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: None, + payload: Some(BcPayloads::BcXml(xml)), + }), + }; + + sub.send(msg).await?; + let msg = sub.recv().await?; + + if msg.meta.response_code == 200 { + Ok(()) + } else { + Err(Error::CameraServiceUnavailable { + id: msg.meta.msg_id, + code: msg.meta.response_code, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::strip_e1_replay_envelope; + + #[test] + fn strip_e1_replay_envelope_returns_payload_after_xml() { + let prefix_32: Vec = (0..32).map(|i| i as u8).collect(); + let xml = b"\n\n1024\n\n"; + let payload = b"ftypmp42\x00\x00\x00\x00"; + let body: Vec = prefix_32.iter().chain(xml).chain(payload.iter()).copied().collect(); + let out = strip_e1_replay_envelope(&body); + assert_eq!(out, payload as &[u8], "should return only the payload after \\n"); + } + + #[test] + fn strip_e1_replay_envelope_passthrough_when_no_xml() { + let data = b"ftypmp42\x00\x00\x00\x00"; + assert_eq!(strip_e1_replay_envelope(data), data as &[u8]); + assert_eq!(strip_e1_replay_envelope(&data[..4]), &data[..4]); + } + + #[test] + fn strip_e1_replay_envelope_xml_at_start_no_32_prefix() { + let xml = b"\n\n1024\n\n"; + let payload = b"ftypmp42\x00\x00\x00\x00"; + let body: Vec = xml.iter().chain(payload.iter()).copied().collect(); + let out = strip_e1_replay_envelope(&body); + assert_eq!(out, payload as &[u8], "when body starts with XML (no 32-byte prefix), should return only payload"); + } +} diff --git a/crates/core/src/bc_protocol/services.rs b/crates/core/src/bc_protocol/services.rs index 25f78c6c0..d432c1c1f 100644 --- a/crates/core/src/bc_protocol/services.rs +++ b/crates/core/src/bc_protocol/services.rs @@ -47,7 +47,7 @@ impl BcCamera { Ok(()) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not except the BcXmp with service data", }) } @@ -113,7 +113,7 @@ impl BcCamera { return Ok(xml); } else { return Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Expected ModernMsg payload but it was not recieved", }); } @@ -132,7 +132,7 @@ impl BcCamera { Ok(xml) } else { Err(Error::UnintelligibleXml { - reply: std::sync::Arc::new(Box::new(bcxml)), + _reply: std::sync::Arc::new(Box::new(bcxml)), why: "Expected ServerPort xml but it was not recieved", }) } @@ -165,7 +165,7 @@ impl BcCamera { .await } else { Err(Error::UnintelligibleXml { - reply: std::sync::Arc::new(Box::new(bcxml)), + _reply: std::sync::Arc::new(Box::new(bcxml)), why: "Expected ServerPort xml but it was not recieved", }) } @@ -182,7 +182,7 @@ impl BcCamera { Ok(xml) } else { Err(Error::UnintelligibleXml { - reply: std::sync::Arc::new(Box::new(bcxml)), + _reply: std::sync::Arc::new(Box::new(bcxml)), why: "Expected HttpPort xml but it was not recieved", }) } @@ -215,7 +215,7 @@ impl BcCamera { .await } else { Err(Error::UnintelligibleXml { - reply: std::sync::Arc::new(Box::new(bcxml)), + _reply: std::sync::Arc::new(Box::new(bcxml)), why: "Expected HttpPort xml but it was not recieved", }) } @@ -232,7 +232,7 @@ impl BcCamera { Ok(xml) } else { Err(Error::UnintelligibleXml { - reply: std::sync::Arc::new(Box::new(bcxml)), + _reply: std::sync::Arc::new(Box::new(bcxml)), why: "Expected HttpsPort xml but it was not recieved", }) } @@ -265,7 +265,7 @@ impl BcCamera { .await } else { Err(Error::UnintelligibleXml { - reply: std::sync::Arc::new(Box::new(bcxml)), + _reply: std::sync::Arc::new(Box::new(bcxml)), why: "Expected HttpsPort xml but it was not recieved", }) } @@ -282,7 +282,7 @@ impl BcCamera { Ok(xml) } else { Err(Error::UnintelligibleXml { - reply: std::sync::Arc::new(Box::new(bcxml)), + _reply: std::sync::Arc::new(Box::new(bcxml)), why: "Expected RtspPort xml but it was not recieved", }) } @@ -315,7 +315,7 @@ impl BcCamera { .await } else { Err(Error::UnintelligibleXml { - reply: std::sync::Arc::new(Box::new(bcxml)), + _reply: std::sync::Arc::new(Box::new(bcxml)), why: "Expected RtspPort xml but it was not recieved", }) } @@ -332,7 +332,7 @@ impl BcCamera { Ok(xml) } else { Err(Error::UnintelligibleXml { - reply: std::sync::Arc::new(Box::new(bcxml)), + _reply: std::sync::Arc::new(Box::new(bcxml)), why: "Expected RtmpPort xml but it was not recieved", }) } @@ -365,7 +365,7 @@ impl BcCamera { .await } else { Err(Error::UnintelligibleXml { - reply: std::sync::Arc::new(Box::new(bcxml)), + _reply: std::sync::Arc::new(Box::new(bcxml)), why: "Expected RtmpPort xml but it was not recieved", }) } @@ -382,7 +382,7 @@ impl BcCamera { Ok(xml) } else { Err(Error::UnintelligibleXml { - reply: std::sync::Arc::new(Box::new(bcxml)), + _reply: std::sync::Arc::new(Box::new(bcxml)), why: "Expected OnvifPort xml but it was not recieved", }) } @@ -415,7 +415,7 @@ impl BcCamera { .await } else { Err(Error::UnintelligibleXml { - reply: std::sync::Arc::new(Box::new(bcxml)), + _reply: std::sync::Arc::new(Box::new(bcxml)), why: "Expected OnvifPort xml but it was not recieved", }) } diff --git a/crates/core/src/bc_protocol/snap.rs b/crates/core/src/bc_protocol/snap.rs index cdbef78b5..9bf6555d0 100644 --- a/crates/core/src/bc_protocol/snap.rs +++ b/crates/core/src/bc_protocol/snap.rs @@ -92,7 +92,7 @@ impl BcCamera { result.extend_from_slice(&data); } else { return Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Expected binary data but got something else", }); } @@ -129,7 +129,7 @@ impl BcCamera { } } else { return Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Expected binary data but got something else", }); } @@ -152,7 +152,7 @@ impl BcCamera { Ok(result) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Expected Snap xml but it was not recieved", }) } diff --git a/crates/core/src/bc_protocol/stream.rs b/crates/core/src/bc_protocol/stream.rs index a372bfb94..b941ede08 100644 --- a/crates/core/src/bc_protocol/stream.rs +++ b/crates/core/src/bc_protocol/stream.rs @@ -44,6 +44,19 @@ pub struct StreamData { } impl StreamData { + /// Create stream data from a task handle and receiver (e.g. for replay streams). + pub(crate) fn from_parts( + handle: JoinHandle>, + rx: Receiver>, + abort_handle: CancellationToken, + ) -> Self { + Self { + handle: Some(handle), + rx, + abort_handle, + } + } + /// Pull data from the camera's buffer /// This returns raw BcMedia packets pub async fn get_data(&mut self) -> Result> { @@ -79,15 +92,24 @@ impl StreamData { impl Drop for StreamData { fn drop(&mut self) { - log::trace!("Drop StreamData"); + log::info!("StreamData::drop: starting"); self.abort_handle.cancel(); - if let Some(handle) = self.handle.take() { - let _gt = tokio::runtime::Handle::current().enter(); - tokio::task::spawn(async move { - let _ = handle.await; - }); + log::info!("StreamData::drop: abort_handle cancelled"); + if let Some(handle) = self.handle.as_ref() { + if handle.is_finished() { + log::info!("StreamData::drop: handle is finished, dropping"); + } else { + log::warn!("StreamData::drop: handle is NOT finished, detaching (task may continue)"); + } + } else { + log::info!("StreamData::drop: no handle to drop"); } - log::trace!("Dropped MotionData"); + // Just drop the handle. If it's finished, dropping is fine. + // If it's not finished, dropping detaches it (task continues but we don't wait). + // We've already cancelled via abort_handle, so the task should finish soon. + // This avoids spawning a task that keeps the runtime alive. + self.handle.take(); + log::info!("StreamData::drop: complete"); } } @@ -197,7 +219,7 @@ impl BcCamera { { } else { return Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not accept the stream start command.", }); } diff --git a/crates/core/src/bc_protocol/stream_info.rs b/crates/core/src/bc_protocol/stream_info.rs index c3798a909..c3ae29de3 100644 --- a/crates/core/src/bc_protocol/stream_info.rs +++ b/crates/core/src/bc_protocol/stream_info.rs @@ -45,7 +45,7 @@ impl BcCamera { Ok(data) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Expected StreamInfoList xml but it was not recieved", }) } diff --git a/crates/core/src/bc_protocol/support.rs b/crates/core/src/bc_protocol/support.rs index 490f0fbb0..03dae5000 100644 --- a/crates/core/src/bc_protocol/support.rs +++ b/crates/core/src/bc_protocol/support.rs @@ -43,7 +43,7 @@ impl BcCamera { Ok(xml) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Expected Support xml but it was not recieved", }) } diff --git a/crates/core/src/bc_protocol/talk.rs b/crates/core/src/bc_protocol/talk.rs index df42e8659..3c86d4631 100644 --- a/crates/core/src/bc_protocol/talk.rs +++ b/crates/core/src/bc_protocol/talk.rs @@ -46,7 +46,7 @@ impl BcCamera { { } else { return Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not accept the talk stop command.", }); } @@ -94,7 +94,7 @@ impl BcCamera { Ok(talk_ability) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Expected TalkAbility xml but it was not recieved", }) } @@ -174,7 +174,7 @@ impl BcCamera { { } else { return Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not accept the TalkConfig xml. Audio format is likely incorrect", }); @@ -315,7 +315,7 @@ impl BcCamera { { } else { return Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not accept the TalkConfig xml. Audio format is likely incorrect", }); diff --git a/crates/core/src/bc_protocol/time.rs b/crates/core/src/bc_protocol/time.rs index a7c94e6a3..5dd9df859 100644 --- a/crates/core/src/bc_protocol/time.rs +++ b/crates/core/src/bc_protocol/time.rs @@ -63,7 +63,7 @@ impl BcCamera { Ok(dt) => dt, Err(_) => { return Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Could not parse date", }) } @@ -84,7 +84,7 @@ impl BcCamera { } } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Reply did not contain SystemGeneral with all time fields filled out", }) } @@ -142,7 +142,7 @@ impl BcCamera { { } else { return Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not accept the set time command.", }); } diff --git a/crates/core/src/bc_protocol/uid.rs b/crates/core/src/bc_protocol/uid.rs index 508717802..57f7e8cd2 100644 --- a/crates/core/src/bc_protocol/uid.rs +++ b/crates/core/src/bc_protocol/uid.rs @@ -42,7 +42,7 @@ impl BcCamera { Ok(uid_xml) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Expected Uid xml but it was not recieved", }) } diff --git a/crates/core/src/bc_protocol/users.rs b/crates/core/src/bc_protocol/users.rs index 1b26f29fa..ab15a7994 100644 --- a/crates/core/src/bc_protocol/users.rs +++ b/crates/core/src/bc_protocol/users.rs @@ -50,7 +50,7 @@ impl BcCamera { Ok(user_list) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "Expected ModernMsg payload with a user_list but it was not recieved", }) } @@ -167,7 +167,7 @@ impl BcCamera { Ok(()) } else { Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(msg)), + _reply: std::sync::Arc::new(Box::new(msg)), why: "The camera did not except the BcXmp with service data", }) } diff --git a/crates/core/src/bc_protocol/version.rs b/crates/core/src/bc_protocol/version.rs index d56fb9bc2..3b16d2793 100644 --- a/crates/core/src/bc_protocol/version.rs +++ b/crates/core/src/bc_protocol/version.rs @@ -46,7 +46,7 @@ impl BcCamera { } _ => { return Err(Error::UnintelligibleReply { - reply: std::sync::Arc::new(Box::new(modern_reply)), + _reply: std::sync::Arc::new(Box::new(modern_reply)), why: "Expected a VersionInfo message", }) } diff --git a/crates/core/src/bcmedia/codex.rs b/crates/core/src/bcmedia/codex.rs index 3523c9a35..4ce29fc55 100644 --- a/crates/core/src/bcmedia/codex.rs +++ b/crates/core/src/bcmedia/codex.rs @@ -2,12 +2,14 @@ //! //! BcMediaCodex is used with a `[tokio_util::codec::Framed]` to form complete packets //! +use crate::bcmedia::de::find_next_bcmedia_magic; use crate::bcmedia::model::*; use crate::{Error, Result}; -use bytes::BytesMut; +use bytes::{Buf, BytesMut}; use log::*; use tokio_util::codec::{Decoder, Encoder}; +/// Codec for BcMedia framed stream (livestream and replay). Use `new(false)` for replay when the stream may have leading junk (e.g. 32-byte header). pub struct BcMediaCodex { /// If true we will not search for the start of the next packet /// in the event that the stream appears to be corrupted @@ -16,7 +18,8 @@ pub struct BcMediaCodex { } impl BcMediaCodex { - pub(crate) fn new(strict: bool) -> Self { + /// Create a new BcMedia codec. Use strict=false for replay/streams that may have leading junk. + pub fn new(strict: bool) -> Self { Self { strict, amount_skipped: 0, @@ -68,16 +71,20 @@ impl Decoder for BcMediaCodex { Err(e) => { if self.strict { return Err(e); - } else if src.is_empty() { + } else if src.len() < 4 { return Ok(None); } else { if self.amount_skipped == 0 { - debug!("Error in stream attempting to restore"); - trace!(" Stream Error: {:?}", e); + trace!("Error in stream attempting to restore: {:?}", e); } - // Drop the whole packet and wait for a packet that starts with magic - self.amount_skipped += src.len(); - src.clear(); + // Prefer resync to next 8-byte-aligned BcMedia magic when found; else advance 1 byte + // so we don't skip past the next real frame (advancing 8 when no magic discarded most of the stream). + let skip = find_next_bcmedia_magic(src) + .unwrap_or(1) + .min(src.len()) + .max(1); + self.amount_skipped += skip; + src.advance(skip); continue; } } diff --git a/crates/core/src/bcmedia/de.rs b/crates/core/src/bcmedia/de.rs index b538efc47..4f6f34307 100644 --- a/crates/core/src/bcmedia/de.rs +++ b/crates/core/src/bcmedia/de.rs @@ -8,6 +8,33 @@ type IResult> = Result<(I, O), nom::Err // PAD_SIZE: Media packets use 8 byte padding const PAD_SIZE: u32 = 8; +/// Returns the byte offset (>= 8) of the next BcMedia frame magic in `buf`, or None if none found. +/// Only considers 8-byte-aligned positions to avoid false positives (the magic can appear inside H.264 payload). +/// Used by the codec to resync after a parse error instead of discarding the rest of the stream. +pub(crate) fn find_next_bcmedia_magic(buf: &[u8]) -> Option { + fn is_magic(u: u32) -> bool { + matches!( + u, + MAGIC_HEADER_BCMEDIA_INFO_V1 + | MAGIC_HEADER_BCMEDIA_INFO_V2 + | MAGIC_HEADER_BCMEDIA_IFRAME..=MAGIC_HEADER_BCMEDIA_IFRAME_LAST + | MAGIC_HEADER_BCMEDIA_PFRAME..=MAGIC_HEADER_BCMEDIA_PFRAME_LAST + | MAGIC_HEADER_BCMEDIA_AAC + | MAGIC_HEADER_BCMEDIA_ADPCM + ) + } + // BcMedia frames are 8-byte padded; only resync at 8-byte boundaries to avoid landing in the middle of NAL data + let mut i = 8; + while i + 4 <= buf.len() { + let word = u32::from_le_bytes([buf[i], buf[i + 1], buf[i + 2], buf[i + 3]]); + if is_magic(word) { + return Some(i); + } + i += 8; + } + None +} + impl BcMedia { pub(crate) fn deserialize(buf: &mut BytesMut) -> Result { let (result, len) = match consumed(bcmedia)(buf) { diff --git a/crates/core/src/bcmedia/mod.rs b/crates/core/src/bcmedia/mod.rs index 7a61a6802..aa91af730 100644 --- a/crates/core/src/bcmedia/mod.rs +++ b/crates/core/src/bcmedia/mod.rs @@ -1,4 +1,4 @@ -pub(crate) mod codex; +pub mod codex; /// Deserlizer for BCMedia pub mod de; /// Structure model for BCMedia diff --git a/crates/core/src/bcmedia/model.rs b/crates/core/src/bcmedia/model.rs index fad14ce68..679a5385b 100644 --- a/crates/core/src/bcmedia/model.rs +++ b/crates/core/src/bcmedia/model.rs @@ -13,6 +13,13 @@ pub enum BcMedia { Aac(BcMediaAac), /// Holds ADPCM audio Adpcm(BcMediaAdpcm), + /// Raw container data (e.g. MP4) from replay; receive-only, not BcMedia-framed. + RawReplayChunk(Vec), + /// Replay stream started with this message ID (5, 8, or 0x17d). Receive-only; consumer uses it to + /// skip the first 32 bytes only when msg_id == 5 (app parity per BCMEDIA_REPLAY_FORMAT §5). + ReplayStarted(u16), + /// Replay/download stream ended normally (camera sent response 300 or 331). Receive-only sentinel. + StreamEnd, } // pub(super) const MAGIC_HEADER_BCMEDIA_INFO_V1: u32 = 0x31303031; @@ -108,7 +115,7 @@ pub(super) const MAGIC_HEADER_BCMEDIA_IFRAME: u32 = 0x63643030; pub(super) const MAGIC_HEADER_BCMEDIA_IFRAME_LAST: u32 = 0x63643039; /// Video Types for I/PFrame -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VideoType { /// H264 video data H264, diff --git a/crates/core/src/bcmedia/ser.rs b/crates/core/src/bcmedia/ser.rs index 26dc1f6c7..f8dc33ad4 100644 --- a/crates/core/src/bcmedia/ser.rs +++ b/crates/core/src/bcmedia/ser.rs @@ -70,6 +70,15 @@ impl BcMedia { buf, )? } + BcMedia::RawReplayChunk(_) => { + return Err(crate::Error::Other("RawReplayChunk is receive-only and cannot be serialized")); + } + BcMedia::ReplayStarted(_) => { + return Err(crate::Error::Other("ReplayStarted is receive-only and cannot be serialized")); + } + BcMedia::StreamEnd => { + return Err(crate::Error::Other("StreamEnd is receive-only and cannot be serialized")); + } }; Ok(buf) diff --git a/dissector/README.md b/dissector/README.md index 3e5a6d1bb..3dd4bad97 100644 --- a/dissector/README.md +++ b/dissector/README.md @@ -1,8 +1,25 @@ # Using the dissector -The dissector can be used with `wireshark` and `tshark`. It requires another wireshark module [`luagcrypt`](https://github.com/Lekensteyn/luagcrypt) which is not packaged in most Linux distributions and needs building from source. Instructions here are suitable for Debian and its derivatives (tested on Debian 12 Bookworm amd64). +The dissector can be used with `wireshark` and `tshark`. It also supports **exporting replay streams** (SD card playback / download) from a pcap to a file. -## Build `luagcrypt.so` +## Exporting replay video from a pcap + +1. Open your pcap in Wireshark (with the Baichuan dissector installed). Replay packets (message IDs 5, 8, 381) are collected automatically during dissection. +2. **Tools → Export Baichuan Replay to File** — reassembles streams (skips 32-byte metadata, concatenates until status 300/331) and writes `replay_1.bin`, `replay_2.bin`, … (or `.mp4` if the payload starts with `ftyp`). +3. Set **Edit → Preferences → Protocols → Baichuan → Replay export directory** to choose where files are saved (empty = current directory). + +Exported files are raw replay payloads (often MP4). If the capture is decrypted (decryption key set), the payloads are the same as the camera sends; you can rename to `.mp4` and play, or run Neolink’s `assemble_mp4_with_mdat` logic if needed. + +--- + +For **AES decryption** of Baichuan payloads the dissector uses, in order: + +1. **Wireshark's built-in Gcrypt Lua API** (Wireshark 4.x) – no extra install. The dissector uses this when available. +2. **Optional fallback: [`luagcrypt`](https://github.com/Lekensteyn/luagcrypt)** – an older, unmaintained C module; only needed if your Wireshark build does not expose GcryptCipher to Lua. Not packaged in most Linux distributions; build from source. Instructions below are for Debian and its derivatives (tested on Debian 12 Bookworm amd64). + +Without either, the dissector still decodes BC headers and XOR-encrypted XML; only AES-decrypted payloads are skipped. + +## Build `luagcrypt.so` (optional fallback) ``` sudo apt install lua5.2 liblua5.2-dev libgcrypt20-dev libgpg-error-dev git clone https://github.com/Lekensteyn/luagcrypt.git diff --git a/dissector/baichuan.lua b/dissector/baichuan.lua index 3b8bc7708..fcdf98401 100644 --- a/dissector/baichuan.lua +++ b/dissector/baichuan.lua @@ -46,6 +46,7 @@ local udp_packet_count = ProtoField.int32("baichuan.udp_packet_count", "udp_pack local udp_last_ack_packet = ProtoField.int32("baichuan.udp_last_ack_packet", "udp_last_ack_packet", base.DEC) local udp_ack_payload_size = ProtoField.int32("baichuan.udp_ack_payload_size", "ack_payload_size", base.DEC) local udp_size = ProtoField.int32("baichuan.udp_size", "udp_size", base.DEC) +local report_subcmd = ProtoField.uint32("baichuan.report_subcmd", "Report sub-command", base.HEX) bc_protocol.fields = { magic_bytes, @@ -74,9 +75,12 @@ bc_protocol.fields = { udp_last_ack_packet, udp_ack_payload_size, udp_size, + report_subcmd, } +-- Message IDs from libBCSDKWrapper SO handleResponseV20 + notes (baichuan-bcsdk-reverse-engineering.md) local message_types = { + [0]="Device report (container)", -- SO: handleResponseV20 case 0/0x21 → handleDeviceReportCmds [1]="login", -- / / [2]="logout", [3]=" (video)", @@ -119,15 +123,15 @@ local message_types = { [59]=" (write)", [65]=" (Export)", [66]=" (Import)", - [67]=" (FW Upgrade)", + [67]=" (FW Upgrade)", -- SO: 0x43 upgrade recv [68]="", [69]=" (write)", [70]="", [71]=" (write)", [76]="", -- // [77]=" (write)", - [78]=" (IPC desc)", - [79]=" (ptz)", + [78]=" (IPC desc)", -- SO: 0x4e → device report path + [79]=" (ptz)", -- SO: 0x4f → device report path [80]="", [81]=" (schedule)", [82]=" (write)", @@ -154,19 +158,23 @@ local message_types = { [116]="", [120]="", [122]="", - [123]="", + [123]="", -- SO: 0x7b; notes: ReplaySeek report 0x123 [124]="", [132]="", -- [133]="", [141]=" (test)", - [142]="", - [145]="", + [142]="", -- SO: 0x8e BaichuanReplayer 0x836 handler + [143]=" (download video)", -- SO: 0x8f BaichuanDownloader + [144]="", -- PCAP: seen in capture; notes: HddInit 0x90 + [145]="", -- SO: 0x91 → device report path [146]="", [151]="", [190]="PTZ Preset", + [192]="", -- 0xc0: RE ambiguous (size/offset in SO) [194]=" (test)", [195]="", [199]="", + [202]="No-op (0xca)", -- SO: handleResponseV20 early exit [208]="", [209]=" (write)", [210]="", @@ -179,11 +187,14 @@ local message_types = { [219]="", [228]="", [229]=" (write)", - [230]="", + [230]="", -- SO: 0xe6 BaichuanDownloader::handleXMLDataResponse [232]="", - [234]="UDP Keep Alive", - [252]="", + [234]="UDP Keep Alive", -- SO: 0xea also device report path + [241]="Device report (0xf1)", -- SO: handleDeviceReportCmds + [242]="Device report (0xf2)", + [252]="", -- SO: 0xfc device report [253]="", + [255]="Device report (0xff)", [263]="", [268]="", [281]="", @@ -192,12 +203,79 @@ local message_types = { [272]="", [273]="", [274]="", - [291]="", + [291]="", -- SO: 0x123 device report (ReplaySeek report) [294]=" (read)", [295]=" (write)", + [298]="", -- 0x12a: preview/replay binary [299]="", [319]="", [342]="", + [357]=" (0x165)", -- SO: BaichuanDownloader::handleXMLDataResponse + [362]=" (0x16a)", + [380]="Device report (0x17c)", -- SO: handleDeviceReportCmds + [381]="Replay XML response (0x17d)", -- SO: BaichuanReplayer::handleXMLDataResponse + [382]="Replay close stream V2 (0x17e)", -- SO: BaichuanReplayer::playbackStreamCloseV2 + [398]="Device report (0x18e)", + [399]="Device report (0x18f)", + [407]="Device report (0x197)", + [408]="Device report (0x198)", + [410]="Device report (0x19a)", + [412]="Device report (0x19c)", + [421]="Device report (0x1a5)", + [429]="Device report (0x1ad)", + [438]="Device report (0x1b6)", + [457]="Device report (0x1c9)", + [464]="Device report (0x1d0)", + [471]="Device report (0x1d7)", + [484]="Device report response container (0x1e4)", -- SO: triggers handleDeviceReportCmds when handle==0 + [490]="Device report (0x1ea)", + [535]=" (0x217)", + [542]="Device report (0x21e)", + [547]="Device report (0x223)", + [573]="Device report (0x23d)", + [580]="Device report (0x244)", + [588]="Device report (0x24c)", + [593]="Device report (0x251)", + [600]="Device report (0x258)", + [607]="Device report (0x25f)", + [623]="Device report (0x26f)", -- SO: handleDeviceReportCmds template 0xce7 + [634]="Device report (0x27a)", + [640]="Device report (0x280)", + [646]=" (0x286)", + [653]=" (0x28d)", + [654]="Device report (0x28e)", + [657]="Device report (0x291)", + [663]="Device report (0x297)", + [668]=" (0x29c)", + [678]="Device report (0x2a6)", + [693]="Device report (0x2b5)", + [723]="Device report (0x2d3)", + [736]="Device report (0x2e0)", + [753]="Device report (0x2f1)", +} + +-- Device report message IDs (handleDeviceReportCmds path). Sub-command at body offset 4; see notes/device-report-message-ids.md +local device_report_msg_ids = { + [0]=true, [33]=true, [78]=true, [79]=true, [145]=true, [234]=true, [241]=true, [242]=true, + [252]=true, [255]=true, [291]=true, [380]=true, [398]=true, [399]=true, [407]=true, [438]=true, + [457]=true, [464]=true, [471]=true, [484]=true, [490]=true, [542]=true, [547]=true, [573]=true, + [580]=true, [588]=true, [600]=true, [623]=true, [634]=true, [640]=true, [654]=true, [657]=true, + [663]=true, [678]=true, [693]=true, [723]=true, [736]=true, [753]=true, +} +-- Report sub-command (at body+4) → human-readable label (from handleDeviceReportCmds + Neolink model.rs) +local report_subcmd_names = { + [33]="AlarmEventList / motion", [145]="ChannelInfoList", [234]="UDP keepalive (special)", + [241]="Report 0xf1 (template 0x890)", [242]="Report 0xf2 (template 0x891)", [252]="BatteryInfoList", + [255]="Report 0xff (template 0x89e)", [291]="ReplaySeek report", [380]="Report 0x17c (template 0x90d)", + [398]="Report 0x18e (template 0x91b)", [399]="Report 0x18f (template 0x91c)", [407]="Report 0x197 (template 0x91f)", + [438]="Floodlight tasks (FloodlightTasksRead)", [457]="Report 0x1c9 (template 0x951)", [464]="Report 0x1d0 (template 0x952)", + [471]="Report 0x1d7 (template 0x953)", [484]="Report 0x1e4 (template 0x96d)", [490]="Report 0x1ea (template 0x965)", + [542]="Report 0x21e (template 0x996)", [547]="Report 0x223 (template 0x999)", [573]="Report 0x23d (template 0x9ac)", + [580]="Report 0x244 (template 0x9d3)", [588]="Report 0x24c (template 0x9b2)", [600]="Report 0x258 (template 0x9bc)", + [623]="Device report 0x26f (template 0x9d2/0xce7)", [640]="Report 0x280 (template 0x9e6)", [654]="Report 0x28e (template 0x9ea)", + [657]="Report 0x291 (template 0x9ed)", [663]="Report 0x297 (template 0x9f3)", [678]="Report 0x2a6 (template 0xa05)", + [693]="Report 0x2b5 (template 0xa0f, simpleRsp)", [723]="Report 0x2d3 (template 0xa19)", [736]="Report 0x2e0 (template 0xa22)", + [753]="Report 0x2f1 (template 0xa37)", } local message_classes = { @@ -219,29 +297,134 @@ local header_lengths = { ----- -- Decryption routine. ----- --- For other locations, use: LUA_CPATH=.../luagcrypt/?.so +-- AES decryption uses either Wireshark's built-in GcryptCipher (no extra install) or +-- optional luagcrypt. Without either, AES decryption is skipped (headers and XOR/xml_decrypt still work). bc_protocol.prefs.key = Pref.string("Decryption key", "", "Passphrase used for the camera. Required to decrypt the AES packets") _G.nonce = {} local function hexencode(str) return (str:gsub(".", function(char) return string.format("%02X", char:byte()) end)) end -local gcrypt = require("luagcrypt") -local function aes_decrypt(data, pinfo) - local nonce_key = tostring(pinfo.src) .. ":" .. pinfo.src_port - local nonce = (_G.nonce[nonce_key] or "") - local raw_key = nonce .. "-" .. bc_protocol.prefs.key - local iv = "0123456789abcdef" + +-- Minimal pure-Lua MD5 (bit32) for key derivation when using Wireshark's GcryptCipher (no luagcrypt needed). +local function md5_binary(msg) + local band, bor, bxor, bnot, rshift = bit32.band, bit32.bor, bit32.bxor, bit32.bnot, bit32.rshift + local lshift = bit32.lshift + -- Rotate left by n bits (32-bit): (x << n) | (x >> (32 - n)) + local function lrotate(x, n) + n = n % 32 + return bor(band(lshift(x, n), 0xFFFFFFFF), rshift(x, 32 - n)) + end + local function F(x,y,z) return bor(band(x,y), band(bnot(x),z)) end + local function G(x,y,z) return bor(band(x,z), band(y,bnot(z))) end + local function H(x,y,z) return bxor(x,bxor(y,z)) end + local function I(x,y,z) return bxor(y, bor(x, bnot(z))) end + local function bytes2word(b0,b1,b2,b3) + return bor(bor(bor(b0, lshift(b1,8)), lshift(b2,16)), lshift(b3,24)) + end + local S = { + 7,12,17,22,7,12,17,22,7,12,17,22,7,12,17,22, + 5,9,14,20,5,9,14,20,5,9,14,20,5,9,14,20, + 4,11,16,23,4,11,16,23,4,11,16,23,4,11,16,23, + 6,10,15,21,6,10,15,21,6,10,15,21,6,10,15,21 + } + local K = { + 0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,0xf57c0faf,0x4787c62a,0xa8304613,0xfd469501, + 0x698098d8,0x8b44f7af,0xffff5bb1,0x895cd7be,0x6b901122,0xfd987193,0xa679438e,0x49b40821, + 0xf61e2562,0xc040b340,0x265e5a51,0xe9b6c7aa,0xd62f105d,0x02441453,0xd8a1e681,0xe7d3fbc8, + 0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,0xa9e3e905,0xfcefa3f8,0x676f02d9,0x8d2a4c8a, + 0xfffa3942,0x8771f681,0x6d9d6122,0xfde5380c,0xa4beea44,0x4bdecfa9,0xf6bb4b60,0xbebfbc70, + 0x289b7ec6,0xeaa127fa,0xd4ef3085,0x04881d05,0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665, + 0xf4292244,0x432aff97,0xab9423a7,0xfc93a039,0x655b59c3,0x8f0ccc92,0xffeff47d,0x85845dd1, + 0x6fa87e4f,0xfe2ce6e0,0xa3014314,0x4e0811a1,0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391 + } + local len = #msg + local buf = {} + for i = 1, len do buf[i] = msg:byte(i) end + buf[len + 1] = 0x80 + local pad = (56 - ((len + 1) % 64) + 64) % 64 + for _ = 1, pad do buf[#buf + 1] = 0 end + local lo = band(len * 8, 0xFFFFFFFF) + local hi = math.floor(len * 8 / 0x100000000) + for _, v in ipairs({ band(lo, 0xFF), band(rshift(lo, 8), 0xFF), band(rshift(lo, 16), 0xFF), band(rshift(lo, 24), 0xFF), band(hi, 0xFF), band(rshift(hi, 8), 0xFF), band(rshift(hi, 16), 0xFF), band(rshift(hi, 24), 0xFF) }) do buf[#buf + 1] = v end + local A, B, C, D = 0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476 + for chunk = 1, #buf, 64 do + local X = {} + for i = 0, 15 do + local j = chunk + i * 4 + X[i + 1] = bytes2word(buf[j], buf[j+1], buf[j+2], buf[j+3]) + end + local a, b, c, d = A, B, C, D + for i = 0, 63 do + local f, g + if i <= 15 then f = F(b,c,d); g = i + elseif i <= 31 then f = G(b,c,d); g = (5 * i + 1) % 16 + elseif i <= 47 then f = H(b,c,d); g = (3 * i + 5) % 16 + else f = I(b,c,d); g = (7 * i) % 16 + end + local t = d + d = c + c = b + b = band(b + lrotate(band(a + f + K[i + 1] + X[g + 1], 0xFFFFFFFF), S[i + 1]), 0xFFFFFFFF) + a = t + end + A, B, C, D = band(A + a, 0xFFFFFFFF), band(B + b, 0xFFFFFFFF), band(C + c, 0xFFFFFFFF), band(D + d, 0xFFFFFFFF) + end + local function word2bytes(w) + return band(w, 0xFF), band(rshift(w, 8), 0xFF), band(rshift(w, 16), 0xFF), band(rshift(w, 24), 0xFF) + end + return string.char(word2bytes(A), word2bytes(B), word2bytes(C), word2bytes(D)) +end + +local gcrypt +do + local ok, mod = pcall(require, "luagcrypt") + gcrypt = ok and mod or nil +end + +local function derive_aes_key(pinfo) + local nonce_key = tostring(pinfo.src) .. ":" .. pinfo.src_port + local nonce = (_G.nonce[nonce_key] or "") + local raw_key = nonce .. "-" .. bc_protocol.prefs.key + local key_hex + if gcrypt then local hasher = gcrypt.Hash(gcrypt.MD_MD5) hasher:write(raw_key) - local key = string.sub(hexencode(hasher:read()),0,16) - local ciphertext = data + key_hex = hexencode(hasher:read()) + else + key_hex = hexencode(md5_binary(raw_key)) + end + return string.sub(key_hex, 1, 16) +end + +local function aes_decrypt(data, pinfo) + local key = derive_aes_key(pinfo) + local iv = "0123456789abcdef" + local ciphertext = data + + -- Prefer Wireshark's built-in GcryptCipher (no luagcrypt needed) + if GcryptCipher and GCRY_CIPHER_AES256 and GCRY_CIPHER_MODE_CFB then + local ok, decrypted = pcall(function() + local cipher = GcryptCipher.open(GCRY_CIPHER_AES256, GCRY_CIPHER_MODE_CFB, 0) + cipher:setkey(ByteArray.new(key)) + cipher:setiv(ByteArray.new(iv)) + return cipher:decrypt(nil, ByteArray.new(ciphertext, true)) + end) + if ok and decrypted and decrypted:len() > 0 then + return decrypted + end + end + + -- Fallback: luagcrypt (optional, build from source) + if gcrypt then local cipher = gcrypt.Cipher(gcrypt.CIPHER_AES256, gcrypt.CIPHER_MODE_CFB) cipher:setkey(key) cipher:setiv(iv) local decrypted = cipher:decrypt(ciphertext) - local result = ByteArray.new(decrypted, true) - return result + return ByteArray.new(decrypted, true) + end + + return ByteArray.new("", true) end local function xml_decrypt(ba, offset) @@ -345,6 +528,13 @@ local function process_body(header, body_buffer, bc_subtree, pinfo) local body = bc_subtree:add(bc_protocol, body_buffer(0,header.msg_len), "Baichuan Message Body, " .. header.class .. ", length: " .. header.msg_len .. ", type " .. header.msg_type) + -- Device report: sub-command at body offset 4 (handleDeviceReportCmds iVar1) + if device_report_msg_ids[header.msg_type] and header.msg_len >= 8 then + local subcmd = body_buffer(4, 4):le_uint() + local label = report_subcmd_names[subcmd] or ("Report sub-command 0x" .. string.format("%x", subcmd)) + body:add_le(report_subcmd, body_buffer(4, 4)):append_text(" (" .. label .. ")") + end + if header.class == "legacy" then if header.msg_type == 1 then body:add_le(username, body_buffer(0, 32)) @@ -596,7 +786,8 @@ local function process_bc_message(buffer, pinfo, tree) local remaining = sub_buffer:len() - header.header_len - local bc_subtree = tree:add(bc_protocol, sub_buffer(0, header.full_body_len), + local full_body_len = header.header_len + header.msg_len + local bc_subtree = tree:add(bc_protocol, sub_buffer(0, full_body_len), "Baichuan IP Camera Protocol, " .. header.msg_type_str .. ":" .. header.msg_type .. " message") process_header(sub_buffer, bc_subtree) if header.header_len < sub_buffer:len() then @@ -606,7 +797,7 @@ local function process_bc_message(buffer, pinfo, tree) remaining = body_buffer:len() - header.msg_len end - -- Remaning bytes? + -- Remaining bytes? if remaining == 0 then continue_loop = false else @@ -710,7 +901,9 @@ local function udp_reassemple(udp_header, subbuffer, more, pinfo, tree) local needed = start_fragment.result if needed == "DONE" then if start_fragment.message_id == udp_header.packet_count then - process_bc_message(start_fragment.buffer:tvb("UDP Reassembly"), pinfo, tree) + local rtvb = start_fragment.buffer:tvb("UDP Reassembly") + process_bc_message(rtvb, pinfo, tree) + parse_and_collect_replay(rtvb, pinfo) end elseif needed == "+1" then -- Cannot handle in UDP... @@ -736,7 +929,9 @@ local function udp_reassemple(udp_header, subbuffer, more, pinfo, tree) end if reassembled:len() >= target_len then start_fragment.result = "DONE" - process_bc_message(reassembled:tvb("Reassembled UDP"), pinfo, tree) + local rtvb = reassembled:tvb("Reassembled UDP") + process_bc_message(rtvb, pinfo, tree) + parse_and_collect_replay(rtvb, pinfo) end end end @@ -744,6 +939,7 @@ end function bc_protocol.init () udp_fragments = {} + _replay_packets = {} end function bc_protocol.dissector(buffer, pinfo, tree) @@ -782,6 +978,7 @@ function bc_protocol.dissector(buffer, pinfo, tree) if pinfo.can_desegment == 1 then -- TCP can use the desegment method if more == "DONE" then process_bc_message(subbuffer, pinfo, tree) + parse_and_collect_replay(subbuffer, pinfo) return elseif more == "+1" then pinfo.desegment_len = DESEGMENT_ONE_MORE_SEGMENT @@ -858,6 +1055,96 @@ local function heuristic_checker_tcp(buffer, pinfo, tree) return true end +----- +-- Replay stream export: collect replay binary (msg 5/8/381) and export to file. +----- +local REPLAY_MSG_IDS = { [5]=true, [8]=true, [381]=true } +local REPLAY_END_STATUS = { [300]=true, [331]=true } +local _replay_packets = {} -- list of {pinfo, status, payload} in packet order + +function parse_and_collect_replay(tvb, pinfo) + if tvb:len() < 20 then return end + local offset = 0 + while offset + 20 <= tvb:len() do + local sub = tvb(offset, nil) + local header_len = get_header_len(sub(0, nil)) + if header_len < 0 or offset + header_len > tvb:len() then break end + local header = get_header(sub(0, nil)) + local full_len = header.header_len + header.msg_len + if offset + full_len > tvb:len() then break end + if REPLAY_MSG_IDS[header.msg_type] and header.status_code and (header.status_code == 200 or REPLAY_END_STATUS[header.status_code]) then + if header.bin_offset and header.msg_len > header.bin_offset then + local bin_len = header.msg_len - header.bin_offset + local payload = sub(header.header_len + header.bin_offset, bin_len):bytes():raw() + table.insert(_replay_packets, { num = pinfo.number, status = header.status_code, payload = payload }) + end + end + offset = offset + full_len + end +end + +bc_protocol.prefs.replay_export_dir = Pref.string("Replay export directory", "", + "Directory path to save exported replay streams (replay_1.bin, replay_2.bin). Empty = current directory.") + +local function reassemble_replay_stream(packets) + table.sort(packets, function(a,b) return a.num < b.num end) + local out = {} + local skip_first_32 = true + for _, p in ipairs(packets) do + if skip_first_32 and #p.payload == 32 then + skip_first_32 = false + else + skip_first_32 = false + out[#out + 1] = p.payload + end + if REPLAY_END_STATUS[p.status] then break end + end + return table.concat(out, "") +end + +local function export_replay_streams() + if #_replay_packets == 0 then + if gui and gui.message_box then + gui.message_box("No replay packets found in this capture.", "Export", "ok") + end + return + end + local dir = bc_protocol.prefs.replay_export_dir + if dir and dir ~= "" and not (dir:match("/$")) then dir = dir .. "/" end + dir = dir or "" + -- Split into streams: each stream ends at status 300/331 or at end of list + local streams = {} + local current = {} + for _, p in ipairs(_replay_packets) do + current[#current + 1] = p + if REPLAY_END_STATUS[p.status] then + streams[#streams + 1] = current + current = {} + end + end + if #current > 0 then streams[#streams + 1] = current end + local written = 0 + for i, stream_packets in ipairs(streams) do + local raw = reassemble_replay_stream(stream_packets) + if #raw > 0 then + local ext = "bin" + if raw:sub(5,8) == "ftyp" then ext = "mp4" end + local fname = dir .. "replay_" .. i .. "." .. ext + local f = io.open(fname, "wb") + if f then + f:write(raw) + f:close() + written = written + 1 + end + end + end + if gui and gui.message_box and written > 0 then + gui.message_box("Exported " .. written .. " replay stream(s) to " .. (dir ~= "" and dir or "(current directory)"), "Export", "ok") + end +end + +register_menu("Export Baichuan Replay to File", export_replay_streams, MENU_TOOLS_UNSORTED) + bc_protocol:register_heuristic("udp", heuristic_checker_udp) bc_protocol:register_heuristic("tcp", heuristic_checker_tcp) -- DissectorTable.get("tcp.port"):add(53959, bc_protocol) -- change to your own custom port diff --git a/docs/BCMEDIA_REPLAY_FORMAT.md b/docs/BCMEDIA_REPLAY_FORMAT.md new file mode 100644 index 000000000..ec1fcb991 --- /dev/null +++ b/docs/BCMEDIA_REPLAY_FORMAT.md @@ -0,0 +1,237 @@ +# BcMedia replay format + +This document describes the **BcMedia** binary format used for Reolink/Baichuan camera replay (SD card playback) and live streaming. It is a proprietary framed format; Neolink implements the only known open decoder. + +--- + +## 1. Overview + +- **Name**: BcMedia (Baichuan Media). +- **Use**: Livestream and replay video/audio over the Baichuan TCP protocol (Reolink cameras, Swann, and other OEMs using the same SDK). +- **Byte order**: Little-endian. +- **Framing**: Each logical “packet” starts with a 4-byte magic (uint32 LE); the parser then reads type-specific header and payload. + +Replay over the wire can appear in two forms: + +1. **BcMedia-framed stream** — Same as livestream: sequence of BcMedia packets (Info, I-frame, P-frame, AAC, ADPCM). No file container (no `ftyp`). +2. **Raw container** — Some cameras send a 32-byte replay header then raw MP4 (ISO Base Media: `ftyp` at offset 4). In that case the payload is standard MP4 and can be fed to FFmpeg directly after skipping the 32-byte header. + +This document focuses on the **BcMedia** format itself. Transport (32-byte header, MSG 5/8, end codes) is summarized in §5. + +--- + +## 2. Binary layout: packet types and magic + +All packets start with a **4-byte magic** (uint32 LE). Valid magics: + +| Magic (hex) | ASCII (LE) | Type | Description | +|-------------|------------|-------------|--------------------------------| +| `0x31303031`| "1001" | Info V1 | Stream info (legacy) | +| `0x32303031`| "1002" | Info V2 | Stream info (current) | +| `0x63643030`..`0x63643039` | "cd00".."cd09" | I-frame | H.264/H.265 keyframe | +| `0x63643130`..`0x63643139` | "cd10".."cd19" | P-frame | H.264/H.265 delta frame | +| `0x62773530`| "bw50" | AAC | AAC audio | +| `0x62773130`| "bw10" | ADPCM | ADPCM audio (DVI-4, 8 kHz) | + +- **"cd"** (video): I- and P-frame magics; the low nibble can encode NVR channel (0–9). +- **"bw"** (audio): Both audio types use this prefix; it may mean mono / single-channel (e.g. “black & white” for one channel). The Android app uses **"wb"** for audio in a different 7-byte framing (see §7 References). +- **No other magics**: The Neolink parser and all observed streams only use the above. There is no other BcMedia 4-byte magic in the codebase or in pcaps. (A separate, non-BcMedia format in the Android app uses 7-byte tags: `dcH26` for video, `wb` for audio; see `notes/ANDROID_REPLAY_DOWNLOAD_FLOW.md`.) + +--- + +## 3. Packet structures (after magic) + +### 3.1 Info V1 (`0x31303031`) + +| Offset | Size | Field | Description | +|--------|------|-----------------|--------------------------------| +| 0 | 4 | header_size | Must be 32 | +| 4 | 4 | video_width | Width in pixels | +| 8 | 4 | video_height | Height in pixels | +| 12 | 1 | unknown | Reserved | +| 13 | 1 | fps | FPS (or index on older cams) | +| 14 | 1 | start_year | Start time (year byte, e.g. 121 = 2021) | +| 15..20 | 6 | start_month..start_seconds | Start date/time | +| 21..26 | 6 | end_* | End date/time | +| 27 | 2 | unknown | Reserved | + +**Total header**: 4 (magic) + 32 = 36 bytes. + +### 3.2 Info V2 (`0x32303031`) + +Same layout as Info V1 (header_size 32, width, height, fps, start/end date-time). Total header 36 bytes. + +### 3.3 I-frame (`cd00`..`cd09`) + +| Offset | Size | Field | Description | +|--------|------|------------------------|--------------------------------| +| 0 | 4 | video_type | "H264" or "H265" (ASCII) | +| 4 | 4 | payload_size | Length of NAL data following | +| 8 | 4 | additional_header_size | Extra header bytes after fixed fields | +| 12 | 4 | microseconds | Timestamp (µs) | +| 16 | 4 | unknown | Reserved | +| 20 | 0.. | optional time | If additional_header_size ≥ 4: POSIX time (uint32) | +| 20+ | (rest)| additional_header | Remaining extra header | +| — | payload_size | data | Raw NAL (H.264/HEVC) | +| — | 0–7 | padding | Pad to 8-byte boundary | + +Padding: `pad = (8 - (payload_size % 8)) % 8` bytes of zeros after the payload. + +### 3.4 P-frame (`cd10`..`cd19`) + +Same as I-frame but no optional `time` field in practice (additional_header_size often 0): + +- video_type (4), payload_size (4), additional_header_size (4), microseconds (4), unknown (4), additional_header (additional_header_size), data (payload_size), padding to 8 bytes. + +### 3.5 AAC (`0x62773530`) + +| Offset | Size | Field | Description | +|--------|------|----------------|--------------------| +| 0 | 2 | payload_size | Length of AAC data | +| 2 | 2 | payload_size_b | Duplicate | +| 4 | payload_size | data | Raw ADTS AAC | +| — | 0–7 | padding | Pad to 8 bytes | + +### 3.6 ADPCM (`0x62773130`) + +| Offset | Size | Field | Description | +|--------|------|----------|--------------------------------------| +| 0 | 2 | payload_size | Total payload (includes sub-header) | +| 2 | 2 | payload_size_b | Duplicate | +| 4 | 2 | magic | `0x0100` (MAGIC_HEADER_BCMEDIA_ADPCM_DATA) | +| 6 | 2 | half_block_size | Block size related (camera-dependent) | +| 8 | (payload_size - 4) | data | 4-byte predictor state + DVI-4 ADPCM block | +| — | 0–7 | padding | Pad to 8 bytes | + +Sample rate is 8000 Hz. Block size for duration: `block_size = data.len() - 4`; duration µs = `block_size * 2 * 1_000_000 / 8000`. + +--- + +## 4. Stream order and decoding + +- **Start**: Stream typically begins with **Info V1** or **Info V2**, then I-frames and P-frames (and optionally AAC/ADPCM). +- **Parsing**: Read 4-byte magic; dispatch to the correct parser; consume header + payload + padding; repeat. If the stream is corrupted, a non-strict decoder may skip bytes until the next known magic. +- **Video**: I-frame and P-frame `data` are raw NAL units (H.264 or H.265). They may already include Annex B start codes (`0x00 0x00 0x01` or `0x00 0x00 0x00 0x01`); if not, prepend `0x00 0x00 0x00 0x01` for Annex B. FFmpeg can then read `-f h264` or `-f hevc` and mux to MP4. +- **Receive-only sentinels** (not on wire): Neolink uses `RawReplayChunk` for raw container bytes and `StreamEnd` when the camera signals end of file (response 300/331). + +Reference implementation: `crates/core/src/bcmedia/` (model.rs, de.rs, ser.rs, codex.rs). + +--- + +## 5. Replay transport (wire) + +Replay is carried over Baichuan TCP messages (e.g. MSG 5, MSG 8, or desktop 0x17d): + +1. **32-byte replay header**: In the **official app** (Ghidra: BaichuanReplayer::handleBinaryDataResponse, Android 0x0034b22c; Desktop FUN_1801768c0), the first packet is treated as **metadata only** (not written to the stream) **only when** **msg_id == 5**, **response_code == 200**, and **body length == 32**. For **0x17d** the app does *not* skip: it writes that first 32-byte body. Neolink skips the first 32 bytes of the accumulated buffer when it does not start with `ftyp` (so BcMedia/MP4 logic sees the real start); for strict app parity you would skip only when the replay was started with MSG 5. Optional file size may be at +0x10 and +0x18 (uint64 LE) in the 32-byte header. +2. **Response codes**: **200** = accept (replay start); **300** = end (by-name); **331** = end (by-time); **400** = reject. Streaming data packets may use other response codes; the app only writes when `header.response_code == replayer.expected_response_code` (e.g. Android this+0x1c). +3. **Following packets**: Either BcMedia-framed data, raw MP4 chunks, or (E1) **Extension XML + binary** blocks — see `notes/REPLAY_BIN_ANALYSIS.md`. If the first payload after the 32-byte header has `ftyp` at bytes 4–8, the stream is treated as raw MP4; otherwise BcMedia or E1 format. +4. **End of stream**: Camera sends **300** or **331**. Some cameras never send them; Neolink can stop when received payload size reaches the expected size from the file list or 32-byte header. +5. **Decrypt boundary (Ghidra):** Replay binary is decrypted **before** it reaches the replayer. In Android `libBCSDKWrapper.so`, `BaichuanDevice::handleResponseV20` @ 0x002bdc60: (a) decrypts the **extension** (if encrypted) via `BaichuanEncryptor::decrypt`, (b) parses `Net_get_query_from_xml`, (c) decrypts the **body** in place (or only the region given by E1 `encryptPos`/`encryptLen` when present), (d) then calls `handleBinaryDataResponse` or `BCNetSessionQueue::setResponseData`. So BcMedia/MP4/E1 payload formats in docs refer to bytes **after** this decrypt step; on wire they are encrypted (XOR or AES per login). See `notes/baichuan-bcsdk-reverse-engineering.md` (reolink) and `notes/REPLAY_SCHEMA_PLAN.md`. + +See `crates/core/src/bc_protocol/replay.rs` and `dissector/PCAP_ANALYSIS.md` for message IDs and reassembly. + +--- + +## 6. Third-party support + +### 6.1 FFmpeg + +**BcMedia is not implemented in FFmpeg.** There is no demuxer or format name for “BcMedia” or “Baichuan” in libavformat. The official formats list (e.g. https://ffmpeg.org/ffmpeg-formats.html) does not include it. + +**Practical use with FFmpeg:** + +- **BcMedia replay**: Decode BcMedia in Neolink (or similar), extract H.264/HEVC NALs, write Annex B (e.g. `.h264`), then: + ```bash + ffmpeg -y -f h264 -i out.h264 -c copy out.mp4 + ``` + (or `-f hevc` for H.265.) +- **Raw MP4 replay**: After skipping the 32-byte replay header, if the rest is MP4, feed it to FFmpeg as a normal file or pipe; no special demuxer needed. + +So FFmpeg is used **after** converting BcMedia to raw H.264/HEVC (or when the camera sends raw MP4). + +### 6.2 go2rtc + +**go2rtc does not implement the BcMedia or Reolink replay format.** It supports RTSP, WebRTC, and various camera brands for **live** streams; Reolink-related issues in the tracker are about RTSP/HTTP (e.g. 400 Bad Request, connection), not about BcMedia or SD card replay. + +Replay from Reolink/Baichuan (MSG 5/8, BcMedia or raw MP4) is not a go2rtc feature; it would require a custom source that speaks the Baichuan protocol and then either: + +- parses BcMedia and exposes NALs (e.g. as H.264 stream), or +- forwards raw MP4 when the camera sends it. + +### 6.3 Other libraries + +No other well-known open-source projects (GStreamer, VLC demuxers, etc.) were found that implement BcMedia. The only open implementation documented here is **Neolink** (`neolink_core::bcmedia` and replay in `src/replay/`). + +--- + +## 6.4 When does the replay stream end? + +We can stop in three ways: + +1. **Camera end codes** — The camera sends a message with `response_code == 300` (replay by-name) or `331` (download-by-time). The core then emits `BcMedia::StreamEnd` and the app exits with “camera signalled end of file”. Some cameras (e.g. E1) may **not** send 300 before we hit a timeout. +2. **Size-based** — When we have an expected file size (from the file list or from the 32-byte replay header), the core stops when `total_binary_bytes - 32 >= expected_payload_size` and sends `StreamEnd`. So we do **not** rely only on time if size is known. +3. **Duration / timeout** — If the app has a duration (e.g. from the file list, “31 s”), it runs a timer. When the timer fires we break with “31s duration reached” and send MSG 7 (replay stop). So in that case we are **timing out**, not necessarily receiving 300 or hitting expected size. + +So: we **do** know stream end when the camera sends 300/331 or when we receive the expected byte count; otherwise we stop when the duration timer fires. + +--- + +## 6.5 E1 replay dump inspection (`out.replay.bin`) + +When assembly fails, the app writes the raw reassembled stream to `out.replay.bin`. Neolink now **strips the E1 envelope** per packet (32-byte prefix + Extension XML, or XML-only when the body starts with ``/`` in the Extension. The wire payload is then `[encrypted extension][encrypted media]`. Decrypting the whole payload in one go yields valid XML at the start but **ciphertext after `\n`** (CFB state is wrong for the rest). Neolink therefore uses a **two-stage** decrypt when FullAes + binary and no encryptLen: decrypt once to find `\n`, then decrypt `payload[0..ext_len]` and `payload[ext_len..]` **separately** (IV reset for each call), and concatenate. So the bytes after the XML become plaintext (e.g. ftyp MP4). + +If the dump **still shows uniform high-entropy binary** (no `ftyp`, no `mdat`, no XML) after the 32-byte header, then the payload we are receiving is either **still encrypted** or **decrypted with the wrong parameters**. That points to E1 **payload decryption** being wrong for replay, for example: + +- **Per-packet IV**: the camera may re‑key or advance an IV (e.g. counter) per packet; the session cipher might need to be reset or updated per E1 packet. +- **Wrong region**: `encrypt_pos` / `encrypt_len` from the Extension might be interpreted differently (e.g. offsets into a different buffer, or lengths in another unit). +- **Replay-specific key**: replay might use a different key or nonce than the rest of the session. + +**Debug logging**: With `RUST_LOG=neolink_core=debug`, Neolink logs E1 decrypt and strip details: in **de.rs** (`E1 decrypt: msg_id=... encryptPos=... encryptLen=... processed_first_64=...`) and in **replay.rs** for the first 3 packets (`E1 strip: pkt=... data_first_64=... payload_first_64=...`). Example: `RUST_LOG=neolink_core=debug ./target/release/neolink replay koty play --name 0120260204150221 --duration 5 --config=neolink.toml 2>&1 | head -200`. + +**Next steps**: capture a replay session (e.g. with the official app) in a pcap and compare the same packet (same offset) with Neolink’s decrypted output; or enable debug logs in `de.rs` to print the first few bytes of the decrypted region and of the final Binary payload for one E1 packet. + +--- + +## 6.6 E1 replay start (file name format) + +E1 cameras can **reject** MSG 5/8 (400) or 0x17d (405) when the replay file name does not match the format they expect. Observed behaviour: + +- **Rejected**: `01_20260204120000` (underscore, readable date) → 400 for MSG 5 and 8, 405 for 0x17d. +- **Accepted**: `0120260204150221` (no underscore; `01` + `YYYYMMDDHHMMSS`) → MSG 5 returns 200 and streaming starts. + +Use the exact file names listed by `neolink replay files --date YYYY-MM-DD` (e.g. `0120260204150221`). If replay start returns 400/405, try a different file from the list; the naming may vary by firmware. + +--- + +## 6.7 E1 replay decoder (Python) and pcaps in repo + +Pcaps from replay sessions are in the repo root (e.g. `PCAPdroid_08_Feb_04_24_56.pcap`, `PCAPdroid_06_Feb_13_42_19.pcap`). Use them to test the Python E1 decoder and compare with Neolink. + +**How to run the E1 replay decoder** (from repo root, with [uv](https://docs.astral.sh/uv/)): + +```bash +# Password from env or neolink.toml in repo root +uv run python scripts/e1_replay_decoder.py PCAPdroid_08_Feb_04_24_56.pcap e1_decoded.bin + +# Or with explicit password (same as in neolink.toml for the camera) +uv run python scripts/e1_replay_decoder.py --password 'YourCameraPassword' PCAPdroid_08_Feb_04_24_56.pcap e1_decoded.bin + +# If the pcap has multiple TCP streams to the camera, try --stream 2 etc. +uv run python scripts/e1_replay_decoder.py --stream 2 --password '...' PCAPdroid_08_Feb_04_24_56.pcap out.bin +``` + +Output: writes decoded bytes to the given path (default `e1_replay_decoded.bin` in the pcap’s directory) and prints whether the stream starts with `ftyp`. If the decoder yields `ftyp`, align Neolink’s E1 decrypt/strip logic with the script. + +--- + +## 7. References + +- Neolink: `crates/core/src/bcmedia/` (model, de, ser, codex), `crates/core/src/bc_protocol/replay.rs`, `src/replay/mod.rs` +- Notes: `notes/REPLAY_VIDEO_FORMAT.md` (wire format variants), `notes/REPLAY_RE_ANALYSIS.md`, `dissector/PCAP_ANALYSIS.md` +- Dissector: `dissector/baichuan.lua` (replay export skips 32-byte header, concatenates until 300/331) +- Script: `scripts/extract_replay_from_pcap_app.py` (BcMedia parse → NAL → Annex B → FFmpeg mux) diff --git a/sample_config.toml b/sample_config.toml index 85a7ea843..c40c895a4 100644 --- a/sample_config.toml +++ b/sample_config.toml @@ -37,10 +37,11 @@ bind = "0.0.0.0" [[cameras]] -name = "driveway" +name = "koty" username = "admin" -password = "12345678" -address = "192.168.1.187:9000" +password = "XKv9Zud7ZQCLtpLmMFm1" +address = "koty.lan:9000" + # MQTT Discovery: https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery # mqtt.discovery.topic = "homeassistant" # Uncomment to enable # If using discovery, _ characters are replaced with spaces in the name and title case is applied @@ -85,11 +86,7 @@ address = "192.168.1.187:9000" # print_format = "None" -[[cameras]] -name = "storage shed" -username = "admin" -password = "987654321" -address = "192.168.1.245:9000" + # If you use a battery camera: **Instead** of an `address` supply the uid # as follows # uid = "ABCD01234567890EFG" diff --git a/src/cmdline.rs b/src/cmdline.rs index 693c5b4e8..eb69e35c2 100644 --- a/src/cmdline.rs +++ b/src/cmdline.rs @@ -17,6 +17,8 @@ pub struct Opt { #[derive(Parser, Debug)] pub enum Command { + Disk(super::disk::Opt), + Replay(super::replay::Opt), #[cfg(feature = "gstreamer")] Rtsp(super::rtsp::Opt), StatusLight(super::statusled::Opt), diff --git a/src/common/camthread.rs b/src/common/camthread.rs index 7bdbd70f0..6254ab9c6 100644 --- a/src/common/camthread.rs +++ b/src/common/camthread.rs @@ -71,7 +71,7 @@ impl NeoCamThread { missed_pings = 0; continue }, - Ok(Err(neolink_core::Error::UnintelligibleReply { reply, why })) => { + Ok(Err(neolink_core::Error::UnintelligibleReply { _reply: reply, why })) => { // Camera does not support pings just wait forever log::trace!("Pings not supported: {reply:?}: {why}"); futures::future::pending().await diff --git a/src/disk/cmdline.rs b/src/disk/cmdline.rs new file mode 100644 index 000000000..b68f06dd7 --- /dev/null +++ b/src/disk/cmdline.rs @@ -0,0 +1,26 @@ +use clap::Parser; + +/// Disk subcommand: list HDD/SD or format disk(s). Default is list. +#[derive(Parser, Debug)] +pub struct Opt { + /// Camera name from config + pub camera: String, + /// Subcommand (default: list) + #[command(subcommand)] + pub cmd: Option, +} + +#[derive(Parser, Debug)] +pub enum DiskCommand { + /// List HDD/SD disks and their status + List, + /// Format one or more disks (e.g. SD card slots) + Format { + /// Disk/slot number(s) to format (from `disk list`). Example: 0 or 0 1 + #[arg(short, long, value_delimiter = ' ', num_args = 1..)] + disk: Vec, + /// Perform full format instead of quick format + #[arg(long)] + full: bool, + }, +} diff --git a/src/disk/mod.rs b/src/disk/mod.rs new file mode 100644 index 000000000..f1f71819e --- /dev/null +++ b/src/disk/mod.rs @@ -0,0 +1,93 @@ +//! +//! # Neolink Disk +//! +//! List HDD/SD disks and format disk(s) (MSG 102, MSG 103). +//! +//! # Usage +//! +//! ```bash +//! neolink disk list --config=config.toml CameraName +//! neolink disk format --config=config.toml CameraName --disk 0 +//! neolink disk format --config=config.toml CameraName --disk 0 --full +//! ``` +//! + +use anyhow::{Context, Result}; + +mod cmdline; + +use crate::common::NeoReactor; +use neolink_core::bc::xml::HddInfoList; + +pub(crate) use cmdline::Opt; + +/// Entry point for the disk subcommand +pub(crate) async fn main(opt: Opt, reactor: NeoReactor) -> Result<()> { + let camera = reactor.get(&opt.camera).await?; + let cmd = opt.cmd.unwrap_or(cmdline::DiskCommand::List); + + match cmd { + cmdline::DiskCommand::List => { + let list = camera + .run_task(|cam| { + Box::pin( + async move { cam.get_hdd_list().await.context("Could not get disk list from camera") }, + ) + }) + .await?; + + print_hdd_list(&list); + } + cmdline::DiskCommand::Format { disk, full } => { + if disk.is_empty() { + anyhow::bail!("At least one disk must be specified (e.g. --disk 0)"); + } + camera + .run_task(|cam| { + let disks = disk.clone(); + Box::pin( + async move { + cam.format_disk(&disks, full) + .await + .context("Could not send format command to camera") + }, + ) + }) + .await?; + println!("Format command sent successfully."); + } + } + + Ok(()) +} + +fn print_hdd_list(list: &HddInfoList) { + if list.hdd_info.is_empty() { + println!("No disks found."); + return; + } + println!("Disks:"); + for h in &list.hdd_info { + let cap = h + .capacity + .map(|c| format!("{} GB", c)) + .unwrap_or_else(|| "—".to_string()); + let remain = h + .remain_size + .map(|r| format!("{} GB free", r)) + .or(h.remain_size_m.map(|r| format!("{} MB free", r))) + .unwrap_or_else(|| "—".to_string()); + let mount = h + .mount + .map(|m| if m != 0 { "mounted" } else { "not mounted" }) + .unwrap_or("—"); + let fmt = h + .format + .map(|f| if f != 0 { "formatted" } else { "not formatted" }) + .unwrap_or("—"); + println!( + " Slot {}: capacity {}, {}, {}, {}", + h.number, cap, remain, mount, fmt + ); + } +} diff --git a/src/main.rs b/src/main.rs index e143d05ee..36a2bae23 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,6 +40,8 @@ mod battery; mod cmdline; mod common; mod config; +mod disk; +mod replay; #[cfg(feature = "gstreamer")] mod image; mod mqtt; @@ -141,6 +143,12 @@ async fn main() -> Result<()> { Some(Command::Battery(opts)) => { battery::main(opts, neo_reactor.clone()).await?; } + Some(Command::Disk(opts)) => { + disk::main(opts, neo_reactor.clone()).await?; + } + Some(Command::Replay(opts)) => { + replay::main(opts, neo_reactor.clone()).await?; + } Some(Command::Services(opts)) => { services::main(opts, neo_reactor.clone()).await?; } diff --git a/src/mqtt/mqttc.rs b/src/mqtt/mqttc.rs index f82ec709b..64c66f1cf 100644 --- a/src/mqtt/mqttc.rs +++ b/src/mqtt/mqttc.rs @@ -483,7 +483,7 @@ pub(crate) struct MqttReply { } impl MqttReply { - pub(crate) fn as_ref(&self) -> MqttReplyRef { + pub(crate) fn as_ref(&self) -> MqttReplyRef<'_> { MqttReplyRef { topic: &self.topic, message: &self.message, diff --git a/src/replay/cmdline.rs b/src/replay/cmdline.rs new file mode 100644 index 000000000..3e95714e4 --- /dev/null +++ b/src/replay/cmdline.rs @@ -0,0 +1,155 @@ +use clap::Parser; + +/// Standard record types for file search (MSG 14). AI-specific tags (people, vehicle, etc.) +/// are in the response recordType field, not valid search parameters. Use --ai-filter to +/// filter by AI detection tags client-side. +pub const FILE_SEARCH_RECORD_TYPES: &str = "manual,sched,md,pir,io"; + +/// All known record/alarm types including AI detections. Used for alarm search (MSG 175). +pub const ALL_RECORD_TYPES: &str = + "manual,sched,md,pir,io,people,vehicle,face,dog_cat,package,visitor,cry,crossline,intrusion,loitering,nonmotorveh,other,legacy,loss"; + +/// Replay: list recording days, list files, start/stop playback (SD card). +#[derive(Parser, Debug)] +pub struct Opt { + /// Camera name from config + pub camera: String, + #[command(subcommand)] + pub cmd: ReplayCommand, +} + +#[derive(Parser, Debug)] +pub enum ReplayCommand { + /// List days that have recordings in a date range + Days { + /// Start date (YYYY-MM-DD) + #[arg(long)] + start: String, + /// End date (YYYY-MM-DD). Defaults to start if omitted + #[arg(long)] + end: Option, + }, + /// List recording files for a single day + Files { + /// Date (YYYY-MM-DD) + #[arg(long)] + date: String, + /// Stream type: mainStream or subStream. If omitted, lists files for both streams. + #[arg(long)] + stream: Option, + /// Record types to search for (comma-separated). Use --ai-filter to filter by AI tags. + #[arg(long, default_value = FILE_SEARCH_RECORD_TYPES)] + record_type: String, + /// Filter results by AI detection type (comma-separated, e.g. "people,vehicle,dog_cat"). + /// Only files whose recordType contains at least one of these tags are shown. + #[arg(long)] + ai_filter: Option, + }, + /// Start replay playback (stream BCMedia for a recording file) + Play { + /// File name from "replay files" output + #[arg(long)] + name: String, + /// Stream type: mainStream or subStream + #[arg(long, default_value = "subStream")] + stream: String, + /// Play speed (1 = normal) + #[arg(long, default_value = "1")] + speed: u32, + /// Output path for replay (e.g. .mp4). If omitted, stream is discarded (logs only; use for testing). + #[arg(long)] + output: Option, + /// Stop after N seconds and close the file (camera keeps streaming otherwise). Use e.g. 10 for a 10s clip. + #[arg(long)] + duration: Option, + /// Debug: write raw replay stream (after 32-byte header) to this file. Use xxd or hex editor to inspect for ftyp/moov/mdat. + #[arg(long)] + dump_replay: Option, + /// Max bytes to write to --dump-replay (default 131072). Use 0 for full stream. + #[arg(long)] + dump_replay_limit: Option, + }, + /// Download a recording file (same as play; stops when camera sends response 300 or after --duration) + Download { + /// File name from "replay files" output + #[arg(long)] + name: String, + /// Stream type: mainStream or subStream + #[arg(long, default_value = "subStream")] + stream: String, + /// Output path for the downloaded file (e.g. .mp4) + #[arg(long)] + output: Option, + /// Stop after N seconds if camera does not send response 300 + #[arg(long)] + duration: Option, + /// Debug: write raw replay stream to this file (default limit 128KB; use --dump-replay-limit 0 for full). + #[arg(long)] + dump_replay: Option, + #[arg(long)] + dump_replay_limit: Option, + }, + /// Download a specific SD card file directly by name (MSG 8, NET_DOWNLOAD_V20). + /// Requests the raw MP4 file from the camera without BcMedia re-encoding. + /// Uses a binary BC_DOWNLOAD_BY_NAME_INFO payload; camera streams the file back. + /// Faster than 'download' for cameras that support MSG 8. + DownloadByName { + /// File name from "replay files" output (e.g. "01_20240204120000") + #[arg(long)] + name: String, + /// Output path for the downloaded file (e.g. out.mp4) + #[arg(long)] + output: std::path::PathBuf, + /// Debug: write raw stream to this file for inspection + #[arg(long)] + dump_replay: Option, + /// Max bytes to write to --dump-replay (default 131072). Use 0 for full stream. + #[arg(long)] + dump_replay_limit: Option, + }, + /// Download by time range (MSG 143; camera sends response 331 at end). Use when you want a date range instead of a file name. + DownloadByTime { + /// Start date (YYYY-MM-DD); time 00:00:00 + #[arg(long)] + start: String, + /// End date (YYYY-MM-DD); time 23:59:59. Defaults to start if omitted + #[arg(long)] + end: Option, + /// Stream type: mainStream or subStream + #[arg(long, default_value = "subStream")] + stream: String, + /// Output path for the downloaded file (e.g. .mp4) + #[arg(long)] + output: Option, + /// Stop after N seconds if camera does not send response 331 + #[arg(long)] + duration: Option, + /// Debug: write raw replay stream to this file (default limit 128KB; use --dump-replay-limit 0 for full). + #[arg(long)] + dump_replay: Option, + #[arg(long)] + dump_replay_limit: Option, + }, + /// Stop replay playback (pass file name from "replay files") + Stop { + /// File name to stop (e.g. from "replay files" output) + #[arg(long)] + name: String, + }, + /// Search recordings by alarm/AI type (MSG 175). Server-side filtering by detection type. + AlarmSearch { + /// Start date (YYYY-MM-DD) + #[arg(long)] + start: String, + /// End date (YYYY-MM-DD). Defaults to start if omitted + #[arg(long)] + end: Option, + /// Stream type: 0 = mainStream, 1 = subStream + #[arg(long, default_value = "1")] + stream_type: u8, + /// Alarm/AI types to search for (comma-separated, e.g. "md,people,vehicle,dog_cat"). + /// Defaults to all known types. + #[arg(long, default_value = ALL_RECORD_TYPES)] + alarm_types: String, + }, +} diff --git a/src/replay/gst.rs b/src/replay/gst.rs new file mode 100644 index 000000000..04bcb1403 --- /dev/null +++ b/src/replay/gst.rs @@ -0,0 +1,295 @@ +//! GStreamer pipeline to mux H.264 + optional AAC audio to MP4. +//! Supports per-frame timestamps (VFR) via appsrc PTS. +//! Requires the `gstreamer` feature. + +use anyhow::{Context, Result}; +use gstreamer::prelude::*; +use gstreamer::{Caps, ClockTime, MessageView, Pipeline, State}; +use gstreamer_app::AppSrc; +use std::path::Path; + +/// Annex B start code. +const START_CODE: &[u8] = &[0x00, 0x00, 0x00, 0x01]; + +/// Optional metadata to embed in the MP4 container. +#[derive(Debug, Default, Clone)] +pub struct Mp4Metadata { + /// Camera recording type / AI detection tags (e.g. "manual,sched,md,people,vehicle,dog_cat"). + /// Stored as MP4 comment + keywords. + pub record_type: Option, + /// Recording start time as "YYYY-MM-DD HH:MM:SS". + pub start_time: Option, + /// Recording end time as "YYYY-MM-DD HH:MM:SS". + pub end_time: Option, + /// Camera name / channel. + pub camera_name: Option, +} + +/// Mux H.264 NALs with per-frame timestamps (+ optional AAC audio) to MP4 using GStreamer. +/// +/// Pipeline: +/// video: appsrc(PTS per buffer) ! h264parse ! mp4mux ! filesink +/// audio: appsrc(PTS per buffer) ! aacparse ! mp4mux +/// +/// Each NAL in `nals` is pushed as a separate buffer with PTS derived from `timestamps_us`. +/// `timestamps_us[i]` corresponds to `nals[i]` (microseconds, from BcMedia Iframe/Pframe header). +/// If `aac_data` is non-empty, ADTS frames are split and pushed with cumulative PTS. +/// If `metadata` is provided, AI detection tags and recording times are embedded as MP4 tags. +pub fn mux_nals_to_mp4( + nals: &[Vec], + timestamps_us: &[u32], + aac_data: &[u8], + output_path: &Path, + metadata: &Mp4Metadata, + is_h265: bool, +) -> Result<()> { + gstreamer::init().context("GStreamer init")?; + + let pipeline = Pipeline::new(); + + // --- Video path --- + let video_src = gstreamer::ElementFactory::make("appsrc") + .build() + .context("create video appsrc")? + .downcast::() + .map_err(|_| anyhow::anyhow!("appsrc downcast"))?; + let video_parse_name = if is_h265 { "h265parse" } else { "h264parse" }; + let h264parse = gstreamer::ElementFactory::make(video_parse_name) + .build() + .context("create video parse element")?; + let mp4mux = gstreamer::ElementFactory::make("mp4mux") + .build() + .context("create mp4mux")?; + mp4mux.set_property("faststart", true); + let filesink = gstreamer::ElementFactory::make("filesink") + .build() + .context("create filesink")?; + filesink.set_property("location", output_path); + + let video_src_el = video_src.upcast_ref(); + pipeline.add_many([video_src_el, &h264parse, &mp4mux, &filesink])?; + video_src_el.link(&h264parse)?; + h264parse.link(&mp4mux)?; + mp4mux.link(&filesink)?; + + let video_media_type = if is_h265 { "video/x-h265" } else { "video/x-h264" }; + let caps = Caps::builder(video_media_type) + .field("stream-format", "byte-stream") + .build(); + video_src.set_caps(Some(&caps)); + video_src.set_stream_type(gstreamer_app::AppStreamType::Stream); + video_src.set_format(gstreamer::Format::Time); + + // --- Audio path (optional) --- + let audio_src = if !aac_data.is_empty() { + let src = gstreamer::ElementFactory::make("appsrc") + .build() + .context("create audio appsrc")? + .downcast::() + .map_err(|_| anyhow::anyhow!("audio appsrc downcast"))?; + let aacparse = gstreamer::ElementFactory::make("aacparse") + .build() + .context("create aacparse")?; + + let src_el = src.upcast_ref(); + pipeline.add_many([src_el, &aacparse])?; + src_el.link(&aacparse)?; + aacparse.link(&mp4mux)?; + + let caps = Caps::builder("audio/mpeg") + .field("mpegversion", 4i32) + .field("stream-format", "adts") + .build(); + src.set_caps(Some(&caps)); + src.set_stream_type(gstreamer_app::AppStreamType::Stream); + src.set_format(gstreamer::Format::Time); + Some(src) + } else { + None + }; + + // Embed metadata as MP4 tags via mp4mux's TagSetter interface + if metadata.record_type.is_some() + || metadata.start_time.is_some() + || metadata.end_time.is_some() + || metadata.camera_name.is_some() + { + let tag_setter = mp4mux + .dynamic_cast_ref::() + .expect("mp4mux should implement TagSetter"); + // AI detection labels as keywords + if let Some(ref rt) = metadata.record_type { + let ai_tags: Vec<&str> = rt + .split(',') + .map(|s| s.trim()) + .filter(|s| { + matches!( + *s, + "people" | "vehicle" | "face" | "dog_cat" | "package" + | "visitor" | "cry" | "crossline" | "intrusion" + | "loitering" | "nonmotorveh" | "md" | "pir" + | "io" | "other" | "legacy" | "loss" + ) + }) + .collect(); + if !ai_tags.is_empty() { + let kw = ai_tags.join(", "); + tag_setter.add_tag::( + &kw.as_str(), + gstreamer::TagMergeMode::Replace, + ); + } + let comment = format!("recordType: {}", rt); + tag_setter.add_tag::( + &comment.as_str(), + gstreamer::TagMergeMode::Replace, + ); + } + // Build description from timing + camera info + let mut desc_parts = Vec::new(); + if let Some(ref name) = metadata.camera_name { + desc_parts.push(format!("Camera: {}", name)); + tag_setter.add_tag::( + &name.as_str(), + gstreamer::TagMergeMode::Replace, + ); + } + if let Some(ref st) = metadata.start_time { + desc_parts.push(format!("Start: {}", st)); + } + if let Some(ref et) = metadata.end_time { + desc_parts.push(format!("End: {}", et)); + } + if !desc_parts.is_empty() { + let desc = desc_parts.join("; "); + tag_setter.add_tag::( + &desc.as_str(), + gstreamer::TagMergeMode::Replace, + ); + } + tag_setter.add_tag::( + &"neolink replay", + gstreamer::TagMergeMode::Replace, + ); + log::info!("Replay GStreamer: set metadata tags on mp4mux TagSetter"); + } + + pipeline.set_state(State::Playing)?; + + // Push video NALs with per-frame PTS + let base_ts = timestamps_us.first().copied().unwrap_or(0); + // Compute average frame duration for use on the last frame + let avg_frame_dur_us: u32 = if timestamps_us.len() > 1 { + (timestamps_us.last().unwrap().wrapping_sub(*timestamps_us.first().unwrap())) + / (timestamps_us.len() as u32 - 1) + } else { + 33333 // ~30fps fallback + }; + for (i, nal) in nals.iter().enumerate() { + let ts_us = timestamps_us.get(i).copied().unwrap_or(0); + let pts_us = ts_us.wrapping_sub(base_ts) as u64; + let pts_ns = pts_us * 1000; + let next_ts_us = timestamps_us.get(i + 1).copied().unwrap_or_else(|| ts_us.wrapping_add(avg_frame_dur_us)); + let dur_ns = (next_ts_us.wrapping_sub(ts_us) as u64) * 1000; + + // Ensure Annex B start code + let has_start = (nal.len() >= 4 && nal[0..4] == [0, 0, 0, 1]) + || (nal.len() >= 3 && nal[0..3] == [0, 0, 1]); + let data_len = if has_start { nal.len() } else { START_CODE.len() + nal.len() }; + + let mut buf = gstreamer::Buffer::with_size(data_len).context("video buffer alloc")?; + { + let buf_ref = buf.get_mut().unwrap(); + buf_ref.set_pts(ClockTime::from_nseconds(pts_ns)); + buf_ref.set_duration(ClockTime::from_nseconds(dur_ns)); + let mut map = buf_ref.map_writable().context("video buffer map")?; + if has_start { + map.copy_from_slice(nal); + } else { + map[..START_CODE.len()].copy_from_slice(START_CODE); + map[START_CODE.len()..].copy_from_slice(nal); + } + } + video_src + .push_buffer(buf) + .map_err(|e| anyhow::anyhow!("video push frame {}: {:?}", i, e))?; + } + video_src + .end_of_stream() + .map_err(|e| anyhow::anyhow!("video eos: {:?}", e))?; + + // Push AAC ADTS frames with cumulative PTS + if let Some(ref audio) = audio_src { + let mut pos = 0; + let mut audio_pts_ns: u64 = 0; + while pos + 7 <= aac_data.len() { + // ADTS syncword 0xFFF + if aac_data[pos] != 0xFF || (aac_data[pos + 1] & 0xF0) != 0xF0 { + pos += 1; + continue; + } + let frame_len = (((aac_data[pos + 3] & 0x03) as usize) << 11) + | ((aac_data[pos + 4] as usize) << 3) + | ((aac_data[pos + 5] as usize) >> 5); + if frame_len < 7 || pos + frame_len > aac_data.len() { + break; + } + + // Duration from ADTS: sample_freq, num_frames (each 1024 samples) + let freq_idx = (aac_data[pos + 2] & 0x3C) >> 2; + let sample_freq: u64 = match freq_idx { + 0 => 96000, 1 => 88200, 2 => 64000, 3 => 48000, 4 => 44100, + 5 => 32000, 6 => 24000, 7 => 22050, 8 => 16000, 9 => 12000, + 10 => 11025, 11 => 8000, 12 => 7350, _ => 16000, + }; + let num_frames = ((aac_data[pos + 6] & 0x03) + 1) as u64; + let samples = num_frames * 1024; + let duration_ns = samples * 1_000_000_000 / sample_freq; + + let mut buf = + gstreamer::Buffer::with_size(frame_len).context("audio buffer alloc")?; + { + let buf_ref = buf.get_mut().unwrap(); + buf_ref.set_pts(ClockTime::from_nseconds(audio_pts_ns)); + buf_ref.set_duration(ClockTime::from_nseconds(duration_ns)); + let mut map = buf_ref.map_writable().context("audio buffer map")?; + map.copy_from_slice(&aac_data[pos..pos + frame_len]); + } + audio + .push_buffer(buf) + .map_err(|e| anyhow::anyhow!("audio push: {:?}", e))?; + + audio_pts_ns += duration_ns; + pos += frame_len; + } + audio + .end_of_stream() + .map_err(|e| anyhow::anyhow!("audio eos: {:?}", e))?; + log::info!( + "Replay GStreamer: pushed {} ms of AAC audio", + audio_pts_ns / 1_000_000 + ); + } + + // Wait for EOS + let bus = pipeline.bus().context("pipeline bus")?; + for msg in bus.iter_timed(ClockTime::NONE) { + match msg.view() { + MessageView::Eos(..) => break, + MessageView::Error(err) => { + let _ = pipeline.set_state(State::Null); + anyhow::bail!("GStreamer error: {:?}", err); + } + _ => {} + } + } + + pipeline.set_state(State::Null)?; + log::info!( + "Replay GStreamer: muxed {} {} frames + audio to {}", + nals.len(), + if is_h265 { "H.265" } else { "H.264" }, + output_path.display() + ); + Ok(()) +} diff --git a/src/replay/mod.rs b/src/replay/mod.rs new file mode 100644 index 000000000..267a5afb9 --- /dev/null +++ b/src/replay/mod.rs @@ -0,0 +1,2057 @@ +//! +//! # Neolink Replay +//! +//! List recording days and files from SD card, start/stop playback (MSG 142, 14, 15, 5, 7). +//! +//! # Usage +//! +//! ```bash +//! neolink replay days --config=config.toml CameraName --start 2024-02-01 [--end 2024-02-07] +//! neolink replay files --config=config.toml CameraName --date 2024-02-04 [--stream subStream] +//! neolink replay play --config=config.toml CameraName --name "01_20240204120000" [--output out.h264] +//! neolink replay stop --config=config.toml CameraName --name "01_20240204120000" +//! ``` +//! + +use anyhow::{Context, Result}; +use std::convert::TryInto; +use std::path::Path; + +mod cmdline; + +#[cfg(feature = "gstreamer")] +mod gst; + +/// Annex B start code (4-byte) for H.264/HEVC NAL. +const ANNEX_B_START: &[u8] = &[0x00, 0x00, 0x00, 0x01]; + +/// Scan for the first BcMedia 4-byte LE magic (InfoV1/2, IFRAME, PFRAME, AAC). Returns offset or None. +fn find_bcmedia_magic_offset(stream: &[u8]) -> Option { + const MAGIC_INFO_V1: u32 = 0x31303031; + const MAGIC_INFO_V2: u32 = 0x32303031; + const MAGIC_IFRAME: u32 = 0x63643030; + const MAGIC_IFRAME_LAST: u32 = 0x63643039; + const MAGIC_PFRAME: u32 = 0x63643130; + const MAGIC_PFRAME_LAST: u32 = 0x63643139; + const MAGIC_AAC: u32 = 0x62773530; + for i in 0..stream.len().saturating_sub(4) { + let magic = u32::from_le_bytes(stream[i..i + 4].try_into().unwrap()); + if magic == MAGIC_INFO_V1 + || magic == MAGIC_INFO_V2 + || (MAGIC_IFRAME..=MAGIC_IFRAME_LAST).contains(&magic) + || (MAGIC_PFRAME..=MAGIC_PFRAME_LAST).contains(&magic) + || magic == MAGIC_AAC + { + return Some(i); + } + } + None +} + +/// True if payload looks like a valid H.264 NAL (NAL type 1–9). Drops garbage from resync false positives. +fn is_likely_valid_h264_nal(payload: &[u8]) -> bool { + if payload.is_empty() { + return false; + } + let first = if payload.len() >= 5 && payload[0..4] == [0x00, 0x00, 0x00, 0x01] { + payload[4] + } else if payload.len() >= 4 && payload[0..3] == [0x00, 0x00, 0x01] { + payload[3] + } else { + payload[0] + }; + if first >> 7 != 0 { return false; } // forbidden_zero_bit must be 0 + (1..=9).contains(&(first & 0x1F)) +} + +/// True if payload looks like a valid H.265 NAL. H.265 NAL header is 2 bytes; +/// nal_unit_type = (byte0 >> 1) & 0x3F; valid types are 0–47. +fn is_likely_valid_h265_nal(payload: &[u8]) -> bool { + let first = if payload.len() >= 5 && payload[0..4] == [0x00, 0x00, 0x00, 0x01] { + if payload.len() < 6 { return false; } + payload[4] + } else if payload.len() >= 4 && payload[0..3] == [0x00, 0x00, 0x01] { + if payload.len() < 5 { return false; } + payload[3] + } else { + if payload.is_empty() { return false; } + payload[0] + }; + if first >> 7 != 0 { return false; } // forbidden_zero_bit must be 0 + let nal_type = (first >> 1) & 0x3F; + nal_type <= 47 +} + +/// Inspect actual NAL bytes to determine whether the stream is H.264 or H.265, +/// regardless of what the BcMedia header's video_type field claims. +/// +/// Some cameras (e.g. Argus PT) send H.265 data but label it "H264" in the BcMedia header. +/// Detection is based on NAL unit type after start codes: +/// - H.265 VPS(32)/SPS(33)/PPS(34): first byte = 0x40/0x42/0x44 — H.265-specific, cannot appear in H.264 +/// - H.264 SPS(7)/PPS(8)/IDR(5): first byte = 0x67/0x68/0x65 +/// +/// Returns Some(codec) if a distinctive NAL is found, None if indeterminate. +fn detect_codec_from_nal_bytes(data: &[u8]) -> Option { + use neolink_core::bcmedia::model::VideoType; + let mut i = 0; + while i < data.len() { + let nal_start = if data[i..].starts_with(&[0, 0, 0, 1]) { + i + 4 + } else if data[i..].starts_with(&[0, 0, 1]) { + i + 3 + } else { + i += 1; + continue; + }; + if nal_start >= data.len() { + break; + } + let byte0 = data[nal_start]; + if byte0 >> 7 != 0 { + i = nal_start + 1; + continue; + } + let h265_type = (byte0 >> 1) & 0x3F; + if matches!(h265_type, 32..=34) { + // VPS/SPS/PPS — only possible in H.265 + return Some(VideoType::H265); + } + let h264_type = byte0 & 0x1F; + if matches!(h264_type, 5 | 7 | 8) { + // IDR/SPS/PPS — confirms H.264 + return Some(VideoType::H264); + } + i = nal_start + 1; + } + None +} + +/// Max bytes to advance on Incomplete without finding any NAL. Prevents hanging on garbage/ciphertext (e.g. undecrypted replay). +const BCMEDIA_RESYNC_CAP: usize = 128 * 1024; + +/// Decode a buffer (e.g. after stream_for_mp4_assembly) as BcMedia and collect video NAL payloads. +/// Returns Some((nals, fps)) if at least one IFRAME/PFRAME was decoded; None if stream is not BcMedia or has no video. +/// fps is extracted from the BcMediaInfoV1/V2 header if present, otherwise defaults to 25. +/// When the codec returns Incomplete (Ok(None)) we resync to the next frame and continue, so a full buffer is decoded. +/// Only payloads that look like valid H.264 NALs are collected to avoid garbage from resync. +/// See notes/REPLAY_VIDEO_FORMAT.md. +/// Result of decoding a BcMedia stream. +struct BcMediaDecoded { + nals: Vec>, + fps: u8, + /// Per-frame microsecond timestamps from BcMedia headers (same length as `nals`). + timestamps_us: Vec, + /// Concatenated AAC ADTS frames (empty if no audio in stream). + aac_data: Vec, + /// H.264 or H.265 — detected from the first IFrame/PFrame in the stream. + video_codec: neolink_core::bcmedia::model::VideoType, +} + +fn try_decode_bcmedia_nals(stream: &[u8]) -> Option { + use neolink_core::bcmedia::model::VideoType; + let mut codec = BcMediaCodex::new(false); + let mut buf = BytesMut::from(stream); + let mut nals: Vec> = Vec::new(); + let mut timestamps_us: Vec = Vec::new(); + let mut fps: u8 = 25; // default; overridden by InfoV1/V2 if present + let mut aac_data: Vec = Vec::new(); + let mut detected_codec: Option = None; + let start_len = buf.len(); + let mut bytes_advanced_without_nal: usize = 0; + loop { + match codec.decode(&mut buf) { + Ok(Some(BcMedia::Iframe(BcMediaIframe { data, microseconds, video_type, .. }))) => { + if detected_codec.is_none() { + // Override header claim with NAL-level detection — some cameras (e.g. Argus PT) + // label H.265 streams as "H264" in BcMedia headers. + let actual = detect_codec_from_nal_bytes(&data).unwrap_or(video_type); + if actual != video_type { + log::warn!( + "Replay: BcMedia header says {:?} but NAL bytes indicate {:?} — using detected codec", + video_type, actual + ); + } + detected_codec = Some(actual); + log::info!("Replay: BcMedia stream codec = {:?} (from first IFrame)", actual); + } + let effective_codec = detected_codec.unwrap_or(video_type); + let valid = match effective_codec { + VideoType::H265 => is_likely_valid_h265_nal(&data), + VideoType::H264 => is_likely_valid_h264_nal(&data), + }; + if valid { + nals.push(data); + timestamps_us.push(microseconds); + bytes_advanced_without_nal = 0; + } + } + Ok(Some(BcMedia::Pframe(BcMediaPframe { data, microseconds, video_type, .. }))) => { + let effective_codec = detected_codec.unwrap_or(video_type); + let valid = match effective_codec { + VideoType::H265 => is_likely_valid_h265_nal(&data), + VideoType::H264 => is_likely_valid_h264_nal(&data), + }; + if valid { + nals.push(data); + timestamps_us.push(microseconds); + bytes_advanced_without_nal = 0; + } + } + Ok(Some(BcMedia::InfoV1(BcMediaInfoV1 { fps: f, .. }))) + | Ok(Some(BcMedia::InfoV2(BcMediaInfoV2 { fps: f, .. }))) => { + if f > 0 { + log::info!("Replay: BcMedia stream fps={} (from InfoV1/V2 header)", f); + fps = f; + } + } + Ok(Some(BcMedia::Aac(BcMediaAac { data, .. }))) => { + aac_data.extend_from_slice(&data); + } + Ok(Some(_)) => {} + Err(_) => break, + Ok(None) => { + if buf.remaining() < 4 { + break; + } + buf.advance(1); + bytes_advanced_without_nal += 1; + if nals.is_empty() && bytes_advanced_without_nal >= BCMEDIA_RESYNC_CAP { + log::debug!( + "Replay: BcMedia decode advanced {} bytes without finding a frame, stopping (likely garbage/undecrypted)", + start_len - buf.len() + ); + break; + } + } + } + } + if !aac_data.is_empty() { + log::info!("Replay: collected {} bytes of AAC audio from BcMedia stream", aac_data.len()); + } + if nals.is_empty() { None } else { + let video_codec = detected_codec.unwrap_or(neolink_core::bcmedia::model::VideoType::H264); + Some(BcMediaDecoded { nals, fps, timestamps_us, aac_data, video_codec }) + } +} + +/// Compute actual average fps from per-frame microsecond timestamps. Returns None if < 2 frames. +fn compute_actual_fps(timestamps_us: &[u32]) -> Option { + if timestamps_us.len() < 2 { + return None; + } + // Handle u32 wraparound: timestamps are relative to stream start and may wrap at ~4295s (~71 min). + // Use deltas between consecutive frames to avoid wraparound issues. + let mut total_delta_us: u64 = 0; + let mut valid_deltas: u64 = 0; + for pair in timestamps_us.windows(2) { + let delta = pair[1].wrapping_sub(pair[0]); + // Reject implausible deltas (> 2 seconds or 0) as likely wraparound artifacts or duplicates. + if delta > 0 && delta < 2_000_000 { + total_delta_us += delta as u64; + valid_deltas += 1; + } + } + if valid_deltas == 0 || total_delta_us == 0 { + return None; + } + let avg_frame_duration_us = total_delta_us as f64 / valid_deltas as f64; + let fps = 1_000_000.0 / avg_frame_duration_us; + // Sanity: fps should be in 1..120 range + if fps >= 1.0 && fps <= 120.0 { + Some(fps) + } else { + None + } +} + +/// Recording metadata for MP4 embedding. +#[derive(Debug, Default, Clone)] +struct RecordingMeta { + /// Camera recording type / AI detection tags (e.g. "manual,sched,md,people,vehicle"). + pub record_type: Option, + /// Recording start time as "YYYY-MM-DD HH:MM:SS". + pub start_time: Option, + /// Recording end time as "YYYY-MM-DD HH:MM:SS". + pub end_time: Option, + /// Camera name / channel. + pub camera_name: Option, +} + +/// Mux decoded BcMedia NALs + optional AAC audio to MP4. +/// Uses GStreamer (with per-frame PTS from timestamps) when available; falls back to ffmpeg. +/// If `meta` is provided, AI detection tags and timing info are embedded in the MP4. +/// Returns Ok(true) on success. +async fn mux_to_mp4( + nals: &[Vec], + aac_data: &[u8], + fps: u8, + timestamps_us: &[u32], + output: &Path, + meta: &RecordingMeta, + video_codec: neolink_core::bcmedia::model::VideoType, +) -> Result { + use neolink_core::bcmedia::model::VideoType; + if let Some(afps) = compute_actual_fps(timestamps_us) { + log::info!( + "Replay: actual avg fps from timestamps = {:.2} (declared fps = {})", + afps, fps + ); + } + + // Primary: GStreamer with per-frame PTS (handles VFR + audio natively, no external binaries) + #[cfg(feature = "gstreamer")] + { + let nals = nals.to_vec(); + let timestamps_us = timestamps_us.to_vec(); + let aac_data = aac_data.to_vec(); + let path = output.to_path_buf(); + let gst_meta = gst::Mp4Metadata { + record_type: meta.record_type.clone(), + start_time: meta.start_time.clone(), + end_time: meta.end_time.clone(), + camera_name: meta.camera_name.clone(), + }; + let is_h265 = matches!(video_codec, VideoType::H265); + match tokio::task::spawn_blocking(move || { + gst::mux_nals_to_mp4(&nals, ×tamps_us, &aac_data, &path, &gst_meta, is_h265) + }) + .await + { + Ok(Ok(())) => return Ok(true), + Ok(Err(e)) => log::warn!("Replay: GStreamer mux failed: {:?}, trying ffmpeg", e), + Err(e) => log::warn!("Replay: GStreamer task panicked: {:?}, trying ffmpeg", e), + } + } + + // Fallback: ffmpeg with computed average fps + let effective_fps = compute_actual_fps(timestamps_us).unwrap_or(fps as f64); + let fps_str = format!("{:.3}", effective_fps); + log::info!("Replay: muxing with ffmpeg at {} fps (fallback)", fps_str); + + let is_h265 = matches!(video_codec, VideoType::H265); + let raw_ext = if is_h265 { "replay.h265" } else { "replay.h264" }; + let raw_fmt = if is_h265 { "hevc" } else { "h264" }; + let raw_path = output.with_extension(raw_ext); + let annex_b = annex_b_from_nals(nals); + tokio::fs::write(&raw_path, &annex_b).await.context("Write temp video")?; + + let aac_path = output.with_extension("replay.aac"); + if !aac_data.is_empty() { + tokio::fs::write(&aac_path, aac_data).await.context("Write temp AAC")?; + } + + let mut cmd = tokio::process::Command::new("ffmpeg"); + cmd.args(["-y", "-hide_banner", "-loglevel", "error", + "-fflags", "+genpts", "-r", &fps_str, "-f", raw_fmt, "-i"]); + cmd.arg(&raw_path); + if !aac_data.is_empty() { + cmd.args(["-f", "aac", "-i"]); + cmd.arg(&aac_path); + } + cmd.args(["-c", "copy", "-fps_mode", "cfr", "-r", &fps_str, "-movflags", "+faststart"]); + // Embed metadata + if let Some(ref rt) = meta.record_type { + cmd.args(["-metadata", &format!("comment=recordType: {}", rt)]); + } + if let Some(ref desc) = meta.start_time { + cmd.args(["-metadata", &format!("description=Start: {}", desc)]); + } + cmd.arg(output); + + let status = cmd.status().await.context("Run ffmpeg for BcMedia mux")?; + let _ = tokio::fs::remove_file(&raw_path).await; + if !aac_data.is_empty() { + let _ = tokio::fs::remove_file(&aac_path).await; + } + Ok(status.success()) +} + +/// Format a ReplayDateTime as "YYYY-MM-DD HH:MM:SS". +fn format_replay_datetime(dt: &ReplayDateTime) -> String { + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", + dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second + ) +} + +/// Write NAL payloads as H.264 Annex B (start code + NAL). Payloads that already start with 0x00 0x00 0x01 or 0x00 0x00 0x00 0x01 are written as-is. +fn annex_b_from_nals(nals: &[Vec]) -> Vec { + let mut out = Vec::new(); + for nal in nals { + let has_start = (nal.len() >= 3 && nal[0..3] == [0x00, 0x00, 0x01]) + || (nal.len() >= 4 && nal[0..4] == [0x00, 0x00, 0x00, 0x01]); + if !has_start { + out.extend_from_slice(ANNEX_B_START); + } + out.extend_from_slice(nal); + } + out +} + +/// H.264 Annex B start codes: 0x00 0x00 0x01 or 0x00 0x00 0x00 0x01. +/// Kept for potential use when scanning raw buffers for Annex B. +#[allow(dead_code)] +fn find_annexb_h264_start(buffer: &[u8]) -> Option { + let mut i = 0; + while i + 3 <= buffer.len() { + if buffer[i..i + 3] == [0x00, 0x00, 0x01] { + return Some(i); + } + if i + 4 <= buffer.len() && buffer[i..i + 4] == [0x00, 0x00, 0x00, 0x01] { + return Some(i); + } + i += 1; + } + None +} + +use crate::common::NeoReactor; +use bytes::{Buf, BytesMut}; +use neolink_core::bc::xml::{DayRecords, FileInfo, ReplayDateTime}; +use neolink_core::bcmedia::codex::BcMediaCodex; +use neolink_core::bcmedia::model::{BcMedia, BcMediaAac, BcMediaIframe, BcMediaInfoV1, BcMediaInfoV2, BcMediaPframe}; +use tokio::io::AsyncWriteExt; +use tokio::time::{sleep_until, Duration, Instant}; +use tokio_util::codec::Decoder; + +pub(crate) use cmdline::Opt; + +/// E1 cameras sometimes write wrong values in the avcC box (e.g. config version 0, wrong lengthSizeMinusOne), +/// which breaks decoders. Patch the first bytes so the file can play when the rest (SPS/PPS) is present. +/// Provenance: empirical (no RE). App replay path does not handle raw MP4; download path builds MP4 from +/// DATA_FRAME_DESC, so we have no Ghidra trace of the app patching avcC. See notes § Replay: full end-to-end flow and provenance. +fn patch_e1_avcc_if_needed(path: &Path) -> Result<()> { + const AVCC_TAG: &[u8; 4] = b"avcC"; + // avcC content: [0]=configurationVersion(1), [1-3]=profile/compat/level, [4]=lengthSizeMinusOne|reserved, [5]=numSPS|reserved + const CONTENT_CONFIG_VERSION: usize = 0; + const CONTENT_LENGTH_SIZE_BYTE: usize = 4; + const CONFIG_VERSION_CORRECT: u8 = 0x01; + const LENGTH_SIZE_BYTE_CORRECT: u8 = 0x43; // lengthSizeMinusOne=3 (4-byte NAL length) + + let mut buf = [0u8; 4096]; + let mut f = std::fs::File::open(path).context("Open MP4 for avcC patch")?; + let n = std::io::Read::read(&mut f, &mut buf).context("Read MP4 head")?; + let head = &buf[..n]; + if let Some(pos) = head.windows(4).position(|w| w == AVCC_TAG) { + let content_start = pos + 4; // first byte of avcC content (after "avcC" tag) + let mut patches: Vec<(usize, u8, u8)> = Vec::new(); + if content_start + CONTENT_LENGTH_SIZE_BYTE < head.len() { + let b4 = head[content_start + CONTENT_LENGTH_SIZE_BYTE]; + if b4 != LENGTH_SIZE_BYTE_CORRECT { + patches.push((content_start + CONTENT_LENGTH_SIZE_BYTE, b4, LENGTH_SIZE_BYTE_CORRECT)); + } + } + if content_start + CONTENT_CONFIG_VERSION < head.len() { + let b0 = head[content_start + CONTENT_CONFIG_VERSION]; + if b0 != CONFIG_VERSION_CORRECT { + patches.push((content_start + CONTENT_CONFIG_VERSION, b0, CONFIG_VERSION_CORRECT)); + } + } + if !patches.is_empty() { + drop(f); + use std::io::{Seek, SeekFrom, Write}; + let mut f = std::fs::OpenOptions::new().write(true).open(path)?; + for (fix_pos, _was, correct) in &patches { + f.seek(SeekFrom::Start(*fix_pos as u64))?; + f.write_all(&[*correct])?; + } + f.sync_all().context("Sync patched MP4 to disk")?; + for (_, w, c) in &patches { + log::info!("Replay: patched E1 avcC (0x{:02x} -> 0x{:02x}) for playback", w, c); + } + } + } + Ok(()) +} + +/// Some cameras omit colour_primaries/transfer_characteristics/matrix_coefficients in the +/// avc1 sample entry, so ffmpeg reports "unspecified pixel format" and may not start decoding. +/// Write BT.709 (1,1,1) into the optional fields so players can infer yuv420p. +/// Only patch when avc1 box is >= 92 bytes so we don't overwrite the following avcC box. +/// Provenance: empirical (no RE). See notes § Replay: full end-to-end flow and provenance. +fn patch_avc1_colour_if_needed(path: &Path) -> Result<()> { + const AVC1_TAG: &[u8; 4] = b"avc1"; + // Optional colour at 80..86; require box >= 92 so we don't overwrite avcC. + const COLOUR_OFFSET_IN_AVC1_BOX: usize = 80; + const MIN_AVC1_BOX_LEN: usize = 92; + const COLOUR_BYTES: &[u8; 6] = &[0x00, 0x01, 0x00, 0x01, 0x00, 0x01]; // BT.709 + + let mut buf = [0u8; 65536]; + let mut f = std::fs::File::open(path).context("Open MP4 for avc1 colour patch")?; + let n = std::io::Read::read(&mut f, &mut buf).context("Read MP4 for avc1")?; + let head = &buf[..n]; + + let mut box_start = 0usize; + while let Some(tag_pos) = head[box_start..].windows(4).position(|w| w == AVC1_TAG) { + let i = box_start + tag_pos; + if i < 4 { + box_start = i + 1; + continue; + } + let start = i - 4; + let size_u32 = u32::from_be_bytes(head[start..start + 4].try_into().unwrap()); + let box_len = if size_u32 == 1 { + if start + 16 > head.len() { + box_start = i + 1; + continue; + } + u64::from_be_bytes(head[start + 8..start + 16].try_into().unwrap()) as usize + } else { + size_u32 as usize + }; + if box_len >= MIN_AVC1_BOX_LEN + && start + COLOUR_OFFSET_IN_AVC1_BOX + COLOUR_BYTES.len() <= head.len() + { + let colour_pos = start + COLOUR_OFFSET_IN_AVC1_BOX; + if head[colour_pos..colour_pos + COLOUR_BYTES.len()] != COLOUR_BYTES[..] { + drop(f); + let mut f = std::fs::OpenOptions::new().write(true).open(path)?; + std::io::Seek::seek(&mut f, std::io::SeekFrom::Start(colour_pos as u64))?; + std::io::Write::write_all(&mut f, COLOUR_BYTES)?; + f.sync_all().context("Sync avc1 colour patch")?; + log::info!("Replay: patched avc1 colour description (BT.709) for playback"); + return Ok(()); + } + } + box_start = i + 1; + } + Ok(()) +} + +/// Parse box size at buffer[start]; supports 32-bit and size=1 (64-bit). +fn parse_box_size(buffer: &[u8], start: usize) -> Result { + if buffer.len() < start + 8 { + anyhow::bail!("Buffer too short for box header at {}", start); + } + let size_u32 = u32::from_be_bytes(buffer[start..start + 4].try_into().unwrap()); + if size_u32 == 1 { + if buffer.len() < start + 16 { + anyhow::bail!("Buffer too short for 64-bit box size"); + } + let size64 = u64::from_be_bytes(buffer[start + 8..start + 16].try_into().unwrap()); + Ok(size64 as usize) + } else { + Ok(size_u32 as usize) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Minimal BcMedia IFRAME: magic 0x63643030, "H264", payload_size=5, additional_header_size=0, + /// then 5-byte NAL (start code + SPS type) + 3-byte pad to 8-byte boundary. + fn minimal_bcmedia_iframe() -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&0x63643030u32.to_le_bytes()); // MAGIC IFRAME + buf.extend_from_slice(b"H264"); + buf.extend_from_slice(&5u32.to_le_bytes()); // payload_size + buf.extend_from_slice(&0u32.to_le_bytes()); // additional_header_size + buf.extend_from_slice(&1000u32.to_le_bytes()); // microseconds = 1ms + buf.extend_from_slice(&0u32.to_le_bytes()); // unknown_b + buf.extend_from_slice(&[0x00, 0x00, 0x00, 0x01, 0x67]); // NAL: start code + SPS (type 7) + buf.extend_from_slice(&[0u8; 3]); // padding to 8-byte boundary + buf + } + + #[test] + fn test_try_decode_bcmedia_nals_decodes_iframe() { + let stream = minimal_bcmedia_iframe(); + let result = try_decode_bcmedia_nals(&stream); + assert!(result.is_some(), "BcMedia decode should return Some(BcMediaDecoded)"); + let decoded = result.unwrap(); + assert_eq!(decoded.nals.len(), 1); + assert_eq!(decoded.nals[0], &[0x00, 0x00, 0x00, 0x01, 0x67]); + assert_eq!(decoded.timestamps_us.len(), 1); + assert_eq!(decoded.timestamps_us[0], 1000); + assert_eq!(decoded.fps, 25, "Default fps should be 25 when no InfoV1/V2 header"); + assert!(decoded.aac_data.is_empty(), "No audio in video-only stream"); + } + + #[test] + fn test_compute_actual_fps() { + // 10 frames at exactly 100ms intervals = 10 fps + let ts: Vec = (0..10).map(|i| i * 100_000).collect(); + let fps = compute_actual_fps(&ts).unwrap(); + assert!((fps - 10.0).abs() < 0.1, "Expected ~10 fps, got {}", fps); + + // 30 frames at ~33.3ms intervals = 30 fps + let ts: Vec = (0..30).map(|i| i * 33_333).collect(); + let fps = compute_actual_fps(&ts).unwrap(); + assert!((fps - 30.0).abs() < 0.5, "Expected ~30 fps, got {}", fps); + + // < 2 frames → None + assert!(compute_actual_fps(&[0]).is_none()); + assert!(compute_actual_fps(&[]).is_none()); + + // All same timestamp → None (0 deltas rejected) + assert!(compute_actual_fps(&[1000, 1000, 1000]).is_none()); + } + + #[test] + fn test_stream_for_mp4_assembly_skips_32_when_no_ftyp() { + let mut buf = vec![0u8; 64]; + buf[0..32].fill(0xab); + buf[32..36].copy_from_slice(&0x63643030u32.to_le_bytes()); + buf[36..40].copy_from_slice(b"H264"); + let stream = stream_for_mp4_assembly(&buf, None); + assert_eq!(stream.len(), 32); + assert_eq!(stream[0..4], 0x63643030u32.to_le_bytes()); + } + + /// Decode a real replay dump if present. Generate with: + /// uv run python scripts/extract_replay_from_pcap_app.py --password PASSWORD --debug-dump /tmp/replay.bin PCAPdroid_*.pcap + /// Then: REPLAY_DUMP=/tmp/replay.bin cargo test test_decode_real_replay_dump -- --ignored + /// The script's dump is already reassembled with skip_first_32, so it starts with BcMedia (no leading 32-byte header). + #[test] + #[ignore] + fn test_decode_real_replay_dump() { + let path = std::env::var("REPLAY_DUMP").ok(); + let path = match path.as_deref() { + Some(p) => std::path::Path::new(p), + None => return, + }; + let raw = std::fs::read(path).expect("read replay dump"); + // Script dump may have leading bytes before first BcMedia frame; find magic then decode. + let stream = find_bcmedia_magic_offset(&raw) + .map(|off| &raw[off..]) + .unwrap_or_else(|| stream_for_mp4_assembly(&raw, None)); + let decoded = try_decode_bcmedia_nals(stream).expect("BcMedia decode"); + assert!(!decoded.nals.is_empty(), "should get at least one video frame"); + eprintln!("Replay dump: {} NALs, fps={}, aac={} bytes", decoded.nals.len(), decoded.fps, decoded.aac_data.len()); + let annex_b = annex_b_from_nals(&decoded.nals); + assert!(annex_b.len() > 4, "Annex B output non-empty"); + } +} + +/// Replay stream used for MP4 assembly. If the buffer doesn't start with ftyp, we may skip +/// the first 32-byte replay header (app does this only for MSG 5; for 0x17d it writes the 32 bytes). +/// `skip_first_32`: Some(true) = skip 32 when no ftyp (MSG 5); Some(false) = never skip (MSG 8/0x17d); None = legacy (skip when no ftyp and len>32). +fn stream_for_mp4_assembly(buffer: &[u8], skip_first_32: Option) -> &[u8] { + const REPLAY_HEADER_LEN: usize = 32; + let starts_with_ftyp = buffer.len() >= 8 && buffer[4..8] == *b"ftyp"; + let should_skip = match skip_first_32 { + Some(true) => buffer.len() > REPLAY_HEADER_LEN && !starts_with_ftyp, + Some(false) => false, + None => buffer.len() > REPLAY_HEADER_LEN && !starts_with_ftyp, + }; + if should_skip { + &buffer[REPLAY_HEADER_LEN..] + } else { + buffer + } +} + +/// ftyp box and return the slice from that box to the end. Accepts ftyp size 8..=1024 (ISO BMFF allows variable length). +/// Returns None if buffer already starts with ftyp or no valid ftyp found. +fn slice_from_ftyp(buffer: &[u8]) -> Option<&[u8]> { + const FTYP_SIZE_MAX: u32 = 1024; + if buffer.len() >= 8 && buffer[4..8] == *b"ftyp" { + return None; // already starts with ftyp, caller uses buffer as-is + } + // Search for [size_be][b"ftyp"] so we can start MP4 from there + let mut i = 0; + let mut first_ftyp_at: Option<(usize, u32)> = None; // for diagnostic if no valid box found + while i + 8 <= buffer.len() { + if buffer[i + 4..i + 8] == *b"ftyp" { + let size = u32::from_be_bytes(buffer[i..i + 4].try_into().unwrap()); + if first_ftyp_at.is_none() { + first_ftyp_at = Some((i, size)); + } + let sz = size as usize; + if size >= 8 && size <= FTYP_SIZE_MAX && buffer.len() >= i + sz + 8 { + return Some(&buffer[i..]); + } + } + i += 1; + } + if let Some((off, size)) = first_ftyp_at { + log::info!( + "Replay: found \"ftyp\" at offset {} but size {} not in 8..{} or buffer too short; cannot use as MP4 start", + off, size, FTYP_SIZE_MAX + ); + } else if !buffer.is_empty() { + log::debug!( + "Replay: no ftyp in buffer (first 32 bytes): {:02x?}", + &buffer[..buffer.len().min(32)] + ); + } + None +} + +/// Given start of mdat box, return (content_start, header_len in bytes). +fn parse_mdat_header_range(buffer: &[u8], mdat_start: usize) -> Result<(usize, usize)> { + if buffer.len() < mdat_start + 8 { + anyhow::bail!("Buffer too short for mdat header"); + } + let size_u32 = u32::from_be_bytes(buffer[mdat_start..mdat_start + 4].try_into().unwrap()); + if size_u32 == 1 { + if buffer.len() < mdat_start + 16 { + anyhow::bail!("Buffer too short for mdat 64-bit size"); + } + Ok((mdat_start + 16, 16)) + } else { + Ok((mdat_start + 8, 8)) + } +} + +/// Build a playable MP4 by wrapping post-moov payload in an mdat box and fixing stco/co64. +/// Buffer is the raw stream after the 32-byte replay header (starts with ftyp). +/// Handles both box orders: ftyp+moov+mdat and ftyp+mdat+moov (E1 and others). +/// Provenance: reassembly requires updating stco/co64 when we relocate mdat (ISO BMFF). +/// E1 moov may lack stco/co64 (camera uses different structure); then we have a gap (see notes § Replay: full end-to-end flow and provenance). +fn assemble_mp4_with_mdat(buffer: &[u8]) -> Result> { + if buffer.len() < 8 { + anyhow::bail!("Replay buffer too short for ftyp"); + } + if buffer[4..8] != *b"ftyp" { + anyhow::bail!("Replay buffer does not start with ftyp"); + } + let ftyp_size = u32::from_be_bytes(buffer[0..4].try_into().unwrap()) as usize; + const FTYP_SIZE_MAX: usize = 1024; + if ftyp_size < 8 || ftyp_size > FTYP_SIZE_MAX || buffer.len() < ftyp_size + 8 { + anyhow::bail!( + "Replay buffer: invalid ftyp box size {} (max {}) or buffer too short", + ftyp_size, FTYP_SIZE_MAX + ); + } + + let (moov_start, moov_end, payload, mdat_payload_offset) = { + let first_box_type = &buffer[ftyp_size + 4..ftyp_size + 8]; + if first_box_type == b"moov" { + // Layout: ftyp + moov + [mdat or raw payload] + let moov_start = ftyp_size; + let moov_size = parse_box_size(buffer, moov_start)?; + let moov_end = moov_start + moov_size; + if buffer.len() < moov_end { + anyhow::bail!("Replay buffer truncated before end of moov (moov_size={})", moov_size); + } + let (payload, mdat_header_in_output) = if buffer.len() >= moov_end + 8 && buffer[moov_end + 4..moov_end + 8] == *b"mdat" { + let (mdat_content_start, _) = parse_mdat_header_range(buffer, moov_end)?; + (&buffer[mdat_content_start..], 8usize) + } else { + (&buffer[moov_end..], 8usize) + }; + let mdat_payload_offset = moov_end + mdat_header_in_output; + (moov_start, moov_end, payload, mdat_payload_offset) + } else if first_box_type == b"mdat" { + // Layout: ftyp + mdat + [optional boxes] + moov (E1 and some cameras) + let mdat_start = ftyp_size; + let mdat_box_len = parse_box_size(buffer, mdat_start)?; + let mdat_end = mdat_start + mdat_box_len; + if buffer.len() < mdat_end + 8 { + anyhow::bail!("Replay buffer truncated before moov (mdat_size={})", mdat_box_len); + } + // Skip any boxes (free, wide, etc.) until we find moov + let mut pos = mdat_end; + let (moov_start, moov_size) = loop { + if pos + 8 > buffer.len() { + anyhow::bail!("Replay buffer: no moov box found after mdat"); + } + let box_size = parse_box_size(buffer, pos)?; + let box_type = &buffer[pos + 4..pos + 8]; + if box_type == b"moov" { + break (pos, box_size); + } + pos += box_size; + }; + let moov_end = moov_start + moov_size; + if buffer.len() < moov_end { + anyhow::bail!("Replay buffer truncated before end of moov (moov_size={})", moov_size); + } + // Output will be ftyp + moov + mdat; mdat content starts at ftyp + moov_size + 8 + let payload = if buffer[mdat_start + 4..mdat_start + 8] == *b"mdat" { + let (content_start, _) = parse_mdat_header_range(buffer, mdat_start)?; + &buffer[content_start..mdat_end] + } else { + &buffer[mdat_start + 8..mdat_end] + }; + let mdat_payload_offset = ftyp_size + moov_size + 8; + (moov_start, moov_end, payload, mdat_payload_offset) + } else { + anyhow::bail!("Replay buffer: expected moov or mdat after ftyp, got {:?}", first_box_type); + } + }; + + let mut moov_copy = buffer[moov_start..moov_end].to_vec(); + let mut patched_count = 0u32; + + // Helper: for a chunk-offset box at tag_offset i (start of "stco" or "co64"), return (box_start, header_len to first entry, entry_count, entry_byte_len). + let chunk_box_meta = |moov: &[u8], i: usize, entry_byte_len: usize| -> Option<(usize, usize, usize, usize)> { + if i < 4 { + return None; + } + let box_start = i - 4; + let size_u32 = u32::from_be_bytes(moov[box_start..box_start + 4].try_into().unwrap()); + let (header_len, count_offset) = if size_u32 == 1 { + // Extended size: size(4)=1, type(4), size64(8), ver+flags(4), entry_count(4) + if i + 20 > moov.len() { + return None; + } + (20, 16) + } else { + (12, 8) + }; + let first_entry = i + header_len; + let count_end = i + count_offset + 4; + if count_end > moov.len() || first_entry + entry_byte_len > moov.len() { + return None; + } + let entry_count = u32::from_be_bytes(moov[i + count_offset..count_end].try_into().unwrap()) as usize; + let box_size = if size_u32 == 1 { + if box_start + 16 > moov.len() { + return None; + } + u64::from_be_bytes(moov[box_start + 8..box_start + 16].try_into().unwrap()) as usize + } else { + size_u32 as usize + }; + if box_start + box_size > moov.len() { + return None; + } + Some((box_start, first_entry, entry_count, box_size)) + }; + + // Patch all stco boxes (32-bit chunk offsets). + let mut search_from = 0usize; + while let Some(i) = moov_copy[search_from..].windows(4).position(|w| w == b"stco") { + let i = search_from + i; + if let Some((box_start, first_entry, entry_count, box_size)) = chunk_box_meta(&moov_copy, i, 4) { + let old_first = u32::from_be_bytes(moov_copy[first_entry..first_entry + 4].try_into().unwrap()); + let delta = (mdat_payload_offset as i64) - (old_first as i64); + for j in 0..entry_count { + let pos = first_entry + j * 4; + if pos + 4 > moov_copy.len() { + break; + } + let old_val = u32::from_be_bytes(moov_copy[pos..pos + 4].try_into().unwrap()); + let new_val = ((old_val as i64) + delta) as u32; + moov_copy[pos..pos + 4].copy_from_slice(&new_val.to_be_bytes()); + } + patched_count += 1; + search_from = box_start + box_size; + } else { + search_from = i + 1; + } + } + + // Patch all co64 boxes (64-bit chunk offsets). + search_from = 0usize; + while let Some(i) = moov_copy[search_from..].windows(4).position(|w| w == b"co64") { + let i = search_from + i; + if let Some((box_start, first_entry, entry_count, box_size)) = chunk_box_meta(&moov_copy, i, 8) { + let old_first = u64::from_be_bytes(moov_copy[first_entry..first_entry + 8].try_into().unwrap()); + let delta = (mdat_payload_offset as i64) - (old_first as i64); + for j in 0..entry_count { + let pos = first_entry + j * 8; + if pos + 8 > moov_copy.len() { + break; + } + let old_val = u64::from_be_bytes(moov_copy[pos..pos + 8].try_into().unwrap()); + let new_val = ((old_val as i64) + delta) as u64; + moov_copy[pos..pos + 8].copy_from_slice(&new_val.to_be_bytes()); + } + patched_count += 1; + search_from = box_start + box_size; + } else { + search_from = i + 1; + } + } + + if patched_count == 0 { + log::warn!("Replay: no stco/co64 found in moov, mdat offset may be wrong"); + // Diagnose: do the strings exist but chunk_box_meta failed? + let moov_len = moov_copy.len(); + if let Some(pos) = moov_copy.windows(4).position(|w| w == b"stco") { + let box_start = pos.saturating_sub(4); + let size_u32 = if box_start + 4 <= moov_len { + u32::from_be_bytes(moov_copy[box_start..box_start + 4].try_into().unwrap()) + } else { + 0 + }; + log::info!("Replay: moov size {} bytes; 'stco' at offset {} (box size field={}), chunk_box_meta would need valid box", moov_len, pos, size_u32); + } else if let Some(pos) = moov_copy.windows(4).position(|w| w == b"co64") { + let box_start = pos.saturating_sub(4); + let size_u32 = if box_start + 4 <= moov_len { + u32::from_be_bytes(moov_copy[box_start..box_start + 4].try_into().unwrap()) + } else { + 0 + }; + log::info!("Replay: moov size {} bytes; 'co64' at offset {} (box size field={}), chunk_box_meta would need valid box", moov_len, pos, size_u32); + } else { + log::info!("Replay: moov size {} bytes; no 'stco' or 'co64' substring in moov (camera may use different structure)", moov_len); + } + } else { + log::info!("Replay: patched {} stco/co64 box(es) to mdat offset {}", patched_count, mdat_payload_offset); + } + + let mdat_box_len = 8 + payload.len(); // 4 size + 4 tag + payload + let mut out = Vec::with_capacity(ftyp_size + moov_copy.len() + mdat_box_len); + out.extend_from_slice(&buffer[0..ftyp_size]); + out.extend_from_slice(&moov_copy); + if mdat_box_len <= 0xFF_FF_FF_FF { + out.extend_from_slice(&(mdat_box_len as u32).to_be_bytes()); + out.extend_from_slice(b"mdat"); + } else { + out.extend_from_slice(&1u32.to_be_bytes()); // size 1 = 64-bit size follows + out.extend_from_slice(b"mdat"); + out.extend_from_slice(&((16 + payload.len()) as u64).to_be_bytes()); // 4+4+8+payload + } + out.extend_from_slice(payload); + Ok(out) +} + +/// Parse YYYY-MM-DD into (year, month, day). +fn parse_date(s: &str) -> Result<(i32, u8, u8)> { + let parts: Vec<&str> = s.split('-').collect(); + if parts.len() != 3 { + anyhow::bail!("Date must be YYYY-MM-DD, got {:?}", s); + } + let year: i32 = parts[0].parse().context("Invalid year")?; + let month: u8 = parts[1].parse().context("Invalid month")?; + let day: u8 = parts[2].parse().context("Invalid day")?; + if month == 0 || month > 12 || day == 0 || day > 31 { + anyhow::bail!("Invalid month or day"); + } + Ok((year, month, day)) +} + +fn date_to_replay_start(year: i32, month: u8, day: u8) -> ReplayDateTime { + ReplayDateTime { + year, + month, + day, + hour: 0, + minute: 0, + second: 0, + } +} + +fn date_to_replay_end(year: i32, month: u8, day: u8) -> ReplayDateTime { + ReplayDateTime { + year, + month, + day, + hour: 23, + minute: 59, + second: 59, + } +} + +async fn run_replay_or_download( + camera: &crate::common::NeoInstance, + name: &str, + stream_type: &str, + speed: u32, + output: Option, + duration: Option, + dump_replay: Option, + dump_replay_limit: Option, + _is_download: bool, +) -> Result<()> { + log::info!( + "Replay play: name={} stream={} speed={}", + name, + stream_type, + speed + ); + // If writing to file and no --duration, try to get duration from file list (seek + list files by day). + let mut duration = duration; + if duration.is_none() && output.is_some() { + let auto_secs = camera + .run_task(|cam| { + let name = name.to_string(); + let stream_type = stream_type.to_string(); + let record_type = cmdline::FILE_SEARCH_RECORD_TYPES.to_string(); + Box::pin(async move { + cam.get_replay_file_duration_secs(&name, &stream_type, &record_type) + .await + .map_err(anyhow::Error::msg) + }) + }) + .await + .ok() + .flatten(); + if let Some(secs) = auto_secs { + log::info!( + "Replay: file duration {} s (from file list), will stop when complete", + secs + ); + duration = Some(secs); + } + } + if duration.is_none() { + log::info!("Replay: no --duration set; camera will stream until you stop or send response 300. Use --duration N to record N seconds then close."); + } + // Get file metadata (size, record_type with AI tags, timing) from file list. + let file_meta: Option = camera + .run_task(|cam| { + let name = name.to_string(); + let stream_type = stream_type.to_string(); + let record_type = cmdline::FILE_SEARCH_RECORD_TYPES.to_string(); + Box::pin(async move { + let meta = cam.get_replay_file_metadata(&name, &stream_type, &record_type).await.ok().flatten(); + Ok(meta) + }) + }) + .await + .ok() + .flatten(); + let expected_size = file_meta.as_ref().and_then(|info| { + let l = info.size_l.unwrap_or(0) as u64; + let h = info.size_h.unwrap_or(0) as u64; + if l == 0 && h == 0 { None } else { Some(l + (h << 32)) } + }); + if let Some(sz) = expected_size { + log::info!("Replay: expected file size {} bytes (from file list), will stop when complete", sz); + } + // Build recording metadata for MP4 embedding + let recording_meta = RecordingMeta { + record_type: file_meta.as_ref().and_then(|f| f.record_type.clone()), + start_time: file_meta.as_ref().and_then(|f| f.start_time.as_ref().map(format_replay_datetime)), + end_time: file_meta.as_ref().and_then(|f| f.end_time.as_ref().map(format_replay_datetime)), + camera_name: None, + }; + if let Some(ref rt) = recording_meta.record_type { + log::info!("Replay: file recordType = {}", rt); + } + let dump_limit = dump_replay.as_ref().and_then(|_| Some(dump_replay_limit.unwrap_or(131072))); + let mut stream = camera + .run_task(|cam| { + let name = name.to_string(); + let stream_type = stream_type.to_string(); + let dump_path = dump_replay.clone(); + let expected_size = expected_size; + let limit = dump_limit; + Box::pin(async move { + cam.start_replay(&name, &stream_type, speed, false, 100, dump_path, limit, expected_size) + .await + .context("Could not start replay on camera") + }) + }) + .await?; + + // When no output path is given, use a sink so we don't flood the terminal with binary. + let mut out: Box = match &output { + Some(p) => Box::new(tokio::fs::File::create(p).await?), + None => Box::new(tokio::io::sink()), + }; + + let mut frames: u64 = 0; + let mut raw_bytes: u64 = 0; + let mut raw_replay_buffer: Vec = Vec::new(); // when writing to file, buffer raw chunks to assemble with mdat + // When output is .mp4, collect BcMedia NALs+timestamps for proper MP4 muxing at the end. + let mux_to_mp4_output = output.as_ref().map(|p| p.extension().map(|e| e == "mp4").unwrap_or(false)).unwrap_or(false); + let mut bcmedia_nals: Vec> = Vec::new(); + let mut bcmedia_timestamps: Vec = Vec::new(); + let mut bcmedia_aac: Vec = Vec::new(); + let mut bcmedia_codec: neolink_core::bcmedia::model::VideoType = neolink_core::bcmedia::model::VideoType::H264; + // Skip first 32 bytes only when replay was started with MSG 5 (app parity; set when we receive ReplayStarted). + let mut skip_first_32: Option = None; + let deadline = duration.map(|secs| Instant::now() + Duration::from_secs(secs)); + + loop { + let res = if let Some(deadline) = deadline { + tokio::select! { + _ = sleep_until(deadline) => { + log::info!("Replay: {}s duration reached, sending replay stop (MSG 7) and closing file.", duration.unwrap()); + break; + } + _ = tokio::signal::ctrl_c() => { + log::info!("Replay: Ctrl+C received, stopping and writing partial file."); + break; + } + r = stream.get_data() => r, + } + } else { + tokio::select! { + _ = tokio::signal::ctrl_c() => { + log::info!("Replay: Ctrl+C received, stopping and writing partial file."); + break; + } + r = stream.get_data() => r, + } + }; + + match res { + Ok(Ok(BcMedia::Iframe(BcMediaIframe { data, microseconds, video_type, .. }))) + | Ok(Ok(BcMedia::Pframe(BcMediaPframe { data, microseconds, video_type, .. }))) => { + if mux_to_mp4_output { + if frames == 0 { + // Override header claim with NAL-level detection (e.g. Argus PT labels H.265 as "H264") + let actual = detect_codec_from_nal_bytes(&data).unwrap_or(video_type); + if actual != video_type { + log::warn!( + "Replay: BcMedia header says {:?} but NAL bytes indicate {:?} — using detected codec", + video_type, actual + ); + } + bcmedia_codec = actual; + log::info!("Replay: stream codec = {:?}", actual); + } + bcmedia_nals.push(data); + bcmedia_timestamps.push(microseconds); + } else { + out.write_all(&data).await?; + out.flush().await?; + } + frames += 1; + if frames <= 5 || frames % 30 == 0 { + log::info!("Replay: wrote frame {} to output", frames); + } + } + Ok(Ok(BcMedia::Aac(BcMediaAac { data, .. }))) if mux_to_mp4_output => { + bcmedia_aac.extend_from_slice(&data); + } + Ok(Ok(BcMedia::ReplayStarted(msg_id))) => { + skip_first_32 = Some(msg_id == 5); + } + Ok(Ok(BcMedia::RawReplayChunk(data))) => { + raw_bytes += data.len() as u64; + if output.is_some() { + raw_replay_buffer.extend_from_slice(&data); + } + // Log progress; skip the initial "32 bytes" (replay header) to avoid confusion + if (raw_bytes > 32 && raw_bytes <= 1024) || raw_bytes % 10240 == 0 { + log::info!("Replay: received {} bytes total (container/raw)", raw_bytes); + } + } + Ok(Ok(BcMedia::StreamEnd)) => { + log::info!("Replay: camera signalled end of file, finishing download."); + break; + } + Ok(Ok(_)) => {} + Ok(Err(e)) | Err(e) => return Err(e.into()), + } + } + + // Send MSG 7 (replay stop). Use a short timeout so we don't hang if the camera + // already closed the stream (e.g. after "no data for 15s"). + log::info!("Replay: sending MSG 7 (stop) with {}s timeout", 5); + const REPLAY_STOP_TIMEOUT_SECS: u64 = 5; + let stop_start = std::time::Instant::now(); + match tokio::time::timeout( + tokio::time::Duration::from_secs(REPLAY_STOP_TIMEOUT_SECS), + camera.run_task(|cam| { + let name = name.to_string(); + Box::pin( + async move { cam.replay_stop(&name).await.context("Replay stop failed") }, + ) + }), + ) + .await + { + Ok(Ok(())) => { + log::info!("Replay: MSG 7 (stop) completed successfully in {:?}", stop_start.elapsed()); + } + Ok(Err(e)) => { + log::warn!("Replay: MSG 7 (stop) failed after {:?}: {:?}", stop_start.elapsed(), e); + } + Err(_) => { + log::warn!( + "Replay: MSG 7 (stop) timed out after {}s (camera may have already closed stream)", + REPLAY_STOP_TIMEOUT_SECS + ); + } + } + if let Some(p) = &output { + if !raw_replay_buffer.is_empty() { + let stream = stream_for_mp4_assembly(&raw_replay_buffer, skip_first_32); + let to_try: &[u8] = slice_from_ftyp(stream).unwrap_or(stream); + match assemble_mp4_with_mdat(to_try) { + Ok(assembled) => { + tokio::fs::write(p, &assembled).await.context("Write assembled MP4")?; + patch_e1_avcc_if_needed(p).context("Patch E1 avcC for playback")?; + patch_avc1_colour_if_needed(p).context("Patch avc1 colour for playback")?; + println!( + "Wrote {} bytes (assembled MP4) to {}", + assembled.len(), + p.display() + ); + } + Err(e) => { + log::warn!("Replay: could not assemble MP4: {:?}; trying BcMedia decode", e); + let stream_to_decode = find_bcmedia_magic_offset(stream) + .map(|off| &stream[off..]) + .unwrap_or(stream); + let stream_to_decode_len = stream_to_decode.len(); + // Save raw stream for offline analysis + let bcmedia_dump_path = p.with_extension("replay.bin"); + if let Err(e) = tokio::fs::write(&bcmedia_dump_path, stream_to_decode).await { + log::warn!("Replay: could not write BcMedia dump: {}", e); + } else { + log::info!("Replay: BcMedia stream ({} bytes) saved to {}", stream_to_decode_len, bcmedia_dump_path.display()); + } + // Decode BcMedia frames and mux to MP4 + const DECODE_MUX_TIMEOUT_SECS: u64 = 60; + let stream_to_decode = stream_to_decode.to_vec(); + let decoded_opt = match tokio::time::timeout( + tokio::time::Duration::from_secs(DECODE_MUX_TIMEOUT_SECS), + tokio::task::spawn_blocking(move || try_decode_bcmedia_nals(&stream_to_decode)), + ) + .await + { + Ok(Ok(n)) => n, + Ok(Err(e)) => { log::warn!("Replay: BcMedia decode failed: {:?}", e); None } + Err(_) => { log::warn!("Replay: BcMedia decode timed out after {}s", DECODE_MUX_TIMEOUT_SECS); None } + }; + if let Some(BcMediaDecoded { nals, fps, timestamps_us, aac_data, video_codec }) = decoded_opt { + let annex_b = annex_b_from_nals(&nals); + let is_mp4 = p.extension().map(|e| e == "mp4").unwrap_or(false); + if is_mp4 { + if mux_to_mp4(&nals, &aac_data, fps, ×tamps_us, p, &recording_meta, video_codec).await? { + println!("Parsed BcMedia replay and muxed to {}", p.display()); + } else { + tokio::fs::write(p, &annex_b).await.context("Write video fallback")?; + println!("Wrote {} bytes (Annex B) to {} (mux failed)", annex_b.len(), p.display()); + } + } else { + tokio::fs::write(p, &annex_b).await.context("Write video replay")?; + println!("Wrote {} bytes (Annex B) to {}", annex_b.len(), p.display()); + } + } else { + let raw_path = if p.extension().map(|e| e == "mp4").unwrap_or(false) { + p.with_extension("replay.raw.bin") + } else { + p.clone() + }; + tokio::fs::write(&raw_path, &raw_replay_buffer).await.context("Write raw replay")?; + if raw_path.as_path() != p.as_path() { + println!( + "Replay stream is not valid MP4 or BcMedia. Wrote {} bytes to {} for inspection.", + raw_replay_buffer.len(), + raw_path.display() + ); + } else { + patch_e1_avcc_if_needed(&raw_path).context("Patch E1 avcC for playback")?; + patch_avc1_colour_if_needed(&raw_path).context("Patch avc1 colour for playback")?; + println!("Wrote {} bytes (raw container) to {}", raw_replay_buffer.len(), raw_path.display()); + } + } + } + } + } else if !bcmedia_nals.is_empty() { + if let Some(p) = &output { + if mux_to_mp4( + &bcmedia_nals, + &bcmedia_aac, + 0, + &bcmedia_timestamps, + p, + &recording_meta, + bcmedia_codec, + ) + .await? + { + println!("Wrote {} frames to {} (MP4)", frames, p.display()); + } else { + let annex_b = annex_b_from_nals(&bcmedia_nals); + tokio::fs::write(p, &annex_b).await.context("Write video fallback")?; + println!("Wrote {} frames to {} (video, mux failed)", frames, p.display()); + } + } + } else if frames > 0 { + out.flush().await?; + out.shutdown().await?; + println!("Wrote {} frames to {}", frames, p.display()); + } else { + // No data received — delete the empty file that was created upfront. + drop(out); + let _ = tokio::fs::remove_file(p).await; + return Err(anyhow::anyhow!( + "No data received from camera (0 frames, 0 bytes). Output file removed. \ + The camera may be busy or the file may no longer exist — try again." + )); + } + } else { + out.flush().await?; + out.shutdown().await?; + if raw_bytes > 0 || frames > 0 { + log::info!("Replay: received {} bytes, {} frames (use --output to save to file)", raw_bytes, frames); + } + } + + drop(stream); + Ok(()) +} + +/// Download by time range (MSG 143). Stream ends on response 331; stop is MSG 144 (sent on drop). +async fn run_download_by_time( + camera: &crate::common::NeoInstance, + start_time: ReplayDateTime, + end_time: ReplayDateTime, + stream_type: &str, + output: std::path::PathBuf, + duration: Option, + dump_replay: Option, + dump_replay_limit: Option, +) -> Result<()> { + let stream_type_u32 = if stream_type == "subStream" { 1 } else { 0 }; + let save_path = output.display().to_string(); + const DEFAULT_DUMP_LIMIT: usize = 131072; + let dump_limit = dump_replay.as_ref().map(|_| dump_replay_limit.unwrap_or(DEFAULT_DUMP_LIMIT)); + + log::info!( + "Replay download-by-time: {:?} to {:?} stream={}", + start_time, + end_time, + stream_type + ); + + // Build metadata from the time range (no FileInfo available for download-by-time) + let recording_meta = RecordingMeta { + record_type: None, + start_time: Some(format_replay_datetime(&start_time)), + end_time: Some(format_replay_datetime(&end_time)), + camera_name: None, + }; + + let mut stream = camera + .run_task(|cam| { + let start_time = start_time.clone(); + let end_time = end_time.clone(); + let save_path = save_path.clone(); + let dump_path = dump_replay.clone(); + let limit = dump_limit; + Box::pin(async move { + cam.start_download_by_time( + start_time, + end_time, + &save_path, + stream_type_u32, + false, + 100, + dump_path, + limit, + ) + .await + .context("Could not start download-by-time on camera") + }) + }) + .await?; + + let mut out: Box = + Box::new(tokio::fs::File::create(&output).await?); + let mut frames: u64 = 0; + let mut raw_bytes: u64 = 0; + let mut raw_replay_buffer: Vec = Vec::new(); + let deadline = duration.map(|secs| Instant::now() + Duration::from_secs(secs)); + + loop { + let res = if let Some(deadline) = deadline { + tokio::select! { + _ = sleep_until(deadline) => { + log::info!("DownloadByTime: {}s duration reached, closing (MSG 144 sent on drop).", duration.unwrap()); + break; + } + _ = tokio::signal::ctrl_c() => { + log::info!("DownloadByTime: Ctrl+C received, writing partial file."); + break; + } + r = stream.get_data() => r, + } + } else { + tokio::select! { + _ = tokio::signal::ctrl_c() => { + log::info!("DownloadByTime: Ctrl+C received, writing partial file."); + break; + } + r = stream.get_data() => r, + } + }; + + match res { + Ok(Ok(BcMedia::Iframe(BcMediaIframe { data, .. }))) + | Ok(Ok(BcMedia::Pframe(BcMediaPframe { data, .. }))) => { + out.write_all(&data).await?; + out.flush().await?; + frames += 1; + if frames <= 5 || frames % 30 == 0 { + log::info!("DownloadByTime: wrote frame {} to output", frames); + } + } + Ok(Ok(BcMedia::RawReplayChunk(data))) => { + raw_bytes += data.len() as u64; + raw_replay_buffer.extend_from_slice(&data); + if (raw_bytes > 32 && raw_bytes <= 1024) || raw_bytes % 10240 == 0 { + log::info!("DownloadByTime: received {} bytes total (container/raw)", raw_bytes); + } + } + Ok(Ok(BcMedia::StreamEnd)) => { + log::info!("DownloadByTime: camera signalled end of file, finishing download."); + break; + } + Ok(Ok(_)) => {} + Ok(Err(e)) | Err(e) => { + if frames == 0 && raw_bytes == 0 { + return Err(anyhow::anyhow!( + "Camera rejected download-by-time (MSG 143). This camera may not support \ + time-range downloads. Use 'replay download --name ' instead." + )); + } + return Err(e.into()); + } + } + } + + drop(stream); // sends MSG 144 + out.flush().await?; + out.shutdown().await?; + + if !raw_replay_buffer.is_empty() { + let stream = stream_for_mp4_assembly(&raw_replay_buffer, None); + let to_try: &[u8] = slice_from_ftyp(stream).unwrap_or(stream); + match assemble_mp4_with_mdat(to_try) { + Ok(assembled) => { + tokio::fs::write(&output, &assembled).await.context("Write assembled MP4")?; + patch_e1_avcc_if_needed(&output).context("Patch E1 avcC for playback")?; + patch_avc1_colour_if_needed(&output).context("Patch avc1 colour for playback")?; + println!( + "Wrote {} bytes (assembled MP4 with mdat) to {}. If ffplay shows 'unspecified pixel format', try: ffplay -vf format=yuv420p {}", + assembled.len(), + output.display(), + output.display() + ); + } + Err(e) => { + log::warn!( + "DownloadByTime: could not assemble MP4 with mdat: {:?}; trying BcMedia decode", + e + ); + let stream_to_decode = find_bcmedia_magic_offset(stream) + .map(|off| &stream[off..]) + .unwrap_or(stream); + if let Some(BcMediaDecoded { nals, fps, timestamps_us, aac_data, video_codec }) = try_decode_bcmedia_nals(stream_to_decode) { + let annex_b = annex_b_from_nals(&nals); + let is_mp4 = output.extension().map(|e| e == "mp4").unwrap_or(false); + if is_mp4 { + if mux_to_mp4(&nals, &aac_data, fps, ×tamps_us, &output, &recording_meta, video_codec).await? { + println!("Parsed BcMedia replay and muxed to {}", output.display()); + } else { + tokio::fs::write(&output, &annex_b).await.context("Write video fallback")?; + println!("Wrote {} bytes (Annex B) to {} (mux failed)", annex_b.len(), output.display()); + } + } else { + tokio::fs::write(&output, &annex_b).await.context("Write video replay")?; + println!("Wrote {} bytes (Annex B) to {}", annex_b.len(), output.display()); + } + } else { + // Stream is not ftyp MP4 and BcMedia decode yielded no video; don't write to .mp4. + let raw_path = if output.extension().map(|e| e == "mp4").unwrap_or(false) { + output.with_extension("replay.bin") + } else { + output.clone() + }; + tokio::fs::write(&raw_path, &raw_replay_buffer).await.context("Write raw replay")?; + if raw_path.as_path() != output.as_path() { + println!( + "Replay stream is not valid MP4 or BcMedia (e.g. camera sent XML or other format). Wrote {} bytes to {} for inspection.", + raw_replay_buffer.len(), + raw_path.display() + ); + } else { + patch_e1_avcc_if_needed(&raw_path).context("Patch E1 avcC for playback")?; + patch_avc1_colour_if_needed(&raw_path).context("Patch avc1 colour for playback")?; + println!( + "Wrote {} bytes (raw container) to {}. If ffplay shows 'unspecified pixel format', try: ffplay -vf format=yuv420p {}", + raw_replay_buffer.len(), + raw_path.display(), + raw_path.display() + ); + } + } + } + } + } else if frames > 0 { + println!("Wrote {} frames to {}", frames, output.display()); + } + Ok(()) +} + +/// Download a specific SD card file by name (MSG 8, NET_DOWNLOAD_V20). +/// Camera streams the raw MP4 directly — no BcMedia re-encoding needed. +async fn run_download_file_by_name( + camera: &crate::common::NeoInstance, + file_name: &str, + output: std::path::PathBuf, + dump_replay: Option, + dump_replay_limit: Option, +) -> Result<()> { + log::info!("DownloadByName: requesting file '{}' via MSG 8", file_name); + + let mut stream = camera + .run_task(|cam| { + let name = file_name.to_string(); + let dump_path = dump_replay.clone(); + let dump_limit = dump_replay_limit; + Box::pin(async move { + cam.start_download_file_by_name(&name, false, 100, dump_path, dump_limit) + .await + .context("Could not start download-file-by-name on camera") + }) + }) + .await?; + + let mut raw_bytes: u64 = 0; + let mut raw_replay_buffer: Vec = Vec::new(); + + loop { + let res = tokio::select! { + _ = tokio::signal::ctrl_c() => { + log::info!("DownloadByName: Ctrl+C received, writing partial file."); + break; + } + r = stream.get_data() => r, + }; + + match res { + Ok(Ok(BcMedia::RawReplayChunk(data))) => { + raw_bytes += data.len() as u64; + raw_replay_buffer.extend_from_slice(&data); + if (raw_bytes > 32 && raw_bytes <= 1024) || raw_bytes % (1024 * 1024) == 0 { + log::info!("DownloadByName: received {} bytes total", raw_bytes); + } + } + Ok(Ok(BcMedia::Iframe(BcMediaIframe { data, .. }))) + | Ok(Ok(BcMedia::Pframe(BcMediaPframe { data, .. }))) => { + // BcMedia path (unexpected for direct download but handle gracefully) + raw_replay_buffer.extend_from_slice(&data); + raw_bytes += data.len() as u64; + } + Ok(Ok(BcMedia::StreamEnd)) => { + log::info!("DownloadByName: camera signalled end of file ({} bytes).", raw_bytes); + break; + } + Ok(Ok(_)) => {} + Ok(Err(e)) | Err(e) => { + if raw_bytes == 0 { + return Err(anyhow::anyhow!( + "Camera rejected download-by-name (MSG 8): {}. \ + This camera may not support direct file download. \ + Try 'replay download --name {}' instead.", + e, file_name + )); + } + log::warn!("DownloadByName: stream error after {} bytes: {:?}", raw_bytes, e); + break; + } + } + } + + drop(stream); // sends MSG 9 stop + + if raw_replay_buffer.is_empty() { + return Err(anyhow::anyhow!("DownloadByName: no data received from camera")); + } + + let stream = stream_for_mp4_assembly(&raw_replay_buffer, None); + let to_try: &[u8] = slice_from_ftyp(stream).unwrap_or(stream); + match assemble_mp4_with_mdat(to_try) { + Ok(assembled) => { + tokio::fs::write(&output, &assembled).await.context("Write assembled MP4")?; + patch_e1_avcc_if_needed(&output).context("Patch E1 avcC")?; + patch_avc1_colour_if_needed(&output).context("Patch avc1 colour")?; + println!( + "Wrote {} bytes (assembled MP4) to {}", + assembled.len(), + output.display() + ); + } + Err(_) => { + // Not an MP4 container; try BcMedia decode + let stream_to_decode = find_bcmedia_magic_offset(stream) + .map(|off| &stream[off..]) + .unwrap_or(stream); + if let Some(BcMediaDecoded { nals, fps, timestamps_us, aac_data, video_codec }) = + try_decode_bcmedia_nals(stream_to_decode) + { + let recording_meta = RecordingMeta { + record_type: None, + start_time: None, + end_time: None, + camera_name: None, + }; + let is_mp4 = output.extension().map(|e| e == "mp4").unwrap_or(false); + if is_mp4 { + if mux_to_mp4(&nals, &aac_data, fps, ×tamps_us, &output, &recording_meta, video_codec) + .await? + { + println!( + "Parsed BcMedia and muxed to {}", + output.display() + ); + } else { + let annex_b = annex_b_from_nals(&nals); + tokio::fs::write(&output, &annex_b).await.context("Write H.264")?; + println!("Wrote {} bytes (H.264) to {}", annex_b.len(), output.display()); + } + } else { + let annex_b = annex_b_from_nals(&nals); + tokio::fs::write(&output, &annex_b).await.context("Write H.264")?; + println!("Wrote {} bytes (H.264 Annex B) to {}", annex_b.len(), output.display()); + } + } else { + // Unknown format — save raw for inspection + let raw_path = if output.extension().map(|e| e == "mp4").unwrap_or(false) { + output.with_extension("raw.bin") + } else { + output.clone() + }; + tokio::fs::write(&raw_path, &raw_replay_buffer) + .await + .context("Write raw download")?; + println!( + "Wrote {} bytes (raw, unknown format) to {} for inspection", + raw_replay_buffer.len(), + raw_path.display() + ); + } + } + } + Ok(()) +} + +/// Entry point for the replay subcommand +pub(crate) async fn main(opt: Opt, reactor: NeoReactor) -> Result<()> { + let camera = reactor.get(&opt.camera).await?; + + match opt.cmd { + cmdline::ReplayCommand::Days { start, end } => { + let (sy, sm, sd) = parse_date(&start)?; + let end_str = end.as_deref().unwrap_or(&start); + let (ey, em, ed) = parse_date(end_str)?; + let start_time = date_to_replay_start(sy, sm, sd); + let end_time = date_to_replay_end(ey, em, ed); + + let records = camera + .run_task(|cam| { + let st = start_time.clone(); + let et = end_time.clone(); + Box::pin( + async move { + cam.get_day_records(st, et) + .await + .context("Could not get day records from camera") + }, + ) + }) + .await?; + + let single_day = start_time.year == end_time.year + && start_time.month == end_time.month + && start_time.day == end_time.day; + if records.day_type_list.is_none() && single_day { + // E1 and some cameras don't send dayTypeList; fall back to file list for this day + let handle_info = camera + .run_task({ + let st = start_time.clone(); + let et = end_time.clone(); + move |cam| { + let st = st.clone(); + let et = et.clone(); + Box::pin( + async move { + cam.get_file_list_handle("subStream", cmdline::FILE_SEARCH_RECORD_TYPES, st, et) + .await + .context("Could not get file list handle") + }, + ) + } + }) + .await?; + if let Some(handle) = handle_info.handle { + let files = camera + .run_task(|cam| { + Box::pin( + async move { + cam.get_file_list_by_handle(handle) + .await + .context("Could not get file list") + }, + ) + }) + .await?; + let n = files.len(); + if n == 0 { + println!("No recordings for this day."); + } else { + println!("This day has {} recording(s).", n); + } + } else { + print_day_records(&records); + } + } else { + print_day_records(&records); + } + } + cmdline::ReplayCommand::Files { + date, + stream, + record_type, + ai_filter, + } => { + let (y, m, d) = parse_date(&date)?; + let start_time = date_to_replay_start(y, m, d); + let end_time = date_to_replay_end(y, m, d); + + // When --stream is not specified, query both mainStream and subStream. + // Cameras (e.g. Argus 3) store mainStream and subStream as separate files. + let streams_to_query: Vec = match stream { + Some(s) => vec![s], + None => vec!["mainStream".to_string(), "subStream".to_string()], + }; + + let mut files: Vec<_> = Vec::new(); + for stream_name in &streams_to_query { + let handle_result = camera + .run_task(|cam| { + let st = start_time.clone(); + let et = end_time.clone(); + let sn = stream_name.clone(); + let record_type = record_type.clone(); + Box::pin(async move { + cam.get_file_list_handle(&sn, &record_type, st, et) + .await + .map_err(|e| anyhow::anyhow!("{}", e)) + }) + }) + .await; + + let handle_info = match handle_result { + Ok(info) => info, + Err(e) => { + let msg = format!("{}", e); + if msg.contains("returned code 400") { + // No recordings for this stream/day — try next stream + continue; + } + return Err(e).context("Could not get file list handle from camera"); + } + }; + + let handle = match handle_info.handle { + Some(h) => h, + None => continue, // no handle = no files for this stream + }; + + let stream_files = camera + .run_task(|cam| { + Box::pin(async move { + cam.get_file_list_by_handle(handle) + .await + .map_err(|e| anyhow::anyhow!("{}", e)) + }) + }) + .await + .context("Could not get file list from camera")?; + files.extend(stream_files); + } + + if files.is_empty() { + println!("No files for this day."); + return Ok(()); + } + + // Apply --ai-filter: only show files whose recordType contains at least one requested tag + let files = if let Some(ref filter) = ai_filter { + let wanted: Vec<&str> = filter.split(',').map(|s| s.trim()).collect(); + files + .into_iter() + .filter(|f| { + if let Some(ref rt) = f.record_type { + let tags: Vec<&str> = rt.split(',').map(|s| s.trim()).collect(); + wanted.iter().any(|w| tags.contains(w)) + } else { + false + } + }) + .collect() + } else { + files + }; + + print_file_list(&files); + } + cmdline::ReplayCommand::Play { + name, + stream: stream_type, + speed, + output, + duration, + dump_replay, + dump_replay_limit, + } => { + run_replay_or_download( + &camera, + &name, + &stream_type, + speed, + output, + duration, + dump_replay, + dump_replay_limit, + false, + ) + .await?; + } + cmdline::ReplayCommand::Download { + name, + stream: stream_type, + output, + duration, + dump_replay, + dump_replay_limit, + } => { + log::info!( + "Replay download: name={} stream={} (stops on response 300 or --duration)", + name, + stream_type + ); + let output = output.ok_or_else(|| anyhow::anyhow!("Download requires --output "))?; + run_replay_or_download( + &camera, + &name, + &stream_type, + 1, + Some(output), + duration, + dump_replay, + dump_replay_limit, + true, + ) + .await?; + } + cmdline::ReplayCommand::DownloadByTime { + start, + end, + stream: stream_type, + output, + duration, + dump_replay, + dump_replay_limit, + } => { + let output = + output.ok_or_else(|| anyhow::anyhow!("download-by-time requires --output "))?; + let (sy, sm, sd) = parse_date(&start)?; + let end_str = end.as_deref().unwrap_or(&start); + let (ey, em, ed) = parse_date(end_str)?; + let start_time = date_to_replay_start(sy, sm, sd); + let end_time = date_to_replay_end(ey, em, ed); + run_download_by_time( + &camera, + start_time, + end_time, + &stream_type, + output, + duration, + dump_replay, + dump_replay_limit, + ) + .await?; + } + cmdline::ReplayCommand::AlarmSearch { + start, + end, + stream_type, + alarm_types, + } => { + let (sy, sm, sd) = parse_date(&start)?; + let end_str = end.as_deref().unwrap_or(&start); + let (ey, em, ed) = parse_date(end_str)?; + let start_time = date_to_replay_start(sy, sm, sd); + let end_time = date_to_replay_end(ey, em, ed); + + let alarm_list: Vec = alarm_types.split(',').map(|s| s.trim().to_string()).collect(); + let alarm_refs: Vec<&str> = alarm_list.iter().map(|s| s.as_str()).collect(); + + // START: send search params, get handle + let result = camera + .run_task(|cam| { + let st = start_time.clone(); + let et = end_time.clone(); + let ar: Vec = alarm_refs.iter().map(|s| s.to_string()).collect(); + Box::pin(async move { + let refs: Vec<&str> = ar.iter().map(|s| s.as_str()).collect(); + cam.alarm_video_search_start(stream_type, &refs, st, et) + .await + .map_err(|e| anyhow::anyhow!("{}", e)) + }) + }) + .await; + + // Handle cameras that don't support alarm search (405 = not supported) + let result = match result { + Ok(r) => r, + Err(e) => { + let msg = format!("{}", e); + if msg.contains("returned code 405") { + println!("This camera does not support alarm video search (MSG 175)."); + return Ok(()); + } + if msg.contains("returned code 400") { + println!("No alarm events found for this date range."); + return Ok(()); + } + return Err(e).context("Alarm video search failed"); + } + }; + + println!("Alarm search response:"); + println!(" channelId: {:?}", result.channel_id); + println!(" fileHandle: {:?}", result.file_handle); + println!(" streamType: {:?}", result.stream_type); + println!(" alarmType: {:?}", result.alarm_type); + if let Some(ref st) = result.start_time { + println!(" startTime: {}", format_replay_datetime(st)); + } + if let Some(ref et) = result.end_time { + println!(" endTime: {}", format_replay_datetime(et)); + } + + // If we got a handle, paginate to collect all events + if let Some(handle) = result.file_handle { + if handle >= 0 { + println!("\nPaginating with handle {}...", handle); + let mut page = 0; + loop { + page += 1; + let next_result = camera + .run_task(|cam| { + Box::pin(async move { + cam.alarm_video_search_next(handle) + .await + .context("Alarm video search DO/paginate failed") + }) + }) + .await; + match next_result { + Ok(fav) => { + println!("\n--- Page {} ---", page); + println!(" channelId: {:?}", fav.channel_id); + println!(" fileHandle: {:?}", fav.file_handle); + println!(" alarmType: {:?}", fav.alarm_type); + if let Some(ref st) = fav.start_time { + println!(" startTime: {}", format_replay_datetime(st)); + } + if let Some(ref et) = fav.end_time { + println!(" endTime: {}", format_replay_datetime(et)); + } + // If no more data or handle changes, stop + if fav.file_handle.is_none() || fav.file_handle == Some(-1) { + println!("\nEnd of alarm search results."); + break; + } + } + Err(e) => { + // Camera returns non-200 when no more results + log::debug!("Alarm search pagination ended: {}", e); + println!("\nEnd of alarm search results (page {}).", page); + break; + } + } + if page >= 100 { + println!("Reached page limit (100). Stopping."); + break; + } + } + } + } + } + cmdline::ReplayCommand::DownloadByName { + name, + output, + dump_replay, + dump_replay_limit, + } => { + run_download_file_by_name( + &camera, + &name, + output, + dump_replay, + dump_replay_limit, + ) + .await?; + } + cmdline::ReplayCommand::Stop { name } => { + camera + .run_task(|cam| { + let name = name.clone(); + Box::pin( + async move { + cam.replay_stop(&name) + .await + .context("Could not send replay stop to camera") + }, + ) + }) + .await?; + println!("Replay stop sent."); + } + } + + // Force exit to ensure the process terminates even if background tasks keep the runtime alive. + use std::io::Write; + let _ = std::io::stdout().flush(); + let _ = std::io::stderr().flush(); + std::process::exit(0); +} + +fn print_day_records(records: &DayRecords) { + if let Some(ref list) = records.day_type_list { + if list.day_type.is_empty() { + println!("No recording days in range."); + return; + } + println!("Days with recordings:"); + for dt in &list.day_type { + // index is day-in-range (0 = first day). type is e.g. "normal" + println!(" Day index {}: {}", dt.index, dt.type_); + } + } else { + println!( + "This camera does not report which days have recordings.\n\ + Use: neolink replay files --date YYYY-MM-DD to list files for a specific day." + ); + } +} + +/// AI detection tag names from recordType. +const AI_TAGS: &[&str] = &[ + "people", "vehicle", "face", "dog_cat", "package", "visitor", "cry", + "crossline", "intrusion", "loitering", "nonmotorveh", +]; + +fn print_file_list(files: &[FileInfo]) { + if files.is_empty() { + println!("No files for this day."); + return; + } + println!("Files ({}):", files.len()); + for f in files { + let name = f.name.as_deref().unwrap_or("—"); + let size_l = f.size_l.unwrap_or(0); + let size_h = f.size_h.unwrap_or(0); + let size_bytes = size_l as u64 + ((size_h as u64) << 32); + let stream = f.stream_type.as_deref().unwrap_or("—"); + let rec_type = f.record_type.as_deref().unwrap_or(""); + // Split recordType into trigger (md/sched/manual/pir/io) and AI detections + let ai: Vec<&str> = rec_type + .split(',') + .map(|s| s.trim()) + .filter(|s| AI_TAGS.contains(s)) + .collect(); + let ai_str = if ai.is_empty() { + String::new() + } else { + format!(" [AI: {}]", ai.join(", ")) + }; + let size_str = if size_bytes == 0 { + "— ".to_string() + } else if size_bytes < 1024 * 1024 { + format!("{} KB", size_bytes / 1024) + } else { + format!("{} MB", size_bytes / (1024 * 1024)) + }; + println!(" {} {} {} {}{}", name, size_str, stream, rec_type, ai_str); + } +} diff --git a/src/rtsp/factory.rs b/src/rtsp/factory.rs index 9ba058eed..fa631d592 100644 --- a/src/rtsp/factory.rs +++ b/src/rtsp/factory.rs @@ -123,6 +123,9 @@ impl StreamConfig { | BcMedia::Pframe(BcMediaPframe { video_type, .. }) => { self.vid_type = Some(*video_type); } + BcMedia::RawReplayChunk(_) => {} + BcMedia::ReplayStarted(_) => {} // replay-only; RTSP ignores + BcMedia::StreamEnd => {} } } }