diff --git a/CODEOWNERS b/CODEOWNERS index 6d145a064a1..3313fd7bfb9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -45,6 +45,8 @@ homeassistant/components/airtouch4/* @LonePurpleWolf tests/components/airtouch4/* @LonePurpleWolf homeassistant/components/airvisual/* @bachya tests/components/airvisual/* @bachya +homeassistant/components/airzone/* @Noltari +tests/components/airzone/* @Noltari homeassistant/components/alarm_control_panel/* @home-assistant/core tests/components/alarm_control_panel/* @home-assistant/core homeassistant/components/alert/* @home-assistant/core diff --git a/homeassistant/components/airzone/__init__.py b/homeassistant/components/airzone/__init__.py new file mode 100644 index 00000000000..183a759122a --- /dev/null +++ b/homeassistant/components/airzone/__init__.py @@ -0,0 +1,65 @@ +"""The Airzone integration.""" +from __future__ import annotations + +from aioairzone.common import ConnectionOptions +from aioairzone.localapi_device import AirzoneLocalApi + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import AirzoneUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +class AirzoneEntity(CoordinatorEntity): + """Define an Airzone entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + entry: ConfigEntry, + system_zone_id: str, + zone_name: str, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._attr_device_info: DeviceInfo = { + "identifiers": {(DOMAIN, f"{entry.entry_id}_{system_zone_id}")}, + "manufacturer": MANUFACTURER, + "name": f"Airzone [{system_zone_id}] {zone_name}", + } + self.system_zone_id = system_zone_id + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Airzone from a config entry.""" + options = ConnectionOptions( + entry.data[CONF_HOST], + entry.data[CONF_PORT], + ) + + airzone = AirzoneLocalApi(aiohttp_client.async_get_clientsession(hass), options) + + coordinator = AirzoneUpdateCoordinator(hass, airzone) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(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 diff --git a/homeassistant/components/airzone/config_flow.py b/homeassistant/components/airzone/config_flow.py new file mode 100644 index 00000000000..c78f43a7db7 --- /dev/null +++ b/homeassistant/components/airzone/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for Airzone.""" +from __future__ import annotations + +from typing import Any + +from aioairzone.common import ConnectionOptions +from aioairzone.exceptions import InvalidHost +from aioairzone.localapi_device import AirzoneLocalApi +from aiohttp.client_exceptions import ClientConnectorError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import DEFAULT_LOCAL_API_PORT, DOMAIN + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle config flow for an Airzone device.""" + + 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: + self._async_abort_entries_match( + { + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + } + ) + + try: + airzone = AirzoneLocalApi( + aiohttp_client.async_get_clientsession(self.hass), + ConnectionOptions( + user_input[CONF_HOST], + user_input[CONF_PORT], + ), + ) + await airzone.validate_airzone() + except (ClientConnectorError, InvalidHost): + errors["base"] = "cannot_connect" + else: + title = f"Airzone {user_input[CONF_HOST]}:{user_input[CONF_PORT]}" + return self.async_create_entry(title=title, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_LOCAL_API_PORT): int, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/airzone/const.py b/homeassistant/components/airzone/const.py new file mode 100644 index 00000000000..8c48cc1aca1 --- /dev/null +++ b/homeassistant/components/airzone/const.py @@ -0,0 +1,9 @@ +"""Constants for the Airzone integration.""" + +from typing import Final + +DOMAIN: Final = "airzone" +MANUFACTURER: Final = "Airzone" + +AIOAIRZONE_DEVICE_TIMEOUT_SEC: Final = 10 +DEFAULT_LOCAL_API_PORT: Final = 3000 diff --git a/homeassistant/components/airzone/coordinator.py b/homeassistant/components/airzone/coordinator.py new file mode 100644 index 00000000000..b12305e7913 --- /dev/null +++ b/homeassistant/components/airzone/coordinator.py @@ -0,0 +1,42 @@ +"""The Airzone integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from aioairzone.localapi_device import AirzoneLocalApi +from aiohttp.client_exceptions import ClientConnectorError +import async_timeout + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import AIOAIRZONE_DEVICE_TIMEOUT_SEC, DOMAIN + +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + + +class AirzoneUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the Airzone device.""" + + def __init__(self, hass: HomeAssistant, airzone: AirzoneLocalApi) -> None: + """Initialize.""" + self.airzone = airzone + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self): + """Update data via library.""" + async with async_timeout.timeout(AIOAIRZONE_DEVICE_TIMEOUT_SEC): + try: + await self.airzone.update_airzone() + return self.airzone.data() + except ClientConnectorError as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json new file mode 100644 index 00000000000..c6836a63ee5 --- /dev/null +++ b/homeassistant/components/airzone/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "airzone", + "name": "Airzone", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airzone", + "requirements": ["aioairzone==0.0.2"], + "codeowners": ["@Noltari"], + "iot_class": "local_polling", + "loggers": ["aioairzone"] +} diff --git a/homeassistant/components/airzone/sensor.py b/homeassistant/components/airzone/sensor.py new file mode 100644 index 00000000000..e860eba1ad1 --- /dev/null +++ b/homeassistant/components/airzone/sensor.py @@ -0,0 +1,95 @@ +"""Support for the Airzone sensors.""" +from __future__ import annotations + +from typing import Final + +from aioairzone.const import AZD_HUMIDITY, AZD_NAME, AZD_TEMP, AZD_ZONES + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + PERCENTAGE, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import AirzoneEntity +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator + +SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + device_class=DEVICE_CLASS_TEMPERATURE, + key=AZD_TEMP, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + device_class=DEVICE_CLASS_HUMIDITY, + key=AZD_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Airzone sensors from a config_entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + sensors = [] + for system_zone_id, zone_data in coordinator.data[AZD_ZONES].items(): + zone_name = zone_data[AZD_NAME] + + for description in SENSOR_TYPES: + if description.key in zone_data: + sensors.append( + AirzoneSensor( + coordinator, + description, + entry, + system_zone_id, + zone_name, + ) + ) + + async_add_entities(sensors) + + +class AirzoneSensor(AirzoneEntity, SensorEntity): + """Define an Airzone sensor.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: SensorEntityDescription, + entry: ConfigEntry, + system_zone_id: str, + zone_name: str, + ) -> None: + """Initialize.""" + super().__init__(coordinator, entry, system_zone_id, zone_name) + self._attr_name = f"{zone_name} {description.name}" + self._attr_unique_id = f"{entry.entry_id}_{system_zone_id}_{description.key}" + self.entity_description = description + + @property + def native_value(self): + """Return the state.""" + value = None + if self.system_zone_id in self.coordinator.data[AZD_ZONES]: + zone = self.coordinator.data[AZD_ZONES][self.system_zone_id] + if self.entity_description.key in zone: + value = zone[self.entity_description.key] + return value diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json new file mode 100644 index 00000000000..e6ee49a6786 --- /dev/null +++ b/homeassistant/components/airzone/strings.json @@ -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": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "description": "Set up Airzone integration." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airzone/translations/en.json b/homeassistant/components/airzone/translations/en.json new file mode 100644 index 00000000000..b24e62fa34e --- /dev/null +++ b/homeassistant/components/airzone/translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Port" + }, + "description": "Set up Airzone integration." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 195f09c91a1..7e94ae5a165 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -19,6 +19,7 @@ FLOWS = [ "airthings", "airtouch4", "airvisual", + "airzone", "alarmdecoder", "almond", "ambee", diff --git a/requirements_all.txt b/requirements_all.txt index 6aa1319bef0..e318f61ccfe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -106,6 +106,9 @@ aio_geojson_nsw_rfs_incidents==0.4 # homeassistant.components.gdacs aio_georss_gdacs==0.5 +# homeassistant.components.airzone +aioairzone==0.0.2 + # homeassistant.components.ambient_station aioambient==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a2738303a03..f1aa2224391 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -87,6 +87,9 @@ aio_geojson_nsw_rfs_incidents==0.4 # homeassistant.components.gdacs aio_georss_gdacs==0.5 +# homeassistant.components.airzone +aioairzone==0.0.2 + # homeassistant.components.ambient_station aioambient==2021.11.0 diff --git a/tests/components/airzone/__init__.py b/tests/components/airzone/__init__.py new file mode 100644 index 00000000000..1d38439991b --- /dev/null +++ b/tests/components/airzone/__init__.py @@ -0,0 +1 @@ +"""Tests for the Airzone integration.""" diff --git a/tests/components/airzone/test_config_flow.py b/tests/components/airzone/test_config_flow.py new file mode 100644 index 00000000000..bb878ea6fc8 --- /dev/null +++ b/tests/components/airzone/test_config_flow.py @@ -0,0 +1,82 @@ +"""Define tests for the Airzone config flow.""" + +from unittest.mock import MagicMock, patch + +from aiohttp.client_exceptions import ClientConnectorError + +from homeassistant import data_entry_flow +from homeassistant.components.airzone.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PORT + +from .util import CONFIG, HVAC_MOCK + +from tests.common import MockConfigEntry + + +async def test_form(hass): + """Test that the form is served with valid input.""" + + with patch( + "homeassistant.components.airzone.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "aioairzone.localapi_device.AirzoneLocalApi.get_hvac", + return_value=HVAC_MOCK, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG + ) + + 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.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == f"Airzone {CONFIG[CONF_HOST]}:{CONFIG[CONF_PORT]}" + assert result["data"][CONF_HOST] == CONFIG[CONF_HOST] + assert result["data"][CONF_PORT] == CONFIG[CONF_PORT] + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_duplicated_id(hass): + """Test setting up duplicated entry.""" + + with patch( + "aioairzone.localapi_device.AirzoneLocalApi.get_hvac", + return_value=HVAC_MOCK, + ): + entry = MockConfigEntry(domain=DOMAIN, data=CONFIG) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_connection_error(hass): + """Test connection to host error.""" + + with patch( + "aioairzone.localapi_device.AirzoneLocalApi.validate_airzone", + side_effect=ClientConnectorError(MagicMock(), MagicMock()), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/airzone/test_init.py b/tests/components/airzone/test_init.py new file mode 100644 index 00000000000..8443148af65 --- /dev/null +++ b/tests/components/airzone/test_init.py @@ -0,0 +1,31 @@ +"""Define tests for the Airzone init.""" + +from unittest.mock import patch + +from homeassistant.components.airzone.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState + +from .util import CONFIG, HVAC_MOCK + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass): + """Test unload.""" + + with patch( + "aioairzone.localapi_device.AirzoneLocalApi.get_hvac", + return_value=HVAC_MOCK, + ): + config_entry = MockConfigEntry( + domain=DOMAIN, unique_id="airzone_unique_id", data=CONFIG + ) + config_entry.add_to_hass(hass) + + 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 diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py new file mode 100644 index 00000000000..fc03d8a3301 --- /dev/null +++ b/tests/components/airzone/test_sensor.py @@ -0,0 +1,39 @@ +"""The sensor tests for the Airzone platform.""" + +from .util import async_init_integration + + +async def test_airzone_create_sensors(hass): + """Test creation of sensors.""" + + await async_init_integration(hass) + + state = hass.states.get("sensor.despacho_temperature") + assert state.state == "21.2" + + state = hass.states.get("sensor.despacho_humidity") + assert state.state == "36" + + state = hass.states.get("sensor.dorm_1_temperature") + assert state.state == "20.8" + + state = hass.states.get("sensor.dorm_1_humidity") + assert state.state == "35" + + state = hass.states.get("sensor.dorm_2_temperature") + assert state.state == "20.5" + + state = hass.states.get("sensor.dorm_2_humidity") + assert state.state == "40" + + state = hass.states.get("sensor.dorm_ppal_temperature") + assert state.state == "21.1" + + state = hass.states.get("sensor.dorm_ppal_humidity") + assert state.state == "39" + + state = hass.states.get("sensor.salon_temperature") + assert state.state == "19.6" + + state = hass.states.get("sensor.salon_humidity") + assert state.state == "34" diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py new file mode 100644 index 00000000000..268a7fd1d2b --- /dev/null +++ b/tests/components/airzone/util.py @@ -0,0 +1,164 @@ +"""Tests for the Airzone integration.""" + +from unittest.mock import patch + +from aioairzone.const import ( + API_AIR_DEMAND, + API_COLD_STAGE, + API_COLD_STAGES, + API_DATA, + API_ERRORS, + API_FLOOR_DEMAND, + API_HEAT_STAGE, + API_HEAT_STAGES, + API_HUMIDITY, + API_MAX_TEMP, + API_MIN_TEMP, + API_MODE, + API_MODES, + API_NAME, + API_ON, + API_ROOM_TEMP, + API_SET_POINT, + API_SYSTEM_ID, + API_SYSTEMS, + API_UNITS, + API_ZONE_ID, +) + +from homeassistant.components.airzone import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +CONFIG = { + CONF_HOST: "192.168.1.100", + CONF_PORT: 3000, +} + +HVAC_MOCK = { + API_SYSTEMS: [ + { + API_DATA: [ + { + API_SYSTEM_ID: 1, + API_ZONE_ID: 1, + API_NAME: "Salon", + API_ON: 0, + API_MAX_TEMP: 30, + API_MIN_TEMP: 15, + API_SET_POINT: 19.5, + API_ROOM_TEMP: 19.6, + API_MODES: [1, 4, 2, 3, 5], + API_MODE: 3, + API_COLD_STAGES: 1, + API_COLD_STAGE: 1, + API_HEAT_STAGES: 1, + API_HEAT_STAGE: 1, + API_HUMIDITY: 34, + API_UNITS: 0, + API_ERRORS: [], + API_AIR_DEMAND: 0, + API_FLOOR_DEMAND: 0, + }, + { + API_SYSTEM_ID: 1, + API_ZONE_ID: 2, + API_NAME: "Dorm Ppal", + API_ON: 0, + API_MAX_TEMP: 30, + API_MIN_TEMP: 15, + API_SET_POINT: 19.5, + API_ROOM_TEMP: 21.1, + API_MODE: 3, + API_COLD_STAGES: 1, + API_COLD_STAGE: 1, + API_HEAT_STAGES: 1, + API_HEAT_STAGE: 1, + API_HUMIDITY: 39, + API_UNITS: 0, + API_ERRORS: [], + API_AIR_DEMAND: 0, + API_FLOOR_DEMAND: 0, + }, + { + API_SYSTEM_ID: 1, + API_ZONE_ID: 3, + API_NAME: "Dorm #1", + API_ON: 0, + API_MAX_TEMP: 30, + API_MIN_TEMP: 15, + API_SET_POINT: 19.5, + API_ROOM_TEMP: 20.8, + API_MODE: 3, + API_COLD_STAGES: 1, + API_COLD_STAGE: 1, + API_HEAT_STAGES: 1, + API_HEAT_STAGE: 1, + API_HUMIDITY: 35, + API_UNITS: 0, + API_ERRORS: [], + API_AIR_DEMAND: 0, + API_FLOOR_DEMAND: 0, + }, + { + API_SYSTEM_ID: 1, + API_ZONE_ID: 4, + API_NAME: "Despacho", + API_ON: 0, + API_MAX_TEMP: 30, + API_MIN_TEMP: 15, + API_SET_POINT: 19.5, + API_ROOM_TEMP: 21.2, + API_MODE: 3, + API_COLD_STAGES: 1, + API_COLD_STAGE: 1, + API_HEAT_STAGES: 1, + API_HEAT_STAGE: 1, + API_HUMIDITY: 36, + API_UNITS: 0, + API_ERRORS: [], + API_AIR_DEMAND: 0, + API_FLOOR_DEMAND: 0, + }, + { + API_SYSTEM_ID: 1, + API_ZONE_ID: 5, + API_NAME: "Dorm #2", + API_ON: 0, + API_MAX_TEMP: 30, + API_MIN_TEMP: 15, + API_SET_POINT: 19.5, + API_ROOM_TEMP: 20.5, + API_MODE: 3, + API_COLD_STAGES: 1, + API_COLD_STAGE: 1, + API_HEAT_STAGES: 1, + API_HEAT_STAGE: 1, + API_HUMIDITY: 40, + API_UNITS: 0, + API_ERRORS: [], + API_AIR_DEMAND: 0, + API_FLOOR_DEMAND: 0, + }, + ] + } + ] +} + + +async def async_init_integration( + hass: HomeAssistant, +): + """Set up the Airzone integration in Home Assistant.""" + + with patch( + "aioairzone.localapi_device.AirzoneLocalApi.get_hvac", + return_value=HVAC_MOCK, + ): + entry = MockConfigEntry(domain=DOMAIN, data=CONFIG) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done()