From 958c199d8011cf3db86df9f5a2336fc256c5458d Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 18 Nov 2021 23:00:42 +0100 Subject: [PATCH] Brunt package update with async, data update coordinator and config flow (#49714) * implemented config_flow and dataupdatecoordinator * implemented config flow, dataupdatecoordinator and tests. * undid extra vscode task * fixed pylint errors * updates based on review * fix mypy in reauth * fast interval to 5 sec * fixed test patches and others from review * added released package * deleted wrong line from coveragerc * updates to config and tests * fixed test patch --- .coveragerc | 2 + homeassistant/components/brunt/__init__.py | 77 ++++++ homeassistant/components/brunt/config_flow.py | 119 +++++++++ homeassistant/components/brunt/const.py | 17 ++ homeassistant/components/brunt/cover.py | 231 ++++++++++++------ homeassistant/components/brunt/manifest.json | 3 +- homeassistant/components/brunt/strings.json | 29 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + tests/components/brunt/__init__.py | 1 + tests/components/brunt/test_config_flow.py | 180 ++++++++++++++ 12 files changed, 586 insertions(+), 79 deletions(-) create mode 100644 homeassistant/components/brunt/config_flow.py create mode 100644 homeassistant/components/brunt/const.py create mode 100644 homeassistant/components/brunt/strings.json create mode 100644 tests/components/brunt/__init__.py create mode 100644 tests/components/brunt/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 2b247c2c923..1adede05ca4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -138,7 +138,9 @@ omit = homeassistant/components/broadlink/updater.py homeassistant/components/brottsplatskartan/sensor.py homeassistant/components/browser/* + homeassistant/components/brunt/__init__.py homeassistant/components/brunt/cover.py + homeassistant/components/brunt/const.py homeassistant/components/bsblan/climate.py homeassistant/components/bt_home_hub_5/device_tracker.py homeassistant/components/bt_smarthub/device_tracker.py diff --git a/homeassistant/components/brunt/__init__.py b/homeassistant/components/brunt/__init__.py index f89d57cdec1..37c9fd73632 100644 --- a/homeassistant/components/brunt/__init__.py +++ b/homeassistant/components/brunt/__init__.py @@ -1 +1,78 @@ """The brunt component.""" +from __future__ import annotations + +import logging + +from aiohttp.client_exceptions import ClientResponseError, ServerDisconnectedError +import async_timeout +from brunt import BruntClientAsync + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DATA_BAPI, DATA_COOR, DOMAIN, PLATFORMS, REGULAR_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Brunt using config flow.""" + session = async_get_clientsession(hass) + bapi = BruntClientAsync( + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + session=session, + ) + try: + await bapi.async_login() + except ServerDisconnectedError as exc: + raise ConfigEntryNotReady("Brunt not ready to connect.") from exc + except ClientResponseError as exc: + raise ConfigEntryAuthFailed( + f"Brunt could not connect with username: {entry.data[CONF_USERNAME]}." + ) from exc + + async def async_update_data(): + """Fetch data from the Brunt endpoint for all Things. + + Error 403 is the API response for any kind of authentication error (failed password or email) + Error 401 is the API response for things that are not part of the account, could happen when a device is deleted from the account. + """ + try: + async with async_timeout.timeout(10): + things = await bapi.async_get_things(force=True) + return {thing.SERIAL: thing for thing in things} + except ServerDisconnectedError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + except ClientResponseError as err: + if err.status == 403: + raise ConfigEntryAuthFailed() from err + if err.status == 401: + _LOGGER.warning("Device not found, will reload Brunt integration") + await hass.config_entries.async_reload(entry.entry_id) + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="brunt", + update_method=async_update_data, + update_interval=REGULAR_INTERVAL, + ) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {DATA_BAPI: bapi, DATA_COOR: coordinator} + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/brunt/config_flow.py b/homeassistant/components/brunt/config_flow.py new file mode 100644 index 00000000000..636a9affddd --- /dev/null +++ b/homeassistant/components/brunt/config_flow.py @@ -0,0 +1,119 @@ +"""Config flow for brunt integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from aiohttp import ClientResponseError +from aiohttp.client_exceptions import ServerDisconnectedError +from brunt import BruntClientAsync +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) +REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) + + +async def validate_input(user_input: dict[str, Any]) -> dict[str, str] | None: + """Login to the brunt api and return errors if any.""" + errors = None + bapi = BruntClientAsync( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ) + try: + await bapi.async_login() + except ClientResponseError as exc: + if exc.status == 403: + _LOGGER.warning("Brunt Credentials are incorrect") + errors = {"base": "invalid_auth"} + else: + _LOGGER.exception("Unknown error when trying to login to Brunt: %s", exc) + errors = {"base": "unknown"} + except ServerDisconnectedError: + _LOGGER.warning("Cannot connect to Brunt") + errors = {"base": "cannot_connect"} + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Unknown error when trying to login to Brunt: %s", exc) + errors = {"base": "unknown"} + finally: + await bapi.async_close() + return errors + + +class BruntConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Brunt.""" + + VERSION = 1 + + _reauth_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) + + errors = await validate_input(user_input) + if errors is not None: + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=user_input, + ) + + async def async_step_reauth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + assert self._reauth_entry + username = self._reauth_entry.data[CONF_USERNAME] + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, + description_placeholders={"username": username}, + ) + user_input[CONF_USERNAME] = username + errors = await validate_input(user_input) + if errors is not None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, + errors=errors, + description_placeholders={"username": username}, + ) + + self.hass.config_entries.async_update_entry(self._reauth_entry, data=user_input) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import config from configuration.yaml.""" + await self.async_set_unique_id(import_config[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + return await self.async_step_user(import_config) diff --git a/homeassistant/components/brunt/const.py b/homeassistant/components/brunt/const.py new file mode 100644 index 00000000000..4ffaf2875c9 --- /dev/null +++ b/homeassistant/components/brunt/const.py @@ -0,0 +1,17 @@ +"""Constants for Brunt.""" +from datetime import timedelta + +DOMAIN = "brunt" +ATTR_REQUEST_POSITION = "request_position" +NOTIFICATION_ID = "brunt_notification" +NOTIFICATION_TITLE = "Brunt Cover Setup" +ATTRIBUTION = "Based on an unofficial Brunt SDK." +PLATFORMS = ["cover"] +DATA_BAPI = "bapi" +DATA_COOR = "coordinator" + +CLOSED_POSITION = 0 +OPEN_POSITION = 100 + +REGULAR_INTERVAL = timedelta(seconds=20) +FAST_INTERVAL = timedelta(seconds=5) diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index 650ce9c05c6..cc0ecd0feab 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -1,84 +1,134 @@ """Support for Brunt Blind Engine covers.""" from __future__ import annotations +from collections.abc import MutableMapping import logging +from typing import Any -from brunt import BruntAPI -import voluptuous as vol +from aiohttp.client_exceptions import ClientResponseError +from brunt import BruntClientAsync, Thing from homeassistant.components.cover import ( ATTR_POSITION, - DEVICE_CLASS_WINDOW, - PLATFORM_SCHEMA, + DEVICE_CLASS_SHADE, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION, CoverEntity, ) -from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME -import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ( + ATTR_REQUEST_POSITION, + ATTRIBUTION, + CLOSED_POSITION, + DATA_BAPI, + DATA_COOR, + DOMAIN, + FAST_INTERVAL, + OPEN_POSITION, + REGULAR_INTERVAL, +) _LOGGER = logging.getLogger(__name__) COVER_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION -ATTR_REQUEST_POSITION = "request_position" -NOTIFICATION_ID = "brunt_notification" -NOTIFICATION_TITLE = "Brunt Cover Setup" -ATTRIBUTION = "Based on an unofficial Brunt SDK." -CLOSED_POSITION = 0 -OPEN_POSITION = 100 - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the brunt platform.""" - - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - - bapi = BruntAPI(username=username, password=password) - try: - if not (things := bapi.getThings()["things"]): - _LOGGER.error("No things present in account") - else: - add_entities( - [ - BruntDevice(bapi, thing["NAME"], thing["thingUri"]) - for thing in things - ], - True, - ) - except (TypeError, KeyError, NameError, ValueError) as ex: - _LOGGER.error("%s", ex) - hass.components.persistent_notification.create( - "Error: {ex}
You will need to restart hass after fixing.", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Component setup, run import config flow for each entry in config.""" + _LOGGER.warning( + "Loading brunt via platform config is deprecated; The configuration has been migrated to a config entry and can be safely removed from configuration.yaml" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config ) + ) -class BruntDevice(CoverEntity): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the brunt platform.""" + bapi: BruntClientAsync = hass.data[DOMAIN][entry.entry_id][DATA_BAPI] + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][DATA_COOR] + + async_add_entities( + BruntDevice(coordinator, serial, thing, bapi, entry.entry_id) + for serial, thing in coordinator.data.items() + ) + + +class BruntDevice(CoordinatorEntity, CoverEntity): """ Representation of a Brunt cover device. Contains the common logic for all Brunt devices. """ - _attr_device_class = DEVICE_CLASS_WINDOW - _attr_supported_features = COVER_FEATURES - - def __init__(self, bapi, name, thing_uri): + def __init__( + self, + coordinator: DataUpdateCoordinator, + serial: str, + thing: Thing, + bapi: BruntClientAsync, + entry_id: str, + ) -> None: """Init the Brunt device.""" + super().__init__(coordinator) + self._attr_unique_id = serial self._bapi = bapi - self._attr_name = name - self._thing_uri = thing_uri + self._thing = thing + self._entry_id = entry_id - self._state = {} + self._remove_update_listener = None + + self._attr_name = self._thing.NAME + self._attr_device_class = DEVICE_CLASS_SHADE + self._attr_supported_features = COVER_FEATURES + self._attr_attribution = ATTRIBUTION + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + name=self._attr_name, + via_device=(DOMAIN, self._entry_id), + manufacturer="Brunt", + sw_version=self._thing.FW_VERSION, + model=self._thing.MODEL, + ) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_add_listener(self._brunt_update_listener) + ) + + @property + def current_cover_position(self) -> int | None: + """ + Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + pos = self.coordinator.data[self.unique_id].currentPosition + return int(pos) if pos is not None else None @property def request_cover_position(self) -> int | None: @@ -89,8 +139,8 @@ class BruntDevice(CoverEntity): to Brunt, at times there is a diff of 1 to current None is unknown, 0 is closed, 100 is fully open. """ - pos = self._state.get("requestPosition") - return int(pos) if pos else None + pos = self.coordinator.data[self.unique_id].requestPosition + return int(pos) if pos is not None else None @property def move_state(self) -> int | None: @@ -99,37 +149,64 @@ class BruntDevice(CoverEntity): None is unknown, 0 when stopped, 1 when opening, 2 when closing """ - mov = self._state.get("moveState") - return int(mov) if mov else None + mov = self.coordinator.data[self.unique_id].moveState + return int(mov) if mov is not None else None - def update(self): - """Poll the current state of the device.""" - try: - self._state = self._bapi.getState(thingUri=self._thing_uri).get("thing") - self._attr_available = True - except (TypeError, KeyError, NameError, ValueError) as ex: - _LOGGER.error("%s", ex) - self._attr_available = False - self._attr_is_opening = self.move_state == 1 - self._attr_is_closing = self.move_state == 2 - pos = self._state.get("currentPosition") - self._attr_current_cover_position = int(pos) if pos else None - self._attr_is_closed = self.current_cover_position == CLOSED_POSITION - self._attr_extra_state_attributes = { - ATTR_ATTRIBUTION: ATTRIBUTION, + @property + def is_opening(self) -> bool: + """Return if the cover is opening or not.""" + return self.move_state == 1 + + @property + def is_closing(self) -> bool: + """Return if the cover is closing or not.""" + return self.move_state == 2 + + @property + def extra_state_attributes(self) -> MutableMapping[str, Any]: + """Return the detailed device state attributes.""" + return { ATTR_REQUEST_POSITION: self.request_cover_position, } - def open_cover(self, **kwargs): + @property + def is_closed(self) -> bool: + """Return true if cover is closed, else False.""" + return self.current_cover_position == CLOSED_POSITION + + async def async_open_cover(self, **kwargs: Any) -> None: """Set the cover to the open position.""" - self._bapi.changeRequestPosition(OPEN_POSITION, thingUri=self._thing_uri) + await self._async_update_cover(OPEN_POSITION) - def close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Set the cover to the closed position.""" - self._bapi.changeRequestPosition(CLOSED_POSITION, thingUri=self._thing_uri) + await self._async_update_cover(CLOSED_POSITION) - def set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Set the cover to a specific position.""" - self._bapi.changeRequestPosition( - kwargs[ATTR_POSITION], thingUri=self._thing_uri - ) + await self._async_update_cover(int(kwargs[ATTR_POSITION])) + + async def _async_update_cover(self, position: int) -> None: + """Set the cover to the new position and wait for the update to be reflected.""" + try: + await self._bapi.async_change_request_position( + position, thingUri=self._thing.thingUri + ) + except ClientResponseError as exc: + raise HomeAssistantError( + f"Unable to reposition {self._thing.NAME}" + ) from exc + self.coordinator.update_interval = FAST_INTERVAL + await self.coordinator.async_request_refresh() + + @callback + def _brunt_update_listener(self) -> None: + """Update the update interval after each refresh.""" + if ( + self.request_cover_position + == self._bapi.last_requested_positions[self._thing.thingUri] + and self.move_state == 0 + ): + self.coordinator.update_interval = REGULAR_INTERVAL + else: + self.coordinator.update_interval = FAST_INTERVAL diff --git a/homeassistant/components/brunt/manifest.json b/homeassistant/components/brunt/manifest.json index ba7d1ba117d..976b017ca09 100644 --- a/homeassistant/components/brunt/manifest.json +++ b/homeassistant/components/brunt/manifest.json @@ -1,8 +1,9 @@ { "domain": "brunt", "name": "Brunt Blind Engine", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/brunt", - "requirements": ["brunt==0.1.3"], + "requirements": ["brunt==1.0.0"], "codeowners": ["@eavanvalkenburg"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/brunt/strings.json b/homeassistant/components/brunt/strings.json new file mode 100644 index 00000000000..37b2f95bc08 --- /dev/null +++ b/homeassistant/components/brunt/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "title": "Setup your Brunt integration", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Please reenter the password for: {username}", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } + } \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b620366a31b..5d865465e61 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -43,6 +43,7 @@ FLOWS = [ "braviatv", "broadlink", "brother", + "brunt", "bsblan", "buienradar", "canary", diff --git a/requirements_all.txt b/requirements_all.txt index fdc4013ea50..7b060b33723 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -440,7 +440,7 @@ brother==1.1.0 brottsplatskartan==0.0.1 # homeassistant.components.brunt -brunt==0.1.3 +brunt==1.0.0 # homeassistant.components.bsblan bsblan==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8567435edd..d399d56e424 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -277,6 +277,9 @@ broadlink==0.18.0 # homeassistant.components.brother brother==1.1.0 +# homeassistant.components.brunt +brunt==1.0.0 + # homeassistant.components.bsblan bsblan==0.4.0 diff --git a/tests/components/brunt/__init__.py b/tests/components/brunt/__init__.py new file mode 100644 index 00000000000..15060cbaf4c --- /dev/null +++ b/tests/components/brunt/__init__.py @@ -0,0 +1 @@ +"""Brunt tests.""" diff --git a/tests/components/brunt/test_config_flow.py b/tests/components/brunt/test_config_flow.py new file mode 100644 index 00000000000..f053a6d18b0 --- /dev/null +++ b/tests/components/brunt/test_config_flow.py @@ -0,0 +1,180 @@ +"""Test the Brunt config flow.""" +from unittest.mock import Mock, patch + +from aiohttp import ClientResponseError +from aiohttp.client_exceptions import ServerDisconnectedError +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.brunt.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +CONFIG = {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"} + + +async def test_form(hass): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch( + "homeassistant.components.brunt.config_flow.BruntClientAsync.async_login", + return_value=None, + ), patch( + "homeassistant.components.brunt.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "test-username" + assert result2["data"] == CONFIG + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import(hass): + """Test we get the form.""" + + with patch( + "homeassistant.components.brunt.config_flow.BruntClientAsync.async_login", + return_value=None, + ), patch( + "homeassistant.components.brunt.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=CONFIG + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == CONFIG + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_duplicate_login(hass): + """Test uniqueness of username.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG, + title="test-username", + unique_id="test-username", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.brunt.config_flow.BruntClientAsync.async_login", + return_value=None, + ), patch( + "homeassistant.components.brunt.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=CONFIG + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_form_duplicate_login(hass): + """Test uniqueness of username.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG, + title="test-username", + unique_id="test-username", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.brunt.config_flow.BruntClientAsync.async_login", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=CONFIG + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "side_effect, error_message", + [ + (ServerDisconnectedError, "cannot_connect"), + (ClientResponseError(Mock(), None, status=403), "invalid_auth"), + (ClientResponseError(Mock(), None, status=401), "unknown"), + (Exception, "unknown"), + ], +) +async def test_form_error(hass, side_effect, error_message): + """Test we handle cannot connect.""" + with patch( + "homeassistant.components.brunt.config_flow.BruntClientAsync.async_login", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": error_message} + + +@pytest.mark.parametrize( + "side_effect, result_type, password, step_id, reason", + [ + (None, data_entry_flow.RESULT_TYPE_ABORT, "test", None, "reauth_successful"), + ( + Exception, + data_entry_flow.RESULT_TYPE_FORM, + CONFIG[CONF_PASSWORD], + "reauth_confirm", + None, + ), + ], +) +async def test_reauth(hass, side_effect, result_type, password, step_id, reason): + """Test uniqueness of username.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG, + title="test-username", + unique_id="test-username", + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, + data=None, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + with patch( + "homeassistant.components.brunt.config_flow.BruntClientAsync.async_login", + return_value=None, + side_effect=side_effect, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"password": "test"}, + ) + assert result3["type"] == result_type + assert entry.data["password"] == password + assert result3.get("step_id", None) == step_id + assert result3.get("reason", None) == reason