Fix evohome HVAC modes for VisionPro Wifi systems (#129161)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
David Bonnes 2024-10-29 12:37:35 +00:00 committed by GitHub
parent db4278fb9d
commit a36b350954
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 291 additions and 26 deletions

View File

@ -66,8 +66,6 @@ _LOGGER = logging.getLogger(__name__)
PRESET_RESET = "Reset" # reset all child zones to EVO_FOLLOW PRESET_RESET = "Reset" # reset all child zones to EVO_FOLLOW
PRESET_CUSTOM = "Custom" PRESET_CUSTOM = "Custom"
HA_HVAC_TO_TCS = {HVACMode.OFF: EVO_HEATOFF, HVACMode.HEAT: EVO_AUTO}
TCS_PRESET_TO_HA = { TCS_PRESET_TO_HA = {
EVO_AWAY: PRESET_AWAY, EVO_AWAY: PRESET_AWAY,
EVO_CUSTOM: PRESET_CUSTOM, EVO_CUSTOM: PRESET_CUSTOM,
@ -150,14 +148,10 @@ async def async_setup_platform(
class EvoClimateEntity(EvoDevice, ClimateEntity): class EvoClimateEntity(EvoDevice, ClimateEntity):
"""Base for any evohome-compatible climate entity (controller, zone).""" """Base for any evohome-compatible climate entity (controller, zone)."""
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
_enable_turn_on_off_backwards_compatibility = False _enable_turn_on_off_backwards_compatibility = False
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return a list of available hvac operation modes."""
return list(HA_HVAC_TO_TCS)
class EvoZone(EvoChild, EvoClimateEntity): class EvoZone(EvoChild, EvoClimateEntity):
"""Base for any evohome-compatible heating zone.""" """Base for any evohome-compatible heating zone."""
@ -365,9 +359,9 @@ class EvoController(EvoClimateEntity):
self._attr_unique_id = evo_device.systemId self._attr_unique_id = evo_device.systemId
self._attr_name = evo_device.location.name self._attr_name = evo_device.location.name
modes = [m[SZ_SYSTEM_MODE] for m in evo_broker.tcs.allowedSystemModes] self._evo_modes = [m[SZ_SYSTEM_MODE] for m in evo_device.allowedSystemModes]
self._attr_preset_modes = [ self._attr_preset_modes = [
TCS_PRESET_TO_HA[m] for m in modes if m in list(TCS_PRESET_TO_HA) TCS_PRESET_TO_HA[m] for m in self._evo_modes if m in list(TCS_PRESET_TO_HA)
] ]
if self._attr_preset_modes: if self._attr_preset_modes:
self._attr_supported_features = ClimateEntityFeature.PRESET_MODE self._attr_supported_features = ClimateEntityFeature.PRESET_MODE
@ -401,14 +395,14 @@ class EvoController(EvoClimateEntity):
"""Set a Controller to any of its native EVO_* operating modes.""" """Set a Controller to any of its native EVO_* operating modes."""
until = dt_util.as_utc(until) if until else None until = dt_util.as_utc(until) if until else None
await self._evo_broker.call_client_api( await self._evo_broker.call_client_api(
self._evo_tcs.set_mode(mode, until=until) # type: ignore[arg-type] self._evo_device.set_mode(mode, until=until) # type: ignore[arg-type]
) )
@property @property
def hvac_mode(self) -> HVACMode: def hvac_mode(self) -> HVACMode:
"""Return the current operating mode of a Controller.""" """Return the current operating mode of a Controller."""
tcs_mode = self._evo_tcs.system_mode evo_mode = self._evo_device.system_mode
return HVACMode.OFF if tcs_mode == EVO_HEATOFF else HVACMode.HEAT return HVACMode.OFF if evo_mode in (EVO_HEATOFF, "Off") else HVACMode.HEAT
@property @property
def current_temperature(self) -> float | None: def current_temperature(self) -> float | None:
@ -418,7 +412,7 @@ class EvoController(EvoClimateEntity):
""" """
temps = [ temps = [
z.temperature z.temperature
for z in self._evo_tcs.zones.values() for z in self._evo_device.zones.values()
if z.temperature is not None if z.temperature is not None
] ]
return round(sum(temps) / len(temps), 1) if temps else None return round(sum(temps) / len(temps), 1) if temps else None
@ -426,9 +420,9 @@ class EvoController(EvoClimateEntity):
@property @property
def preset_mode(self) -> str | None: def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp.""" """Return the current preset mode, e.g., home, away, temp."""
if not self._evo_tcs.system_mode: if not self._evo_device.system_mode:
return None return None
return TCS_PRESET_TO_HA.get(self._evo_tcs.system_mode) return TCS_PRESET_TO_HA.get(self._evo_device.system_mode)
async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_temperature(self, **kwargs: Any) -> None:
"""Raise exception as Controllers don't have a target temperature.""" """Raise exception as Controllers don't have a target temperature."""
@ -436,9 +430,13 @@ class EvoController(EvoClimateEntity):
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set an operating mode for a Controller.""" """Set an operating mode for a Controller."""
if not (tcs_mode := HA_HVAC_TO_TCS.get(hvac_mode)): if hvac_mode == HVACMode.HEAT:
evo_mode = EVO_AUTO if EVO_AUTO in self._evo_modes else "Heat"
elif hvac_mode == HVACMode.OFF:
evo_mode = EVO_HEATOFF if EVO_HEATOFF in self._evo_modes else "Off"
else:
raise HomeAssistantError(f"Invalid hvac_mode: {hvac_mode}") raise HomeAssistantError(f"Invalid hvac_mode: {hvac_mode}")
await self._set_tcs_mode(tcs_mode) await self._set_tcs_mode(evo_mode)
async def async_set_preset_mode(self, preset_mode: str) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode; if None, then revert to 'Auto' mode.""" """Set the preset mode; if None, then revert to 'Auto' mode."""
@ -451,6 +449,6 @@ class EvoController(EvoClimateEntity):
attrs = self._device_state_attrs attrs = self._device_state_attrs
for attr in STATE_ATTRS_TCS: for attr in STATE_ATTRS_TCS:
if attr == SZ_ACTIVE_FAULTS: if attr == SZ_ACTIVE_FAULTS:
attrs["activeSystemFaults"] = getattr(self._evo_tcs, attr) attrs["activeSystemFaults"] = getattr(self._evo_device, attr)
else: else:
attrs[attr] = getattr(self._evo_tcs, attr) attrs[attr] = getattr(self._evo_device, attr)

View File

@ -42,7 +42,6 @@ class EvoDevice(Entity):
"""Initialize an evohome-compatible entity (TCS, DHW, zone).""" """Initialize an evohome-compatible entity (TCS, DHW, zone)."""
self._evo_device = evo_device self._evo_device = evo_device
self._evo_broker = evo_broker self._evo_broker = evo_broker
self._evo_tcs = evo_broker.tcs
self._device_state_attrs: dict[str, Any] = {} self._device_state_attrs: dict[str, Any] = {}
@ -101,6 +100,8 @@ class EvoChild(EvoDevice):
"""Initialize an evohome-compatible child entity (DHW, zone).""" """Initialize an evohome-compatible child entity (DHW, zone)."""
super().__init__(evo_broker, evo_device) super().__init__(evo_broker, evo_device)
self._evo_tcs = evo_device.tcs
self._schedule: dict[str, Any] = {} self._schedule: dict[str, Any] = {}
self._setpoints: dict[str, Any] = {} self._setpoints: dict[str, Any] = {}

View File

@ -11,6 +11,7 @@ from unittest.mock import MagicMock, patch
from aiohttp import ClientSession from aiohttp import ClientSession
from evohomeasync2 import EvohomeClient from evohomeasync2 import EvohomeClient
from evohomeasync2.broker import Broker from evohomeasync2.broker import Broker
from evohomeasync2.controlsystem import ControlSystem
from evohomeasync2.zone import Zone from evohomeasync2.zone import Zone
import pytest import pytest
@ -177,13 +178,28 @@ async def evohome(
yield mock_client yield mock_client
@pytest.fixture
async def ctl_id(
hass: HomeAssistant,
config: dict[str, str],
install: MagicMock,
) -> AsyncGenerator[str]:
"""Return the entity_id of the evohome integration's controller."""
async for mock_client in setup_evohome(hass, config, install=install):
evo: EvohomeClient = mock_client.return_value
ctl: ControlSystem = evo._get_single_tcs()
yield f"{Platform.CLIMATE}.{slugify(ctl.location.name)}"
@pytest.fixture @pytest.fixture
async def zone_id( async def zone_id(
hass: HomeAssistant, hass: HomeAssistant,
config: dict[str, str], config: dict[str, str],
install: MagicMock, install: MagicMock,
) -> AsyncGenerator[str]: ) -> AsyncGenerator[str]:
"""Return the entity_id of the evohome integration' first Climate zone.""" """Return the entity_id of the evohome integration's first zone."""
async for mock_client in setup_evohome(hass, config, install=install): async for mock_client in setup_evohome(hass, config, install=install):
evo: EvohomeClient = mock_client.return_value evo: EvohomeClient = mock_client.return_value

View File

@ -1,4 +1,124 @@
# serializer version: 1 # serializer version: 1
# name: test_ctl_set_hvac_mode[default]
list([
tuple(
'HeatingOff',
),
tuple(
'Auto',
),
])
# ---
# name: test_ctl_set_hvac_mode[h032585]
list([
tuple(
'Off',
),
tuple(
'Heat',
),
])
# ---
# name: test_ctl_set_hvac_mode[h099625]
list([
tuple(
'HeatingOff',
),
tuple(
'Auto',
),
])
# ---
# name: test_ctl_set_hvac_mode[minimal]
list([
tuple(
'HeatingOff',
),
tuple(
'Auto',
),
])
# ---
# name: test_ctl_set_hvac_mode[sys_004]
list([
tuple(
'HeatingOff',
),
tuple(
'Auto',
),
])
# ---
# name: test_ctl_turn_off[default]
list([
tuple(
'HeatingOff',
),
])
# ---
# name: test_ctl_turn_off[h032585]
list([
tuple(
'Off',
),
])
# ---
# name: test_ctl_turn_off[h099625]
list([
tuple(
'HeatingOff',
),
])
# ---
# name: test_ctl_turn_off[minimal]
list([
tuple(
'HeatingOff',
),
])
# ---
# name: test_ctl_turn_off[sys_004]
list([
tuple(
'HeatingOff',
),
])
# ---
# name: test_ctl_turn_on[default]
list([
tuple(
'Auto',
),
])
# ---
# name: test_ctl_turn_on[h032585]
list([
tuple(
'Heat',
),
])
# ---
# name: test_ctl_turn_on[h099625]
list([
tuple(
'Auto',
),
])
# ---
# name: test_ctl_turn_on[minimal]
list([
tuple(
'Auto',
),
])
# ---
# name: test_ctl_turn_on[sys_004]
list([
tuple(
'Auto',
),
])
# ---
# name: test_setup_platform[botched][climate.bathroom_dn-state] # name: test_setup_platform[botched][climate.bathroom_dn-state]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({

View File

@ -27,6 +27,7 @@ from homeassistant.const import (
Platform, Platform,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .conftest import setup_evohome from .conftest import setup_evohome
from .const import TEST_INSTALLS from .const import TEST_INSTALLS
@ -53,13 +54,142 @@ async def test_setup_platform(
assert x == snapshot(name=f"{x.entity_id}-state") assert x == snapshot(name=f"{x.entity_id}-state")
@pytest.mark.parametrize("install", TEST_INSTALLS)
async def test_ctl_set_hvac_mode(
hass: HomeAssistant,
ctl_id: str,
snapshot: SnapshotAssertion,
) -> None:
"""Test SERVICE_SET_HVAC_MODE of an evohome controller."""
results = []
# SERVICE_SET_HVAC_MODE: HVACMode.OFF
with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn:
await hass.services.async_call(
Platform.CLIMATE,
SERVICE_SET_HVAC_MODE,
{
ATTR_ENTITY_ID: ctl_id,
ATTR_HVAC_MODE: HVACMode.OFF,
},
blocking=True,
)
assert mock_fcn.await_count == 1
assert mock_fcn.await_args.args != () # 'HeatingOff' or 'Off'
assert mock_fcn.await_args.kwargs == {"until": None}
results.append(mock_fcn.await_args.args)
# SERVICE_SET_HVAC_MODE: HVACMode.HEAT
with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn:
await hass.services.async_call(
Platform.CLIMATE,
SERVICE_SET_HVAC_MODE,
{
ATTR_ENTITY_ID: ctl_id,
ATTR_HVAC_MODE: HVACMode.HEAT,
},
blocking=True,
)
assert mock_fcn.await_count == 1
assert mock_fcn.await_args.args != () # 'Auto' or 'Heat'
assert mock_fcn.await_args.kwargs == {"until": None}
results.append(mock_fcn.await_args.args)
assert results == snapshot
@pytest.mark.parametrize("install", TEST_INSTALLS)
async def test_ctl_set_temperature(
hass: HomeAssistant,
ctl_id: str,
) -> None:
"""Test SERVICE_SET_TEMPERATURE of an evohome controller."""
# Entity climate.xxx does not support this service
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
Platform.CLIMATE,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: ctl_id,
ATTR_TEMPERATURE: 19.1,
},
blocking=True,
)
@pytest.mark.parametrize("install", TEST_INSTALLS)
async def test_ctl_turn_off(
hass: HomeAssistant,
ctl_id: str,
snapshot: SnapshotAssertion,
) -> None:
"""Test SERVICE_TURN_OFF of an evohome controller."""
results = []
# SERVICE_TURN_OFF
with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn:
await hass.services.async_call(
Platform.CLIMATE,
SERVICE_TURN_OFF,
{
ATTR_ENTITY_ID: ctl_id,
},
blocking=True,
)
assert mock_fcn.await_count == 1
assert mock_fcn.await_args.args != () # 'HeatingOff' or 'Off'
assert mock_fcn.await_args.kwargs == {"until": None}
results.append(mock_fcn.await_args.args)
assert results == snapshot
@pytest.mark.parametrize("install", TEST_INSTALLS)
async def test_ctl_turn_on(
hass: HomeAssistant,
ctl_id: str,
snapshot: SnapshotAssertion,
) -> None:
"""Test SERVICE_TURN_ON of an evohome controller."""
results = []
# SERVICE_TURN_ON
with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn:
await hass.services.async_call(
Platform.CLIMATE,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: ctl_id,
},
blocking=True,
)
assert mock_fcn.await_count == 1
assert mock_fcn.await_args.args != () # 'Auto' or 'Heat'
assert mock_fcn.await_args.kwargs == {"until": None}
results.append(mock_fcn.await_args.args)
assert results == snapshot
@pytest.mark.parametrize("install", TEST_INSTALLS) @pytest.mark.parametrize("install", TEST_INSTALLS)
async def test_zone_set_hvac_mode( async def test_zone_set_hvac_mode(
hass: HomeAssistant, hass: HomeAssistant,
zone_id: str, zone_id: str,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
) -> None: ) -> None:
"""Test SERVICE_SET_HVAC_MODE of an evohome zone Climate entity.""" """Test SERVICE_SET_HVAC_MODE of an evohome heating zone."""
results = [] results = []
@ -107,7 +237,7 @@ async def test_zone_set_preset_mode(
freezer: FrozenDateTimeFactory, freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
) -> None: ) -> None:
"""Test SERVICE_SET_PRESET_MODE of an evohome zone Climate entity.""" """Test SERVICE_SET_PRESET_MODE of an evohome heating zone."""
freezer.move_to("2024-07-10T12:00:00Z") freezer.move_to("2024-07-10T12:00:00Z")
results = [] results = []
@ -175,7 +305,7 @@ async def test_zone_set_temperature(
freezer: FrozenDateTimeFactory, freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
) -> None: ) -> None:
"""Test SERVICE_SET_TEMPERATURE of an evohome zone Climate entity.""" """Test SERVICE_SET_TEMPERATURE of an evohome heating zone."""
freezer.move_to("2024-07-10T12:00:00Z") freezer.move_to("2024-07-10T12:00:00Z")
results = [] results = []
@ -207,7 +337,7 @@ async def test_zone_turn_off(
zone_id: str, zone_id: str,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
) -> None: ) -> None:
"""Test SERVICE_TURN_OFF of a evohome zone Climate entity.""" """Test SERVICE_TURN_OFF of an evohome heating zone."""
results = [] results = []
@ -236,7 +366,7 @@ async def test_zone_turn_on(
hass: HomeAssistant, hass: HomeAssistant,
zone_id: str, zone_id: str,
) -> None: ) -> None:
"""Test SERVICE_TURN_ON of a evohome zone Climate entity.""" """Test SERVICE_TURN_ON of an evohome heating zone."""
# SERVICE_TURN_ON # SERVICE_TURN_ON
with patch("evohomeasync2.zone.Zone.reset_mode") as mock_fcn: with patch("evohomeasync2.zone.Zone.reset_mode") as mock_fcn: