diff --git a/crates/core/src/bc/model.rs b/crates/core/src/bc/model.rs index a1e1cf27..af66d302 100644 --- a/crates/core/src/bc/model.rs +++ b/crates/core/src/bc/model.rs @@ -40,6 +40,10 @@ pub const MSG_ID_GET_SERVICE_PORTS: u32 = 37; pub const MSG_ID_GET_EMAIL: u32 = 42; /// Set email settings pub const MSG_ID_SET_EMAIL: u32 = 43; +/// Get compression config +pub const MSG_ID_GET_COMPRESSION: u32 = 56; +/// Set compression config +pub const MSG_ID_SET_COMPRESSION: u32 = 57; /// Get users and general system info pub const MSG_ID_GET_ABILITY_SUPPORT: u32 = 58; /// Update, create and remove users diff --git a/crates/core/src/bc/xml.rs b/crates/core/src/bc/xml.rs index 16048f58..508cf8c8 100644 --- a/crates/core/src/bc/xml.rs +++ b/crates/core/src/bc/xml.rs @@ -141,6 +141,9 @@ pub struct BcXml { /// Read and write users #[serde(rename = "UserList", skip_serializing_if = "Option::is_none")] pub user_list: Option, + /// Compression/encoding settings + #[serde(rename = "Compression", skip_serializing_if = "Option::is_none")] + pub compression: Option, } impl BcXml { @@ -423,6 +426,64 @@ pub struct LedState { pub light_state: String, } +/// GOP (group of pictures) settings +#[allow(missing_docs)] +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize, Clone)] +pub struct GopSettings { + #[serde(skip_serializing_if = "Option::is_none")] + pub cur: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub min: Option, +} + +/// Per-stream compression/encoding settings +#[allow(missing_docs)] +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize, Clone)] +pub struct StreamCompression { + #[serde(skip_serializing_if = "Option::is_none")] + pub audio: Option, + #[serde(rename = "resolutionName", skip_serializing_if = "Option::is_none")] + pub resolution_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub width: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub height: Option, + /// Rate control mode (e.g. "cbr", "vbr") + #[serde(rename = "encoderType", skip_serializing_if = "Option::is_none")] + pub encoder_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub frame: Option, + /// Bitrate in kbps + #[serde(rename = "bitRate", skip_serializing_if = "Option::is_none")] + pub bit_rate: Option, + #[serde(rename = "encoderProfile", skip_serializing_if = "Option::is_none")] + pub encoder_profile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gop: Option, + #[serde(rename = "videoEncType", skip_serializing_if = "Option::is_none")] + pub video_enc_type: Option, +} + +/// Compression/encoding settings (MSG 56/57) +#[allow(missing_docs)] +#[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize, Clone)] +pub struct Compression { + #[serde(rename = "@version")] + pub version: String, + #[serde(rename = "channelId")] + pub channel_id: u8, + #[serde(rename = "isNoTranslateFrame", skip_serializing_if = "Option::is_none")] + pub is_no_translate_frame: Option, + #[serde(rename = "mainStream", skip_serializing_if = "Option::is_none")] + pub main_stream: Option, + #[serde(rename = "subStream", skip_serializing_if = "Option::is_none")] + pub sub_stream: Option, + #[serde(rename = "thirdStream", skip_serializing_if = "Option::is_none")] + pub third_stream: Option, +} + /// FloodlightStatus xml #[derive(PartialEq, Eq, Default, Debug, Deserialize, Serialize, Clone)] pub struct FloodlightStatus { diff --git a/crates/core/src/bc_protocol.rs b/crates/core/src/bc_protocol.rs index b3ad9d90..4190d939 100644 --- a/crates/core/src/bc_protocol.rs +++ b/crates/core/src/bc_protocol.rs @@ -14,6 +14,7 @@ use Md5Trunc::*; mod abilityinfo; mod battery; +mod compression; mod connection; mod credentials; mod email; diff --git a/crates/core/src/bc_protocol/compression.rs b/crates/core/src/bc_protocol/compression.rs new file mode 100644 index 00000000..72d173b2 --- /dev/null +++ b/crates/core/src/bc_protocol/compression.rs @@ -0,0 +1,107 @@ +use super::{BcCamera, Error, Result}; +use crate::bc::{model::*, xml::*}; + +impl BcCamera { + /// Get the current compression/encoding settings + pub async fn get_compression(&self) -> Result { + self.has_ability_ro("compress").await?; + let connection = self.get_connection(); + let msg_num = self.new_message_num(); + let mut sub_get = connection.subscribe(MSG_ID_GET_COMPRESSION, msg_num).await?; + let get = Bc { + meta: BcMeta { + msg_id: MSG_ID_GET_COMPRESSION, + channel_id: self.channel_id, + msg_num, + response_code: 0, + stream_type: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: Some(Extension { + channel_id: Some(self.channel_id), + ..Default::default() + }), + payload: None, + }), + }; + + sub_get.send(get).await?; + let msg = sub_get.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 { + compression: Some(compression), + .. + })), + .. + }) = msg.body + { + Ok(compression) + } else { + Err(Error::UnintelligibleReply { + reply: std::sync::Arc::new(Box::new(msg)), + why: "Expected Compression xml but it was not received", + }) + } + } + + /// Set compression/encoding settings + pub async fn set_compression(&self, compression: Compression) -> Result<()> { + self.has_ability_rw("compress").await?; + let connection = self.get_connection(); + + let msg_num = self.new_message_num(); + let mut sub_set = connection.subscribe(MSG_ID_SET_COMPRESSION, msg_num).await?; + + let set = Bc { + meta: BcMeta { + msg_id: MSG_ID_SET_COMPRESSION, + channel_id: self.channel_id, + msg_num, + response_code: 0, + stream_type: 0, + class: 0x6414, + }, + body: BcBody::ModernMsg(ModernMsg { + extension: Some(Extension { + channel_id: Some(self.channel_id), + ..Default::default() + }), + payload: Some(BcPayloads::BcXml(BcXml { + compression: Some(compression), + ..Default::default() + })), + }), + }; + + sub_set.send(set).await?; + if let Ok(reply) = + tokio::time::timeout(tokio::time::Duration::from_millis(500), sub_set.recv()).await + { + let msg = reply?; + + if let BcMeta { + response_code: 200, .. + } = msg.meta + { + Ok(()) + } else { + Err(Error::UnintelligibleReply { + reply: std::sync::Arc::new(Box::new(msg)), + why: "The camera did not accept the Compression xml", + }) + } + } else { + // Some cameras seem to just not send a reply on success + Ok(()) + } + } +} diff --git a/src/cmdline.rs b/src/cmdline.rs index 693c5b4e..1597181f 100644 --- a/src/cmdline.rs +++ b/src/cmdline.rs @@ -33,4 +33,5 @@ pub enum Command { Battery(super::battery::Opt), Services(super::services::Opt), Users(super::users::Opt), + Encoding(super::encoding::Opt), } diff --git a/src/encoding/cmdline.rs b/src/encoding/cmdline.rs new file mode 100644 index 00000000..95431cd8 --- /dev/null +++ b/src/encoding/cmdline.rs @@ -0,0 +1,75 @@ +use clap::{Parser, Subcommand, ValueEnum}; + +/// Control video encoding settings per stream +#[derive(Parser, Debug)] +pub struct Opt { + /// The name of the camera. Must be a name in the config + pub camera: String, + #[command(subcommand)] + pub cmd: EncodingAction, +} + +#[derive(Subcommand, Debug)] +pub enum EncodingAction { + /// Get the current encoding settings for all streams + Get, + /// Set encoding parameters for a stream + Set { + /// Which stream to modify + #[arg(long)] + stream: StreamName, + /// Bitrate in kbps (e.g. 4096) + #[arg(long)] + bitrate: Option, + /// Frames per second (e.g. 15) + #[arg(long)] + fps: Option, + /// Rate control mode + #[arg(long)] + rate_control: Option, + /// Encoder profile + #[arg(long)] + profile: Option, + }, +} + +#[derive(Debug, Clone, ValueEnum)] +pub enum StreamName { + Main, + Sub, + Third, +} + +#[derive(Debug, Clone, ValueEnum)] +pub enum RateControl { + Cbr, + Vbr, +} + +#[derive(Debug, Clone, ValueEnum)] +pub enum Profile { + Default, + BaseLine, + High, + Main, +} + +impl std::fmt::Display for RateControl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RateControl::Cbr => write!(f, "cbr"), + RateControl::Vbr => write!(f, "vbr"), + } + } +} + +impl std::fmt::Display for Profile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Profile::Default => write!(f, "default"), + Profile::BaseLine => write!(f, "baseLine"), + Profile::High => write!(f, "high"), + Profile::Main => write!(f, "main"), + } + } +} diff --git a/src/encoding/mod.rs b/src/encoding/mod.rs new file mode 100644 index 00000000..3f5a610f --- /dev/null +++ b/src/encoding/mod.rs @@ -0,0 +1,118 @@ +use anyhow::{Context, Result}; +use neolink_core::bc::xml::StreamCompression; + +mod cmdline; + +use crate::common::NeoReactor; +pub(crate) use cmdline::Opt; +use cmdline::{EncodingAction, StreamName}; + +fn print_stream(name: &str, stream: &Option) { + match stream { + Some(s) => { + println!( + " {:<8} {:>5}x{:<5} {:>6} kbps {:>3} fps {:<4} {}", + name, + s.width.unwrap_or(0), + s.height.unwrap_or(0), + s.bit_rate.unwrap_or(0), + s.frame.unwrap_or(0), + s.encoder_type.as_deref().unwrap_or("-"), + s.encoder_profile.as_deref().unwrap_or("-"), + ); + } + None => { + println!(" {:<8} (not available)", name); + } + } +} + +pub(crate) async fn main(opt: Opt, reactor: NeoReactor) -> Result<()> { + let camera = reactor.get(&opt.camera).await?; + + match opt.cmd { + EncodingAction::Get => { + let compression = camera + .run_task(|cam| { + Box::pin(async move { + cam.get_compression() + .await + .context("Unable to get encoding settings") + }) + }) + .await?; + + println!("Encoding settings (channel {}):", compression.channel_id); + println!( + " {:<8} {:>10} {:>10} {:>7} {:<4} {}", + "Stream", "Resolution", "Bitrate", "FPS", "Type", "Profile" + ); + println!(" {}", "-".repeat(64)); + print_stream("main", &compression.main_stream); + print_stream("sub", &compression.sub_stream); + print_stream("third", &compression.third_stream); + } + EncodingAction::Set { + stream, + bitrate, + fps, + rate_control, + profile, + } => { + if bitrate.is_none() + && fps.is_none() + && rate_control.is_none() + && profile.is_none() + { + anyhow::bail!( + "At least one of --bitrate, --fps, --rate-control, or --profile must be specified" + ); + } + + let stream_name = stream.clone(); + camera + .run_task(move |cam| { + let rate_control = rate_control.clone(); + let profile = profile.clone(); + let stream = stream_name.clone(); + Box::pin(async move { + let mut compression = cam + .get_compression() + .await + .context("Unable to get current encoding settings")?; + + let target = match stream { + StreamName::Main => &mut compression.main_stream, + StreamName::Sub => &mut compression.sub_stream, + StreamName::Third => &mut compression.third_stream, + }; + + let s = target.get_or_insert_with(Default::default); + + if let Some(br) = bitrate { + s.bit_rate = Some(br); + } + if let Some(f) = fps { + s.frame = Some(f); + } + if let Some(ref rc) = rate_control { + s.encoder_type = Some(rc.to_string()); + } + if let Some(ref p) = profile { + s.encoder_profile = Some(p.to_string()); + } + + cam.set_compression(compression) + .await + .context("Unable to set encoding settings")?; + + println!("Encoding settings updated successfully."); + Ok(()) + }) + }) + .await?; + } + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index e143d05e..32cb539d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,6 +40,7 @@ mod battery; mod cmdline; mod common; mod config; +mod encoding; #[cfg(feature = "gstreamer")] mod image; mod mqtt; @@ -147,6 +148,9 @@ async fn main() -> Result<()> { Some(Command::Users(opts)) => { users::main(opts, neo_reactor.clone()).await?; } + Some(Command::Encoding(opts)) => { + encoding::main(opts, neo_reactor.clone()).await?; + } } Ok(())