From f1ffb25d997f67a3974730a89a28b4a3078ffe60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Wed, 23 Nov 2022 20:32:23 +0100 Subject: [PATCH] Add Airzone DHCP discovery support (#82339) --- .../components/airzone/config_flow.py | 93 +++++++- .../components/airzone/manifest.json | 7 +- homeassistant/components/airzone/strings.json | 13 +- .../components/airzone/translations/en.json | 13 +- homeassistant/generated/dhcp.py | 4 + tests/components/airzone/test_config_flow.py | 209 +++++++++++++++++- tests/components/airzone/util.py | 5 + 7 files changed, 332 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/airzone/config_flow.py b/homeassistant/components/airzone/config_flow.py index 89a2d7f1f9e..7a8fdbf884b 100644 --- a/homeassistant/components/airzone/config_flow.py +++ b/homeassistant/components/airzone/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Airzone.""" from __future__ import annotations +import logging from typing import Any from aioairzone.const import DEFAULT_PORT, DEFAULT_SYSTEM_ID @@ -9,13 +10,16 @@ from aioairzone.localapi import AirzoneLocalApi, ConnectionOptions import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, @@ -29,9 +33,17 @@ SYSTEM_ID_SCHEMA = CONFIG_SCHEMA.extend( ) +def short_mac(addr: str) -> str: + """Convert MAC address to short address.""" + return addr.replace(":", "")[-4:].upper() + + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle config flow for an Airzone device.""" + _discovered_ip: str | None = None + _discovered_mac: str | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -60,7 +72,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" else: if mac: - await self.async_set_unique_id(format_mac(mac)) + await self.async_set_unique_id( + format_mac(mac), raise_on_progress=False + ) self._abort_if_unique_id_configured( updates={ CONF_HOST: user_input[CONF_HOST], @@ -76,3 +90,78 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=data_schema, errors=errors, ) + + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Handle DHCP discovery.""" + self._discovered_ip = discovery_info.ip + self._discovered_mac = discovery_info.macaddress + + _LOGGER.debug( + "DHCP discovery detected Airzone WebServer: %s", self._discovered_mac + ) + + self._async_abort_entries_match({CONF_HOST: self._discovered_ip}) + + await self.async_set_unique_id(format_mac(self._discovered_mac)) + self._abort_if_unique_id_configured() + + options = ConnectionOptions(self._discovered_ip) + airzone = AirzoneLocalApi( + aiohttp_client.async_get_clientsession(self.hass), options + ) + try: + await airzone.get_version() + except AirzoneError as err: + raise AbortFlow("cannot_connect") from err + + return await self.async_step_discovered_connection() + + async def async_step_discovered_connection( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + assert self._discovered_ip is not None + assert self._discovered_mac is not None + + errors = {} + base_schema = {vol.Required(CONF_PORT, default=DEFAULT_PORT): int} + + if user_input is not None: + airzone = AirzoneLocalApi( + aiohttp_client.async_get_clientsession(self.hass), + ConnectionOptions( + self._discovered_ip, + user_input[CONF_PORT], + user_input.get(CONF_ID, DEFAULT_SYSTEM_ID), + ), + ) + + try: + mac = await airzone.validate() + except InvalidSystem: + base_schema[vol.Required(CONF_ID, default=1)] = int + errors[CONF_ID] = "invalid_system_id" + except AirzoneError: + errors["base"] = "cannot_connect" + else: + user_input[CONF_HOST] = self._discovered_ip + + if mac is None: + mac = self._discovered_mac + + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + } + ) + + title = f"Airzone {short_mac(mac)}" + return self.async_create_entry(title=title, data=user_input) + + return self.async_show_form( + step_id="discovered_connection", + data_schema=vol.Schema(base_schema), + errors=errors, + ) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 25b853db290..142ace5e70b 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -6,5 +6,10 @@ "requirements": ["aioairzone==0.5.1"], "codeowners": ["@Noltari"], "iot_class": "local_polling", - "loggers": ["aioairzone"] + "loggers": ["aioairzone"], + "dhcp": [ + { + "macaddress": "E84F25*" + } + ] } diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json index 855b5615482..306e63da36c 100644 --- a/homeassistant/components/airzone/strings.json +++ b/homeassistant/components/airzone/strings.json @@ -8,12 +8,19 @@ "invalid_system_id": "Invalid Airzone System ID" }, "step": { - "user": { + "discovered_connection": { "data": { + "id": "[%key:component::airzone::config::step::user::data::id%]", "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" - }, - "description": "Set up Airzone integration." + } + }, + "user": { + "data": { + "id": "System ID", + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } } } } diff --git a/homeassistant/components/airzone/translations/en.json b/homeassistant/components/airzone/translations/en.json index 85da81afd55..862887818d6 100644 --- a/homeassistant/components/airzone/translations/en.json +++ b/homeassistant/components/airzone/translations/en.json @@ -8,12 +8,19 @@ "invalid_system_id": "Invalid Airzone System ID" }, "step": { - "user": { + "discovered_connection": { "data": { + "id": "System ID", "host": "Host", "port": "Port" - }, - "description": "Set up Airzone integration." + } + }, + "user": { + "data": { + "id": "System ID", + "host": "Host", + "port": "Port" + } } } } diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 7aaa183c114..dd156b29f22 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -6,6 +6,10 @@ To update, run python3 -m script.hassfest from __future__ import annotations DHCP: list[dict[str, str | bool]] = [ + { + "domain": "airzone", + "macaddress": "E84F25*", + }, { "domain": "august", "hostname": "connect", diff --git a/tests/components/airzone/test_config_flow.py b/tests/components/airzone/test_config_flow.py index 32eaade93ee..a4b4c310f38 100644 --- a/tests/components/airzone/test_config_flow.py +++ b/tests/components/airzone/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from aioairzone.const import API_SYSTEMS +from aioairzone.const import API_MAC, API_SYSTEMS from aioairzone.exceptions import ( AirzoneError, InvalidMethod, @@ -10,16 +10,28 @@ from aioairzone.exceptions import ( SystemOutOfRange, ) -from homeassistant import data_entry_flow +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import dhcp +from homeassistant.components.airzone.config_flow import short_mac from homeassistant.components.airzone.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT from homeassistant.core import HomeAssistant -from .util import CONFIG, CONFIG_ID1, HVAC_MOCK, HVAC_WEBSERVER_MOCK +from .util import CONFIG, CONFIG_ID1, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK from tests.common import MockConfigEntry +DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( + hostname="airzone", + ip="192.168.1.100", + macaddress="E84F25000000", +) + +TEST_ID = 1 +TEST_IP = DHCP_SERVICE_INFO.ip +TEST_PORT = 3000 + async def test_form(hass: HomeAssistant) -> None: """Test that the form is served with valid input.""" @@ -145,3 +157,194 @@ async def test_connection_error(hass: HomeAssistant): ) assert result["errors"] == {"base": "cannot_connect"} + + +async def test_dhcp_flow(hass: HomeAssistant) -> None: + """Test that DHCP discovery works.""" + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DHCP_SERVICE_INFO, + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "discovered_connection" + + with patch( + "homeassistant.components.airzone.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", + return_value=HVAC_MOCK, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", + side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", + return_value=HVAC_WEBSERVER_MOCK, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PORT: TEST_PORT, + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == { + CONF_HOST: TEST_IP, + CONF_PORT: TEST_PORT, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_flow_error(hass: HomeAssistant) -> None: + """Test that DHCP discovery fails.""" + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + side_effect=AirzoneError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DHCP_SERVICE_INFO, + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_dhcp_connection_error(hass: HomeAssistant): + """Test DHCP connection to host error.""" + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DHCP_SERVICE_INFO, + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "discovered_connection" + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.validate", + side_effect=AirzoneError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PORT: 3001, + }, + ) + + assert result["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.airzone.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", + return_value=HVAC_MOCK, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", + side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", + return_value=HVAC_WEBSERVER_MOCK, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PORT: TEST_PORT, + }, + ) + + 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 {short_mac(HVAC_WEBSERVER_MOCK[API_MAC])}" + assert result["data"][CONF_HOST] == TEST_IP + assert result["data"][CONF_PORT] == TEST_PORT + + mock_setup_entry.assert_called_once() + + +async def test_dhcp_invalid_system_id(hass: HomeAssistant) -> None: + """Test Invalid System ID 0.""" + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DHCP_SERVICE_INFO, + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "discovered_connection" + + with patch( + "homeassistant.components.airzone.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", + side_effect=InvalidSystem, + ) as mock_hvac, patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", + side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", + side_effect=InvalidMethod, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PORT: TEST_PORT, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "discovered_connection" + assert result["errors"] == {CONF_ID: "invalid_system_id"} + + mock_hvac.return_value = HVAC_MOCK[API_SYSTEMS][0] + mock_hvac.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PORT: TEST_PORT, + CONF_ID: TEST_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.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == f"Airzone {short_mac(DHCP_SERVICE_INFO.macaddress)}" + assert result["data"][CONF_HOST] == TEST_IP + assert result["data"][CONF_PORT] == TEST_PORT + assert result["data"][CONF_ID] == TEST_ID + + mock_setup_entry.assert_called_once() diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 6b81c493eb6..5bed0fc1d99 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -30,6 +30,7 @@ from aioairzone.const import ( API_THERMOS_RADIO, API_THERMOS_TYPE, API_UNITS, + API_VERSION, API_WIFI_CHANNEL, API_WIFI_RSSI, API_ZONE_ID, @@ -191,6 +192,10 @@ HVAC_SYSTEMS_MOCK = { ] } +HVAC_VERSION_MOCK = { + API_VERSION: "1.62", +} + HVAC_WEBSERVER_MOCK = { API_MAC: "11:22:33:44:55:66", API_WIFI_CHANNEL: 6,