use std::path::PathBuf;
use clap::{Parser, Subcommand};
const HELP_TEMPLATE: &str = "\
{name} {version} — {about-with-newline}
{usage-heading} {usage}
Service modes:
mqtt Run the MQTT bridge only
rtsp Run the RTSP server only
mqtt-rtsp Run both the MQTT bridge and RTSP server
Camera commands:
reboot Reboot one camera
snapshot Capture a JPEG (or H.264/265 with --use-stream-raw) (alias: image)
battery Print battery status
floodlight Query or toggle the floodlight (held 30 s on set)
pir Query or set the PIR sensor
status-light Query or toggle the blue status LED
ptz Pan / tilt / zoom / preset control
presets List PTZ presets (shorthand for `ptz preset`)
services Query or configure camera network services (bare form: list all)
users List or manage camera user accounts
set-time Set the camera clock to the host's current local time
version Print firmware + model info
siren Trigger the siren once
abilities Dump the camera's abilityInfo XML + parsed permissions
Other:
check-config Validate the config file and exit (no camera connection)
help Print help for the given subcommand
Options:
{options}\
";
#[derive(Debug, Parser)]
#[command(
name = "bairelay",
version = env!("CARGO_PKG_VERSION"),
about,
help_template = HELP_TEMPLATE,
arg_required_else_help = true,
)]
pub struct Cli {
#[arg(
short = 'c',
long = "config",
global = true,
default_value = "config.toml"
)]
pub config: PathBuf,
#[arg(long, global = true)]
pub json: bool,
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)]
pub verbose: u8,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum FloodlightState {
On,
Off,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum PtzDirection {
Up,
Down,
Left,
Right,
Stop,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum ServiceName {
Baichuan,
Http,
Https,
Rtmp,
Rtsp,
Onvif,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum UserTypeArg {
User,
Administrator,
}
#[derive(Debug, Subcommand)]
pub enum PtzCommand {
Preset { preset_id: Option<u8> },
Assign { preset_id: u8, name: String },
Control {
amount: u32,
direction: PtzDirection,
speed: Option<u32>,
},
Zoom { amount: f32 },
}
#[derive(Debug, Subcommand)]
pub enum ServiceAction {
Get,
On,
Off,
Port { port: u16 },
Set { port: u16, enabled: OnOff },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum OnOff {
On,
Off,
}
#[derive(Debug, Subcommand)]
pub enum UserAction {
List,
Add {
name: String,
user_type: UserTypeArg,
password: Option<String>,
},
Password {
name: String,
password: Option<String>,
},
Delete { name: String },
}
#[derive(Debug, clap::Args)]
pub struct OneShotArgs {
pub camera: String,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Mqtt,
Rtsp {
#[arg(long = "dump-bcmedia", value_name = "DIR")]
dump_bcmedia: Option<PathBuf>,
},
MqttRtsp {
#[arg(long = "dump-bcmedia", value_name = "DIR")]
dump_bcmedia: Option<PathBuf>,
},
Reboot(OneShotArgs),
#[command(alias = "image")]
Snapshot {
#[command(flatten)]
common: OneShotArgs,
#[arg(short = 'f', long = "output", visible_alias = "file-path")]
output: Option<PathBuf>,
#[arg(long = "use-stream", conflicts_with = "use_stream_raw")]
use_stream: bool,
#[arg(long = "use-stream-raw")]
use_stream_raw: bool,
},
Battery(OneShotArgs),
Floodlight {
#[command(flatten)]
common: OneShotArgs,
#[arg(value_enum)]
state: Option<FloodlightState>,
},
Presets(OneShotArgs),
SetTime(OneShotArgs),
Version(OneShotArgs),
Siren(OneShotArgs),
Abilities(OneShotArgs),
Pir {
#[command(flatten)]
common: OneShotArgs,
#[arg(value_enum)]
state: Option<OnOff>,
},
#[command(name = "status-light")]
StatusLight {
#[command(flatten)]
common: OneShotArgs,
#[arg(value_enum)]
state: Option<OnOff>,
},
Ptz {
#[command(flatten)]
common: OneShotArgs,
#[command(subcommand)]
cmd: Option<PtzCommand>,
},
Services {
#[command(flatten)]
common: OneShotArgs,
service: Option<ServiceName>,
#[command(subcommand)]
action: Option<ServiceAction>,
},
Users {
#[command(flatten)]
common: OneShotArgs,
#[command(subcommand)]
action: Option<UserAction>,
},
#[command(name = "check-config")]
CheckConfig,
#[command(hide = true)]
RenderHassioConfig {
#[arg(long = "options-json", value_name = "PATH")]
options_json: PathBuf,
#[arg(long = "overlay", value_name = "PATH")]
overlay: Option<PathBuf>,
#[arg(long = "mqtt-host")]
mqtt_host: Option<String>,
#[arg(long = "mqtt-port")]
mqtt_port: Option<u16>,
#[arg(long = "mqtt-user")]
mqtt_user: Option<String>,
#[arg(long = "mqtt-pass")]
mqtt_pass: Option<String>,
#[arg(long = "mqtt-ssl", default_value = "false")]
mqtt_ssl: bool,
#[arg(long = "output", value_name = "PATH")]
output: Option<PathBuf>,
},
}
impl Cli {
pub fn config_path(&self) -> &std::path::Path {
&self.config
}
pub fn dump_bcmedia_path(&self) -> Option<&std::path::Path> {
match &self.command {
Command::Rtsp { dump_bcmedia, .. } => dump_bcmedia.as_deref(),
Command::MqttRtsp { dump_bcmedia, .. } => dump_bcmedia.as_deref(),
Command::Mqtt
| Command::Reboot(_)
| Command::Snapshot { .. }
| Command::Battery(_)
| Command::Floodlight { .. }
| Command::Presets(_)
| Command::SetTime(_)
| Command::Version(_)
| Command::Siren(_)
| Command::Abilities(_)
| Command::Pir { .. }
| Command::StatusLight { .. }
| Command::Ptz { .. }
| Command::Services { .. }
| Command::Users { .. }
| Command::CheckConfig
| Command::RenderHassioConfig { .. } => None,
}
}
pub fn is_check_config(&self) -> bool {
matches!(self.command, Command::CheckConfig)
}
pub fn wants_mqtt(&self) -> bool {
matches!(&self.command, Command::Mqtt | Command::MqttRtsp { .. })
}
pub fn wants_rtsp(&self) -> bool {
matches!(
&self.command,
Command::Rtsp { .. } | Command::MqttRtsp { .. }
)
}
pub fn is_oneshot(&self) -> bool {
!matches!(
self.command,
Command::Mqtt | Command::Rtsp { .. } | Command::MqttRtsp { .. }
)
}
pub fn camera_name(&self) -> Option<&str> {
match &self.command {
Command::Mqtt
| Command::Rtsp { .. }
| Command::MqttRtsp { .. }
| Command::CheckConfig
| Command::RenderHassioConfig { .. } => None,
Command::Reboot(a)
| Command::Battery(a)
| Command::Presets(a)
| Command::SetTime(a)
| Command::Version(a)
| Command::Siren(a)
| Command::Abilities(a) => Some(&a.camera),
Command::Snapshot { common, .. }
| Command::Floodlight { common, .. }
| Command::Pir { common, .. }
| Command::StatusLight { common, .. }
| Command::Ptz { common, .. }
| Command::Services { common, .. }
| Command::Users { common, .. } => Some(&common.camera),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn parse_mqtt_only_mode() {
let cli = Cli::try_parse_from(["bairelay", "mqtt", "-c", "foo.toml"]).unwrap();
assert!(cli.wants_mqtt());
assert!(!cli.wants_rtsp());
assert_eq!(cli.config_path(), std::path::Path::new("foo.toml"));
assert_eq!(cli.dump_bcmedia_path(), None);
}
#[test]
fn parse_rtsp_only_mode_with_dump() {
let cli = Cli::try_parse_from([
"bairelay",
"rtsp",
"-c",
"bar.toml",
"--dump-bcmedia",
"/tmp/dump",
])
.unwrap();
assert!(!cli.wants_mqtt());
assert!(cli.wants_rtsp());
assert_eq!(cli.config_path(), std::path::Path::new("bar.toml"));
assert_eq!(
cli.dump_bcmedia_path(),
Some(std::path::Path::new("/tmp/dump"))
);
}
#[test]
fn parse_mqtt_rtsp_combined() {
let cli = Cli::try_parse_from(["bairelay", "mqtt-rtsp"]).unwrap();
assert!(cli.wants_mqtt());
assert!(cli.wants_rtsp());
assert_eq!(cli.config_path(), std::path::Path::new("config.toml"));
assert_eq!(cli.dump_bcmedia_path(), None);
}
#[test]
fn dump_bcmedia_path_is_none_for_mqtt_only() {
let cli = Cli::try_parse_from(["bairelay", "mqtt"]).unwrap();
assert!(cli.dump_bcmedia_path().is_none());
}
#[test]
fn parse_reboot_subcommand() {
let cli = Cli::try_parse_from(["bairelay", "reboot", "-c", "c.toml", "driveway"]).unwrap();
let Command::Reboot(args) = &cli.command else {
panic!("wrong variant")
};
assert_eq!(args.camera, "driveway");
assert_eq!(cli.config_path(), std::path::Path::new("c.toml"));
let cli = Cli::try_parse_from(["bairelay", "-c", "c.toml", "reboot", "driveway"]).unwrap();
assert_eq!(cli.config_path(), std::path::Path::new("c.toml"));
let Command::Reboot(args) = &cli.command else {
panic!("wrong variant")
};
assert_eq!(args.camera, "driveway");
}
#[test]
fn parse_image_is_alias_for_snapshot() {
let cli = Cli::try_parse_from(["bairelay", "image", "driveway", "--output", "/tmp/x.jpg"])
.unwrap();
let Command::Snapshot {
common,
output,
use_stream,
use_stream_raw,
} = &cli.command
else {
panic!("wrong variant — expected Snapshot")
};
assert_eq!(common.camera, "driveway");
assert_eq!(output.as_deref(), Some(std::path::Path::new("/tmp/x.jpg")));
assert!(!use_stream);
assert!(!use_stream_raw);
}
#[test]
fn parse_snapshot_with_output() {
let cli = Cli::try_parse_from([
"bairelay",
"-c",
"c.toml",
"snapshot",
"driveway",
"--output",
"/tmp/s.jpg",
])
.unwrap();
let Command::Snapshot { common, output, .. } = &cli.command else {
panic!("wrong variant")
};
assert_eq!(common.camera, "driveway");
assert_eq!(output.as_deref(), Some(std::path::Path::new("/tmp/s.jpg")));
assert_eq!(cli.config_path(), std::path::Path::new("c.toml"));
}
#[test]
fn parse_snapshot_file_path_alias() {
for arg in ["-f", "--file-path"] {
let cli = Cli::try_parse_from(["bairelay", "snapshot", "driveway", arg, "/tmp/x.jpg"])
.unwrap();
let Command::Snapshot { output, .. } = &cli.command else {
panic!("wrong variant")
};
assert_eq!(
output.as_deref(),
Some(std::path::Path::new("/tmp/x.jpg")),
"flag {} should parse as --output",
arg
);
}
}
#[test]
fn parse_snapshot_use_stream_is_noop_flag() {
let cli = Cli::try_parse_from([
"bairelay",
"snapshot",
"driveway",
"--use-stream",
"-f",
"/tmp/x.jpg",
])
.unwrap();
let Command::Snapshot {
use_stream,
use_stream_raw,
..
} = &cli.command
else {
panic!("wrong variant")
};
assert!(use_stream);
assert!(!use_stream_raw);
}
#[test]
fn parse_snapshot_use_stream_raw_emits_nal_bytes() {
let cli = Cli::try_parse_from([
"bairelay",
"snapshot",
"driveway",
"--use-stream-raw",
"-f",
"/tmp/x.h265",
])
.unwrap();
let Command::Snapshot {
output,
use_stream,
use_stream_raw,
..
} = &cli.command
else {
panic!("wrong variant")
};
assert!(!use_stream);
assert!(use_stream_raw);
assert_eq!(output.as_deref(), Some(std::path::Path::new("/tmp/x.h265")));
}
#[test]
fn parse_snapshot_conflicting_stream_flags_errors() {
let err = Cli::try_parse_from([
"bairelay",
"snapshot",
"driveway",
"--use-stream",
"--use-stream-raw",
"-f",
"/tmp/x",
])
.unwrap_err();
assert!(
err.to_string().contains("use-stream"),
"expected conflict hint, got: {}",
err
);
}
#[test]
fn parse_battery_json_and_verbose() {
let cli =
Cli::try_parse_from(["bairelay", "-vv", "--json", "battery", "driveway"]).unwrap();
assert!(cli.json);
assert_eq!(cli.verbose, 2);
let Command::Battery(args) = &cli.command else {
panic!("wrong variant")
};
assert_eq!(args.camera, "driveway");
}
#[test]
fn parse_floodlight_read_and_set() {
let cli = Cli::try_parse_from(["bairelay", "floodlight", "driveway"]).unwrap();
let Command::Floodlight { common, state } = &cli.command else {
panic!("wrong variant")
};
assert_eq!(common.camera, "driveway");
assert!(state.is_none());
let cli = Cli::try_parse_from(["bairelay", "floodlight", "driveway", "on"]).unwrap();
let Command::Floodlight { state, .. } = &cli.command else {
panic!("wrong variant")
};
assert!(matches!(state, Some(FloodlightState::On)));
}
#[test]
fn parse_all_oneshots_default_config() {
let cases: &[&[&str]] = &[
&["reboot", "driveway"],
&["snapshot", "driveway"],
&["battery", "driveway"],
&["floodlight", "driveway", "on"],
&["presets", "driveway"],
&["set-time", "driveway"],
&["version", "driveway"],
&["siren", "driveway"],
&["abilities", "driveway"],
];
for args in cases {
let mut full = vec!["bairelay"];
full.extend_from_slice(args);
let cli = Cli::try_parse_from(full).unwrap();
assert_eq!(
cli.config_path(),
std::path::Path::new("config.toml"),
"default config for {:?}",
args
);
}
}
#[test]
fn is_oneshot_true_for_new_variants_only() {
let cli = Cli::try_parse_from(["bairelay", "reboot", "driveway"]).unwrap();
assert!(cli.is_oneshot());
let cli = Cli::try_parse_from(["bairelay", "mqtt-rtsp"]).unwrap();
assert!(!cli.is_oneshot());
}
#[test]
fn parse_pir_read_and_set() {
let cli = Cli::try_parse_from(["bairelay", "pir", "driveway"]).unwrap();
let Command::Pir { common, state } = &cli.command else {
panic!("wrong variant")
};
assert_eq!(common.camera, "driveway");
assert!(state.is_none());
let cli = Cli::try_parse_from(["bairelay", "pir", "driveway", "on"]).unwrap();
let Command::Pir { state, .. } = &cli.command else {
panic!("wrong variant")
};
assert!(matches!(state, Some(OnOff::On)));
}
#[test]
fn parse_status_light_read_and_set() {
let cli = Cli::try_parse_from(["bairelay", "status-light", "driveway"]).unwrap();
assert!(matches!(cli.command, Command::StatusLight { .. }));
let cli = Cli::try_parse_from(["bairelay", "status-light", "driveway", "off"]).unwrap();
let Command::StatusLight { state, .. } = &cli.command else {
panic!("wrong variant")
};
assert!(matches!(state, Some(OnOff::Off)));
}
#[test]
fn parse_ptz_tree() {
let cli = Cli::try_parse_from(["bairelay", "ptz", "driveway"]).unwrap();
let Command::Ptz { cmd, .. } = &cli.command else {
panic!("wrong variant")
};
assert!(cmd.is_none());
let cli = Cli::try_parse_from(["bairelay", "ptz", "driveway", "preset", "3"]).unwrap();
let Command::Ptz { cmd, .. } = &cli.command else {
panic!("wrong variant")
};
assert!(matches!(
cmd,
Some(PtzCommand::Preset { preset_id: Some(3) })
));
let cli =
Cli::try_parse_from(["bairelay", "ptz", "driveway", "assign", "2", "home"]).unwrap();
let Command::Ptz { cmd, .. } = &cli.command else {
panic!("wrong variant")
};
let Some(PtzCommand::Assign { preset_id, name }) = cmd else {
panic!("wrong ptz cmd")
};
assert_eq!(*preset_id, 2);
assert_eq!(name, "home");
let cli =
Cli::try_parse_from(["bairelay", "ptz", "driveway", "control", "32", "left"]).unwrap();
let Command::Ptz { cmd, .. } = &cli.command else {
panic!("wrong variant")
};
let Some(PtzCommand::Control {
amount, direction, ..
}) = cmd
else {
panic!("wrong ptz cmd")
};
assert_eq!(*amount, 32);
assert!(matches!(direction, PtzDirection::Left));
let cli = Cli::try_parse_from(["bairelay", "ptz", "driveway", "zoom", "0.5"]).unwrap();
let Command::Ptz { cmd, .. } = &cli.command else {
panic!("wrong variant")
};
let Some(PtzCommand::Zoom { amount }) = cmd else {
panic!("wrong ptz cmd")
};
assert!((*amount - 0.5).abs() < 1e-6);
}
#[test]
fn parse_services_tree() {
let cli = Cli::try_parse_from(["bairelay", "services", "driveway"]).unwrap();
let Command::Services {
service, action, ..
} = &cli.command
else {
panic!("wrong variant")
};
assert!(service.is_none());
assert!(action.is_none());
let cli = Cli::try_parse_from(["bairelay", "services", "driveway", "http"]).unwrap();
let Command::Services {
service, action, ..
} = &cli.command
else {
panic!("wrong variant")
};
assert!(matches!(service, Some(ServiceName::Http)));
assert!(action.is_none());
let cli = Cli::try_parse_from(["bairelay", "services", "driveway", "http", "get"]).unwrap();
let Command::Services {
service, action, ..
} = &cli.command
else {
panic!("wrong variant")
};
assert!(matches!(service, Some(ServiceName::Http)));
assert!(matches!(action, Some(ServiceAction::Get)));
let cli = Cli::try_parse_from([
"bairelay", "services", "driveway", "rtsp", "set", "554", "on",
])
.unwrap();
let Command::Services { action, .. } = &cli.command else {
panic!("wrong variant")
};
let Some(ServiceAction::Set { port, enabled }) = action else {
panic!("wrong service action")
};
assert_eq!(*port, 554);
assert!(matches!(enabled, OnOff::On));
}
#[test]
fn parse_users_tree() {
let cli = Cli::try_parse_from(["bairelay", "users", "driveway"]).unwrap();
let Command::Users { action, .. } = &cli.command else {
panic!("wrong variant")
};
assert!(action.is_none());
let cli = Cli::try_parse_from(["bairelay", "users", "driveway", "list"]).unwrap();
let Command::Users { action, .. } = &cli.command else {
panic!("wrong variant")
};
assert!(matches!(action, Some(UserAction::List)));
let cli = Cli::try_parse_from([
"bairelay", "users", "driveway", "add", "bob", "user", "p4ss",
])
.unwrap();
let Command::Users { action, .. } = &cli.command else {
panic!("wrong variant")
};
let Some(UserAction::Add {
name,
password,
user_type,
}) = action
else {
panic!("wrong user action")
};
assert_eq!(name, "bob");
assert_eq!(password.as_deref(), Some("p4ss"));
assert!(matches!(user_type, UserTypeArg::User));
let cli =
Cli::try_parse_from(["bairelay", "users", "driveway", "add", "bob", "user"]).unwrap();
let Command::Users { action, .. } = &cli.command else {
panic!("wrong variant")
};
let Some(UserAction::Add { name, password, .. }) = action else {
panic!("wrong user action")
};
assert_eq!(name, "bob");
assert!(
password.is_none(),
"omitted password must parse to None; got {password:?}"
);
}
#[test]
fn help_template_lists_all_subcommands_grouped() {
let mut buf = Vec::new();
use clap::CommandFactory;
Cli::command().write_help(&mut buf).unwrap();
let help = String::from_utf8(buf).unwrap();
assert!(help.contains("Service modes:"));
assert!(help.contains("Camera commands:"));
for cmd in [
"mqtt",
"rtsp",
"mqtt-rtsp",
"reboot",
"snapshot",
"battery",
"floodlight",
"pir",
"status-light",
"ptz",
"presets",
"services",
"users",
"set-time",
"version",
"siren",
"abilities",
] {
assert!(
help.contains(cmd),
"help missing subcommand `{}`:\n{}",
cmd,
help
);
}
}
}