Improve Atag integration and bump version to 0.3.5.3 (#47778)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
MatsNl 2021-03-12 07:15:45 +01:00 committed by GitHub
parent f4b775b125
commit fa0c544bf5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 160 additions and 182 deletions

View File

@ -31,17 +31,34 @@ async def async_setup(hass: HomeAssistant, config):
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Atag integration from a config entry."""
session = async_get_clientsession(hass)
coordinator = AtagDataUpdateCoordinator(hass, session, entry)
async def _async_update_data():
"""Update data via library."""
with async_timeout.timeout(20):
try:
await atag.update()
except AtagException as err:
raise UpdateFailed(err) from err
return atag
atag = AtagOne(
session=async_get_clientsession(hass), **entry.data, device=entry.unique_id
)
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name=DOMAIN.title(),
update_method=_async_update_data,
update_interval=timedelta(seconds=60),
)
await coordinator.async_refresh()
if not coordinator.last_update_success:
raise ConfigEntryNotReady
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
if entry.unique_id is None:
hass.config_entries.async_update_entry(entry, unique_id=coordinator.atag.id)
hass.config_entries.async_update_entry(entry, unique_id=atag.id)
for platform in PLATFORMS:
hass.async_create_task(
@ -51,28 +68,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
return True
class AtagDataUpdateCoordinator(DataUpdateCoordinator):
"""Define an object to hold Atag data."""
def __init__(self, hass, session, entry):
"""Initialize."""
self.atag = AtagOne(session=session, **entry.data)
super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=30)
)
async def _async_update_data(self):
"""Update data via library."""
with async_timeout.timeout(20):
try:
if not await self.atag.update():
raise UpdateFailed("No data received")
except AtagException as error:
raise UpdateFailed(error) from error
return self.atag.report
async def async_unload_entry(hass, entry):
"""Unload Atag config entry."""
unload_ok = all(
@ -91,7 +86,7 @@ async def async_unload_entry(hass, entry):
class AtagEntity(CoordinatorEntity):
"""Defines a base Atag entity."""
def __init__(self, coordinator: AtagDataUpdateCoordinator, atag_id: str) -> None:
def __init__(self, coordinator: DataUpdateCoordinator, atag_id: str) -> None:
"""Initialize the Atag entity."""
super().__init__(coordinator)
@ -101,8 +96,8 @@ class AtagEntity(CoordinatorEntity):
@property
def device_info(self) -> dict:
"""Return info for device registry."""
device = self.coordinator.atag.id
version = self.coordinator.atag.apiversion
device = self.coordinator.data.id
version = self.coordinator.data.apiversion
return {
"identifiers": {(DOMAIN, device)},
"name": "Atag Thermostat",
@ -119,4 +114,4 @@ class AtagEntity(CoordinatorEntity):
@property
def unique_id(self):
"""Return a unique ID to use for this entity."""
return f"{self.coordinator.atag.id}-{self._id}"
return f"{self.coordinator.data.id}-{self._id}"

View File

@ -16,16 +16,14 @@ from homeassistant.const import ATTR_TEMPERATURE
from . import CLIMATE, DOMAIN, AtagEntity
PRESET_SCHEDULE = "Auto"
PRESET_MANUAL = "Manual"
PRESET_EXTEND = "Extend"
SUPPORT_PRESET = [
PRESET_MANUAL,
PRESET_SCHEDULE,
PRESET_EXTEND,
PRESET_AWAY,
PRESET_BOOST,
]
PRESET_MAP = {
"Manual": "manual",
"Auto": "automatic",
"Extend": "extend",
PRESET_AWAY: "vacation",
PRESET_BOOST: "fireplace",
}
PRESET_INVERTED = {v: k for k, v in PRESET_MAP.items()}
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
HVAC_MODES = [HVAC_MODE_AUTO, HVAC_MODE_HEAT]
@ -47,8 +45,8 @@ class AtagThermostat(AtagEntity, ClimateEntity):
@property
def hvac_mode(self) -> Optional[str]:
"""Return hvac operation ie. heat, cool mode."""
if self.coordinator.atag.climate.hvac_mode in HVAC_MODES:
return self.coordinator.atag.climate.hvac_mode
if self.coordinator.data.climate.hvac_mode in HVAC_MODES:
return self.coordinator.data.climate.hvac_mode
return None
@property
@ -59,46 +57,46 @@ class AtagThermostat(AtagEntity, ClimateEntity):
@property
def hvac_action(self) -> Optional[str]:
"""Return the current running hvac operation."""
if self.coordinator.atag.climate.status:
return CURRENT_HVAC_HEAT
return CURRENT_HVAC_IDLE
is_active = self.coordinator.data.climate.status
return CURRENT_HVAC_HEAT if is_active else CURRENT_HVAC_IDLE
@property
def temperature_unit(self):
def temperature_unit(self) -> Optional[str]:
"""Return the unit of measurement."""
return self.coordinator.atag.climate.temp_unit
return self.coordinator.data.climate.temp_unit
@property
def current_temperature(self) -> Optional[float]:
"""Return the current temperature."""
return self.coordinator.atag.climate.temperature
return self.coordinator.data.climate.temperature
@property
def target_temperature(self) -> Optional[float]:
"""Return the temperature we try to reach."""
return self.coordinator.atag.climate.target_temperature
return self.coordinator.data.climate.target_temperature
@property
def preset_mode(self) -> Optional[str]:
"""Return the current preset mode, e.g., auto, manual, fireplace, extend, etc."""
return self.coordinator.atag.climate.preset_mode
preset = self.coordinator.data.climate.preset_mode
return PRESET_INVERTED.get(preset)
@property
def preset_modes(self) -> Optional[List[str]]:
"""Return a list of available preset modes."""
return SUPPORT_PRESET
return list(PRESET_MAP.keys())
async def async_set_temperature(self, **kwargs) -> None:
"""Set new target temperature."""
await self.coordinator.atag.climate.set_temp(kwargs.get(ATTR_TEMPERATURE))
await self.coordinator.data.climate.set_temp(kwargs.get(ATTR_TEMPERATURE))
self.async_write_ha_state()
async def async_set_hvac_mode(self, hvac_mode: str) -> None:
"""Set new target hvac mode."""
await self.coordinator.atag.climate.set_hvac_mode(hvac_mode)
await self.coordinator.data.climate.set_hvac_mode(hvac_mode)
self.async_write_ha_state()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
await self.coordinator.atag.climate.set_preset_mode(preset_mode)
await self.coordinator.data.climate.set_preset_mode(PRESET_MAP[preset_mode])
self.async_write_ha_state()

View File

@ -3,14 +3,13 @@ import pyatag
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_EMAIL, CONF_HOST, CONF_PORT
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from . import DOMAIN # pylint: disable=unused-import
DATA_SCHEMA = {
vol.Required(CONF_HOST): str,
vol.Optional(CONF_EMAIL): str,
vol.Required(CONF_PORT, default=pyatag.const.DEFAULT_PORT): vol.Coerce(int),
}
@ -26,15 +25,14 @@ class AtagConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if not user_input:
return await self._show_form()
session = async_get_clientsession(self.hass)
try:
atag = pyatag.AtagOne(session=session, **user_input)
await atag.authorize()
await atag.update(force=True)
except pyatag.errors.Unauthorized:
atag = pyatag.AtagOne(session=async_get_clientsession(self.hass), **user_input)
try:
await atag.update()
except pyatag.Unauthorized:
return await self._show_form({"base": "unauthorized"})
except pyatag.errors.AtagException:
except pyatag.AtagException:
return await self._show_form({"base": "cannot_connect"})
await self.async_set_unique_id(atag.id)

View File

@ -3,6 +3,6 @@
"name": "Atag",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/atag/",
"requirements": ["pyatag==0.3.4.4"],
"requirements": ["pyatag==0.3.5.3"],
"codeowners": ["@MatsNL"]
}

View File

@ -26,10 +26,7 @@ SENSORS = {
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Initialize sensor platform from config entry."""
coordinator = hass.data[DOMAIN][config_entry.entry_id]
entities = []
for sensor in SENSORS:
entities.append(AtagSensor(coordinator, sensor))
async_add_entities(entities)
async_add_entities([AtagSensor(coordinator, sensor) for sensor in SENSORS])
class AtagSensor(AtagEntity):
@ -43,32 +40,32 @@ class AtagSensor(AtagEntity):
@property
def state(self):
"""Return the state of the sensor."""
return self.coordinator.data[self._id].state
return self.coordinator.data.report[self._id].state
@property
def icon(self):
"""Return icon."""
return self.coordinator.data[self._id].icon
return self.coordinator.data.report[self._id].icon
@property
def device_class(self):
"""Return deviceclass."""
if self.coordinator.data[self._id].sensorclass in [
if self.coordinator.data.report[self._id].sensorclass in [
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
]:
return self.coordinator.data[self._id].sensorclass
return self.coordinator.data.report[self._id].sensorclass
return None
@property
def unit_of_measurement(self):
"""Return measure."""
if self.coordinator.data[self._id].measure in [
if self.coordinator.data.report[self._id].measure in [
PRESSURE_BAR,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
PERCENTAGE,
TIME_HOURS,
]:
return self.coordinator.data[self._id].measure
return self.coordinator.data.report[self._id].measure
return None

View File

@ -5,7 +5,6 @@
"title": "Connect to the device",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"email": "[%key:common::config_flow::data::email%]",
"port": "[%key:common::config_flow::data::port%]"
}
}

View File

@ -35,12 +35,12 @@ class AtagWaterHeater(AtagEntity, WaterHeaterEntity):
@property
def current_temperature(self):
"""Return the current temperature."""
return self.coordinator.atag.dhw.temperature
return self.coordinator.data.dhw.temperature
@property
def current_operation(self):
"""Return current operation."""
operation = self.coordinator.atag.dhw.current_operation
operation = self.coordinator.data.dhw.current_operation
return operation if operation in self.operation_list else STATE_OFF
@property
@ -50,20 +50,20 @@ class AtagWaterHeater(AtagEntity, WaterHeaterEntity):
async def async_set_temperature(self, **kwargs):
"""Set new target temperature."""
if await self.coordinator.atag.dhw.set_temp(kwargs.get(ATTR_TEMPERATURE)):
if await self.coordinator.data.dhw.set_temp(kwargs.get(ATTR_TEMPERATURE)):
self.async_write_ha_state()
@property
def target_temperature(self):
"""Return the setpoint if water demand, otherwise return base temp (comfort level)."""
return self.coordinator.atag.dhw.target_temperature
return self.coordinator.data.dhw.target_temperature
@property
def max_temp(self):
"""Return the maximum temperature."""
return self.coordinator.atag.dhw.max_temp
return self.coordinator.data.dhw.max_temp
@property
def min_temp(self):
"""Return the minimum temperature."""
return self.coordinator.atag.dhw.min_temp
return self.coordinator.data.dhw.min_temp

View File

@ -1269,7 +1269,7 @@ pyalmond==0.0.2
pyarlo==0.2.4
# homeassistant.components.atag
pyatag==0.3.4.4
pyatag==0.3.5.3
# homeassistant.components.netatmo
pyatmo==4.2.2

View File

@ -667,7 +667,7 @@ pyalmond==0.0.2
pyarlo==0.2.4
# homeassistant.components.atag
pyatag==0.3.4.4
pyatag==0.3.5.3
# homeassistant.components.netatmo
pyatmo==4.2.2

View File

@ -1,7 +1,7 @@
"""Tests for the Atag integration."""
from homeassistant.components.atag import DOMAIN
from homeassistant.const import CONF_EMAIL, CONF_HOST, CONF_PORT, CONTENT_TYPE_JSON
from homeassistant.components.atag import DOMAIN, AtagException
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@ -9,12 +9,15 @@ from tests.test_util.aiohttp import AiohttpClientMocker
USER_INPUT = {
CONF_HOST: "127.0.0.1",
CONF_EMAIL: "atag@domain.com",
CONF_PORT: 10000,
}
UID = "xxxx-xxxx-xxxx_xx-xx-xxx-xxx"
PAIR_REPLY = {"pair_reply": {"status": {"device_id": UID}, "acc_status": 2}}
UPDATE_REPLY = {"update_reply": {"status": {"device_id": UID}, "acc_status": 2}}
AUTHORIZED = 2
UNAUTHORIZED = 3
PAIR_REPLY = {"pair_reply": {"status": {"device_id": UID}, "acc_status": AUTHORIZED}}
UPDATE_REPLY = {
"update_reply": {"status": {"device_id": UID}, "acc_status": AUTHORIZED}
}
RECEIVE_REPLY = {
"retrieve_reply": {
"status": {"device_id": UID},
@ -46,35 +49,52 @@ RECEIVE_REPLY = {
"dhw_max_set": 65,
"dhw_min_set": 40,
},
"acc_status": 2,
"acc_status": AUTHORIZED,
}
}
def mock_connection(
aioclient_mock: AiohttpClientMocker, authorized=True, conn_error=False
) -> None:
"""Mock the requests to Atag endpoint."""
if conn_error:
aioclient_mock.post(
"http://127.0.0.1:10000/pair",
exc=AtagException,
)
aioclient_mock.post(
"http://127.0.0.1:10000/retrieve",
exc=AtagException,
)
return
PAIR_REPLY["pair_reply"].update(
{"acc_status": AUTHORIZED if authorized else UNAUTHORIZED}
)
RECEIVE_REPLY["retrieve_reply"].update(
{"acc_status": AUTHORIZED if authorized else UNAUTHORIZED}
)
aioclient_mock.post(
"http://127.0.0.1:10000/retrieve",
json=RECEIVE_REPLY,
)
aioclient_mock.post(
"http://127.0.0.1:10000/update",
json=UPDATE_REPLY,
)
aioclient_mock.post(
"http://127.0.0.1:10000/pair",
json=PAIR_REPLY,
)
async def init_integration(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
rgbw: bool = False,
skip_setup: bool = False,
) -> MockConfigEntry:
"""Set up the Atag integration in Home Assistant."""
aioclient_mock.post(
"http://127.0.0.1:10000/retrieve",
json=RECEIVE_REPLY,
headers={"Content-Type": CONTENT_TYPE_JSON},
)
aioclient_mock.post(
"http://127.0.0.1:10000/update",
json=UPDATE_REPLY,
headers={"Content-Type": CONTENT_TYPE_JSON},
)
aioclient_mock.post(
"http://127.0.0.1:10000/pair",
json=PAIR_REPLY,
headers={"Content-Type": CONTENT_TYPE_JSON},
)
mock_connection(aioclient_mock)
entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT)
entry.add_to_hass(hass)

