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:
Baptiste Candellier 2020-07-05 21:20:51 +02:00 committed by GitHub
parent 10893f6246
commit 3062312649
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 330 additions and 60 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

View 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"
}
}
}
}

View 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"
}
}
}
}

View File

@ -140,6 +140,7 @@ FLOWS = [
"shopping_list",
"simplisafe",
"smappee",
"smarthab",
"smartthings",
"smhi",
"sms",

View File

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

View File

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

View File

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

View 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