Add Airzone Cloud integration (#93238)

* airzone-cloud: add new integration

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* Add missing aioairzone-cloud to test requirements

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* Update aioairzone-cloud to v0.0.4

Allows to handle TooManyRequests exception on coordinator.

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* aioairzone_cloud: reduce API requests

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* airzone_cloud: remove system_zone_id

As opposed to the Local API of Airzone devices, the Cloud API provides unique
IDs for both systems and zones, so we can remove the system_zone_id copied from
the Local API integration.

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* airzone_cloud: minor improvements

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* tests: airzone_cloud: simplify mock_get_webserver

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* Update aioairzone to v0.0.5

- Add token refresh and relogin support.
- Improve fetching installation devices.

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* airzone_cloud: add to strict typing

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* Update aioairzone to v0.0.7

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* trigger CI

* airzone_cloud: remove unneeded api_get_user call

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* Add Airzone brand

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* Update aioairzone to v0.1.1

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* airzone_cloud: use unique_id instead of entry_id

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* airzone_cloud: remove special handling of TooManyRequests

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* airzone_cloud: bump coordinator timeout to 30s

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* airzone_cloud: make AirzoneEntity an ABC

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* airzone_cloud: fix strings typo

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* tests: airzone_cloud: simplify webserver mock

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* Update aioairzone-cloud to v0.1.2

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

---------

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>
This commit is contained in:
Álvaro Fernández Rojas 2023-05-23 19:22:50 +02:00 committed by GitHub
parent 8bf22014ce
commit 8edb253ace
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 892 additions and 3 deletions

View File

@ -49,6 +49,7 @@ homeassistant.components.air_quality.*
homeassistant.components.airly.*
homeassistant.components.airvisual.*
homeassistant.components.airzone.*
homeassistant.components.airzone_cloud.*
homeassistant.components.aladdin_connect.*
homeassistant.components.alarm_control_panel.*
homeassistant.components.alert.*

View File

@ -59,6 +59,8 @@ build.json @home-assistant/supervisor
/tests/components/airvisual_pro/ @bachya
/homeassistant/components/airzone/ @Noltari
/tests/components/airzone/ @Noltari
/homeassistant/components/airzone_cloud/ @Noltari
/tests/components/airzone_cloud/ @Noltari
/homeassistant/components/aladdin_connect/ @mkmer
/tests/components/aladdin_connect/ @mkmer
/homeassistant/components/alarm_control_panel/ @home-assistant/core

View File

@ -0,0 +1,5 @@
{
"domain": "airzone",
"name": "Airzone",
"integrations": ["airzone", "airzone_cloud"]
}

View File

@ -0,0 +1,48 @@
"""The Airzone Cloud integration."""
from __future__ import annotations
from aioairzone_cloud.cloudapi import AirzoneCloudApi
from aioairzone_cloud.common import ConnectionOptions
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Airzone Cloud from a config entry."""
options = ConnectionOptions(
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
)
airzone = AirzoneCloudApi(aiohttp_client.async_get_clientsession(hass), options)
await airzone.login()
inst_list = await airzone.list_installations()
for inst in inst_list:
if inst.get_id() == entry.data[CONF_ID]:
airzone.select_installation(inst)
await airzone.update_installation(inst)
coordinator = AirzoneUpdateCoordinator(hass, airzone)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@ -0,0 +1,116 @@
"""Config flow for Airzone Cloud."""
from __future__ import annotations
from typing import Any
from aioairzone_cloud.cloudapi import AirzoneCloudApi
from aioairzone_cloud.common import ConnectionOptions
from aioairzone_cloud.const import AZD_ID, AZD_NAME, AZD_WEBSERVERS
from aioairzone_cloud.exceptions import AirzoneCloudError, LoginError
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import DOMAIN
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle config flow for an Airzone Cloud device."""
airzone: AirzoneCloudApi
async def async_step_inst_pick(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the installation selection."""
errors = {}
options: dict[str, str] = {}
inst_desc = None
inst_id = None
if user_input is not None:
inst_id = user_input[CONF_ID]
try:
inst_list = await self.airzone.list_installations()
except AirzoneCloudError:
errors["base"] = "cannot_connect"
else:
for inst in inst_list:
_data = inst.data()
_id = _data[AZD_ID]
options[_id] = f"{_data[AZD_NAME]} {_data[AZD_WEBSERVERS][0]} ({_id})"
if _id is not None and _id == inst_id:
inst_desc = options[_id]
if user_input is not None and inst_desc is not None:
await self.async_set_unique_id(inst_id)
self._abort_if_unique_id_configured()
user_input[CONF_USERNAME] = self.airzone.options.username
user_input[CONF_PASSWORD] = self.airzone.options.password
return self.async_create_entry(title=inst_desc, data=user_input)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ID): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(value=k, label=v)
for k, v in options.items()
],
mode=SelectSelectorMode.DROPDOWN,
)
),
}
),
errors=errors,
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors = {}
if user_input is not None:
if CONF_ID in user_input:
return await self.async_step_inst_pick(user_input)
self.airzone = AirzoneCloudApi(
aiohttp_client.async_get_clientsession(self.hass),
ConnectionOptions(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
),
)
try:
await self.airzone.login()
except (AirzoneCloudError, LoginError):
errors["base"] = "cannot_connect"
else:
return await self.async_step_inst_pick()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
),
errors=errors,
)

View File

@ -0,0 +1,8 @@
"""Constants for the Airzone Cloud integration."""
from typing import Final
DOMAIN: Final[str] = "airzone_cloud"
MANUFACTURER: Final[str] = "Airzone"
AIOAIRZONE_CLOUD_TIMEOUT_SEC: Final[int] = 30

View File

@ -0,0 +1,43 @@
"""The Airzone Cloud integration coordinator."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from aioairzone_cloud.cloudapi import AirzoneCloudApi
from aioairzone_cloud.exceptions import AirzoneCloudError
import async_timeout
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import AIOAIRZONE_CLOUD_TIMEOUT_SEC, DOMAIN
SCAN_INTERVAL = timedelta(seconds=60)
_LOGGER = logging.getLogger(__name__)
class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching data from the Airzone Cloud device."""
def __init__(self, hass: HomeAssistant, airzone: AirzoneCloudApi) -> None:
"""Initialize."""
self.airzone = airzone
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
async with async_timeout.timeout(AIOAIRZONE_CLOUD_TIMEOUT_SEC):
try:
await self.airzone.update()
except AirzoneCloudError as error:
raise UpdateFailed(error) from error
return self.airzone.data()

View File

@ -0,0 +1,54 @@
"""Entity classes for the Airzone Cloud integration."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
from aioairzone_cloud.const import AZD_NAME, AZD_SYSTEM_ID, AZD_ZONES
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import AirzoneUpdateCoordinator
class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC):
"""Define an Airzone Cloud entity."""
@abstractmethod
def get_airzone_value(self, key: str) -> Any:
"""Return Airzone Cloud entity value by key."""
class AirzoneZoneEntity(AirzoneEntity):
"""Define an Airzone Cloud Zone entity."""
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
entry: ConfigEntry,
zone_id: str,
zone_data: dict[str, Any],
) -> None:
"""Initialize."""
super().__init__(coordinator)
self.system_id = zone_data[AZD_SYSTEM_ID]
self.zone_id = zone_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{entry.unique_id}_{zone_id}")},
manufacturer=MANUFACTURER,
name=zone_data[AZD_NAME],
via_device=(DOMAIN, f"{entry.unique_id}_{self.system_id}"),
)
def get_airzone_value(self, key: str) -> Any:
"""Return zone value by key."""
value = None
if zone := self.coordinator.data[AZD_ZONES].get(self.zone_id):
if key in zone:
value = zone[key]
return value