View File

@ -1,7 +1,7 @@
"""Tests for the Atag climate platform."""
from unittest.mock import PropertyMock, patch
from homeassistant.components.atag import CLIMATE, DOMAIN
from homeassistant.components.atag.climate import CLIMATE, DOMAIN, PRESET_MAP
from homeassistant.components.climate import (
ATTR_HVAC_ACTION,
ATTR_HVAC_MODE,
@ -11,11 +11,8 @@ from homeassistant.components.climate import (
SERVICE_SET_PRESET_MODE,
SERVICE_SET_TEMPERATURE,
)
from homeassistant.components.climate.const import CURRENT_HVAC_HEAT, PRESET_AWAY
from homeassistant.components.homeassistant import (
DOMAIN as HA_DOMAIN,
SERVICE_UPDATE_ENTITY,
)
from homeassistant.components.climate.const import CURRENT_HVAC_IDLE, PRESET_AWAY
from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@ -31,17 +28,13 @@ async def test_climate(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the creation and values of Atag climate device."""
with patch("pyatag.entities.Climate.status"):
entry = await init_integration(hass, aioclient_mock)
registry = er.async_get(hass)
await init_integration(hass, aioclient_mock)
entity_registry = er.async_get(hass)
assert registry.async_is_registered(CLIMATE_ID)
entry = registry.async_get(CLIMATE_ID)
assert entry.unique_id == f"{UID}-{CLIMATE}"
assert (
hass.states.get(CLIMATE_ID).attributes[ATTR_HVAC_ACTION]
== CURRENT_HVAC_HEAT
)
assert entity_registry.async_is_registered(CLIMATE_ID)
entity = entity_registry.async_get(CLIMATE_ID)
assert entity.unique_id == f"{UID}-{CLIMATE}"
assert hass.states.get(CLIMATE_ID).attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE
async def test_setting_climate(
@ -67,7 +60,7 @@ async def test_setting_climate(
blocking=True,
)
await hass.async_block_till_done()
mock_set_preset.assert_called_once_with(PRESET_AWAY)
mock_set_preset.assert_called_once_with(PRESET_MAP[PRESET_AWAY])
with patch("pyatag.entities.Climate.set_hvac_mode") as mock_set_hvac:
await hass.services.async_call(
@ -93,18 +86,18 @@ async def test_incorrect_modes(
assert hass.states.get(CLIMATE_ID).state == STATE_UNKNOWN
async def test_update_service(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
async def test_update_failed(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test the updater service is called."""
await init_integration(hass, aioclient_mock)
"""Test data is not destroyed on update failure."""
entry = await init_integration(hass, aioclient_mock)
await async_setup_component(hass, HA_DOMAIN, {})
with patch("pyatag.AtagOne.update") as updater:
await hass.services.async_call(
HA_DOMAIN,
SERVICE_UPDATE_ENTITY,
{ATTR_ENTITY_ID: CLIMATE_ID},
blocking=True,
)
assert hass.states.get(CLIMATE_ID).state == HVAC_MODE_HEAT
coordinator = hass.data[DOMAIN][entry.entry_id]
with patch("pyatag.AtagOne.update", side_effect=TimeoutError) as updater:
await coordinator.async_refresh()
await hass.async_block_till_done()
updater.assert_called_once()
assert not coordinator.last_update_success
assert coordinator.data.id == UID

View File

@ -1,24 +1,18 @@
"""Tests for the Atag config flow."""
from unittest.mock import PropertyMock, patch
from pyatag import errors
from homeassistant import config_entries, data_entry_flow
from homeassistant.components.atag import DOMAIN
from homeassistant.core import HomeAssistant
from tests.components.atag import (
PAIR_REPLY,
RECEIVE_REPLY,
UID,
USER_INPUT,
init_integration,
)
from . import UID, USER_INPUT, init_integration, mock_connection
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_show_form(hass):
async def test_show_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker):
"""Test that the form is served with no input."""
mock_connection(aioclient_mock)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@ -48,28 +42,30 @@ async def test_adding_second_device(
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
async def test_connection_error(hass):
async def test_connection_error(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
):
"""Test we show user form on Atag connection error."""
with patch("pyatag.AtagOne.authorize", side_effect=errors.AtagException()):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=USER_INPUT,
)
mock_connection(aioclient_mock, conn_error=True)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=USER_INPUT,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
async def test_unauthorized(hass):
async def test_unauthorized(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker):
"""Test we show correct form when Unauthorized error is raised."""
with patch("pyatag.AtagOne.authorize", side_effect=errors.Unauthorized()):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=USER_INPUT,
)
mock_connection(aioclient_mock, authorized=False)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=USER_INPUT,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "unauthorized"}
@ -79,14 +75,7 @@ async def test_full_flow_implementation(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test registering an integration and finishing flow works."""
aioclient_mock.post(
"http://127.0.0.1:10000/pair",
json=PAIR_REPLY,
)
aioclient_mock.post(
"http://127.0.0.1:10000/retrieve",
json=RECEIVE_REPLY,
)
mock_connection(aioclient_mock)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},

View File

@ -1,13 +1,11 @@
"""Tests for the ATAG integration."""
from unittest.mock import patch
import aiohttp
from homeassistant.components.atag import DOMAIN
from homeassistant.config_entries import ENTRY_STATE_SETUP_RETRY
from homeassistant.core import HomeAssistant
from tests.components.atag import init_integration
from . import init_integration, mock_connection
from tests.test_util.aiohttp import AiohttpClientMocker
@ -15,20 +13,11 @@ async def test_config_entry_not_ready(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test configuration entry not ready on library error."""
aioclient_mock.post("http://127.0.0.1:10000/retrieve", exc=aiohttp.ClientError)
mock_connection(aioclient_mock, conn_error=True)
entry = await init_integration(hass, aioclient_mock)
assert entry.state == ENTRY_STATE_SETUP_RETRY
async def test_config_entry_empty_reply(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test configuration entry not ready when library returns False."""
with patch("pyatag.AtagOne.update", return_value=False):
entry = await init_integration(hass, aioclient_mock)
assert entry.state == ENTRY_STATE_SETUP_RETRY
async def test_unload_config_entry(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None: