diff --git a/homeassistant/components/control4/climate.py b/homeassistant/components/control4/climate.py index 3626260051cf13..da7f74ca65edbf 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,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__( @@ -200,6 +199,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 +283,35 @@ 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 + 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 modes is None: + return None + # Handle both string (comma-separated) and list formats + if isinstance(modes, str): + 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.""" c4_hvac_mode = HA_TO_C4_HVAC_MODE[hvac_mode] @@ -299,3 +340,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.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..76bf76110f26e8 --- /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 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/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..ec550f3f8127f0 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,8 +42,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, }) @@ -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..4a8a1ae3580f87 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,79 @@ 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, + ) + # 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 + )