diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 20565e811c..5e26e6d6de 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -14,6 +14,7 @@ from esphome.const import ( CONF_DEFAULT_TARGET_TEMPERATURE_LOW, CONF_DRY_ACTION, CONF_DRY_MODE, + CONF_FAN_MODE, CONF_FAN_MODE_ON_ACTION, CONF_FAN_MODE_OFF_ACTION, CONF_FAN_MODE_AUTO_ACTION, @@ -37,6 +38,7 @@ from esphome.const import ( CONF_IDLE_ACTION, CONF_MAX_COOLING_RUN_TIME, CONF_MAX_HEATING_RUN_TIME, + CONF_MAX_TEMPERATURE, CONF_MIN_COOLING_OFF_TIME, CONF_MIN_COOLING_RUN_TIME, CONF_MIN_FAN_MODE_SWITCHING_TIME, @@ -45,7 +47,11 @@ from esphome.const import ( CONF_MIN_HEATING_OFF_TIME, CONF_MIN_HEATING_RUN_TIME, CONF_MIN_IDLE_TIME, + CONF_MIN_TEMPERATURE, + CONF_NAME, + CONF_MODE, CONF_OFF_MODE, + CONF_PRESET, CONF_SENSOR, CONF_SET_POINT_MINIMUM_DIFFERENTIAL, CONF_STARTUP_DELAY, @@ -55,11 +61,15 @@ from esphome.const import ( CONF_SUPPLEMENTAL_HEATING_DELTA, CONF_SWING_BOTH_ACTION, CONF_SWING_HORIZONTAL_ACTION, + CONF_SWING_MODE, CONF_SWING_OFF_ACTION, CONF_SWING_VERTICAL_ACTION, CONF_TARGET_TEMPERATURE_CHANGE_ACTION, + CONF_VISUAL, ) +CONF_PRESET_CHANGE = "preset_change" + CODEOWNERS = ["@kbx81"] climate_ns = cg.esphome_ns.namespace("climate") @@ -82,6 +92,38 @@ CLIMATE_MODES = { } validate_climate_mode = cv.enum(CLIMATE_MODES, upper=True) +ClimatePreset = climate_ns.enum("ClimatePreset") + +PRESET_CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(ThermostatClimateTargetTempConfig), + cv.Required(CONF_NAME): cv.string_strict, + cv.Optional(CONF_MODE): validate_climate_mode, + cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_HIGH): cv.temperature, + cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_LOW): cv.temperature, + cv.Optional(CONF_FAN_MODE): cv.templatable(climate.validate_climate_fan_mode), + cv.Optional(CONF_SWING_MODE): cv.templatable( + climate.validate_climate_swing_mode + ), + } +) + + +def validate_temperature_preset(preset, root_config, name, requirements): + # verify temperature settings for the provided preset / default / away configuration + for config_temp, req_actions in requirements.items(): + for req_action in req_actions: + # verify corresponding default target temperature exists when a given climate action exists + if config_temp not in preset and req_action in root_config: + raise cv.Invalid( + f"{config_temp} must be defined in {name} config when using {req_action}" + ) + # if a given climate action is NOT defined, it should not have a default target temperature + if config_temp in preset and req_action not in root_config: + raise cv.Invalid( + f"{config_temp} is defined in {name} config with no {req_action}" + ) + def validate_thermostat(config): # verify corresponding action(s) exist(s) for any defined climate mode or action @@ -235,33 +277,22 @@ def validate_thermostat(config): CONF_DEFAULT_TARGET_TEMPERATURE_LOW: [CONF_HEAT_ACTION], } - for config_temp, req_actions in requirements.items(): - for req_action in req_actions: - # verify corresponding default target temperature exists when a given climate action exists - if config_temp not in config and req_action in config: - raise cv.Invalid( - f"{config_temp} must be defined when using {req_action}" - ) - # if a given climate action is NOT defined, it should not have a default target temperature - if config_temp in config and req_action not in config: - raise cv.Invalid(f"{config_temp} is defined with no {req_action}") + # Validate temperature requirements for default configuraation + validate_temperature_preset(config, config, "default", requirements) + # Validate temperature requirements for away configuration if CONF_AWAY_CONFIG in config: away = config[CONF_AWAY_CONFIG] - for config_temp, req_actions in requirements.items(): - for req_action in req_actions: - # verify corresponding default target temperature exists when a given climate action exists - if config_temp not in away and req_action in config: - raise cv.Invalid( - f"{config_temp} must be defined in away configuration when using {req_action}" - ) - # if a given climate action is NOT defined, it should not have a default target temperature - if config_temp in away and req_action not in config: - raise cv.Invalid( - f"{config_temp} is defined in away configuration with no {req_action}" - ) + validate_temperature_preset(away, config, "away", requirements) - # verify default climate mode is valid given above configuration + # Validate temperature requirements for presets + if CONF_PRESET in config: + for preset_config in config[CONF_PRESET]: + validate_temperature_preset( + preset_config, config, preset_config[CONF_NAME], requirements + ) + + # Verify default climate mode is valid given above configuration default_mode = config[CONF_DEFAULT_MODE] requirements = { "HEAT_COOL": [CONF_COOL_ACTION, CONF_HEAT_ACTION], @@ -270,13 +301,108 @@ def validate_thermostat(config): "DRY": [CONF_DRY_ACTION], "FAN_ONLY": [CONF_FAN_ONLY_ACTION], "AUTO": [CONF_COOL_ACTION, CONF_HEAT_ACTION], - }.get(default_mode, []) - for req in requirements: + "OFF": [], + } + actions_for_default_mode = requirements.get(default_mode, []) + for req in actions_for_default_mode: if req not in config: raise cv.Invalid( f"{CONF_DEFAULT_MODE} is set to {default_mode} but {req} is not present in the configuration" ) + # Verify that the modes for presets are valid given the configuration + if CONF_PRESET in config: + # Preset temperature vs Visual temperature validation + + # Default visual configuration from climate_traits.h + visual_min_temperature = 10.0 + visual_max_temperature = 30.0 + if CONF_VISUAL in config: + visual_config = config[CONF_VISUAL] + + if CONF_MIN_TEMPERATURE in visual_config: + visual_min_temperature = visual_config[CONF_MIN_TEMPERATURE] + + if CONF_MAX_TEMPERATURE in visual_config: + visual_max_temperature = visual_config[CONF_MAX_TEMPERATURE] + + for preset_config in config[CONF_PRESET]: + if CONF_DEFAULT_TARGET_TEMPERATURE_LOW in preset_config: + preset_min_temperature = preset_config[ + CONF_DEFAULT_TARGET_TEMPERATURE_LOW + ] + if preset_min_temperature < visual_min_temperature: + raise cv.Invalid( + f"{CONF_DEFAULT_TARGET_TEMPERATURE_LOW} for {preset_config[CONF_NAME]} is set to {preset_min_temperature} which is less than the visual minimum temperature of {visual_min_temperature}" + ) + + if CONF_DEFAULT_TARGET_TEMPERATURE_HIGH in preset_config: + preset_max_temperature = preset_config[ + CONF_DEFAULT_TARGET_TEMPERATURE_HIGH + ] + if preset_max_temperature > visual_max_temperature: + raise cv.Invalid( + f"{CONF_DEFAULT_TARGET_TEMPERATURE_HIGH} for {preset_config[CONF_NAME]} is set to {preset_max_temperature} which is more than the visual maximum temperature of {visual_max_temperature}" + ) + + # Mode validation + for preset_config in config[CONF_PRESET]: + if CONF_MODE not in preset_config: + continue + + mode = preset_config[CONF_MODE] + + for req in requirements[mode]: + if req not in config: + raise cv.Invalid( + f"{CONF_MODE} is set to {mode} for {preset_config[CONF_NAME]} but {req} is not present in the configuration" + ) + + # Fan mode requirements + requirements = { + "ON": [CONF_FAN_MODE_ON_ACTION], + "OFF": [CONF_FAN_MODE_OFF_ACTION], + "AUTO": [CONF_FAN_MODE_AUTO_ACTION], + "LOW": [CONF_FAN_MODE_LOW_ACTION], + "MEDIUM": [CONF_FAN_MODE_MEDIUM_ACTION], + "HIGH": [CONF_FAN_MODE_HIGH_ACTION], + "MIDDLE": [CONF_FAN_MODE_MIDDLE_ACTION], + "FOCUS": [CONF_FAN_MODE_FOCUS_ACTION], + "DIFFUSE": [CONF_FAN_MODE_DIFFUSE_ACTION], + } + + for preset_config in config[CONF_PRESET]: + if CONF_FAN_MODE not in preset_config: + continue + + fan_mode = preset_config[CONF_FAN_MODE] + + for req in requirements[fan_mode]: + if req not in config: + raise cv.Invalid( + f"{CONF_FAN_MODE} is set to {fan_mode} for {preset_config[CONF_NAME]} but {req} is not present in the configuration" + ) + + # Swing mode requirements + requirements = { + "OFF": [CONF_SWING_OFF_ACTION], + "BOTH": [CONF_SWING_BOTH_ACTION], + "VERTICAL": [CONF_SWING_VERTICAL_ACTION], + "HORIZONTAL": [CONF_SWING_HORIZONTAL_ACTION], + } + + for preset_config in config[CONF_PRESET]: + if CONF_SWING_MODE not in preset_config: + continue + + swing_mode = preset_config[CONF_SWING_MODE] + + for req in requirements[swing_mode]: + if req not in config: + raise cv.Invalid( + f"{CONF_SWING_MODE} is set to {swing_mode} for {preset_config[CONF_NAME]} but {req} is not present in the configuration" + ) + if config[CONF_FAN_WITH_COOLING] is True and CONF_FAN_ONLY_ACTION not in config: raise cv.Invalid( f"{CONF_FAN_ONLY_ACTION} must be defined to use {CONF_FAN_WITH_COOLING}" @@ -415,6 +541,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_LOW): cv.temperature, } ), + cv.Optional(CONF_PRESET): cv.ensure_list(PRESET_CONFIG_SCHEMA), + cv.Optional(CONF_PRESET_CHANGE): automation.validate_automation( + single=True + ), } ).extend(cv.COMPONENT_SCHEMA), cv.has_at_least_one_key( @@ -531,7 +661,7 @@ async def to_code(config): cg.add(var.set_supports_fan_with_heating(config[CONF_FAN_WITH_HEATING])) cg.add(var.set_use_startup_delay(config[CONF_STARTUP_DELAY])) - cg.add(var.set_normal_config(normal_config)) + cg.add(var.set_preset_config(ClimatePreset.CLIMATE_PRESET_HOME, normal_config)) await automation.build_automation( var.get_idle_action_trigger(), [], config[CONF_IDLE_ACTION] @@ -694,4 +824,55 @@ async def to_code(config): away_config = ThermostatClimateTargetTempConfig( away[CONF_DEFAULT_TARGET_TEMPERATURE_LOW] ) - cg.add(var.set_away_config(away_config)) + cg.add(var.set_preset_config(ClimatePreset.CLIMATE_PRESET_AWAY, away_config)) + + if CONF_PRESET in config: + for preset_config in config[CONF_PRESET]: + + name = preset_config[CONF_NAME] + standard_preset = None + if name.upper() in climate.CLIMATE_PRESETS: + standard_preset = climate.CLIMATE_PRESETS[name.upper()] + + if two_points_available is True: + preset_target_config = ThermostatClimateTargetTempConfig( + preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW], + preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH], + ) + elif CONF_DEFAULT_TARGET_TEMPERATURE_HIGH in preset_config: + preset_target_config = ThermostatClimateTargetTempConfig( + preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH] + ) + elif CONF_DEFAULT_TARGET_TEMPERATURE_LOW in preset_config: + preset_target_config = ThermostatClimateTargetTempConfig( + preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW] + ) + + preset_target_variable = cg.new_variable( + preset_config[CONF_ID], preset_target_config + ) + + if CONF_MODE in preset_config: + cg.add(preset_target_variable.set_mode(preset_config[CONF_MODE])) + + if CONF_FAN_MODE in preset_config: + cg.add( + preset_target_variable.set_fan_mode(preset_config[CONF_FAN_MODE]) + ) + + if CONF_SWING_MODE in preset_config: + cg.add( + preset_target_variable.set_swing_mode( + preset_config[CONF_SWING_MODE] + ) + ) + + if standard_preset is not None: + cg.add(var.set_preset_config(standard_preset, preset_target_variable)) + else: + cg.add(var.set_custom_preset_config(name, preset_target_variable)) + + if CONF_PRESET_CHANGE in config: + await automation.build_automation( + var.get_preset_change_trigger(), [], config[CONF_PRESET_CHANGE] + ) diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index 760525e2cd..dc4e1e437e 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -32,7 +32,7 @@ void ThermostatClimate::setup() { } else { // restore from defaults, change_away handles temps for us this->mode = this->default_mode_; - this->change_away_(false); + this->change_preset_(climate::CLIMATE_PRESET_HOME); } // refresh the climate action based on the restored settings, we'll publish_state() later this->switch_to_action_(this->compute_action_(), false); @@ -162,11 +162,20 @@ void ThermostatClimate::control(const climate::ClimateCall &call) { if (call.get_preset().has_value()) { // setup_complete_ blocks modifying/resetting the temps immediately after boot if (this->setup_complete_) { - this->change_away_(*call.get_preset() == climate::CLIMATE_PRESET_AWAY); + this->change_preset_(*call.get_preset()); } else { this->preset = *call.get_preset(); } } + if (call.get_custom_preset().has_value()) { + // setup_complete_ blocks modifying/resetting the temps immediately after boot + if (this->setup_complete_) { + this->change_custom_preset_(*call.get_custom_preset()); + } else { + this->custom_preset = *call.get_custom_preset(); + } + } + if (call.get_mode().has_value()) this->mode = *call.get_mode(); if (call.get_fan_mode().has_value()) @@ -236,8 +245,12 @@ climate::ClimateTraits ThermostatClimate::traits() { if (supports_swing_mode_vertical_) traits.add_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); - if (supports_away_) - traits.set_supported_presets({climate::CLIMATE_PRESET_HOME, climate::CLIMATE_PRESET_AWAY}); + for (auto &it : this->preset_config_) { + traits.add_supported_preset(it.first); + } + for (auto &it : this->custom_preset_config_) { + traits.add_supported_custom_preset(it.first); + } traits.set_supports_two_point_target_temperature(this->supports_two_points_); traits.set_supports_action(true); @@ -910,30 +923,112 @@ bool ThermostatClimate::supplemental_heating_required_() { (this->supplemental_action_ == climate::CLIMATE_ACTION_HEATING)); } -void ThermostatClimate::change_away_(bool away) { - if (!away) { +void ThermostatClimate::dump_preset_config_(const std::string &preset, + const ThermostatClimateTargetTempConfig &config) { + const auto *preset_name = preset.c_str(); + + if (this->supports_heat_) { if (this->supports_two_points_) { - this->target_temperature_low = this->normal_config_.default_temperature_low; - this->target_temperature_high = this->normal_config_.default_temperature_high; - } else - this->target_temperature = this->normal_config_.default_temperature; - } else { - if (this->supports_two_points_) { - this->target_temperature_low = this->away_config_.default_temperature_low; - this->target_temperature_high = this->away_config_.default_temperature_high; - } else - this->target_temperature = this->away_config_.default_temperature; + ESP_LOGCONFIG(TAG, " %s Default Target Temperature Low: %.1f°C", preset_name, + config.default_temperature_low); + } else { + ESP_LOGCONFIG(TAG, " %s Default Target Temperature Low: %.1f°C", preset_name, config.default_temperature); + } + } + if ((this->supports_cool_) || (this->supports_fan_only_)) { + if (this->supports_two_points_) { + ESP_LOGCONFIG(TAG, " %s Default Target Temperature High: %.1f°C", preset_name, + config.default_temperature_high); + } else { + ESP_LOGCONFIG(TAG, " %s Default Target Temperature High: %.1f°C", preset_name, config.default_temperature); + } + } + + if (config.mode_.has_value()) { + ESP_LOGCONFIG(TAG, " %s Default Mode: %s", preset_name, + LOG_STR_ARG(climate::climate_mode_to_string(*config.mode_))); + } + if (config.fan_mode_.has_value()) { + ESP_LOGCONFIG(TAG, " %s Default Fan Mode: %s", preset_name, + LOG_STR_ARG(climate::climate_fan_mode_to_string(*config.fan_mode_))); + } + if (config.swing_mode_.has_value()) { + ESP_LOGCONFIG(TAG, " %s Default Swing Mode: %s", preset_name, + LOG_STR_ARG(climate::climate_swing_mode_to_string(*config.swing_mode_))); } - this->preset = away ? climate::CLIMATE_PRESET_AWAY : climate::CLIMATE_PRESET_HOME; } -void ThermostatClimate::set_normal_config(const ThermostatClimateTargetTempConfig &normal_config) { - this->normal_config_ = normal_config; +void ThermostatClimate::change_preset_(climate::ClimatePreset preset) { + auto config = this->preset_config_.find(preset); + + if (config != this->preset_config_.end()) { + ESP_LOGI(TAG, "Switching to preset %s", LOG_STR_ARG(climate::climate_preset_to_string(preset))); + this->change_preset_internal_(config->second); + + this->custom_preset.reset(); + this->preset = preset; + } else { + ESP_LOGE(TAG, "Preset %s is not configured, ignoring.", LOG_STR_ARG(climate::climate_preset_to_string(preset))); + } } -void ThermostatClimate::set_away_config(const ThermostatClimateTargetTempConfig &away_config) { - this->supports_away_ = true; - this->away_config_ = away_config; +void ThermostatClimate::change_custom_preset_(const std::string &custom_preset) { + auto config = this->custom_preset_config_.find(custom_preset); + + if (config != this->custom_preset_config_.end()) { + ESP_LOGI(TAG, "Switching to custom preset %s", custom_preset.c_str()); + this->change_preset_internal_(config->second); + + this->preset.reset(); + this->custom_preset = custom_preset; + } else { + ESP_LOGE(TAG, "Custom Preset %s is not configured, ignoring.", custom_preset.c_str()); + } +} + +void ThermostatClimate::change_preset_internal_(const ThermostatClimateTargetTempConfig &config) { + if (this->supports_two_points_) { + this->target_temperature_low = config.default_temperature_low; + this->target_temperature_high = config.default_temperature_high; + } else { + this->target_temperature = config.default_temperature; + } + + // Note: The mode, fan_mode, and swing_mode can all be defined on the preset but if the climate.control call + // also specifies them then the control's version will override these for that call + if (config.mode_.has_value()) { + this->mode = *config.mode_; + ESP_LOGV(TAG, "Setting mode to %s", LOG_STR_ARG(climate::climate_mode_to_string(*config.mode_))); + } + + if (config.fan_mode_.has_value()) { + this->fan_mode = *config.fan_mode_; + ESP_LOGV(TAG, "Setting fan mode to %s", LOG_STR_ARG(climate::climate_fan_mode_to_string(*config.fan_mode_))); + } + + if (config.swing_mode_.has_value()) { + ESP_LOGV(TAG, "Setting swing mode to %s", LOG_STR_ARG(climate::climate_swing_mode_to_string(*config.swing_mode_))); + this->swing_mode = *config.swing_mode_; + } + + // Fire any preset changed trigger if defined + if (this->preset != preset) { + Trigger<> *trig = this->preset_change_trigger_; + assert(trig != nullptr); + trig->trigger(); + } + + this->refresh(); +} + +void ThermostatClimate::set_preset_config(climate::ClimatePreset preset, + const ThermostatClimateTargetTempConfig &config) { + this->preset_config_[preset] = config; +} + +void ThermostatClimate::set_custom_preset_config(const std::string &name, + const ThermostatClimateTargetTempConfig &config) { + this->custom_preset_config_[name] = config; } ThermostatClimate::ThermostatClimate() @@ -963,7 +1058,8 @@ ThermostatClimate::ThermostatClimate() swing_mode_off_trigger_(new Trigger<>()), swing_mode_horizontal_trigger_(new Trigger<>()), swing_mode_vertical_trigger_(new Trigger<>()), - temperature_change_trigger_(new Trigger<>()) {} + temperature_change_trigger_(new Trigger<>()), + preset_change_trigger_(new Trigger<>()) {} void ThermostatClimate::set_default_mode(climate::ClimateMode default_mode) { this->default_mode_ = default_mode; } void ThermostatClimate::set_set_point_minimum_differential(float differential) { @@ -1112,23 +1208,11 @@ Trigger<> *ThermostatClimate::get_swing_mode_off_trigger() const { return this-> Trigger<> *ThermostatClimate::get_swing_mode_horizontal_trigger() const { return this->swing_mode_horizontal_trigger_; } Trigger<> *ThermostatClimate::get_swing_mode_vertical_trigger() const { return this->swing_mode_vertical_trigger_; } Trigger<> *ThermostatClimate::get_temperature_change_trigger() const { return this->temperature_change_trigger_; } +Trigger<> *ThermostatClimate::get_preset_change_trigger() const { return this->preset_change_trigger_; } void ThermostatClimate::dump_config() { LOG_CLIMATE("", "Thermostat", this); - if (this->supports_heat_) { - if (this->supports_two_points_) { - ESP_LOGCONFIG(TAG, " Default Target Temperature Low: %.1f°C", this->normal_config_.default_temperature_low); - } else { - ESP_LOGCONFIG(TAG, " Default Target Temperature Low: %.1f°C", this->normal_config_.default_temperature); - } - } - if ((this->supports_cool_) || (this->supports_fan_only_ && this->supports_fan_only_cooling_)) { - if (this->supports_two_points_) { - ESP_LOGCONFIG(TAG, " Default Target Temperature High: %.1f°C", this->normal_config_.default_temperature_high); - } else { - ESP_LOGCONFIG(TAG, " Default Target Temperature High: %.1f°C", this->normal_config_.default_temperature); - } - } + if (this->supports_two_points_) ESP_LOGCONFIG(TAG, " Minimum Set Point Differential: %.1f°C", this->set_point_minimum_differential_); ESP_LOGCONFIG(TAG, " Start-up Delay Enabled: %s", YESNO(this->use_startup_delay_)); @@ -1194,24 +1278,21 @@ void ThermostatClimate::dump_config() { ESP_LOGCONFIG(TAG, " Supports SWING MODE HORIZONTAL: %s", YESNO(this->supports_swing_mode_horizontal_)); ESP_LOGCONFIG(TAG, " Supports SWING MODE VERTICAL: %s", YESNO(this->supports_swing_mode_vertical_)); ESP_LOGCONFIG(TAG, " Supports TWO SET POINTS: %s", YESNO(this->supports_two_points_)); - ESP_LOGCONFIG(TAG, " Supports AWAY mode: %s", YESNO(this->supports_away_)); - if (this->supports_away_) { - if (this->supports_heat_) { - if (this->supports_two_points_) { - ESP_LOGCONFIG(TAG, " Away Default Target Temperature Low: %.1f°C", - this->away_config_.default_temperature_low); - } else { - ESP_LOGCONFIG(TAG, " Away Default Target Temperature Low: %.1f°C", this->away_config_.default_temperature); - } - } - if ((this->supports_cool_) || (this->supports_fan_only_)) { - if (this->supports_two_points_) { - ESP_LOGCONFIG(TAG, " Away Default Target Temperature High: %.1f°C", - this->away_config_.default_temperature_high); - } else { - ESP_LOGCONFIG(TAG, " Away Default Target Temperature High: %.1f°C", this->away_config_.default_temperature); - } - } + + ESP_LOGCONFIG(TAG, " Supported PRESETS: "); + for (auto &it : this->preset_config_) { + const auto *preset_name = LOG_STR_ARG(climate::climate_preset_to_string(it.first)); + + ESP_LOGCONFIG(TAG, " Supports %s: %s", preset_name, YESNO(true)); + this->dump_preset_config_(preset_name, it.second); + } + + ESP_LOGCONFIG(TAG, " Supported CUSTOM PRESETS: "); + for (auto &it : this->custom_preset_config_) { + const auto *preset_name = it.first.c_str(); + + ESP_LOGCONFIG(TAG, " Supports %s: %s", preset_name, YESNO(true)); + this->dump_preset_config_(preset_name, it.second); } } diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index 8d3e926752..c9231370ba 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -4,6 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/components/climate/climate.h" #include "esphome/components/sensor/sensor.h" +#include namespace esphome { namespace thermostat { @@ -34,6 +35,10 @@ struct ThermostatClimateTargetTempConfig { ThermostatClimateTargetTempConfig(float default_temperature); ThermostatClimateTargetTempConfig(float default_temperature_low, float default_temperature_high); + void set_fan_mode(climate::ClimateFanMode fan_mode) { this->fan_mode_ = fan_mode; } + void set_swing_mode(climate::ClimateSwingMode swing_mode) { this->swing_mode_ = swing_mode; } + void set_mode(climate::ClimateMode mode) { this->mode_ = mode; } + float default_temperature{NAN}; float default_temperature_low{NAN}; float default_temperature_high{NAN}; @@ -41,6 +46,9 @@ struct ThermostatClimateTargetTempConfig { float cool_overrun_{NAN}; float heat_deadband_{NAN}; float heat_overrun_{NAN}; + optional fan_mode_{}; + optional swing_mode_{}; + optional mode_{}; }; class ThermostatClimate : public climate::Climate, public Component { @@ -94,8 +102,8 @@ class ThermostatClimate : public climate::Climate, public Component { void set_supports_swing_mode_vertical(bool supports_swing_mode_vertical); void set_supports_two_points(bool supports_two_points); - void set_normal_config(const ThermostatClimateTargetTempConfig &normal_config); - void set_away_config(const ThermostatClimateTargetTempConfig &away_config); + void set_preset_config(climate::ClimatePreset preset, const ThermostatClimateTargetTempConfig &config); + void set_custom_preset_config(const std::string &name, const ThermostatClimateTargetTempConfig &config); Trigger<> *get_cool_action_trigger() const; Trigger<> *get_supplemental_cool_action_trigger() const; @@ -124,6 +132,7 @@ class ThermostatClimate : public climate::Climate, public Component { Trigger<> *get_swing_mode_off_trigger() const; Trigger<> *get_swing_mode_vertical_trigger() const; Trigger<> *get_temperature_change_trigger() const; + Trigger<> *get_preset_change_trigger() const; /// Get current hysteresis values float cool_deadband(); float cool_overrun(); @@ -149,8 +158,14 @@ class ThermostatClimate : public climate::Climate, public Component { /// Override control to change settings of the climate device. void control(const climate::ClimateCall &call) override; - /// Change the away setting, will reset target temperatures to defaults. - void change_away_(bool away); + /// Change to a provided preset setting; will reset temperature, mode, fan, and swing modes accordingly + void change_preset_(climate::ClimatePreset preset); + /// Change to a provided custom preset setting; will reset temperature, mode, fan, and swing modes accordingly + void change_custom_preset_(const std::string &custom_preset); + + /// Applies the temperature, mode, fan, and swing modes of the provded config. + /// This is agnostic of custom vs built in preset + void change_preset_internal_(const ThermostatClimateTargetTempConfig &config); /// Return the traits of this controller. climate::ClimateTraits traits() override; @@ -210,6 +225,8 @@ class ThermostatClimate : public climate::Climate, public Component { bool supplemental_cooling_required_(); bool supplemental_heating_required_(); + void dump_preset_config_(const std::string &preset_name, const ThermostatClimateTargetTempConfig &config); + /// The sensor used for getting the current temperature sensor::Sensor *sensor_{nullptr}; @@ -267,11 +284,6 @@ class ThermostatClimate : public climate::Climate, public Component { /// A false value means that the controller has no such support. bool supports_two_points_{false}; - /// Whether the controller supports an "away" mode - /// - /// A false value means that the controller has no such mode. - bool supports_away_{false}; - /// Flags indicating if maximum allowable run time was exceeded bool cooling_max_runtime_exceeded_{false}; bool heating_max_runtime_exceeded_{false}; @@ -368,6 +380,9 @@ class ThermostatClimate : public climate::Climate, public Component { /// The trigger to call when the target temperature(s) change(es). Trigger<> *temperature_change_trigger_{nullptr}; + /// The triggr to call when the preset mode changes + Trigger<> *preset_change_trigger_{nullptr}; + /// A reference to the trigger that was previously active. /// /// This is so that the previous trigger can be stopped before enabling a new one @@ -409,10 +424,6 @@ class ThermostatClimate : public climate::Climate, public Component { /// Minimum allowable duration in seconds for action timers const uint8_t min_timer_duration_{1}; - /// Temperature data for normal/home and away modes - ThermostatClimateTargetTempConfig normal_config_{}; - ThermostatClimateTargetTempConfig away_config_{}; - /// Climate action timers std::vector timer_{ {"cool_run", false, 0, std::bind(&ThermostatClimate::cooling_max_run_time_timer_callback_, this)}, @@ -425,6 +436,11 @@ class ThermostatClimate : public climate::Climate, public Component { {"heat_off", false, 0, std::bind(&ThermostatClimate::heating_off_timer_callback_, this)}, {"heat_on", false, 0, std::bind(&ThermostatClimate::heating_on_timer_callback_, this)}, {"idle_on", false, 0, std::bind(&ThermostatClimate::idle_on_timer_callback_, this)}}; + + /// The set of standard preset configurations this thermostat supports (Eg. AWAY, ECO, etc) + std::map preset_config_{}; + /// The set of custom preset configurations this thermostat supports (eg. "My Custom Preset") + std::map custom_preset_config_{}; }; } // namespace thermostat