From 74522390ad36ef7a07f19b6ec6aab1b85cd97d45 Mon Sep 17 00:00:00 2001 From: "Glenn Vandeuren (aka Iondependent)" Date: Sat, 30 Nov 2024 12:16:12 +0100 Subject: [PATCH] Add config flow to NHC (#130554) Co-authored-by: Joost Lekkerkerker Co-authored-by: VandeurenGlenn <8685280+VandeurenGlenn@users.noreply.github.com> --- CODEOWNERS | 2 + .../components/niko_home_control/__init__.py | 84 ++++++++++- .../niko_home_control/config_flow.py | 66 +++++++++ .../components/niko_home_control/const.py | 3 + .../components/niko_home_control/light.py | 106 ++++++------- .../niko_home_control/manifest.json | 4 +- .../components/niko_home_control/strings.json | 27 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + .../components/niko_home_control/__init__.py | 13 ++ .../components/niko_home_control/conftest.py | 43 ++++++ .../niko_home_control/test_config_flow.py | 140 ++++++++++++++++++ 13 files changed, 440 insertions(+), 54 deletions(-) create mode 100644 homeassistant/components/niko_home_control/config_flow.py create mode 100644 homeassistant/components/niko_home_control/const.py create mode 100644 homeassistant/components/niko_home_control/strings.json create mode 100644 tests/components/niko_home_control/__init__.py create mode 100644 tests/components/niko_home_control/conftest.py create mode 100644 tests/components/niko_home_control/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index ba233c0c141..7755c3eb4ae 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1004,6 +1004,8 @@ build.json @home-assistant/supervisor /tests/components/nice_go/ @IceBotYT /homeassistant/components/nightscout/ @marciogranzotto /tests/components/nightscout/ @marciogranzotto +/homeassistant/components/niko_home_control/ @VandeurenGlenn +/tests/components/niko_home_control/ @VandeurenGlenn /homeassistant/components/nilu/ @hfurubotten /homeassistant/components/nina/ @DeerMaximum /tests/components/nina/ @DeerMaximum diff --git a/homeassistant/components/niko_home_control/__init__.py b/homeassistant/components/niko_home_control/__init__.py index 2cb5c70d1dd..bdbb8d6b85f 100644 --- a/homeassistant/components/niko_home_control/__init__.py +++ b/homeassistant/components/niko_home_control/__init__.py @@ -1 +1,83 @@ -"""The niko_home_control component.""" +"""The Niko home control integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from nclib.errors import NetcatError +from nikohomecontrol import NikoHomeControl + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.util import Throttle + +PLATFORMS: list[Platform] = [Platform.LIGHT] + +type NikoHomeControlConfigEntry = ConfigEntry[NikoHomeControlData] + + +_LOGGER = logging.getLogger(__name__) +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) + + +async def async_setup_entry( + hass: HomeAssistant, entry: NikoHomeControlConfigEntry +) -> bool: + """Set Niko Home Control from a config entry.""" + try: + controller = NikoHomeControl({"ip": entry.data[CONF_HOST], "port": 8000}) + niko_data = NikoHomeControlData(hass, controller) + await niko_data.async_update() + except NetcatError as err: + raise ConfigEntryNotReady("cannot connect to controller.") from err + except OSError as err: + raise ConfigEntryNotReady( + "unknown error while connecting to controller." + ) from err + + entry.runtime_data = niko_data + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: NikoHomeControlConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +class NikoHomeControlData: + """The class for handling data retrieval.""" + + def __init__(self, hass, nhc): + """Set up Niko Home Control Data object.""" + self.nhc = nhc + self.hass = hass + self.available = True + self.data = {} + self._system_info = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Get the latest data from the NikoHomeControl API.""" + _LOGGER.debug("Fetching async state in bulk") + try: + self.data = await self.hass.async_add_executor_job( + self.nhc.list_actions_raw + ) + self.available = True + except OSError as ex: + _LOGGER.error("Unable to retrieve data from Niko, %s", str(ex)) + self.available = False + + def get_state(self, aid): + """Find and filter state based on action id.""" + for state in self.data: + if state["id"] == aid: + return state["value1"] + _LOGGER.error("Failed to retrieve state off unknown light") + return None diff --git a/homeassistant/components/niko_home_control/config_flow.py b/homeassistant/components/niko_home_control/config_flow.py new file mode 100644 index 00000000000..9174a932534 --- /dev/null +++ b/homeassistant/components/niko_home_control/config_flow.py @@ -0,0 +1,66 @@ +"""Config flow for the Niko home control integration.""" + +from __future__ import annotations + +from typing import Any + +from nikohomecontrol import NikoHomeControlConnection +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST + +from .const import DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +def test_connection(host: str) -> str | None: + """Test if we can connect to the Niko Home Control controller.""" + try: + NikoHomeControlConnection(host, 8000) + except Exception: # noqa: BLE001 + return "cannot_connect" + return None + + +class NikoHomeControlConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Niko Home Control.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + error = test_connection(user_input[CONF_HOST]) + if not error: + return self.async_create_entry( + title="Niko Home Control", + data=user_input, + ) + errors["base"] = error + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: + """Import a config entry.""" + self._async_abort_entries_match({CONF_HOST: import_info[CONF_HOST]}) + error = test_connection(import_info[CONF_HOST]) + + if not error: + return self.async_create_entry( + title="Niko Home Control", + data={CONF_HOST: import_info[CONF_HOST]}, + ) + return self.async_abort(reason=error) diff --git a/homeassistant/components/niko_home_control/const.py b/homeassistant/components/niko_home_control/const.py new file mode 100644 index 00000000000..202b031b9a2 --- /dev/null +++ b/homeassistant/components/niko_home_control/const.py @@ -0,0 +1,3 @@ +"""Constants for niko_home_control integration.""" + +DOMAIN = "niko_home_control" diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index b2d41f3a41e..f2bf302eab7 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -1,4 +1,4 @@ -"""Support for Niko Home Control.""" +"""Light platform Niko Home Control.""" from __future__ import annotations @@ -6,7 +6,6 @@ from datetime import timedelta import logging from typing import Any -import nikohomecontrol import voluptuous as vol from homeassistant.components.light import ( @@ -16,18 +15,22 @@ from homeassistant.components.light import ( LightEntity, brightness_supported, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle + +from . import NikoHomeControlConfigEntry +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) SCAN_INTERVAL = timedelta(seconds=30) +# delete after 2025.7.0 PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend({vol.Required(CONF_HOST): cv.string}) @@ -38,20 +41,56 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Niko Home Control light platform.""" - host = config[CONF_HOST] - - try: - nhc = nikohomecontrol.NikoHomeControl( - {"ip": host, "port": 8000, "timeout": 20000} + # Start import flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + if ( + result.get("type") == FlowResultType.ABORT + and result.get("reason") != "already_configured" + ): + ir.async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result['reason']}", + breaks_in_ha_version="2025.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result['reason']}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Niko Home Control", + }, ) - niko_data = NikoHomeControlData(hass, nhc) - await niko_data.async_update() - except OSError as err: - _LOGGER.error("Unable to access %s (%s)", host, err) - raise PlatformNotReady from err + return + + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2025.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Niko Home Control", + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NikoHomeControlConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Niko Home Control light entry.""" + niko_data = entry.runtime_data async_add_entities( - [NikoHomeControlLight(light, niko_data) for light in nhc.list_actions()], True + NikoHomeControlLight(light, niko_data) for light in niko_data.nhc.list_actions() ) @@ -88,36 +127,3 @@ class NikoHomeControlLight(LightEntity): self._attr_is_on = state != 0 if brightness_supported(self.supported_color_modes): self._attr_brightness = state * 2.55 - - -class NikoHomeControlData: - """The class for handling data retrieval.""" - - def __init__(self, hass, nhc): - """Set up Niko Home Control Data object.""" - self._nhc = nhc - self.hass = hass - self.available = True - self.data = {} - self._system_info = None - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self): - """Get the latest data from the NikoHomeControl API.""" - _LOGGER.debug("Fetching async state in bulk") - try: - self.data = await self.hass.async_add_executor_job( - self._nhc.list_actions_raw - ) - self.available = True - except OSError as ex: - _LOGGER.error("Unable to retrieve data from Niko, %s", str(ex)) - self.available = False - - def get_state(self, aid): - """Find and filter state based on action id.""" - for state in self.data: - if state["id"] == aid: - return state["value1"] - _LOGGER.error("Failed to retrieve state off unknown light") - return None diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index 316dc1dc958..194596d534f 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -1,10 +1,10 @@ { "domain": "niko_home_control", "name": "Niko Home Control", - "codeowners": [], + "codeowners": ["@VandeurenGlenn"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/niko_home_control", "iot_class": "local_polling", "loggers": ["nikohomecontrol"], - "quality_scale": "legacy", "requirements": ["niko-home-control==0.2.1"] } diff --git a/homeassistant/components/niko_home_control/strings.json b/homeassistant/components/niko_home_control/strings.json new file mode 100644 index 00000000000..495dca94c0c --- /dev/null +++ b/homeassistant/components/niko_home_control/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up your Niko Home Control instance.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Niko Home Control controller." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "YAML import failed due to a connection error", + "description": "Configuring {integration_title} using YAML is being removed but there was a connect error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ffe61b915c6..9a75ac32ea1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -406,6 +406,7 @@ FLOWS = { "nibe_heatpump", "nice_go", "nightscout", + "niko_home_control", "nina", "nmap_tracker", "nobo_hub", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8238a09072b..9fee6abb894 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4145,7 +4145,7 @@ "niko_home_control": { "name": "Niko Home Control", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "nilu": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1215799f132..feeb35017f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1222,6 +1222,9 @@ nibe==2.13.0 # homeassistant.components.nice_go nice-go==0.3.10 +# homeassistant.components.niko_home_control +niko-home-control==0.2.1 + # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 diff --git a/tests/components/niko_home_control/__init__.py b/tests/components/niko_home_control/__init__.py new file mode 100644 index 00000000000..f6e8187bf0f --- /dev/null +++ b/tests/components/niko_home_control/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the niko_home_control integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/niko_home_control/conftest.py b/tests/components/niko_home_control/conftest.py new file mode 100644 index 00000000000..932480ac710 --- /dev/null +++ b/tests/components/niko_home_control/conftest.py @@ -0,0 +1,43 @@ +"""niko_home_control integration tests configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.niko_home_control.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override integration setup.""" + with patch( + "homeassistant.components.niko_home_control.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_niko_home_control_connection() -> Generator[AsyncMock]: + """Mock a NHC client.""" + with ( + patch( + "homeassistant.components.niko_home_control.config_flow.NikoHomeControlConnection", + autospec=True, + ) as mock_client, + ): + client = mock_client.return_value + client.return_value = True + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, title="Niko Home Control", data={CONF_HOST: "192.168.0.123"} + ) diff --git a/tests/components/niko_home_control/test_config_flow.py b/tests/components/niko_home_control/test_config_flow.py new file mode 100644 index 00000000000..8220ee15e02 --- /dev/null +++ b/tests/components/niko_home_control/test_config_flow.py @@ -0,0 +1,140 @@ +"""Test niko_home_control config flow.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant.components.niko_home_control.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.123"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Niko Home Control" + assert result["data"] == {CONF_HOST: "192.168.0.123"} + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_cannot_connect(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test the cannot connect error.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.niko_home_control.config_flow.NikoHomeControlConnection", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.123"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.niko_home_control.config_flow.NikoHomeControlConnection" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.123"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test uniqueness.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.123"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import_flow( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the import flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_HOST: "192.168.0.123"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Niko Home Control" + assert result["data"] == {CONF_HOST: "192.168.0.123"} + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test the cannot connect error.""" + + with patch( + "homeassistant.components.niko_home_control.config_flow.NikoHomeControlConnection", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_HOST: "192.168.0.123"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_duplicate_import_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test uniqueness.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_HOST: "192.168.0.123"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured"