diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 5e26e6d6de..8aa61dbb93 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -69,6 +69,8 @@ from esphome.const import ( ) CONF_PRESET_CHANGE = "preset_change" +CONF_DEFAULT_PRESET = "default_preset" +CONF_ON_BOOT_RESTORE_FROM = "on_boot_restore_from" CODEOWNERS = ["@kbx81"] @@ -80,6 +82,13 @@ ThermostatClimate = thermostat_ns.class_( ThermostatClimateTargetTempConfig = thermostat_ns.struct( "ThermostatClimateTargetTempConfig" ) +OnBootRestoreFrom = thermostat_ns.enum("OnBootRestoreFrom") +ON_BOOT_RESTORE_FROM = { + "MEMORY": OnBootRestoreFrom.MEMORY, + "DEFAULT_PRESET": OnBootRestoreFrom.DEFAULT_PRESET, +} +validate_on_boot_restore_from = cv.enum(ON_BOOT_RESTORE_FROM, upper=True) + ClimateMode = climate_ns.enum("ClimateMode") CLIMATE_MODES = { "OFF": ClimateMode.CLIMATE_MODE_OFF, @@ -125,6 +134,17 @@ def validate_temperature_preset(preset, root_config, name, requirements): ) +def generate_comparable_preset(config, name): + comparable_preset = f"{CONF_PRESET}:\n" f" - {CONF_NAME}: {name}\n" + + if CONF_DEFAULT_TARGET_TEMPERATURE_LOW in config: + comparable_preset += f" {CONF_DEFAULT_TARGET_TEMPERATURE_LOW}: {config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW]}\n" + if CONF_DEFAULT_TARGET_TEMPERATURE_HIGH in config: + comparable_preset += f" {CONF_DEFAULT_TARGET_TEMPERATURE_HIGH}: {config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH]}\n" + + return comparable_preset + + def validate_thermostat(config): # verify corresponding action(s) exist(s) for any defined climate mode or action requirements = { @@ -277,13 +297,32 @@ def validate_thermostat(config): CONF_DEFAULT_TARGET_TEMPERATURE_LOW: [CONF_HEAT_ACTION], } - # Validate temperature requirements for default configuraation - validate_temperature_preset(config, config, "default", requirements) + # Legacy high/low configs + if CONF_DEFAULT_TARGET_TEMPERATURE_LOW in config: + comparable_preset = generate_comparable_preset(config, "Your new preset") - # Validate temperature requirements for away configuration + raise cv.Invalid( + f"{CONF_DEFAULT_TARGET_TEMPERATURE_LOW} is no longer valid. Please switch to using a preset for an equivalent experience.\nEquivalent configuration:\n\n" + f"{comparable_preset}" + ) + if CONF_DEFAULT_TARGET_TEMPERATURE_HIGH in config: + comparable_preset = generate_comparable_preset(config, "Your new preset") + + raise cv.Invalid( + f"{CONF_DEFAULT_TARGET_TEMPERATURE_HIGH} is no longer valid. Please switch to using a preset for an equivalent experience.\nEquivalent configuration:\n\n" + f"{comparable_preset}" + ) + + # Legacy away mode - raise an error instructing the user to switch to presets if CONF_AWAY_CONFIG in config: - away = config[CONF_AWAY_CONFIG] - validate_temperature_preset(away, config, "away", requirements) + comparable_preset = generate_comparable_preset(config[CONF_AWAY_CONFIG], "Away") + + raise cv.Invalid( + f"{CONF_AWAY_CONFIG} is no longer valid. Please switch to using a preset named " + "Away" + " for an equivalent experience.\nEquivalent configuration:\n\n" + f"{comparable_preset}" + ) # Validate temperature requirements for presets if CONF_PRESET in config: @@ -292,7 +331,12 @@ def validate_thermostat(config): preset_config, config, preset_config[CONF_NAME], requirements ) - # Verify default climate mode is valid given above configuration + # Warn about using the removed CONF_DEFAULT_MODE and advise users + if CONF_DEFAULT_MODE in config and config[CONF_DEFAULT_MODE] is not None: + raise cv.Invalid( + f"{CONF_DEFAULT_MODE} is no longer valid. Please switch to using presets and specify a {CONF_DEFAULT_PRESET}." + ) + default_mode = config[CONF_DEFAULT_MODE] requirements = { "HEAT_COOL": [CONF_COOL_ACTION, CONF_HEAT_ACTION], @@ -403,6 +447,38 @@ def validate_thermostat(config): f"{CONF_SWING_MODE} is set to {swing_mode} for {preset_config[CONF_NAME]} but {req} is not present in the configuration" ) + # If a default preset is requested then ensure that preset is defined + if CONF_DEFAULT_PRESET in config: + default_preset = config[CONF_DEFAULT_PRESET] + + if CONF_PRESET not in config: + raise cv.Invalid( + f"{CONF_DEFAULT_PRESET} is specified but no presets are defined" + ) + + presets = config[CONF_PRESET] + found_preset = False + + for preset in presets: + if preset[CONF_NAME] == default_preset: + found_preset = True + break + + if found_preset is False: + raise cv.Invalid( + f"{CONF_DEFAULT_PRESET} set to '{default_preset}' but no such preset has been defined. Available presets: {[preset[CONF_NAME] for preset in presets]}" + ) + + # If restoring default preset on boot is true then ensure we have a default preset + if ( + CONF_ON_BOOT_RESTORE_FROM in config + and config[CONF_ON_BOOT_RESTORE_FROM] is OnBootRestoreFrom.DEFAULT_PRESET + ): + if CONF_DEFAULT_PRESET not in config: + raise cv.Invalid( + f"{CONF_DEFAULT_PRESET} must be defined to use {CONF_ON_BOOT_RESTORE_FROM} in DEFAULT_PRESET mode" + ) + 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}" @@ -502,9 +578,8 @@ CONFIG_SCHEMA = cv.All( 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_MODE, default=None): cv.valid, + cv.Optional(CONF_DEFAULT_PRESET): cv.templatable(cv.string), cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_HIGH): cv.temperature, cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_LOW): cv.temperature, cv.Optional( @@ -542,6 +617,7 @@ CONFIG_SCHEMA = cv.All( } ), cv.Optional(CONF_PRESET): cv.ensure_list(PRESET_CONFIG_SCHEMA), + cv.Optional(CONF_ON_BOOT_RESTORE_FROM): validate_on_boot_restore_from, cv.Optional(CONF_PRESET_CHANGE): automation.validate_automation( single=True ), @@ -564,9 +640,10 @@ async def to_code(config): CONF_COOL_ACTION in config or (config[CONF_FAN_ONLY_COOLING] and CONF_FAN_ONLY_ACTION in config) ) + if two_points_available: + cg.add(var.set_supports_two_points(True)) 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] @@ -579,23 +656,6 @@ async def to_code(config): cg.add(var.set_heat_deadband(config[CONF_HEAT_DEADBAND])) cg.add(var.set_heat_overrun(config[CONF_HEAT_OVERRUN])) - if two_points_available is True: - cg.add(var.set_supports_two_points(True)) - normal_config = ThermostatClimateTargetTempConfig( - config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW], - config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH], - ) - elif CONF_DEFAULT_TARGET_TEMPERATURE_HIGH in config: - cg.add(var.set_supports_two_points(False)) - normal_config = ThermostatClimateTargetTempConfig( - config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH] - ) - elif CONF_DEFAULT_TARGET_TEMPERATURE_LOW in config: - cg.add(var.set_supports_two_points(False)) - normal_config = ThermostatClimateTargetTempConfig( - config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW] - ) - if CONF_MAX_COOLING_RUN_TIME in config: cg.add( var.set_cooling_maximum_run_time_in_sec(config[CONF_MAX_COOLING_RUN_TIME]) @@ -661,7 +721,6 @@ 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_preset_config(ClimatePreset.CLIMATE_PRESET_HOME, normal_config)) await automation.build_automation( var.get_idle_action_trigger(), [], config[CONF_IDLE_ACTION] @@ -808,27 +867,8 @@ async def to_code(config): config[CONF_TARGET_TEMPERATURE_CHANGE_ACTION], ) - if CONF_AWAY_CONFIG in config: - away = config[CONF_AWAY_CONFIG] - - if two_points_available is True: - away_config = ThermostatClimateTargetTempConfig( - away[CONF_DEFAULT_TARGET_TEMPERATURE_LOW], - away[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH], - ) - elif CONF_DEFAULT_TARGET_TEMPERATURE_HIGH in away: - away_config = ThermostatClimateTargetTempConfig( - away[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH] - ) - elif CONF_DEFAULT_TARGET_TEMPERATURE_LOW in away: - away_config = ThermostatClimateTargetTempConfig( - away[CONF_DEFAULT_TARGET_TEMPERATURE_LOW] - ) - 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: @@ -872,6 +912,19 @@ async def to_code(config): else: cg.add(var.set_custom_preset_config(name, preset_target_variable)) + if CONF_DEFAULT_PRESET in config: + default_preset_name = config[CONF_DEFAULT_PRESET] + + # if the name is a built in preset use the appropriate naming format + if default_preset_name.upper() in climate.CLIMATE_PRESETS: + climate_preset = climate.CLIMATE_PRESETS[default_preset_name.upper()] + cg.add(var.set_default_preset(climate_preset)) + else: + cg.add(var.set_default_preset(default_preset_name)) + + if CONF_ON_BOOT_RESTORE_FROM in config: + cg.add(var.set_on_boot_restore_from(config[CONF_ON_BOOT_RESTORE_FROM])) + 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 dc4e1e437e..a9b03187d3 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -25,15 +25,27 @@ void ThermostatClimate::setup() { this->publish_state(); }); this->current_temperature = this->sensor_->state; - // restore all climate data, if possible - auto restore = this->restore_state_(); - if (restore.has_value()) { - restore->to_call(this).perform(); - } else { - // restore from defaults, change_away handles temps for us - this->mode = this->default_mode_; - this->change_preset_(climate::CLIMATE_PRESET_HOME); + + auto use_default_preset = true; + + if (this->on_boot_restore_from_ == thermostat::OnBootRestoreFrom::MEMORY) { + // restore all climate data, if possible + auto restore = this->restore_state_(); + if (restore.has_value()) { + use_default_preset = false; + restore->to_call(this).perform(); + } } + + // Either we failed to restore state or the user has requested we always apply the default preset + if (use_default_preset) { + if (this->default_preset_ != climate::ClimatePreset::CLIMATE_PRESET_NONE) { + this->change_preset_(this->default_preset_); + } else if (!this->default_custom_preset_.empty()) { + this->change_custom_preset_(this->default_custom_preset_); + } + } + // refresh the climate action based on the restored settings, we'll publish_state() later this->switch_to_action_(this->compute_action_(), false); this->switch_to_supplemental_action_(this->compute_supplemental_action_()); @@ -923,10 +935,12 @@ bool ThermostatClimate::supplemental_heating_required_() { (this->supplemental_action_ == climate::CLIMATE_ACTION_HEATING)); } -void ThermostatClimate::dump_preset_config_(const std::string &preset, - const ThermostatClimateTargetTempConfig &config) { +void ThermostatClimate::dump_preset_config_(const std::string &preset, const ThermostatClimateTargetTempConfig &config, + bool is_default_preset) { const auto *preset_name = preset.c_str(); + ESP_LOGCONFIG(TAG, " %s Is Default: %s", preset_name, YESNO(is_default_preset)); + if (this->supports_heat_) { if (this->supports_two_points_) { ESP_LOGCONFIG(TAG, " %s Default Target Temperature Low: %.1f°C", preset_name, @@ -1061,7 +1075,15 @@ ThermostatClimate::ThermostatClimate() 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_default_preset(const std::string &custom_preset) { + this->default_custom_preset_ = custom_preset; +} + +void ThermostatClimate::set_default_preset(climate::ClimatePreset preset) { this->default_preset_ = preset; } + +void ThermostatClimate::set_on_boot_restore_from(thermostat::OnBootRestoreFrom on_boot_restore_from) { + this->on_boot_restore_from_ = on_boot_restore_from; +} void ThermostatClimate::set_set_point_minimum_differential(float differential) { this->set_point_minimum_differential_ = differential; } @@ -1213,8 +1235,9 @@ Trigger<> *ThermostatClimate::get_preset_change_trigger() const { return this->p void ThermostatClimate::dump_config() { LOG_CLIMATE("", "Thermostat", this); - if (this->supports_two_points_) + 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_)); if (this->supports_cool_) { ESP_LOGCONFIG(TAG, " Cooling Parameters:"); @@ -1284,7 +1307,7 @@ void ThermostatClimate::dump_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); + this->dump_preset_config_(preset_name, it.second, it.first == this->default_preset_); } ESP_LOGCONFIG(TAG, " Supported CUSTOM PRESETS: "); @@ -1292,8 +1315,10 @@ void ThermostatClimate::dump_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); + this->dump_preset_config_(preset_name, it.second, it.first == this->default_custom_preset_); } + ESP_LOGCONFIG(TAG, " On boot, restore from: %s", + this->on_boot_restore_from_ == thermostat::DEFAULT_PRESET ? "DEFAULT_PRESET" : "MEMORY"); } ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig() = default; diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index a5498dc53d..aa7529cfb1 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -22,6 +22,7 @@ enum ThermostatClimateTimerIndex : size_t { TIMER_IDLE_ON = 9, }; +enum OnBootRestoreFrom : size_t { MEMORY = 0, DEFAULT_PRESET = 1 }; struct ThermostatClimateTimer { const std::string name; bool active; @@ -57,7 +58,9 @@ class ThermostatClimate : public climate::Climate, public Component { void setup() override; void dump_config() override; - void set_default_mode(climate::ClimateMode default_mode); + void set_default_preset(const std::string &custom_preset); + void set_default_preset(climate::ClimatePreset preset); + void set_on_boot_restore_from(thermostat::OnBootRestoreFrom on_boot_restore_from); void set_set_point_minimum_differential(float differential); void set_cool_deadband(float deadband); void set_cool_overrun(float overrun); @@ -225,7 +228,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); + void dump_preset_config_(const std::string &preset_name, const ThermostatClimateTargetTempConfig &config, + bool is_default_preset); /// The sensor used for getting the current temperature sensor::Sensor *sensor_{nullptr}; @@ -397,7 +401,6 @@ class ThermostatClimate : public climate::Climate, public Component { /// These are used to determine when a trigger/action needs to be called climate::ClimateAction supplemental_action_{climate::CLIMATE_ACTION_OFF}; climate::ClimateFanMode prev_fan_mode_{climate::CLIMATE_FAN_ON}; - climate::ClimateMode default_mode_{climate::CLIMATE_MODE_OFF}; climate::ClimateMode prev_mode_{climate::CLIMATE_MODE_OFF}; climate::ClimateSwingMode prev_swing_mode_{climate::CLIMATE_SWING_OFF}; @@ -441,6 +444,15 @@ class ThermostatClimate : public climate::Climate, public Component { std::map preset_config_{}; /// The set of custom preset configurations this thermostat supports (eg. "My Custom Preset") std::map custom_preset_config_{}; + + /// Default standard preset to use on start up + climate::ClimatePreset default_preset_{}; + /// Default custom preset to use on start up + std::string default_custom_preset_{}; + + /// If set to DEFAULT_PRESET then the default preset is always used. When MEMORY prior + /// state will attempt to be restored if possible + thermostat::OnBootRestoreFrom on_boot_restore_from_{thermostat::OnBootRestoreFrom::MEMORY}; }; } // namespace thermostat diff --git a/tests/test3.yaml b/tests/test3.yaml index 4eee0fd2c9..8fc66f4918 100644 --- a/tests/test3.yaml +++ b/tests/test3.yaml @@ -1061,8 +1061,13 @@ climate: - platform: thermostat name: Thermostat Climate sensor: ha_hello_world - default_target_temperature_low: 18°C - default_target_temperature_high: 24°C + preset: + - name: Default Preset + default_target_temperature_low: 18°C + default_target_temperature_high: 24°C + - name: Away + default_target_temperature_low: 16°C + default_target_temperature_high: 20°C idle_action: - switch.turn_on: gpio_switch1 cool_action: @@ -1137,9 +1142,6 @@ climate: fan_only_cooling: true fan_with_cooling: true fan_with_heating: true - away_config: - default_target_temperature_low: 16°C - default_target_temperature_high: 20°C - platform: pid id: pid_climate name: PID Climate Controller