Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix evohome HVAC modes for VisionPro Wifi systems #126378

Closed
wants to merge 14 commits into from
Closed
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
34 changes: 16 additions & 18 deletions homeassistant/components/evohome/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,6 @@
PRESET_RESET = "Reset" # reset all child zones to EVO_FOLLOW
PRESET_CUSTOM = "Custom"

HA_HVAC_TO_TCS = {HVACMode.OFF: EVO_HEATOFF, HVACMode.HEAT: EVO_AUTO}

TCS_PRESET_TO_HA = {
EVO_AWAY: PRESET_AWAY,
EVO_CUSTOM: PRESET_CUSTOM,
Expand Down Expand Up @@ -150,14 +148,10 @@ async def async_setup_platform(
class EvoClimateEntity(EvoDevice, ClimateEntity):
"""Base for any evohome-compatible climate entity (controller, zone)."""

_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_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):
"""Base for any evohome-compatible heating zone."""
Expand Down Expand Up @@ -365,9 +359,9 @@ def __init__(self, evo_broker: EvoBroker, evo_device: evo.ControlSystem) -> None
self._attr_unique_id = evo_device.systemId
self._attr_name = evo_device.location.name

modes = [m[SZ_SYSTEM_MODE] for m in evo_broker.tcs.allowedSystemModes]
self._modes = [m[SZ_SYSTEM_MODE] for m in evo_device.allowedSystemModes]
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._modes if m in list(TCS_PRESET_TO_HA)
]
if self._attr_preset_modes:
self._attr_supported_features = ClimateEntityFeature.PRESET_MODE
Expand Down Expand Up @@ -401,14 +395,14 @@ async def _set_tcs_mode(self, mode: str, until: datetime | None = None) -> None:
"""Set a Controller to any of its native EVO_* operating modes."""
until = dt_util.as_utc(until) if until else None
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
def hvac_mode(self) -> HVACMode:
"""Return the current operating mode of a Controller."""
tcs_mode = self._evo_tcs.system_mode
return HVACMode.OFF if tcs_mode == EVO_HEATOFF else HVACMode.HEAT
tcs_mode = self._evo_device.system_mode
return HVACMode.OFF if tcs_mode in (EVO_HEATOFF, "Off") else HVACMode.HEAT

@property
def current_temperature(self) -> float | None:
Expand All @@ -418,25 +412,29 @@ def current_temperature(self) -> float | None:
"""
temps = [
z.temperature
for z in self._evo_tcs.zones.values()
for z in self._evo_device.zones.values()
if z.temperature is not None
]
return round(sum(temps) / len(temps), 1) if temps else None

@property
def preset_mode(self) -> str | None:
"""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 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:
"""Raise exception as Controllers don't have a target temperature."""
raise NotImplementedError("Evohome Controllers don't have target temperatures.")

