diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 0f01443bd0..6ca9e394f7 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -551,7 +551,14 @@ ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) { void ClimateDeviceRestoreState::apply(Climate *climate) { auto traits = climate->get_traits(); - climate->mode = this->mode; + // A saved mode the device no longer offers cannot be selected again, so skip it and leave the + // entity on the mode it already has. The other saved fields are still restored. + if (traits.supports_mode(this->mode)) { + climate->mode = this->mode; + } else { + ESP_LOGW(TAG, "'%s' - Saved mode %s is no longer supported, keeping %s", climate->get_name().c_str(), + LOG_STR_ARG(climate_mode_to_string(this->mode)), LOG_STR_ARG(climate_mode_to_string(climate->mode))); + } if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { climate->target_temperature_low = this->target_temperature_low; diff --git a/tests/components/climate/climate_test.cpp b/tests/components/climate/climate_test.cpp new file mode 100644 index 0000000000..bda014b87a --- /dev/null +++ b/tests/components/climate/climate_test.cpp @@ -0,0 +1,73 @@ +#include +#include "esphome/components/climate/climate.h" + +namespace esphome::climate::testing { + +// Minimal concrete Climate that offers a fixed set of modes, so the restore path can be exercised +// without any hardware or platform component. +class TestClimate : public Climate { + public: + ClimateTraits traits() override { + auto traits = ClimateTraits(); + traits.set_supported_modes({CLIMATE_MODE_OFF, CLIMATE_MODE_COOL}); + traits.set_supported_fan_modes({CLIMATE_FAN_LOW, CLIMATE_FAN_HIGH}); + return traits; + } + + protected: + void control(const ClimateCall &call) override {} +}; + +TEST(ClimateRestoreStateTest, RestoresASupportedMode) { + TestClimate climate; + // Value-initialized: several members (mode, swing_mode, the temperature union) have no default + // member initializer, so leaving the {} off would read indeterminate values. + ClimateDeviceRestoreState state{}; + state.mode = CLIMATE_MODE_COOL; + + state.apply(&climate); + + EXPECT_EQ(climate.mode, CLIMATE_MODE_COOL); +} + +TEST(ClimateRestoreStateTest, DoesNotRestoreAnUnsupportedMode) { + TestClimate climate; + ClimateDeviceRestoreState state{}; + state.mode = CLIMATE_MODE_HEAT; + + state.apply(&climate); + + // The device never advertised HEAT, so the mode stays where it was. + EXPECT_EQ(climate.mode, CLIMATE_MODE_OFF); +} + +TEST(ClimateRestoreStateTest, LeavesTheCurrentModeAloneRatherThanForcingOff) { + TestClimate climate; + // apply() is public and nothing restricts it to setup(), so the entity is not necessarily off + // when an unsupported mode is dropped. It keeps what it had rather than being forced to OFF. + climate.mode = CLIMATE_MODE_COOL; + ClimateDeviceRestoreState state{}; + state.mode = CLIMATE_MODE_HEAT; + + state.apply(&climate); + + EXPECT_EQ(climate.mode, CLIMATE_MODE_COOL); +} + +TEST(ClimateRestoreStateTest, KeepsRestoringTheOtherFieldsWhenTheModeIsDropped) { + TestClimate climate; + ClimateDeviceRestoreState state{}; + state.mode = CLIMATE_MODE_HEAT; + state.target_temperature = 21.0f; + state.uses_custom_fan_mode = false; + state.fan_mode = CLIMATE_FAN_HIGH; + + state.apply(&climate); + + EXPECT_EQ(climate.mode, CLIMATE_MODE_OFF); + EXPECT_FLOAT_EQ(climate.target_temperature, 21.0f); + // Compared as an optional: this asserts both that the fan mode was restored and what it holds. + EXPECT_EQ(climate.fan_mode, CLIMATE_FAN_HIGH); +} + +} // namespace esphome::climate::testing