* 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>
252 lines
9.3 KiB
Python
252 lines
9.3 KiB
Python
"""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()
|