Skip to content

Commit

Permalink
speculatively support changing target temperature
Browse files Browse the repository at this point in the history
For devices that report temperature settings, this commit
will now expose a number entity in Celsius that will change
the target temperature when set.

None of the govee device metadata I've see so far report
the current temperature target, so this entity works in
optimistic mode.

It's possible that hass will keep it greyed out until
a value is reported for it, which will be kinda sucky.

refs: #30
refs: #47
refs: #56
refs: #59
refs: #69
refs: #75
  • Loading branch information
wez committed Jan 16, 2024
1 parent 0ed80dc commit 91a16b8
Show file tree
Hide file tree
Showing 11 changed files with 512 additions and 40 deletions.
155 changes: 155 additions & 0 deletions src/hass_mqtt/climate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
use crate::hass_mqtt::base::{Device, EntityConfig, Origin};
use crate::hass_mqtt::instance::EntityInstance;
use crate::hass_mqtt::number::NumberConfig;
use crate::platform_api::{DeviceCapability, DeviceParameters};
use crate::service::device::Device as ServiceDevice;
use crate::service::hass::{availability_topic, topic_safe_id, topic_safe_string, HassClient};
use crate::service::state::StateHandle;
use crate::temperature::{TemperatureScale, TemperatureUnits, TemperatureValue};
use anyhow::anyhow;
use axum::async_trait;
use mosquitto_rs::router::{Params, Payload, State};
use serde::Deserialize;

// TODO: register an actual climate entity.
// I don't have one of these devices, so it is currently guesswork!

pub struct TargetTemperatureEntity {
number: NumberConfig,
}

struct TemperatureConstraints {
min: TemperatureValue,
max: TemperatureValue,
precision: TemperatureValue,
}

impl TemperatureConstraints {
fn as_unit(&self, unit: TemperatureUnits) -> Self {
Self {
min: self.min.as_unit(unit),
max: self.max.as_unit(unit),
precision: self.precision.as_unit(unit),
}
}
}

fn parse_temperature_constraints(
instance: &DeviceCapability,
) -> anyhow::Result<TemperatureConstraints> {
let units = instance
.struct_field_by_name("unit")
.map(
|field| match field.default_value.as_ref().and_then(|v| v.as_str()) {
Some("Celsius") => TemperatureUnits::Celsius,
Some("Farenheit") => TemperatureUnits::Farenheit,
_ => TemperatureUnits::Farenheit,
},
)
.unwrap_or(TemperatureUnits::Farenheit);

let temperature = instance
.struct_field_by_name("temperature")
.ok_or_else(|| anyhow!("no temperature field in {instance:?}"))?;
match &temperature.field_type {
DeviceParameters::Integer { unit, range } => {
let range_units = match unit.as_deref() {
Some("Celsius") => TemperatureUnits::Celsius,
Some("Farenheit") => TemperatureUnits::Farenheit,
_ => units,
};

let min = TemperatureValue::new(range.min.into(), range_units);
let max = TemperatureValue::new(range.max.into(), range_units);
let precision = TemperatureValue::new(range.precision.into(), range_units);

Ok(TemperatureConstraints {
min: min.as_unit(units),
max: max.as_unit(units),
precision: precision.as_unit(units),
})
}
_ => {
anyhow::bail!("Unexpected temperature value in {instance:?}");
}
}
}

impl TargetTemperatureEntity {
pub async fn new(device: &ServiceDevice, instance: &DeviceCapability) -> anyhow::Result<Self> {
let constraints =
parse_temperature_constraints(instance)?.as_unit(TemperatureUnits::Celsius);
let unique_id = format!(
"{id}-{inst}",
id = topic_safe_id(device),
inst = topic_safe_string(&instance.instance)
);

let name = "Target Temperature °C".to_string();
let units = TemperatureUnits::Celsius;
let command_topic = format!(
"gv2mqtt/{id}/set-temperature/{units}",
id = topic_safe_id(device),
);

Ok(Self {
number: NumberConfig {
base: EntityConfig {
availability_topic: availability_topic(),
name: Some(name),
entity_category: None,
origin: Origin::default(),
device: Device::for_device(device),
unique_id: unique_id.clone(),
device_class: None,
icon: Some("mdi:thermometer".to_string()),
},
state_topic: None,
command_topic,
min: Some(constraints.min.value() as f32),
max: Some(constraints.max.value() as f32),
step: constraints.precision.value() as f32,
unit_of_measurement: units.unit_of_measurement(),
},
})
}
}

#[async_trait]
impl EntityInstance for TargetTemperatureEntity {
async fn publish_config(&self, state: &StateHandle, client: &HassClient) -> anyhow::Result<()> {
self.number.publish(&state, &client).await
}

async fn notify_state(&self, _client: &HassClient) -> anyhow::Result<()> {
// No state to publish
Ok(())
}
}

#[derive(Deserialize)]
pub struct IdAndUnits {
id: String,
units: String,
}

pub async fn mqtt_set_temperature(
Payload(value): Payload<String>,
Params(IdAndUnits { id, units }): Params<IdAndUnits>,
State(state): State<StateHandle>,
) -> anyhow::Result<()> {
log::info!("Command: set-temperature for {id}: {value}");
let device = state
.resolve_device(&id)
.await
.ok_or_else(|| anyhow::anyhow!("device '{id}' not found"))?;

let scale: TemperatureScale = units.parse()?;
let target_value = TemperatureValue::parse_with_optional_scale(&value, Some(scale))?;

state
.device_set_target_temperature(&device, target_value)
.await?;

Ok(())
}
5 changes: 5 additions & 0 deletions src/hass_mqtt/enumerator.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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::instance::EntityList;
use crate::hass_mqtt::light::DeviceLight;
Expand Down Expand Up @@ -362,6 +363,10 @@ pub async fn enumerate_entities_for_device<'a>(
}
}

DeviceCapabilityKind::TemperatureSetting => {
entities.add(TargetTemperatureEntity::new(&d, cap).await?);
}

