diff --git a/esphome/components/climate_ir/__init__.py b/esphome/components/climate_ir/__init__.py index 0667bd91a2..f5a31b31c4 100644 --- a/esphome/components/climate_ir/__init__.py +++ b/esphome/components/climate_ir/__init__.py @@ -14,6 +14,8 @@ from esphome.types import ConfigType, SafeExpType _LOGGER = logging.getLogger(__name__) +CONF_SUPPORTS_HEAT_COOL = "supports_heat_cool" + DEPENDENCIES = ["remote_transmitter"] AUTO_LOAD = ["sensor", "remote_base"] CODEOWNERS = ["@glmnet"] @@ -37,6 +39,7 @@ def climate_ir_schema( { cv.Optional(CONF_SUPPORTS_COOL, default=True): cv.boolean, cv.Optional(CONF_SUPPORTS_HEAT, default=True): cv.boolean, + cv.Optional(CONF_SUPPORTS_HEAT_COOL): cv.boolean, cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor), cv.Optional(CONF_HUMIDITY_SENSOR): cv.use_id(sensor.Sensor), } @@ -61,8 +64,13 @@ def climate_ir_with_receiver_schema( async def register_climate_ir(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) await remote_base.register_transmittable(var, config) - cg.add(var.set_supports_cool(config[CONF_SUPPORTS_COOL])) - cg.add(var.set_supports_heat(config[CONF_SUPPORTS_HEAT])) + supports_cool = config[CONF_SUPPORTS_COOL] + supports_heat = config[CONF_SUPPORTS_HEAT] + cg.add(var.set_supports_cool(supports_cool)) + cg.add(var.set_supports_heat(supports_heat)) + # The header default is true, so only the false case needs a call. + if not config.get(CONF_SUPPORTS_HEAT_COOL, supports_cool and supports_heat): + cg.add(var.set_supports_heat_cool(False)) if remote_base.CONF_RECEIVER_ID in config: await remote_base.register_listener(var, config) if sensor_id := config.get(CONF_SENSOR): diff --git a/esphome/components/climate_ir/climate_ir.cpp b/esphome/components/climate_ir/climate_ir.cpp index a8edaae6ea..ca639c7de0 100644 --- a/esphome/components/climate_ir/climate_ir.cpp +++ b/esphome/components/climate_ir/climate_ir.cpp @@ -13,11 +13,13 @@ climate::ClimateTraits ClimateIR::traits() { if (this->humidity_sensor_ != nullptr) { traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY); } - traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT_COOL}); + traits.set_supported_modes({climate::CLIMATE_MODE_OFF}); if (this->supports_cool_) traits.add_supported_mode(climate::CLIMATE_MODE_COOL); if (this->supports_heat_) traits.add_supported_mode(climate::CLIMATE_MODE_HEAT); + if (this->supports_heat_cool_) + traits.add_supported_mode(climate::CLIMATE_MODE_HEAT_COOL); if (this->supports_dry_) traits.add_supported_mode(climate::CLIMATE_MODE_DRY); if (this->supports_fan_only_) @@ -94,9 +96,10 @@ void ClimateIR::dump_config() { " Min. Temperature: %.1f°C\n" " Max. Temperature: %.1f°C\n" " Supports HEAT: %s\n" - " Supports COOL: %s", + " Supports COOL: %s\n" + " Supports HEAT_COOL: %s", this->minimum_temperature_, this->maximum_temperature_, YESNO(this->supports_heat_), - YESNO(this->supports_cool_)); + YESNO(this->supports_cool_), YESNO(this->supports_heat_cool_)); } } // namespace esphome::climate_ir diff --git a/esphome/components/climate_ir/climate_ir.h b/esphome/components/climate_ir/climate_ir.h index 6c49b31030..d65ed5d621 100644 --- a/esphome/components/climate_ir/climate_ir.h +++ b/esphome/components/climate_ir/climate_ir.h @@ -41,6 +41,7 @@ class ClimateIR : public Component, void dump_config() override; void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } + void set_supports_heat_cool(bool supports_heat_cool) { this->supports_heat_cool_ = supports_heat_cool; } void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } void set_humidity_sensor(sensor::Sensor *sensor) { this->humidity_sensor_ = sensor; } @@ -60,6 +61,8 @@ class ClimateIR : public Component, bool supports_cool_{true}; bool supports_heat_{true}; + // Default (supports_cool && supports_heat) is resolved during code generation. + bool supports_heat_cool_{true}; bool supports_dry_{false}; bool supports_fan_only_{false}; climate::ClimateFanModeMask fan_modes_{}; diff --git a/tests/component_tests/climate_ir/__init__.py b/tests/component_tests/climate_ir/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/climate_ir/config/cool_only.yaml b/tests/component_tests/climate_ir/config/cool_only.yaml new file mode 100644 index 0000000000..e40efc811a --- /dev/null +++ b/tests/component_tests/climate_ir/config/cool_only.yaml @@ -0,0 +1,15 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_heat: false diff --git a/tests/component_tests/climate_ir/config/cool_only_override_on.yaml b/tests/component_tests/climate_ir/config/cool_only_override_on.yaml new file mode 100644 index 0000000000..82c2d7c881 --- /dev/null +++ b/tests/component_tests/climate_ir/config/cool_only_override_on.yaml @@ -0,0 +1,16 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_heat: false + supports_heat_cool: true diff --git a/tests/component_tests/climate_ir/config/heat_and_cool.yaml b/tests/component_tests/climate_ir/config/heat_and_cool.yaml new file mode 100644 index 0000000000..d92c2ade39 --- /dev/null +++ b/tests/component_tests/climate_ir/config/heat_and_cool.yaml @@ -0,0 +1,14 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix diff --git a/tests/component_tests/climate_ir/config/heat_and_cool_override_off.yaml b/tests/component_tests/climate_ir/config/heat_and_cool_override_off.yaml new file mode 100644 index 0000000000..6a72a5981e --- /dev/null +++ b/tests/component_tests/climate_ir/config/heat_and_cool_override_off.yaml @@ -0,0 +1,15 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_heat_cool: false diff --git a/tests/component_tests/climate_ir/config/heat_only.yaml b/tests/component_tests/climate_ir/config/heat_only.yaml new file mode 100644 index 0000000000..c87a3820b7 --- /dev/null +++ b/tests/component_tests/climate_ir/config/heat_only.yaml @@ -0,0 +1,15 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_cool: false diff --git a/tests/component_tests/climate_ir/config/neither.yaml b/tests/component_tests/climate_ir/config/neither.yaml new file mode 100644 index 0000000000..78830a0a9b --- /dev/null +++ b/tests/component_tests/climate_ir/config/neither.yaml @@ -0,0 +1,16 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_heat: false + supports_cool: false diff --git a/tests/component_tests/climate_ir/test_supports_heat_cool.py b/tests/component_tests/climate_ir/test_supports_heat_cool.py new file mode 100644 index 0000000000..7ff9381cd6 --- /dev/null +++ b/tests/component_tests/climate_ir/test_supports_heat_cool.py @@ -0,0 +1,53 @@ +"""Tests for the supports_heat_cool default resolved in climate_ir code generation.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + + +def _emitted_value(main_cpp: str) -> str | None: + """Return the argument of the generated set_supports_heat_cool() call, or None if absent.""" + match = re.search(r"set_supports_heat_cool\((true|false)\)", main_cpp) + return match.group(1) if match else None + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + ("heat_and_cool.yaml", None), + ("cool_only.yaml", "false"), + ("heat_only.yaml", "false"), + ("neither.yaml", "false"), + ], +) +def test_default_requires_heat_and_cool( + config: str, + expected: str | None, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without the key, HEAT_COOL follows supports_heat and supports_cool.""" + main_cpp = generate_main(component_config_path(config)) + assert _emitted_value(main_cpp) == expected + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + ("cool_only_override_on.yaml", None), + ("heat_and_cool_override_off.yaml", "false"), + ], +) +def test_explicit_key_overrides_default( + config: str, + expected: str | None, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A cool-only unit can still offer HEAT_COOL, and a heat+cool unit can drop it.""" + main_cpp = generate_main(component_config_path(config)) + assert _emitted_value(main_cpp) == expected diff --git a/tests/components/climate_ir/__init__.py b/tests/components/climate_ir/__init__.py new file mode 100644 index 0000000000..ae68ae228a --- /dev/null +++ b/tests/components/climate_ir/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # ClimateIR derives from climate::Climate without declaring it as a dependency. + manifest.dependencies = manifest.dependencies + ["climate"] diff --git a/tests/components/climate_ir/climate_ir_test.cpp b/tests/components/climate_ir/climate_ir_test.cpp new file mode 100644 index 0000000000..479e1f1b45 --- /dev/null +++ b/tests/components/climate_ir/climate_ir_test.cpp @@ -0,0 +1,56 @@ +#include +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::climate_ir::testing { + +class TestClimateIR : public ClimateIR { + public: + TestClimateIR() : ClimateIR(16.0f, 30.0f) {} + + using ClimateIR::traits; + + protected: + void transmit_state() override {} +}; + +// The HEAT_COOL default is covered in tests/component_tests/climate_ir. + +TEST(ClimateIRTest, HeatCoolAdvertisedWhenSupported) { + TestClimateIR climate; + climate.set_supports_heat(true); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(true); + EXPECT_TRUE(climate.traits().supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); +} + +TEST(ClimateIRTest, HeatCoolNotAdvertisedWhenUnsupported) { + TestClimateIR climate; + climate.set_supports_heat(true); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(false); + EXPECT_FALSE(climate.traits().supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); +} + +TEST(ClimateIRTest, HeatCoolAdvertisedForCoolOnlyDeviceThatSupportsIt) { + TestClimateIR climate; + climate.set_supports_heat(false); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(true); + auto traits = climate.traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); +} + +TEST(ClimateIRTest, HeatAndCoolModesFollowTheirOwnFlags) { + TestClimateIR climate; + climate.set_supports_heat(false); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(false); + auto traits = climate.traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_COOL)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_OFF)); +} + +} // namespace esphome::climate_ir::testing diff --git a/tests/components/gree/validate-cool-only-heat-cool.esp32-idf.yaml b/tests/components/gree/validate-cool-only-heat-cool.esp32-idf.yaml new file mode 100644 index 0000000000..9b17da7b9b --- /dev/null +++ b/tests/components/gree/validate-cool-only-heat-cool.esp32-idf.yaml @@ -0,0 +1,10 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + +climate: + - platform: gree + name: GREE + transmitter_id: xmitr + model: YAN + supports_heat: false + supports_heat_cool: true diff --git a/tests/components/gree/validate-no-cool.esp32-idf.yaml b/tests/components/gree/validate-no-cool.esp32-idf.yaml new file mode 100644 index 0000000000..3862f302de --- /dev/null +++ b/tests/components/gree/validate-no-cool.esp32-idf.yaml @@ -0,0 +1,9 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + +climate: + - platform: gree + name: GREE + transmitter_id: xmitr + model: YAN + supports_cool: false diff --git a/tests/components/gree/validate-no-heat-cool.esp32-idf.yaml b/tests/components/gree/validate-no-heat-cool.esp32-idf.yaml new file mode 100644 index 0000000000..eea670a696 --- /dev/null +++ b/tests/components/gree/validate-no-heat-cool.esp32-idf.yaml @@ -0,0 +1,9 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + +climate: + - platform: gree + name: GREE + transmitter_id: xmitr + model: YAN + supports_heat_cool: false diff --git a/tests/components/gree/validate-no-heat.esp32-idf.yaml b/tests/components/gree/validate-no-heat.esp32-idf.yaml new file mode 100644 index 0000000000..3c042ebe69 --- /dev/null +++ b/tests/components/gree/validate-no-heat.esp32-idf.yaml @@ -0,0 +1,9 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + +climate: + - platform: gree + name: GREE + transmitter_id: xmitr + model: YAN + supports_heat: false