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

@ -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
)