Add Fan mode support to Control4 integration (#159980)

This commit is contained in:
David Recordon
2026-02-06 21:11:18 +08:00
committed by GitHub
parent 65f95e5c4b
commit 5ad632c34a
6 changed files with 152 additions and 9 deletions

View File

@@ -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()

View File

@@ -0,0 +1,15 @@
{
"entity": {
"climate": {
"thermostat": {
"state_attributes": {
"fan_mode": {
"state": {
"circulate": "mdi:fan-clock"
}
}
}
}
}
}
}

View File

@@ -21,6 +21,19 @@
}
}
},
"entity": {
"climate": {
"thermostat": {
"state_attributes": {
"fan_mode": {
"state": {
"circulate": "Circulate"
}
}
}
}
}
},
"options": {
"step": {
"init": {

View File

@@ -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

View File

@@ -5,6 +5,11 @@
}),
'area_id': None,
'capabilities': dict({
'fan_modes': list([
'auto',
'on',
'circulate',
]),
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
@@ -38,8 +43,8 @@
'platform': 'control4',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 387>,
'translation_key': None,
'supported_features': <ClimateEntityFeature: 395>,
'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': <HVACAction.OFF: 'off'>,
'hvac_modes': list([
@@ -59,7 +70,7 @@
]),
'max_temp': 95,
'min_temp': 45,
'supported_features': <ClimateEntityFeature: 387>,
'supported_features': <ClimateEntityFeature: 395>,
'target_temp_high': None,
'target_temp_low': None,
'temperature': 68,

View File

@@ -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
)