From b12b76bd5010f57a816e0fc7505539bbd4a62ebf Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Sat, 7 Oct 2023 05:34:33 +0200 Subject: [PATCH] Added Air Filter support --- config/config.toml | 6 + config/zeus.dev.toml | 6 + google-home/src/attributes.rs | 8 ++ google-home/src/device.rs | 29 +++- google-home/src/request/execute.rs | 4 +- google-home/src/response.rs | 3 + google-home/src/response/execute.rs | 5 +- google-home/src/traits.rs | 36 +++++ google-home/src/types.rs | 2 + src/device_manager.rs | 7 +- src/devices/air_filter.rs | 216 ++++++++++++++++++++++++++++ src/devices/mod.rs | 2 + src/messages.rs | 34 +++++ 13 files changed, 347 insertions(+), 11 deletions(-) create mode 100644 src/devices/air_filter.rs diff --git a/config/config.toml b/config/config.toml index a07440a..316831b 100644 --- a/config/config.toml +++ b/config/config.toml @@ -111,3 +111,9 @@ type = "ContactSensor" topic = "zigbee2mqtt/hallway/frontdoor" presence = { topic = "automation/presence/contact/frontdoor", timeout = 900 } trigger = { devices = ["hallway_lights"], timeout = 60 } + +[device.bedroom_air_filter] +type = "AirFilter" +name = "Air Filter" +room = "Bedroom" +topic = "pico/filter/test" diff --git a/config/zeus.dev.toml b/config/zeus.dev.toml index b9cfb43..69d0b15 100644 --- a/config/zeus.dev.toml +++ b/config/zeus.dev.toml @@ -112,3 +112,9 @@ type = "ContactSensor" topic = "zigbee2mqtt/hallway/frontdoor" presence = { topic = "automation_dev/presence/contact/frontdoor", timeout = 10 } trigger = { devices = ["hallway_lights"], timeout = 10 } + +[device.bedroom_air_filter] +type = "AirFilter" +name = "Air Filter" +room = "Bedroom" +topic = "pico/filter/test" diff --git a/google-home/src/attributes.rs b/google-home/src/attributes.rs index 44dee16..523d5db 100644 --- a/google-home/src/attributes.rs +++ b/google-home/src/attributes.rs @@ -1,5 +1,7 @@ use serde::Serialize; +use crate::traits::AvailableSpeeds; + #[derive(Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct Attributes { @@ -9,4 +11,10 @@ pub struct Attributes { pub query_only_on_off: Option, #[serde(skip_serializing_if = "Option::is_none")] pub scene_reversible: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reversible: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub command_only_fan_speed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub available_fan_speeds: Option, } diff --git a/google-home/src/device.rs b/google-home/src/device.rs index 95655a9..ce325ef 100644 --- a/google-home/src/device.rs +++ b/google-home/src/device.rs @@ -5,7 +5,7 @@ use crate::{ errors::{DeviceError, ErrorCode}, request::execute::CommandType, response, - traits::{OnOff, Scene, Trait}, + traits::{FanSpeed, OnOff, Scene, Trait}, types::Type, }; @@ -44,7 +44,7 @@ where } #[async_trait] -#[impl_cast::device(As: OnOff + Scene)] +#[impl_cast::device(As: OnOff + Scene + FanSpeed)] pub trait GoogleHomeDevice: AsGoogleHomeDevice + Sync + Send + 'static { fn get_device_type(&self) -> Type; fn get_device_name(&self) -> Name; @@ -90,6 +90,13 @@ pub trait GoogleHomeDevice: AsGoogleHomeDevice + Sync + Send + 'static { device.attributes.scene_reversible = scene.is_scene_reversible(); } + // FanSpeed + if let Some(fan_speed) = As::::cast(self) { + traits.push(Trait::FanSpeed); + device.attributes.command_only_fan_speed = fan_speed.command_only_fan_speed(); + device.attributes.available_fan_speeds = Some(fan_speed.available_speeds()); + } + device.traits = traits; device @@ -110,25 +117,35 @@ pub trait GoogleHomeDevice: AsGoogleHomeDevice + Sync + Send + 'static { .ok(); } + // FanSpeed + if let Some(fan_speed) = As::::cast(self) { + device.state.current_fan_speed_setting = Some(fan_speed.current_speed().await); + } + device } async fn execute(&mut self, command: &CommandType) -> Result<(), ErrorCode> { match command { CommandType::OnOff { on } => { - if let Some(on_off) = As::::cast_mut(self) { - on_off.set_on(*on).await?; + if let Some(t) = As::::cast_mut(self) { + t.set_on(*on).await?; } else { return Err(DeviceError::ActionNotAvailable.into()); } } CommandType::ActivateScene { deactivate } => { - if let Some(scene) = As::::cast(self) { - scene.set_active(!deactivate).await?; + if let Some(t) = As::::cast(self) { + t.set_active(!deactivate).await?; } else { return Err(DeviceError::ActionNotAvailable.into()); } } + CommandType::SetFanSpeed { fan_speed } => { + if let Some(t) = As::::cast(self) { + t.set_speed(fan_speed).await?; + } + } } Ok(()) diff --git a/google-home/src/request/execute.rs b/google-home/src/request/execute.rs index d76c969..0d67bcf 100644 --- a/google-home/src/request/execute.rs +++ b/google-home/src/request/execute.rs @@ -20,13 +20,15 @@ pub struct Device { // customData } -#[derive(Debug, Deserialize, Clone, Copy)] +#[derive(Debug, Deserialize, Clone)] #[serde(tag = "command", content = "params")] pub enum CommandType { #[serde(rename = "action.devices.commands.OnOff")] OnOff { on: bool }, #[serde(rename = "action.devices.commands.ActivateScene")] ActivateScene { deactivate: bool }, + #[serde(rename = "action.devices.commands.SetFanSpeed")] + SetFanSpeed { fan_speed: String }, } #[cfg(test)] diff --git a/google-home/src/response.rs b/google-home/src/response.rs index 191b07b..dfb68b4 100644 --- a/google-home/src/response.rs +++ b/google-home/src/response.rs @@ -33,4 +33,7 @@ pub enum ResponsePayload { pub struct State { #[serde(skip_serializing_if = "Option::is_none")] pub on: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub current_fan_speed_setting: Option, } diff --git a/google-home/src/response/execute.rs b/google-home/src/response/execute.rs index 7473736..1aee44b 100644 --- a/google-home/src/response/execute.rs +++ b/google-home/src/response/execute.rs @@ -96,7 +96,10 @@ mod tests { fn serialize() { let mut execute_resp = Payload::new(); - let state = State { on: Some(true) }; + let state = State { + on: Some(true), + current_fan_speed_setting: None, + }; let mut command = Command::new(Status::Success); command.states = Some(States { online: true, diff --git a/google-home/src/traits.rs b/google-home/src/traits.rs index 0daaf98..c38100d 100644 --- a/google-home/src/traits.rs +++ b/google-home/src/traits.rs @@ -9,6 +9,8 @@ pub enum Trait { OnOff, #[serde(rename = "action.devices.traits.Scene")] Scene, + #[serde(rename = "action.devices.traits.FanSpeed")] + FanSpeed, } #[async_trait] @@ -36,3 +38,37 @@ pub trait Scene { async fn set_active(&self, activate: bool) -> Result<(), ErrorCode>; } + +#[derive(Debug, Serialize)] +pub struct SpeedValues { + pub speed_synonym: Vec, + pub lang: String, +} + +#[derive(Debug, Serialize)] +pub struct Speed { + pub speed_name: String, + pub speed_values: Vec, +} + +#[derive(Debug, Serialize)] +pub struct AvailableSpeeds { + pub speeds: Vec, + pub ordered: bool, +} + +#[async_trait] +#[impl_cast::device_trait] +pub trait FanSpeed { + fn reversible(&self) -> Option { + None + } + + fn command_only_fan_speed(&self) -> Option { + None + } + + fn available_speeds(&self) -> AvailableSpeeds; + async fn current_speed(&self) -> String; + async fn set_speed(&self, speed: &str) -> Result<(), ErrorCode>; +} diff --git a/google-home/src/types.rs b/google-home/src/types.rs index a430e44..d082088 100644 --- a/google-home/src/types.rs +++ b/google-home/src/types.rs @@ -10,4 +10,6 @@ pub enum Type { Light, #[serde(rename = "action.devices.types.SCENE")] Scene, + #[serde(rename = "action.devices.types.AIRPURIFIER")] + AirPurifier, } diff --git a/src/device_manager.rs b/src/device_manager.rs index 5da5653..3126c32 100644 --- a/src/device_manager.rs +++ b/src/device_manager.rs @@ -11,9 +11,9 @@ use tracing::{debug, error, instrument, trace}; use crate::{ devices::{ - As, AudioSetupConfig, ContactSensorConfig, DebugBridgeConfig, Device, HueBridgeConfig, - HueGroupConfig, IkeaOutletConfig, KasaOutletConfig, LightSensorConfig, WakeOnLANConfig, - WasherConfig, + AirFilterConfig, As, AudioSetupConfig, ContactSensorConfig, DebugBridgeConfig, Device, + HueBridgeConfig, HueGroupConfig, IkeaOutletConfig, KasaOutletConfig, LightSensorConfig, + WakeOnLANConfig, WasherConfig, }, error::DeviceConfigError, event::OnDarkness, @@ -42,6 +42,7 @@ pub trait DeviceConfig { #[serde(tag = "type")] #[enum_dispatch(DeviceConfig)] pub enum DeviceConfigs { + AirFilter(AirFilterConfig), AudioSetup(AudioSetupConfig), ContactSensor(ContactSensorConfig), DebugBridge(DebugBridgeConfig), diff --git a/src/devices/air_filter.rs b/src/devices/air_filter.rs new file mode 100644 index 0000000..ac9ec89 --- /dev/null +++ b/src/devices/air_filter.rs @@ -0,0 +1,216 @@ +use async_trait::async_trait; +use google_home::device::Name; +use google_home::errors::ErrorCode; +use google_home::traits::{AvailableSpeeds, FanSpeed, OnOff, Speed, SpeedValues}; +use google_home::types::Type; +use google_home::GoogleHomeDevice; +use rumqttc::{AsyncClient, Publish}; +use serde::Deserialize; +use tracing::{debug, error, warn}; + +use crate::config::{InfoConfig, MqttDeviceConfig}; +use crate::device_manager::{ConfigExternal, DeviceConfig}; +use crate::devices::Device; +use crate::error::DeviceConfigError; +use crate::event::OnMqtt; +use crate::messages::{AirFilterMessage, AirFilterState}; + +#[derive(Debug, Deserialize)] +pub struct AirFilterConfig { + #[serde(flatten)] + info: InfoConfig, + #[serde(flatten)] + mqtt: MqttDeviceConfig, +} + +#[async_trait] +impl DeviceConfig for AirFilterConfig { + async fn create( + self, + identifier: &str, + ext: &ConfigExternal, + ) -> Result, DeviceConfigError> { + let device = AirFilter { + identifier: identifier.into(), + info: self.info, + mqtt: self.mqtt, + client: ext.client.clone(), + last_known_state: AirFilterState::Off, + }; + + Ok(Box::new(device)) + } +} + +#[derive(Debug)] +pub struct AirFilter { + identifier: String, + info: InfoConfig, + mqtt: MqttDeviceConfig, + + client: AsyncClient, + last_known_state: AirFilterState, +} + +impl AirFilter { + async fn set_speed(&self, state: AirFilterState) { + let message = AirFilterMessage::new(state); + + let topic = format!("{}/set", self.mqtt.topic); + // TODO: Handle potential errors here + self.client + .publish( + topic.clone(), + rumqttc::QoS::AtLeastOnce, + false, + serde_json::to_string(&message).unwrap(), + ) + .await + .map_err(|err| warn!("Failed to update state on {topic}: {err}")) + .ok(); + } +} + +impl Device for AirFilter { + fn get_id(&self) -> &str { + &self.identifier + } +} + +#[async_trait] +impl OnMqtt for AirFilter { + fn topics(&self) -> Vec<&str> { + vec![&self.mqtt.topic] + } + + async fn on_mqtt(&mut self, message: Publish) { + let state = match AirFilterMessage::try_from(message) { + Ok(state) => state.state(), + Err(err) => { + error!(id = self.identifier, "Failed to parse message: {err}"); + return; + } + }; + + if state == self.last_known_state { + return; + } + + debug!(id = self.identifier, "Updating state to {state:?}"); + + self.last_known_state = state; + } +} + +impl GoogleHomeDevice for AirFilter { + fn get_device_type(&self) -> Type { + Type::AirPurifier + } + + fn get_device_name(&self) -> Name { + Name::new(&self.info.name) + } + + fn get_id(&self) -> &str { + Device::get_id(self) + } + + fn is_online(&self) -> bool { + true + } + + fn get_room_hint(&self) -> Option<&str> { + self.info.room.as_deref() + } + + fn will_report_state(&self) -> bool { + false + } +} + +#[async_trait] +impl OnOff for AirFilter { + async fn is_on(&self) -> Result { + Ok(self.last_known_state != AirFilterState::Off) + } + + async fn set_on(&mut self, on: bool) -> Result<(), ErrorCode> { + debug!("Turning on air filter: {on}"); + + if on { + self.set_speed(AirFilterState::High).await; + } else { + self.set_speed(AirFilterState::Off).await; + } + + Ok(()) + } +} + +#[async_trait] +impl FanSpeed for AirFilter { + fn available_speeds(&self) -> AvailableSpeeds { + AvailableSpeeds { + speeds: vec![ + Speed { + speed_name: "off".into(), + speed_values: vec![SpeedValues { + speed_synonym: vec!["Off".into()], + lang: "en".into(), + }], + }, + Speed { + speed_name: "low".into(), + speed_values: vec![SpeedValues { + speed_synonym: vec!["Low".into()], + lang: "en".into(), + }], + }, + Speed { + speed_name: "medium".into(), + speed_values: vec![SpeedValues { + speed_synonym: vec!["Medium".into()], + lang: "en".into(), + }], + }, + Speed { + speed_name: "high".into(), + speed_values: vec![SpeedValues { + speed_synonym: vec!["High".into()], + lang: "en".into(), + }], + }, + ], + ordered: true, + } + } + + async fn current_speed(&self) -> String { + let speed = match self.last_known_state { + AirFilterState::Off => "off", + AirFilterState::Low => "low", + AirFilterState::Medium => "medium", + AirFilterState::High => "high", + }; + + speed.into() + } + + async fn set_speed(&self, speed: &str) -> Result<(), ErrorCode> { + let state = if speed == "off" { + AirFilterState::Off + } else if speed == "low" { + AirFilterState::Low + } else if speed == "medium" { + AirFilterState::Medium + } else if speed == "high" { + AirFilterState::High + } else { + return Err(google_home::errors::DeviceError::TransientError.into()); + }; + + self.set_speed(state).await; + + Ok(()) + } +} diff --git a/src/devices/mod.rs b/src/devices/mod.rs index 1262d7e..2901a7c 100644 --- a/src/devices/mod.rs +++ b/src/devices/mod.rs @@ -1,3 +1,4 @@ +mod air_filter; mod audio_setup; mod contact_sensor; mod debug_bridge; @@ -11,6 +12,7 @@ mod presence; mod wake_on_lan; mod washer; +pub use self::air_filter::AirFilterConfig; pub use self::audio_setup::AudioSetupConfig; pub use self::contact_sensor::ContactSensorConfig; pub use self::debug_bridge::DebugBridgeConfig; diff --git a/src/messages.rs b/src/messages.rs index 1ab36fb..724eabb 100644 --- a/src/messages.rs +++ b/src/messages.rs @@ -241,3 +241,37 @@ impl TryFrom for HueMessage { serde_json::from_slice(&bytes).or(Err(ParseError::InvalidPayload(bytes.clone()))) } } + +// TODO: Import this from the air_filter code itself instead of copying +#[derive(PartialEq, Eq, Debug, Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AirFilterState { + Off, + Low, + Medium, + High, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +pub struct AirFilterMessage { + state: AirFilterState, +} + +impl AirFilterMessage { + pub fn state(&self) -> AirFilterState { + self.state + } + + pub fn new(state: AirFilterState) -> Self { + Self { state } + } +} + +impl TryFrom for AirFilterMessage { + type Error = ParseError; + + fn try_from(message: Publish) -> Result { + serde_json::from_slice(&message.payload) + .or(Err(ParseError::InvalidPayload(message.payload.clone()))) + } +}