From 890b54e36f3406a436ca5d6b40daa887dd0b7e7d Mon Sep 17 00:00:00 2001 From: GeoffAtHome <geoff@soord.org.uk> Date: Sun, 21 Jul 2024 18:57:41 +0100 Subject: [PATCH] Add config flow to Genius hub (#116173) * Adding config flow * Fix setup issues. * Added test for config_flow * Refactor schemas. * Fixed ruff-format on const.py * Added geniushub-cleint to requirements_test_all.txt * Updates from review. * Correct multiple logger comment errors. * User menu rather than check box. * Correct logger messages. * Correct test_config_flow * Import config entry from YAML * Config flow integration * Refactor genius hub test_config_flow. * Improvements and simplification from code review. * Correct tests * Stop device being added twice. * Correct validate_input. * Changes to meet code review three week ago. * Fix Ruff undefined error * Update homeassistant/components/geniushub/config_flow.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/geniushub/config_flow.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Change case Cloud and Local to CLOUD and LOCAL. * More from code review * Fix * Fix * Update homeassistant/components/geniushub/strings.json --------- Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> --- CODEOWNERS | 1 + .../components/geniushub/__init__.py | 117 ++++- .../components/geniushub/binary_sensor.py | 20 +- homeassistant/components/geniushub/climate.py | 20 +- .../components/geniushub/config_flow.py | 136 +++++ homeassistant/components/geniushub/const.py | 19 + .../components/geniushub/manifest.json | 1 + homeassistant/components/geniushub/sensor.py | 14 +- .../components/geniushub/strings.json | 35 ++ homeassistant/components/geniushub/switch.py | 21 +- .../components/geniushub/water_heater.py | 22 +- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/geniushub/__init__.py | 1 + tests/components/geniushub/conftest.py | 65 +++ .../components/geniushub/test_config_flow.py | 482 ++++++++++++++++++ 17 files changed, 869 insertions(+), 91 deletions(-) create mode 100644 homeassistant/components/geniushub/config_flow.py create mode 100644 homeassistant/components/geniushub/const.py create mode 100644 tests/components/geniushub/__init__.py create mode 100644 tests/components/geniushub/conftest.py create mode 100644 tests/components/geniushub/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index f79da235bb6..b382d63cf44 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -505,6 +505,7 @@ build.json @home-assistant/supervisor /homeassistant/components/generic_hygrostat/ @Shulyaka /tests/components/generic_hygrostat/ @Shulyaka /homeassistant/components/geniushub/ @manzanotti +/tests/components/geniushub/ @manzanotti /homeassistant/components/geo_json_events/ @exxamalte /tests/components/geo_json_events/ @exxamalte /homeassistant/components/geo_location/ @home-assistant/core diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index 05afb121d44..84e835ac2bb 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -10,6 +10,8 @@ import aiohttp from geniushubclient import GeniusHub import voluptuous as vol +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -21,23 +23,29 @@ from homeassistant.const import ( Platform, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + HomeAssistant, + ServiceCall, + callback, +) +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service import verify_domain_control from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN -DOMAIN = "geniushub" +_LOGGER = logging.getLogger(__name__) # temperature is repeated here, as it gives access to high-precision temps GH_ZONE_ATTRS = ["mode", "temperature", "type", "occupied", "override"] @@ -54,13 +62,15 @@ SCAN_INTERVAL = timedelta(seconds=60) MAC_ADDRESS_REGEXP = r"^([0-9A-F]{2}:){5}([0-9A-F]{2})$" -V1_API_SCHEMA = vol.Schema( +CLOUD_API_SCHEMA = vol.Schema( { vol.Required(CONF_TOKEN): cv.string, vol.Required(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP), } ) -V3_API_SCHEMA = vol.Schema( + + +LOCAL_API_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, @@ -68,8 +78,9 @@ V3_API_SCHEMA = vol.Schema( vol.Optional(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP), } ) + CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Any(V3_API_SCHEMA, V1_API_SCHEMA)}, extra=vol.ALLOW_EXTRA + {DOMAIN: vol.Any(LOCAL_API_SCHEMA, CLOUD_API_SCHEMA)}, extra=vol.ALLOW_EXTRA ) ATTR_ZONE_MODE = "mode" @@ -106,20 +117,78 @@ PLATFORMS = ( ) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def _async_import(hass: HomeAssistant, base_config: ConfigType) -> None: + """Import a config entry from configuration.yaml.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=base_config[DOMAIN], + ) + if ( + result["type"] is FlowResultType.CREATE_ENTRY + or result["reason"] == "already_configured" + ): + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Genius Hub", + }, + ) + return + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result['reason']}", + breaks_in_ha_version="2024.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result['reason']}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Genius Hub", + }, + ) + + +async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool: + """Set up a Genius Hub system.""" + if DOMAIN in base_config: + hass.async_create_task(_async_import(hass, base_config)) + return True + + +type GeniusHubConfigEntry = ConfigEntry[GeniusBroker] + + +async def async_setup_entry(hass: HomeAssistant, entry: GeniusHubConfigEntry) -> bool: """Create a Genius Hub system.""" - hass.data[DOMAIN] = {} - kwargs = dict(config[DOMAIN]) - if CONF_HOST in kwargs: - args = (kwargs.pop(CONF_HOST),) + session = async_get_clientsession(hass) + if CONF_HOST in entry.data: + client = GeniusHub( + entry.data[CONF_HOST], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + session=session, + ) else: - args = (kwargs.pop(CONF_TOKEN),) - hub_uid = kwargs.pop(CONF_MAC, None) + client = GeniusHub(entry.data[CONF_TOKEN], session=session) - client = GeniusHub(*args, **kwargs, session=async_get_clientsession(hass)) + unique_id = entry.unique_id or entry.entry_id - broker = hass.data[DOMAIN]["broker"] = GeniusBroker(hass, client, hub_uid) + broker = entry.runtime_data = GeniusBroker( + hass, client, entry.data.get(CONF_MAC, unique_id) + ) try: await client.update() @@ -130,11 +199,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_track_time_interval(hass, broker.async_update, SCAN_INTERVAL) - for platform in PLATFORMS: - hass.async_create_task(async_load_platform(hass, platform, DOMAIN, {}, config)) - setup_service_functions(hass, broker) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True @@ -175,20 +243,13 @@ def setup_service_functions(hass: HomeAssistant, broker): class GeniusBroker: """Container for geniushub client and data.""" - def __init__( - self, hass: HomeAssistant, client: GeniusHub, hub_uid: str | None - ) -> None: + def __init__(self, hass: HomeAssistant, client: GeniusHub, hub_uid: str) -> None: """Initialize the geniushub client.""" self.hass = hass self.client = client - self._hub_uid = hub_uid + self.hub_uid = hub_uid self._connect_error = False - @property - def hub_uid(self) -> str: - """Return the Hub UID (MAC address).""" - return self._hub_uid if self._hub_uid is not None else self.client.uid - async def async_update(self, now, **kwargs) -> None: """Update the geniushub client's data.""" try: diff --git a/homeassistant/components/geniushub/binary_sensor.py b/homeassistant/components/geniushub/binary_sensor.py index f078bb4b363..2d6acf0c955 100644 --- a/homeassistant/components/geniushub/binary_sensor.py +++ b/homeassistant/components/geniushub/binary_sensor.py @@ -5,33 +5,27 @@ from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, GeniusDevice +from . import GeniusDevice, GeniusHubConfigEntry GH_STATE_ATTR = "outputOnOff" GH_TYPE = "Receiver" -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: GeniusHubConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Genius Hub sensor entities.""" - if discovery_info is None: - return + """Set up the Genius Hub binary sensor entities.""" - broker = hass.data[DOMAIN]["broker"] + broker = entry.runtime_data - switches = [ + async_add_entities( GeniusBinarySensor(broker, d, GH_STATE_ATTR) for d in broker.client.device_objs if GH_TYPE in d.data["type"] - ] - - async_add_entities(switches, update_before_add=True) + ) class GeniusBinarySensor(GeniusDevice, BinarySensorEntity): diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index 02038ced198..ea2a79be767 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -12,9 +12,8 @@ from homeassistant.components.climate import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, GeniusHeatingZone +from . import GeniusHeatingZone, GeniusHubConfigEntry # GeniusHub Zones support: Off, Timer, Override/Boost, Footprint & Linked modes HA_HVAC_TO_GH = {HVACMode.OFF: "off", HVACMode.HEAT: "timer"} @@ -26,24 +25,19 @@ GH_PRESET_TO_HA = {v: k for k, v in HA_PRESET_TO_GH.items()} GH_ZONES = ["radiator", "wet underfloor"] -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: GeniusHubConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Genius Hub climate entities.""" - if discovery_info is None: - return - broker = hass.data[DOMAIN]["broker"] + broker = entry.runtime_data async_add_entities( - [ - GeniusClimateZone(broker, z) - for z in broker.client.zone_objs - if z.data.get("type") in GH_ZONES - ] + GeniusClimateZone(broker, z) + for z in broker.client.zone_objs + if z.data.get("type") in GH_ZONES ) diff --git a/homeassistant/components/geniushub/config_flow.py b/homeassistant/components/geniushub/config_flow.py new file mode 100644 index 00000000000..5f026c91ee1 --- /dev/null +++ b/homeassistant/components/geniushub/config_flow.py @@ -0,0 +1,136 @@ +"""Config flow for Geniushub integration.""" + +from __future__ import annotations + +from http import HTTPStatus +import logging +import socket +from typing import Any + +import aiohttp +from geniushubclient import GeniusService +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CLOUD_API_SCHEMA = vol.Schema( + { + vol.Required(CONF_TOKEN): str, + } +) + + +LOCAL_API_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class GeniusHubConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Geniushub.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """User config step for determine cloud or local.""" + return self.async_show_menu( + step_id="user", + menu_options=["local_api", "cloud_api"], + ) + + async def async_step_local_api( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Version 3 configuration.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_HOST: user_input[CONF_HOST], + CONF_USERNAME: user_input[CONF_USERNAME], + } + ) + service = GeniusService( + user_input[CONF_HOST], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + try: + response = await service.request("GET", "auth/release") + except socket.gaierror: + errors["base"] = "invalid_host" + except aiohttp.ClientResponseError as err: + if err.status == HTTPStatus.UNAUTHORIZED: + errors["base"] = "invalid_auth" + else: + errors["base"] = "invalid_host" + except (TimeoutError, aiohttp.ClientConnectionError): + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(response["data"]["UID"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) + + return self.async_show_form( + step_id="local_api", errors=errors, data_schema=LOCAL_API_SCHEMA + ) + + async def async_step_cloud_api( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Version 1 configuration.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match(user_input) + service = GeniusService( + user_input[CONF_TOKEN], session=async_get_clientsession(self.hass) + ) + try: + await service.request("GET", "version") + except aiohttp.ClientResponseError as err: + if err.status == HTTPStatus.UNAUTHORIZED: + errors["base"] = "invalid_auth" + else: + errors["base"] = "invalid_host" + except socket.gaierror: + errors["base"] = "invalid_host" + except (TimeoutError, aiohttp.ClientConnectionError): + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title="Genius hub", data=user_input) + + return self.async_show_form( + step_id="cloud_api", errors=errors, data_schema=CLOUD_API_SCHEMA + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Import the yaml config.""" + if CONF_HOST in user_input: + result = await self.async_step_local_api(user_input) + else: + result = await self.async_step_cloud_api(user_input) + if result["type"] is FlowResultType.FORM: + assert result["errors"] + return self.async_abort(reason=result["errors"]["base"]) + return result diff --git a/homeassistant/components/geniushub/const.py b/homeassistant/components/geniushub/const.py new file mode 100644 index 00000000000..4601eca5f9b --- /dev/null +++ b/homeassistant/components/geniushub/const.py @@ -0,0 +1,19 @@ +"""Constants for Genius Hub.""" + +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "geniushub" + +SCAN_INTERVAL = timedelta(seconds=60) + +SENSOR_PREFIX = "Genius" + +PLATFORMS = ( + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.SENSOR, + Platform.SWITCH, + Platform.WATER_HEATER, +) diff --git a/homeassistant/components/geniushub/manifest.json b/homeassistant/components/geniushub/manifest.json index 28079293821..c6444bdb95d 100644 --- a/homeassistant/components/geniushub/manifest.json +++ b/homeassistant/components/geniushub/manifest.json @@ -2,6 +2,7 @@ "domain": "geniushub", "name": "Genius Hub", "codeowners": ["@manzanotti"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/geniushub", "iot_class": "local_polling", "loggers": ["geniushubclient"], diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py index f5cd8625e8b..ee65e679498 100644 --- a/homeassistant/components/geniushub/sensor.py +++ b/homeassistant/components/geniushub/sensor.py @@ -9,10 +9,9 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -from . import DOMAIN, GeniusDevice, GeniusEntity +from . import GeniusDevice, GeniusEntity, GeniusHubConfigEntry GH_STATE_ATTR = "batteryLevel" @@ -23,17 +22,14 @@ GH_LEVEL_MAPPING = { } -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: GeniusHubConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Genius Hub sensor entities.""" - if discovery_info is None: - return - broker = hass.data[DOMAIN]["broker"] + broker = entry.runtime_data entities: list[GeniusBattery | GeniusIssue] = [ GeniusBattery(broker, d, GH_STATE_ATTR) @@ -42,7 +38,7 @@ async def async_setup_platform( ] entities.extend([GeniusIssue(broker, i) for i in list(GH_LEVEL_MAPPING)]) - async_add_entities(entities, update_before_add=True) + async_add_entities(entities) class GeniusBattery(GeniusDevice, SensorEntity): diff --git a/homeassistant/components/geniushub/strings.json b/homeassistant/components/geniushub/strings.json index ac057f5c639..faf5011d752 100644 --- a/homeassistant/components/geniushub/strings.json +++ b/homeassistant/components/geniushub/strings.json @@ -1,4 +1,39 @@ { + "config": { + "step": { + "user": { + "title": "Genius Hub configuration", + "menu_options": { + "local_api": "Local: IP address and user credentials", + "cloud_api": "Cloud: API token" + } + }, + "local_api": { + "title": "Genius Hub local configuration", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + } + }, + "cloud_api": { + "title": "Genius Hub cloud configuration", + "data": { + "token": "[%key:common::config_flow::data::access_token%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "services": { "set_zone_mode": { "name": "Set zone mode", diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py index 85f7f1bb03a..2fffbddde01 100644 --- a/homeassistant/components/geniushub/switch.py +++ b/homeassistant/components/geniushub/switch.py @@ -11,9 +11,9 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType +from homeassistant.helpers.typing import VolDictType -from . import ATTR_DURATION, DOMAIN, GeniusZone +from . import ATTR_DURATION, GeniusHubConfigEntry, GeniusZone GH_ON_OFF_ZONE = "on / off" @@ -27,24 +27,19 @@ SET_SWITCH_OVERRIDE_SCHEMA: VolDictType = { } -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: GeniusHubConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Genius Hub switch entities.""" - if discovery_info is None: - return - broker = hass.data[DOMAIN]["broker"] + broker = entry.runtime_data async_add_entities( - [ - GeniusSwitch(broker, z) - for z in broker.client.zone_objs - if z.data.get("type") == GH_ON_OFF_ZONE - ] + GeniusSwitch(broker, z) + for z in broker.client.zone_objs + if z.data.get("type") == GH_ON_OFF_ZONE ) # Register custom services diff --git a/homeassistant/components/geniushub/water_heater.py b/homeassistant/components/geniushub/water_heater.py index f17560ebc62..6d3da570547 100644 --- a/homeassistant/components/geniushub/water_heater.py +++ b/homeassistant/components/geniushub/water_heater.py @@ -9,9 +9,8 @@ from homeassistant.components.water_heater import ( from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, GeniusHeatingZone +from . import GeniusHeatingZone, GeniusHubConfigEntry STATE_AUTO = "auto" STATE_MANUAL = "manual" @@ -33,24 +32,19 @@ GH_STATE_TO_HA = { GH_HEATERS = ["hot water temperature"] -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: GeniusHubConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Genius Hub water_heater entities.""" - if discovery_info is None: - return + """Set up the Genius Hub water heater entities.""" - broker = hass.data[DOMAIN]["broker"] + broker = entry.runtime_data async_add_entities( - [ - GeniusWaterHeater(broker, z) - for z in broker.client.zone_objs - if z.data.get("type") in GH_HEATERS - ] + GeniusWaterHeater(broker, z) + for z in broker.client.zone_objs + if z.data.get("type") in GH_HEATERS ) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b8614705823..96875e247f1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -202,6 +202,7 @@ FLOWS = { "gardena_bluetooth", "gdacs", "generic", + "geniushub", "geo_json_events", "geocaching", "geofency", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 84d69c868db..f60028240fb 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2124,7 +2124,7 @@ "geniushub": { "name": "Genius Hub", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "geo_json_events": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4d1562f340a..449c95e88e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -773,6 +773,9 @@ gassist-text==0.0.11 # homeassistant.components.google gcal-sync==6.1.4 +# homeassistant.components.geniushub +geniushub-client==0.7.1 + # homeassistant.components.geocaching geocachingapi==0.2.1 diff --git a/tests/components/geniushub/__init__.py b/tests/components/geniushub/__init__.py new file mode 100644 index 00000000000..15886486e38 --- /dev/null +++ b/tests/components/geniushub/__init__.py @@ -0,0 +1 @@ +"""Tests for the geniushub integration.""" diff --git a/tests/components/geniushub/conftest.py b/tests/components/geniushub/conftest.py new file mode 100644 index 00000000000..125f1cfa80c --- /dev/null +++ b/tests/components/geniushub/conftest.py @@ -0,0 +1,65 @@ +"""GeniusHub tests configuration.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.components.geniushub.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME + +from tests.common import MockConfigEntry +from tests.components.smhi.common import AsyncMock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.geniushub.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_geniushub_client() -> Generator[AsyncMock]: + """Mock a GeniusHub client.""" + with patch( + "homeassistant.components.geniushub.config_flow.GeniusService", + autospec=True, + ) as mock_client: + client = mock_client.return_value + client.request.return_value = { + "data": { + "UID": "aa:bb:cc:dd:ee:ff", + } + } + yield client + + +@pytest.fixture +def mock_local_config_entry() -> MockConfigEntry: + """Mock a local config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="aa:bb:cc:dd:ee:ff", + data={ + CONF_HOST: "10.0.0.131", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + unique_id="aa:bb:cc:dd:ee:ff", + ) + + +@pytest.fixture +def mock_cloud_config_entry() -> MockConfigEntry: + """Mock a cloud config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Genius hub", + data={ + CONF_TOKEN: "abcdef", + }, + ) diff --git a/tests/components/geniushub/test_config_flow.py b/tests/components/geniushub/test_config_flow.py new file mode 100644 index 00000000000..9234e03e35a --- /dev/null +++ b/tests/components/geniushub/test_config_flow.py @@ -0,0 +1,482 @@ +"""Test the Geniushub config flow.""" + +from http import HTTPStatus +import socket +from typing import Any +from unittest.mock import AsyncMock + +from aiohttp import ClientConnectionError, ClientResponseError +import pytest + +from homeassistant.components.geniushub import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_local_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_geniushub_client: AsyncMock, +) -> None: + """Test full local flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "local_api"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_api" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "10.0.0.130" + assert result["data"] == { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert result["result"].unique_id == "aa:bb:cc:dd:ee:ff" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (socket.gaierror, "invalid_host"), + ( + ClientResponseError(AsyncMock(), (), status=HTTPStatus.UNAUTHORIZED), + "invalid_auth", + ), + ( + ClientResponseError(AsyncMock(), (), status=HTTPStatus.NOT_FOUND), + "invalid_host", + ), + (TimeoutError, "cannot_connect"), + (ClientConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_local_flow_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_geniushub_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test local flow exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "local_api"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_api" + + mock_geniushub_client.request.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_geniushub_client.request.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_local_duplicate_data( + hass: HomeAssistant, + mock_geniushub_client: AsyncMock, + mock_local_config_entry: MockConfigEntry, +) -> None: + """Test local flow aborts on duplicate data.""" + mock_local_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "local_api"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_api" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_local_duplicate_mac( + hass: HomeAssistant, + mock_geniushub_client: AsyncMock, + mock_local_config_entry: MockConfigEntry, +) -> None: + """Test local flow aborts on duplicate MAC.""" + mock_local_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "local_api"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_api" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "10.0.0.131", + CONF_USERNAME: "test-username1", + CONF_PASSWORD: "test-password", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_full_cloud_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_geniushub_client: AsyncMock, +) -> None: + """Test full cloud flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_api"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_api" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: "abcdef", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Genius hub" + assert result["data"] == { + CONF_TOKEN: "abcdef", + } + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (socket.gaierror, "invalid_host"), + ( + ClientResponseError(AsyncMock(), (), status=HTTPStatus.UNAUTHORIZED), + "invalid_auth", + ), + ( + ClientResponseError(AsyncMock(), (), status=HTTPStatus.NOT_FOUND), + "invalid_host", + ), + (TimeoutError, "cannot_connect"), + (ClientConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_cloud_flow_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_geniushub_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test cloud flow exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_api"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_api" + + mock_geniushub_client.request.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: "abcdef", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_geniushub_client.request.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: "abcdef", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_cloud_duplicate( + hass: HomeAssistant, + mock_geniushub_client: AsyncMock, + mock_cloud_config_entry: MockConfigEntry, +) -> None: + """Test cloud flow aborts on duplicate data.""" + mock_cloud_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_api"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_api" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: "abcdef", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("data"), + [ + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_MAC: "aa:bb:cc:dd:ee:ff", + }, + ], +) +async def test_import_local_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_geniushub_client: AsyncMock, + data: dict[str, Any], +) -> None: + """Test full local import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=data, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "10.0.0.130" + assert result["data"] == data + assert result["result"].unique_id == "aa:bb:cc:dd:ee:ff" + + +@pytest.mark.parametrize( + ("data"), + [ + { + CONF_TOKEN: "abcdef", + }, + { + CONF_TOKEN: "abcdef", + CONF_MAC: "aa:bb:cc:dd:ee:ff", + }, + ], +) +async def test_import_cloud_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_geniushub_client: AsyncMock, + data: dict[str, Any], +) -> None: + """Test full cloud import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=data, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Genius hub" + assert result["data"] == data + + +@pytest.mark.parametrize( + ("data"), + [ + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_MAC: "aa:bb:cc:dd:ee:ff", + }, + { + CONF_TOKEN: "abcdef", + }, + { + CONF_TOKEN: "abcdef", + CONF_MAC: "aa:bb:cc:dd:ee:ff", + }, + ], +) +@pytest.mark.parametrize( + ("exception", "reason"), + [ + (socket.gaierror, "invalid_host"), + ( + ClientResponseError(AsyncMock(), (), status=HTTPStatus.UNAUTHORIZED), + "invalid_auth", + ), + ( + ClientResponseError(AsyncMock(), (), status=HTTPStatus.NOT_FOUND), + "invalid_host", + ), + (TimeoutError, "cannot_connect"), + (ClientConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_import_flow_exceptions( + hass: HomeAssistant, + mock_geniushub_client: AsyncMock, + data: dict[str, Any], + exception: Exception, + reason: str, +) -> None: + """Test import flow exceptions.""" + mock_geniushub_client.request.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=data, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +@pytest.mark.parametrize( + ("data"), + [ + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + { + CONF_HOST: "10.0.0.131", + CONF_USERNAME: "test-username1", + CONF_PASSWORD: "test-password", + }, + ], +) +async def test_import_flow_local_duplicate( + hass: HomeAssistant, + mock_geniushub_client: AsyncMock, + mock_local_config_entry: MockConfigEntry, + data: dict[str, Any], +) -> None: + """Test import flow aborts on local duplicate data.""" + mock_local_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=data, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import_flow_cloud_duplicate( + hass: HomeAssistant, + mock_geniushub_client: AsyncMock, + mock_cloud_config_entry: MockConfigEntry, +) -> None: + """Test import flow aborts on cloud duplicate data.""" + mock_cloud_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_TOKEN: "abcdef", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" -- GitLab