kind => {
log::warn!(
"Do something about {kind:?} {} for {d} {cap:?}",
Expand Down
1 change: 1 addition & 0 deletions src/hass_mqtt/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod base;
pub mod button;
pub mod climate;
pub mod cover;
pub mod enumerator;
pub mod humidifier;
Expand Down
24 changes: 15 additions & 9 deletions src/hass_mqtt/number.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use crate::hass_mqtt::instance::{publish_entity_config, EntityInstance};
use crate::service::device::Device as ServiceDevice;
use crate::service::hass::{availability_topic, topic_safe_id, topic_safe_string, HassClient};
use crate::service::state::StateHandle;
use anyhow::anyhow;
use async_trait::async_trait;
use mosquitto_rs::router::{Params, Payload, State};
use serde::{Deserialize, Serialize};
Expand All @@ -15,16 +16,18 @@ pub struct NumberConfig {
pub base: EntityConfig,

pub command_topic: String,
pub state_topic: String,
pub state_topic: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max: Option<f32>,
pub step: f32,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit_of_measurement: Option<&'static str>,
}

impl NumberConfig {
async fn publish(&self, state: &StateHandle, client: &HassClient) -> anyhow::Result<()> {
pub async fn publish(&self, state: &StateHandle, client: &HassClient) -> anyhow::Result<()> {
publish_entity_config("number", state, client, &self.base, self).await
}
}
Expand Down Expand Up @@ -79,13 +82,14 @@ impl WorkModeNumber {
icon: None,
},
command_topic,
state_topic,
state_topic: Some(state_topic),
min: range.as_ref().map(|r| r.start as f32).or(Some(0.)),
max: range
.as_ref()
.map(|r| r.end.saturating_sub(1) as f32)
.or(Some(255.)),
step: 1f32,
unit_of_measurement: None,
},
device_id: device.id.to_string(),
state: state.clone(),
Expand All @@ -102,6 +106,12 @@ impl EntityInstance for WorkModeNumber {
}

async fn notify_state(&self, client: &HassClient) -> anyhow::Result<()> {
let state_topic = self
.number
.state_topic
.as_ref()
.ok_or_else(|| anyhow!("state_topic is None!?"))?;

let device = self
.state
.device_by_id(&self.device_id)
Expand All @@ -118,9 +128,7 @@ impl EntityInstance for WorkModeNumber {

if let Some(value) = cap.state.pointer("/value/modeValue") {
if let Some(n) = value.as_i64() {
client
.publish(&self.number.state_topic, n.to_string())
.await?;
client.publish(state_topic, n.to_string()).await?;
return Ok(());
}
}
Expand All @@ -134,9 +142,7 @@ impl EntityInstance for WorkModeNumber {
if let Some(work_mode) = self.work_mode.as_i64() {
// FIXME: assuming humidifier, rename that field?
if let Some(n) = device.humidifier_param_by_mode.get(&(work_mode as u8)) {
client
.publish(&self.number.state_topic, n.to_string())
.await?;
client.publish(state_topic, n.to_string()).await?;
return Ok(());
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/hass_mqtt/sensor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ use crate::hass_mqtt::instance::{publish_entity_config, EntityInstance};
use crate::platform_api::DeviceCapability;
use crate::service::device::Device as ServiceDevice;
use crate::service::hass::{availability_topic, topic_safe_id, topic_safe_string, HassClient};
use crate::service::quirks::{ctof, HumidityUnits, TemperatureUnits};
use crate::service::quirks::HumidityUnits;
use crate::service::state::StateHandle;
use crate::temperature::{ctof, TemperatureUnits};
use async_trait::async_trait;
use chrono::Utc;
use serde::Serialize;
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ mod lan_api;
mod platform_api;
mod rest_api;
mod service;
mod temperature;
mod undoc_api;
mod version_info;

Expand Down
20 changes: 20 additions & 0 deletions src/platform_api.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::cache::{cache_get, CacheComputeResult, CacheGetOptions};
use crate::opt_env_var;
use crate::service::state::sort_and_dedup_scenes;
use crate::temperature::TemperatureValue;
use crate::undoc_api::GoveeUndocumentedApi;
use anyhow::Context;
use reqwest::Method;
Expand Down Expand Up @@ -349,6 +350,25 @@ impl GoveeApiClient {
anyhow::bail!("Scene '{scene}' is not available for this device");
}

pub async fn set_target_temperature(
&self,
device: &HttpDeviceInfo,
target: TemperatureValue,
) -> anyhow::Result<ControlDeviceResponseCapability> {
let cap = device
.capability_by_instance("targetTemperature")
.ok_or_else(|| anyhow::anyhow!("device has no targetTemperature"))?;

let celsius = target.as_celsius();

let value = json!({
"temperature": celsius,
"unit": "Celsius",
});

self.control_device(&device, &cap, value).await
}

pub async fn set_work_mode(
&self,
device: &HttpDeviceInfo,
Expand Down
4 changes: 4 additions & 0 deletions src/service/hass.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
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::instance::EntityList;
Expand Down Expand Up @@ -525,6 +526,9 @@ async fn run_mqtt_loop(
mqtt_humidifier_set_target,
)
.await?;
router
.route("gv2mqtt/:id/set-temperature/:units", mqtt_set_temperature)
.await?;

state
.get_hass_client()
Expand Down
31 changes: 1 addition & 30 deletions src/service/quirks.rs
Original file line number Diff line number Diff line change
@@ -1,38 +1,9 @@
use crate::platform_api::DeviceType;
use crate::temperature::TemperatureUnits;
use once_cell::sync::Lazy;
use std::borrow::Cow;
use std::collections::HashMap;

#[allow(unused)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TemperatureUnits {
Celsius,
CelsiusTimes100,
Farenheit,
FarenheitTimes100,
}

/// Convert farenheit to celsius
pub fn ftoc(f: f64) -> f64 {
(f - 32.) * (5. / 9.)
}

/// Convert farenheit to celsius
pub fn ctof(f: f64) -> f64 {
(f * 9. / 5.) + 32.
}

impl TemperatureUnits {
pub fn from_reading_to_celsius(&self, value: f64) -> f64 {
match self {
Self::Celsius => value,
Self::CelsiusTimes100 => value / 100.,
Self::Farenheit => ftoc(value),
Self::FarenheitTimes100 => ftoc(value / 100.),
}
}
}

#[allow(unused)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HumidityUnits {
Expand Down
Loading

0 comments on commit 91a16b8

Please sign in to comment.