View File

@ -0,0 +1,10 @@
{
"domain": "airzone_cloud",
"name": "Airzone Cloud",
"codeowners": ["@Noltari"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"iot_class": "cloud_polling",
"loggers": ["aioairzone_cloud"],
"requirements": ["aioairzone-cloud==0.1.2"]
}

View File

@ -0,0 +1,97 @@
"""Support for the Airzone Cloud sensors."""
from __future__ import annotations
from typing import Any, Final
from aioairzone_cloud.const import AZD_HUMIDITY, AZD_NAME, AZD_TEMP, AZD_ZONES
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator
from .entity import AirzoneEntity, AirzoneZoneEntity
ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
SensorEntityDescription(
device_class=SensorDeviceClass.TEMPERATURE,
key=AZD_TEMP,
name="Temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
device_class=SensorDeviceClass.HUMIDITY,
key=AZD_HUMIDITY,
name="Humidity",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add Airzone Cloud sensors from a config_entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
sensors = []
for zone_id, zone_data in coordinator.data[AZD_ZONES].items():
for description in ZONE_SENSOR_TYPES:
if description.key in zone_data:
sensors.append(
AirzoneZoneSensor(
coordinator,
description,
entry,
zone_id,
zone_data,
)
)
async_add_entities(sensors)
class AirzoneSensor(AirzoneEntity, SensorEntity):
"""Define an Airzone Cloud sensor."""
@callback
def _handle_coordinator_update(self) -> None:
"""Update attributes when the coordinator updates."""
self._async_update_attrs()
super()._handle_coordinator_update()
@callback
def _async_update_attrs(self) -> None:
"""Update sensor attributes."""
self._attr_native_value = self.get_airzone_value(self.entity_description.key)
class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor):
"""Define an Airzone Cloud Zone sensor."""
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
description: SensorEntityDescription,
entry: ConfigEntry,
zone_id: str,
zone_data: dict[str, Any],
) -> None:
"""Initialize."""
super().__init__(coordinator, entry, zone_id, zone_data)
self._attr_name = f"{zone_data[AZD_NAME]} {description.name}"
self._attr_unique_id = f"{entry.unique_id}_{zone_id}_{description.key}"
self.entity_description = description
self._async_update_attrs()

