From 5ad632c34aacc619ea12ae53402720fd0770e1ef Mon Sep 17 00:00:00 2001 From: David Recordon Date: Fri, 6 Feb 2026 21:11:18 +0800 Subject: [PATCH] Add Fan mode support to Control4 integration (#159980) --- homeassistant/components/control4/climate.py | 52 ++++++++++++++-- homeassistant/components/control4/icons.json | 15 +++++ .../components/control4/strings.json | 13 ++++ tests/components/control4/conftest.py | 3 + .../control4/snapshots/test_climate.ambr | 17 +++++- tests/components/control4/test_climate.py | 61 +++++++++++++++++++ 6 files changed, 152 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/control4/icons.json diff --git a/homeassistant/components/control4/climate.py b/homeassistant/components/control4/climate.py index cc7c7e4f629..d28fceb8bbe 100644 --- a/homeassistant/components/control4/climate.py +++ b/homeassistant/components/control4/climate.py @@ -38,6 +38,8 @@ CONTROL4_CURRENT_TEMPERATURE = "TEMPERATURE_F" CONTROL4_HUMIDITY = "HUMIDITY" CONTROL4_COOL_SETPOINT = "COOL_SETPOINT_F" CONTROL4_HEAT_SETPOINT = "HEAT_SETPOINT_F" +CONTROL4_FAN_MODE = "FAN_MODE" +CONTROL4_FAN_MODES_LIST = "FAN_MODES_LIST" VARIABLES_OF_INTEREST = { CONTROL4_HVAC_STATE, @@ -46,6 +48,8 @@ VARIABLES_OF_INTEREST = { CONTROL4_HUMIDITY, CONTROL4_COOL_SETPOINT, CONTROL4_HEAT_SETPOINT, + CONTROL4_FAN_MODE, + CONTROL4_FAN_MODES_LIST, } # Map Control4 HVAC modes to Home Assistant @@ -153,12 +157,7 @@ class Control4Climate(Control4Entity, ClimateEntity): _attr_has_entity_name = True _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TURN_ON - | ClimateEntityFeature.TURN_OFF - ) + _attr_translation_key = "thermostat" _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL] def __init__( @@ -201,6 +200,19 @@ class Control4Climate(Control4Entity, ClimateEntity): """Return the thermostat data from the coordinator.""" return self.coordinator.data.get(self._idx) + @property + def supported_features(self) -> ClimateEntityFeature: + """Return the list of supported features.""" + features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + if self.fan_modes: + features |= ClimateEntityFeature.FAN_MODE + return features + @property def current_temperature(self) -> float | None: """Return the current temperature.""" @@ -275,6 +287,28 @@ class Control4Climate(Control4Entity, ClimateEntity): return data.get(CONTROL4_HEAT_SETPOINT) return None + @property + def fan_mode(self) -> str | None: + """Return the current fan mode.""" + data = self._thermostat_data + if data is None: + return None + c4_fan_mode = data.get(CONTROL4_FAN_MODE) + if c4_fan_mode is None: + return None + return c4_fan_mode.lower() + + @property + def fan_modes(self) -> list[str] | None: + """Return the list of available fan modes.""" + data = self._thermostat_data + if data is None: + return None + modes = data.get(CONTROL4_FAN_MODES_LIST) + if not modes: + return None + return [m.strip().lower() for m in modes.split(",") if m.strip()] + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target HVAC mode.""" c4_hvac_mode = HA_TO_C4_HVAC_MODE[hvac_mode] @@ -303,3 +337,9 @@ class Control4Climate(Control4Entity, ClimateEntity): await c4_climate.setHeatSetpointF(temp) await self.coordinator.async_request_refresh() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + c4_climate = self._create_api_object() + await c4_climate.setFanMode(fan_mode.title()) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/control4/icons.json b/homeassistant/components/control4/icons.json new file mode 100644 index 00000000000..76bf76110f2 --- /dev/null +++ b/homeassistant/components/control4/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "climate": { + "thermostat": { + "state_attributes": { + "fan_mode": { + "state": { + "circulate": "mdi:fan-clock" + } + } + } + } + } + } +} diff --git a/homeassistant/components/control4/strings.json b/homeassistant/components/control4/strings.json index 9b5981c2e3c..e1b12f45dbe 100644 --- a/homeassistant/components/control4/strings.json +++ b/homeassistant/components/control4/strings.json @@ -21,6 +21,19 @@ } } }, + "entity": { + "climate": { + "thermostat": { + "state_attributes": { + "fan_mode": { + "state": { + "circulate": "Circulate" + } + } + } + } + } + }, "options": { "step": { "init": { diff --git a/tests/components/control4/conftest.py b/tests/components/control4/conftest.py index 9cfddcdafc8..671588c0d94 100644 --- a/tests/components/control4/conftest.py +++ b/tests/components/control4/conftest.py @@ -127,6 +127,8 @@ def mock_climate_variables() -> dict: "HUMIDITY": 45, "COOL_SETPOINT_F": 75.0, "HEAT_SETPOINT_F": 68.0, + "FAN_MODE": "Auto", + "FAN_MODES_LIST": "Auto,On,Circulate", } } @@ -157,6 +159,7 @@ def mock_c4_climate() -> Generator[MagicMock]: mock_instance.setHvacMode = AsyncMock() mock_instance.setHeatSetpointF = AsyncMock() mock_instance.setCoolSetpointF = AsyncMock() + mock_instance.setFanMode = AsyncMock() yield mock_instance diff --git a/tests/components/control4/snapshots/test_climate.ambr b/tests/components/control4/snapshots/test_climate.ambr index a01dd5dc851..9859e57881d 100644 --- a/tests/components/control4/snapshots/test_climate.ambr +++ b/tests/components/control4/snapshots/test_climate.ambr @@ -5,6 +5,11 @@ }), 'area_id': None, 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'on', + 'circulate', + ]), 'hvac_modes': list([ , , @@ -38,8 +43,8 @@ 'platform': 'control4', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, + 'supported_features': , + 'translation_key': 'thermostat', 'unique_id': '123', 'unit_of_measurement': None, }) @@ -49,6 +54,12 @@ 'attributes': ReadOnlyDict({ 'current_humidity': 45, 'current_temperature': 72, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'on', + 'circulate', + ]), 'friendly_name': 'Test Controller Residential Thermostat V2', 'hvac_action': , 'hvac_modes': list([ @@ -59,7 +70,7 @@ ]), 'max_temp': 95, 'min_temp': 45, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'temperature': 68, diff --git a/tests/components/control4/test_climate.py b/tests/components/control4/test_climate.py index 930131aaba7..97d432d55b3 100644 --- a/tests/components/control4/test_climate.py +++ b/tests/components/control4/test_climate.py @@ -7,13 +7,16 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( + ATTR_FAN_MODE, ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, ATTR_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, SERVICE_SET_TEMPERATURE, + ClimateEntityFeature, HVACAction, HVACMode, ) @@ -406,3 +409,61 @@ async def test_climate_unknown_hvac_state( state = hass.states.get(ENTITY_ID) assert state is not None assert state.attributes.get("hvac_action") is None + + +@pytest.mark.usefixtures( + "mock_c4_account", + "mock_c4_director", + "mock_climate_update_variables", + "init_integration", +) +async def test_set_fan_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_c4_climate: MagicMock, +) -> None: + """Test setting fan mode.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "on"}, + blocking=True, + ) + # Verify the Control4 API is called with the C4 format ("On" not "on") + mock_c4_climate.setFanMode.assert_called_once_with("On") + + +@pytest.mark.parametrize( + "mock_climate_variables", + [ + { + 123: { + "HVAC_STATE": "idle", + "HVAC_MODE": "Heat", + "TEMPERATURE_F": 72.0, + "HUMIDITY": 50, + "COOL_SETPOINT_F": 75.0, + "HEAT_SETPOINT_F": 68.0, + # No FAN_MODE or FAN_MODES_LIST + } + } + ], +) +@pytest.mark.usefixtures( + "mock_c4_account", + "mock_c4_director", + "mock_climate_update_variables", + "init_integration", +) +async def test_fan_mode_not_supported( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test fan mode feature not set when device doesn't support it.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get("fan_mode") is None + assert state.attributes.get("fan_modes") is None + assert not ( + state.attributes.get("supported_features") & ClimateEntityFeature.FAN_MODE + )