Improve Elmax code quality (#61273)

Co-authored-by: Marvin Wichmann <marvin@fam-wichmann.de>
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Alberto Geniola 2021-12-27 19:50:43 +01:00 committed by GitHub
parent 0d957ad93b
commit 1f425b1942
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 426 additions and 319 deletions

View File

@ -1,11 +1,9 @@
"""Elmax integration common classes and utilities.""" """Elmax integration common classes and utilities."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from datetime import timedelta from datetime import timedelta
import logging import logging
from logging import Logger from logging import Logger
from typing import Any
import async_timeout import async_timeout
from elmax_api.exceptions import ( from elmax_api.exceptions import (
@ -15,20 +13,24 @@ from elmax_api.exceptions import (
ElmaxNetworkError, ElmaxNetworkError,
) )
from elmax_api.http import Elmax from elmax_api.http import Elmax
from elmax_api.model.actuator import Actuator
from elmax_api.model.endpoint import DeviceEndpoint from elmax_api.model.endpoint import DeviceEndpoint
from elmax_api.model.panel import PanelEntry, PanelStatus from elmax_api.model.panel import PanelEntry, PanelStatus
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from .const import DEFAULT_TIMEOUT, DOMAIN from .const import DEFAULT_TIMEOUT, DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class ElmaxCoordinator(DataUpdateCoordinator): class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]):
"""Coordinator helper to handle Elmax API polling.""" """Coordinator helper to handle Elmax API polling."""
def __init__( def __init__(
@ -57,16 +59,11 @@ class ElmaxCoordinator(DataUpdateCoordinator):
"""Return the panel entry.""" """Return the panel entry."""
return self._panel_entry return self._panel_entry
@property def get_actuator_state(self, actuator_id: str) -> Actuator:
def panel_status(self) -> PanelStatus | None: """Return state of a specific actuator."""
"""Return the last fetched panel status."""
return self.data
def get_endpoint_state(self, endpoint_id: str) -> DeviceEndpoint | None:
"""Return the last fetched status for the given endpoint-id."""
if self._state_by_endpoint is not None: if self._state_by_endpoint is not None:
return self._state_by_endpoint.get(endpoint_id) return self._state_by_endpoint.get(actuator_id)
return None raise HomeAssistantError("Unknown actuator")
@property @property
def http_client(self): def http_client(self):
@ -78,18 +75,17 @@ class ElmaxCoordinator(DataUpdateCoordinator):
async with async_timeout.timeout(DEFAULT_TIMEOUT): async with async_timeout.timeout(DEFAULT_TIMEOUT):
# Retrieve the panel online status first # Retrieve the panel online status first
panels = await self._client.list_control_panels() panels = await self._client.list_control_panels()
panels = list(filter(lambda x: x.hash == self._panel_id, panels)) panel = next(
(panel for panel in panels if panel.hash == self._panel_id), None
)
# If the panel is no more available within the given. Raise config error as the user must # If the panel is no more available within the given. Raise config error as the user must
# reconfigure it in order to make it work again # reconfigure it in order to make it work again
if len(panels) < 1: if not panel:
_LOGGER.error( raise ConfigEntryAuthFailed(
"Panel ID %s is no more linked to this user account", f"Panel ID {self._panel_id} is no more linked to this user account"
self._panel_id,
) )
raise ConfigEntryAuthFailed()
panel = panels[0]
self._panel_entry = panel self._panel_entry = panel
# If the panel is online, proceed with fetching its state # If the panel is online, proceed with fetching its state
@ -109,27 +105,22 @@ class ElmaxCoordinator(DataUpdateCoordinator):
return None return None
except ElmaxBadPinError as err: except ElmaxBadPinError as err:
_LOGGER.error("Control panel pin was refused") raise ConfigEntryAuthFailed("Control panel pin was refused") from err
raise ConfigEntryAuthFailed from err
except ElmaxBadLoginError as err: except ElmaxBadLoginError as err:
_LOGGER.error("Refused username/password") raise ConfigEntryAuthFailed("Refused username/password") from err
raise ConfigEntryAuthFailed from err
except ElmaxApiError as err: except ElmaxApiError as err:
raise HomeAssistantError( raise UpdateFailed(f"Error communicating with ELMAX API: {err}") from err
f"Error communicating with ELMAX API: {err}"
) from err
except ElmaxNetworkError as err: except ElmaxNetworkError as err:
raise HomeAssistantError( raise UpdateFailed(
"Network error occurred while contacting ELMAX cloud" "A network error occurred while communicating with Elmax cloud."
) from err ) from err
except Exception as err:
_LOGGER.exception("Unexpected exception")
raise HomeAssistantError("An unexpected error occurred") from err
class ElmaxEntity(Entity): class ElmaxEntity(CoordinatorEntity):
"""Wrapper for Elmax entities.""" """Wrapper for Elmax entities."""
coordinator: ElmaxCoordinator
def __init__( def __init__(
self, self,
panel: PanelEntry, panel: PanelEntry,
@ -138,21 +129,11 @@ class ElmaxEntity(Entity):
coordinator: ElmaxCoordinator, coordinator: ElmaxCoordinator,
) -> None: ) -> None:
"""Construct the object.""" """Construct the object."""
super().__init__(coordinator=coordinator)
self._panel = panel self._panel = panel
self._device = elmax_device self._device = elmax_device
self._panel_version = panel_version self._panel_version = panel_version
self._coordinator = coordinator self._client = coordinator.http_client
self._transitory_state = None
@property
def transitory_state(self) -> Any | None:
"""Return the transitory state for this entity."""
return self._transitory_state
@transitory_state.setter
def transitory_state(self, value: Any) -> None:
"""Set the transitory state value."""
self._transitory_state = value
@property @property
def panel_id(self) -> str: def panel_id(self) -> str:
@ -169,21 +150,13 @@ class ElmaxEntity(Entity):
"""Return the entity name.""" """Return the entity name."""
return self._device.name return self._device.name
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return extra attributes."""
return {
"index": self._device.index,
"visible": self._device.visible,
}
@property @property
def device_info(self): def device_info(self):
"""Return device specific attributes.""" """Return device specific attributes."""
return { return {
"identifiers": {(DOMAIN, self._panel.hash)}, "identifiers": {(DOMAIN, self._panel.hash)},
"name": self._panel.get_name_by_user( "name": self._panel.get_name_by_user(
self._coordinator.http_client.get_authenticated_username() self.coordinator.http_client.get_authenticated_username()
), ),
"manufacturer": "Elmax", "manufacturer": "Elmax",
"model": self._panel_version, "model": self._panel_version,
@ -192,39 +165,5 @@ class ElmaxEntity(Entity):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return True if entity is available.""" """Return if entity is available."""
return self._panel.online return super().available and self._panel.online
def _http_data_changed(self) -> None:
# Whenever new HTTP data is received from the coordinator we extract the stat of this
# device and store it locally for later use
device_state = self._coordinator.get_endpoint_state(self._device.endpoint_id)
if self._device is None or device_state.__dict__ != self._device.__dict__:
# If HTTP data has changed, we need to schedule a forced refresh
self._device = device_state
self.async_schedule_update_ha_state(force_refresh=True)
# Reset the transitory state as we did receive a fresh state
self._transitory_state = None
@property
def should_poll(self) -> bool:
"""Return True if entity has to be polled for state.
False if entity pushes its state to HA.
"""
return False
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass.
To be extended by integrations.
"""
self._coordinator.async_add_listener(self._http_data_changed)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass.
To be extended by integrations.
"""
self._coordinator.async_remove_listener(self._http_data_changed)

View File

@ -50,16 +50,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for elmax-cloud.""" """Handle a config flow for elmax-cloud."""
VERSION = 1 VERSION = 1
_client: Elmax
def __init__(self): _username: str
"""Initialize.""" _password: str
self._client: Elmax = None _panels_schema: vol.Schema
self._username: str = None _panel_names: dict
self._password: str = None _reauth_username: str | None
self._panels_schema = None _reauth_panelid: str | None
self._panel_names = None
self._reauth_username = None
self._reauth_panelid = None
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -69,69 +66,73 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is None: if user_input is None:
return self.async_show_form(step_id="user", data_schema=LOGIN_FORM_SCHEMA) return self.async_show_form(step_id="user", data_schema=LOGIN_FORM_SCHEMA)
errors: dict[str, str] = {}
username = user_input[CONF_ELMAX_USERNAME] username = user_input[CONF_ELMAX_USERNAME]
password = user_input[CONF_ELMAX_PASSWORD] password = user_input[CONF_ELMAX_PASSWORD]
# Otherwise, it means we are handling now the "submission" of the user form. # Otherwise, it means we are handling now the "submission" of the user form.
# In this case, let's try to log in to the Elmax cloud and retrieve the available panels. # In this case, let's try to log in to the Elmax cloud and retrieve the available panels.
try: try:
client = Elmax(username=username, password=password) client = await self._async_login(username=username, password=password)
await client.login()
# If the login succeeded, retrieve the list of available panels and filter the online ones
online_panels = [x for x in await client.list_control_panels() if x.online]
# If no online panel was found, we display an error in the next UI.
panels = list(online_panels)
if len(panels) < 1:
raise NoOnlinePanelsError()
# Show the panel selection.
# We want the user to choose the panel using the associated name, we set up a mapping
# dictionary to handle that case.
panel_names: dict[str, str] = {}
username = client.get_authenticated_username()
for panel in panels:
_store_panel_by_name(
panel=panel, username=username, panel_names=panel_names
)
self._client = client
self._panel_names = panel_names
schema = vol.Schema(
{
vol.Required(CONF_ELMAX_PANEL_NAME): vol.In(
self._panel_names.keys()
),
vol.Required(CONF_ELMAX_PANEL_PIN, default="000000"): str,
}
)
self._panels_schema = schema
self._username = username
self._password = password
return self.async_show_form(
step_id="panels", data_schema=schema, errors=errors
)
except ElmaxBadLoginError: except ElmaxBadLoginError:
_LOGGER.error("Wrong credentials or failed login") return self.async_show_form(
errors["base"] = "bad_auth" step_id="user",
except NoOnlinePanelsError: data_schema=LOGIN_FORM_SCHEMA,
_LOGGER.warning("No online device panel was found") errors={"base": "invalid_auth"},
errors["base"] = "no_panel_online" )
except ElmaxNetworkError: except ElmaxNetworkError:
_LOGGER.exception("A network error occurred") _LOGGER.exception("A network error occurred")
errors["base"] = "network_error" return self.async_show_form(
step_id="user",
data_schema=LOGIN_FORM_SCHEMA,
errors={"base": "network_error"},
)
# If an error occurred, show back the login form. # If the login succeeded, retrieve the list of available panels and filter the online ones
return self.async_show_form( online_panels = [x for x in await client.list_control_panels() if x.online]
step_id="user", data_schema=LOGIN_FORM_SCHEMA, errors=errors
# If no online panel was found, we display an error in the next UI.
if not online_panels:
return self.async_show_form(
step_id="user",
data_schema=LOGIN_FORM_SCHEMA,
errors={"base": "no_panel_online"},
)
# Show the panel selection.
# We want the user to choose the panel using the associated name, we set up a mapping
# dictionary to handle that case.
panel_names: dict[str, str] = {}
username = client.get_authenticated_username()
for panel in online_panels:
_store_panel_by_name(
panel=panel, username=username, panel_names=panel_names
)
self._client = client
self._panel_names = panel_names
schema = vol.Schema(
{
vol.Required(CONF_ELMAX_PANEL_NAME): vol.In(self._panel_names.keys()),
vol.Required(CONF_ELMAX_PANEL_PIN, default="000000"): str,
}
) )
self._panels_schema = schema
self._username = username
self._password = password
# If everything went OK, proceed to panel selection.
return await self.async_step_panels(user_input=None)
async def async_step_panels(self, user_input: dict[str, Any]) -> FlowResult: async def async_step_panels(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle Panel selection step.""" """Handle Panel selection step."""
errors = {} errors: dict[str, Any] = {}
if user_input is None:
return self.async_show_form(
step_id="panels", data_schema=self._panels_schema, errors=errors
)
panel_name = user_input[CONF_ELMAX_PANEL_NAME] panel_name = user_input[CONF_ELMAX_PANEL_NAME]
panel_pin = user_input[CONF_ELMAX_PANEL_PIN] panel_pin = user_input[CONF_ELMAX_PANEL_PIN]
@ -160,7 +161,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_pin" errors["base"] = "invalid_pin"
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error occurred") _LOGGER.exception("Error occurred")
errors["base"] = "unknown_error" errors["base"] = "unknown"
return self.async_show_form( return self.async_show_form(
step_id="panels", data_schema=self._panels_schema, errors=errors step_id="panels", data_schema=self._panels_schema, errors=errors
@ -184,8 +185,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# and verify its pin is correct. # and verify its pin is correct.
try: try:
# Test login. # Test login.
client = Elmax(username=self._reauth_username, password=password) client = await self._async_login(
await client.login() username=self._reauth_username, password=password
)
# Make sure the panel we are authenticating to is still available. # Make sure the panel we are authenticating to is still available.
panels = [ panels = [
@ -220,7 +222,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
_LOGGER.error( _LOGGER.error(
"Wrong credentials or failed login while re-authenticating" "Wrong credentials or failed login while re-authenticating"
) )
errors["base"] = "bad_auth" errors["base"] = "invalid_auth"
except NoOnlinePanelsError: except NoOnlinePanelsError:
_LOGGER.warning( _LOGGER.warning(
"Panel ID %s is no longer associated to this user", "Panel ID %s is no longer associated to this user",
@ -245,6 +247,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="reauth_confirm", data_schema=schema, errors=errors step_id="reauth_confirm", data_schema=schema, errors=errors
) )
@staticmethod
async def _async_login(username: str, password: str) -> Elmax:
"""Log in to the Elmax cloud and return the http client."""
client = Elmax(username=username, password=password)
await client.login()
return client
class NoOnlinePanelsError(HomeAssistantError): class NoOnlinePanelsError(HomeAssistantError):
"""Error occurring when no online panel was found.""" """Error occurring when no online panel was found."""

View File

@ -1,9 +1,7 @@
{ {
"title": "Elmax Cloud Setup",
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Account Login",
"description": "Please login to the Elmax cloud using your credentials", "description": "Please login to the Elmax cloud using your credentials",
"data": { "data": {
"password": "[%key:common::config_flow::data::password%]", "password": "[%key:common::config_flow::data::password%]",
@ -11,7 +9,6 @@
} }
}, },
"panels": { "panels": {
"title": "Panel selection",
"description": "Select which panel you would like to control with this integration. Please note that the panel must be ON in order to be configured.", "description": "Select which panel you would like to control with this integration. Please note that the panel must be ON in order to be configured.",
"data": { "data": {
"panel_name": "Panel Name", "panel_name": "Panel Name",
@ -22,10 +19,10 @@
}, },
"error": { "error": {
"no_panel_online": "No online Elmax control panel was found.", "no_panel_online": "No online Elmax control panel was found.",
"bad_auth": "Invalid authentication", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"network_error": "A network error occurred", "network_error": "A network error occurred",
"invalid_pin": "The provided pin is invalid", "invalid_pin": "The provided pin is invalid",
"unknown_error": "An unexpected error occurred" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"

View File

@ -1,4 +1,6 @@
"""Elmax switch platform.""" """Elmax switch platform."""
import asyncio
import logging
from typing import Any from typing import Any
from elmax_api.model.command import SwitchCommand from elmax_api.model.command import SwitchCommand
@ -13,39 +15,7 @@ from . import ElmaxCoordinator
from .common import ElmaxEntity from .common import ElmaxEntity
from .const import DOMAIN from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class ElmaxSwitch(ElmaxEntity, SwitchEntity):
"""Implement the Elmax switch entity."""
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
if self.transitory_state is not None:
return self.transitory_state
return self._device.opened
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
client = self._coordinator.http_client
await client.execute_command(
endpoint_id=self._device.endpoint_id, command=SwitchCommand.TURN_ON
)
self.transitory_state = True
await self.async_update_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
client = self._coordinator.http_client
await client.execute_command(
endpoint_id=self._device.endpoint_id, command=SwitchCommand.TURN_OFF
)
self.transitory_state = False
await self.async_update_ha_state()
@property
def assumed_state(self) -> bool:
"""Return True if unable to access real state of the entity."""
return False
async def async_setup_entry( async def async_setup_entry(
@ -58,7 +28,7 @@ async def async_setup_entry(
known_devices = set() known_devices = set()
def _discover_new_devices(): def _discover_new_devices():
panel_status = coordinator.panel_status # type: PanelStatus panel_status: PanelStatus = coordinator.data
# In case the panel is offline, its status will be None. In that case, simply do nothing # In case the panel is offline, its status will be None. In that case, simply do nothing
if panel_status is None: if panel_status is None:
return return
@ -82,3 +52,48 @@ async def async_setup_entry(
# Immediately run a discovery, so we don't need to wait for the next update # Immediately run a discovery, so we don't need to wait for the next update
_discover_new_devices() _discover_new_devices()
class ElmaxSwitch(ElmaxEntity, SwitchEntity):
"""Implement the Elmax switch entity."""
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
return self.coordinator.get_actuator_state(self._device.endpoint_id).opened
async def _wait_for_state_change(self) -> bool:
"""Refresh data and wait until the state state changes."""
old_state = self.coordinator.get_actuator_state(self._device.endpoint_id).opened
# Wait a bit at first to let Elmax cloud assimilate the new state.
await asyncio.sleep(2.0)
await self.coordinator.async_refresh()
new_state = self.coordinator.get_actuator_state(self._device.endpoint_id).opened
# First check attempt.
if new_state == old_state:
# Otherwise sleep a bit more and then trigger a final update.
await asyncio.sleep(5.0)
await self.coordinator.async_refresh()
new_state = self.coordinator.get_actuator_state(
self._device.endpoint_id
).opened
return new_state != old_state
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self.coordinator.http_client.execute_command(
endpoint_id=self._device.endpoint_id, command=SwitchCommand.TURN_ON
)
if await self._wait_for_state_change():
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.coordinator.http_client.execute_command(
endpoint_id=self._device.endpoint_id, command=SwitchCommand.TURN_OFF
)
if await self._wait_for_state_change():
self.async_write_ha_state()

View File

@ -1,14 +1,15 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "Device is already configured" "single_instance_allowed": "There already is an integration for that Elmaxc panel."
}, },
"error": { "error": {
"bad_auth": "Invalid authentication", "invalid_auth": "Invalid authentication",
"invalid_pin": "The provided pin is invalid", "invalid_pin": "The provided pin is invalid",
"network_error": "A network error occurred", "network_error": "A network error occurred",
"no_panel_online": "No online Elmax control panel was found.", "no_panel_online": "No online Elmax control panel was found.",
"unknown_error": "An unexpected error occurred" "reauth_panel_disappeared": "The panel is no longer associated to your account.",
"unknown": "Unexpected error"
}, },
"step": { "step": {
"panels": { "panels": {
@ -20,6 +21,16 @@
"description": "Select which panel you would like to control with this integration. Please note that the panel must be ON in order to be configured.", "description": "Select which panel you would like to control with this integration. Please note that the panel must be ON in order to be configured.",
"title": "Panel selection" "title": "Panel selection"
}, },
"reauth_confirm": {
"data": {
"username": "Username",
"password": "Password",
"panel_id": "Panel ID",
"panel_pin": "PIN Code"
},
"description": "Please authenticate again to the Elmax cloud.",
"title": "Re-Authenticate"
},
"user": { "user": {
"data": { "data": {
"password": "Password", "password": "Password",

View File

@ -1,4 +1,4 @@
"""Tests for the Abode config flow.""" """Tests for the Elmax config flow."""
from unittest.mock import patch from unittest.mock import patch
from elmax_api.exceptions import ElmaxBadLoginError, ElmaxBadPinError, ElmaxNetworkError from elmax_api.exceptions import ElmaxBadLoginError, ElmaxBadPinError, ElmaxNetworkError
@ -13,8 +13,8 @@ from homeassistant.components.elmax.const import (
DOMAIN, DOMAIN,
) )
from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.data_entry_flow import FlowResult
from tests.common import MockConfigEntry
from tests.components.elmax import ( from tests.components.elmax import (
MOCK_PANEL_ID, MOCK_PANEL_ID,
MOCK_PANEL_NAME, MOCK_PANEL_NAME,
@ -26,78 +26,6 @@ from tests.components.elmax import (
CONF_POLLING = "polling" CONF_POLLING = "polling"
def _has_error(errors):
return errors is not None and len(errors.keys()) > 0
async def _bootstrap(
hass,
source=config_entries.SOURCE_USER,
username=MOCK_USERNAME,
password=MOCK_PASSWORD,
panel_name=MOCK_PANEL_NAME,
panel_pin=MOCK_PANEL_PIN,
) -> FlowResult:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": source}
)
if result["type"] != data_entry_flow.RESULT_TYPE_FORM or _has_error(
result["errors"]
):
return result
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_ELMAX_USERNAME: username,
CONF_ELMAX_PASSWORD: password,
},
)
if result2["type"] != data_entry_flow.RESULT_TYPE_FORM or _has_error(
result2["errors"]
):
return result2
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_ELMAX_PANEL_NAME: panel_name,
CONF_ELMAX_PANEL_PIN: panel_pin,
},
)
return result3
async def _reauth(hass):
# Trigger reauth
result2 = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH},
data={
CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
},
)
if result2["type"] != data_entry_flow.RESULT_TYPE_FORM or _has_error(
result2["errors"]
):
return result2
# Perform reauth confirm step
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
},
)
return result3
async def test_show_form(hass): async def test_show_form(hass):
"""Test that the form is served with no input.""" """Test that the form is served with no input."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -107,16 +35,67 @@ async def test_show_form(hass):
assert result["step_id"] == "user" assert result["step_id"] == "user"
async def test_standard_setup(hass):
"""Test the standard setup case."""
# Setup once.
show_form_result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.elmax.async_setup_entry",
return_value=True,
):
login_result = await hass.config_entries.flow.async_configure(
show_form_result["flow_id"],
{
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
},
)
result = await hass.config_entries.flow.async_configure(
login_result["flow_id"],
{
CONF_ELMAX_PANEL_NAME: MOCK_PANEL_NAME,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
async def test_one_config_allowed(hass): async def test_one_config_allowed(hass):
"""Test that only one Elmax configuration is allowed for each panel.""" """Test that only one Elmax configuration is allowed for each panel."""
# Setup once. MockConfigEntry(
attempt1 = await _bootstrap(hass) domain=DOMAIN,
assert attempt1["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY data={
CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID,
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
},
unique_id=MOCK_PANEL_ID,
).add_to_hass(hass)
# Attempt to add another instance of the integration for the very same panel, it must fail. # Attempt to add another instance of the integration for the very same panel, it must fail.
attempt2 = await _bootstrap(hass) show_form_result = await hass.config_entries.flow.async_init(
assert attempt2["type"] == data_entry_flow.RESULT_TYPE_ABORT DOMAIN, context={"source": config_entries.SOURCE_USER}
assert attempt2["reason"] == "already_configured" )
login_result = await hass.config_entries.flow.async_configure(
show_form_result["flow_id"],
{
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
},
)
result = await hass.config_entries.flow.async_configure(
login_result["flow_id"],
{
CONF_ELMAX_PANEL_NAME: MOCK_PANEL_NAME,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
async def test_invalid_credentials(hass): async def test_invalid_credentials(hass):
@ -125,12 +104,19 @@ async def test_invalid_credentials(hass):
"elmax_api.http.Elmax.login", "elmax_api.http.Elmax.login",
side_effect=ElmaxBadLoginError(), side_effect=ElmaxBadLoginError(),
): ):
result = await _bootstrap( show_form_result = await hass.config_entries.flow.async_init(
hass, username="wrong_user_name@email.com", password="incorrect_password" DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
assert result["step_id"] == "user" login_result = await hass.config_entries.flow.async_configure(
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM show_form_result["flow_id"],
assert result["errors"] == {"base": "bad_auth"} {
CONF_ELMAX_USERNAME: "wrong_user_name@email.com",
CONF_ELMAX_PASSWORD: "incorrect_password",
},
)
assert login_result["step_id"] == "user"
assert login_result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert login_result["errors"] == {"base": "invalid_auth"}
async def test_connection_error(hass): async def test_connection_error(hass):
@ -139,12 +125,19 @@ async def test_connection_error(hass):
"elmax_api.http.Elmax.login", "elmax_api.http.Elmax.login",
side_effect=ElmaxNetworkError(), side_effect=ElmaxNetworkError(),
): ):
result = await _bootstrap( show_form_result = await hass.config_entries.flow.async_init(
hass, username="wrong_user_name@email.com", password="incorrect_password" DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
assert result["step_id"] == "user" login_result = await hass.config_entries.flow.async_configure(
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM show_form_result["flow_id"],
assert result["errors"] == {"base": "network_error"} {
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
},
)
assert login_result["step_id"] == "user"
assert login_result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert login_result["errors"] == {"base": "network_error"}
async def test_unhandled_error(hass): async def test_unhandled_error(hass):
@ -153,10 +146,26 @@ async def test_unhandled_error(hass):
"elmax_api.http.Elmax.get_panel_status", "elmax_api.http.Elmax.get_panel_status",
side_effect=Exception(), side_effect=Exception(),
): ):
result = await _bootstrap(hass) show_form_result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
login_result = await hass.config_entries.flow.async_configure(
show_form_result["flow_id"],
{
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
},
)
result = await hass.config_entries.flow.async_configure(
login_result["flow_id"],
{
CONF_ELMAX_PANEL_NAME: MOCK_PANEL_NAME,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
},
)
assert result["step_id"] == "panels" assert result["step_id"] == "panels"
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "unknown_error"} assert result["errors"] == {"base": "unknown"}
async def test_invalid_pin(hass): async def test_invalid_pin(hass):
@ -166,7 +175,23 @@ async def test_invalid_pin(hass):
"elmax_api.http.Elmax.get_panel_status", "elmax_api.http.Elmax.get_panel_status",
side_effect=ElmaxBadPinError(), side_effect=ElmaxBadPinError(),
): ):
result = await _bootstrap(hass) show_form_result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
login_result = await hass.config_entries.flow.async_configure(
show_form_result["flow_id"],
{
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
},
)
result = await hass.config_entries.flow.async_configure(
login_result["flow_id"],
{
CONF_ELMAX_PANEL_NAME: MOCK_PANEL_NAME,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
},
)
assert result["step_id"] == "panels" assert result["step_id"] == "panels"
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "invalid_pin"} assert result["errors"] == {"base": "invalid_pin"}
@ -179,22 +204,19 @@ async def test_no_online_panel(hass):
"elmax_api.http.Elmax.list_control_panels", "elmax_api.http.Elmax.list_control_panels",
return_value=[], return_value=[],
): ):
result = await _bootstrap(hass) show_form_result = await hass.config_entries.flow.async_init(
assert result["step_id"] == "user" DOMAIN, context={"source": config_entries.SOURCE_USER}
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM )
assert result["errors"] == {"base": "no_panel_online"} login_result = await hass.config_entries.flow.async_configure(
show_form_result["flow_id"],
{
async def test_step_user(hass): CONF_ELMAX_USERNAME: MOCK_USERNAME,
"""Test that the user step works.""" CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
result = await _bootstrap(hass) },
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY )
assert result["data"] == { assert login_result["step_id"] == "user"
CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, assert login_result["type"] == data_entry_flow.RESULT_TYPE_FORM
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, assert login_result["errors"] == {"base": "no_panel_online"}
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
}
async def test_show_reauth(hass): async def test_show_reauth(hass):
@ -215,24 +237,84 @@ async def test_show_reauth(hass):
async def test_reauth_flow(hass): async def test_reauth_flow(hass):
"""Test that the reauth flow works.""" """Test that the reauth flow works."""
# Simulate a first setup MockConfigEntry(
await _bootstrap(hass) domain=DOMAIN,
data={
CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID,
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
},
unique_id=MOCK_PANEL_ID,
).add_to_hass(hass)
# Trigger reauth # Trigger reauth
result = await _reauth(hass) with patch(
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT "homeassistant.components.elmax.async_setup_entry",
assert result["reason"] == "reauth_successful" return_value=True,
):
reauth_result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH},
data={
CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
},
)
result = await hass.config_entries.flow.async_configure(
reauth_result["flow_id"],
{
CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
await hass.async_block_till_done()
assert result["reason"] == "reauth_successful"
async def test_reauth_panel_disappeared(hass): async def test_reauth_panel_disappeared(hass):
"""Test that the case where panel is no longer associated with the user.""" """Test that the case where panel is no longer associated with the user."""
# Simulate a first setup # Simulate a first setup
await _bootstrap(hass) MockConfigEntry(
domain=DOMAIN,
data={
CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID,
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
},
unique_id=MOCK_PANEL_ID,
).add_to_hass(hass)
# Trigger reauth # Trigger reauth
with patch( with patch(
"elmax_api.http.Elmax.list_control_panels", "elmax_api.http.Elmax.list_control_panels",
return_value=[], return_value=[],
): ):
result = await _reauth(hass) reauth_result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH},
data={
CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
},
)
result = await hass.config_entries.flow.async_configure(
reauth_result["flow_id"],
{
CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
},
)
assert result["step_id"] == "reauth_confirm" assert result["step_id"] == "reauth_confirm"
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "reauth_panel_disappeared"} assert result["errors"] == {"base": "reauth_panel_disappeared"}
@ -240,14 +322,41 @@ async def test_reauth_panel_disappeared(hass):
async def test_reauth_invalid_pin(hass): async def test_reauth_invalid_pin(hass):
"""Test that the case where panel is no longer associated with the user.""" """Test that the case where panel is no longer associated with the user."""
# Simulate a first setup MockConfigEntry(
await _bootstrap(hass) domain=DOMAIN,
data={
CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID,
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
},
unique_id=MOCK_PANEL_ID,
).add_to_hass(hass)
# Trigger reauth # Trigger reauth
with patch( with patch(
"elmax_api.http.Elmax.get_panel_status", "elmax_api.http.Elmax.get_panel_status",
side_effect=ElmaxBadPinError(), side_effect=ElmaxBadPinError(),
): ):
result = await _reauth(hass) reauth_result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH},
data={
CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
},
)
result = await hass.config_entries.flow.async_configure(
reauth_result["flow_id"],
{
CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
},
)
assert result["step_id"] == "reauth_confirm" assert result["step_id"] == "reauth_confirm"
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "invalid_pin"} assert result["errors"] == {"base": "invalid_pin"}
@ -255,14 +364,41 @@ async def test_reauth_invalid_pin(hass):
async def test_reauth_bad_login(hass): async def test_reauth_bad_login(hass):
"""Test bad login attempt at reauth time.""" """Test bad login attempt at reauth time."""
# Simulate a first setup MockConfigEntry(
await _bootstrap(hass) domain=DOMAIN,
data={
CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID,
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
},
unique_id=MOCK_PANEL_ID,
).add_to_hass(hass)
# Trigger reauth # Trigger reauth
with patch( with patch(
"elmax_api.http.Elmax.login", "elmax_api.http.Elmax.login",
side_effect=ElmaxBadLoginError(), side_effect=ElmaxBadLoginError(),
): ):
result = await _reauth(hass) reauth_result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH},
data={
CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
},
)
result = await hass.config_entries.flow.async_configure(
reauth_result["flow_id"],
{
CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID,
CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
CONF_ELMAX_USERNAME: MOCK_USERNAME,
CONF_ELMAX_PASSWORD: MOCK_PASSWORD,
},
)
assert result["step_id"] == "reauth_confirm" assert result["step_id"] == "reauth_confirm"
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "bad_auth"} assert result["errors"] == {"base": "invalid_auth"}