View File

@ -0,0 +1,19 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"user": {
"data": {
"id": "Installation",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
}
}
}

View File

@ -32,6 +32,7 @@ FLOWS = {
"airvisual",
"airvisual_pro",
"airzone",
"airzone_cloud",
"aladdin_connect",
"alarmdecoder",
"amberelectric",

View File

@ -137,9 +137,20 @@
},
"airzone": {
"name": "Airzone",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling"
"integrations": {
"airzone": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling",
"name": "Airzone"
},
"airzone_cloud": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling",
"name": "Airzone Cloud"
}
}
},
"aladdin_connect": {
"name": "Aladdin Connect",

View File

@ -251,6 +251,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.airzone_cloud.*]
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.aladdin_connect.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -115,6 +115,9 @@ aio_georss_gdacs==0.8
# homeassistant.components.airq
aioairq==0.2.4
# homeassistant.components.airzone_cloud
aioairzone-cloud==0.1.2
# homeassistant.components.airzone
aioairzone==0.5.6

View File

@ -105,6 +105,9 @@ aio_georss_gdacs==0.8
# homeassistant.components.airq
aioairq==0.2.4
# homeassistant.components.airzone_cloud
aioairzone-cloud==0.1.2
# homeassistant.components.airzone
aioairzone==0.5.6

View File

@ -0,0 +1 @@
"""Tests for the Airzone Cloud integration."""

View File

@ -0,0 +1,143 @@
"""Define tests for the Airzone Cloud config flow."""
from unittest.mock import patch
from aioairzone_cloud.exceptions import AirzoneCloudError, LoginError
from homeassistant import data_entry_flow
from homeassistant.components.airzone_cloud.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from .util import (
CONFIG,
GET_INSTALLATION_MOCK,
GET_INSTALLATIONS_MOCK,
GET_WEBSERVER_MOCK,
WS_ID,
mock_get_device_status,
)
async def test_form(hass: HomeAssistant) -> None:
"""Test that the form is served with valid input."""
with patch(
"homeassistant.components.airzone_cloud.async_setup_entry",
return_value=True,
) as mock_setup_entry, patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status",
side_effect=mock_get_device_status,
), patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installation",
return_value=GET_INSTALLATION_MOCK,
), patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations",
return_value=GET_INSTALLATIONS_MOCK,
), patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver",
return_value=GET_WEBSERVER_MOCK,
), patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.login",
return_value=None,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: CONFIG[CONF_USERNAME],
CONF_PASSWORD: CONFIG[CONF_PASSWORD],
},
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_ID: CONFIG[CONF_ID],
},
)
await hass.async_block_till_done()
conf_entries = hass.config_entries.async_entries(DOMAIN)
entry = conf_entries[0]
assert entry.state is ConfigEntryState.LOADED
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert result["title"] == f"House {WS_ID} ({CONFIG[CONF_ID]})"
assert result["data"][CONF_ID] == CONFIG[CONF_ID]
assert result["data"][CONF_USERNAME] == CONFIG[CONF_USERNAME]
assert result["data"][CONF_PASSWORD] == CONFIG[CONF_PASSWORD]
assert len(mock_setup_entry.mock_calls) == 1
async def test_installations_list_error(hass: HomeAssistant) -> None:
"""Test connection error."""
with patch(
"homeassistant.components.airzone_cloud.async_setup_entry",
return_value=True,
), patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status",
side_effect=mock_get_device_status,
), patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations",
side_effect=AirzoneCloudError,
), patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver",
return_value=GET_WEBSERVER_MOCK,
), patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.login",
return_value=None,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: CONFIG[CONF_USERNAME],
CONF_PASSWORD: CONFIG[CONF_PASSWORD],
},
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
async def test_login_error(hass: HomeAssistant) -> None:
"""Test login error."""
with patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.login",
side_effect=LoginError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data={
CONF_USERNAME: CONFIG[CONF_USERNAME],
CONF_PASSWORD: CONFIG[CONF_PASSWORD],
},
)
assert result["errors"] == {"base": "cannot_connect"}

