From fa5b84d939131ef62328affceb5335efb6b38201 Mon Sep 17 00:00:00 2001 From: David Recordon Date: Tue, 30 Dec 2025 02:14:28 +0000 Subject: [PATCH 1/3] Add HVAC mode support to Control4 integration --- homeassistant/components/control4/climate.py | 51 ++++++++++-- tests/components/control4/conftest.py | 3 + .../control4/snapshots/test_climate.ambr | 15 +++- tests/components/control4/test_climate.py | 78 +++++++++++++++++++ 4 files changed, 139 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/control4/climate.py b/homeassistant/components/control4/climate.py index 3626260051cf13..8071bd996b7ae9 100644 --- a/homeassistant/components/control4/climate.py +++ b/homeassistant/components/control4/climate.py @@ -38,6 +38,8 @@ 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 @@ CONTROL4_HUMIDITY, CONTROL4_COOL_SETPOINT, CONTROL4_HEAT_SETPOINT, + CONTROL4_FAN_MODE, + CONTROL4_FAN_MODES_LIST, } # Map Control4 HVAC modes to Home Assistant @@ -152,12 +156,6 @@ 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_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL] def __init__( @@ -200,6 +198,19 @@ def _thermostat_data(self) -> dict[str, Any] | None: """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.""" @@ -271,6 +282,28 @@ def target_temperature_low(self) -> float | None: 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 + return data.get(CONTROL4_FAN_MODE) + + @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 modes is None: + return None + # Handle both string (comma-separated) and list formats + if isinstance(modes, str): + return [m.strip() for m in modes.split(",") if m.strip()] + return list(modes) if modes else None + 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] @@ -299,3 +332,9 @@ async def async_set_temperature(self, **kwargs: Any) -> None: 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) + await self.coordinator.async_request_refresh() diff --git a/tests/components/control4/conftest.py b/tests/components/control4/conftest.py index c6b54fb8373ce5..cb383a4274aad7 100644 --- a/tests/components/control4/conftest.py +++ b/tests/components/control4/conftest.py @@ -102,6 +102,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", } } @@ -132,6 +134,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 d961babf59f5ba..27e68a62d0f750 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([ , , @@ -37,7 +42,7 @@ 'platform': 'control4', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '123', 'unit_of_measurement': None, @@ -48,6 +53,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([ @@ -58,7 +69,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 cba5c235adc624..064fb55e06cd2a 100644 --- a/tests/components/control4/test_climate.py +++ b/tests/components/control4/test_climate.py @@ -6,13 +6,16 @@ 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, ) @@ -388,3 +391,78 @@ 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_fan_mode_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test fan mode properties.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get("fan_mode") == "Auto" + assert state.attributes.get("fan_modes") == ["Auto", "On", "Circulate"] + assert state.attributes.get("supported_features") & ClimateEntityFeature.FAN_MODE + + +@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, + ) + 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 + ) From 7f739f0f91a402c7fd2da662519b7440f3a9e632 Mon Sep 17 00:00:00 2001 From: David Recordon Date: Sun, 4 Jan 2026 23:28:58 +0000 Subject: [PATCH 2/3] Localize fan modes --- homeassistant/components/control4/climate.py | 16 ++++++++++++---- homeassistant/components/control4/icons.json | 17 +++++++++++++++++ homeassistant/components/control4/strings.json | 13 +++++++++++++ .../control4/snapshots/test_climate.ambr | 16 ++++++++-------- tests/components/control4/test_climate.py | 7 ++++--- 5 files changed, 54 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/control4/icons.json diff --git a/homeassistant/components/control4/climate.py b/homeassistant/components/control4/climate.py index 8071bd996b7ae9..da7f74ca65edbf 100644 --- a/homeassistant/components/control4/climate.py +++ b/homeassistant/components/control4/climate.py @@ -156,6 +156,7 @@ class Control4Climate(Control4Entity, ClimateEntity): _attr_has_entity_name = True _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _attr_translation_key = "thermostat" _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL] def __init__( @@ -288,7 +289,10 @@ def fan_mode(self) -> str | None: data = self._thermostat_data if data is None: return None - return data.get(CONTROL4_FAN_MODE) + 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: @@ -301,8 +305,12 @@ def fan_modes(self) -> list[str] | None: return None # Handle both string (comma-separated) and list formats if isinstance(modes, str): - return [m.strip() for m in modes.split(",") if m.strip()] - return list(modes) if modes else None + c4_modes = [m.strip() for m in modes.split(",") if m.strip()] + else: + c4_modes = list(modes) + if not c4_modes: + return None + return [m.lower() for m in c4_modes] async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target HVAC mode.""" @@ -336,5 +344,5 @@ async def async_set_temperature(self, **kwargs: Any) -> None: 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) + 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 00000000000000..25736f0dad1cef --- /dev/null +++ b/homeassistant/components/control4/icons.json @@ -0,0 +1,17 @@ +{ + "entity": { + "climate": { + "thermostat": { + "state_attributes": { + "fan_mode": { + "state": { + "auto": "mdi:fan-auto", + "circulate": "mdi:fan-clock", + "on": "mdi:fan" + } + } + } + } + } + } +} diff --git a/homeassistant/components/control4/strings.json b/homeassistant/components/control4/strings.json index 34331bc18fa6ec..6680350fdae8dc 100644 --- a/homeassistant/components/control4/strings.json +++ b/homeassistant/components/control4/strings.json @@ -19,6 +19,19 @@ } } }, + "entity": { + "climate": { + "thermostat": { + "state_attributes": { + "fan_mode": { + "state": { + "circulate": "Circulate" + } + } + } + } + } + }, "options": { "step": { "init": { diff --git a/tests/components/control4/snapshots/test_climate.ambr b/tests/components/control4/snapshots/test_climate.ambr index 27e68a62d0f750..ec550f3f8127f0 100644 --- a/tests/components/control4/snapshots/test_climate.ambr +++ b/tests/components/control4/snapshots/test_climate.ambr @@ -6,9 +6,9 @@ 'area_id': None, 'capabilities': dict({ 'fan_modes': list([ - 'Auto', - 'On', - 'Circulate', + 'auto', + 'on', + 'circulate', ]), 'hvac_modes': list([ , @@ -43,7 +43,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'thermostat', 'unique_id': '123', 'unit_of_measurement': None, }) @@ -53,11 +53,11 @@ 'attributes': ReadOnlyDict({ 'current_humidity': 45, 'current_temperature': 72, - 'fan_mode': 'Auto', + 'fan_mode': 'auto', 'fan_modes': list([ - 'Auto', - 'On', - 'Circulate', + 'auto', + 'on', + 'circulate', ]), 'friendly_name': 'Test Controller Residential Thermostat V2', 'hvac_action': , diff --git a/tests/components/control4/test_climate.py b/tests/components/control4/test_climate.py index 064fb55e06cd2a..4a8a1ae3580f87 100644 --- a/tests/components/control4/test_climate.py +++ b/tests/components/control4/test_climate.py @@ -406,8 +406,8 @@ async def test_fan_mode_states( """Test fan mode properties.""" state = hass.states.get(ENTITY_ID) assert state is not None - assert state.attributes.get("fan_mode") == "Auto" - assert state.attributes.get("fan_modes") == ["Auto", "On", "Circulate"] + assert state.attributes.get("fan_mode") == "auto" + assert state.attributes.get("fan_modes") == ["auto", "on", "circulate"] assert state.attributes.get("supported_features") & ClimateEntityFeature.FAN_MODE @@ -426,9 +426,10 @@ async def test_set_fan_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "On"}, + {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") From 8db686b09fbfd6eef33b9971f41b6defafb78a0f Mon Sep 17 00:00:00 2001 From: David Recordon Date: Sun, 4 Jan 2026 23:32:28 +0000 Subject: [PATCH 3/3] Don't respecify auto/on icons --- homeassistant/components/control4/icons.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/control4/icons.json b/homeassistant/components/control4/icons.json index 25736f0dad1cef..76bf76110f26e8 100644 --- a/homeassistant/components/control4/icons.json +++ b/homeassistant/components/control4/icons.json @@ -5,9 +5,7 @@ "state_attributes": { "fan_mode": { "state": { - "auto": "mdi:fan-auto", - "circulate": "mdi:fan-clock", - "on": "mdi:fan" + "circulate": "mdi:fan-clock" } } }