Add Bryant Evolution Integration (#119788)

* Add an integration for Bryant Evolution HVAC systems.

* Update newly created tests so that they pass.

* Improve compliance with home assistant guidelines.

* Added tests

* remove xxx

* Minor test cleanups

* Add a test for reading HVAC actions.

* Update homeassistant/components/bryant_evolution/__init__.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/bryant_evolution/climate.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/bryant_evolution/climate.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/bryant_evolution/climate.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/bryant_evolution/climate.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/bryant_evolution/climate.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Update homeassistant/components/bryant_evolution/config_flow.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Address reviewer comments.

* Address additional reviewer comments.

* Use translation for exception error messages.

* Simplify config flow.

* Continue addressing comments

* Use mocking rather than DI to provide a for-test client in tests.

* Fix a failure in test_config_flow.py

* Track host->filename in strings.json.

* Use config entry ID for climate entity unique id

* Guard against fan mode returning None in async_update.

* Move unavailable-client check from climate.py to init.py.

* Improve test coverage

* Bump evolutionhttp version

* Address comments

* update comment

* only have one _can_reach_device fn

* Auto-detect which systems and zones are attached.

* Add support for reconfiguration

* Fix a few review comments

* Introduce multiple devices

* Track evolutionhttp library change that returns additional per-zone information during enumeration

* Move construction of devices to init

* Avoid triplicate writing

* rework tests to use mocks

* Correct attribute name to unbreak test

* Pull magic tuple of system-zone into a constant

* Address some test comments

* Create test_init.py

* simplify test_reconfigure

* Replace disable_auto_entity_update with mocks.

* Update tests/components/bryant_evolution/test_climate.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update tests/components/bryant_evolution/test_climate.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update tests/components/bryant_evolution/test_config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/bryant_evolution/config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update tests/components/bryant_evolution/test_config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update tests/components/bryant_evolution/test_config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* fix test errors

* do not access runtime_data in tests

* use snapshot_platform and type fixtures

---------

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
danielsmyers 2024-07-29 02:25:04 -07:00 committed by GitHub
parent 686598b6b3
commit fa61ad072d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1224 additions and 0 deletions

View File

@ -120,6 +120,7 @@ homeassistant.components.bond.*
homeassistant.components.braviatv.*
homeassistant.components.brother.*
homeassistant.components.browser.*
homeassistant.components.bryant_evolution.*
homeassistant.components.bthome.*
homeassistant.components.button.*
homeassistant.components.calendar.*

View File

@ -221,6 +221,8 @@ build.json @home-assistant/supervisor
/tests/components/brottsplatskartan/ @gjohansson-ST
/homeassistant/components/brunt/ @eavanvalkenburg
/tests/components/brunt/ @eavanvalkenburg
/homeassistant/components/bryant_evolution/ @danielsmyers
/tests/components/bryant_evolution/ @danielsmyers
/homeassistant/components/bsblan/ @liudger
/tests/components/bsblan/ @liudger
/homeassistant/components/bt_smarthub/ @typhoon2099

View File

@ -0,0 +1,84 @@
"""The Bryant Evolution integration."""
from __future__ import annotations
import logging
from evolutionhttp import BryantEvolutionLocalClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_FILENAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from . import names
from .const import CONF_SYSTEM_ZONE, DOMAIN
PLATFORMS: list[Platform] = [Platform.CLIMATE]
type BryantEvolutionLocalClients = dict[tuple[int, int], BryantEvolutionLocalClient]
type BryantEvolutionConfigEntry = ConfigEntry[BryantEvolutionLocalClients]
_LOGGER = logging.getLogger(__name__)
async def _can_reach_device(client: BryantEvolutionLocalClient) -> bool:
"""Return whether we can reach the device at the given filename."""
# Verify that we can read current temperature to check that the
# (filename, system, zone) is valid.
return await client.read_current_temperature() is not None
async def async_setup_entry(
hass: HomeAssistant, entry: BryantEvolutionConfigEntry
) -> bool:
"""Set up Bryant Evolution from a config entry."""
# Add a device for the SAM itself.
sam_uid = names.sam_device_uid(entry)
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, sam_uid)},
manufacturer="Bryant",
name="System Access Module",
)
# Add a device for each system.
for sys_id in (1, 2):
if not any(sz[0] == sys_id for sz in entry.data[CONF_SYSTEM_ZONE]):
_LOGGER.debug(
"Skipping system %s because it is not configured for this integration: %s",
sys_id,
entry.data[CONF_SYSTEM_ZONE],
)
continue
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, names.system_device_uid(sam_uid, sys_id))},
via_device=(DOMAIN, names.sam_device_uid(entry)),
manufacturer="Bryant",
name=f"System {sys_id}",
)
# Create a client for every zone.
entry.runtime_data = {}
for sz in entry.data[CONF_SYSTEM_ZONE]:
try:
client = await BryantEvolutionLocalClient.get_client(
sz[0], sz[1], entry.data[CONF_FILENAME]
)
if not await _can_reach_device(client):
raise ConfigEntryNotReady
entry.runtime_data[tuple(sz)] = client
except FileNotFoundError as f:
raise ConfigEntryNotReady from f
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: BryantEvolutionConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,252 @@
"""Support for Bryant Evolution HVAC systems."""
from datetime import timedelta
import logging
from typing import Any
from evolutionhttp import BryantEvolutionLocalClient
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import BryantEvolutionConfigEntry, names
from .const import CONF_SYSTEM_ZONE, DOMAIN
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=60)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BryantEvolutionConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up a config entry."""
# Add a climate entity for each system/zone.
sam_uid = names.sam_device_uid(config_entry)
entities: list[Entity] = []
for sz in config_entry.data[CONF_SYSTEM_ZONE]:
system_id = sz[0]
zone_id = sz[1]
client = config_entry.runtime_data.get(tuple(sz))
climate = BryantEvolutionClimate(
client,
system_id,
zone_id,
sam_uid,
)
entities.append(climate)
async_add_entities(entities, update_before_add=True)
class BryantEvolutionClimate(ClimateEntity):
"""ClimateEntity for Bryant Evolution HVAC systems.
Design note: this class updates using polling. However, polling
is very slow (~1500 ms / parameter). To improve the user
experience on updates, we also locally update this instance and
call async_write_ha_state as well.
"""
_attr_has_entity_name = True
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
_attr_hvac_modes = [
HVACMode.HEAT,
HVACMode.COOL,
HVACMode.HEAT_COOL,
HVACMode.OFF,
]
_attr_fan_modes = ["auto", "low", "med", "high"]
_enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
client: BryantEvolutionLocalClient,
system_id: int,
zone_id: int,
sam_uid: str,
) -> None:
"""Initialize an entity from parts."""
self._client = client
self._attr_name = None
self._attr_unique_id = names.zone_entity_uid(sam_uid, system_id, zone_id)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
manufacturer="Bryant",
via_device=(DOMAIN, names.system_device_uid(sam_uid, system_id)),
name=f"System {system_id} Zone {zone_id}",
)
async def async_update(self) -> None:
"""Update the entity state."""
self._attr_current_temperature = await self._client.read_current_temperature()
if (fan_mode := await self._client.read_fan_mode()) is not None:
self._attr_fan_mode = fan_mode.lower()
else:
self._attr_fan_mode = None
self._attr_target_temperature = None
self._attr_target_temperature_high = None
self._attr_target_temperature_low = None
self._attr_hvac_mode = await self._read_hvac_mode()
# Set target_temperature or target_temperature_{high, low} based on mode.
match self._attr_hvac_mode:
case HVACMode.HEAT:
self._attr_target_temperature = (
await self._client.read_heating_setpoint()
)
case HVACMode.COOL:
self._attr_target_temperature = (
await self._client.read_cooling_setpoint()
)
case HVACMode.HEAT_COOL:
self._attr_target_temperature_high = (
await self._client.read_cooling_setpoint()
)
self._attr_target_temperature_low = (
await self._client.read_heating_setpoint()
)
case HVACMode.OFF:
pass
case _:
_LOGGER.error("Unknown HVAC mode %s", self._attr_hvac_mode)
# Note: depends on current temperature and target temperature low read
# above.
self._attr_hvac_action = await self._read_hvac_action()
async def _read_hvac_mode(self) -> HVACMode:
mode_and_active = await self._client.read_hvac_mode()
if not mode_and_active:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_read_hvac_mode"
)
mode = mode_and_active[0]
mode_enum = {
"HEAT": HVACMode.HEAT,
"COOL": HVACMode.COOL,
"AUTO": HVACMode.HEAT_COOL,
"OFF": HVACMode.OFF,
}.get(mode.upper())
if mode_enum is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_parse_hvac_mode",
translation_placeholders={"mode": mode},
)
return mode_enum
async def _read_hvac_action(self) -> HVACAction:
"""Return the current running hvac operation."""
mode_and_active = await self._client.read_hvac_mode()
if not mode_and_active:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_read_hvac_action"
)
mode, is_active = mode_and_active
if not is_active:
return HVACAction.OFF
match mode.upper():
case "HEAT":
return HVACAction.HEATING
case "COOL":
return HVACAction.COOLING
case "OFF":
return HVACAction.OFF
case "AUTO":
# In AUTO, we need to figure out what the actual action is
# based on the setpoints.
if (
self.current_temperature is not None
and self.target_temperature_low is not None
):
if self.current_temperature > self.target_temperature_low:
# If the system is on and the current temperature is
# higher than the point at which heating would activate,
# then we must be cooling.
return HVACAction.COOLING
return HVACAction.HEATING
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_parse_hvac_mode",
translation_placeholders={
"mode_and_active": mode_and_active,
"current_temperature": str(self.current_temperature),
"target_temperature_low": str(self.target_temperature_low),
},
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
if hvac_mode == HVACMode.HEAT_COOL:
hvac_mode = HVACMode.AUTO
if not await self._client.set_hvac_mode(hvac_mode):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_set_hvac_mode"
)
self._attr_hvac_mode = hvac_mode
self._async_write_ha_state()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if kwargs.get("target_temp_high"):
temp = int(kwargs["target_temp_high"])
if not await self._client.set_cooling_setpoint(temp):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_set_clsp"
)
self._attr_target_temperature_high = temp
if kwargs.get("target_temp_low"):
temp = int(kwargs["target_temp_low"])
if not await self._client.set_heating_setpoint(temp):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_set_htsp"
)
self._attr_target_temperature_low = temp
if kwargs.get("temperature"):
temp = int(kwargs["temperature"])
fn = (
self._client.set_heating_setpoint
if self.hvac_mode == HVACMode.HEAT
else self._client.set_cooling_setpoint
)
if not await fn(temp):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_set_temp"
)
self._attr_target_temperature = temp
# If we get here, we must have changed something unless HA allowed an
# invalid service call (without any recognized kwarg).
self._async_write_ha_state()
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
if not await self._client.set_fan_mode(fan_mode):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="failed_to_set_fan_mode"
)
self._attr_fan_mode = fan_mode.lower()
self.async_write_ha_state()

View File

@ -0,0 +1,87 @@
"""Config flow for Bryant Evolution integration."""
from __future__ import annotations
import logging
from typing import Any
from evolutionhttp import BryantEvolutionLocalClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_FILENAME
from homeassistant.helpers.typing import UNDEFINED
from .const import CONF_SYSTEM_ZONE, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_FILENAME, default="/dev/ttyUSB0"): str,
}
)
async def _enumerate_sz(tty: str) -> list[tuple[int, int]]:
"""Return (system, zone) tuples for each system+zone accessible through tty."""
return [
(system_id, zone.zone_id)
for system_id in (1, 2)
for zone in await BryantEvolutionLocalClient.enumerate_zones(system_id, tty)
]
class BryantConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Bryant Evolution."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
system_zone = await _enumerate_sz(user_input[CONF_FILENAME])
except FileNotFoundError:
_LOGGER.error("Could not open %s: not found", user_input[CONF_FILENAME])
errors["base"] = "cannot_connect"
else:
if len(system_zone) != 0:
return self.async_create_entry(
title=f"SAM at {user_input[CONF_FILENAME]}",
data={
CONF_FILENAME: user_input[CONF_FILENAME],
CONF_SYSTEM_ZONE: system_zone,
},
)
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle integration reconfiguration."""
errors: dict[str, str] = {}
if user_input is not None:
system_zone = await _enumerate_sz(user_input[CONF_FILENAME])
if len(system_zone) != 0:
our_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
assert our_entry is not None, "Could not find own entry"
return self.async_update_reload_and_abort(
entry=our_entry,
data={
CONF_FILENAME: user_input[CONF_FILENAME],
CONF_SYSTEM_ZONE: system_zone,
},
unique_id=UNDEFINED,
reason="reconfigured",
)
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="reconfigure", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@ -0,0 +1,4 @@
"""Constants for the Bryant Evolution integration."""
DOMAIN = "bryant_evolution"
CONF_SYSTEM_ZONE = "system_zone"