View File

@ -0,0 +1,70 @@
"""Define tests for the Airzone Cloud coordinator."""
from unittest.mock import patch
from aioairzone_cloud.exceptions import AirzoneCloudError
from homeassistant.components.airzone_cloud.const import DOMAIN
from homeassistant.components.airzone_cloud.coordinator import SCAN_INTERVAL
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.util.dt import utcnow
from .util import (
CONFIG,
GET_INSTALLATION_MOCK,
GET_INSTALLATIONS_MOCK,
GET_WEBSERVER_MOCK,
mock_get_device_status,
)
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None:
"""Test ClientConnectorError on coordinator update."""
config_entry = MockConfigEntry(
data=CONFIG,
domain=DOMAIN,
unique_id="airzone_cloud_unique_id",
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status",
side_effect=mock_get_device_status,
) as mock_device_status, patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installation",
return_value=GET_INSTALLATION_MOCK,
) as mock_installation, patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations",
return_value=GET_INSTALLATIONS_MOCK,
) as mock_installations, patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver",
return_value=GET_WEBSERVER_MOCK,
) as mock_webserver, patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.login",
return_value=None,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
mock_device_status.assert_called()
mock_installation.assert_awaited_once()
mock_installations.assert_called_once()
mock_webserver.assert_called_once()
mock_device_status.reset_mock()
mock_installation.reset_mock()
mock_installations.reset_mock()
mock_webserver.reset_mock()
mock_device_status.side_effect = AirzoneCloudError
async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL)
await hass.async_block_till_done()
mock_device_status.assert_called()
state = hass.states.get("sensor.salon_temperature")
assert state.state == STATE_UNAVAILABLE

View File

@ -0,0 +1,43 @@
"""Define tests for the Airzone Cloud init."""
from unittest.mock import patch
from homeassistant.components.airzone_cloud.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from .util import CONFIG
from tests.common import MockConfigEntry
async def test_unload_entry(hass: HomeAssistant) -> None:
"""Test unload."""
config_entry = MockConfigEntry(
data=CONFIG,
domain=DOMAIN,
unique_id="airzone_cloud_unique_id",
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.login",
return_value=None,
), patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.list_installations",
return_value=[],
), patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.update_installation",
return_value=None,
), patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.update",
return_value=None,
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.NOT_LOADED

View File

@ -0,0 +1,26 @@
"""The sensor tests for the Airzone Cloud platform."""
from homeassistant.core import HomeAssistant
from .util import async_init_integration
async def test_airzone_create_sensors(
hass: HomeAssistant, entity_registry_enabled_by_default: None
) -> None:
"""Test creation of sensors."""
await async_init_integration(hass)
# Zones
state = hass.states.get("sensor.dormitorio_temperature")
assert state.state == "25.0"
state = hass.states.get("sensor.dormitorio_humidity")
assert state.state == "24"
state = hass.states.get("sensor.salon_temperature")
assert state.state == "20.0"
state = hass.states.get("sensor.salon_humidity")
assert state.state == "30"

