From bb131f2b1a0bed9be37d6a22571f633f1b598acb Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Tue, 15 Aug 2023 04:45:35 +0200 Subject: [PATCH] Added basic hue light bridge, improved Timeout trait and setup frontdoor to turn on hallway ligh temporarily --- config/config.toml | 8 ++ config/zeus.dev.toml | 9 +- src/config.rs | 8 +- src/devices/contact_sensor.rs | 3 +- src/devices/hue_bridge.rs | 177 +++++++++++++++++++++++++++++++++- src/devices/ikea_outlet.rs | 19 ++-- src/devices/mod.rs | 2 +- src/traits.rs | 5 +- 8 files changed, 211 insertions(+), 20 deletions(-) diff --git a/config/config.toml b/config/config.toml index ec14367..8ed0fbe 100644 --- a/config/config.toml +++ b/config/config.toml @@ -79,10 +79,18 @@ topic = "zigbee2mqtt/living/remote" mixer = "living_mixer" speakers = "living_speakers" +[devices.hallway_light] +type = "HueLight" +ip = "10.0.0.146" +login = "${HUE_TOKEN}" +light_id = 16 +timer_id = 1 + [devices.hallway_frontdoor] type = "ContactSensor" topic = "zigbee2mqtt/hallway/frontdoor" presence = { timeout = 900 } +lights = { lights = ["hallway_light"], timeout = 60 } [devices.bathroom_washer] type = "Washer" diff --git a/config/zeus.dev.toml b/config/zeus.dev.toml index 584810e..33cdf8f 100644 --- a/config/zeus.dev.toml +++ b/config/zeus.dev.toml @@ -79,11 +79,18 @@ topic = "zigbee2mqtt/living/remote" mixer = "living_mixer" speakers = "living_speakers" +[devices.hallway_light] +type = "HueLight" +ip = "10.0.0.146" +login = "${HUE_TOKEN}" +light_id = 16 +timer_id = 1 + [devices.hallway_frontdoor] type = "ContactSensor" topic = "zigbee2mqtt/hallway/frontdoor" presence = { timeout = 10 } -lights = { lights = ["bathroom_light"], timeout = 10 } +lights = { lights = ["hallway_light"], timeout = 10 } [devices.bathroom_washer] type = "Washer" diff --git a/src/config.rs b/src/config.rs index 8a1431e..39df54f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,8 +14,8 @@ use crate::{ auth::OpenIDConfig, device_manager::DeviceManager, devices::{ - AudioSetup, ContactSensor, DebugBridgeConfig, Device, HueBridgeConfig, IkeaOutlet, - KasaOutlet, LightSensorConfig, PresenceConfig, WakeOnLAN, Washer, + AudioSetup, ContactSensor, DebugBridgeConfig, Device, HueBridgeConfig, HueLight, + IkeaOutlet, KasaOutlet, LightSensorConfig, PresenceConfig, WakeOnLAN, Washer, }, error::{ConfigParseError, CreateDeviceError, MissingEnv}, event::EventChannel, @@ -131,6 +131,7 @@ pub enum DeviceConfig { KasaOutlet(::Config), WakeOnLAN(::Config), Washer(::Config), + HueLight(::Config), } impl Config { @@ -202,7 +203,8 @@ impl DeviceConfig { IkeaOutlet, KasaOutlet, WakeOnLAN, - Washer + Washer, + HueLight ] }) } diff --git a/src/devices/contact_sensor.rs b/src/devices/contact_sensor.rs index 99f1f6f..063a60b 100644 --- a/src/devices/contact_sensor.rs +++ b/src/devices/contact_sensor.rs @@ -201,8 +201,9 @@ impl OnMqtt for ContactSensor { if lights.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); + light.start_timeout(lights.timeout).await; } + // TODO: Put a warning/error on creation if either of this has to option to fail } } } diff --git a/src/devices/hue_bridge.rs b/src/devices/hue_bridge.rs index bd70ebb..f90eb09 100644 --- a/src/devices/hue_bridge.rs +++ b/src/devices/hue_bridge.rs @@ -1,10 +1,19 @@ -use std::net::{Ipv4Addr, SocketAddr}; +use std::{ + net::{Ipv4Addr, SocketAddr}, + time::Duration, +}; use async_trait::async_trait; +use google_home::{errors::ErrorCode, traits::OnOff}; +use rumqttc::AsyncClient; use serde::{Deserialize, Serialize}; -use tracing::{error, trace, warn}; +use serde_json::Value; +use tracing::{debug, error, trace, warn}; -use crate::{devices::Device, event::OnDarkness, event::OnPresence}; +use crate::{ + config::CreateDevice, device_manager::DeviceManager, devices::Device, error::CreateDeviceError, + event::EventChannel, event::OnDarkness, event::OnPresence, traits::Timeout, +}; #[derive(Debug)] pub enum Flag { @@ -68,9 +77,7 @@ impl HueBridge { } } } -} -impl HueBridge { pub fn new(config: HueBridgeConfig) -> Self { Self { addr: (config.ip, 80).into(), @@ -101,3 +108,163 @@ 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, +} + +#[derive(Debug)] +pub struct HueLight { + pub identifier: String, + pub addr: SocketAddr, + pub login: String, + pub light_id: isize, + pub timer_id: isize, +} + +#[async_trait] +impl CreateDevice for HueLight { + type Config = HueLightConfig; + + async fn create( + identifier: &str, + config: Self::Config, + _event_channel: &EventChannel, + _client: &AsyncClient, + _presence_topic: &str, + _devices: &DeviceManager, + ) -> Result { + Ok(Self { + identifier: identifier.to_owned(), + addr: (config.ip, 80).into(), + login: config.login, + light_id: config.light_id, + timer_id: config.timer_id, + }) + } +} + +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!(self.identifier, "Status code is not success: {status}"); + } + } + Err(err) => error!(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!(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!(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!(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!(self.identifier, "Status code is not success: {status}"); + } + } + Err(err) => error!(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!(self.identifier, "Status code is not success: {status}"); + } + } + Err(err) => error!(self.identifier, "Error: {err}"), + } + } +} diff --git a/src/devices/ikea_outlet.rs b/src/devices/ikea_outlet.rs index 59cbfb2..f22a137 100644 --- a/src/devices/ikea_outlet.rs +++ b/src/devices/ikea_outlet.rs @@ -135,16 +135,14 @@ impl OnMqtt for IkeaOutlet { } // Abort any timer that is currently running - if let Some(handle) = self.handle.take() { - handle.abort(); - } + self.stop_timeout().await; debug!(id = self.identifier, "Updating state to {state}"); self.last_known_state = state; // If this is a kettle start a timeout for turning it of again if state && let Some(timeout) = self.timeout { - self.start_timeout(timeout); + self.start_timeout(timeout).await; } } } @@ -205,12 +203,11 @@ impl traits::OnOff for IkeaOutlet { } } +#[async_trait] impl crate::traits::Timeout for IkeaOutlet { - fn start_timeout(&mut self, timeout: Duration) { + async fn start_timeout(&mut self, timeout: Duration) { // Abort any timer that is currently running - if let Some(handle) = self.handle.take() { - handle.abort(); - } + self.stop_timeout().await; // Turn the kettle of after the specified timeout // TODO: Impl Drop for IkeaOutlet that will abort the handle if the IkeaOutlet @@ -228,4 +225,10 @@ impl crate::traits::Timeout for IkeaOutlet { set_on(client, &topic, false).await; })); } + + async fn stop_timeout(&mut self) { + if let Some(handle) = self.handle.take() { + handle.abort(); + } + } } diff --git a/src/devices/mod.rs b/src/devices/mod.rs index 36bd5b0..3729964 100644 --- a/src/devices/mod.rs +++ b/src/devices/mod.rs @@ -13,7 +13,7 @@ mod washer; pub use self::audio_setup::AudioSetup; pub use self::contact_sensor::ContactSensor; pub use self::debug_bridge::{DebugBridge, DebugBridgeConfig}; -pub use self::hue_bridge::{HueBridge, HueBridgeConfig}; +pub use self::hue_bridge::{HueBridge, HueBridgeConfig, HueLight}; pub use self::ikea_outlet::IkeaOutlet; pub use self::kasa_outlet::KasaOutlet; pub use self::light_sensor::{LightSensor, LightSensorConfig}; diff --git a/src/traits.rs b/src/traits.rs index b359b2d..bd55a97 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -1,8 +1,11 @@ use std::time::Duration; +use async_trait::async_trait; use impl_cast::device_trait; +#[async_trait] #[device_trait] pub trait Timeout { - fn start_timeout(&mut self, _timeout: Duration) {} + async fn start_timeout(&mut self, _timeout: Duration); + async fn stop_timeout(&mut self); }