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