Add config flow + async support for SmartHab integration (#34387)
* Setup barebones SmartHab config flow
* Setup authentication flow
* Make setup async, add config flow receivers
* Add French translation
* Fix async issues
* Address review comments (thanks bdraco!)
* Fix unloading entries
* Migrate translations dir according to warning
* Create list of components
* Fix pylint false positive
* Fix bad copy-pastes 🤭
* Add async support to SmartHab component
* Address review comments (bdraco)
* Fix pylint
* Improve exception handling (bdraco)
* Apply suggestions from code review (bdraco)
Co-authored-by: J. Nick Koston <nick@koston.org>
* Don't log exceptions manually, fix error
* Reduce repeated lines in async_step_user (bdraco)
* Remove useless else (pylint)
* Remove broad exception handler
* Create strings.json + remove fr i18n
* Write tests for smarthab config flow
* Test import flow
* Fix import test
* Update homeassistant/components/smarthab/config_flow.py
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
10893f6246
commit
3062312649
13 changed files with 330 additions and 60 deletions
|
@ -732,7 +732,9 @@ omit =
|
|||
homeassistant/components/smappee/sensor.py
|
||||
homeassistant/components/smappee/switch.py
|
||||
homeassistant/components/smarty/*
|
||||
homeassistant/components/smarthab/*
|
||||
homeassistant/components/smarthab/__init__.py
|
||||
homeassistant/components/smarthab/cover.py
|
||||
homeassistant/components/smarthab/light.py
|
||||
homeassistant/components/sms/*
|
||||
homeassistant/components/smtp/notify.py
|
||||
homeassistant/components/snapcast/*
|
||||
|
|
|
@ -1,15 +1,19 @@
|
|||
"""Support for SmartHab device integration."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import pysmarthab
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.discovery import load_platform
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
DOMAIN = "smarthab"
|
||||
DATA_HUB = "hub"
|
||||
COMPONENTS = ["light", "cover"]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -26,34 +30,61 @@ CONFIG_SCHEMA = vol.Schema(
|
|||
)
|
||||
|
||||
|
||||
def setup(hass, config) -> bool:
|
||||
async def async_setup(hass, config) -> bool:
|
||||
"""Set up the SmartHab platform."""
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
sh_conf = config.get(DOMAIN)
|
||||
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=sh_conf,
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
|
||||
"""Set up config entry for SmartHab integration."""
|
||||
|
||||
# Assign configuration variables
|
||||
username = sh_conf[CONF_EMAIL]
|
||||
password = sh_conf[CONF_PASSWORD]
|
||||
username = entry.data[CONF_EMAIL]
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
|
||||
# Setup connection with SmartHab API
|
||||
hub = pysmarthab.SmartHab()
|
||||
|
||||
try:
|
||||
hub.login(username, password)
|
||||
except pysmarthab.RequestFailedException as ex:
|
||||
_LOGGER.error("Error while trying to reach SmartHab API.")
|
||||
_LOGGER.debug(ex, exc_info=True)
|
||||
return False
|
||||
|
||||
# Verify that passed in configuration works
|
||||
if not hub.is_logged_in():
|
||||
_LOGGER.error("Could not authenticate with SmartHab API")
|
||||
return False
|
||||
await hub.async_login(username, password)
|
||||
except pysmarthab.RequestFailedException:
|
||||
_LOGGER.exception("Error while trying to reach SmartHab API")
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
# Pass hub object to child platforms
|
||||
hass.data[DOMAIN] = {DATA_HUB: hub}
|
||||
hass.data[DOMAIN][entry.entry_id] = {DATA_HUB: hub}
|
||||
|
||||
load_platform(hass, "light", DOMAIN, None, config)
|
||||
load_platform(hass, "cover", DOMAIN, None, config)
|
||||
for component in COMPONENTS:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_forward_entry_setup(entry, component)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry):
|
||||
"""Unload config entry from SmartHab integration."""
|
||||
|
||||
result = all(
|
||||
await asyncio.gather(
|
||||
*[
|
||||
hass.config_entries.async_forward_entry_unload(entry, component)
|
||||
for component in COMPONENTS
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
if result:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return result
|
||||
|
|
77
homeassistant/components/smarthab/config_flow.py
Normal file
77
homeassistant/components/smarthab/config_flow.py
Normal file
|
@ -0,0 +1,77 @@
|
|||
"""SmartHab configuration flow."""
|
||||
import logging
|
||||
|
||||
import pysmarthab
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
|
||||
# pylint: disable=unused-import
|
||||
from . import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SmartHabConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""SmartHab config flow."""
|
||||
|
||||
def _show_setup_form(self, user_input=None, errors=None):
|
||||
"""Show the setup form to the user."""
|
||||
|
||||
if user_input is None:
|
||||
user_input = {}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_EMAIL, default=user_input.get(CONF_EMAIL, "")
|
||||
): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
errors=errors or {},
|
||||
)
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a flow initiated by the user."""
|
||||
errors = {}
|
||||
|
||||
if user_input is None:
|
||||
return self._show_setup_form(user_input, None)
|
||||
|
||||
username = user_input[CONF_EMAIL]
|
||||
password = user_input[CONF_PASSWORD]
|
||||
|
||||
# Check if already configured
|
||||
if self.unique_id is None:
|
||||
await self.async_set_unique_id(username)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Setup connection with SmartHab API
|
||||
hub = pysmarthab.SmartHab()
|
||||
|
||||
try:
|
||||
await hub.async_login(username, password)
|
||||
|
||||
# Verify that passed in configuration works
|
||||
if hub.is_logged_in():
|
||||
return self.async_create_entry(
|
||||
title=username, data={CONF_EMAIL: username, CONF_PASSWORD: password}
|
||||
)
|
||||
|
||||
errors["base"] = "wrong_login"
|
||||
except pysmarthab.RequestFailedException:
|
||||
_LOGGER.exception("Error while trying to reach SmartHab API")
|
||||
errors["base"] = "service"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected error during login")
|
||||
errors["base"] = "unknown_error"
|
||||
|
||||
return self._show_setup_form(user_input, errors)
|
||||
|
||||
async def async_step_import(self, user_input):
|
||||
"""Handle import from legacy config."""
|
||||
return await self.async_step_user(user_input)
|
|
@ -7,6 +7,7 @@ from requests.exceptions import Timeout
|
|||
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
DEVICE_CLASS_WINDOW,
|
||||
SUPPORT_CLOSE,
|
||||
SUPPORT_OPEN,
|
||||
SUPPORT_SET_POSITION,
|
||||
|
@ -20,21 +21,17 @@ _LOGGER = logging.getLogger(__name__)
|
|||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the SmartHab roller shutters platform."""
|
||||
|
||||
hub = hass.data[DOMAIN][DATA_HUB]
|
||||
devices = hub.get_device_list()
|
||||
|
||||
_LOGGER.debug("Found a total of %s devices", str(len(devices)))
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up SmartHab covers from a config entry."""
|
||||
hub = hass.data[DOMAIN][config_entry.entry_id][DATA_HUB]
|
||||
|
||||
entities = (
|
||||
SmartHabCover(cover)
|
||||
for cover in devices
|
||||
for cover in await hub.async_get_device_list()
|
||||
if isinstance(cover, pysmarthab.Shutter)
|
||||
)
|
||||
|
||||
add_entities(entities, True)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class SmartHabCover(CoverEntity):
|
||||
|
@ -51,7 +48,7 @@ class SmartHabCover(CoverEntity):
|
|||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the display name of this light."""
|
||||
"""Return the display name of this cover."""
|
||||
return self._cover.label
|
||||
|
||||
@property
|
||||
|
@ -65,12 +62,7 @@ class SmartHabCover(CoverEntity):
|
|||
@property
|
||||
def supported_features(self) -> int:
|
||||
"""Flag supported features."""
|
||||
supported_features = SUPPORT_OPEN | SUPPORT_CLOSE
|
||||
|
||||
if self.current_cover_position is not None:
|
||||
supported_features |= SUPPORT_SET_POSITION
|
||||
|
||||
return supported_features
|
||||
return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
|
@ -80,24 +72,24 @@ class SmartHabCover(CoverEntity):
|
|||
@property
|
||||
def device_class(self) -> str:
|
||||
"""Return the class of this device, from component DEVICE_CLASSES."""
|
||||
return "window"
|
||||
return DEVICE_CLASS_WINDOW
|
||||
|
||||
def open_cover(self, **kwargs):
|
||||
async def async_open_cover(self, **kwargs):
|
||||
"""Open the cover."""
|
||||
self._cover.open()
|
||||
await self._cover.async_open()
|
||||
|
||||
def close_cover(self, **kwargs):
|
||||
async def async_close_cover(self, **kwargs):
|
||||
"""Close cover."""
|
||||
self._cover.close()
|
||||
await self._cover.async_close()
|
||||
|
||||
def set_cover_position(self, **kwargs):
|
||||
async def async_set_cover_position(self, **kwargs):
|
||||
"""Move the cover to a specific position."""
|
||||
self._cover.state = kwargs[ATTR_POSITION]
|
||||
await self._cover.async_set_state(kwargs[ATTR_POSITION])
|
||||
|
||||
def update(self):
|
||||
async def async_update(self):
|
||||
"""Fetch new state data for this cover."""
|
||||
try:
|
||||
self._cover.update()
|
||||
await self._cover.async_update()
|
||||
except Timeout:
|
||||
_LOGGER.error(
|
||||
"Reached timeout while updating cover %s from API", self.entity_id
|
||||
|
|
|
@ -14,19 +14,17 @@ _LOGGER = logging.getLogger(__name__)
|
|||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the SmartHab lights platform."""
|
||||
|
||||
hub = hass.data[DOMAIN][DATA_HUB]
|
||||
devices = hub.get_device_list()
|
||||
|
||||
_LOGGER.debug("Found a total of %s devices", str(len(devices)))
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up SmartHab lights from a config entry."""
|
||||
hub = hass.data[DOMAIN][config_entry.entry_id][DATA_HUB]
|
||||
|
||||
entities = (
|
||||
SmartHabLight(light) for light in devices if isinstance(light, pysmarthab.Light)
|
||||
SmartHabLight(light)
|
||||
for light in await hub.async_get_device_list()
|
||||
if isinstance(light, pysmarthab.Light)
|
||||
)
|
||||
|
||||
add_entities(entities, True)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class SmartHabLight(LightEntity):
|
||||
|
@ -51,18 +49,18 @@ class SmartHabLight(LightEntity):
|
|||
"""Return true if light is on."""
|
||||
return self._light.state
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Instruct the light to turn on."""
|
||||
self._light.turn_on()
|
||||
await self._light.async_turn_on()
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
async def async_turn_off(self, **kwargs):
|
||||
"""Instruct the light to turn off."""
|
||||
self._light.turn_off()
|
||||
await self._light.async_turn_off()
|
||||
|
||||
def update(self):
|
||||
async def async_update(self):
|
||||
"""Fetch new state data for this light."""
|
||||
try:
|
||||
self._light.update()
|
||||
await self._light.async_update()
|
||||
except Timeout:
|
||||
_LOGGER.error(
|
||||
"Reached timeout while updating light %s from API", self.entity_id
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
"domain": "smarthab",
|
||||
"name": "SmartHab",
|
||||
"documentation": "https://www.home-assistant.io/integrations/smarthab",
|
||||
"requirements": ["smarthab==0.20"],
|
||||
"config_flow": true,
|
||||
"requirements": ["smarthab==0.21"],
|
||||
"codeowners": ["@outadoc"]
|
||||
}
|
||||
|
|
19
homeassistant/components/smarthab/strings.json
Normal file
19
homeassistant/components/smarthab/strings.json
Normal file
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"config": {
|
||||
"error": {
|
||||
"service": "Error while trying to reach SmartHab. Service might be down. Check your connection.",
|
||||
"wrong_login": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown_error": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"email": "[%key:common::config_flow::data::email%]"
|
||||
},
|
||||
"description": "For technical reasons, be sure to use a secondary account specific to your Home Assistant setup. You can create one from the SmartHab application.",
|
||||
"title": "Setup SmartHab"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
19
homeassistant/components/smarthab/translations/en.json
Normal file
19
homeassistant/components/smarthab/translations/en.json
Normal file
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"config": {
|
||||
"error": {
|
||||
"service": "Error while trying to reach SmartHab. Service might be down. Check your connection.",
|
||||
"wrong_login": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown_error": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"email": "[%key:common::config_flow::data::email%]"
|
||||
},
|
||||
"description": "For technical reasons, be sure to use a secondary account specific to your Home Assistant setup. You can create one from the SmartHab application.",
|
||||
"title": "Setup SmartHab"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -140,6 +140,7 @@ FLOWS = [
|
|||
"shopping_list",
|
||||
"simplisafe",
|
||||
"smappee",
|
||||
"smarthab",
|
||||
"smartthings",
|
||||
"smhi",
|
||||
"sms",
|
||||
|
|
|
@ -1975,7 +1975,7 @@ sleepyq==0.7
|
|||
slixmpp==1.5.1
|
||||
|
||||
# homeassistant.components.smarthab
|
||||
smarthab==0.20
|
||||
smarthab==0.21
|
||||
|
||||
# homeassistant.components.bh1750
|
||||
# homeassistant.components.bme280
|
||||
|
|
|
@ -852,6 +852,9 @@ simplisafe-python==9.2.0
|
|||
# homeassistant.components.sleepiq
|
||||
sleepyq==0.7
|
||||
|
||||
# homeassistant.components.smarthab
|
||||
smarthab==0.21
|
||||
|
||||
# homeassistant.components.smhi
|
||||
smhi-pkg==1.0.13
|
||||
|
||||
|
|
1
tests/components/smarthab/__init__.py
Normal file
1
tests/components/smarthab/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the SmartHab integration."""
|
126
tests/components/smarthab/test_config_flow.py
Normal file
126
tests/components/smarthab/test_config_flow.py
Normal file
|
@ -0,0 +1,126 @@
|
|||
"""Test the SmartHab config flow."""
|
||||
import pysmarthab
|
||||
|
||||
from homeassistant import config_entries, setup
|
||||
from homeassistant.components.smarthab import DOMAIN
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
|
||||
from tests.async_mock import patch
|
||||
|
||||
|
||||
async def test_form(hass):
|
||||
"""Test we get the form."""
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
with patch("pysmarthab.SmartHab.async_login"), patch(
|
||||
"pysmarthab.SmartHab.is_logged_in", return_value=True
|
||||
), patch(
|
||||
"homeassistant.components.smarthab.async_setup", return_value=True
|
||||
) as mock_setup, patch(
|
||||
"homeassistant.components.smarthab.async_setup_entry", return_value=True
|
||||
) as mock_setup_entry:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: "mock@example.com", CONF_PASSWORD: "test-password"},
|
||||
)
|
||||
|
||||
assert result2["type"] == "create_entry"
|
||||
assert result2["title"] == "mock@example.com"
|
||||
assert result2["data"] == {
|
||||
CONF_EMAIL: "mock@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
}
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_invalid_auth(hass):
|
||||
"""Test we handle invalid auth."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("pysmarthab.SmartHab.async_login"), patch(
|
||||
"pysmarthab.SmartHab.is_logged_in", return_value=False
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: "mock@example.com", CONF_PASSWORD: "test-password"},
|
||||
)
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {"base": "wrong_login"}
|
||||
|
||||
|
||||
async def test_form_service_error(hass):
|
||||
"""Test we handle service errors."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"pysmarthab.SmartHab.async_login",
|
||||
side_effect=pysmarthab.RequestFailedException(42),
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: "mock@example.com", CONF_PASSWORD: "test-password"},
|
||||
)
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {"base": "service"}
|
||||
|
||||
|
||||
async def test_form_unknown_error(hass):
|
||||
"""Test we handle unknown errors."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"pysmarthab.SmartHab.async_login", side_effect=Exception,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: "mock@example.com", CONF_PASSWORD: "test-password"},
|
||||
)
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {"base": "unknown_error"}
|
||||
|
||||
|
||||
async def test_import(hass):
|
||||
"""Test import."""
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
|
||||
imported_conf = {
|
||||
CONF_EMAIL: "mock@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
}
|
||||
|
||||
with patch("pysmarthab.SmartHab.async_login"), patch(
|
||||
"pysmarthab.SmartHab.is_logged_in", return_value=True
|
||||
), patch(
|
||||
"homeassistant.components.smarthab.async_setup", return_value=True
|
||||
) as mock_setup, patch(
|
||||
"homeassistant.components.smarthab.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=imported_conf
|
||||
)
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == "mock@example.com"
|
||||
assert result["data"] == {
|
||||
CONF_EMAIL: "mock@example.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
}
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
Loading…
Add table
Reference in a new issue