From 335210d788498b92177a882353980676ce01a9c1 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 2 Aug 2021 04:08:24 -0500 Subject: [PATCH] Thermostat enhancements and code clean-up (#2073) --- esphome/components/thermostat/climate.py | 177 +++++---- .../thermostat/thermostat_climate.cpp | 347 ++++++++++++------ .../thermostat/thermostat_climate.h | 42 ++- esphome/const.py | 6 + 4 files changed, 355 insertions(+), 217 deletions(-) diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index bf3195a5dd..55fb534682 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -6,7 +6,9 @@ from esphome.const import ( CONF_AUTO_MODE, CONF_AWAY_CONFIG, CONF_COOL_ACTION, + CONF_COOL_DEADBAND, CONF_COOL_MODE, + CONF_COOL_OVERRUN, CONF_DEFAULT_MODE, CONF_DEFAULT_TARGET_TEMPERATURE_HIGH, CONF_DEFAULT_TARGET_TEMPERATURE_LOW, @@ -22,14 +24,18 @@ from esphome.const import ( CONF_FAN_MODE_FOCUS_ACTION, CONF_FAN_MODE_DIFFUSE_ACTION, CONF_FAN_ONLY_ACTION, + CONF_FAN_ONLY_COOLING, CONF_FAN_ONLY_MODE, CONF_HEAT_ACTION, + CONF_HEAT_DEADBAND, CONF_HEAT_MODE, + CONF_HEAT_OVERRUN, CONF_HYSTERESIS, CONF_ID, CONF_IDLE_ACTION, CONF_OFF_MODE, CONF_SENSOR, + CONF_SET_POINT_MINIMUM_DIFFERENTIAL, CONF_SWING_BOTH_ACTION, CONF_SWING_HORIZONTAL_ACTION, CONF_SWING_OFF_ACTION, @@ -62,99 +68,59 @@ validate_climate_mode = cv.enum(CLIMATE_MODES, upper=True) def validate_thermostat(config): # verify corresponding climate action action exists for any defined climate mode action - if CONF_COOL_MODE in config and CONF_COOL_ACTION not in config: - raise cv.Invalid( - "{} must be defined to use {}".format(CONF_COOL_ACTION, CONF_COOL_MODE) - ) - if CONF_DRY_MODE in config and CONF_DRY_ACTION not in config: - raise cv.Invalid( - "{} must be defined to use {}".format(CONF_DRY_ACTION, CONF_DRY_MODE) - ) - if CONF_FAN_ONLY_MODE in config and CONF_FAN_ONLY_ACTION not in config: - raise cv.Invalid( - "{} must be defined to use {}".format( - CONF_FAN_ONLY_ACTION, CONF_FAN_ONLY_MODE - ) - ) - if CONF_HEAT_MODE in config and CONF_HEAT_ACTION not in config: - raise cv.Invalid( - "{} must be defined to use {}".format(CONF_HEAT_ACTION, CONF_HEAT_MODE) - ) - # verify corresponding default target temperature exists when a given climate action exists - if CONF_DEFAULT_TARGET_TEMPERATURE_HIGH not in config and ( - CONF_COOL_ACTION in config or CONF_FAN_ONLY_ACTION in config - ): - raise cv.Invalid( - "{} must be defined when using {} or {}".format( - CONF_DEFAULT_TARGET_TEMPERATURE_HIGH, + requirements = { + CONF_AUTO_MODE: [CONF_COOL_ACTION, CONF_HEAT_ACTION], + CONF_COOL_MODE: [CONF_COOL_ACTION], + CONF_DRY_MODE: [CONF_DRY_ACTION], + CONF_FAN_ONLY_MODE: [CONF_FAN_ONLY_ACTION], + CONF_HEAT_MODE: [CONF_HEAT_ACTION], + } + for config_mode, req_actions in requirements.items(): + for req_action in req_actions: + if config_mode in config and req_action not in config: + raise cv.Invalid(f"{req_action} must be defined to use {config_mode}") + + # determine validation requirements based on fan_only_cooling setting + if config[CONF_FAN_ONLY_COOLING] is True: + requirements = { + CONF_DEFAULT_TARGET_TEMPERATURE_HIGH: [ CONF_COOL_ACTION, CONF_FAN_ONLY_ACTION, - ) - ) - if CONF_DEFAULT_TARGET_TEMPERATURE_LOW not in config and CONF_HEAT_ACTION in config: - raise cv.Invalid( - "{} must be defined when using {}".format( - CONF_DEFAULT_TARGET_TEMPERATURE_LOW, CONF_HEAT_ACTION - ) - ) - # if a given climate action is NOT defined, it should not have a default target temperature - if CONF_DEFAULT_TARGET_TEMPERATURE_HIGH in config and ( - CONF_COOL_ACTION not in config and CONF_FAN_ONLY_ACTION not in config - ): - raise cv.Invalid( - "{} is defined with no {}".format( - CONF_DEFAULT_TARGET_TEMPERATURE_HIGH, CONF_COOL_ACTION - ) - ) - if CONF_DEFAULT_TARGET_TEMPERATURE_LOW in config and CONF_HEAT_ACTION not in config: - raise cv.Invalid( - "{} is defined with no {}".format( - CONF_DEFAULT_TARGET_TEMPERATURE_LOW, CONF_HEAT_ACTION - ) - ) + ], + CONF_DEFAULT_TARGET_TEMPERATURE_LOW: [CONF_HEAT_ACTION], + } + else: + requirements = { + CONF_DEFAULT_TARGET_TEMPERATURE_HIGH: [CONF_COOL_ACTION], + 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}") if CONF_AWAY_CONFIG in config: away = config[CONF_AWAY_CONFIG] - # verify corresponding default target temperature exists when a given climate action exists - if CONF_DEFAULT_TARGET_TEMPERATURE_HIGH not in away and ( - CONF_COOL_ACTION in config or CONF_FAN_ONLY_ACTION in config - ): - raise cv.Invalid( - "{} must be defined in away configuration when using {} or {}".format( - CONF_DEFAULT_TARGET_TEMPERATURE_HIGH, - CONF_COOL_ACTION, - CONF_FAN_ONLY_ACTION, - ) - ) - if ( - CONF_DEFAULT_TARGET_TEMPERATURE_LOW not in away - and CONF_HEAT_ACTION in config - ): - raise cv.Invalid( - "{} must be defined in away configuration when using {}".format( - CONF_DEFAULT_TARGET_TEMPERATURE_LOW, CONF_HEAT_ACTION - ) - ) - # if a given climate action is NOT defined, it should not have a default target temperature - if CONF_DEFAULT_TARGET_TEMPERATURE_HIGH in away and ( - CONF_COOL_ACTION not in config and CONF_FAN_ONLY_ACTION not in config - ): - raise cv.Invalid( - "{} is defined in away configuration with no {} or {}".format( - CONF_DEFAULT_TARGET_TEMPERATURE_HIGH, - CONF_COOL_ACTION, - CONF_FAN_ONLY_ACTION, - ) - ) - if ( - CONF_DEFAULT_TARGET_TEMPERATURE_LOW in away - and CONF_HEAT_ACTION not in config - ): - raise cv.Invalid( - "{} is defined in away configuration with no {}".format( - 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 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}" + ) + # verify default climate mode is valid given above configuration default_mode = config[CONF_DEFAULT_MODE] requirements = { @@ -241,7 +207,15 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_HIGH): cv.temperature, cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_LOW): cv.temperature, + cv.Optional( + CONF_SET_POINT_MINIMUM_DIFFERENTIAL, default=0.5 + ): cv.temperature, + cv.Optional(CONF_COOL_DEADBAND): cv.temperature, + cv.Optional(CONF_COOL_OVERRUN): cv.temperature, + cv.Optional(CONF_HEAT_DEADBAND): cv.temperature, + cv.Optional(CONF_HEAT_OVERRUN): cv.temperature, cv.Optional(CONF_HYSTERESIS, default=0.5): cv.temperature, + cv.Optional(CONF_FAN_ONLY_COOLING, default=False): cv.boolean, cv.Optional(CONF_AWAY_CONFIG): cv.Schema( { cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_HIGH): cv.temperature, @@ -269,8 +243,32 @@ 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_set_point_minimum_differential( + config[CONF_SET_POINT_MINIMUM_DIFFERENTIAL] + ) + ) cg.add(var.set_sensor(sens)) - cg.add(var.set_hysteresis(config[CONF_HYSTERESIS])) + + if CONF_COOL_DEADBAND in config: + cg.add(var.set_cool_deadband(config[CONF_COOL_DEADBAND])) + else: + cg.add(var.set_cool_deadband(config[CONF_HYSTERESIS])) + + if CONF_COOL_OVERRUN in config: + cg.add(var.set_cool_overrun(config[CONF_COOL_OVERRUN])) + else: + cg.add(var.set_cool_overrun(config[CONF_HYSTERESIS])) + + if CONF_HEAT_DEADBAND in config: + cg.add(var.set_heat_deadband(config[CONF_HEAT_DEADBAND])) + else: + cg.add(var.set_heat_deadband(config[CONF_HYSTERESIS])) + + if CONF_HEAT_OVERRUN in config: + cg.add(var.set_heat_overrun(config[CONF_HEAT_OVERRUN])) + else: + cg.add(var.set_heat_overrun(config[CONF_HYSTERESIS])) if two_points_available is True: cg.add(var.set_supports_two_points(True)) @@ -288,6 +286,7 @@ async def to_code(config): normal_config = ThermostatClimateTargetTempConfig( config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW] ) + cg.add(var.set_supports_fan_only_cooling(config[CONF_FAN_ONLY_COOLING])) cg.add(var.set_normal_config(normal_config)) await automation.build_automation( diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index 4610d5caad..1176a75514 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -7,6 +7,7 @@ namespace thermostat { static const char *const TAG = "thermostat.climate"; void ThermostatClimate::setup() { + // add a callback so that whenever the sensor state changes we can take action this->sensor_->add_on_state_callback([this](float state) { this->current_temperature = state; // required action may have changed, recompute, refresh @@ -29,7 +30,12 @@ void ThermostatClimate::setup() { this->setup_complete_ = true; this->publish_state(); } -float ThermostatClimate::hysteresis() { return this->hysteresis_; } + +float ThermostatClimate::cool_deadband() { return this->cool_deadband_; } +float ThermostatClimate::cool_overrun() { return this->cool_overrun_; } +float ThermostatClimate::heat_deadband() { return this->heat_deadband_; } +float ThermostatClimate::heat_overrun() { return this->heat_overrun_; } + void ThermostatClimate::refresh() { this->switch_to_mode_(this->mode); this->switch_to_action_(compute_action_()); @@ -38,6 +44,77 @@ void ThermostatClimate::refresh() { this->check_temperature_change_trigger_(); this->publish_state(); } + +bool ThermostatClimate::hysteresis_valid() { + if ((this->supports_cool_ || (this->supports_fan_only_ && this->supports_fan_only_cooling_)) && + (isnan(this->cool_deadband_) || isnan(this->cool_overrun_))) + return false; + + if (this->supports_heat_ && (isnan(this->heat_deadband_) || isnan(this->heat_overrun_))) + return false; + + return true; +} + +void ThermostatClimate::validate_target_temperature() { + if (isnan(this->target_temperature)) { + this->target_temperature = + ((this->get_traits().get_visual_max_temperature() - this->get_traits().get_visual_min_temperature()) / 2) + + this->get_traits().get_visual_min_temperature(); + } else { + // target_temperature must be between the visual minimum and the visual maximum + if (this->target_temperature < this->get_traits().get_visual_min_temperature()) + this->target_temperature = this->get_traits().get_visual_min_temperature(); + if (this->target_temperature > this->get_traits().get_visual_max_temperature()) + this->target_temperature = this->get_traits().get_visual_max_temperature(); + } +} + +void ThermostatClimate::validate_target_temperatures() { + if (this->supports_two_points_) { + validate_target_temperature_low(); + validate_target_temperature_high(); + } else { + validate_target_temperature(); + } +} + +void ThermostatClimate::validate_target_temperature_low() { + if (isnan(this->target_temperature_low)) { + this->target_temperature_low = this->get_traits().get_visual_min_temperature(); + } else { + // target_temperature_low must not be lower than the visual minimum + if (this->target_temperature_low < this->get_traits().get_visual_min_temperature()) + this->target_temperature_low = this->get_traits().get_visual_min_temperature(); + // target_temperature_low must not be greater than the visual maximum minus set_point_minimum_differential_ + if (this->target_temperature_low > + this->get_traits().get_visual_max_temperature() - this->set_point_minimum_differential_) + this->target_temperature_low = + this->get_traits().get_visual_max_temperature() - this->set_point_minimum_differential_; + // if target_temperature_low is set greater than target_temperature_high, move up target_temperature_high + if (this->target_temperature_low > this->target_temperature_high - this->set_point_minimum_differential_) + this->target_temperature_high = this->target_temperature_low + this->set_point_minimum_differential_; + } +} + +void ThermostatClimate::validate_target_temperature_high() { + if (isnan(this->target_temperature_high)) { + this->target_temperature_high = this->get_traits().get_visual_max_temperature(); + } else { + // target_temperature_high must not be lower than the visual maximum + if (this->target_temperature_high > this->get_traits().get_visual_max_temperature()) + this->target_temperature_high = this->get_traits().get_visual_max_temperature(); + // target_temperature_high must not be lower than the visual minimum plus set_point_minimum_differential_ + if (this->target_temperature_high < + this->get_traits().get_visual_min_temperature() + this->set_point_minimum_differential_) + this->target_temperature_high = + this->get_traits().get_visual_min_temperature() + this->set_point_minimum_differential_; + // if target_temperature_high is set less than target_temperature_low, move down target_temperature_low + if (this->target_temperature_high < this->target_temperature_low + this->set_point_minimum_differential_) + this->target_temperature_low = this->target_temperature_high - this->set_point_minimum_differential_; + } +} + void ThermostatClimate::control(const climate::ClimateCall &call) { if (call.get_preset().has_value()) { // setup_complete_ blocks modifying/resetting the temps immediately after boot @@ -53,29 +130,25 @@ void ThermostatClimate::control(const climate::ClimateCall &call) { this->fan_mode = *call.get_fan_mode(); if (call.get_swing_mode().has_value()) this->swing_mode = *call.get_swing_mode(); - if (call.get_target_temperature().has_value()) - this->target_temperature = *call.get_target_temperature(); - if (call.get_target_temperature_low().has_value()) - 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(); - // set point validation if (this->supports_two_points_) { - if (this->target_temperature_low < this->get_traits().get_visual_min_temperature()) - this->target_temperature_low = this->get_traits().get_visual_min_temperature(); - if (this->target_temperature_high > this->get_traits().get_visual_max_temperature()) - this->target_temperature_high = this->get_traits().get_visual_max_temperature(); - if (this->target_temperature_high < this->target_temperature_low) - this->target_temperature_high = this->target_temperature_low; + if (call.get_target_temperature_low().has_value()) { + this->target_temperature_low = *call.get_target_temperature_low(); + validate_target_temperature_low(); + } + if (call.get_target_temperature_high().has_value()) { + this->target_temperature_high = *call.get_target_temperature_high(); + validate_target_temperature_high(); + } } else { - if (this->target_temperature < this->get_traits().get_visual_min_temperature()) - this->target_temperature = this->get_traits().get_visual_min_temperature(); - if (this->target_temperature > this->get_traits().get_visual_max_temperature()) - this->target_temperature = this->get_traits().get_visual_max_temperature(); + if (call.get_target_temperature().has_value()) { + this->target_temperature = *call.get_target_temperature(); + validate_target_temperature(); + } } // make any changes happen refresh(); } + climate::ClimateTraits ThermostatClimate::traits() { auto traits = climate::ClimateTraits(); traits.set_supports_current_temperature(true); @@ -127,114 +200,54 @@ climate::ClimateTraits ThermostatClimate::traits() { traits.set_supports_action(true); 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) { + auto target_action = climate::CLIMATE_ACTION_IDLE; + // if any hysteresis values or current_temperature is not valid, we go to OFF; + if (isnan(this->current_temperature) || !this->hysteresis_valid()) { 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_)) - // if any control parameters are nan, go to OFF action (not IDLE!) - return climate::CLIMATE_ACTION_OFF; - - if (((this->action == climate::CLIMATE_ACTION_FAN) && (this->mode != climate::CLIMATE_MODE_FAN_ONLY)) || - ((this->action == climate::CLIMATE_ACTION_DRYING) && (this->mode != climate::CLIMATE_MODE_DRY))) { - target_action = climate::CLIMATE_ACTION_IDLE; - } - - switch (this->mode) { - case climate::CLIMATE_MODE_FAN_ONLY: - if (this->supports_fan_only_) { - if (this->current_temperature > this->target_temperature_high + this->hysteresis_) - target_action = climate::CLIMATE_ACTION_FAN; - else if (this->current_temperature < this->target_temperature_high - this->hysteresis_) - if (this->action == climate::CLIMATE_ACTION_FAN) - target_action = climate::CLIMATE_ACTION_IDLE; - } - break; - case climate::CLIMATE_MODE_DRY: - target_action = climate::CLIMATE_ACTION_DRYING; - break; - case climate::CLIMATE_MODE_HEAT_COOL: - case climate::CLIMATE_MODE_COOL: - case climate::CLIMATE_MODE_HEAT: - if (this->supports_cool_) { - if (this->current_temperature > this->target_temperature_high + this->hysteresis_) - target_action = climate::CLIMATE_ACTION_COOLING; - else if (this->current_temperature < this->target_temperature_high - this->hysteresis_) - if (this->action == climate::CLIMATE_ACTION_COOLING) - target_action = climate::CLIMATE_ACTION_IDLE; - } - if (this->supports_heat_) { - if (this->current_temperature < this->target_temperature_low - this->hysteresis_) - target_action = climate::CLIMATE_ACTION_HEATING; - else if (this->current_temperature > this->target_temperature_low + this->hysteresis_) - if (this->action == climate::CLIMATE_ACTION_HEATING) - target_action = climate::CLIMATE_ACTION_IDLE; - } - break; - default: - break; - } - } else { - if (isnan(this->current_temperature) || isnan(this->target_temperature) || isnan(this->hysteresis_)) - // if any control parameters are nan, go to OFF action (not IDLE!) - return climate::CLIMATE_ACTION_OFF; - - if (((this->action == climate::CLIMATE_ACTION_FAN) && (this->mode != climate::CLIMATE_MODE_FAN_ONLY)) || - ((this->action == climate::CLIMATE_ACTION_DRYING) && (this->mode != climate::CLIMATE_MODE_DRY))) { - target_action = climate::CLIMATE_ACTION_IDLE; - } - - switch (this->mode) { - case climate::CLIMATE_MODE_FAN_ONLY: - if (this->supports_fan_only_) { - if (this->current_temperature > this->target_temperature + this->hysteresis_) - target_action = climate::CLIMATE_ACTION_FAN; - else if (this->current_temperature < this->target_temperature - this->hysteresis_) - if (this->action == climate::CLIMATE_ACTION_FAN) - target_action = climate::CLIMATE_ACTION_IDLE; - } - break; - case climate::CLIMATE_MODE_DRY: - target_action = climate::CLIMATE_ACTION_DRYING; - break; - case climate::CLIMATE_MODE_COOL: - if (this->supports_cool_) { - if (this->current_temperature > this->target_temperature + this->hysteresis_) - target_action = climate::CLIMATE_ACTION_COOLING; - else if (this->current_temperature < this->target_temperature - this->hysteresis_) - if (this->action == climate::CLIMATE_ACTION_COOLING) - target_action = climate::CLIMATE_ACTION_IDLE; - } - case climate::CLIMATE_MODE_HEAT: - if (this->supports_heat_) { - if (this->current_temperature < this->target_temperature - this->hysteresis_) - target_action = climate::CLIMATE_ACTION_HEATING; - else if (this->current_temperature > this->target_temperature + this->hysteresis_) - if (this->action == climate::CLIMATE_ACTION_HEATING) - target_action = climate::CLIMATE_ACTION_IDLE; - } - break; - default: - break; - } + // ensure set point(s) is/are valid before computing the action + this->validate_target_temperatures(); + // everything has been validated so we can now safely compute the action + switch (this->mode) { + // if the climate mode is OFF then the climate action must be OFF + case climate::CLIMATE_MODE_OFF: + target_action = climate::CLIMATE_ACTION_OFF; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + if (this->fanning_required_()) + target_action = climate::CLIMATE_ACTION_FAN; + break; + case climate::CLIMATE_MODE_DRY: + target_action = climate::CLIMATE_ACTION_DRYING; + break; + case climate::CLIMATE_MODE_HEAT_COOL: + if (this->cooling_required_() && this->heating_required_()) { + // this is bad and should never happen, so just stop. + // target_action = climate::CLIMATE_ACTION_IDLE; + } else if (this->cooling_required_()) { + target_action = climate::CLIMATE_ACTION_COOLING; + } else if (this->heating_required_()) { + target_action = climate::CLIMATE_ACTION_HEATING; + } + break; + case climate::CLIMATE_MODE_COOL: + if (this->cooling_required_()) { + target_action = climate::CLIMATE_ACTION_COOLING; + } + break; + case climate::CLIMATE_MODE_HEAT: + if (this->heating_required_()) { + target_action = climate::CLIMATE_ACTION_HEATING; + } + break; + default: + break; } - // do not switch to an action that isn't enabled per the active climate mode - if ((this->mode == climate::CLIMATE_MODE_COOL) && (target_action == climate::CLIMATE_ACTION_HEATING)) - target_action = climate::CLIMATE_ACTION_IDLE; - if ((this->mode == climate::CLIMATE_MODE_HEAT) && (target_action == climate::CLIMATE_ACTION_COOLING)) - target_action = climate::CLIMATE_ACTION_IDLE; - return target_action; } + void ThermostatClimate::switch_to_action_(climate::ClimateAction action) { // setup_complete_ helps us ensure an action is called immediately after boot if ((action == this->action) && this->setup_complete_) @@ -284,6 +297,7 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action) { this->action = action; this->prev_action_trigger_ = trig; } + void ThermostatClimate::switch_to_fan_mode_(climate::ClimateFanMode fan_mode) { // setup_complete_ helps us ensure an action is called immediately after boot if ((fan_mode == this->prev_fan_mode_) && this->setup_complete_) @@ -335,6 +349,7 @@ void ThermostatClimate::switch_to_fan_mode_(climate::ClimateFanMode fan_mode) { this->prev_fan_mode_ = fan_mode; this->prev_fan_mode_trigger_ = trig; } + void ThermostatClimate::switch_to_mode_(climate::ClimateMode mode) { // setup_complete_ helps us ensure an action is called immediately after boot if ((mode == this->prev_mode_) && this->setup_complete_) @@ -377,6 +392,7 @@ void ThermostatClimate::switch_to_mode_(climate::ClimateMode mode) { this->prev_mode_ = mode; this->prev_mode_trigger_ = trig; } + void ThermostatClimate::switch_to_swing_mode_(climate::ClimateSwingMode swing_mode) { // setup_complete_ helps us ensure an action is called immediately after boot if ((swing_mode == this->prev_swing_mode_) && this->setup_complete_) @@ -413,6 +429,7 @@ 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 @@ -437,6 +454,70 @@ void ThermostatClimate::check_temperature_change_trigger_() { assert(trig != nullptr); trig->trigger(); } + +bool ThermostatClimate::cooling_required_() { + auto temperature = this->supports_two_points_ ? this->target_temperature_high : this->target_temperature; + + if (this->supports_cool_) { + if (this->current_temperature > (temperature + this->cool_deadband_)) { + // if the current temperature exceeds the target + deadband, cooling is required + return true; + } else if (this->current_temperature < (temperature - this->cool_overrun_)) { + // if the current temperature is less than the target - overrun, cooling should stop + return false; + } else { + // if we get here, the current temperature is between target + deadband and target - overrun, + // so the action should not change unless it conflicts with the current mode + return (this->action == climate::CLIMATE_ACTION_COOLING) && + ((this->mode == climate::CLIMATE_MODE_HEAT_COOL) || (this->mode == climate::CLIMATE_MODE_COOL)); + } + } + return false; +} + +bool ThermostatClimate::fanning_required_() { + auto temperature = this->supports_two_points_ ? this->target_temperature_high : this->target_temperature; + + if (this->supports_fan_only_) { + if (this->supports_fan_only_cooling_) { + if (this->current_temperature > (temperature + this->cool_deadband_)) { + // if the current temperature exceeds the target + deadband, fanning is required + return true; + } else if (this->current_temperature < (temperature - this->cool_overrun_)) { + // if the current temperature is less than the target - overrun, fanning should stop + return false; + } else { + // if we get here, the current temperature is between target + deadband and target - overrun, + // so the action should not change unless it conflicts with the current mode + return (this->action == climate::CLIMATE_ACTION_FAN) && (this->mode == climate::CLIMATE_MODE_FAN_ONLY); + } + } else { + return true; + } + } + return false; +} + +bool ThermostatClimate::heating_required_() { + auto temperature = this->supports_two_points_ ? this->target_temperature_low : this->target_temperature; + + if (this->supports_heat_) { + if (this->current_temperature < temperature - this->heat_deadband_) { + // if the current temperature is below the target - deadband, heating is required + return true; + } else if (this->current_temperature > temperature + this->heat_overrun_) { + // if the current temperature is above the target + overrun, heating should stop + return false; + } else { + // if we get here, the current temperature is between target - deadband and target + overrun, + // so the action should not change unless it conflicts with the current mode + return (this->action == climate::CLIMATE_ACTION_HEATING) && + ((this->mode == climate::CLIMATE_MODE_HEAT_COOL) || (this->mode == climate::CLIMATE_MODE_HEAT)); + } + } + return false; +} + void ThermostatClimate::change_away_(bool away) { if (!away) { if (this->supports_two_points_) { @@ -453,13 +534,16 @@ void ThermostatClimate::change_away_(bool away) { } 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::set_away_config(const ThermostatClimateTargetTempConfig &away_config) { this->supports_away_ = true; this->away_config_ = away_config; } + ThermostatClimate::ThermostatClimate() : cool_action_trigger_(new Trigger<>()), cool_mode_trigger_(new Trigger<>()), @@ -486,8 +570,15 @@ ThermostatClimate::ThermostatClimate() swing_mode_horizontal_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_set_point_minimum_differential(float differential) { + this->set_point_minimum_differential_ = differential; +} +void ThermostatClimate::set_cool_deadband(float deadband) { this->cool_deadband_ = deadband; } +void ThermostatClimate::set_cool_overrun(float overrun) { this->cool_overrun_ = overrun; } +void ThermostatClimate::set_heat_deadband(float deadband) { this->heat_deadband_ = deadband; } +void ThermostatClimate::set_heat_overrun(float overrun) { this->heat_overrun_ = overrun; } void ThermostatClimate::set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } void ThermostatClimate::set_supports_heat_cool(bool supports_heat_cool) { this->supports_heat_cool_ = supports_heat_cool; @@ -496,6 +587,9 @@ void ThermostatClimate::set_supports_auto(bool supports_auto) { this->supports_a void ThermostatClimate::set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } void ThermostatClimate::set_supports_dry(bool supports_dry) { this->supports_dry_ = supports_dry; } void ThermostatClimate::set_supports_fan_only(bool supports_fan_only) { this->supports_fan_only_ = supports_fan_only; } +void ThermostatClimate::set_supports_fan_only_cooling(bool supports_fan_only_cooling) { + this->supports_fan_only_cooling_ = supports_fan_only_cooling; +} void ThermostatClimate::set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } void ThermostatClimate::set_supports_fan_mode_on(bool supports_fan_mode_on) { this->supports_fan_mode_on_ = supports_fan_mode_on; @@ -539,6 +633,7 @@ void ThermostatClimate::set_supports_swing_mode_vertical(bool supports_swing_mod void ThermostatClimate::set_supports_two_points(bool supports_two_points) { this->supports_two_points_ = supports_two_points; } + Trigger<> *ThermostatClimate::get_cool_action_trigger() const { return this->cool_action_trigger_; } Trigger<> *ThermostatClimate::get_dry_action_trigger() const { return this->dry_action_trigger_; } Trigger<> *ThermostatClimate::get_fan_only_action_trigger() const { return this->fan_only_action_trigger_; } @@ -564,6 +659,7 @@ 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_; } + void ThermostatClimate::dump_config() { LOG_CLIMATE("", "Thermostat", this); if (this->supports_heat_) { @@ -578,12 +674,17 @@ void ThermostatClimate::dump_config() { else ESP_LOGCONFIG(TAG, " Default Target Temperature High: %.1f°C", this->normal_config_.default_temperature); } - ESP_LOGCONFIG(TAG, " Hysteresis: %.1f°C", this->hysteresis_); + ESP_LOGCONFIG(TAG, " Minimum Set Point Differential: %.1f°C", this->set_point_minimum_differential_); + ESP_LOGCONFIG(TAG, " Cool Deadband: %.1f°C", this->cool_deadband_); + ESP_LOGCONFIG(TAG, " Cool Overrun: %.1f°C", this->cool_overrun_); + ESP_LOGCONFIG(TAG, " Heat Deadband: %.1f°C", this->heat_deadband_); + ESP_LOGCONFIG(TAG, " Heat Overrun: %.1f°C", this->heat_overrun_); ESP_LOGCONFIG(TAG, " Supports AUTO: %s", YESNO(this->supports_auto_)); ESP_LOGCONFIG(TAG, " Supports HEAT/COOL: %s", YESNO(this->supports_heat_cool_)); ESP_LOGCONFIG(TAG, " Supports COOL: %s", YESNO(this->supports_cool_)); ESP_LOGCONFIG(TAG, " Supports DRY: %s", YESNO(this->supports_dry_)); ESP_LOGCONFIG(TAG, " Supports FAN_ONLY: %s", YESNO(this->supports_fan_only_)); + ESP_LOGCONFIG(TAG, " Supports FAN_ONLY_COOLING: %s", YESNO(this->supports_fan_only_cooling_)); ESP_LOGCONFIG(TAG, " Supports HEAT: %s", YESNO(this->supports_heat_)); ESP_LOGCONFIG(TAG, " Supports FAN MODE ON: %s", YESNO(this->supports_fan_mode_on_)); ESP_LOGCONFIG(TAG, " Supports FAN MODE OFF: %s", YESNO(this->supports_fan_mode_off_)); @@ -619,8 +720,10 @@ void ThermostatClimate::dump_config() { } ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig() = default; + ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig(float default_temperature) : default_temperature(default_temperature) {} + ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig(float default_temperature_low, float default_temperature_high) : default_temperature_low(default_temperature_low), default_temperature_high(default_temperature_high) {} diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index bff9e9bdc1..67f002a22a 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -17,7 +17,10 @@ struct ThermostatClimateTargetTempConfig { float default_temperature{NAN}; float default_temperature_low{NAN}; float default_temperature_high{NAN}; - float hysteresis{NAN}; + float cool_deadband_{NAN}; + float cool_overrun_{NAN}; + float heat_deadband_{NAN}; + float heat_overrun_{NAN}; }; class ThermostatClimate : public climate::Climate, public Component { @@ -27,13 +30,18 @@ class ThermostatClimate : public climate::Climate, public Component { void dump_config() override; void set_default_mode(climate::ClimateMode default_mode); - void set_hysteresis(float hysteresis); + void set_set_point_minimum_differential(float differential); + void set_cool_deadband(float deadband); + void set_cool_overrun(float overrun); + void set_heat_deadband(float deadband); + void set_heat_overrun(float overrun); void set_sensor(sensor::Sensor *sensor); void set_supports_auto(bool supports_auto); void set_supports_heat_cool(bool supports_heat_cool); void set_supports_cool(bool supports_cool); void set_supports_dry(bool supports_dry); void set_supports_fan_only(bool supports_fan_only); + void set_supports_fan_only_cooling(bool supports_fan_only_cooling); void set_supports_heat(bool supports_heat); void set_supports_fan_mode_on(bool supports_fan_mode_on); void set_supports_fan_mode_off(bool supports_fan_mode_off); @@ -78,10 +86,19 @@ 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; - /// Get current hysteresis value - float hysteresis(); + /// Get current hysteresis values + float cool_deadband(); + float cool_overrun(); + float heat_deadband(); + float heat_overrun(); /// Call triggers based on updated climate states (modes/actions) void refresh(); + /// Set point and hysteresis validation + bool hysteresis_valid(); // returns true if valid + void validate_target_temperature(); + void validate_target_temperatures(); + void validate_target_temperature_low(); + void validate_target_temperature_high(); protected: /// Override control to change settings of the climate device. @@ -111,6 +128,11 @@ class ThermostatClimate : public climate::Climate, public Component { /// Check if the temperature change trigger should be called. void check_temperature_change_trigger_(); + /// Check if cooling/fanning/heating actions are required; returns true if so + bool cooling_required_(); + bool fanning_required_(); + bool heating_required_(); + /// The sensor used for getting the current temperature sensor::Sensor *sensor_{nullptr}; @@ -124,6 +146,8 @@ class ThermostatClimate : public climate::Climate, public Component { bool supports_dry_{false}; bool supports_fan_only_{false}; bool supports_heat_{false}; + /// Special flag -- enables fan to be switched based on target_temperature_high + bool supports_fan_only_cooling_{false}; /// Whether the controller supports turning on or off just the fan. /// @@ -278,8 +302,14 @@ class ThermostatClimate : public climate::Climate, public Component { ThermostatClimateTargetTempConfig normal_config_{}; ThermostatClimateTargetTempConfig away_config_{}; - /// Hysteresis value used for computing climate actions - float hysteresis_{0}; + /// Minimum differential required between set points + float set_point_minimum_differential_{0}; + + /// Hysteresis values used for computing climate actions + float cool_deadband_{0}; + float cool_overrun_{0}; + float heat_deadband_{0}; + float heat_overrun_{0}; /// setup_complete_ blocks modifying/resetting the temps immediately after boot bool setup_complete_{false}; diff --git a/esphome/const.py b/esphome/const.py index 8327921211..1ed8aeb402 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -134,7 +134,9 @@ CONF_CONDITION_ID = "condition_id" CONF_CONDUCTIVITY = "conductivity" CONF_CONTRAST = "contrast" CONF_COOL_ACTION = "cool_action" +CONF_COOL_DEADBAND = "cool_deadband" CONF_COOL_MODE = "cool_mode" +CONF_COOL_OVERRUN = "cool_overrun" CONF_COUNT = "count" CONF_COUNT_MODE = "count_mode" CONF_COURSE = "course" @@ -219,6 +221,7 @@ CONF_FAN_MODE_MIDDLE_ACTION = "fan_mode_middle_action" CONF_FAN_MODE_OFF_ACTION = "fan_mode_off_action" CONF_FAN_MODE_ON_ACTION = "fan_mode_on_action" CONF_FAN_ONLY_ACTION = "fan_only_action" +CONF_FAN_ONLY_COOLING = "fan_only_cooling" CONF_FAN_ONLY_MODE = "fan_only_mode" CONF_FAST_CONNECT = "fast_connect" CONF_FILE = "file" @@ -248,7 +251,9 @@ CONF_GROUP = "group" CONF_HARDWARE_UART = "hardware_uart" CONF_HEARTBEAT = "heartbeat" CONF_HEAT_ACTION = "heat_action" +CONF_HEAT_DEADBAND = "heat_deadband" CONF_HEAT_MODE = "heat_mode" +CONF_HEAT_OVERRUN = "heat_overrun" CONF_HEATER = "heater" CONF_HEIGHT = "height" CONF_HIDDEN = "hidden" @@ -541,6 +546,7 @@ CONF_SERVERS = "servers" CONF_SERVICE = "service" CONF_SERVICE_UUID = "service_uuid" CONF_SERVICES = "services" +CONF_SET_POINT_MINIMUM_DIFFERENTIAL = "set_point_minimum_differential" CONF_SETUP_MODE = "setup_mode" CONF_SETUP_PRIORITY = "setup_priority" CONF_SHUNT_RESISTANCE = "shunt_resistance"