diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 07a94fd184..bf3195a5dd 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -7,6 +7,7 @@ from esphome.const import ( CONF_AWAY_CONFIG, CONF_COOL_ACTION, CONF_COOL_MODE, + CONF_DEFAULT_MODE, CONF_DEFAULT_TARGET_TEMPERATURE_HIGH, CONF_DEFAULT_TARGET_TEMPERATURE_LOW, CONF_DRY_ACTION, @@ -33,10 +34,12 @@ from esphome.const import ( CONF_SWING_HORIZONTAL_ACTION, CONF_SWING_OFF_ACTION, CONF_SWING_VERTICAL_ACTION, + CONF_TARGET_TEMPERATURE_CHANGE_ACTION, ) CODEOWNERS = ["@kbx81"] +climate_ns = cg.esphome_ns.namespace("climate") thermostat_ns = cg.esphome_ns.namespace("thermostat") ThermostatClimate = thermostat_ns.class_( "ThermostatClimate", climate.Climate, cg.Component @@ -44,6 +47,17 @@ ThermostatClimate = thermostat_ns.class_( ThermostatClimateTargetTempConfig = thermostat_ns.struct( "ThermostatClimateTargetTempConfig" ) +ClimateMode = climate_ns.enum("ClimateMode") +CLIMATE_MODES = { + "OFF": ClimateMode.CLIMATE_MODE_OFF, + "HEAT_COOL": ClimateMode.CLIMATE_MODE_HEAT_COOL, + "COOL": ClimateMode.CLIMATE_MODE_COOL, + "HEAT": ClimateMode.CLIMATE_MODE_HEAT, + "DRY": ClimateMode.CLIMATE_MODE_DRY, + "FAN_ONLY": ClimateMode.CLIMATE_MODE_FAN_ONLY, + "AUTO": ClimateMode.CLIMATE_MODE_AUTO, +} +validate_climate_mode = cv.enum(CLIMATE_MODES, upper=True) def validate_thermostat(config): @@ -141,6 +155,21 @@ def validate_thermostat(config): CONF_DEFAULT_TARGET_TEMPERATURE_LOW, CONF_HEAT_ACTION ) ) + # verify default climate mode is valid given above configuration + default_mode = config[CONF_DEFAULT_MODE] + requirements = { + "HEAT_COOL": [CONF_COOL_ACTION, CONF_HEAT_ACTION], + "COOL": [CONF_COOL_ACTION], + "HEAT": [CONF_HEAT_ACTION], + "DRY": [CONF_DRY_ACTION], + "FAN_ONLY": [CONF_FAN_ONLY_ACTION], + "AUTO": [CONF_COOL_ACTION, CONF_HEAT_ACTION], + }.get(default_mode, []) + for req in requirements: + 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" + ) return config @@ -204,6 +233,12 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_SWING_VERTICAL_ACTION): automation.validate_automation( single=True ), + cv.Optional( + CONF_TARGET_TEMPERATURE_CHANGE_ACTION + ): automation.validate_automation(single=True), + cv.Optional(CONF_DEFAULT_MODE, default="OFF"): cv.templatable( + validate_climate_mode + ), cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_HIGH): cv.temperature, cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_LOW): cv.temperature, cv.Optional(CONF_HYSTERESIS, default=0.5): cv.temperature, @@ -233,6 +268,7 @@ async def to_code(config): ) sens = await cg.get_variable(config[CONF_SENSOR]) + cg.add(var.set_default_mode(config[CONF_DEFAULT_MODE])) cg.add(var.set_sensor(sens)) cg.add(var.set_hysteresis(config[CONF_HYSTERESIS])) @@ -380,6 +416,12 @@ async def to_code(config): config[CONF_SWING_VERTICAL_ACTION], ) cg.add(var.set_supports_swing_mode_vertical(True)) + if CONF_TARGET_TEMPERATURE_CHANGE_ACTION in config: + await automation.build_automation( + var.get_temperature_change_trigger(), + [], + config[CONF_TARGET_TEMPERATURE_CHANGE_ACTION], + ) if CONF_AWAY_CONFIG in config: away = config[CONF_AWAY_CONFIG] diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index 305db66f16..4610d5caad 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -21,7 +21,7 @@ void ThermostatClimate::setup() { restore->to_call(this).perform(); } else { // restore from defaults, change_away handles temps for us - this->mode = climate::CLIMATE_MODE_HEAT_COOL; + this->mode = this->default_mode_; this->change_away_(false); } // refresh the climate action based on the restored settings @@ -35,9 +35,18 @@ void ThermostatClimate::refresh() { this->switch_to_action_(compute_action_()); this->switch_to_fan_mode_(this->fan_mode.value()); this->switch_to_swing_mode_(this->swing_mode); + this->check_temperature_change_trigger_(); this->publish_state(); } 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); + } else { + this->preset = *call.get_preset(); + } + } if (call.get_mode().has_value()) this->mode = *call.get_mode(); if (call.get_fan_mode().has_value()) @@ -50,15 +59,6 @@ void ThermostatClimate::control(const climate::ClimateCall &call) { this->target_temperature_low = *call.get_target_temperature_low(); if (call.get_target_temperature_high().has_value()) this->target_temperature_high = *call.get_target_temperature_high(); - 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); - } else { - this->preset = *call.get_preset(); - ; - } - } // set point validation if (this->supports_two_points_) { if (this->target_temperature_low < this->get_traits().get_visual_min_temperature()) @@ -128,7 +128,16 @@ climate::ClimateTraits ThermostatClimate::traits() { return traits; } climate::ClimateAction ThermostatClimate::compute_action_() { + // we need to know the current climate action before anything else happens here climate::ClimateAction target_action = this->action; + // if the climate mode is OFF then the climate action must be OFF + if (this->mode == climate::CLIMATE_MODE_OFF) { + return climate::CLIMATE_ACTION_OFF; + } else if (this->action == climate::CLIMATE_ACTION_OFF) { + // ...but if the climate mode is NOT OFF then the climate action must not be OFF + target_action = climate::CLIMATE_ACTION_IDLE; + } + if (this->supports_two_points_) { if (isnan(this->current_temperature) || isnan(this->target_temperature_low) || isnan(this->target_temperature_high) || isnan(this->hysteresis_)) @@ -153,9 +162,6 @@ climate::ClimateAction ThermostatClimate::compute_action_() { case climate::CLIMATE_MODE_DRY: target_action = climate::CLIMATE_ACTION_DRYING; break; - case climate::CLIMATE_MODE_OFF: - target_action = climate::CLIMATE_ACTION_OFF; - break; case climate::CLIMATE_MODE_HEAT_COOL: case climate::CLIMATE_MODE_COOL: case climate::CLIMATE_MODE_HEAT: @@ -200,9 +206,6 @@ climate::ClimateAction ThermostatClimate::compute_action_() { case climate::CLIMATE_MODE_DRY: target_action = climate::CLIMATE_ACTION_DRYING; break; - case climate::CLIMATE_MODE_OFF: - target_action = climate::CLIMATE_ACTION_OFF; - break; case climate::CLIMATE_MODE_COOL: if (this->supports_cool_) { if (this->current_temperature > this->target_temperature + this->hysteresis_) @@ -410,6 +413,30 @@ void ThermostatClimate::switch_to_swing_mode_(climate::ClimateSwingMode swing_mo this->prev_swing_mode_ = swing_mode; this->prev_swing_mode_trigger_ = trig; } +void ThermostatClimate::check_temperature_change_trigger_() { + if (this->supports_two_points_) { + // setup_complete_ helps us ensure an action is called immediately after boot + if ((this->prev_target_temperature_low_ == this->target_temperature_low) && + (this->prev_target_temperature_high_ == this->target_temperature_high) && this->setup_complete_) { + return; // nothing changed, no reason to trigger + } else { + // save the new temperatures so we can check them again later; the trigger will fire below + this->prev_target_temperature_low_ = this->target_temperature_low; + this->prev_target_temperature_high_ = this->target_temperature_high; + } + } else { + if ((this->prev_target_temperature_ == this->target_temperature) && this->setup_complete_) { + return; // nothing changed, no reason to trigger + } else { + // save the new temperature so we can check it again later; the trigger will fire below + this->prev_target_temperature_ = this->target_temperature; + } + } + // trigger the action + Trigger<> *trig = this->temperature_change_trigger_; + assert(trig != nullptr); + trig->trigger(); +} void ThermostatClimate::change_away_(bool away) { if (!away) { if (this->supports_two_points_) { @@ -457,7 +484,9 @@ ThermostatClimate::ThermostatClimate() swing_mode_both_trigger_(new Trigger<>()), swing_mode_off_trigger_(new Trigger<>()), swing_mode_horizontal_trigger_(new Trigger<>()), - swing_mode_vertical_trigger_(new Trigger<>()) {} + swing_mode_vertical_trigger_(new Trigger<>()), + temperature_change_trigger_(new Trigger<>()) {} +void ThermostatClimate::set_default_mode(climate::ClimateMode default_mode) { this->default_mode_ = default_mode; } void ThermostatClimate::set_hysteresis(float hysteresis) { this->hysteresis_ = hysteresis; } void ThermostatClimate::set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } void ThermostatClimate::set_supports_heat_cool(bool supports_heat_cool) { @@ -534,6 +563,7 @@ Trigger<> *ThermostatClimate::get_swing_mode_both_trigger() const { return this- Trigger<> *ThermostatClimate::get_swing_mode_off_trigger() const { return this->swing_mode_off_trigger_; } 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_; } void ThermostatClimate::dump_config() { LOG_CLIMATE("", "Thermostat", this); if (this->supports_heat_) { diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index 3fd482da53..bff9e9bdc1 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -26,6 +26,7 @@ class ThermostatClimate : public climate::Climate, public Component { void setup() override; void dump_config() override; + void set_default_mode(climate::ClimateMode default_mode); void set_hysteresis(float hysteresis); void set_sensor(sensor::Sensor *sensor); void set_supports_auto(bool supports_auto); @@ -76,6 +77,7 @@ class ThermostatClimate : public climate::Climate, public Component { Trigger<> *get_swing_mode_horizontal_trigger() const; Trigger<> *get_swing_mode_off_trigger() const; Trigger<> *get_swing_mode_vertical_trigger() const; + Trigger<> *get_temperature_change_trigger() const; /// Get current hysteresis value float hysteresis(); /// Call triggers based on updated climate states (modes/actions) @@ -106,6 +108,9 @@ class ThermostatClimate : public climate::Climate, public Component { /// Switch the climate device to the given climate swing mode. void switch_to_swing_mode_(climate::ClimateSwingMode swing_mode); + /// Check if the temperature change trigger should be called. + void check_temperature_change_trigger_(); + /// The sensor used for getting the current temperature sensor::Sensor *sensor_{nullptr}; @@ -242,6 +247,9 @@ class ThermostatClimate : public climate::Climate, public Component { /// The trigger to call when the controller should switch the swing mode to "vertical". Trigger<> *swing_mode_vertical_trigger_{nullptr}; + /// The trigger to call when the target temperature(s) change(es). + Trigger<> *temperature_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 @@ -256,8 +264,16 @@ class ThermostatClimate : public climate::Climate, public Component { /// These are used to determine when a trigger/action needs to be called climate::ClimateFanMode prev_fan_mode_{climate::CLIMATE_FAN_ON}; climate::ClimateMode prev_mode_{climate::CLIMATE_MODE_OFF}; + climate::ClimateMode default_mode_{climate::CLIMATE_MODE_OFF}; climate::ClimateSwingMode prev_swing_mode_{climate::CLIMATE_SWING_OFF}; + /// Store previously-known temperatures + /// + /// These are used to determine when the temperature change trigger/action needs to be called + float prev_target_temperature_{NAN}; + float prev_target_temperature_low_{NAN}; + float prev_target_temperature_high_{NAN}; + /// Temperature data for normal/home and away modes ThermostatClimateTargetTempConfig normal_config_{}; ThermostatClimateTargetTempConfig away_config_{}; diff --git a/esphome/const.py b/esphome/const.py index 8baca4d34f..3eb56c7cf6 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -159,6 +159,7 @@ CONF_DAYS_OF_WEEK = "days_of_week" CONF_DC_PIN = "dc_pin" CONF_DEBOUNCE = "debounce" CONF_DECELERATION = "deceleration" +CONF_DEFAULT_MODE = "default_mode" CONF_DEFAULT_TARGET_TEMPERATURE_HIGH = "default_target_temperature_high" CONF_DEFAULT_TARGET_TEMPERATURE_LOW = "default_target_temperature_low" CONF_DEFAULT_TRANSITION_LENGTH = "default_transition_length" @@ -572,6 +573,7 @@ CONF_TABLET = "tablet" CONF_TAG = "tag" CONF_TARGET = "target" CONF_TARGET_TEMPERATURE = "target_temperature" +CONF_TARGET_TEMPERATURE_CHANGE_ACTION = "target_temperature_change_action" CONF_TARGET_TEMPERATURE_HIGH = "target_temperature_high" CONF_TARGET_TEMPERATURE_LOW = "target_temperature_low" CONF_TEMPERATURE = "temperature"