diff --git a/README.md b/README.md index 316f653c1..845c1943c 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,9 @@ Status Messages: pir status - `/status/motion` Contains the motion detection alarm status. `on` for motion and `off` for still, only published when `enable_moton` is true in the config +- `/status/doorbell` Contains the doorbell button status. `pressed` when doorbell + is pressed, `idle` otherwise. Only published when `enable_doorbell` is true + in the config (default: true) - `/status/ptz/preset` Sent in reply to a `/query/ptz/preset` an XML encoded version of the PTZ presets - `/status/preview` a base64 encoded camera image updated every 2s. Not @@ -325,6 +328,8 @@ enable_motion = false # motion detection # (limited battery drain since it # is a passive listening connection) # +enable_doorbell = false # doorbell button events + # enable_light = false # flood lights only available on some camera # (limited battery drain since it # is a passive listening connection) @@ -368,6 +373,7 @@ Available features are: - `ir`: This adds a selection switch to chage the IR light on/off/auto to home assistant - `motion`: This adds a motion detection binary sensor to home assistant +- `doorbell`: This adds a doorbell binary sensor to home assistant - `reboot`: This adds a reboot button to home assistant - `pt`: This adds a selection of buttons to control the pan and tilt of the camera diff --git a/crates/core/src/bc_protocol/motion.rs b/crates/core/src/bc_protocol/motion.rs index d910ee49b..53398cad8 100644 --- a/crates/core/src/bc_protocol/motion.rs +++ b/crates/core/src/bc_protocol/motion.rs @@ -12,6 +12,8 @@ pub enum MotionStatus { Start(Instant), /// Sent when motion stops Stop(Instant), + /// Sent when doorbell is pressed + Doorbell(Instant), /// Sent when an Alarm about something other than motion was received NoChange(Instant), } @@ -37,6 +39,7 @@ impl MotionData { MotionStatus::Start(_) => Some(true), MotionStatus::Stop(_) => Some(false), MotionStatus::NoChange(_) => None, + MotionStatus::Doorbell(_) => None, }) } @@ -50,6 +53,7 @@ impl MotionData { MotionStatus::Start(_) => Some(true), MotionStatus::Stop(time) => Some((Instant::now() - *time) < duration), MotionStatus::NoChange(_) => None, + MotionStatus::Doorbell(_) => None, }) } @@ -236,25 +240,33 @@ impl BcCamera { .. }) = motion_msg.body { - let mut result = MotionStatus::NoChange(Instant::now()); + let mut events = Vec::new(); for alarm_event in &alarm_event_list.alarm_events { if alarm_event.channel_id == channel_id { - if alarm_event.status != "none" - || alarm_event - .ai_type - .as_ref() - .map(|ai_type| ai_type != "none") - .unwrap_or(false) - { - result = MotionStatus::Start(Instant::now()); - break; - } else { - result = MotionStatus::Stop(Instant::now()); - break; + let statuses: Vec<&str> = alarm_event.status.split(',').collect(); + for status in statuses { + let event = match status { + "visitor" => MotionStatus::Doorbell(Instant::now()), + "MD" | "PIR" => MotionStatus::Start(Instant::now()), + "none" => MotionStatus::Stop(Instant::now()), + _ => MotionStatus::Start(Instant::now()), + }; + events.push(event); } + if alarm_event.ai_type.as_ref().map(|t| t != "none").unwrap_or(false) { + if !events.iter().any(|e| matches!(e, MotionStatus::Start(_))) { + events.push(MotionStatus::Start(Instant::now())); + } + } + break; + } + } + for event in events { + if tx.send(Ok(event)).await.is_err() { + break; } } - Ok(result) + Ok(MotionStatus::NoChange(Instant::now())) } else { Ok(MotionStatus::NoChange(Instant::now())) } diff --git a/sample_config.toml b/sample_config.toml index 85a7ea843..f34aa50ac 100644 --- a/sample_config.toml +++ b/sample_config.toml @@ -45,6 +45,13 @@ address = "192.168.1.187:9000" # mqtt.discovery.topic = "homeassistant" # Uncomment to enable # If using discovery, _ characters are replaced with spaces in the name and title case is applied # mqtt.discovery.features = ["floodlight"] # Uncomment if this camera has a spotlight/floodlight +# MQTT specific camera settings +# mqtt.enable_motion = true +# mqtt.enable_doorbell = true +# mqtt.enable_light = true +# mqtt.enable_battery = true +# mqtt.enable_preview = true +# mqtt.enable_floodlight = true # If you use a battery camera: **Instead** of an `address` supply the uid # as follows diff --git a/src/common/instance.rs b/src/common/instance.rs index 25db1a726..67798775d 100644 --- a/src/common/instance.rs +++ b/src/common/instance.rs @@ -238,6 +238,14 @@ impl NeoInstance { Ok(instance_rx.await?) } + pub(crate) async fn doorbell(&self) -> Result> { + let (instance_tx, instance_rx) = oneshot(); + self.camera_control + .send(NeoCamCommand::Doorbell(instance_tx)) + .await?; + Ok(instance_rx.await?) + } + pub(crate) async fn config(&self) -> Result> { let (instance_tx, instance_rx) = oneshot(); self.camera_control diff --git a/src/common/instance/gst.rs b/src/common/instance/gst.rs index 7dccd3bf5..b6665cc97 100644 --- a/src/common/instance/gst.rs +++ b/src/common/instance/gst.rs @@ -45,7 +45,7 @@ impl NeoInstance { log::info!("{name}::{stream:?}: Starting with Motion"); counter.create_activated().await? } - MdState::Stop(_) | MdState::Unknown => { + MdState::Stop(_) | MdState::Doorbell(_) | MdState::Unknown => { log::info!("{name}::{stream:?}: Waiting with Motion"); counter.create_deactivated().await? } @@ -64,7 +64,7 @@ impl NeoInstance { log::info!("{thread_name}::{stream:?}: Motion Started"); md_permit.activate().await?; } - MdState::Stop(_) => { + MdState::Stop(_) | MdState::Doorbell(_) => { log::info!("{thread_name}::{stream:?}: Motion Stopped"); md_permit.deactivate().await?; } diff --git a/src/common/mdthread.rs b/src/common/mdthread.rs index 03748b127..83b54d94a 100644 --- a/src/common/mdthread.rs +++ b/src/common/mdthread.rs @@ -22,6 +22,7 @@ use neolink_core::bc_protocol::MotionStatus; pub(crate) enum MdState { Start(Instant), Stop(Instant), + Doorbell(Instant), Unknown, } @@ -87,6 +88,11 @@ impl NeoCamMdThread { MdState::Stop(at.into()) ); } + MotionStatus::Doorbell(at) => { + watcher.send_replace( + MdState::Doorbell(at.into()) + ); + } MotionStatus::NoChange(_) => {}, } } diff --git a/src/common/neocam.rs b/src/common/neocam.rs index 8633eedd9..ba0781680 100644 --- a/src/common/neocam.rs +++ b/src/common/neocam.rs @@ -33,6 +33,7 @@ pub(crate) enum NeoCamCommand { HangUp, Instance(OneshotSender>), Motion(OneshotSender>), + Doorbell(OneshotSender>), Config(OneshotSender>), Disconnect(OneshotSender<()>), Connect(OneshotSender<()>), @@ -114,6 +115,13 @@ impl NeoCam { } ).await?; }, + NeoCamCommand::Doorbell(sender) => { + md_request_tx.send( + MdRequest::Get { + sender, + } + ).await?; + }, NeoCamCommand::Config(sender) => { let _ = sender.send(thread_watch_config_rx.clone()); }, diff --git a/src/config.rs b/src/config.rs index 6ca22ad2c..ff6136d47 100644 --- a/src/config.rs +++ b/src/config.rs @@ -220,7 +220,7 @@ pub(crate) struct CameraConfig { pub(crate) idle_disconnect: bool, } -#[derive(Debug, Deserialize, Serialize, Validate, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Deserialize, Serialize, Clone, Validate, PartialEq, Eq, Hash)] pub(crate) struct UserConfig { #[validate(custom(function = "validate_username"))] #[serde(alias = "username")] @@ -235,6 +235,8 @@ pub(crate) struct MqttConfig { #[serde(default = "default_true")] pub(crate) enable_motion: bool, #[serde(default = "default_true")] + pub(crate) enable_doorbell: bool, + #[serde(default = "default_true")] pub(crate) enable_light: bool, #[serde(default = "default_true")] pub(crate) enable_battery: bool, @@ -303,6 +305,7 @@ const fn default_false() -> bool { fn default_mqtt() -> MqttConfig { MqttConfig { enable_motion: true, + enable_doorbell: true, enable_light: true, enable_battery: true, battery_update: 2000, diff --git a/src/mqtt/discovery.rs b/src/mqtt/discovery.rs index bbba2f255..629421a2b 100644 --- a/src/mqtt/discovery.rs +++ b/src/mqtt/discovery.rs @@ -30,6 +30,8 @@ pub(crate) enum Discoveries { Battery, #[serde(alias = "siren", alias = "alarm")] Siren, + #[serde(alias = "doorbell", alias = "db", alias = "visitor")] + Doorbell, } #[derive(Debug, Clone)] @@ -624,6 +626,36 @@ pub(crate) async fn enable_discovery( ) })?; } + Discoveries::Doorbell => { + let config_data = DiscoveryBinarySensor { + device: device.clone(), + availability: availability.clone(), + name: format!("{} Doorbell", friendly_name.as_str()), + unique_id: format!("neolink_{}_doorbell", cam_config.name), + icon: Some("mdi:doorbell".to_string()), + state_topic: format!("neolink/{}/status/doorbell", cam_config.name), + payload_off: "idle".to_string(), + payload_on: "pressed".to_string(), + }; + + mqtt.send_message_with_root_topic( + &format!( + "{}/binary_sensor/{}", + discovery_config.topic, &config_data.unique_id + ), + "config", + &serde_json::to_string(&config_data) + .with_context(|| "Could not serialise discovery doorbell config into json")?, + true, + ) + .await + .with_context(|| { + format!( + "Failed to publish doorbell auto-discover data over MQTT for {}", + cam_config.name + ) + })?; + } } } diff --git a/src/mqtt/mod.rs b/src/mqtt/mod.rs index 36bf009e9..4936ddd66 100644 --- a/src/mqtt/mod.rs +++ b/src/mqtt/mod.rs @@ -313,6 +313,13 @@ async fn listen_on_camera(camera: NeoInstance, mqtt_instance: MqttInstance) -> R .with_context(|| format!("Failed to publish push notification unknown for {}", camera_name))?; let _drop_message2 = mqtt_instance.last_will("status/motion", "unknown").await?; + // Publish initial doorbell state + mqtt_instance + .send_message("status/doorbell", "unknown", true) + .await + .with_context(|| format!("Failed to publish doorbell unknown for {}", camera_name))?; + let _drop_message3 = mqtt_instance.last_will("status/doorbell", "unknown").await?; + if let Some(discovery_config) = config.discovery.as_ref() { enable_discovery(discovery_config, &mqtt_instance, &camera).await?; } @@ -331,6 +338,9 @@ async fn listen_on_camera(camera: NeoInstance, mqtt_instance: MqttInstance) -> R let camera_motion = camera.clone(); let mqtt_motion = mqtt_instance.resubscribe().await?; + let camera_doorbell = camera.clone(); + let mqtt_doorbell = mqtt_instance.resubscribe().await?; + #[cfg(feature = "pushnoti")] let camera_pn = camera.clone(); #[cfg(feature = "pushnoti")] @@ -476,6 +486,26 @@ async fn listen_on_camera(camera: NeoInstance, mqtt_instance: MqttInstance) -> R }?; } }, if config.enable_motion => v, + // Handle the doorbell messages + v = async { + let mut db = camera_doorbell.doorbell().await?; + loop { + db.wait_for(|state| matches!(state, MdState::Doorbell(_))).await.with_context(|| { + format!("{}: Doorbell Watch Dropped", camera_name) + })?; + mqtt_doorbell.send_message("status/doorbell", "pressed", true).await.with_context(|| { + format!("{}: Failed to publish doorbell pressed", camera_name) + })?; + // Wait for non-doorbell state to reset + db.wait_for(|state| !matches!(state, MdState::Doorbell(_))).await.with_context(|| { + format!("{}: Doorbell Reset Watch Dropped", camera_name) + })?; + mqtt_doorbell.send_message("status/doorbell", "idle", true).await.with_context(|| { + format!("{}: Failed to publish doorbell idle", camera_name) + })?; + } + AnyResult::Ok(()) + }, if config.enable_doorbell => v, // Handle the SNAP (image preview) v = async { let mut wait = IntervalStream::new({