View File

@ -0,0 +1,175 @@
"""Tests for the Airzone integration."""
from typing import Any
from unittest.mock import patch
from aioairzone_cloud.const import (
API_AZ_SYSTEM,
API_AZ_ZONE,
API_CELSIUS,
API_CONFIG,
API_CONNECTION_DATE,
API_DEVICE_ID,
API_DEVICES,
API_DISCONNECTION_DATE,
API_FAH,
API_GROUPS,
API_HUMIDITY,
API_INSTALLATION_ID,
API_INSTALLATIONS,
API_IS_CONNECTED,
API_LOCAL_TEMP,
API_META,
API_NAME,
API_STAT_AP_MAC,
API_STAT_CHANNEL,
API_STAT_QUALITY,
API_STAT_SSID,
API_STATUS,
API_SYSTEM_NUMBER,
API_TYPE,
API_WS_FW,
API_WS_ID,
API_WS_IDS,
API_WS_TYPE,
API_ZONE_NUMBER,
)
from aioairzone_cloud.device import Device
from homeassistant.components.airzone_cloud import DOMAIN
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
WS_ID = "11:22:33:44:55:66"
CONFIG = {
CONF_ID: "inst1",
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
}
GET_INSTALLATION_MOCK = {
API_GROUPS: [
{
API_NAME: "Group",
API_DEVICES: [
{
API_DEVICE_ID: "system1",
API_TYPE: API_AZ_SYSTEM,
API_META: {
API_SYSTEM_NUMBER: 1,
},
API_WS_ID: WS_ID,
},
{
API_DEVICE_ID: "zone1",
API_NAME: "Salon",
API_TYPE: API_AZ_ZONE,
API_META: {
API_SYSTEM_NUMBER: 1,
API_ZONE_NUMBER: 1,
},
API_WS_ID: WS_ID,
},
{
API_DEVICE_ID: "zone2",
API_NAME: "Dormitorio",
API_TYPE: API_AZ_ZONE,
API_META: {
API_SYSTEM_NUMBER: 1,
API_ZONE_NUMBER: 2,
},
API_WS_ID: WS_ID,
},
],
},
],
}
GET_INSTALLATIONS_MOCK = {
API_INSTALLATIONS: [
{
API_INSTALLATION_ID: CONFIG[CONF_ID],
API_NAME: "House",
API_WS_IDS: [
WS_ID,
],
},
],
}
GET_WEBSERVER_MOCK = {
API_WS_TYPE: "ws_az",
API_CONFIG: {
API_WS_FW: "3.44",
API_STAT_SSID: "Wifi",
API_STAT_CHANNEL: 36,
API_STAT_AP_MAC: "00:00:00:00:00:00",
},
API_STATUS: {
API_IS_CONNECTED: True,
API_STAT_QUALITY: 4,
API_CONNECTION_DATE: "2023-05-07T12:55:51.000Z",
API_DISCONNECTION_DATE: "2023-01-01T22:26:55.376Z",
},
}
def mock_get_device_status(device: Device) -> dict[str, Any]:
"""Mock API device status."""
if device.get_id() == "system1":
return {
API_IS_CONNECTED: True,
}
if device.get_id() == "zone2":
return {
API_HUMIDITY: 24,
API_IS_CONNECTED: True,
API_LOCAL_TEMP: {
API_FAH: 77,
API_CELSIUS: 25,
},
}
return {
API_HUMIDITY: 30,
API_IS_CONNECTED: True,
API_LOCAL_TEMP: {
API_FAH: 68,
API_CELSIUS: 20,
},
}
async def async_init_integration(
hass: HomeAssistant,
) -> None:
"""Set up the Airzone integration in Home Assistant."""
config_entry = MockConfigEntry(
data=CONFIG,
domain=DOMAIN,
unique_id="airzone_cloud_unique_id",
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status",
side_effect=mock_get_device_status,
), patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installation",
return_value=GET_INSTALLATION_MOCK,
), patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations",
return_value=GET_INSTALLATIONS_MOCK,
), patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver",
return_value=GET_WEBSERVER_MOCK,
), patch(
"homeassistant.components.airzone_cloud.AirzoneCloudApi.login",
return_value=None,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()