Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 45 additions & 6 deletions homeassistant/components/control4/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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__(
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Comment on lines +285 to +305
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay so how does a control4 system get its fan modes? Are there defaults for that? Can people pick custom ones?

Because ideally if we have fan modes, we make them snake_case and add their translation to strings.json (and icon to icons.json). This way we can show the fan modes localized, but that is only depending on how consistent the source data is.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a good question and one I'm not an expert on...what I've found both in my own system's API responses and looking at pyControl4 is that they default to Auto, Circulate and On but it is configurable within Control4's dealer software and pyControl4 conveys that you may not always get these three default modes (what I don't know is if the three is the superset and you might get fewer or if you can add others too). I'm surprised that like Off isn't an option, but again that could just be from how my system is configured - which is also ironic as I don't have fans which is why I didn't implement this in the first pass.

Makes sense on localization, but I'd split that into a separate PR and just go do the whole module versus only this one label since I don't think any of it is currently 🤷‍♂️


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]
Expand Down Expand Up @@ -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()
3 changes: 3 additions & 0 deletions tests/components/control4/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
}

Expand Down Expand Up @@ -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


Expand Down
15 changes: 13 additions & 2 deletions tests/components/control4/snapshots/test_climate.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
}),
'area_id': None,
'capabilities': dict({
'fan_modes': list([
'Auto',
'On',
'Circulate',
]),
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
Expand Down Expand Up @@ -37,7 +42,7 @@
'platform': 'control4',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 387>,
'supported_features': <ClimateEntityFeature: 395>,
'translation_key': None,
'unique_id': '123',
'unit_of_measurement': None,
Expand All @@ -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': <HVACAction.IDLE: 'idle'>,
'hvac_modes': list([
Expand All @@ -58,7 +69,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,
Expand Down
78 changes: 78 additions & 0 deletions tests/components/control4/test_climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
)
Loading