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>
This commit is contained in:
GeoffAtHome 2024-07-21 18:57:41 +01:00 committed by GitHub
parent 6de824e875
commit 890b54e36f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 869 additions and 91 deletions

View file

@ -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

View file

@ -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:

View file

@ -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):

View file

@ -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
)

View file

@ -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

View file

@ -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,
)

View file

@ -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"],

View file

@ -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):

View file

@ -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",

View file

@ -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

View file

@ -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
)

View file

@ -202,6 +202,7 @@ FLOWS = {
"gardena_bluetooth",
"gdacs",
"generic",
"geniushub",
"geo_json_events",
"geocaching",
"geofency",

View file

@ -2124,7 +2124,7 @@
"geniushub": {
"name": "Genius Hub",
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "local_polling"
},
"geo_json_events": {

View file

@ -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

View file

@ -0,0 +1 @@
"""Tests for the geniushub integration."""

View file

@ -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",
},
)

View file

@ -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"