diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b7954e2..7a1f611 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,24 +1,28 @@ -{ - "name": "Hass Add-On", - "image": "ghcr.io/home-assistant/devcontainer:addons", - "appPort": ["7123:8123", "7357:4357"], - "postStartCommand": "bash devcontainer_bootstrap", - "runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"], - "containerEnv": { - "WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}/addon" - }, - "extensions": ["timonwong.shellcheck", "esbenp.prettier-vscode"], - "mounts": [ "type=volume,target=/var/lib/docker" ], - "settings": { - "terminal.integrated.profiles.linux": { - "zsh": { - "path": "/usr/bin/zsh" - } - }, - "terminal.integrated.defaultProfile.linux": "zsh", - "editor.formatOnPaste": false, - "editor.formatOnSave": true, - "editor.formatOnType": true, - "files.trimTrailingWhitespace": true - } -} +{ + "name": "Hass Add-On", + "image": "ghcr.io/home-assistant/devcontainer:addons", + "appPort": ["7123:8123", "7357:4357"], + "postStartCommand": "bash devcontainer_bootstrap", + "runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"], + "containerEnv": { + "WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}/addon" + }, + "customizations": { + "vscode": { + "extensions": ["timonwong.shellcheck", "esbenp.prettier-vscode"], + "settings": { + "terminal.integrated.profiles.linux": { + "zsh": { + "path": "/usr/bin/zsh" + } + }, + "terminal.integrated.defaultProfile.linux": "zsh", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } + } + }, + "mounts": [ "type=volume,target=/var/lib/docker" ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..9933685 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,19 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Start Home Assistant", + "type": "shell", + "command": "supervisor_run", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + } + ] + } diff --git a/src/hass_mqtt/enumerator.rs b/src/hass_mqtt/enumerator.rs index bd41ec9..e1b24fc 100644 --- a/src/hass_mqtt/enumerator.rs +++ b/src/hass_mqtt/enumerator.rs @@ -2,6 +2,7 @@ use crate::hass_mqtt::base::{Device, EntityConfig, Origin}; use crate::hass_mqtt::button::ButtonConfig; use crate::hass_mqtt::climate::TargetTemperatureEntity; use crate::hass_mqtt::humidifier::Humidifier; +use crate::hass_mqtt::fan::Fan; use crate::hass_mqtt::instance::EntityList; use crate::hass_mqtt::light::DeviceLight; use crate::hass_mqtt::number::WorkModeNumber; @@ -166,6 +167,13 @@ pub async fn enumerate_entities_for_device<'a>( ) { entities.add(Humidifier::new(&d, state).await?); } + + if matches!( + d.device_type(), + DeviceType::Fan + ) { + entities.add(Fan::new(&d, state).await?); + } if d.device_type() != DeviceType::Light { if let Some(scenes) = SceneModeSelect::new(d, state).await? { diff --git a/src/hass_mqtt/fan.rs b/src/hass_mqtt/fan.rs new file mode 100644 index 0000000..baf8447 --- /dev/null +++ b/src/hass_mqtt/fan.rs @@ -0,0 +1,386 @@ +use crate::ble::TargetHumidity; +use crate::hass_mqtt::base::{Device, EntityConfig, Origin}; +use crate::hass_mqtt::instance::{publish_entity_config, EntityInstance}; +use crate::hass_mqtt::work_mode::ParsedWorkMode; +use crate::platform_api::{DeviceParameters, DeviceType, IntegerRange}; +use crate::service::device::Device as ServiceDevice; +use crate::service::hass::{availability_topic, topic_safe_id, HassClient, IdParameter}; +use crate::service::state::StateHandle; +use anyhow::anyhow; +use async_trait::async_trait; +use mosquitto_rs::router::{Params, Payload, State}; +use serde::Serialize; +use serde_json::json; + +pub const DEVICE_CLASS_FAN: &str = "fan"; + +/** + * TODO + * We need to setup 3 properties to handle MQTT Fan + * 1. Fan Mode + * a. set-mode + * b. notify-mode + * 2. Speed + * a. min + * b. max + * c. set-speed + * d. notify-speed + * 3. Oscillation + * a. set-oscillation + * b. notify-oscillation + */ + +/// +#[derive(Serialize, Clone, Debug)] +pub struct FanConfig { + #[serde(flatten)] + pub base: EntityConfig, + + pub command_topic: String, + + /// HASS will publish here to change the current mode + pub mode_command_topic: String, + /// we will publish the current mode here + pub mode_state_topic: String, + + /// we will publish the min speed here + #[serde(skip_serializing_if = "Option::is_none")] + pub speed_range_min: Option, + /// we will publish the max speed here + #[serde(skip_serializing_if = "Option::is_none")] + pub speed_range_max: Option, + /// HASS will publsh here to change the current speed + pub percentage_command_topic: String, + /// we will publsh here the current speed + pub percentage_state_topic: String, + + /// HASS will publish here to change the fan oscillation state + pub oscillation_command_topic: String, + /// HASS will subscribe here to receive the oscillation state + pub oscillation_state_topic: String, + + pub optimistic: bool, + + /// The list of supported modes + #[serde(skip_serializing_if = "Vec::is_empty")] + pub preset_modes: Vec, + + pub state_topic: String, +} + +#[derive(Clone)] +pub struct Fan { + fan: FanConfig, + state: StateHandle, + device_id: String, +} + +impl Fan { + pub async fn new(device: &ServiceDevice, state: &StateHandle) -> anyhow::Result { + let _quirk = device.resolve_quirk(); + let use_iot = device.iot_api_supported() && state.get_iot_client().await.is_some(); + let optimistic = !use_iot; + + let device_class = Some("fan"); + + // command_topic controls the power state; just route it to + // the general power switch handler + let command_topic = format!( + "gv2mqtt/switch/{id}/command/powerSwitch", + id = topic_safe_id(device) + ); + + let oscillation_command_topic = format!( + "gv2mqtt/fan/{id}/set-oscillation", + id = topic_safe_id(device) + ); + let oscillation_state_topic = format!( + "gv2mqtt/fan/{id}/notify-oscillation", + id = topic_safe_id(device) + ); + let state_topic = format!("gv2mqtt/fan/{id}/state", id = topic_safe_id(device)); + + let mode_state_topic = format!( + "gv2mqtt/fan/{id}/notify-mode", + id = topic_safe_id(device) + ); + + let mode_command_topic = format!( + "gv2mqtt/fan/{id}/set-mode", + id = topic_safe_id(device) + ); + let percentage_command_topic = format!( + "gv2mqtt/fan/{id}/set-speed", + id = topic_safe_id(device) + ); + let percentage_state_topic = format!( + "gv2mqtt/fan/{id}/notify-speed", + id = topic_safe_id(device) + ); + + let unique_id = format!("gv2mqtt-{id}-fan", id = topic_safe_id(device),); + + let mut speed_range_min = None; + let mut speed_range_max = None; + + let work_mode = ParsedWorkMode::with_device(device).ok(); + let preset_modes = work_mode + .as_ref() + .map(|wm| wm.get_mode_names()) + .unwrap_or(vec![]); + + if let Some(info) = &device.http_device_info { + if let Some(cap) = info.capability_by_instance("fan") { + match &cap.parameters { + Some(DeviceParameters::Integer { + range: IntegerRange { min, max, .. }, + unit, + }) => { + if unit.as_deref() == Some("unit.percent") { + speed_range_min.replace(*min as u8); + speed_range_max.replace(*max as u8); + } + } + _ => {} + } + } + } + + Ok(Self { + fan: FanConfig { + base: EntityConfig { + availability_topic: availability_topic(), + name: if matches!( + device.device_type(), + DeviceType::Fan + ) { + None + } else { + Some("Fan".to_string()) + }, + device_class, + origin: Origin::default(), + device: Device::for_device(device), + unique_id, + entity_category: None, + icon: None, + }, + command_topic, + oscillation_command_topic, + oscillation_state_topic, + + speed_range_min, + speed_range_max, + + percentage_command_topic, + percentage_state_topic, + + mode_command_topic, + mode_state_topic, + preset_modes, + state_topic, + optimistic, + }, + device_id: device.id.to_string(), + state: state.clone(), + }) + } +} + +#[async_trait] +impl EntityInstance for Fan { + async fn publish_config(&self, state: &StateHandle, client: &HassClient) -> anyhow::Result<()> { + publish_entity_config( + "fan", + state, + client, + &self.fan.base, + &self.fan, + ) + .await + } + + async fn notify_state(&self, client: &HassClient) -> anyhow::Result<()> { + let device = self + .state + .device_by_id(&self.device_id) + .await + .expect("device to exist"); + + // Broadcast powerState value + match device.device_state() { + Some(device_state) => { + let is_on = device_state.on; + client + .publish( + &self.fan.state_topic, + if is_on { "ON" } else { "OFF" }, + ) + .await?; + } + None => { + client.publish(&self.fan.state_topic, "OFF").await?; + } + } + + // Broadcast Speed Setting if present + // TODO ensure target_fan_speed is set correctly + if let Some(speed) = device.target_fan_speed { + client + .publish( + &self.fan.percentage_state_topic, + speed.to_string(), + ) + .await?; + } else { + // We need an initial value otherwise hass will not enable + // the target humidity control in its UI. + // Because we are setting this in the device state, + // this latches so we only do this once. + // TODO ensure speed_range_min is set correctly + let guessed_value = self.fan.speed_range_min.unwrap_or(0); + self.state + .device_mut(&device.sku, &device.id) + .await + .set_fan_speed(guessed_value); + client + .publish( + &self.fan.percentage_state_topic, + guessed_value.to_string(), + ) + .await?; + } + + // Broadcast Mode if present + if let Some(mode_value) = device.humidifier_work_mode { + if let Ok(work_mode) = ParsedWorkMode::with_device(&device) { + let mode_value_json = json!(mode_value); + if let Some(mode) = work_mode.mode_for_value(&mode_value_json) { + client + .publish(&self.fan.mode_state_topic, mode.name.to_string()) + .await?; + } + } + } else { + let work_modes = ParsedWorkMode::with_device(&device)?; + + if let Some(cap) = device.get_state_capability_by_instance("workMode") { + if let Some(mode_num) = cap.state.pointer("/value/workMode") { + if let Some(mode) = work_modes.mode_for_value(mode_num) { + return client + .publish(&self.fan.mode_state_topic, mode.name.to_string()) + .await; + } + } + } + } + + // Broadcast oscillation if not supported + if let Some(oscillate) = device.fan_oscillate { + client + .publish(&self.fan.oscillation_state_topic, if oscillate { "ON" } else { "OFF" }) + .await?; + } + Ok(()) + } +} + +// TODO Review Set Logic +pub async fn mqtt_fan_set_work_mode( + Payload(mode): Payload, + Params(IdParameter { id }): Params, + State(state): State, +) -> anyhow::Result<()> { + log::info!("mqtt_fan_set_mode: {id}: {mode}"); + let device = state.resolve_device_for_control(&id).await?; + + let work_modes = ParsedWorkMode::with_device(&device)?; + let work_mode = work_modes + .mode_by_name(&mode) + .ok_or_else(|| anyhow!("mode {mode} not found"))?; + let mode_num = work_mode + .value + .as_i64() + .ok_or_else(|| anyhow::anyhow!("expected workMode to be a number"))?; + + let value = work_mode.default_value(); + + state + .humidifier_set_parameter(&device, mode_num, value) + .await?; + + Ok(()) +} + +// TODO Review Set Logic +pub async fn mqtt_fan_set_speed( + Payload(percent): Payload, + Params(IdParameter { id }): Params, + State(state): State, +) -> anyhow::Result<()> { + log::info!("mqtt_humidifier_set_target: {id}: {percent}"); + + let device = state.resolve_device_for_control(&id).await?; + + let use_iot = device.pollable_via_iot() && state.get_iot_client().await.is_some(); + + if !use_iot { + if let Some(info) = &device.http_device_info { + if let Some(cap) = info.capability_by_instance("humidity") { + state.device_control(&device, cap, percent).await?; + + // We're running in optimistic mode; stash + // the last set value so that we can report it + // to hass + state + .device_mut(&device.sku, &device.id) + .await + .set_fan_speed(percent as u8); + + // For the H7160 at least, setting the humidity + // will put the device into auto mode and turn + // it on, however, we don't know that the device + // is actually turned on. + // + // This is handled by the device_was_controlled + // stuff; it will cause us to poll the device + // after a short delay, and that should fix up + // the reported device state. + return Ok(()); + } + } + } + + let work_modes = ParsedWorkMode::with_device(&device)?; + let work_mode = work_modes + .mode_by_name("Auto") + .ok_or_else(|| anyhow!("mode Auto not found"))?; + let mode_num = work_mode + .value + .as_i64() + .ok_or_else(|| anyhow::anyhow!("expected workMode to be a number"))?; + + let value = TargetHumidity::from_percent(percent as u8); + + state + .fan_set_speed(&device, mode_num, value.into_inner().into()) + .await?; + + Ok(()) +} + +// TODO Set Oscillation Logic +pub async fn mqtt_fan_set_oscillation( + Payload(oscillate): Payload, + Params(IdParameter { id }): Params, + State(state): State, +) -> anyhow::Result<()> { + log::info!("mqtt_fan_set_oscillation: {id}: {oscillate}"); + let device = state.resolve_device_for_control(&id).await?; + + state + .fan_set_oscillate(&device, oscillate, oscillate) + .await?; + + Ok(()) +} \ No newline at end of file diff --git a/src/hass_mqtt/humidifier.rs b/src/hass_mqtt/humidifier.rs index f0b02f4..86f5660 100644 --- a/src/hass_mqtt/humidifier.rs +++ b/src/hass_mqtt/humidifier.rs @@ -176,6 +176,7 @@ impl EntityInstance for Humidifier { .await .expect("device to exist"); + // Broadcast powerState value match device.device_state() { Some(device_state) => { let is_on = device_state.on; @@ -191,6 +192,7 @@ impl EntityInstance for Humidifier { } } + // Broadcast Humidity Target if present if let Some(humidity) = device.target_humidity_percent { client .publish( @@ -216,6 +218,7 @@ impl EntityInstance for Humidifier { .await?; } + // Broadcast Mode if present if let Some(mode_value) = device.humidifier_work_mode { if let Ok(work_mode) = ParsedWorkMode::with_device(&device) { let mode_value_json = json!(mode_value); @@ -242,7 +245,7 @@ impl EntityInstance for Humidifier { } } -pub async fn mqtt_device_set_work_mode( +pub async fn mqtt_humidifier_set_work_mode( Payload(mode): Payload, Params(IdParameter { id }): Params, State(state): State, diff --git a/src/hass_mqtt/mod.rs b/src/hass_mqtt/mod.rs index dc5a307..d3d6db3 100644 --- a/src/hass_mqtt/mod.rs +++ b/src/hass_mqtt/mod.rs @@ -4,6 +4,7 @@ pub mod climate; pub mod cover; pub mod enumerator; pub mod humidifier; +pub mod fan; pub mod instance; pub mod light; pub mod number; diff --git a/src/hass_mqtt/sensor.rs b/src/hass_mqtt/sensor.rs index a127662..b49ae7f 100644 --- a/src/hass_mqtt/sensor.rs +++ b/src/hass_mqtt/sensor.rs @@ -1,5 +1,6 @@ use crate::commands::serve::POLL_INTERVAL; use crate::hass_mqtt::base::{Device, EntityConfig, Origin}; +use crate::hass_mqtt::fan::DEVICE_CLASS_FAN; use crate::hass_mqtt::humidifier::DEVICE_CLASS_HUMIDITY; use crate::hass_mqtt::instance::{publish_entity_config, EntityInstance}; use crate::platform_api::DeviceCapability; @@ -121,6 +122,8 @@ impl CapabilitySensor { let device_class = match instance.instance.as_str() { "sensorTemperature" => Some(DEVICE_CLASS_TEMPERATURE), "sensorHumidity" => Some(DEVICE_CLASS_HUMIDITY), + // TODO Configure Device Class Detection for Fans + "sensorFan" => Some(DEVICE_CLASS_FAN), _ => None, }; diff --git a/src/service/device.rs b/src/service/device.rs index 707c0f5..6013c71 100644 --- a/src/service/device.rs +++ b/src/service/device.rs @@ -38,8 +38,10 @@ pub struct Device { pub nightlight_state: Option, pub target_humidity_percent: Option, + pub target_fan_speed: Option, pub humidifier_work_mode: Option, pub humidifier_param_by_mode: HashMap, + pub fan_oscillate: Option, pub last_polled: Option>, @@ -184,6 +186,10 @@ impl Device { pub fn set_target_humidity(&mut self, percent: u8) { self.target_humidity_percent.replace(percent); } + + pub fn set_fan_speed(&mut self, percent: u8) { + self.target_fan_speed.replace(percent); + } pub fn set_humidifier_work_mode_and_param(&mut self, mode: u8, param: u8) { self.humidifier_work_mode.replace(mode); diff --git a/src/service/hass.rs b/src/service/hass.rs index 3ee8295..69e6452 100644 --- a/src/service/hass.rs +++ b/src/service/hass.rs @@ -1,6 +1,7 @@ use crate::hass_mqtt::climate::mqtt_set_temperature; use crate::hass_mqtt::enumerator::{enumerate_all_entites, enumerate_entities_for_device}; -use crate::hass_mqtt::humidifier::{mqtt_device_set_work_mode, mqtt_humidifier_set_target}; +use crate::hass_mqtt::humidifier::{mqtt_humidifier_set_work_mode, mqtt_humidifier_set_target }; +use crate::hass_mqtt::fan::{mqtt_fan_set_work_mode, mqtt_fan_set_speed, mqtt_fan_set_oscillation}; use crate::hass_mqtt::instance::EntityList; use crate::hass_mqtt::number::mqtt_number_command; use crate::hass_mqtt::select::mqtt_set_mode_scene; @@ -548,10 +549,17 @@ async fn run_mqtt_loop( ) .await?; router - .route("gv2mqtt/humidifier/:id/set-mode", mqtt_device_set_work_mode) + .route("gv2mqtt/humidifier/:id/set-mode", mqtt_humidifier_set_work_mode) .await?; router - .route("gv2mqtt/:id/set-work-mode", mqtt_device_set_work_mode) + // TODO Determine if humidifier or fan... + .route("gv2mqtt/:id/set-work-mode", mqtt_humidifier_set_work_mode) + .await?; + router + .route( + "gv2mqtt/humidifier/:id/set-target", + mqtt_humidifier_set_target, + ) .await?; router .route( @@ -569,6 +577,22 @@ async fn run_mqtt_loop( .route("gv2mqtt/:id/set-mode-scene", mqtt_set_mode_scene) .await?; + router + .route("gv2mqtt/fan/:id/set-mode", mqtt_fan_set_work_mode) + .await?; + router + .route( + "gv2mqtt/fan/:id/set-speed", + mqtt_fan_set_speed, + ) + .await?; + router + .route( + "gv2mqtt/fan/:id/set-oscillation", + mqtt_fan_set_oscillation, + ) + .await?; + tokio::time::sleep(HASS_REGISTER_DELAY).await; state .get_hass_client() diff --git a/src/service/state.rs b/src/service/state.rs index bcfd024..c5495b5 100644 --- a/src/service/state.rs +++ b/src/service/state.rs @@ -506,6 +506,66 @@ impl State { } anyhow::bail!("Unable to control humidifier parameter work_mode={work_mode} for {device}"); } + + pub async fn fan_set_speed( + self: &Arc, + device: &Device, + work_mode: i64, + value: i64, + ) -> anyhow::Result<()> { + if let Ok(command) = Base64HexBytes::encode_for_sku( + &device.sku, + &SetHumidifierMode { + mode: work_mode as u8, + param: value as u8, + }, + ) { + if let Some(iot) = self.get_iot_client().await { + if let Some(info) = &device.undoc_device_info { + iot.send_real(&info.entry, vec![command.base64()]).await?; + return Ok(()); + } + } + } + + if let Some(client) = self.get_platform_client().await { + if let Some(info) = &device.http_device_info { + client.set_work_mode(info, work_mode, value).await?; + return Ok(()); + } + } + anyhow::bail!("Unable to control fan parameter work_mode={work_mode} for {device}"); + } + + pub async fn fan_set_oscillate( + self: &Arc, + device: &Device, + oscillate: bool, + value: bool, + ) -> anyhow::Result<()> { + if let Ok(command) = Base64HexBytes::encode_for_sku( + &device.sku, + &SetHumidifierMode { + mode: oscillate as u8, + param: value as u8, + }, + ) { + if let Some(iot) = self.get_iot_client().await { + if let Some(info) = &device.undoc_device_info { + iot.send_real(&info.entry, vec![command.base64()]).await?; + return Ok(()); + } + } + } + + if let Some(client) = self.get_platform_client().await { + if let Some(info) = &device.http_device_info { + client.set_toggle_state(info, "oscillate", value).await?; + return Ok(()); + } + } + anyhow::bail!("Unable to control fan parameter oscillate={oscillate} for {device}"); + } pub async fn device_set_color_rgb( self: &Arc,