From 2b4ddf82b69b837e733cb3af4c8b62aedf5254da Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Wed, 28 Dec 2022 03:27:25 +0100 Subject: [PATCH] Added WakeOnLAN device, some small refactoring and improved error handling --- Cargo.lock | 186 ++++++++++++++++++++++++++++++++++++- Cargo.toml | 2 + config/zeus.dev.toml | 10 +- src/config.rs | 9 +- src/devices.rs | 3 + src/devices/ikea_outlet.rs | 118 +++++++++++++---------- src/devices/wake_on_lan.rs | 125 +++++++++++++++++++++++++ src/main.rs | 10 +- 8 files changed, 404 insertions(+), 59 deletions(-) create mode 100644 src/devices/wake_on_lan.rs diff --git a/Cargo.lock b/Cargo.lock index eee72e6..ba9b5c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,9 +13,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.66" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6" +checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" [[package]] name = "autocfg" @@ -27,12 +27,14 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" name = "automation" version = "0.1.0" dependencies = [ + "anyhow", "dotenv", "env_logger", "google-home", "impl_cast", "log", "paste", + "reqwest", "rumqttc", "serde", "serde_json", @@ -153,6 +155,15 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "encoding_rs" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9852635589dc9f9ea1b6fe9f05b50ef208c85c834a562f0c6abb1c475736ec2b" +dependencies = [ + "cfg-if", +] + [[package]] name = "env_logger" version = "0.10.0" @@ -215,6 +226,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.1.0" @@ -470,6 +496,19 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "idna" version = "0.3.0" @@ -516,6 +555,12 @@ dependencies = [ "windows-sys 0.42.0", ] +[[package]] +name = "ipnet" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11b0d96e660696543b251e58030cf9787df56da39dab19ad60eae7353040917e" + [[package]] name = "is-terminal" version = "0.4.2" @@ -641,6 +686,24 @@ dependencies = [ "getrandom", ] +[[package]] +name = "native-tls" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "num_cpus" version = "1.15.0" @@ -657,12 +720,51 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860" +[[package]] +name = "openssl" +version = "0.10.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b102428fd03bc5edf97f62620f7298614c45cedf287c271e7ed450bbaf83f2e1" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b501e44f11665960c7e7fcf062c7d96a14ade4aa98116c004b2e37b5be7d736c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "openssl-probe" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +[[package]] +name = "openssl-sys" +version = "0.9.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23bbbf7854cd45b83958ebe919f0e8e516793727652e27fda10a8384cfc790b7" +dependencies = [ + "autocfg", + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking_lot" version = "0.12.1" @@ -730,6 +832,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" + [[package]] name = "pollster" version = "0.2.5" @@ -831,6 +939,43 @@ dependencies = [ "winapi", ] +[[package]] +name = "reqwest" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68cc60575865c7831548863cc02356512e3f1dc2f3f82cb837d7fc4cc8f3c97c" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "ring" version = "0.16.20" @@ -1210,6 +1355,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.23.4" @@ -1387,6 +1542,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.4" @@ -1465,6 +1626,18 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23639446165ca5a5de86ae1d8896b737ae80319560fbaa4c2887b7da6e7ebd7d" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.83" @@ -1644,3 +1817,12 @@ name = "windows_x86_64_msvc" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] diff --git a/Cargo.toml b/Cargo.toml index b29d686..9b28536 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ log = "0.4" env_logger = "0.10" toml = "0.5.10" dotenv = "0.15.0" +anyhow = "1.0.68" +reqwest = "0.11.13" [profile.release] lto=true diff --git a/config/zeus.dev.toml b/config/zeus.dev.toml index fa01713..adaff86 100644 --- a/config/zeus.dev.toml +++ b/config/zeus.dev.toml @@ -10,10 +10,16 @@ username="Dreaded_X" [devices.kitchen_kettle] type = "IkeaOutlet" info = { name = "Kettle", room = "Kitchen" } -zigbee = { topic = "zigbee2mqtt/kitchen/kettle" } +mqtt = { topic = "zigbee2mqtt/kitchen/kettle" } kettle = { timeout = 5 } [devices.living_workbench] type = "IkeaOutlet" info = { name = "Workbench", room = "Living Room" } -zigbee = { topic = "zigbee2mqtt/living/workbench" } +mqtt = { topic = "zigbee2mqtt/living/workbench" } + +[devices.living_zeus] +type = "WakeOnLAN" +info = { name = "Zeus", room = "Living Room" } +mqtt = { topic = "automation/appliance/living_room/zeus" } +mac_address = "30:9c:23:60:9c:13" diff --git a/src/config.rs b/src/config.rs index 1e2b7ce..f5c7406 100644 --- a/src/config.rs +++ b/src/config.rs @@ -32,7 +32,7 @@ pub struct InfoConfig { } #[derive(Debug, Deserialize)] -pub struct ZigbeeDeviceConfig { +pub struct MqttDeviceConfig { pub topic: String, } @@ -46,9 +46,14 @@ pub struct KettleConfig { pub enum Device { IkeaOutlet { info: InfoConfig, - zigbee: ZigbeeDeviceConfig, + mqtt: MqttDeviceConfig, kettle: Option, }, + WakeOnLAN { + info: InfoConfig, + mqtt: MqttDeviceConfig, + mac_address: String, + } } impl Config { diff --git a/src/devices.rs b/src/devices.rs index b6cda79..d09f660 100644 --- a/src/devices.rs +++ b/src/devices.rs @@ -1,6 +1,9 @@ mod ikea_outlet; pub use self::ikea_outlet::IkeaOutlet; +mod wake_on_lan; +pub use self::wake_on_lan::WakeOnLAN; + use std::collections::HashMap; use google_home::{GoogleHomeDevice, traits::OnOff}; diff --git a/src/devices/ikea_outlet.rs b/src/devices/ikea_outlet.rs index 00e9018..3fc9cee 100644 --- a/src/devices/ikea_outlet.rs +++ b/src/devices/ikea_outlet.rs @@ -4,17 +4,17 @@ use google_home::errors::ErrorCode; use google_home::{GoogleHomeDevice, device, types::Type, traits}; use rumqttc::{AsyncClient, Publish}; use serde::{Deserialize, Serialize}; -use log::{debug, trace}; +use log::{debug, trace, warn}; use tokio::task::JoinHandle; -use crate::config::{KettleConfig, InfoConfig, ZigbeeDeviceConfig}; +use crate::config::{KettleConfig, InfoConfig, MqttDeviceConfig}; use crate::devices::Device; use crate::mqtt::Listener; pub struct IkeaOutlet { identifier: String, info: InfoConfig, - zigbee: ZigbeeDeviceConfig, + mqtt: MqttDeviceConfig, kettle: Option, client: AsyncClient, @@ -23,15 +23,15 @@ pub struct IkeaOutlet { } impl IkeaOutlet { - pub fn new(identifier: String, info: InfoConfig, zigbee: ZigbeeDeviceConfig, kettle: Option, client: AsyncClient) -> Self { + pub fn new(identifier: String, info: InfoConfig, mqtt: MqttDeviceConfig, kettle: Option, client: AsyncClient) -> Self { let c = client.clone(); - let t = zigbee.topic.clone(); + let t = mqtt.topic.clone(); // @TODO Handle potential errors here tokio::spawn(async move { c.subscribe(t, rumqttc::QoS::AtLeastOnce).await.unwrap(); }); - Self{ identifier, info, zigbee, kettle, client, last_known_state: false, handle: None } + Self{ identifier, info, mqtt, kettle, client, last_known_state: false, handle: None } } } @@ -59,64 +59,77 @@ struct StateMessage { state: String } -impl From<&Publish> for StateMessage { - fn from(p: &Publish) -> Self { - let parsed = match serde_json::from_slice(&p.payload) { - Ok(outlet) => outlet, - Err(err) => { - panic!("{}", err); - } - }; +impl TryFrom<&Publish> for StateMessage { + type Error = anyhow::Error; - parsed + fn try_from(message: &Publish) -> Result { + match serde_json::from_slice(&message.payload) { + Ok(message) => Ok(message), + Err(..) => { + Err(anyhow::anyhow!("Invalid message payload received: {:?}", message.payload)) + } + } } } impl Listener for IkeaOutlet { fn notify(&mut self, message: &Publish) { // Update the internal state based on what the device has reported - if message.topic == self.zigbee.topic { - let new_state = StateMessage::from(message).state == "ON"; + if message.topic != self.mqtt.topic { + return; + } - // No need to do anything if the state has not changed - if new_state == self.last_known_state { + let new_state = match StateMessage::try_from(message) { + Ok(state) => state, + Err(err) => { + warn!("Failed to parse message: {err}"); return; } + }.state == "ON"; - // Abort any timer that is currently running - if let Some(handle) = self.handle.take() { - handle.abort(); - } + // No need to do anything if the state has not changed + if new_state == self.last_known_state { + return; + } - trace!("Updating state: {} => {}", self.last_known_state, new_state); - self.last_known_state = new_state; + // Abort any timer that is currently running + if let Some(handle) = self.handle.take() { + handle.abort(); + } - // If this is a kettle start a timeout for turning it of again - if new_state { - if let Some(kettle) = &self.kettle { - if let Some(timeout) = kettle.timeout.clone() { - let client = self.client.clone(); - let topic = self.zigbee.topic.clone(); + trace!("Updating state: {} => {}", self.last_known_state, new_state); + self.last_known_state = new_state; - // Turn the kettle of after the specified timeout - // @TODO Impl Drop for IkeaOutlet that will abort the handle if the IkeaOutlet - // get dropped - self.handle = Some( - tokio::spawn(async move { - debug!("Starting timeout ({timeout}s) for kettle..."); - tokio::time::sleep(Duration::from_secs(timeout)).await; - // @TODO We need to call set_on(false) in order to turn the device off - // again, how are we going to do this? - debug!("Turning kettle off!"); - set_on(client, topic, false).await; - }) - ); - } else { - trace!("Outlet is a kettle without timeout"); - } + // If this is a kettle start a timeout for turning it of again + if new_state { + let kettle = match &self.kettle { + Some(kettle) => kettle, + None => return, + }; - } - } + let timeout = match kettle.timeout.clone() { + Some(timeout) => timeout, + None => { + trace!("Outlet is a kettle without timeout"); + return; + }, + }; + + // Turn the kettle of after the specified timeout + // @TODO Impl Drop for IkeaOutlet that will abort the handle if the IkeaOutlet + // get dropped + let client = self.client.clone(); + let topic = self.mqtt.topic.clone(); + self.handle = Some( + tokio::spawn(async move { + debug!("Starting timeout ({timeout}s) for kettle..."); + tokio::time::sleep(Duration::from_secs(timeout)).await; + // @TODO We need to call set_on(false) in order to turn the device off + // again, how are we going to do this? + debug!("Turning kettle off!"); + set_on(client, topic, false).await; + }) + ); } } } @@ -145,6 +158,11 @@ impl GoogleHomeDevice for IkeaOutlet { fn get_room_hint(&self) -> Option { self.info.room.clone() } + + fn will_report_state(&self) -> bool { + // @TODO Implement state reporting + false + } } impl traits::OnOff for IkeaOutlet { @@ -154,7 +172,7 @@ impl traits::OnOff for IkeaOutlet { fn set_on(&mut self, on: bool) -> Result<(), ErrorCode> { let client = self.client.clone(); - let topic = self.zigbee.topic.clone(); + let topic = self.mqtt.topic.clone(); tokio::spawn(async move { set_on(client, topic, on).await; }); diff --git a/src/devices/wake_on_lan.rs b/src/devices/wake_on_lan.rs new file mode 100644 index 0000000..813a18a --- /dev/null +++ b/src/devices/wake_on_lan.rs @@ -0,0 +1,125 @@ +use google_home::{GoogleHomeDevice, types::Type, device, traits::{self, Scene}, errors::{ErrorCode, DeviceError}}; +use log::{debug, warn}; +use rumqttc::{AsyncClient, Publish}; +use serde::Deserialize; + +use crate::{config::{InfoConfig, MqttDeviceConfig}, mqtt::Listener}; + +use super::Device; + +pub struct WakeOnLAN { + identifier: String, + info: InfoConfig, + mqtt: MqttDeviceConfig, + mac_address: String, +} + +impl WakeOnLAN { + pub fn new(identifier: String, info: InfoConfig, mqtt: MqttDeviceConfig, mac_address: String, client: AsyncClient) -> Self { + let c = client.clone(); + let t = mqtt.topic.clone(); + // @TODO Handle potential errors here + tokio::spawn(async move { + c.subscribe(t, rumqttc::QoS::AtLeastOnce).await.unwrap(); + }); + + Self { identifier, info, mqtt, mac_address } + } +} + +impl Device for WakeOnLAN { + fn get_id(&self) -> String { + self.identifier.clone() + } +} + +#[derive(Debug, Deserialize)] +struct StateMessage { + activate: bool +} + +impl TryFrom<&Publish> for StateMessage { + type Error = anyhow::Error; + + fn try_from(message: &Publish) -> Result { + match serde_json::from_slice(&message.payload) { + Ok(message) => Ok(message), + Err(..) => { + Err(anyhow::anyhow!("Invalid message payload received: {:?}", message.payload)) + } + } + } +} + +impl Listener for WakeOnLAN { + fn notify(&mut self, message: &Publish) { + + if message.topic != self.mqtt.topic { + return; + } + + let payload = match StateMessage::try_from(message) { + Ok(state) => state, + Err(err) => { + warn!("Failed to parse message: {err}"); + return; + } + }; + + self.set_active(payload.activate).ok(); + } +} + +impl GoogleHomeDevice for WakeOnLAN { + fn get_device_type(&self) -> Type { + Type::Scene + } + + fn get_device_name(&self) -> device::Name { + let mut name = device::Name::new(&self.info.name); + name.add_default_name("Computer"); + + return name; + } + + fn get_id(&self) -> String { + Device::get_id(self) + } + + fn is_online(&self) -> bool { + true + } + + fn get_room_hint(&self) -> Option { + self.info.room.clone() + } +} + +impl traits::Scene for WakeOnLAN { + fn set_active(&self, activate: bool) -> Result<(), ErrorCode> { + if activate { + // @TODO In the future send the wake on lan package directly, this is kind of annoying + // if we are inside of docker, so for now just call a webhook that does it for us + let mac_address = self.mac_address.clone(); + tokio::spawn(async move { + debug!("Activating Computer: {}", mac_address); + let req = match reqwest::get(format!("http://10.0.0.2:9000/start-pc?mac={mac_address}")).await { + Ok(req) => req, + Err(err) => { + warn!("Failed to call webhook: {err}"); + return; + } + }; + if req.status() != 200 { + warn!("Failed to call webhook: {}", req.status()); + } + }); + + Ok(()) + } else { + debug!("Trying to deactive computer, this is not currently supported"); + // We do not support deactivating this scene + Err(ErrorCode::DeviceError(DeviceError::ActionNotAvailable)) + } + } +} diff --git a/src/main.rs b/src/main.rs index 6d7dff3..b73720f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use rumqttc::{MqttOptions, Transport, AsyncClient}; use env_logger::Builder; use log::{error, info, debug, trace, LevelFilter}; -use automation::{devices::{Devices, IkeaOutlet}, mqtt::Notifier}; +use automation::{devices::{Devices, IkeaOutlet, WakeOnLAN}, mqtt::Notifier}; use google_home::{GoogleHome, Request}; #[tokio::main] @@ -57,9 +57,13 @@ async fn main() { debug!("Adding device {identifier}"); let device: automation::devices::DeviceBox = match device_config { - Device::IkeaOutlet { info, zigbee, kettle } => { + Device::IkeaOutlet { info, mqtt, kettle } => { trace!("\tIkeaOutlet [{} in {:?}]", info.name, info.room); - Box::new(IkeaOutlet::new(identifier, info, zigbee, kettle, client.clone())) + Box::new(IkeaOutlet::new(identifier, info, mqtt, kettle, client.clone())) + }, + Device::WakeOnLAN { info, mqtt, mac_address } => { + trace!("\tWakeOnLan [{} in {:?}]", info.name, info.room); + Box::new(WakeOnLAN::new(identifier, info, mqtt, mac_address, client.clone())) }, };