diff --git a/Cargo.lock b/Cargo.lock index 0e31e50..29b81cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,13 +85,13 @@ dependencies = [ "futures", "google-home", "impl_cast", + "indexmap 2.0.0", "paste", "pollster", "regex", "reqwest", "rumqttc", "serde", - "serde-tuple-vec-map", "serde_json", "serde_repr", "serde_with", @@ -1355,15 +1355,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-tuple-vec-map" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a04d0ebe0de77d7d445bb729a895dcb0a288854b267ca85f030ce51cdc578c82" -dependencies = [ - "serde", -] - [[package]] name = "serde_derive" version = "1.0.183" diff --git a/Cargo.toml b/Cargo.toml index 20fdab4..c9dc106 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,9 +37,9 @@ anyhow = "1.0.68" wakey = "0.3.0" console-subscriber = "0.1.8" tracing-subscriber = "0.3.16" -serde-tuple-vec-map = "1.0.1" serde_with = "3.2.0" enum_dispatch = "0.3.12" +indexmap = { version = "2.0.0", features = ["serde"] } [patch.crates-io] wakey = { git = "https://github.com/DreadedX/wakey" } diff --git a/config/ares.dev.toml b/config/ares.dev.toml index bdd25f5..d518059 100644 --- a/config/ares.dev.toml +++ b/config/ares.dev.toml @@ -15,15 +15,18 @@ topic = "${NTFY_TOPIC}" [presence] topic = "automation_dev/presence/+/#" -[debug_bridge] +# Devices +[device.debug_bridge] +type = "DebugBridge" topic = "automation_dev/debug" -[light_sensor] +[device.light_sensor] +type = "LightSensor" topic = "zigbee2mqtt_dev/living/light" min = 23_000 max = 25_000 -[devices.kitchen_kettle] +[device.kitchen_kettle] type = "IkeaOutlet" outlet_type = "Kettle" name = "Kettle" @@ -31,7 +34,7 @@ room = "Kitchen" topic = "zigbee2mqtt/kitchen/kettle" timeout = 5 -[devices.workbench_charger] +[device.workbench_charger] type = "IkeaOutlet" outlet_type = "Charger" name = "Charger" @@ -39,20 +42,20 @@ room = "Workbench" topic = "zigbee2mqtt/workbench/charger" timeout = 5 -[devices.workbench_outlet] +[device.workbench_outlet] type = "IkeaOutlet" name = "Outlet" room = "Workbench" topic = "zigbee2mqtt/workbench/outlet" -[devices.living_zeus] +[device.living_zeus] type = "WakeOnLAN" name = "Zeus" room = "Living Room" topic = "automation/appliance/living_room/zeus" mac_address = "30:9c:23:60:9c:13" -[devices.hallway_frontdoor] +[device.hallway_frontdoor] type = "ContactSensor" topic = "zigbee2mqtt/hallway/frontdoor" -presence = { timeout = 10 } +presence = { topic = "automation_dev/presence/contact/frontdoor", timeout = 10 } diff --git a/config/config.toml b/config/config.toml index 73d595f..6ba5d6e 100644 --- a/config/config.toml +++ b/config/config.toml @@ -14,50 +14,25 @@ topic = "${NTFY_TOPIC}" [presence] topic = "automation/presence/+/#" -[hue_bridge] +# Devices +[device.debug_bridge] +type = "DebugBridge" +topic = "automation/debug" + +[device.hue_bridge] +type = "HueBridge" ip = "10.0.0.146" login = "${HUE_TOKEN}" flags = { presence = 41, darkness = 43 } -[debug_bridge] -topic = "automation/debug" -[light_sensor] +[device.living_light_sensor] +type = "LightSensor" topic = "zigbee2mqtt/living/light" min = 22_000 max = 23_500 -[devices.kitchen_kettle] -type = "IkeaOutlet" -outlet_type = "Kettle" -name = "Kettle" -room = "Kitchen" -topic = "zigbee2mqtt/kitchen/kettle" -timeout = 300 - -[devices.bathroom_light] -type = "IkeaOutlet" -outlet_type = "Light" -name = "Light" -room = "Bathroom" -topic = "zigbee2mqtt/bathroom/light" -timeout = 2700 - -[devices.workbench_charger] -type = "IkeaOutlet" -outlet_type = "Charger" -name = "Charger" -room = "Workbench" -topic = "zigbee2mqtt/workbench/charger" -timeout = 72000 - -[devices.workbench_outlet] -type = "IkeaOutlet" -name = "Outlet" -room = "Workbench" -topic = "zigbee2mqtt/workbench/outlet" - -[devices.living_zeus] +[device.living_zeus] type = "WakeOnLAN" name = "Zeus" room = "Living Room" @@ -65,34 +40,68 @@ topic = "automation/appliance/living_room/zeus" mac_address = "30:9c:23:60:9c:13" broadcast_ip = "10.0.0.255" -[devices.living_mixer] +[device.living_mixer] type = "KasaOutlet" ip = "10.0.0.49" -[devices.living_speakers] +[device.living_speakers] type = "KasaOutlet" ip = "10.0.0.182" -[devices.living_audio] +[device.living_audio] type = "AudioSetup" topic = "zigbee2mqtt/living/remote" mixer = "living_mixer" speakers = "living_speakers" -[devices.hallway_light] + +[device.kitchen_kettle] +type = "IkeaOutlet" +outlet_type = "Kettle" +name = "Kettle" +room = "Kitchen" +topic = "zigbee2mqtt/kitchen/kettle" +timeout = 300 + + +[device.bathroom_light] +type = "IkeaOutlet" +outlet_type = "Light" +name = "Light" +room = "Bathroom" +topic = "zigbee2mqtt/bathroom/light" +timeout = 2700 + +[device.bathroom_washer] +type = "Washer" +topic = "zigbee2mqtt/bathroom/washer" +threshold = 1 + + +[device.workbench_charger] +type = "IkeaOutlet" +outlet_type = "Charger" +name = "Charger" +room = "Workbench" +topic = "zigbee2mqtt/workbench/charger" +timeout = 72000 + +[device.workbench_outlet] +type = "IkeaOutlet" +name = "Outlet" +room = "Workbench" +topic = "zigbee2mqtt/workbench/outlet" + + +[device.hallway_light] type = "HueLight" ip = "10.0.0.146" login = "${HUE_TOKEN}" light_id = 16 timer_id = 1 -[devices.hallway_frontdoor] +[device.hallway_frontdoor] type = "ContactSensor" topic = "zigbee2mqtt/hallway/frontdoor" -presence = { timeout = 900 } -lights = { lights = ["hallway_light"], timeout = 60 } - -[devices.bathroom_washer] -type = "Washer" -topic = "zigbee2mqtt/bathroom/washer" -threshold = 1 +presence = { topic = "automation/presence/contact/frontdoor", timeout = 900 } +trigger = { devices = ["hallway_light"], timeout = 60 } diff --git a/config/zeus.dev.toml b/config/zeus.dev.toml index bb69a38..4244def 100644 --- a/config/zeus.dev.toml +++ b/config/zeus.dev.toml @@ -15,20 +15,49 @@ topic = "${NTFY_TOPIC}" [presence] topic = "automation_dev/presence/+/#" -[hue_bridge] +# Devices +[device.debug_bridge] +type = "DebugBridge" +topic = "automation_dev/debug" + +[device.hue_bridge] +type = "HueBridge" ip = "10.0.0.146" login = "${HUE_TOKEN}" flags = { presence = 41, darkness = 43 } -[debug_bridge] -topic = "automation_dev/debug" -[light_sensor] +[device.living_light_sensor] +type = "LightSensor" topic = "zigbee2mqtt_dev/living/light" min = 23_000 max = 25_000 +# TODO: Implement this: +trigger = ["hue_bridge", "debug_bridge"] -[devices.kitchen_kettle] +[device.living_zeus] +type = "WakeOnLAN" +name = "Zeus" +room = "Living Room" +topic = "automation/appliance/living_room/zeus" +mac_address = "30:9c:23:60:9c:13" + +[device.living_mixer] +type = "KasaOutlet" +ip = "10.0.0.49" + +[device.living_speakers] +type = "KasaOutlet" +ip = "10.0.0.182" + +[device.living_audio] +type = "AudioSetup" +topic = "zigbee2mqtt/living/remote" +mixer = "living_mixer" +speakers = "living_speakers" + + +[device.kitchen_kettle] type = "IkeaOutlet" outlet_type = "Kettle" name = "Kettle" @@ -36,7 +65,8 @@ room = "Kitchen" topic = "zigbee2mqtt/kitchen/kettle" timeout = 5 -[devices.bathroom_light] + +[device.bathroom_light] type = "IkeaOutlet" outlet_type = "Light" name = "Bathroom light" @@ -44,7 +74,13 @@ room = "Bathroom" topic = "zigbee2mqtt/bathroom/light" timeout = 60 -[devices.workbench_charger] +[device.bathroom_washer] +type = "Washer" +topic = "zigbee2mqtt/bathroom/washer" +threshold = 1 + + +[device.workbench_charger] type = "IkeaOutlet" outlet_type = "Charger" name = "Charger" @@ -52,47 +88,22 @@ room = "Workbench" topic = "zigbee2mqtt/workbench/charger" timeout = 5 -[devices.workbench_outlet] +[device.workbench_outlet] type = "IkeaOutlet" name = "Outlet" room = "Workbench" topic = "zigbee2mqtt/workbench/outlet" -[devices.living_zeus] -type = "WakeOnLAN" -name = "Zeus" -room = "Living Room" -topic = "automation/appliance/living_room/zeus" -mac_address = "30:9c:23:60:9c:13" -[devices.living_mixer] -type = "KasaOutlet" -ip = "10.0.0.49" - -[devices.living_speakers] -type = "KasaOutlet" -ip = "10.0.0.182" - -[devices.living_audio] -type = "AudioSetup" -topic = "zigbee2mqtt/living/remote" -mixer = "living_mixer" -speakers = "living_speakers" - -[devices.hallway_light] +[device.hallway_light] type = "HueLight" ip = "10.0.0.146" login = "${HUE_TOKEN}" light_id = 16 timer_id = 1 -[devices.hallway_frontdoor] +[device.hallway_frontdoor] type = "ContactSensor" topic = "zigbee2mqtt/hallway/frontdoor" -presence = { timeout = 10 } -lights = { lights = ["hallway_light"], timeout = 10 } - -[devices.bathroom_washer] -type = "Washer" -topic = "zigbee2mqtt/bathroom/washer" -threshold = 1 +presence = { topic = "automation_dev/presence/contact/frontdoor", timeout = 10 } +trigger = { devices = ["hallway_light"], timeout = 10 } diff --git a/src/config.rs b/src/config.rs index 6c079f3..3beeb64 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,23 +4,17 @@ use std::{ time::Duration, }; -use async_trait::async_trait; -use enum_dispatch::enum_dispatch; +use indexmap::IndexMap; use regex::{Captures, Regex}; -use rumqttc::{AsyncClient, MqttOptions, Transport}; +use rumqttc::{MqttOptions, Transport}; use serde::{Deserialize, Deserializer}; use tracing::debug; use crate::{ auth::OpenIDConfig, - device_manager::DeviceManager, - devices::{ - AudioSetupConfig, ContactSensorConfig, DebugBridgeConfig, Device, HueBridgeConfig, - HueLightConfig, IkeaOutletConfig, KasaOutletConfig, LightSensorConfig, PresenceConfig, - WakeOnLANConfig, WasherConfig, - }, - error::{ConfigParseError, DeviceConfigError, MissingEnv}, - event::EventChannel, + device_manager::DeviceConfigs, + devices::{DebugBridgeConfig, PresenceConfig}, + error::{ConfigParseError, MissingEnv}, }; #[derive(Debug, Deserialize)] @@ -32,11 +26,9 @@ pub struct Config { pub fullfillment: FullfillmentConfig, pub ntfy: Option, pub presence: PresenceConfig, - pub light_sensor: LightSensorConfig, - pub hue_bridge: Option, pub debug_bridge: Option, - #[serde(default, with = "tuple_vec_map")] - pub devices: Vec<(String, DeviceConfigs)>, + #[serde(rename = "device")] + pub devices: IndexMap, } #[derive(Debug, Clone, Deserialize)] @@ -151,33 +143,3 @@ impl Config { Ok(config) } } - -pub struct ConfigExternal<'a> { - pub client: &'a AsyncClient, - pub device_manager: &'a DeviceManager, - pub presence_topic: &'a str, - pub event_channel: &'a EventChannel, -} - -#[async_trait] -#[enum_dispatch] -pub trait DeviceConfig { - async fn create( - self, - identifier: &str, - ext: &ConfigExternal, - ) -> Result, DeviceConfigError>; -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "type")] -#[enum_dispatch(DeviceConfig)] -pub enum DeviceConfigs { - AudioSetup(AudioSetupConfig), - ContactSensor(ContactSensorConfig), - IkeaOutlet(IkeaOutletConfig), - KasaOutlet(KasaOutletConfig), - WakeOnLAN(WakeOnLANConfig), - Washer(WasherConfig), - HueLight(HueLightConfig), -} diff --git a/src/device_manager.rs b/src/device_manager.rs index 60cb1c6..eeb4441 100644 --- a/src/device_manager.rs +++ b/src/device_manager.rs @@ -1,19 +1,59 @@ use std::collections::HashMap; use std::sync::Arc; +use async_trait::async_trait; +use enum_dispatch::enum_dispatch; use futures::future::join_all; use rumqttc::{matches, AsyncClient, QoS}; +use serde::Deserialize; use tokio::sync::{RwLock, RwLockReadGuard}; use tracing::{debug, error, instrument, trace}; use crate::{ - devices::{As, Device}, + devices::{ + As, AudioSetupConfig, ContactSensorConfig, DebugBridgeConfig, Device, HueBridgeConfig, + HueLightConfig, IkeaOutletConfig, KasaOutletConfig, LightSensorConfig, WakeOnLANConfig, + WasherConfig, + }, + error::DeviceConfigError, event::OnDarkness, event::OnNotification, event::OnPresence, event::{Event, EventChannel, OnMqtt}, }; +pub struct ConfigExternal<'a> { + pub client: &'a AsyncClient, + pub device_manager: &'a DeviceManager, + pub event_channel: &'a EventChannel, +} + +#[async_trait] +#[enum_dispatch] +pub trait DeviceConfig { + async fn create( + self, + identifier: &str, + ext: &ConfigExternal, + ) -> Result, DeviceConfigError>; +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +#[enum_dispatch(DeviceConfig)] +pub enum DeviceConfigs { + AudioSetup(AudioSetupConfig), + ContactSensor(ContactSensorConfig), + DebugBridge(DebugBridgeConfig), + IkeaOutlet(IkeaOutletConfig), + KasaOutlet(KasaOutletConfig), + WakeOnLAN(WakeOnLANConfig), + Washer(WasherConfig), + HueBridge(HueBridgeConfig), + HueLight(HueLightConfig), + LightSensor(LightSensorConfig), +} + pub type WrappedDevice = Arc>>; pub type DeviceMap = HashMap; @@ -21,31 +61,33 @@ pub type DeviceMap = HashMap; pub struct DeviceManager { devices: Arc>, client: AsyncClient, + event_channel: EventChannel, } impl DeviceManager { pub fn new(client: AsyncClient) -> Self { - Self { - devices: Arc::new(RwLock::new(HashMap::new())), - client, - } - } - - pub fn start(&self) -> EventChannel { let (event_channel, mut event_rx) = EventChannel::new(); - let devices = self.clone(); - tokio::spawn(async move { - loop { - if let Some(event) = event_rx.recv().await { - devices.handle_event(event).await; - } else { - todo!("Handle errors with the event channel properly") + let device_manager = Self { + devices: Arc::new(RwLock::new(HashMap::new())), + client, + event_channel, + }; + + tokio::spawn({ + let device_manager = device_manager.clone(); + async move { + loop { + if let Some(event) = event_rx.recv().await { + device_manager.handle_event(event).await; + } else { + todo!("Handle errors with the event channel properly") + } } } }); - event_channel + device_manager } pub async fn add(&self, device: Box) { @@ -71,6 +113,28 @@ impl DeviceManager { self.devices.write().await.insert(id, device); } + pub async fn create( + &self, + identifier: &str, + device_config: DeviceConfigs, + ) -> Result<(), DeviceConfigError> { + let ext = ConfigExternal { + client: &self.client, + device_manager: self, + event_channel: &self.event_channel, + }; + + let device = device_config.create(identifier, &ext).await?; + + self.add(device).await; + + Ok(()) + } + + pub fn event_channel(&self) -> EventChannel { + self.event_channel.clone() + } + pub async fn get(&self, name: &str) -> Option { self.devices.read().await.get(name).cloned() } diff --git a/src/devices/audio_setup.rs b/src/devices/audio_setup.rs index bf47f9d..d4b31e8 100644 --- a/src/devices/audio_setup.rs +++ b/src/devices/audio_setup.rs @@ -4,8 +4,8 @@ use serde::Deserialize; use tracing::{debug, error, trace, warn}; use crate::{ - config::{ConfigExternal, DeviceConfig, MqttDeviceConfig}, - device_manager::WrappedDevice, + config::MqttDeviceConfig, + device_manager::{ConfigExternal, DeviceConfig, WrappedDevice}, devices::As, error::DeviceConfigError, event::OnMqtt, @@ -64,7 +64,7 @@ impl DeviceConfig for AudioSetupConfig { } let device = AudioSetup { - identifier: identifier.to_owned(), + identifier: identifier.into(), mqtt: self.mqtt, mixer, speakers, diff --git a/src/devices/contact_sensor.rs b/src/devices/contact_sensor.rs index c89aae3..a78735a 100644 --- a/src/devices/contact_sensor.rs +++ b/src/devices/contact_sensor.rs @@ -2,17 +2,17 @@ use std::time::Duration; use async_trait::async_trait; use google_home::traits::OnOff; -use rumqttc::{has_wildcards, AsyncClient}; +use rumqttc::AsyncClient; use serde::Deserialize; use serde_with::{serde_as, DurationSeconds}; use tokio::task::JoinHandle; use tracing::{debug, error, trace, warn}; use crate::{ - config::{ConfigExternal, DeviceConfig, MqttDeviceConfig}, - device_manager::WrappedDevice, + config::MqttDeviceConfig, + device_manager::{ConfigExternal, DeviceConfig, WrappedDevice}, devices::{As, DEFAULT_PRESENCE}, - error::{DeviceConfigError, MissingWildcard}, + error::DeviceConfigError, event::OnMqtt, event::OnPresence, messages::{ContactMessage, PresenceMessage}, @@ -26,42 +26,15 @@ use super::Device; #[derive(Debug, Clone, Deserialize)] pub struct PresenceDeviceConfig { #[serde(flatten)] - pub mqtt: Option, + pub mqtt: MqttDeviceConfig, #[serde_as(as = "DurationSeconds")] pub timeout: Duration, } -impl PresenceDeviceConfig { - /// Set the mqtt topic to an appropriate value if it is not already set - fn generate_topic( - mut self, - class: &str, - identifier: &str, - presence_topic: &str, - ) -> Result { - if self.mqtt.is_none() { - if !has_wildcards(presence_topic) { - return Err(MissingWildcard::new(presence_topic)); - } - - // TODO: This is not perfect, if the topic is some/+/thing/# this will fail - let offset = presence_topic - .find('+') - .or(presence_topic.find('#')) - .unwrap(); - let topic = format!("{}/{class}/{identifier}", &presence_topic[..offset - 1]); - trace!("Setting presence mqtt topic: {topic}"); - self.mqtt = Some(MqttDeviceConfig { topic }); - } - - Ok(self) - } -} - #[serde_as] #[derive(Debug, Clone, Deserialize)] -pub struct LightsConfig { - lights: Vec, +pub struct TriggerConfig { + devices: Vec, #[serde(default)] #[serde_as(as = "DurationSeconds")] pub timeout: Duration, @@ -72,7 +45,7 @@ pub struct ContactSensorConfig { #[serde(flatten)] mqtt: MqttDeviceConfig, presence: Option, - lights: Option, + trigger: Option, } #[async_trait] @@ -84,43 +57,49 @@ impl DeviceConfig for ContactSensorConfig { ) -> Result, DeviceConfigError> { trace!(id = identifier, "Setting up ContactSensor"); - let presence = self - .presence - .map(|p| p.generate_topic("contact", identifier, ext.presence_topic)) - .transpose()?; + let trigger = if let Some(trigger_config) = &self.trigger { + let mut devices = Vec::new(); + for device_name in &trigger_config.devices { + let device = ext.device_manager.get(device_name).await.ok_or( + DeviceConfigError::MissingChild(device_name.into(), "OnOff".into()), + )?; - let lights = - if let Some(lights_config) = self.lights { - let mut lights = Vec::new(); - for name in lights_config.lights { - let light = ext.device_manager.get(&name).await.ok_or( - DeviceConfigError::MissingChild(name.clone(), "OnOff".into()), - )?; - - if !As::::is(light.read().await.as_ref()) { - return Err(DeviceConfigError::MissingTrait(name, "OnOff".into())); - } - - lights.push((light, false)); + if !As::::is(device.read().await.as_ref()) { + return Err(DeviceConfigError::MissingTrait( + device_name.into(), + "OnOff".into(), + )); } - Some(Lights { - lights, - timeout: lights_config.timeout, - }) - } else { - None - }; + if !trigger_config.timeout.is_zero() + && !As::::is(device.read().await.as_ref()) + { + return Err(DeviceConfigError::MissingTrait( + device_name.into(), + "Timeout".into(), + )); + } + + devices.push((device, false)); + } + + Some(Trigger { + devices, + timeout: trigger_config.timeout, + }) + } else { + None + }; let device = ContactSensor { - identifier: identifier.to_owned(), + identifier: identifier.into(), mqtt: self.mqtt, - presence, + presence: self.presence, client: ext.client.clone(), overall_presence: DEFAULT_PRESENCE, is_closed: true, handle: None, - lights, + trigger, }; Ok(Box::new(device)) @@ -128,8 +107,8 @@ impl DeviceConfig for ContactSensorConfig { } #[derive(Debug)] -struct Lights { - lights: Vec<(WrappedDevice, bool)>, +struct Trigger { + devices: Vec<(WrappedDevice, bool)>, timeout: Duration, // Timeout in seconds } @@ -144,7 +123,7 @@ struct ContactSensor { is_closed: bool, handle: Option>, - lights: Option, + trigger: Option, } impl Device for ContactSensor { @@ -182,9 +161,9 @@ impl OnMqtt for ContactSensor { debug!(id = self.identifier, "Updating state to {is_closed}"); self.is_closed = is_closed; - if let Some(lights) = &mut self.lights { + if let Some(trigger) = &mut self.trigger { if !self.is_closed { - for (light, previous) in &mut lights.lights { + for (light, previous) in &mut trigger.devices { let mut light = light.write().await; if let Some(light) = As::::cast_mut(light.as_mut()) { *previous = light.is_on().await.unwrap(); @@ -192,14 +171,14 @@ impl OnMqtt for ContactSensor { } } } else { - for (light, previous) in &lights.lights { + for (light, previous) in &trigger.devices { let mut light = light.write().await; if !previous { // If the timeout is zero just turn the light off directly - if lights.timeout.is_zero() && let Some(light) = As::::cast_mut(light.as_mut()) { + if trigger.timeout.is_zero() && let Some(light) = As::::cast_mut(light.as_mut()) { light.set_on(false).await.ok(); } else if let Some(light) = As::::cast_mut(light.as_mut()) { - light.start_timeout(lights.timeout).await; + light.start_timeout(trigger.timeout).await; } // TODO: Put a warning/error on creation if either of this has to option to fail } @@ -214,14 +193,6 @@ impl OnMqtt for ContactSensor { None => return, }; - let topic = match &presence.mqtt { - Some(mqtt) => mqtt.topic.clone(), - None => { - warn!("Contact sensors is configured as a presence sensor, but no mqtt topic is specified"); - return; - } - }; - if !is_closed { // Activate presence and stop any timeout once we open the door if let Some(handle) = self.handle.take() { @@ -234,13 +205,18 @@ impl OnMqtt for ContactSensor { if !self.overall_presence { self.client .publish( - topic.clone(), + presence.mqtt.topic.clone(), rumqttc::QoS::AtLeastOnce, false, serde_json::to_string(&PresenceMessage::new(true)).unwrap(), ) .await - .map_err(|err| warn!("Failed to publish presence on {topic}: {err}")) + .map_err(|err| { + warn!( + "Failed to publish presence on {}: {err}", + presence.mqtt.topic + ) + }) .ok(); } } else { @@ -248,6 +224,7 @@ impl OnMqtt for ContactSensor { let client = self.client.clone(); let id = self.identifier.clone(); let timeout = presence.timeout; + let topic = presence.mqtt.topic.clone(); self.handle = Some(tokio::spawn(async move { debug!(id, "Starting timeout ({timeout:?}) for contact sensor..."); tokio::time::sleep(timeout).await; diff --git a/src/devices/debug_bridge.rs b/src/devices/debug_bridge.rs index a79dc87..71a03c5 100644 --- a/src/devices/debug_bridge.rs +++ b/src/devices/debug_bridge.rs @@ -3,7 +3,10 @@ use rumqttc::AsyncClient; use serde::Deserialize; use tracing::warn; +use crate::device_manager::ConfigExternal; +use crate::device_manager::DeviceConfig; use crate::devices::Device; +use crate::error::DeviceConfigError; use crate::event::OnDarkness; use crate::event::OnPresence; use crate::{ @@ -17,24 +20,33 @@ pub struct DebugBridgeConfig { pub mqtt: MqttDeviceConfig, } +#[async_trait] +impl DeviceConfig for DebugBridgeConfig { + async fn create( + self, + identifier: &str, + ext: &ConfigExternal, + ) -> Result, DeviceConfigError> { + let device = DebugBridge { + identifier: identifier.into(), + mqtt: self.mqtt, + client: ext.client.clone(), + }; + + Ok(Box::new(device)) + } +} + #[derive(Debug)] pub struct DebugBridge { + identifier: String, mqtt: MqttDeviceConfig, client: AsyncClient, } -impl DebugBridge { - pub fn new(config: DebugBridgeConfig, client: &AsyncClient) -> Self { - Self { - mqtt: config.mqtt, - client: client.clone(), - } - } -} - impl Device for DebugBridge { fn get_id(&self) -> &str { - "debug_bridge" + &self.identifier } } diff --git a/src/devices/hue_bridge.rs b/src/devices/hue_bridge.rs index 080a93a..72b2d1d 100644 --- a/src/devices/hue_bridge.rs +++ b/src/devices/hue_bridge.rs @@ -1,21 +1,15 @@ -use std::{ - net::{Ipv4Addr, SocketAddr}, - time::Duration, -}; +use std::net::{Ipv4Addr, SocketAddr}; use async_trait::async_trait; -use google_home::{errors::ErrorCode, traits::OnOff}; use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tracing::{debug, error, trace, warn}; +use tracing::{error, trace, warn}; use crate::{ - config::{ConfigExternal, DeviceConfig}, + device_manager::{ConfigExternal, DeviceConfig}, devices::Device, error::DeviceConfigError, event::OnDarkness, event::OnPresence, - traits::Timeout, }; #[derive(Debug)] @@ -37,8 +31,27 @@ pub struct HueBridgeConfig { pub flags: FlagIDs, } +#[async_trait] +impl DeviceConfig for HueBridgeConfig { + async fn create( + self, + identifier: &str, + _ext: &ConfigExternal, + ) -> Result, DeviceConfigError> { + let device = HueBridge { + identifier: identifier.into(), + addr: (self.ip, 80).into(), + login: self.login, + flag_ids: self.flags, + }; + + Ok(Box::new(device)) + } +} + #[derive(Debug)] -pub struct HueBridge { +struct HueBridge { + identifier: String, addr: SocketAddr, login: String, flag_ids: FlagIDs, @@ -80,19 +93,11 @@ impl HueBridge { } } } - - pub fn new(config: HueBridgeConfig) -> Self { - Self { - addr: (config.ip, 80).into(), - login: config.login, - flag_ids: config.flags, - } - } } impl Device for HueBridge { fn get_id(&self) -> &str { - "hue_bridge" + &self.identifier } } @@ -111,160 +116,3 @@ impl OnDarkness for HueBridge { self.set_flag(Flag::Darkness, dark).await; } } - -#[derive(Debug, Clone, Deserialize)] -pub struct HueLightConfig { - pub ip: Ipv4Addr, - pub login: String, - pub light_id: isize, - pub timer_id: isize, -} - -#[async_trait] -impl DeviceConfig for HueLightConfig { - async fn create( - self, - identifier: &str, - _ext: &ConfigExternal, - ) -> Result, DeviceConfigError> { - let device = HueLight { - identifier: identifier.to_owned(), - addr: (self.ip, 80).into(), - login: self.login, - light_id: self.light_id, - timer_id: self.timer_id, - }; - - Ok(Box::new(device)) - } -} - -#[derive(Debug)] -struct HueLight { - pub identifier: String, - pub addr: SocketAddr, - pub login: String, - pub light_id: isize, - pub timer_id: isize, -} - -impl Device for HueLight { - fn get_id(&self) -> &str { - &self.identifier - } -} - -#[async_trait] -impl OnOff for HueLight { - async fn set_on(&mut self, on: bool) -> Result<(), ErrorCode> { - // Abort any timer that is currently running - self.stop_timeout().await; - - let url = format!( - "http://{}/api/{}/lights/{}/state", - self.addr, self.login, self.light_id - ); - - let res = reqwest::Client::new() - .put(url) - .body(format!(r#"{{"on": {}}}"#, on)) - .send() - .await; - - match res { - Ok(res) => { - let status = res.status(); - if !status.is_success() { - warn!(id = self.identifier, "Status code is not success: {status}"); - } - } - Err(err) => error!(id = self.identifier, "Error: {err}"), - } - - Ok(()) - } - - async fn is_on(&self) -> Result { - let url = format!( - "http://{}/api/{}/lights/{}", - self.addr, self.login, self.light_id - ); - - let res = reqwest::Client::new().get(url).send().await; - - match res { - Ok(res) => { - let status = res.status(); - if !status.is_success() { - warn!(id = self.identifier, "Status code is not success: {status}"); - } - - let v: Value = serde_json::from_slice(res.bytes().await.unwrap().as_ref()).unwrap(); - // TODO: This is not very nice - return Ok(v["state"]["on"].as_bool().unwrap()); - } - Err(err) => error!(id = self.identifier, "Error: {err}"), - } - - Ok(false) - } -} - -#[async_trait] -impl Timeout for HueLight { - async fn start_timeout(&mut self, timeout: Duration) { - // Abort any timer that is currently running - self.stop_timeout().await; - - let url = format!( - "http://{}/api/{}/schedules/{}", - self.addr, self.login, self.timer_id - ); - - let seconds = timeout.as_secs() % 60; - let minutes = (timeout.as_secs() / 60) % 60; - let hours = timeout.as_secs() / 3600; - let time = format!("PT{hours:<02}:{minutes:<02}:{seconds:<02}"); - - debug!(id = self.identifier, "Starting timeout ({time})..."); - - let res = reqwest::Client::new() - .put(url) - .body(format!(r#"{{"status": "enabled", "localtime": "{time}"}}"#)) - .send() - .await; - - match res { - Ok(res) => { - let status = res.status(); - if !status.is_success() { - warn!(id = self.identifier, "Status code is not success: {status}"); - } - } - Err(err) => error!(id = self.identifier, "Error: {err}"), - } - } - - async fn stop_timeout(&mut self) { - let url = format!( - "http://{}/api/{}/schedules/{}", - self.addr, self.login, self.timer_id - ); - - let res = reqwest::Client::new() - .put(url) - .body(format!(r#"{{"status": "disabled"}}"#)) - .send() - .await; - - match res { - Ok(res) => { - let status = res.status(); - if !status.is_success() { - warn!(id = self.identifier, "Status code is not success: {status}"); - } - } - Err(err) => error!(id = self.identifier, "Error: {err}"), - } - } -} diff --git a/src/devices/hue_light.rs b/src/devices/hue_light.rs new file mode 100644 index 0000000..153b78c --- /dev/null +++ b/src/devices/hue_light.rs @@ -0,0 +1,175 @@ +use std::{ + net::{Ipv4Addr, SocketAddr}, + time::Duration, +}; + +use async_trait::async_trait; +use google_home::{errors::ErrorCode, traits::OnOff}; +use serde::Deserialize; +use serde_json::Value; +use tracing::{debug, error, warn}; + +use crate::{ + device_manager::{ConfigExternal, DeviceConfig}, + error::DeviceConfigError, + traits::Timeout, +}; + +use super::Device; + +#[derive(Debug, Clone, Deserialize)] +pub struct HueLightConfig { + pub ip: Ipv4Addr, + pub login: String, + pub light_id: isize, + pub timer_id: isize, +} + +#[async_trait] +impl DeviceConfig for HueLightConfig { + async fn create( + self, + identifier: &str, + _ext: &ConfigExternal, + ) -> Result, DeviceConfigError> { + let device = HueLight { + identifier: identifier.into(), + addr: (self.ip, 80).into(), + login: self.login, + light_id: self.light_id, + timer_id: self.timer_id, + }; + + Ok(Box::new(device)) + } +} + +#[derive(Debug)] +struct HueLight { + pub identifier: String, + pub addr: SocketAddr, + pub login: String, + pub light_id: isize, + pub timer_id: isize, +} + +impl Device for HueLight { + fn get_id(&self) -> &str { + &self.identifier + } +} + +#[async_trait] +impl OnOff for HueLight { + async fn set_on(&mut self, on: bool) -> Result<(), ErrorCode> { + // Abort any timer that is currently running + self.stop_timeout().await; + + let url = format!( + "http://{}/api/{}/lights/{}/state", + self.addr, self.login, self.light_id + ); + + let res = reqwest::Client::new() + .put(url) + .body(format!(r#"{{"on": {}}}"#, on)) + .send() + .await; + + match res { + Ok(res) => { + let status = res.status(); + if !status.is_success() { + warn!(id = self.identifier, "Status code is not success: {status}"); + } + } + Err(err) => error!(id = self.identifier, "Error: {err}"), + } + + Ok(()) + } + + async fn is_on(&self) -> Result { + let url = format!( + "http://{}/api/{}/lights/{}", + self.addr, self.login, self.light_id + ); + + let res = reqwest::Client::new().get(url).send().await; + + match res { + Ok(res) => { + let status = res.status(); + if !status.is_success() { + warn!(id = self.identifier, "Status code is not success: {status}"); + } + + let v: Value = serde_json::from_slice(res.bytes().await.unwrap().as_ref()).unwrap(); + // TODO: This is not very nice + return Ok(v["state"]["on"].as_bool().unwrap()); + } + Err(err) => error!(id = self.identifier, "Error: {err}"), + } + + Ok(false) + } +} + +#[async_trait] +impl Timeout for HueLight { + async fn start_timeout(&mut self, timeout: Duration) { + // Abort any timer that is currently running + self.stop_timeout().await; + + let url = format!( + "http://{}/api/{}/schedules/{}", + self.addr, self.login, self.timer_id + ); + + let seconds = timeout.as_secs() % 60; + let minutes = (timeout.as_secs() / 60) % 60; + let hours = timeout.as_secs() / 3600; + let time = format!("PT{hours:<02}:{minutes:<02}:{seconds:<02}"); + + debug!(id = self.identifier, "Starting timeout ({time})..."); + + let res = reqwest::Client::new() + .put(url) + .body(format!(r#"{{"status": "enabled", "localtime": "{time}"}}"#)) + .send() + .await; + + match res { + Ok(res) => { + let status = res.status(); + if !status.is_success() { + warn!(id = self.identifier, "Status code is not success: {status}"); + } + } + Err(err) => error!(id = self.identifier, "Error: {err}"), + } + } + + async fn stop_timeout(&mut self) { + let url = format!( + "http://{}/api/{}/schedules/{}", + self.addr, self.login, self.timer_id + ); + + let res = reqwest::Client::new() + .put(url) + .body(format!(r#"{{"status": "disabled"}}"#)) + .send() + .await; + + match res { + Ok(res) => { + let status = res.status(); + if !status.is_success() { + warn!(id = self.identifier, "Status code is not success: {status}"); + } + } + Err(err) => error!(id = self.identifier, "Error: {err}"), + } + } +} diff --git a/src/devices/ikea_outlet.rs b/src/devices/ikea_outlet.rs index c5f26aa..832f59e 100644 --- a/src/devices/ikea_outlet.rs +++ b/src/devices/ikea_outlet.rs @@ -14,7 +14,8 @@ use std::time::Duration; use tokio::task::JoinHandle; use tracing::{debug, error, trace, warn}; -use crate::config::{ConfigExternal, DeviceConfig, InfoConfig, MqttDeviceConfig}; +use crate::config::{InfoConfig, MqttDeviceConfig}; +use crate::device_manager::{ConfigExternal, DeviceConfig}; use crate::devices::Device; use crate::error::DeviceConfigError; use crate::event::OnMqtt; @@ -62,7 +63,7 @@ impl DeviceConfig for IkeaOutletConfig { ); let device = IkeaOutlet { - identifier: identifier.to_owned(), + identifier: identifier.into(), info: self.info, mqtt: self.mqtt, outlet_type: self.outlet_type, diff --git a/src/devices/kasa_outlet.rs b/src/devices/kasa_outlet.rs index deab941..26aa4ae 100644 --- a/src/devices/kasa_outlet.rs +++ b/src/devices/kasa_outlet.rs @@ -18,7 +18,7 @@ use tokio::{ use tracing::trace; use crate::{ - config::{ConfigExternal, DeviceConfig}, + device_manager::{ConfigExternal, DeviceConfig}, error::DeviceConfigError, }; @@ -39,7 +39,7 @@ impl DeviceConfig for KasaOutletConfig { trace!(id = identifier, "Setting up KasaOutlet"); let device = KasaOutlet { - identifier: identifier.to_owned(), + identifier: identifier.into(), addr: (self.ip, 9999).into(), }; diff --git a/src/devices/light_sensor.rs b/src/devices/light_sensor.rs index 8c650be..0b27316 100644 --- a/src/devices/light_sensor.rs +++ b/src/devices/light_sensor.rs @@ -5,9 +5,11 @@ use tracing::{debug, trace, warn}; use crate::{ config::MqttDeviceConfig, + device_manager::{ConfigExternal, DeviceConfig}, devices::Device, + error::DeviceConfigError, event::OnMqtt, - event::{self, Event, EventChannel}, + event::{self, Event}, messages::BrightnessMessage, }; @@ -21,8 +23,31 @@ pub struct LightSensorConfig { pub const DEFAULT: bool = false; +// TODO: The light sensor should get a list of devices that it should inform + +#[async_trait] +impl DeviceConfig for LightSensorConfig { + async fn create( + self, + identifier: &str, + ext: &ConfigExternal, + ) -> Result, DeviceConfigError> { + let device = LightSensor { + identifier: identifier.into(), + tx: ext.event_channel.get_tx(), + mqtt: self.mqtt, + min: self.min, + max: self.max, + is_dark: DEFAULT, + }; + + Ok(Box::new(device)) + } +} + #[derive(Debug)] pub struct LightSensor { + identifier: String, tx: event::Sender, mqtt: MqttDeviceConfig, min: isize, @@ -30,21 +55,9 @@ pub struct LightSensor { is_dark: bool, } -impl LightSensor { - pub fn new(config: LightSensorConfig, event_channel: &EventChannel) -> Self { - Self { - tx: event_channel.get_tx(), - mqtt: config.mqtt, - min: config.min, - max: config.max, - is_dark: DEFAULT, - } - } -} - impl Device for LightSensor { fn get_id(&self) -> &str { - "light_sensor" + &self.identifier } } diff --git a/src/devices/mod.rs b/src/devices/mod.rs index 64ebf2a..f4897d5 100644 --- a/src/devices/mod.rs +++ b/src/devices/mod.rs @@ -2,6 +2,7 @@ mod audio_setup; mod contact_sensor; mod debug_bridge; mod hue_bridge; +mod hue_light; mod ikea_outlet; mod kasa_outlet; mod light_sensor; @@ -12,8 +13,9 @@ mod washer; pub use self::audio_setup::AudioSetupConfig; pub use self::contact_sensor::ContactSensorConfig; -pub use self::debug_bridge::{DebugBridge, DebugBridgeConfig}; -pub use self::hue_bridge::{HueBridge, HueBridgeConfig, HueLightConfig}; +pub use self::debug_bridge::DebugBridgeConfig; +pub use self::hue_bridge::HueBridgeConfig; +pub use self::hue_light::HueLightConfig; pub use self::ikea_outlet::IkeaOutletConfig; pub use self::kasa_outlet::KasaOutletConfig; pub use self::light_sensor::{LightSensor, LightSensorConfig}; diff --git a/src/devices/wake_on_lan.rs b/src/devices/wake_on_lan.rs index 410a2de..04798d2 100644 --- a/src/devices/wake_on_lan.rs +++ b/src/devices/wake_on_lan.rs @@ -14,7 +14,8 @@ use serde::Deserialize; use tracing::{debug, error, trace}; use crate::{ - config::{ConfigExternal, DeviceConfig, InfoConfig, MqttDeviceConfig}, + config::{InfoConfig, MqttDeviceConfig}, + device_manager::{ConfigExternal, DeviceConfig}, error::DeviceConfigError, event::OnMqtt, messages::ActivateMessage, @@ -52,7 +53,7 @@ impl DeviceConfig for WakeOnLANConfig { ); let device = WakeOnLAN { - identifier: identifier.to_owned(), + identifier: identifier.into(), info: self.info, mqtt: self.mqtt, mac_address: self.mac_address, diff --git a/src/devices/washer.rs b/src/devices/washer.rs index 6e25cda..cbaf1a4 100644 --- a/src/devices/washer.rs +++ b/src/devices/washer.rs @@ -4,7 +4,8 @@ use serde::Deserialize; use tracing::{debug, error, warn}; use crate::{ - config::{ConfigExternal, DeviceConfig, MqttDeviceConfig}, + config::MqttDeviceConfig, + device_manager::{ConfigExternal, DeviceConfig}, error::DeviceConfigError, event::{Event, EventChannel, OnMqtt}, messages::PowerMessage, @@ -27,7 +28,7 @@ impl DeviceConfig for WasherConfig { ext: &ConfigExternal, ) -> Result, DeviceConfigError> { let device = Washer { - identifier: identifier.to_owned(), + identifier: identifier.into(), mqtt: self.mqtt, event_channel: ext.event_channel.clone(), threshold: self.threshold, diff --git a/src/main.rs b/src/main.rs index bd6a366..31b8ecb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,9 +10,9 @@ use tracing::{debug, error, info}; use automation::{ auth::{OpenIDConfig, User}, - config::{Config, ConfigExternal, DeviceConfig}, + config::Config, device_manager::DeviceManager, - devices::{DebugBridge, HueBridge, LightSensor, Ntfy, Presence}, + devices::{Ntfy, Presence}, error::ApiError, mqtt, }; @@ -58,27 +58,12 @@ async fn app() -> anyhow::Result<()> { // Setup the device handler let device_manager = DeviceManager::new(client.clone()); - let event_channel = device_manager.start(); - - // Create all the devices specified in the config - let ext = ConfigExternal { - client: &client, - device_manager: &device_manager, - presence_topic: &config.presence.mqtt.topic, - event_channel: &event_channel, - }; for (id, device_config) in config.devices { - let device = device_config.create(&id, &ext).await?; - - device_manager.add(device).await; + device_manager.create(&id, device_config).await?; } - // Create and add the light sensor - { - let light_sensor = LightSensor::new(config.light_sensor, &event_channel); - device_manager.add(Box::new(light_sensor)).await; - } + let event_channel = device_manager.event_channel(); // Create and add the presence system { @@ -86,18 +71,6 @@ async fn app() -> anyhow::Result<()> { device_manager.add(Box::new(presence)).await; } - // If configured, create and add the hue bridge - if let Some(config) = config.hue_bridge { - let hue_bridge = HueBridge::new(config); - device_manager.add(Box::new(hue_bridge)).await; - } - - // Start the debug bridge if it is configured - if let Some(config) = config.debug_bridge { - let debug_bridge = DebugBridge::new(config, &client); - device_manager.add(Box::new(debug_bridge)).await; - } - // Start the ntfy service if it is configured if let Some(config) = config.ntfy { let ntfy = Ntfy::new(config, &event_channel);