use bairelay_mqtt::discovery::Feature;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TlsClientAuth {
#[default]
None,
Request,
#[serde(alias = "required")]
Require,
}
#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum StreamConfig {
None,
#[default]
#[serde(alias = "both")]
All,
Main,
Sub,
Extern,
}
#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DiscoveryMethod {
Local,
Remote,
Map,
#[default]
Relay,
Cellular,
}
#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum MaxEncryption {
None,
#[default]
Aes,
BcEncrypt,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(rename = "bind", default = "default_bind_addr")]
pub bind_addr: String,
#[serde(default = "default_bind_port")]
pub bind_port: u16,
#[serde(default)]
pub certificate: Option<String>,
#[serde(default)]
pub tls_client_auth: TlsClientAuth,
#[serde(default)]
pub tls_bind_port: Option<u16>,
#[serde(default)]
pub tls_client_ca: Option<String>,
#[serde(default)]
pub users: Vec<UserConfig>,
#[serde(default)]
pub mqtt: Option<MqttServerConfig>,
#[serde(default)]
pub wake_server: Option<bairelay_wake_server::WakeServerConfig>,
#[serde(default)]
pub push_listener: Option<PushListenerConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokio_console: Option<bool>,
#[serde(default = "default_prune_grace_secs")]
pub stream_prune_grace_secs: u64,
pub cameras: Vec<CameraConfig>,
}
impl Default for Config {
fn default() -> Self {
Self {
bind_addr: default_bind_addr(),
bind_port: default_bind_port(),
certificate: None,
tls_client_auth: TlsClientAuth::default(),
tls_bind_port: None,
tls_client_ca: None,
users: Vec::new(),
mqtt: None,
wake_server: None,
push_listener: None,
tokio_console: None,
stream_prune_grace_secs: default_prune_grace_secs(),
cameras: Vec::new(),
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PushListenerConfig {
#[serde(default)]
pub enable: bool,
#[serde(default)]
pub push_listener_addr: Option<String>,
#[serde(default = "default_push_listener_port")]
pub push_listener_port: u16,
#[serde(default = "default_motion_wake_hold_secs")]
pub motion_wake_hold_secs: f64,
}
impl Default for PushListenerConfig {
fn default() -> Self {
Self {
enable: false,
push_listener_addr: None,
push_listener_port: default_push_listener_port(),
motion_wake_hold_secs: default_motion_wake_hold_secs(),
}
}
}
fn default_push_listener_port() -> u16 {
443
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct UserConfig {
#[serde(alias = "username")]
pub name: String,
#[serde(alias = "password", default)]
pub pass: String,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct MqttServerConfig {
#[serde(alias = "server")]
pub broker_addr: String,
#[serde(default = "default_mqtt_port")]
pub port: u16,
#[serde(default)]
pub credentials: Option<(String, String)>,
#[serde(default)]
pub ca: Option<String>,
#[serde(default)]
pub client_auth: Option<(String, String)>,
#[serde(default = "default_topic_prefix")]
pub topic_prefix: String,
#[serde(default)]
pub discovery: Option<MqttDiscoveryConfig>,
}
impl Default for MqttServerConfig {
fn default() -> Self {
Self {
broker_addr: String::new(),
port: default_mqtt_port(),
credentials: None,
ca: None,
client_auth: None,
topic_prefix: default_topic_prefix(),
discovery: None,
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct MqttDiscoveryConfig {
pub topic: String,
#[serde(default = "default_features")]
pub features: HashSet<Feature>,
}
fn default_features() -> HashSet<Feature> {
Feature::ALL.iter().copied().collect()
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct CameraConfig {
pub name: String,
#[serde(default)]
pub address: Option<String>,
#[serde(default)]
pub uid: Option<String>,
pub username: String,
#[serde(alias = "pass", default)]
pub password: Option<String>,
#[serde(default, alias = "channel")]
pub channel_id: u8,
#[serde(default)]
pub stream: StreamConfig,
#[serde(default)]
pub discovery: DiscoveryMethod,
#[serde(default)]
pub max_encryption: MaxEncryption,
#[serde(default, alias = "idle", alias = "idle_disc")]
pub idle_disconnect: bool,
#[serde(default)]
pub idle_disconnect_timeout_secs: Option<f64>,
#[serde(default = "default_motion_wake_hold_secs")]
pub motion_wake_hold_secs: f64,
#[serde(default = "default_true", alias = "enable")]
pub enabled: bool,
#[serde(default)]
pub mqtt: MqttConfig,
#[serde(default)]
pub pause: PauseConfig,
#[serde(default)]
pub permitted_users: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "verbose")]
pub debug: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "print")]
pub print_format: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "time")]
pub update_time: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "duration",
alias = "buffer"
)]
pub buffer_duration: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "splash")]
pub use_splash: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "pattern")]
pub splash_pattern: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "retries",
alias = "max_retries"
)]
pub max_discovery_retries: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "push",
alias = "push_noti"
)]
pub push_notifications: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strict: Option<bool>,
}
impl Default for CameraConfig {
fn default() -> Self {
Self {
name: String::new(),
address: None,
uid: None,
username: String::new(),
password: None,
channel_id: 0,
stream: StreamConfig::default(),
discovery: DiscoveryMethod::default(),
max_encryption: MaxEncryption::default(),
idle_disconnect: false,
idle_disconnect_timeout_secs: None,
motion_wake_hold_secs: default_motion_wake_hold_secs(),
enabled: true,
mqtt: MqttConfig::default(),
pause: PauseConfig::default(),
permitted_users: Vec::new(),
debug: None,
print_format: None,
update_time: None,
buffer_duration: None,
use_splash: None,
splash_pattern: None,
max_discovery_retries: None,
push_notifications: None,
strict: None,
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct MqttConfig {
#[serde(default = "default_true")]
pub enable_motion: bool,
#[serde(default = "default_true")]
pub enable_light: bool,
#[serde(default = "default_true")]
pub enable_battery: bool,
#[serde(default = "default_2000")]
pub battery_update: u64,
#[serde(default = "default_true")]
pub enable_preview: bool,
#[serde(default = "default_2000")]
pub preview_update: u64,
#[serde(default)]
pub enable_floodlight: bool,
#[serde(default = "default_2000")]
pub floodlight_update: u64,
#[serde(default)]
pub enable_pir: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discovery: Option<MqttDiscoveryConfig>,
}
impl Default for MqttConfig {
fn default() -> Self {
MqttConfig {
enable_motion: true,
enable_light: true,
enable_battery: true,
battery_update: 2000,
enable_preview: true,
preview_update: 2000,
enable_floodlight: false,
floodlight_update: 2000,
discovery: None,
enable_pir: false,
}
}
}
impl From<&MqttConfig> for bairelay_mqtt::discovery::CameraEnableFlags {
fn from(m: &MqttConfig) -> Self {
Self {
motion: m.enable_motion,
battery: m.enable_battery,
floodlight: m.enable_floodlight,
light: m.enable_light,
pir: m.enable_pir,
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PauseConfig {
#[serde(default = "default_true")]
pub bridge_gaps: bool,
#[serde(default = "default_gap_threshold")]
pub gap_threshold_secs: f64,
#[serde(default = "default_true")]
pub preview_overlay: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_motion: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_client: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_disconnect: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub motion_timeout: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<f64>,
}
impl Default for PauseConfig {
fn default() -> Self {
PauseConfig {
bridge_gaps: true,
gap_threshold_secs: default_gap_threshold(),
preview_overlay: true,
on_motion: None,
on_client: None,
on_disconnect: None,
motion_timeout: None,
mode: None,
timeout: None,
}
}
}
fn default_bind_addr() -> String {
"0.0.0.0".to_string()
}
fn default_bind_port() -> u16 {
8554
}
fn default_mqtt_port() -> u16 {
1883
}
fn default_topic_prefix() -> String {
"bairelay".to_string()
}
const fn default_true() -> bool {
true
}
const fn default_2000() -> u64 {
2000
}
fn default_gap_threshold() -> f64 {
3.0
}
const fn default_prune_grace_secs() -> u64 {
30
}
const fn default_motion_wake_hold_secs() -> f64 {
30.0
}
const TOP_LEVEL_SCALAR_KEYS: &[&str] = &[
"bind",
"bind_addr",
"bind_port",
"certificate",
"tls_bind_port",
"tls_client_auth",
"tls_client_ca",
"tokio_console",
"stream_prune_grace_secs",
];
pub fn validate_top_level_key_placement(value: &toml::Value) -> Result<(), String> {
let toml::Value::Table(root) = value else {
return Ok(());
};
let mut misplaced: Vec<(String, String)> = Vec::new();
for (key, child) in root {
walk_for_misplaced(key, child, &mut misplaced);
}
if misplaced.is_empty() {
return Ok(());
}
let mut msg = String::from(
"config: misplaced top-level keys (TOML scopes scalars to the most-recently-opened table; \
these belong at the document root, before any [section] header):\n",
);
for (path, key) in &misplaced {
msg.push_str(&format!(
" - `{key}` is inside `[{path}]`. Move it to the document root, before the first [section] header.\n",
));
}
Err(msg)
}
fn walk_for_misplaced(path: &str, value: &toml::Value, out: &mut Vec<(String, String)>) {
match value {
toml::Value::Table(t) => {
for (k, v) in t {
match v {
toml::Value::Table(_) => {
walk_for_misplaced(&format!("{path}.{k}"), v, out);
}
toml::Value::Array(arr) => {
for (i, item) in arr.iter().enumerate() {
if matches!(item, toml::Value::Table(_)) {
walk_for_misplaced(&format!("{path}.{k}[{i}]"), item, out);
}
}
}
_ => {
if TOP_LEVEL_SCALAR_KEYS.contains(&k.as_str()) {
out.push((path.to_string(), k.clone()));
}
}
}
}
}
toml::Value::Array(arr) => {
for (i, item) in arr.iter().enumerate() {
if matches!(item, toml::Value::Table(_)) {
walk_for_misplaced(&format!("{path}[{i}]"), item, out);
}
}
}
_ => {}
}
}
pub fn parse_config(toml_str: &str) -> Result<Config, String> {
let value: toml::Value =
toml::from_str(toml_str).map_err(|e| format!("Failed to parse config: {e}"))?;
validate_top_level_key_placement(&value)?;
value
.try_into()
.map_err(|e: toml::de::Error| format!("Failed to parse config: {e}"))
}
pub mod test_helpers {
use super::*;
pub fn minimal_camera_config(name: &str) -> CameraConfig {
CameraConfig {
name: name.to_string(),
address: Some("192.168.1.1:9000".to_string()),
username: "admin".to_string(),
password: Some("test".to_string()),
..Default::default()
}
}
pub fn two_camera_config() -> Config {
Config {
cameras: vec![minimal_camera_config("cam1"), minimal_camera_config("cam2")],
..Default::default()
}
}
}
pub fn warn_deprecated_pause_fields(config: &Config) {
for cam in &config.cameras {
let p = &cam.pause;
if p.on_motion.is_some() {
tracing::warn!(
camera = %cam.name,
"config: [cameras.pause] on_motion is deprecated (neolink migration compat); the motion-driven pause model is replaced by upstream gap-bridging. Remove this field."
);
}
if p.on_client.is_some() {
tracing::warn!(
camera = %cam.name,
"config: [cameras.pause] on_client is deprecated (neolink migration compat). Remove this field."
);
}
if p.on_disconnect.is_some() {
tracing::warn!(
camera = %cam.name,
"config: [cameras.pause] on_disconnect is deprecated (neolink migration compat). Remove this field."
);
}
if p.motion_timeout.is_some() {
tracing::warn!(
camera = %cam.name,
"config: [cameras.pause] motion_timeout is deprecated (neolink migration compat). Remove this field."
);
}
if p.mode.is_some() {
tracing::warn!(
camera = %cam.name,
"config: [cameras.pause] mode is deprecated (neolink migration compat); bairelay's overlay always draws the PreviewState label. Remove this field."
);
}
if let Some(t) = p.timeout {
if cam.idle_disconnect_timeout_secs.is_some() {
tracing::warn!(
camera = %cam.name,
"config: [cameras.pause] timeout is deprecated and overridden by idle_disconnect_timeout_secs; remove this field."
);
} else {
tracing::warn!(
camera = %cam.name,
seconds = t,
"config: [cameras.pause] timeout is deprecated (neolink migration compat); mapped to idle_disconnect_timeout_secs. Move the value under [cameras] to silence this warning."
);
}
}
}
}
pub fn warn_neolink_compat_fields(config: &Config) {
if config.tokio_console.is_some() {
tracing::warn!(
"config: tokio_console is a neolink debugging knob; bairelay drives verbosity via RUST_LOG (or -v / -vv / -vvv on the CLI). Remove this field."
);
}
for cam in &config.cameras {
if cam.debug.is_some() {
tracing::warn!(
camera = %cam.name,
"config: [cameras] debug/verbose is a neolink per-camera flag; bairelay uses a global RUST_LOG (or -v / -vv / -vvv). Remove this field."
);
}
if cam.print_format.is_some() {
tracing::warn!(
camera = %cam.name,
"config: [cameras] print_format is a neolink stdout-dump knob; bairelay uses --dump-bcmedia <dir> on the CLI for fixture capture. Remove this field."
);
}
if cam.update_time.is_some() {
tracing::warn!(
camera = %cam.name,
"config: [cameras] update_time/time is a neolink connect-time clock-sync knob; bairelay exposes this via the `bairelay set-time <camera>` one-shot command. Remove this field."
);
}
if cam.buffer_duration.is_some() {
tracing::warn!(
camera = %cam.name,
"config: [cameras] buffer_duration/duration/buffer is a neolink GStreamer queue knob; bairelay's audio + video pacers handle bursty delivery internally. Remove this field."
);
}
if cam.use_splash.is_some() {
tracing::warn!(
camera = %cam.name,
"config: [cameras] use_splash/splash is a neolink stream-overlay toggle; bairelay's nearest equivalent is `[cameras.pause] preview_overlay` (default true), which captions the MQTT preview JPEG. Remove this field."
);
}
if cam.splash_pattern.is_some() {
tracing::warn!(
camera = %cam.name,
"config: [cameras] splash_pattern/pattern is a neolink GStreamer test-pattern selector; bairelay only renders CONNECTING / SLEEPING captions. Remove this field."
);
}
if cam.max_discovery_retries.is_some() {
tracing::warn!(
camera = %cam.name,
"config: [cameras] max_discovery_retries/retries/max_retries is a neolink discovery cap; bairelay uses exponential backoff with cancellation. Remove this field."
);
}
if cam.push_notifications.is_some() {
tracing::warn!(
camera = %cam.name,
"config: [cameras] push_notifications/push/push_noti is a neolink FCM toggle (deferred — spec §10); bairelay does not consume Reolink push at present. Remove this field."
);
}
if cam.strict.is_some() {
tracing::warn!(
camera = %cam.name,
"config: [cameras] strict is a neolink media-validation toggle; bairelay always validates parameter sets and NAL whitelists. Remove this field."
);
}
if cam.mqtt.discovery.is_some() {
tracing::warn!(
camera = %cam.name,
"config: [cameras.mqtt] discovery is a neolink per-camera HA discovery override; bairelay uses a single global [mqtt.discovery] table. Move topic/features there. Remove this field."
);
}
}
}
pub fn resolve_idle_disconnect_timeout(
cam: &CameraConfig,
prune_grace: std::time::Duration,
) -> std::time::Duration {
let secs = cam
.idle_disconnect_timeout_secs
.or(cam.pause.timeout)
.unwrap_or(45.0);
let configured = std::time::Duration::from_secs_f64(secs);
if configured < prune_grace {
prune_grace + std::time::Duration::from_secs(15)
} else {
configured
}
}
pub fn warn_idle_timeout_below_prune_floor(config: &Config) {
let prune = std::time::Duration::from_secs(config.stream_prune_grace_secs);
let safe_floor = prune + std::time::Duration::from_secs(15);
for cam in &config.cameras {
let secs = cam
.idle_disconnect_timeout_secs
.or(cam.pause.timeout)
.unwrap_or(45.0);
let configured = std::time::Duration::from_secs_f64(secs);
if configured < prune {
tracing::warn!(
camera = %cam.name,
"config: idle_disconnect_timeout_secs ({:.1}s) is shorter than stream_prune_grace_secs ({}s); \
clamped at runtime to {}s so the cached StreamSource cannot outlive the Baichuan session. \
Raise idle_disconnect_timeout_secs (recommended >= {}s) or lower stream_prune_grace_secs.",
configured.as_secs_f64(),
prune.as_secs(),
safe_floor.as_secs(),
safe_floor.as_secs(),
);
}
}
}
pub const DEFAULT_TLS_BIND_PORT: u16 = 8555;
pub fn validate_config(config: &Config) -> Result<(), String> {
if config.bind_port == 0 && config.certificate.is_none() {
return Err(
"bind_port must be greater than 0 unless certificate is set (TLS-only mode)"
.to_string(),
);
}
if config.certificate.is_some() {
let tls_port = config.tls_bind_port.unwrap_or(DEFAULT_TLS_BIND_PORT);
if tls_port == 0 {
return Err("tls_bind_port must be greater than 0".to_string());
}
if config.bind_port != 0 && config.bind_port == tls_port {
return Err(format!(
"bind_port ({}) and tls_bind_port ({}) must differ",
config.bind_port, tls_port
));
}
if matches!(
config.tls_client_auth,
TlsClientAuth::Request | TlsClientAuth::Require
) && config.tls_client_ca.is_none()
{
return Err(format!(
"tls_client_auth = \"{}\" requires tls_client_ca to be set",
match config.tls_client_auth {
TlsClientAuth::Request => "request",
TlsClientAuth::Require => "require",
TlsClientAuth::None => unreachable!(),
}
));
}
} else if matches!(
config.tls_client_auth,
TlsClientAuth::Request | TlsClientAuth::Require
) {
return Err("tls_client_auth requires certificate to be set".to_string());
}
if let Some(ref mqtt) = config.mqtt {
if mqtt.topic_prefix.is_empty() {
return Err("mqtt.topic_prefix must not be empty".to_string());
}
if !mqtt
.topic_prefix
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(format!(
"mqtt.topic_prefix '{}' must contain only ASCII alphanumeric characters, underscores, and hyphens",
mqtt.topic_prefix
));
}
if let Some(ref d) = mqtt.discovery {
if d.topic.is_empty() {
return Err("mqtt.discovery.topic must not be empty".to_string());
}
if d.topic.contains('/') {
return Err(format!(
"mqtt.discovery.topic '{}' must not contain '/'",
d.topic
));
}
}
}
let mut user_names_seen: HashSet<&str> = HashSet::new();
for (i, user) in config.users.iter().enumerate() {
if user.name.trim().is_empty() {
return Err(format!("User #{i}: name must not be empty"));
}
if user.pass.is_empty() {
return Err(format!(
"User '{}' has an empty password; set a non-empty pass or remove the user",
user.name
));
}
if !user_names_seen.insert(user.name.as_str()) {
return Err(format!("Duplicate user name: '{}'", user.name));
}
}
let mut names = HashSet::new();
for (i, cam) in config.cameras.iter().enumerate() {
if cam.name.trim().is_empty() {
return Err(format!("Camera #{}: name must not be empty", i));
}
if !cam
.name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(format!(
"Camera '{}': name must contain only ASCII alphanumeric characters, underscores, and hyphens",
cam.name
));
}
if !names.insert(&cam.name) {
return Err(format!("Duplicate camera name: '{}'", cam.name));
}
if cam.password.is_none() {
return Err(format!("Camera '{}': password is required", cam.name));
}
if cam.address.is_none() && cam.uid.is_none() {
return Err(format!(
"Camera '{}': either address or uid must be provided",
cam.name
));
}
if cam.mqtt.battery_update < 500 {
return Err(format!(
"Camera '{}': battery_update must be >= 500ms, got {}",
cam.name, cam.mqtt.battery_update
));
}
if cam.mqtt.preview_update < 500 {
return Err(format!(
"Camera '{}': preview_update must be >= 500ms, got {}",
cam.name, cam.mqtt.preview_update
));
}
if cam.mqtt.floodlight_update < 500 {
return Err(format!(
"Camera '{}': floodlight_update must be >= 500ms, got {}",
cam.name, cam.mqtt.floodlight_update
));
}
if cam.pause.bridge_gaps
&& (!cam.pause.gap_threshold_secs.is_finite() || cam.pause.gap_threshold_secs <= 0.0)
{
return Err(format!(
"Camera '{}': pause.gap_threshold_secs must be a positive finite number (got {})",
cam.name, cam.pause.gap_threshold_secs,
));
}
if let Some(t) = cam.idle_disconnect_timeout_secs {
if !t.is_finite() || t <= 0.0 {
return Err(format!(
"Camera '{}': idle_disconnect_timeout_secs must be a positive finite number (got {})",
cam.name, t,
));
}
}
if let Some(t) = cam.pause.timeout {
if !t.is_finite() || t <= 0.0 {
return Err(format!(
"Camera '{}': pause.timeout must be a positive finite number (got {})",
cam.name, t,
));
}
}
if !cam.motion_wake_hold_secs.is_finite() || cam.motion_wake_hold_secs < 0.0 {
return Err(format!(
"Camera '{}': motion_wake_hold_secs must be a non-negative finite number (got {})",
cam.name, cam.motion_wake_hold_secs,
));
}
}
if let Some(ref pl) = config.push_listener {
if pl.enable {
if !pl.motion_wake_hold_secs.is_finite() || pl.motion_wake_hold_secs < 0.0 {
return Err(format!(
"[push_listener]: motion_wake_hold_secs must be a non-negative finite number (got {})",
pl.motion_wake_hold_secs,
));
}
if pl.push_listener_port == 0 {
return Err(
"[push_listener]: push_listener_port must be greater than 0".to_string()
);
}
let wake_enabled = config.wake_server.as_ref().is_some_and(|w| w.enable);
if !wake_enabled {
return Err(
"[push_listener] requires [wake_server] enable = true; the push listener resolves peer IPs against the wake server's heartbeat registry"
.to_string(),
);
}
}
}
let user_names: HashSet<&str> = config.users.iter().map(|u| u.name.as_str()).collect();
for camera in &config.cameras {
for pu in &camera.permitted_users {
if !user_names.contains(pu.as_str()) {
return Err(format!(
"camera '{}' permitted_users references unknown user '{}'",
camera.name, pu
));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wake_server_block_parses_with_enable_true() {
let toml = r#"
bind = "0.0.0.0"
bind_port = 8554
[wake_server]
enable = true
[[cameras]]
name = "front"
address = "10.0.0.10"
username = "admin"
password = "x"
"#;
let cfg: Config = toml::from_str(toml).expect("parse");
let ws = cfg.wake_server.expect("wake_server present");
assert!(ws.enable);
assert_eq!(ws.middleman_port, 9999);
}
#[test]
fn wake_server_block_absent_yields_none() {
let toml = r#"
bind = "0.0.0.0"
bind_port = 8554
[[cameras]]
name = "front"
address = "10.0.0.10"
username = "admin"
password = "x"
"#;
let cfg: Config = toml::from_str(toml).expect("parse");
assert!(cfg.wake_server.is_none());
}
#[test]
fn push_listener_block_parses_defaults() {
let toml = r#"
bind = "0.0.0.0"
bind_port = 8554
[push_listener]
enable = true
[[cameras]]
name = "front"
address = "10.0.0.10"
username = "admin"
password = "x"
"#;
let cfg: Config = toml::from_str(toml).expect("parse");
let pl = cfg.push_listener.expect("push_listener present");
assert!(pl.enable);
assert_eq!(pl.push_listener_port, 443);
assert!(pl.push_listener_addr.is_none());
assert!((pl.motion_wake_hold_secs - 30.0).abs() < f64::EPSILON);
}
#[test]
fn push_listener_block_absent_yields_none() {
let toml = r#"
bind = "0.0.0.0"
bind_port = 8554
[[cameras]]
name = "front"
address = "10.0.0.10"
username = "admin"
password = "x"
"#;
let cfg: Config = toml::from_str(toml).expect("parse");
assert!(cfg.push_listener.is_none());
}
#[test]
fn push_listener_overrides_addr_and_port() {
let toml = r#"
bind = "0.0.0.0"
bind_port = 8554
[push_listener]
enable = true
push_listener_addr = "10.0.0.5"
push_listener_port = 8443
motion_wake_hold_secs = 12.5
[[cameras]]
name = "front"
address = "10.0.0.10"
username = "admin"
password = "x"
"#;
let cfg: Config = toml::from_str(toml).expect("parse");
let pl = cfg.push_listener.expect("push_listener present");
assert_eq!(pl.push_listener_addr.as_deref(), Some("10.0.0.5"));
assert_eq!(pl.push_listener_port, 8443);
assert!((pl.motion_wake_hold_secs - 12.5).abs() < f64::EPSILON);
}
#[test]
fn push_listener_rejects_unknown_field() {
let toml = r#"
bind = "0.0.0.0"
bind_port = 8554
[push_listener]
enable = true
totally_made_up = "x"
[[cameras]]
name = "front"
address = "10.0.0.10"
username = "admin"
password = "x"
"#;
assert!(toml::from_str::<Config>(toml).is_err());
}
}