mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 16:57:53 +00:00
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:
parent
686598b6b3
commit
fa61ad072d
@ -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.*
|
||||
|
@ -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
|
||||
|
84
homeassistant/components/bryant_evolution/__init__.py
Normal file
84
homeassistant/components/bryant_evolution/__init__.py
Normal 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)
|
252
homeassistant/components/bryant_evolution/climate.py
Normal file
252
homeassistant/components/bryant_evolution/climate.py
Normal 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()
|
87
homeassistant/components/bryant_evolution/config_flow.py
Normal file
87
homeassistant/components/bryant_evolution/config_flow.py
Normal 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
|
||||
)
|
4
homeassistant/components/bryant_evolution/const.py
Normal file
4
homeassistant/components/bryant_evolution/const.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""Constants for the Bryant Evolution integration."""
|
||||
|
||||
DOMAIN = "bryant_evolution"
|
||||
CONF_SYSTEM_ZONE = "system_zone"
|
10
homeassistant/components/bryant_evolution/manifest.json
Normal file
10
homeassistant/components/bryant_evolution/manifest.json
Normal 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"]
|
||||
}
|
18
homeassistant/components/bryant_evolution/names.py
Normal file
18
homeassistant/components/bryant_evolution/names.py
Normal 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}"
|
48
homeassistant/components/bryant_evolution/strings.json
Normal file
48
homeassistant/components/bryant_evolution/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
@ -93,6 +93,7 @@ FLOWS = {
|
||||
"brother",
|
||||
"brottsplatskartan",
|
||||
"brunt",
|
||||
"bryant_evolution",
|
||||
"bsblan",
|
||||
"bthome",
|
||||
"buienradar",
|
||||
|
@ -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",
|
||||
|
10
mypy.ini
10
mypy.ini
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
1
tests/components/bryant_evolution/__init__.py
Normal file
1
tests/components/bryant_evolution/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the Bryant Evolution integration."""
|
70
tests/components/bryant_evolution/conftest.py
Normal file
70
tests/components/bryant_evolution/conftest.py
Normal 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
|
@ -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',
|
||||
})
|
||||
# ---
|
259
tests/components/bryant_evolution/test_climate.py
Normal file
259
tests/components/bryant_evolution/test_climate.py
Normal 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
|
||||
)
|
170
tests/components/bryant_evolution/test_config_flow.py
Normal file
170
tests/components/bryant_evolution/test_config_flow.py
Normal 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),
|
||||
]
|
112
tests/components/bryant_evolution/test_init.py
Normal file
112
tests/components/bryant_evolution/test_init.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user