diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py new file mode 100644 index 0000000000..e7520fc655 --- /dev/null +++ b/esphome/components/template/climate/__init__.py @@ -0,0 +1,126 @@ +import esphome.codegen as cg +from esphome.components import climate +from esphome.components.climate import ( + CLIMATE_FAN_MODES, + CLIMATE_MODES, + CLIMATE_PRESETS, + CLIMATE_SWING_MODES, +) +from esphome.components.number import Number +from esphome.components.select import Select +from esphome.components.sensor import Sensor +from esphome.components.text_sensor import TextSensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACTION_ID, + CONF_ID, + CONF_SUPPORTED_FAN_MODES, + CONF_SUPPORTED_MODES, + CONF_SUPPORTED_PRESETS, + CONF_SUPPORTED_SWING_MODES, +) + +from .. import template_ns + +DEPENDENCIES = ["climate"] + +CONF_AUTO_ACTION = "auto_action" +CONF_CURRENT_TEMPERATURE_ID = "current_temperature_id" +CONF_TARGET_TEMPERATURE_ID = "target_temperature_id" +CONF_MODE_ID = "mode_id" +CONF_FAN_MODE_ID = "fan_mode_id" +CONF_SWING_MODE_ID = "swing_mode_id" +CONF_PRESET_ID = "preset_id" + +TemplateClimate = template_ns.class_("TemplateClimate", climate.Climate, cg.Component) + +CONFIG_SCHEMA = climate.CLIMATE_SCHEMA.extend( + { + cv.GenerateID(CONF_ID): cv.declare_id(TemplateClimate), + cv.Required(CONF_CURRENT_TEMPERATURE_ID): cv.use_id(Sensor), + cv.Required(CONF_TARGET_TEMPERATURE_ID): cv.use_id(Number), + cv.Required(CONF_MODE_ID): cv.use_id(Select), + cv.Optional(CONF_FAN_MODE_ID): cv.use_id(Select), + cv.Optional(CONF_SWING_MODE_ID): cv.use_id(Select), + cv.Optional(CONF_PRESET_ID): cv.use_id(Select), + cv.Required(CONF_SUPPORTED_MODES): cv.ensure_list(cv.enum(CLIMATE_MODES)), + cv.Optional(CONF_SUPPORTED_FAN_MODES): cv.ensure_list( + cv.enum(CLIMATE_FAN_MODES) + ), + cv.Optional(CONF_SUPPORTED_SWING_MODES): cv.ensure_list( + cv.enum(CLIMATE_SWING_MODES) + ), + cv.Optional(CONF_SUPPORTED_PRESETS): cv.ensure_list(cv.enum(CLIMATE_PRESETS)), + cv.Optional(CONF_ACTION_ID): cv.use_id(TextSensor), + } +) + + +def FINAL_VALIDATE_SCHEMA(config): + if bool(CONF_FAN_MODE_ID in config) != bool(CONF_SUPPORTED_FAN_MODES in config): + raise cv.Invalid( + f"{CONF_FAN_MODE_ID} requires {CONF_SUPPORTED_FAN_MODES} and vice versa" + ) + if bool(CONF_SWING_MODE_ID in config) != bool(CONF_SUPPORTED_SWING_MODES in config): + raise cv.Invalid( + f"{CONF_SWING_MODE_ID} requires {CONF_SUPPORTED_SWING_MODES} and vice versa" + ) + if bool(CONF_PRESET_ID in config) != bool(CONF_SUPPORTED_PRESETS in config): + raise cv.Invalid( + f"{CONF_PRESET_ID} requires {CONF_SUPPORTED_PRESETS} and vice versa" + ) + + # TODO: It would be nice to be able to get the set of options for the mode selectors if defined, + # that way we can validate those as well. But how can they be accessed? + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await climate.register_climate(var, config) + + cg.add( + var.set_current_temperature( + await cg.get_variable(config[CONF_CURRENT_TEMPERATURE_ID]) + ) + ) + + cg.add( + var.set_target_temperature( + await cg.get_variable(config[CONF_TARGET_TEMPERATURE_ID]) + ) + ) + + cg.add(var.set_mode(await cg.get_variable(config[CONF_MODE_ID]))) + cg.add( + var.traits_.set_supported_modes( + [CLIMATE_MODES[m] for m in config[CONF_SUPPORTED_MODES]] + ) + ) + + if v := config.get(CONF_FAN_MODE_ID): + cg.add(var.set_fan_mode(await cg.get_variable(v))) + cg.add( + var.traits_.set_supported_fan_modes( + [CLIMATE_FAN_MODES[m] for m in config[CONF_SUPPORTED_FAN_MODES]] + ) + ) + + if v := config.get(CONF_SWING_MODE_ID): + cg.add(var.set_swing_mode(await cg.get_variable(v))) + cg.add( + var.traits_.set_supported_swing_modes( + [CLIMATE_SWING_MODES[m] for m in config[CONF_SUPPORTED_SWING_MODES]] + ) + ) + + if v := config.get(CONF_PRESET_ID): + cg.add(var.set_preset(await cg.get_variable(v))) + cg.add( + var.traits_.set_supported_presets( + [CLIMATE_PRESETS[m] for m in config[CONF_SUPPORTED_PRESETS]] + ) + ) + + if v := config.get(CONF_ACTION_ID): + cg.add(var.set_action(await cg.get_variable(v))) diff --git a/esphome/components/template/climate/template_climate.cpp b/esphome/components/template/climate/template_climate.cpp new file mode 100644 index 0000000000..5475e1239a --- /dev/null +++ b/esphome/components/template/climate/template_climate.cpp @@ -0,0 +1,197 @@ +#include "template_climate.h" + +namespace esphome { +namespace template_ { + +static const char *const TAG = "template_climate"; + +ClimateMode mode_from_string(const std::string &s) { + if (str_equals_case_insensitive(s, "OFF")) { + return CLIMATE_MODE_OFF; + } else if (str_equals_case_insensitive(s, "HEAT_COOL")) { + return CLIMATE_MODE_HEAT_COOL; + } else if (str_equals_case_insensitive(s, "COOL")) { + return CLIMATE_MODE_COOL; + } else if (str_equals_case_insensitive(s, "HEAT")) { + return CLIMATE_MODE_HEAT; + } else if (str_equals_case_insensitive(s, "FAN_ONLY")) { + return CLIMATE_MODE_FAN_ONLY; + } else if (str_equals_case_insensitive(s, "DRY")) { + return CLIMATE_MODE_DRY; + } else if (str_equals_case_insensitive(s, "AUTO")) { + return CLIMATE_MODE_AUTO; + } else { + ESP_LOGW(TAG, "Unrecognized mode %s", s.c_str()); + return CLIMATE_MODE_OFF; + } +} + +ClimateFanMode fan_mode_from_string(const std::string &s) { + if (str_equals_case_insensitive(s, "ON")) { + return CLIMATE_FAN_ON; + } else if (str_equals_case_insensitive(s, "OFF")) { + return CLIMATE_FAN_OFF; + } else if (str_equals_case_insensitive(s, "AUTO")) { + return CLIMATE_FAN_AUTO; + } else if (str_equals_case_insensitive(s, "LOW")) { + return CLIMATE_FAN_LOW; + } else if (str_equals_case_insensitive(s, "MEDIUM")) { + return CLIMATE_FAN_MEDIUM; + } else if (str_equals_case_insensitive(s, "HIGH")) { + return CLIMATE_FAN_HIGH; + } else if (str_equals_case_insensitive(s, "MIDDLE")) { + return CLIMATE_FAN_MIDDLE; + } else if (str_equals_case_insensitive(s, "FOCUS")) { + return CLIMATE_FAN_FOCUS; + } else if (str_equals_case_insensitive(s, "DIFFUSE")) { + return CLIMATE_FAN_DIFFUSE; + } else if (str_equals_case_insensitive(s, "QUIET")) { + return CLIMATE_FAN_QUIET; + } else { + ESP_LOGW(TAG, "Unrecognized fan mode %s", s.c_str()); + return CLIMATE_FAN_AUTO; + } +} + +ClimateSwingMode swing_mode_from_string(const std::string &s) { + if (str_equals_case_insensitive(s, "OFF")) { + return CLIMATE_SWING_OFF; + } else if (str_equals_case_insensitive(s, "BOTH")) { + return CLIMATE_SWING_BOTH; + } else if (str_equals_case_insensitive(s, "VERTICAL")) { + return CLIMATE_SWING_VERTICAL; + } else if (str_equals_case_insensitive(s, "HORIZONTAL")) { + return CLIMATE_SWING_HORIZONTAL; + } else { + ESP_LOGW(TAG, "Unrecognized swing mode %s", s.c_str()); + return CLIMATE_SWING_BOTH; + } +} + +ClimatePreset preset_from_string(const std::string &s) { + if (str_equals_case_insensitive(s, "NONE")) { + return CLIMATE_PRESET_NONE; + } else if (str_equals_case_insensitive(s, "HOME")) { + return CLIMATE_PRESET_HOME; + } else if (str_equals_case_insensitive(s, "AWAY")) { + return CLIMATE_PRESET_AWAY; + } else if (str_equals_case_insensitive(s, "BOOST")) { + return CLIMATE_PRESET_BOOST; + } else if (str_equals_case_insensitive(s, "COMFORT")) { + return CLIMATE_PRESET_COMFORT; + } else if (str_equals_case_insensitive(s, "ECO")) { + return CLIMATE_PRESET_ECO; + } else if (str_equals_case_insensitive(s, "SLEEP")) { + return CLIMATE_PRESET_SLEEP; + } else if (str_equals_case_insensitive(s, "ACTIVITY")) { + return CLIMATE_PRESET_ACTIVITY; + } else { + ESP_LOGW(TAG, "Unrecognized preset %s", s.c_str()); + return CLIMATE_PRESET_NONE; + } +} + +ClimateAction action_from_string(const std::string &s) { + if (str_equals_case_insensitive(s, "OFF")) { + return CLIMATE_ACTION_OFF; + } else if (str_equals_case_insensitive(s, "COOLING")) { + return CLIMATE_ACTION_COOLING; + } else if (str_equals_case_insensitive(s, "HEATING")) { + return CLIMATE_ACTION_HEATING; + } else if (str_equals_case_insensitive(s, "IDLE")) { + return CLIMATE_ACTION_IDLE; + } else if (str_equals_case_insensitive(s, "DRYING")) { + return CLIMATE_ACTION_DRYING; + } else if (str_equals_case_insensitive(s, "FAN")) { + return CLIMATE_ACTION_FAN; + } else { + ESP_LOGW(TAG, "Unrecognized action %s", s.c_str()); + return CLIMATE_ACTION_OFF; + } +} + +void TemplateClimate::setup() { + this->current_temperature_->add_on_state_callback([this](float x) { + this->current_temperature = x; + this->publish_state(); + }); + this->current_temperature = this->current_temperature_->state; + + this->target_temperature_->add_on_state_callback([this](float x) { + this->target_temperature = x; + this->publish_state(); + }); + this->current_temperature = this->target_temperature_->state; + + this->mode_->add_on_state_callback([this](const std::string &x, size_t i) { + this->mode = mode_from_string(x); + this->publish_state(); + }); + this->mode = mode_from_string(this->mode_->state); + + if (this->fan_mode_ != nullptr) { + this->fan_mode_->add_on_state_callback([this](const std::string &x, size_t i) { + this->fan_mode = fan_mode_from_string(x); + this->publish_state(); + }); + this->fan_mode = fan_mode_from_string(this->fan_mode_->state); + } + + if (this->swing_mode_ != nullptr) { + this->swing_mode_->add_on_state_callback([this](const std::string &x, size_t i) { + this->swing_mode = swing_mode_from_string(x); + this->publish_state(); + }); + this->swing_mode = swing_mode_from_string(this->swing_mode_->state); + } + + if (this->preset_ != nullptr) { + this->preset_->add_on_state_callback([this](const std::string &x, size_t i) { + this->preset = preset_from_string(x); + this->publish_state(); + }); + this->preset = preset_from_string(this->preset_->state); + } + + if (this->action_ != nullptr) { + this->action_->add_on_state_callback([this](const std::string &x) { + this->action = action_from_string(x); + this->publish_state(); + }); + this->action = action_from_string(this->action_->state); + } +} + +void TemplateClimate::control(const ClimateCall &call) { + auto mode = call.get_mode(); + if (mode) { + std::string opt = LOG_STR_ARG(climate_mode_to_string(mode.value())); + this->mode_->make_call().set_option(opt).perform(); + } + + auto fan_mode = call.get_fan_mode(); + if (fan_mode && this->fan_mode_ != nullptr) { + std::string opt = LOG_STR_ARG(climate_fan_mode_to_string(fan_mode.value())); + this->fan_mode_->make_call().set_option(opt).perform(); + } + + auto swing_mode = call.get_swing_mode(); + if (swing_mode && this->swing_mode_ != nullptr) { + std::string opt = LOG_STR_ARG(climate_swing_mode_to_string(swing_mode.value())); + this->swing_mode_->make_call().set_option(opt).perform(); + } + + auto preset = call.get_preset(); + if (preset && this->preset_ != nullptr) { + std::string opt = LOG_STR_ARG(climate_preset_to_string(preset.value())); + this->preset_->make_call().set_option(opt).perform(); + } + + auto target_temperature = call.get_target_temperature(); + if (target_temperature) { + this->target_temperature_->make_call().set_value(target_temperature.value()).perform(); + } +} + +} // namespace template_ +} // namespace esphome diff --git a/esphome/components/template/climate/template_climate.h b/esphome/components/template/climate/template_climate.h new file mode 100644 index 0000000000..254e1e97c4 --- /dev/null +++ b/esphome/components/template/climate/template_climate.h @@ -0,0 +1,62 @@ +#pragma once + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/components/climate/climate.h" +#include "esphome/components/number/number.h" +#include "esphome/components/select/select.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/text_sensor/text_sensor.h" + +namespace esphome { +namespace template_ { + +using namespace climate; +using namespace number; +using namespace select; +using namespace sensor; +using namespace text_sensor; + +class TemplateClimate : public Climate, public Component { + public: + ClimateTraits traits_; + + void setup() override; + + ClimateTraits traits() override { return this->traits_; } + + void set_current_temperature(Sensor *sensor) { + this->current_temperature_ = sensor; + this->traits_.set_supports_current_temperature(true); + } + + void set_target_temperature(Number *num) { this->target_temperature_ = num; } + + void set_mode(Select *s) { this->mode_ = s; } + + void set_fan_mode(Select *s) { this->fan_mode_ = s; } + + void set_swing_mode(Select *s) { this->swing_mode_ = s; } + + void set_preset(Select *s) { this->preset_ = s; } + + void set_action(TextSensor *s) { + this->action_ = s; + this->traits_.set_supports_action(true); + } + + protected: + void control(const ClimateCall &call) override; + + Sensor *current_temperature_{nullptr}; + Number *target_temperature_{nullptr}; + Select *mode_{nullptr}; + Select *fan_mode_{nullptr}; + Select *swing_mode_{nullptr}; + Select *preset_{nullptr}; + TextSensor *action_{nullptr}; +}; + +} // namespace template_ +} // namespace esphome diff --git a/tests/components/template/common.yaml b/tests/components/template/common.yaml index 79201fbe07..34b2ec7b06 100644 --- a/tests/components/template/common.yaml +++ b/tests/components/template/common.yaml @@ -29,6 +29,11 @@ sensor: - timeout: timeout: 1d + - platform: template + id: climate_current_temperature + lambda: |- + return 42; + esphome: on_boot: - sensor.template.publish: @@ -125,6 +130,13 @@ number: max_value: 100 step: 1 + - platform: template + id: climate_target_temperature + optimistic: true + min_value: 10 + max_value: 30 + step: 0.1 + select: - platform: template name: "Template select" @@ -135,6 +147,34 @@ select: - three initial_option: two + - platform: template + id: climate_mode + optimistic: true + options: + - "OFF" + - "HEAT" + + - platform: template + id: climate_fan_mode + optimistic: true + options: + - "ON" + - "AUTO" + + - platform: template + id: climate_swing_mode + optimistic: true + options: + - "OFF" + - "HORIZONTAL" + + - platform: template + id: climate_preset + optimistic: true + options: + - "NONE" + - "ECO" + lock: - platform: template name: "Template Lock" @@ -187,6 +227,10 @@ text: format: Template Text set to %s args: ["x.c_str()"] +text_sensor: + - platform: template + id: climate_action + alarm_control_panel: - platform: template name: Alarm Panel @@ -240,6 +284,33 @@ datetime: - x.minute - x.second +climate: + - platform: template + name: Nice Climate Template Component + target_temperature_id: climate_target_temperature + current_temperature_id: climate_current_temperature + mode_id: climate_mode + fan_mode_id: climate_fan_mode + swing_mode_id: climate_swing_mode + preset_id: climate_preset + action_id: climate_action + supported_modes: + - "OFF" + - "COOL" + - "HEAT" + - "AUTO" + supported_fan_modes: + - "ON" + - "QUIET" + supported_swing_modes: + - "OFF" + - "HORIZONTAL" + supported_presets: + - "NONE" + - "ECO" + visual: + temperature_step: 0.1C + time: - platform: sntp # Required for datetime