View File

@ -0,0 +1,10 @@
{
"domain": "bryant_evolution",
"name": "Bryant Evolution",
"codeowners": ["@danielsmyers"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bryant_evolution",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["evolutionhttp==0.0.18"]
}

View File

@ -0,0 +1,18 @@
"""Functions to generate names for devices and entities."""
from homeassistant.config_entries import ConfigEntry
def sam_device_uid(entry: ConfigEntry) -> str:
"""Return the UID for the SAM device."""
return entry.entry_id
def system_device_uid(sam_uid: str, system_id: int) -> str:
"""Return the UID for a given system (e.g., 1) under a SAM."""
return f"{sam_uid}-S{system_id}"
def zone_entity_uid(sam_uid: str, system_id: int, zone_id: int) -> str:
"""Return the UID for a given system and zone (e.g., 1 and 2) under a SAM."""
return f"{sam_uid}-S{system_id}-Z{zone_id}"

View File

@ -0,0 +1,48 @@
{
"config": {
"step": {
"user": {
"data": {
"filename": "Serial port filename"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"exceptions": {
"failed_to_read_hvac_mode": {
"message": "Failed to read current HVAC mode"
},
"failed_to_parse_hvac_mode": {
"message": "Cannot parse response to HVACMode: {mode}"
},
"failed_to_read_hvac_action": {
"message": "Failed to read current HVAC action"
},
"failed_to_parse_hvac_action": {
"message": "Could not determine HVAC action: {mode_and_active}, {self.current_temperature}, {self.target_temperature_low}"
},
"failed_to_set_hvac_mode": {
"message": "Failed to set HVAC mode"
},
"failed_to_set_clsp": {
"message": "Failed to set cooling setpoint"
},
"failed_to_set_htsp": {
"message": "Failed to set heating setpoint"
},
"failed_to_set_temp": {
"message": "Failed to set temperature"
},
"failed_to_set_fan_mode": {
"message": "Failed to set fan mode"
}
}
}

View File

@ -93,6 +93,7 @@ FLOWS = {
"brother",
"brottsplatskartan",
"brunt",
"bryant_evolution",
"bsblan",
"bthome",
"buienradar",

View File

@ -810,6 +810,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"bryant_evolution": {
"name": "Bryant Evolution",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"bsblan": {
"name": "BSB-Lan",
"integration_type": "device",

View File

@ -955,6 +955,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.bryant_evolution.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.bthome.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -845,6 +845,9 @@ eufylife-ble-client==0.1.8
# homeassistant.components.evohome
evohome-async==0.4.20
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
# homeassistant.components.faa_delays
faadelays==2023.9.1

View File

@ -711,6 +711,9 @@ eternalegypt==0.0.16
# homeassistant.components.eufylife_ble
eufylife-ble-client==0.1.8
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
# homeassistant.components.faa_delays
faadelays==2023.9.1

View File

@ -0,0 +1 @@
"""Tests for the Bryant Evolution integration."""

View File

@ -0,0 +1,70 @@
"""Common fixtures for the Bryant Evolution tests."""
from collections.abc import Generator, Mapping
from unittest.mock import AsyncMock, patch
from evolutionhttp import BryantEvolutionLocalClient
import pytest
from homeassistant.components.bryant_evolution.const import CONF_SYSTEM_ZONE, DOMAIN
from homeassistant.const import CONF_FILENAME
from homeassistant.core import HomeAssistant
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.bryant_evolution.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
DEFAULT_SYSTEM_ZONES = ((1, 1), (1, 2), (2, 3))
"""
A tuple of (system, zone) pairs representing the default system and zone configurations
for the Bryant Evolution integration.
"""
@pytest.fixture(autouse=True)
def mock_evolution_client_factory() -> Generator[AsyncMock, None, None]:
"""Mock an Evolution client."""
with patch(
"evolutionhttp.BryantEvolutionLocalClient.get_client",
austospec=True,
) as mock_get_client:
clients: Mapping[tuple[int, int], AsyncMock] = {}
for system, zone in DEFAULT_SYSTEM_ZONES:
clients[(system, zone)] = AsyncMock(spec=BryantEvolutionLocalClient)
client = clients[system, zone]
client.read_zone_name.return_value = f"System {system} Zone {zone}"
client.read_current_temperature.return_value = 75
client.read_hvac_mode.return_value = ("COOL", False)
client.read_fan_mode.return_value = "AUTO"
client.read_cooling_setpoint.return_value = 72
mock_get_client.side_effect = lambda system, zone, tty: clients[
(system, zone)
]
yield mock_get_client
@pytest.fixture
async def mock_evolution_entry(
hass: HomeAssistant,
mock_evolution_client_factory: AsyncMock,
) -> MockConfigEntry:
"""Configure and return a Bryant evolution integration."""
hass.config.units = US_CUSTOMARY_SYSTEM
entry = MockConfigEntry(
entry_id="01J3XJZSTEF6G5V0QJX6HBC94T", # For determinism in snapshot tests
domain=DOMAIN,
data={CONF_FILENAME: "/dev/ttyUSB0", CONF_SYSTEM_ZONE: [(1, 1)]},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry

View File

@ -0,0 +1,83 @@
# serializer version: 1
# name: test_setup_integration_success[climate.system_1_zone_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'fan_modes': list([
'auto',
'low',
'med',
'high',
]),
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 95,
'min_temp': 45,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.system_1_zone_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'bryant_evolution',
'previous_unique_id': None,
'supported_features': <ClimateEntityFeature: 395>,
'translation_key': None,
'unique_id': '01J3XJZSTEF6G5V0QJX6HBC94T-S1-Z1',
'unit_of_measurement': None,
})
# ---
# name: test_setup_integration_success[climate.system_1_zone_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 75,
'fan_mode': 'auto',
'fan_modes': list([
'auto',
'low',
'med',
'high',
]),
'friendly_name': 'System 1 Zone 1',
'hvac_action': <HVACAction.OFF: 'off'>,
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 95,
'min_temp': 45,
'supported_features': <ClimateEntityFeature: 395>,
'target_temp_high': None,
'target_temp_low': None,
'temperature': 72,
}),
'context': <ANY>,
'entity_id': 'climate.system_1_zone_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'cool',
})
# ---

View File

@ -0,0 +1,259 @@
"""Test the BryantEvolutionClient type."""
from collections.abc import Generator
from datetime import timedelta
import logging
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.bryant_evolution.climate import SCAN_INTERVAL
from homeassistant.components.climate import (
ATTR_FAN_MODE,
ATTR_HVAC_ACTION,
ATTR_HVAC_MODE,
ATTR_TEMPERATURE,
DOMAIN as CLIMATE_DOMAIN,
SERVICE_SET_FAN_MODE,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_TEMPERATURE,
HVACAction,
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
_LOGGER = logging.getLogger(__name__)
async def trigger_polling(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None:
"""Trigger a polling event."""
freezer.tick(SCAN_INTERVAL + timedelta(seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
async def test_setup_integration_success(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_evolution_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test that an instance can be constructed."""
await snapshot_platform(
hass, entity_registry, snapshot, mock_evolution_entry.entry_id
)
async def test_set_temperature_mode_cool(
hass: HomeAssistant,
mock_evolution_entry: MockConfigEntry,
mock_evolution_client_factory: Generator[AsyncMock, None, None],
freezer: FrozenDateTimeFactory,
) -> None:
"""Test setting the temperature in cool mode."""
# Start with known initial conditions
client = await mock_evolution_client_factory(1, 1, "/dev/unused")
client.read_hvac_mode.return_value = ("COOL", False)
client.read_cooling_setpoint.return_value = 75
await trigger_polling(hass, freezer)
state = hass.states.get("climate.system_1_zone_1")
assert state.attributes["temperature"] == 75, state.attributes
# Make the call, modifting the mock client to throw an exception on
# read to ensure that the update is visible iff we call
# async_update_ha_state.
data = {ATTR_TEMPERATURE: 70}
data[ATTR_ENTITY_ID] = "climate.system_1_zone_1"
client.read_cooling_setpoint.side_effect = Exception("fake failure")
await hass.services.async_call(
CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True
)
# Verify effect.
client.set_cooling_setpoint.assert_called_once_with(70)
state = hass.states.get("climate.system_1_zone_1")
assert state.attributes["temperature"] == 70
async def test_set_temperature_mode_heat(
hass: HomeAssistant,
mock_evolution_entry: MockConfigEntry,
mock_evolution_client_factory: Generator[AsyncMock, None, None],
freezer: FrozenDateTimeFactory,
) -> None:
"""Test setting the temperature in heat mode."""
# Start with known initial conditions
client = await mock_evolution_client_factory(1, 1, "/dev/unused")
client.read_hvac_mode.return_value = ("HEAT", False)
client.read_heating_setpoint.return_value = 60
await trigger_polling(hass, freezer)
# Make the call, modifting the mock client to throw an exception on
# read to ensure that the update is visible iff we call
# async_update_ha_state.
data = {"temperature": 65}
data[ATTR_ENTITY_ID] = "climate.system_1_zone_1"
client.read_heating_setpoint.side_effect = Exception("fake failure")
await hass.services.async_call(
CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True
)
# Verify effect.
state = hass.states.get("climate.system_1_zone_1")
assert state.attributes["temperature"] == 65, state.attributes
async def test_set_temperature_mode_heat_cool(
hass: HomeAssistant,
mock_evolution_entry: MockConfigEntry,
mock_evolution_client_factory: Generator[AsyncMock, None, None],
freezer: FrozenDateTimeFactory,
) -> None:
"""Test setting the temperature in heat_cool mode."""
# Enter heat_cool with known setpoints
mock_client = await mock_evolution_client_factory(1, 1, "/dev/unused")
mock_client.read_hvac_mode.return_value = ("AUTO", False)
mock_client.read_cooling_setpoint.return_value = 90
mock_client.read_heating_setpoint.return_value = 40
await trigger_polling(hass, freezer)
state = hass.states.get("climate.system_1_zone_1")
assert state.state == "heat_cool"
assert state.attributes["target_temp_low"] == 40
assert state.attributes["target_temp_high"] == 90
# Make the call, modifting the mock client to throw an exception on
# read to ensure that the update is visible iff we call
# async_update_ha_state.
mock_client.read_heating_setpoint.side_effect = Exception("fake failure")
mock_client.read_cooling_setpoint.side_effect = Exception("fake failure")
data = {"target_temp_low": 70, "target_temp_high": 80}
data[ATTR_ENTITY_ID] = "climate.system_1_zone_1"
await hass.services.async_call(
CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True
)
state = hass.states.get("climate.system_1_zone_1")
assert state.attributes["target_temp_low"] == 70, state.attributes
assert state.attributes["target_temp_high"] == 80, state.attributes
mock_client.set_cooling_setpoint.assert_called_once_with(80)
mock_client.set_heating_setpoint.assert_called_once_with(70)
async def test_set_fan_mode(
hass: HomeAssistant,
mock_evolution_entry: MockConfigEntry,
mock_evolution_client_factory: Generator[AsyncMock, None, None],
) -> None:
"""Test that setting fan mode works."""
mock_client = await mock_evolution_client_factory(1, 1, "/dev/unused")
fan_modes = ["auto", "low", "med", "high"]
for mode in fan_modes:
# Make the call, modifting the mock client to throw an exception on
# read to ensure that the update is visible iff we call
# async_update_ha_state.
mock_client.read_fan_mode.side_effect = Exception("fake failure")
data = {ATTR_FAN_MODE: mode}
data[ATTR_ENTITY_ID] = "climate.system_1_zone_1"
await hass.services.async_call(
CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, data, blocking=True
)
assert (
hass.states.get("climate.system_1_zone_1").attributes[ATTR_FAN_MODE] == mode
)
mock_client.set_fan_mode.assert_called_with(mode)
@pytest.mark.parametrize(
("hvac_mode", "evolution_mode"),
[("heat_cool", "auto"), ("heat", "heat"), ("cool", "cool"), ("off", "off")],
)
async def test_set_hvac_mode(
hass: HomeAssistant,
mock_evolution_entry: MockConfigEntry,
mock_evolution_client_factory: Generator[AsyncMock, None, None],
hvac_mode,
evolution_mode,
) -> None:
"""Test that setting HVAC mode works."""
mock_client = await mock_evolution_client_factory(1, 1, "/dev/unused")
# Make the call, modifting the mock client to throw an exception on
# read to ensure that the update is visible iff we call
# async_update_ha_state.
data = {ATTR_HVAC_MODE: hvac_mode}
data[ATTR_ENTITY_ID] = "climate.system_1_zone_1"
mock_client.read_hvac_mode.side_effect = Exception("fake failure")
await hass.services.async_call(
CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, data, blocking=True
)
await hass.async_block_till_done()
assert hass.states.get("climate.system_1_zone_1").state == evolution_mode
mock_client.set_hvac_mode.assert_called_with(evolution_mode)
@pytest.mark.parametrize(
("curr_temp", "expected_action"),
[(62, HVACAction.HEATING), (70, HVACAction.OFF), (80, HVACAction.COOLING)],
)
async def test_read_hvac_action_heat_cool(
hass: HomeAssistant,
mock_evolution_entry: MockConfigEntry,
mock_evolution_client_factory: Generator[AsyncMock, None, None],
freezer: FrozenDateTimeFactory,
curr_temp: int,
expected_action: HVACAction,
) -> None:
"""Test that we can read the current HVAC action in heat_cool mode."""
htsp = 68
clsp = 72
mock_client = await mock_evolution_client_factory(1, 1, "/dev/unused")
mock_client.read_heating_setpoint.return_value = htsp
mock_client.read_cooling_setpoint.return_value = clsp
is_active = curr_temp < htsp or curr_temp > clsp
mock_client.read_hvac_mode.return_value = ("auto", is_active)
mock_client.read_current_temperature.return_value = curr_temp
await trigger_polling(hass, freezer)
state = hass.states.get("climate.system_1_zone_1")
assert state.attributes[ATTR_HVAC_ACTION] == expected_action
@pytest.mark.parametrize(
("mode", "active", "expected_action"),
[
("heat", True, "heating"),
("heat", False, "off"),
("cool", True, "cooling"),
("cool", False, "off"),
("off", False, "off"),
],
)
async def test_read_hvac_action(
hass: HomeAssistant,
mock_evolution_entry: MockConfigEntry,
mock_evolution_client_factory: Generator[AsyncMock, None, None],
freezer: FrozenDateTimeFactory,
mode: str,
active: bool,
expected_action: str,
) -> None:
"""Test that we can read the current HVAC action."""
# Initial state should be no action.
assert (
hass.states.get("climate.system_1_zone_1").attributes[ATTR_HVAC_ACTION]
== HVACAction.OFF
)
# Perturb the system and verify we see an action.
mock_client = await mock_evolution_client_factory(1, 1, "/dev/unused")
mock_client.read_heating_setpoint.return_value = 75 # Needed if mode == heat
mock_client.read_hvac_mode.return_value = (mode, active)
await trigger_polling(hass, freezer)
assert (
hass.states.get("climate.system_1_zone_1").attributes[ATTR_HVAC_ACTION]
== expected_action
)

View File

@ -0,0 +1,170 @@
"""Test the Bryant Evolution config flow."""
from unittest.mock import DEFAULT, AsyncMock, patch
from evolutionhttp import BryantEvolutionLocalClient, ZoneInfo
from homeassistant import config_entries
from homeassistant.components.bryant_evolution.const import CONF_SYSTEM_ZONE, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_FILENAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_form_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
with (
patch.object(
BryantEvolutionLocalClient,
"enumerate_zones",
return_value=DEFAULT,
) as mock_call,
):
mock_call.side_effect = lambda system_id, filename: {
1: [ZoneInfo(1, 1, "S1Z1"), ZoneInfo(1, 2, "S1Z2")],
2: [ZoneInfo(2, 3, "S2Z2"), ZoneInfo(2, 4, "S2Z3")],
}.get(system_id, [])
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_FILENAME: "test_form_success",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY, result
assert result["title"] == "SAM at test_form_success"
assert result["data"] == {
CONF_FILENAME: "test_form_success",
CONF_SYSTEM_ZONE: [(1, 1), (1, 2), (2, 3), (2, 4)],
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_cannot_connect(
hass: HomeAssistant,
mock_evolution_client_factory: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
with (
patch.object(
BryantEvolutionLocalClient,
"enumerate_zones",
return_value=DEFAULT,
) as mock_call,
):
mock_call.return_value = []
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_FILENAME: "test_form_cannot_connect",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
with (
patch.object(
BryantEvolutionLocalClient,
"enumerate_zones",
return_value=DEFAULT,
) as mock_call,
):
mock_call.side_effect = lambda system_id, filename: {
1: [ZoneInfo(1, 1, "S1Z1"), ZoneInfo(1, 2, "S1Z2")],
2: [ZoneInfo(2, 3, "S2Z3"), ZoneInfo(2, 4, "S2Z4")],
}.get(system_id, [])
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_FILENAME: "some-serial",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "SAM at some-serial"
assert result["data"] == {
CONF_FILENAME: "some-serial",
CONF_SYSTEM_ZONE: [(1, 1), (1, 2), (2, 3), (2, 4)],
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_cannot_connect_bad_file(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_evolution_client_factory: AsyncMock,
) -> None:
"""Test we handle cannot connect error from a missing file."""
mock_evolution_client_factory.side_effect = FileNotFoundError("test error")
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
# This file does not exist.
CONF_FILENAME: "test_form_cannot_connect_bad_file",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
async def test_reconfigure(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_evolution_entry: MockConfigEntry,
) -> None:
"""Test that reconfigure discovers additional systems and zones."""
# Reconfigure with additional systems and zones.
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": config_entries.SOURCE_RECONFIGURE,
"entry_id": mock_evolution_entry.entry_id,
},
)
with (
patch.object(
BryantEvolutionLocalClient,
"enumerate_zones",
return_value=DEFAULT,
) as mock_call,
):
mock_call.side_effect = lambda system_id, filename: {
1: [ZoneInfo(1, 1, "S1Z1")],
2: [ZoneInfo(2, 3, "S2Z3"), ZoneInfo(2, 4, "S2Z4"), ZoneInfo(2, 5, "S2Z5")],
}.get(system_id, [])
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_FILENAME: "test_reconfigure",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT, result
assert result["reason"] == "reconfigured"
config_entry = hass.config_entries.async_entries()[0]
assert config_entry.data[CONF_SYSTEM_ZONE] == [
(1, 1),
(2, 3),
(2, 4),
(2, 5),
]

View File

@ -0,0 +1,112 @@
"""Test setup for the bryant_evolution integration."""
import logging
from unittest.mock import AsyncMock
from evolutionhttp import BryantEvolutionLocalClient
from freezegun.api import FrozenDateTimeFactory
from homeassistant.components.bryant_evolution.const import CONF_SYSTEM_ZONE, DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_FILENAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
from .conftest import DEFAULT_SYSTEM_ZONES
from .test_climate import trigger_polling
from tests.common import MockConfigEntry
_LOGGER = logging.getLogger(__name__)
async def test_setup_integration_prevented_by_unavailable_client(
hass: HomeAssistant, mock_evolution_client_factory: AsyncMock
) -> None:
"""Test that setup throws ConfigEntryNotReady when the client is unavailable."""
mock_evolution_client_factory.side_effect = FileNotFoundError("test error")
mock_evolution_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_FILENAME: "test_setup_integration_prevented_by_unavailable_client",
CONF_SYSTEM_ZONE: [(1, 1)],
},
)
mock_evolution_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_evolution_entry.entry_id)
await hass.async_block_till_done()
assert mock_evolution_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_integration_client_returns_none(
hass: HomeAssistant, mock_evolution_client_factory: AsyncMock
) -> None:
"""Test that an unavailable client causes ConfigEntryNotReady."""
mock_client = AsyncMock(spec=BryantEvolutionLocalClient)
mock_evolution_client_factory.side_effect = None
mock_evolution_client_factory.return_value = mock_client
mock_client.read_fan_mode.return_value = None
mock_client.read_current_temperature.return_value = None
mock_client.read_hvac_mode.return_value = None
mock_client.read_cooling_setpoint.return_value = None
mock_client.read_zone_name.return_value = None
mock_evolution_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_FILENAME: "/dev/ttyUSB0", CONF_SYSTEM_ZONE: [(1, 1)]},
)
mock_evolution_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_evolution_entry.entry_id)
await hass.async_block_till_done()
assert mock_evolution_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_multiple_systems_zones(
hass: HomeAssistant,
mock_evolution_client_factory: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that a device with multiple systems and zones works."""
hass.config.units = US_CUSTOMARY_SYSTEM
mock_evolution_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_FILENAME: "/dev/ttyUSB0", CONF_SYSTEM_ZONE: DEFAULT_SYSTEM_ZONES},
)
mock_evolution_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_evolution_entry.entry_id)
await hass.async_block_till_done()
# Set the temperature of each zone to its zone number so that we can
# ensure we've created the right client for each zone.
for sz, client in mock_evolution_entry.runtime_data.items():
client.read_current_temperature.return_value = sz[1]
await trigger_polling(hass, freezer)
# Check that each system and zone has the expected temperature value to
# verify that the initial setup flow worked as expected.
for sz in DEFAULT_SYSTEM_ZONES:
system = sz[0]
zone = sz[1]
state = hass.states.get(f"climate.system_{system}_zone_{zone}")
assert state, hass.states.async_all()
assert state.attributes["current_temperature"] == zone
# Check that the created devices are wired to each other as expected.
device_registry = dr.async_get(hass)
def find_device(name):
return next(filter(lambda x: x.name == name, device_registry.devices.values()))
sam = find_device("System Access Module")
s1 = find_device("System 1")
s2 = find_device("System 2")
s1z1 = find_device("System 1 Zone 1")
s1z2 = find_device("System 1 Zone 2")
s2z3 = find_device("System 2 Zone 3")
assert sam.via_device_id is None
assert s1.via_device_id == sam.id
assert s2.via_device_id == sam.id
assert s1z1.via_device_id == s1.id
assert s1z2.via_device_id == s1.id
assert s2z3.via_device_id == s2.id