From be6c2554dd73232ae4e0f0d9e3d92bcf9e4fba12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sun, 19 Jun 2022 16:43:29 +0200 Subject: [PATCH] Add QNAP QSW DHCP discovery (#73130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * qnap_qsw: add DHCP discovery Signed-off-by: Álvaro Fernández Rojas * qnap_qsw: config_flow: add async_step_dhcp Signed-off-by: Álvaro Fernández Rojas * qnap_qsw: config_flow: lower DHCP logging Signed-off-by: Álvaro Fernández Rojas * tests: qnap_qsw: fix copy & paste Signed-off-by: Álvaro Fernández Rojas * qnap_qsw: dhcp: introduce changes suggested by @bdraco Signed-off-by: Álvaro Fernández Rojas * Update homeassistant/components/qnap_qsw/config_flow.py Co-authored-by: J. Nick Koston * qnap_qsw: async_step_user: disable raising on progress Allows async_step_user to win over a discovery. Signed-off-by: Álvaro Fernández Rojas Co-authored-by: J. Nick Koston --- .../components/qnap_qsw/config_flow.py | 67 ++++++++- .../components/qnap_qsw/manifest.json | 7 +- homeassistant/generated/dhcp.py | 1 + tests/components/qnap_qsw/test_config_flow.py | 137 +++++++++++++++++- 4 files changed, 209 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/qnap_qsw/config_flow.py b/homeassistant/components/qnap_qsw/config_flow.py index 891c72c9911..e9d11433021 100644 --- a/homeassistant/components/qnap_qsw/config_flow.py +++ b/homeassistant/components/qnap_qsw/config_flow.py @@ -1,6 +1,7 @@ """Config flow for QNAP QSW.""" from __future__ import annotations +import logging from typing import Any from aioqsw.exceptions import LoginError, QswError @@ -8,6 +9,7 @@ from aioqsw.localapi import ConnectionOptions, QnapQswApi import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import dhcp from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import aiohttp_client @@ -15,10 +17,15 @@ from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle config flow for a QNAP QSW device.""" + _discovered_mac: str | None = None + _discovered_url: str | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -46,7 +53,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if mac is None: raise AbortFlow("invalid_id") - 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() title = f"QNAP {system_board.get_product()} {mac}" @@ -63,3 +70,61 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Handle DHCP discovery.""" + self._discovered_url = f"http://{discovery_info.ip}" + self._discovered_mac = discovery_info.macaddress + + _LOGGER.debug("DHCP discovery detected QSW: %s", self._discovered_mac) + + mac = format_mac(self._discovered_mac) + options = ConnectionOptions(self._discovered_url, "", "") + qsw = QnapQswApi(aiohttp_client.async_get_clientsession(self.hass), options) + + try: + await qsw.get_live() + except QswError as err: + raise AbortFlow("cannot_connect") from err + + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_configured() + + return await self.async_step_discovered_connection() + + async def async_step_discovered_connection( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + errors = {} + assert self._discovered_url is not None + + if user_input is not None: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + qsw = QnapQswApi( + aiohttp_client.async_get_clientsession(self.hass), + ConnectionOptions(self._discovered_url, username, password), + ) + + try: + system_board = await qsw.validate() + except LoginError: + errors[CONF_PASSWORD] = "invalid_auth" + except QswError: + errors[CONF_URL] = "cannot_connect" + else: + title = f"QNAP {system_board.get_product()} {self._discovered_mac}" + return self.async_create_entry(title=title, data=user_input) + + return self.async_show_form( + step_id="discovered_connection", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/qnap_qsw/manifest.json b/homeassistant/components/qnap_qsw/manifest.json index 0dfd0e4793e..be565f2a07e 100644 --- a/homeassistant/components/qnap_qsw/manifest.json +++ b/homeassistant/components/qnap_qsw/manifest.json @@ -6,5 +6,10 @@ "requirements": ["aioqsw==0.1.0"], "codeowners": ["@Noltari"], "iot_class": "local_polling", - "loggers": ["aioqsw"] + "loggers": ["aioqsw"], + "dhcp": [ + { + "macaddress": "245EBE*" + } + ] } diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 43bf7ca2715..9f2438aafa1 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -74,6 +74,7 @@ DHCP: list[dict[str, str | bool]] = [ {'domain': 'oncue', 'hostname': 'kohlergen*', 'macaddress': '00146F*'}, {'domain': 'overkiz', 'hostname': 'gateway*', 'macaddress': 'F8811A*'}, {'domain': 'powerwall', 'hostname': '1118431-*'}, + {'domain': 'qnap_qsw', 'macaddress': '245EBE*'}, {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': '009D6B*'}, {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': 'F0038C*'}, {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': '74C63B*'}, diff --git a/tests/components/qnap_qsw/test_config_flow.py b/tests/components/qnap_qsw/test_config_flow.py index e8cc9c56c0a..0b7072dd602 100644 --- a/tests/components/qnap_qsw/test_config_flow.py +++ b/tests/components/qnap_qsw/test_config_flow.py @@ -5,7 +5,8 @@ from unittest.mock import MagicMock, patch from aioqsw.const import API_MAC_ADDR, API_PRODUCT, API_RESULT from aioqsw.exceptions import LoginError, QswError -from homeassistant import data_entry_flow +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import dhcp from homeassistant.components.qnap_qsw.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME @@ -16,6 +17,16 @@ from .util import CONFIG, LIVE_MOCK, SYSTEM_BOARD_MOCK, USERS_LOGIN_MOCK from tests.common import MockConfigEntry +DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( + hostname="qsw-m408-4c", + ip="192.168.1.200", + macaddress="245EBE000000", +) + +TEST_PASSWORD = "test-password" +TEST_URL = "test-url" +TEST_USERNAME = "test-username" + async def test_form(hass: HomeAssistant) -> None: """Test that the form is served with valid input.""" @@ -134,3 +145,127 @@ async def test_login_error(hass: HomeAssistant): ) assert result["errors"] == {CONF_PASSWORD: "invalid_auth"} + + +async def test_dhcp_flow(hass: HomeAssistant) -> None: + """Test that DHCP discovery works.""" + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_live", + return_value=LIVE_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.qnap_qsw.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_live", + return_value=LIVE_MOCK, + ), patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_system_board", + return_value=SYSTEM_BOARD_MOCK, + ), patch( + "homeassistant.components.qnap_qsw.QnapQswApi.post_users_login", + return_value=USERS_LOGIN_MOCK, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result2["type"] == "create_entry" + assert result2["data"] == { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + } + + 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.qnap_qsw.QnapQswApi.get_live", + side_effect=QswError, + ): + 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_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_dhcp_connection_error(hass: HomeAssistant): + """Test DHCP connection to host error.""" + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_live", + return_value=LIVE_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.qnap_qsw.QnapQswApi.validate", + side_effect=QswError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["errors"] == {CONF_URL: "cannot_connect"} + + +async def test_dhcp_login_error(hass: HomeAssistant): + """Test DHCP login error.""" + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_live", + return_value=LIVE_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.qnap_qsw.QnapQswApi.validate", + side_effect=LoginError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["errors"] == {CONF_PASSWORD: "invalid_auth"}