async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set an operating mode for a Controller."""
if not (tcs_mode := HA_HVAC_TO_TCS.get(hvac_mode)):
if hvac_mode == HVACMode.HEAT:
tcs_mode = EVO_AUTO if EVO_AUTO in self._modes else "Heat"
elif hvac_mode == HVACMode.OFF:
tcs_mode = EVO_HEATOFF if EVO_HEATOFF in self._modes else "Off"
else:
raise HomeAssistantError(f"Invalid hvac_mode: {hvac_mode}")
await self._set_tcs_mode(tcs_mode)

Expand All @@ -451,6 +449,6 @@ async def async_update(self) -> None:
attrs = self._device_state_attrs
for attr in STATE_ATTRS_TCS:
if attr == SZ_ACTIVE_FAULTS:
attrs["activeSystemFaults"] = getattr(self._evo_tcs, attr)
attrs["activeSystemFaults"] = getattr(self._evo_device, attr)
else:
attrs[attr] = getattr(self._evo_tcs, attr)
attrs[attr] = getattr(self._evo_device, attr)
3 changes: 2 additions & 1 deletion homeassistant/components/evohome/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ def __init__(
"""Initialize an evohome-compatible entity (TCS, DHW, zone)."""
self._evo_device = evo_device
self._evo_broker = evo_broker
self._evo_tcs = evo_broker.tcs

self._device_state_attrs: dict[str, Any] = {}

Expand Down Expand Up @@ -101,6 +100,8 @@ def __init__(
"""Initialize an evohome-compatible child entity (DHW, zone)."""
super().__init__(evo_broker, evo_device)

self._evo_tcs = evo_device.tcs

self._schedule: dict[str, Any] = {}
self._setpoints: dict[str, Any] = {}

Expand Down
102 changes: 83 additions & 19 deletions tests/components/evohome/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,36 @@

from __future__ import annotations

from collections.abc import Callable
from datetime import datetime, timedelta
from collections.abc import AsyncGenerator, Callable
from datetime import datetime, timedelta, timezone
from http import HTTPMethod
from typing import Any
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock, patch

from aiohttp import ClientSession
from evohomeasync2 import EvohomeClient
from evohomeasync2.broker import Broker
import pytest

from homeassistant.components.evohome import CONF_PASSWORD, CONF_USERNAME, DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from homeassistant.util.json import JsonArrayType, JsonObjectType

from .const import ACCESS_TOKEN, REFRESH_TOKEN, USERNAME

from tests.common import load_json_array_fixture, load_json_object_fixture

if TYPE_CHECKING:
from evohomeasync2.broker import Broker

from homeassistant.components.evohome import EvoBroker
from homeassistant.components.evohome.climate import EvoController, EvoZone
from homeassistant.components.evohome.water_heater import EvoDHW


def user_account_config_fixture(install: str) -> JsonObjectType:
"""Load JSON for the config of a user's account."""
Expand Down Expand Up @@ -100,35 +110,38 @@ async def mock_get(
return mock_get


async def block_request(
self: Broker, method: HTTPMethod, url: str, **kwargs: Any
) -> None:
"""Fail if the code attempts any actual I/O via aiohttp."""

pytest.fail(f"Unexpected request: {method} {url}")


@pytest.fixture
def evo_config() -> dict[str, str]:
@pytest.fixture(scope="module")
def config() -> dict[str, str]:
"Return a default/minimal configuration."
return {
CONF_USERNAME: USERNAME,
CONF_PASSWORD: "password",
}


@patch("evohomeasync.broker.Broker._make_request", block_request)
@patch("evohomeasync2.broker.Broker._client", block_request)
async def setup_evohome(
hass: HomeAssistant,
test_config: dict[str, str],
install: str = "default",
) -> MagicMock:
"""Set up the evohome integration and return its client.
) -> AsyncGenerator[MagicMock]:
"""Mock the evohome integration and return its client.

The class is mocked here to check the client was instantiated with the correct args.
"""

# set the time zone as for the active evohome location
loc_idx: int = test_config.get("location_idx", 0) # type: ignore[assignment]

try:
locn = user_locations_config_fixture(install)[loc_idx]
except IndexError:
if loc_idx == 0:
raise
locn = user_locations_config_fixture(install)[0]

utc_offset: int = locn["locationInfo"]["timeZone"]["currentOffsetMinutes"] # type: ignore[assignment, call-overload, index]
dt_util.set_default_time_zone(timezone(timedelta(minutes=utc_offset)))

with (
patch("homeassistant.components.evohome.evo.EvohomeClient") as mock_client,
patch("homeassistant.components.evohome.ev1.EvohomeClient", return_value=None),
Expand All @@ -148,4 +161,55 @@ async def setup_evohome(

assert mock_client.account_info is not None

return mock_client
try:
yield mock_client
finally:
await hass.data[DOMAIN]["coordinator"].async_shutdown()
await hass.async_block_till_done()


def ctl_entity(hass: HomeAssistant) -> EvoController:
"""Return the controller entity of the evohome system."""

broker: EvoBroker = hass.data[DOMAIN]["broker"]

entity_registry = er.async_get(hass)
entity_id = entity_registry.async_get_entity_id(
Platform.CLIMATE, DOMAIN, broker.tcs._id
)

component: EntityComponent = hass.data.get(Platform.CLIMATE) # type: ignore[assignment]
return next(e for e in component.entities if e.entity_id == entity_id) # type: ignore[return-value]


def dhw_entity(hass: HomeAssistant) -> EvoDHW | None:
"""Return the DHW entity of the evohome system."""

broker: EvoBroker = hass.data[DOMAIN]["broker"]

if (dhw := broker.tcs.hotwater) is None:
return None

entity_registry = er.async_get(hass)
entity_id = entity_registry.async_get_entity_id(
Platform.WATER_HEATER, DOMAIN, dhw._id
)

component: EntityComponent = hass.data.get(Platform.WATER_HEATER) # type: ignore[assignment]
return next(e for e in component.entities if e.entity_id == entity_id) # type: ignore[return-value]


def zone_entity(hass: HomeAssistant) -> EvoZone:
"""Return the entity of the first zone of the evohome system."""

broker: EvoBroker = hass.data[DOMAIN]["broker"]

unique_id = broker.tcs._zones[0]._id
if unique_id == broker.tcs._id:
unique_id += "z" # special case of merged controller/zone

entity_registry = er.async_get(hass)
entity_id = entity_registry.async_get_entity_id(Platform.CLIMATE, DOMAIN, unique_id)

component: EntityComponent = hass.data.get(Platform.CLIMATE) # type: ignore[assignment]
return next(e for e in component.entities if e.entity_id == entity_id) # type: ignore[return-value]
17 changes: 13 additions & 4 deletions tests/components/evohome/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,20 @@
SESSION_ID: Final = "F7181186..."
USERNAME: Final = "[email protected]"

CTL_MODE_LOOKUP = {
"Reset": "AutoWithReset",
"eco": "AutoWithEco",
"away": "Away",
"home": "DayOff",
"Custom": "Custom",
}

# The h-numbers refer to issues in HA's core repo
TEST_INSTALLS: Final = (
"minimal", # evohome (single zone, no DHW)
"default", # evohome (multi-zone, with DHW & ghost zones)
"h032585", # VisionProWifi (no preset_mode for TCS)
"minimal", # evohome: single zone, no DHW
"default", # evohome: multi-zone, with DHW
"h032585", # VisionProWifi: no preset modes for TCS, zoneId=systemId
"h099625", # RoundThermostat
"system_004", # RoundModulation
"sys_004", # RoundModulation
)
# "botched", # as default: but with activeFaults, ghost zones & unknown types
Loading