Add OAuth to Neato (#44031)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Santobert 2020-12-16 23:39:41 +01:00 committed by GitHub
parent fd24baa1f6
commit d0ebc00684
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 363 additions and 504 deletions

View File

@ -567,6 +567,8 @@ omit =
homeassistant/components/n26/* homeassistant/components/n26/*
homeassistant/components/nad/media_player.py homeassistant/components/nad/media_player.py
homeassistant/components/nanoleaf/light.py homeassistant/components/nanoleaf/light.py
homeassistant/components/neato/__init__.py
homeassistant/components/neato/api.py
homeassistant/components/neato/camera.py homeassistant/components/neato/camera.py
homeassistant/components/neato/sensor.py homeassistant/components/neato/sensor.py
homeassistant/components/neato/switch.py homeassistant/components/neato/switch.py

View File

@ -3,26 +3,30 @@ import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
from pybotvac import Account, Neato, Vorwerk from pybotvac import Account, Neato
from pybotvac.exceptions import NeatoException, NeatoLoginException, NeatoRobotException from pybotvac.exceptions import NeatoException
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_SOURCE,
CONF_TOKEN,
)
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from homeassistant.util import Throttle from homeassistant.util import Throttle
from .config_flow import NeatoConfigFlow from . import api, config_flow
from .const import ( from .const import (
CONF_VENDOR,
NEATO_CONFIG, NEATO_CONFIG,
NEATO_DOMAIN, NEATO_DOMAIN,
NEATO_LOGIN, NEATO_LOGIN,
NEATO_MAP_DATA, NEATO_MAP_DATA,
NEATO_PERSISTENT_MAPS, NEATO_PERSISTENT_MAPS,
NEATO_ROBOTS, NEATO_ROBOTS,
VALID_VENDORS,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -32,82 +36,74 @@ CONFIG_SCHEMA = vol.Schema(
{ {
NEATO_DOMAIN: vol.Schema( NEATO_DOMAIN: vol.Schema(
{ {
vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_CLIENT_ID): cv.string,
vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_CLIENT_SECRET): cv.string,
vol.Optional(CONF_VENDOR, default="neato"): vol.In(VALID_VENDORS),
} }
) )
}, },
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
PLATFORMS = ["camera", "vacuum", "switch", "sensor"]
async def async_setup(hass, config):
async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
"""Set up the Neato component.""" """Set up the Neato component."""
hass.data[NEATO_DOMAIN] = {}
if NEATO_DOMAIN not in config: if NEATO_DOMAIN not in config:
# There is an entry and nothing in configuration.yaml
return True return True
entries = hass.config_entries.async_entries(NEATO_DOMAIN)
hass.data[NEATO_CONFIG] = config[NEATO_DOMAIN] hass.data[NEATO_CONFIG] = config[NEATO_DOMAIN]
vendor = Neato()
if entries: config_flow.OAuth2FlowHandler.async_register_implementation(
# There is an entry and something in the configuration.yaml hass,
entry = entries[0] api.NeatoImplementation(
conf = config[NEATO_DOMAIN] hass,
if ( NEATO_DOMAIN,
entry.data[CONF_USERNAME] == conf[CONF_USERNAME] config[NEATO_DOMAIN][CONF_CLIENT_ID],
and entry.data[CONF_PASSWORD] == conf[CONF_PASSWORD] config[NEATO_DOMAIN][CONF_CLIENT_SECRET],
and entry.data[CONF_VENDOR] == conf[CONF_VENDOR] vendor.auth_endpoint,
): vendor.token_endpoint,
# The entry is not outdated ),
return True )
# The entry is outdated
error = await hass.async_add_executor_job(
NeatoConfigFlow.try_login,
conf[CONF_USERNAME],
conf[CONF_PASSWORD],
conf[CONF_VENDOR],
)
if error is not None:
_LOGGER.error(error)
return False
# Update the entry
hass.config_entries.async_update_entry(entry, data=config[NEATO_DOMAIN])
else:
# Create the new entry
hass.async_create_task(
hass.config_entries.flow.async_init(
NEATO_DOMAIN,
context={"source": SOURCE_IMPORT},
data=config[NEATO_DOMAIN],
)
)
return True return True
async def async_setup_entry(hass, entry): async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
"""Set up config entry.""" """Set up config entry."""
hub = NeatoHub(hass, entry.data, Account) if CONF_TOKEN not in entry.data:
# Init reauth flow
await hass.async_add_executor_job(hub.login) hass.async_create_task(
if not hub.logged_in: hass.config_entries.flow.async_init(
_LOGGER.debug("Failed to login to Neato API") NEATO_DOMAIN,
context={CONF_SOURCE: SOURCE_REAUTH},
)
)
return False return False
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
neato_session = api.ConfigEntryAuth(hass, entry, session)
hass.data[NEATO_DOMAIN][entry.entry_id] = neato_session
hub = NeatoHub(hass, Account(neato_session))
try: try:
await hass.async_add_executor_job(hub.update_robots) await hass.async_add_executor_job(hub.update_robots)
except NeatoRobotException as ex: except NeatoException as ex:
_LOGGER.debug("Failed to connect to Neato API") _LOGGER.debug("Failed to connect to Neato API")
raise ConfigEntryNotReady from ex raise ConfigEntryNotReady from ex
hass.data[NEATO_LOGIN] = hub hass.data[NEATO_LOGIN] = hub
for component in ("camera", "vacuum", "switch", "sensor"): for component in PLATFORMS:
hass.async_create_task( hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component) hass.config_entries.async_forward_entry_setup(entry, component)
) )
@ -115,53 +111,27 @@ async def async_setup_entry(hass, entry):
return True return True
async def async_unload_entry(hass, entry): async def async_unload_entry(hass: HomeAssistantType, entry: ConfigType) -> bool:
"""Unload config entry.""" """Unload config entry."""
hass.data.pop(NEATO_LOGIN) unload_functions = (
await asyncio.gather( hass.config_entries.async_forward_entry_unload(entry, platform)
hass.config_entries.async_forward_entry_unload(entry, "camera"), for platform in PLATFORMS
hass.config_entries.async_forward_entry_unload(entry, "vacuum"),
hass.config_entries.async_forward_entry_unload(entry, "switch"),
hass.config_entries.async_forward_entry_unload(entry, "sensor"),
) )
return True
unload_ok = all(await asyncio.gather(*unload_functions))
if unload_ok:
hass.data[NEATO_DOMAIN].pop(entry.entry_id)
return unload_ok
class NeatoHub: class NeatoHub:
"""A My Neato hub wrapper class.""" """A My Neato hub wrapper class."""
def __init__(self, hass, domain_config, neato): def __init__(self, hass: HomeAssistantType, neato: Account):
"""Initialize the Neato hub.""" """Initialize the Neato hub."""
self.config = domain_config self._hass: HomeAssistantType = hass
self._neato = neato self.my_neato: Account = neato
self._hass = hass
if self.config[CONF_VENDOR] == "vorwerk":
self._vendor = Vorwerk()
else: # Neato
self._vendor = Neato()
self.my_neato = None
self.logged_in = False
def login(self):
"""Login to My Neato."""
_LOGGER.debug("Trying to connect to Neato API")
try:
self.my_neato = self._neato(
self.config[CONF_USERNAME], self.config[CONF_PASSWORD], self._vendor
)
except NeatoException as ex:
if isinstance(ex, NeatoLoginException):
_LOGGER.error("Invalid credentials")
else:
_LOGGER.error("Unable to connect to Neato API")
raise ConfigEntryNotReady from ex
self.logged_in = False
return
self.logged_in = True
_LOGGER.debug("Successfully connected to Neato API")
@Throttle(timedelta(minutes=1)) @Throttle(timedelta(minutes=1))
def update_robots(self): def update_robots(self):

View File

@ -0,0 +1,55 @@
"""API for Neato Botvac bound to Home Assistant OAuth."""
from asyncio import run_coroutine_threadsafe
import logging
import pybotvac
from homeassistant import config_entries, core
from homeassistant.helpers import config_entry_oauth2_flow
_LOGGER = logging.getLogger(__name__)
class ConfigEntryAuth(pybotvac.OAuthSession):
"""Provide Neato Botvac authentication tied to an OAuth2 based config entry."""
def __init__(
self,
hass: core.HomeAssistant,
config_entry: config_entries.ConfigEntry,
implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation,
):
"""Initialize Neato Botvac Auth."""
self.hass = hass
self.session = config_entry_oauth2_flow.OAuth2Session(
hass, config_entry, implementation
)
super().__init__(self.session.token, vendor=pybotvac.Neato())
def refresh_tokens(self) -> str:
"""Refresh and return new Neato Botvac tokens using Home Assistant OAuth2 session."""
run_coroutine_threadsafe(
self.session.async_ensure_token_valid(), self.hass.loop
).result()
return self.session.token["access_token"]
class NeatoImplementation(config_entry_oauth2_flow.LocalOAuth2Implementation):
"""Neato implementation of LocalOAuth2Implementation.
We need this class because we have to add client_secret and scope to the authorization request.
"""
@property
def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url."""
return {"client_secret": self.client_secret}
async def async_generate_authorize_url(self, flow_id: str) -> str:
"""Generate a url for the user to authorize.
We must make sure that the plus signs are not encoded.
"""
url = await super().async_generate_authorize_url(flow_id)
return f"{url}&scope=public_profile+control_robots+maps"

View File

@ -45,7 +45,7 @@ class NeatoCleaningMap(Camera):
self.robot = robot self.robot = robot
self.neato = neato self.neato = neato
self._mapdata = mapdata self._mapdata = mapdata
self._available = self.neato.logged_in if self.neato is not None else False self._available = neato is not None
self._robot_name = f"{self.robot.name} Cleaning Map" self._robot_name = f"{self.robot.name} Cleaning Map"
self._robot_serial = self.robot.serial self._robot_serial = self.robot.serial
self._generated_at = None self._generated_at = None

View File

@ -1,112 +1,65 @@
"""Config flow to configure Neato integration.""" """Config flow for Neato Botvac."""
import logging import logging
from typing import Optional
from pybotvac import Account, Neato, Vorwerk
from pybotvac.exceptions import NeatoLoginException, NeatoRobotException
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_TOKEN
from homeassistant.helpers import config_entry_oauth2_flow
# pylint: disable=unused-import # pylint: disable=unused-import
from .const import CONF_VENDOR, NEATO_DOMAIN, VALID_VENDORS from .const import NEATO_DOMAIN
DOCS_URL = "https://www.home-assistant.io/integrations/neato"
DEFAULT_VENDOR = "neato"
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class NeatoConfigFlow(config_entries.ConfigFlow, domain=NEATO_DOMAIN): class OAuth2FlowHandler(
"""Neato integration config flow.""" config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=NEATO_DOMAIN
):
"""Config flow to handle Neato Botvac OAuth2 authentication."""
VERSION = 1 DOMAIN = NEATO_DOMAIN
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
def __init__(self): @property
"""Initialize flow.""" def logger(self) -> logging.Logger:
self._username = vol.UNDEFINED """Return logger."""
self._password = vol.UNDEFINED return logging.getLogger(__name__)
self._vendor = vol.UNDEFINED
async def async_step_user(self, user_input=None): async def async_step_user(self, user_input: Optional[dict] = None) -> dict:
"""Handle a flow initialized by the user.""" """Create an entry for the flow."""
errors = {} current_entries = self._async_current_entries()
if current_entries and CONF_TOKEN in current_entries[0].data:
if self._async_current_entries(): # Already configured
return self.async_abort(reason="already_configured") return self.async_abort(reason="already_configured")
if user_input is not None: return await super().async_step_user(user_input=user_input)
self._username = user_input["username"]
self._password = user_input["password"]
self._vendor = user_input["vendor"]
error = await self.hass.async_add_executor_job( async def async_step_reauth(self, data) -> dict:
self.try_login, self._username, self._password, self._vendor """Perform reauth upon migration of old entries."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: Optional[dict] = None
) -> dict:
"""Confirm reauth upon migration of old entries."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm", data_schema=vol.Schema({})
) )
if error: return await self.async_step_user()
errors["base"] = error
else:
return self.async_create_entry(
title=user_input[CONF_USERNAME],
data=user_input,
description_placeholders={"docs_url": DOCS_URL},
)
return self.async_show_form( async def async_oauth_create_entry(self, data: dict) -> dict:
step_id="user", """Create an entry for the flow. Update an entry if one already exist."""
data_schema=vol.Schema( current_entries = self._async_current_entries()
{ if current_entries and CONF_TOKEN not in current_entries[0].data:
vol.Required(CONF_USERNAME): str, # Update entry
vol.Required(CONF_PASSWORD): str, self.hass.config_entries.async_update_entry(
vol.Optional(CONF_VENDOR, default="neato"): vol.In(VALID_VENDORS), current_entries[0], title=self.flow_impl.name, data=data
} )
), self.hass.async_create_task(
description_placeholders={"docs_url": DOCS_URL}, self.hass.config_entries.async_reload(current_entries[0].entry_id)
errors=errors, )
) return self.async_abort(reason="reauth_successful")
return self.async_create_entry(title=self.flow_impl.name, data=data)
async def async_step_import(self, user_input):
"""Import a config flow from configuration."""
if self._async_current_entries():
return self.async_abort(reason="already_configured")
username = user_input[CONF_USERNAME]
password = user_input[CONF_PASSWORD]
vendor = user_input[CONF_VENDOR]
error = await self.hass.async_add_executor_job(
self.try_login, username, password, vendor
)
if error is not None:
_LOGGER.error(error)
return self.async_abort(reason=error)
return self.async_create_entry(
title=f"{username} (from configuration)",
data={
CONF_USERNAME: username,
CONF_PASSWORD: password,
CONF_VENDOR: vendor,
},
)
@staticmethod
def try_login(username, password, vendor):
"""Try logging in to device and return any errors."""
this_vendor = None
if vendor == "vorwerk":
this_vendor = Vorwerk()
else: # Neato
this_vendor = Neato()
try:
Account(username, password, this_vendor)
except NeatoLoginException:
return "invalid_auth"
except NeatoRobotException:
return "unknown"
return None

View File

@ -11,8 +11,6 @@ NEATO_ROBOTS = "neato_robots"
SCAN_INTERVAL_MINUTES = 1 SCAN_INTERVAL_MINUTES = 1
VALID_VENDORS = ["neato", "vorwerk"]
MODE = {1: "Eco", 2: "Turbo"} MODE = {1: "Eco", 2: "Turbo"}
ACTION = { ACTION = {

View File

@ -3,6 +3,14 @@
"name": "Neato Botvac", "name": "Neato Botvac",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/neato", "documentation": "https://www.home-assistant.io/integrations/neato",
"requirements": ["pybotvac==0.0.17"], "requirements": [
"codeowners": ["@dshokouhi", "@Santobert"] "pybotvac==0.0.19"
],
"codeowners": [
"@dshokouhi",
"@Santobert"
],
"dependencies": [
"http"
]
} }

View File

@ -37,7 +37,7 @@ class NeatoSensor(Entity):
def __init__(self, neato, robot): def __init__(self, neato, robot):
"""Initialize Neato sensor.""" """Initialize Neato sensor."""
self.robot = robot self.robot = robot
self._available = neato.logged_in if neato is not None else False self._available = neato is not None
self._robot_name = f"{self.robot.name} {BATTERY}" self._robot_name = f"{self.robot.name} {BATTERY}"
self._robot_serial = self.robot.serial self._robot_serial = self.robot.serial
self._state = None self._state = None

View File

@ -1,26 +1,23 @@
{ {
"config": { "config": {
"step": { "step": {
"user": { "pick_implementation": {
"title": "Neato Account Info", "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
"data": { },
"username": "[%key:common::config_flow::data::username%]", "reauth_confirm": {
"password": "[%key:common::config_flow::data::password%]", "title": "[%key:common::config_flow::description::confirm_setup%]"
"vendor": "Vendor"
},
"description": "See [Neato documentation]({docs_url})."
} }
}, },
"error": { "abort": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"unknown": "[%key:common::config_flow::error::unknown%]" "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}, },
"create_entry": { "create_entry": {
"default": "See [Neato documentation]({docs_url})." "default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
} }
} },
"title": "Neato Botvac"
} }

View File

@ -40,7 +40,7 @@ class NeatoConnectedSwitch(ToggleEntity):
"""Initialize the Neato Connected switches.""" """Initialize the Neato Connected switches."""
self.type = switch_type self.type = switch_type
self.robot = robot self.robot = robot
self._available = neato.logged_in if neato is not None else False self._available = neato is not None
self._robot_name = f"{self.robot.name} {SWITCH_TYPES[self.type][0]}" self._robot_name = f"{self.robot.name} {SWITCH_TYPES[self.type][0]}"
self._state = None self._state = None
self._schedule_state = None self._schedule_state = None

View File

@ -1,21 +1,23 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "Bereits konfiguriert" "already_configured": "Konto ist bereits konfiguriert.",
"authorize_url_timeout": "Timeout beim Erzeugen der Autorisierungs-URL.",
"missing_configuration": "Die Komponente ist nicht konfiguriert. Bitte beachten Sie die Dokumentation.",
"no_url_available": "Keine URL verfügbar. Informationen zu diesem Fehler finden Sie [im Hilfebereich]({docs_url})",
"reauth_successful": "Re-Authentifizierung war erfolgreich"
}, },
"create_entry": { "create_entry": {
"default": "Siehe [Neato-Dokumentation]({docs_url})." "default": "Erfolgreich authentifiziert"
}, },
"step": { "step": {
"user": { "pick_implementation": {
"data": { "title": "Authentifizierungsmethode auswählen"
"password": "Passwort", },
"username": "Benutzername", "reauth_confirm": {
"vendor": "Hersteller" "title": "Einrichtung bestätigen?"
},
"description": "Siehe [Neato-Dokumentation]({docs_url}).",
"title": "Neato-Kontoinformationen"
} }
} }
} },
"title": "Neato Botvac"
} }

View File

@ -1,26 +1,23 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "Device is already configured", "already_configured": "Account is already configured.",
"invalid_auth": "Invalid authentication" "authorize_url_timeout": "Timeout generating authorize URL.",
"missing_configuration": "The component is not configured. Please follow the documentation.",
"no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})",
"reauth_successful": "Re-authentication was successful"
}, },
"create_entry": { "create_entry": {
"default": "See [Neato documentation]({docs_url})." "default": "Successfully authenticated"
},
"error": {
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
}, },
"step": { "step": {
"user": { "pick_implementation": {
"data": { "title": "Pick Authentication Method"
"password": "Password", },
"username": "Username", "reauth_confirm": {
"vendor": "Vendor" "title": "Confirm setup?"
},
"description": "See [Neato documentation]({docs_url}).",
"title": "Neato Account Info"
} }
} }
} },
"title": "Neato Botvac"
} }

View File

@ -24,7 +24,7 @@ from homeassistant.components.vacuum import (
SUPPORT_STOP, SUPPORT_STOP,
StateVacuumEntity, StateVacuumEntity,
) )
from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE from homeassistant.const import ATTR_MODE
from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers import config_validation as cv, entity_platform
from .const import ( from .const import (
@ -93,7 +93,6 @@ async def async_setup_entry(hass, entry, async_add_entities):
platform.async_register_entity_service( platform.async_register_entity_service(
"custom_cleaning", "custom_cleaning",
{ {
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
vol.Optional(ATTR_MODE, default=2): cv.positive_int, vol.Optional(ATTR_MODE, default=2): cv.positive_int,
vol.Optional(ATTR_NAVIGATION, default=1): cv.positive_int, vol.Optional(ATTR_NAVIGATION, default=1): cv.positive_int,
vol.Optional(ATTR_CATEGORY, default=4): cv.positive_int, vol.Optional(ATTR_CATEGORY, default=4): cv.positive_int,
@ -109,7 +108,7 @@ class NeatoConnectedVacuum(StateVacuumEntity):
def __init__(self, neato, robot, mapdata, persistent_maps): def __init__(self, neato, robot, mapdata, persistent_maps):
"""Initialize the Neato Connected Vacuum.""" """Initialize the Neato Connected Vacuum."""
self.robot = robot self.robot = robot
self._available = neato.logged_in if neato is not None else False self._available = neato is not None
self._mapdata = mapdata self._mapdata = mapdata
self._name = f"{self.robot.name}" self._name = f"{self.robot.name}"
self._robot_has_map = self.robot.has_persistent_maps self._robot_has_map = self.robot.has_persistent_maps

View File

@ -1292,7 +1292,7 @@ pyblackbird==0.5
# pybluez==0.22 # pybluez==0.22
# homeassistant.components.neato # homeassistant.components.neato
pybotvac==0.0.17 pybotvac==0.0.19
# homeassistant.components.nissan_leaf # homeassistant.components.nissan_leaf
pycarwings2==2.9 pycarwings2==2.9

View File

@ -655,7 +655,7 @@ pyatv==0.7.5
pyblackbird==0.5 pyblackbird==0.5
# homeassistant.components.neato # homeassistant.components.neato
pybotvac==0.0.17 pybotvac==0.0.19
# homeassistant.components.cloudflare # homeassistant.components.cloudflare
pycfdns==1.2.1 pycfdns==1.2.1

View File

@ -1,160 +1,156 @@
"""Tests for the Neato config flow.""" """Test the Neato Botvac config flow."""
from pybotvac.exceptions import NeatoLoginException, NeatoRobotException from pybotvac.neato import Neato
import pytest
from homeassistant import data_entry_flow from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.neato import config_flow from homeassistant.components.neato.const import NEATO_DOMAIN
from homeassistant.components.neato.const import CONF_VENDOR, NEATO_DOMAIN from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.typing import HomeAssistantType
from tests.async_mock import patch from tests.async_mock import patch
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
USERNAME = "myUsername" CLIENT_ID = "1234"
PASSWORD = "myPassword" CLIENT_SECRET = "5678"
VENDOR_NEATO = "neato"
VENDOR_VORWERK = "vorwerk" VENDOR = Neato()
VENDOR_INVALID = "invalid" OAUTH2_AUTHORIZE = VENDOR.auth_endpoint
OAUTH2_TOKEN = VENDOR.token_endpoint
@pytest.fixture(name="account") async def test_full_flow(
def mock_controller_login(): hass, aiohttp_client, aioclient_mock, current_request_with_host
"""Mock a successful login.""" ):
with patch("homeassistant.components.neato.config_flow.Account", return_value=True): """Check full flow."""
yield assert await setup.async_setup_component(
hass,
"neato",
def init_config_flow(hass): {
"""Init a configuration flow.""" "neato": {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
flow = config_flow.NeatoConfigFlow() "http": {"base_url": "https://example.com"},
flow.hass = hass
return flow
async def test_user(hass, account):
"""Test user config."""
flow = init_config_flow(hass)
result = await flow.async_step_user()
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
result = await flow.async_step_user(
{CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_VENDOR: VENDOR_NEATO}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == USERNAME
assert result["data"][CONF_USERNAME] == USERNAME
assert result["data"][CONF_PASSWORD] == PASSWORD
assert result["data"][CONF_VENDOR] == VENDOR_NEATO
result = await flow.async_step_user(
{CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_VENDOR: VENDOR_VORWERK}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == USERNAME
assert result["data"][CONF_USERNAME] == USERNAME
assert result["data"][CONF_PASSWORD] == PASSWORD
assert result["data"][CONF_VENDOR] == VENDOR_VORWERK
async def test_import(hass, account):
"""Test import step."""
flow = init_config_flow(hass)
result = await flow.async_step_import(
{CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_VENDOR: VENDOR_NEATO}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == f"{USERNAME} (from configuration)"
assert result["data"][CONF_USERNAME] == USERNAME
assert result["data"][CONF_PASSWORD] == PASSWORD
assert result["data"][CONF_VENDOR] == VENDOR_NEATO
async def test_abort_if_already_setup(hass, account):
"""Test we abort if Neato is already setup."""
flow = init_config_flow(hass)
MockConfigEntry(
domain=NEATO_DOMAIN,
data={
CONF_USERNAME: USERNAME,
CONF_PASSWORD: PASSWORD,
CONF_VENDOR: VENDOR_NEATO,
}, },
)
result = await hass.config_entries.flow.async_init(
"neato", context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
f"&client_secret={CLIENT_SECRET}"
"&scope=public_profile+control_robots+maps"
)
client = await aiohttp_client(hass.http.app)
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
with patch(
"homeassistant.components.neato.async_setup_entry", return_value=True
) as mock_setup:
await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(NEATO_DOMAIN)) == 1
assert len(mock_setup.mock_calls) == 1
async def test_abort_if_already_setup(hass: HomeAssistantType):
"""Test we abort if Neato is already setup."""
entry = MockConfigEntry(
domain=NEATO_DOMAIN,
data={"auth_implementation": "neato", "token": {"some": "data"}},
)
entry.add_to_hass(hass)
# Should fail
result = await hass.config_entries.flow.async_init(
"neato", context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
async def test_reauth(
hass: HomeAssistantType, aiohttp_client, aioclient_mock, current_request_with_host
):
"""Test initialization of the reauth flow."""
assert await setup.async_setup_component(
hass,
"neato",
{
"neato": {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
"http": {"base_url": "https://example.com"},
},
)
MockConfigEntry(
entry_id="my_entry",
domain=NEATO_DOMAIN,
data={"username": "abcdef", "password": "123456", "vendor": "neato"},
).add_to_hass(hass) ).add_to_hass(hass)
# Should fail, same USERNAME (import) # Should show form
result = await flow.async_step_import( result = await hass.config_entries.flow.async_init(
{CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_VENDOR: VENDOR_NEATO} "neato", context={"source": config_entries.SOURCE_REAUTH}
) )
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["reason"] == "already_configured" assert result["step_id"] == "reauth_confirm"
# Should fail, same USERNAME (flow) # Confirm reauth flow
result = await flow.async_step_user( result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
{CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_VENDOR: VENDOR_NEATO}
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
) )
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
client = await aiohttp_client(hass.http.app)
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
async def test_abort_on_invalid_credentials(hass): aioclient_mock.post(
"""Test when we have invalid credentials.""" OAUTH2_TOKEN,
flow = init_config_flow(hass) json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
# Update entry
with patch( with patch(
"homeassistant.components.neato.config_flow.Account", "homeassistant.components.neato.async_setup_entry", return_value=True
side_effect=NeatoLoginException(), ) as mock_setup:
): result3 = await hass.config_entries.flow.async_configure(result2["flow_id"])
result = await flow.async_step_user( await hass.async_block_till_done()
{
CONF_USERNAME: USERNAME,
CONF_PASSWORD: PASSWORD,
CONF_VENDOR: VENDOR_NEATO,
}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "invalid_auth"}
result = await flow.async_step_import( new_entry = hass.config_entries.async_get_entry("my_entry")
{
CONF_USERNAME: USERNAME,
CONF_PASSWORD: PASSWORD,
CONF_VENDOR: VENDOR_NEATO,
}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "invalid_auth"
assert result3["type"] == data_entry_flow.RESULT_TYPE_ABORT
async def test_abort_on_unexpected_error(hass): assert result3["reason"] == "reauth_successful"
"""Test when we have an unexpected error.""" assert new_entry.state == "loaded"
flow = init_config_flow(hass) assert len(hass.config_entries.async_entries(NEATO_DOMAIN)) == 1
assert len(mock_setup.mock_calls) == 1
with patch(
"homeassistant.components.neato.config_flow.Account",
side_effect=NeatoRobotException(),
):
result = await flow.async_step_user(
{
CONF_USERNAME: USERNAME,
CONF_PASSWORD: PASSWORD,
CONF_VENDOR: VENDOR_NEATO,
}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "unknown"}
result = await flow.async_step_import(
{
CONF_USERNAME: USERNAME,
CONF_PASSWORD: PASSWORD,
CONF_VENDOR: VENDOR_NEATO,
}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "unknown"

View File

@ -1,118 +0,0 @@
"""Tests for the Neato init file."""
from pybotvac.exceptions import NeatoLoginException
import pytest
from homeassistant.components.neato.const import CONF_VENDOR, NEATO_DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.setup import async_setup_component
from tests.async_mock import patch
from tests.common import MockConfigEntry
USERNAME = "myUsername"
PASSWORD = "myPassword"
VENDOR_NEATO = "neato"
VENDOR_VORWERK = "vorwerk"
VENDOR_INVALID = "invalid"
VALID_CONFIG = {
CONF_USERNAME: USERNAME,
CONF_PASSWORD: PASSWORD,
CONF_VENDOR: VENDOR_NEATO,
}
DIFFERENT_CONFIG = {
CONF_USERNAME: "anotherUsername",
CONF_PASSWORD: "anotherPassword",
CONF_VENDOR: VENDOR_VORWERK,
}
INVALID_CONFIG = {
CONF_USERNAME: USERNAME,
CONF_PASSWORD: PASSWORD,
CONF_VENDOR: VENDOR_INVALID,
}
@pytest.fixture(name="config_flow")
def mock_config_flow_login():
"""Mock a successful login."""
with patch("homeassistant.components.neato.config_flow.Account", return_value=True):
yield
@pytest.fixture(name="hub")
def mock_controller_login():
"""Mock a successful login."""
with patch("homeassistant.components.neato.Account", return_value=True):
yield
async def test_no_config_entry(hass):
"""There is nothing in configuration.yaml."""
res = await async_setup_component(hass, NEATO_DOMAIN, {})
assert res is True
async def test_create_valid_config_entry(hass, config_flow, hub):
"""There is something in configuration.yaml."""
assert hass.config_entries.async_entries(NEATO_DOMAIN) == []
assert await async_setup_component(hass, NEATO_DOMAIN, {NEATO_DOMAIN: VALID_CONFIG})
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(NEATO_DOMAIN)
assert entries
assert entries[0].data[CONF_USERNAME] == USERNAME
assert entries[0].data[CONF_PASSWORD] == PASSWORD
assert entries[0].data[CONF_VENDOR] == VENDOR_NEATO
async def test_config_entries_in_sync(hass, hub):
"""The config entry and configuration.yaml are in sync."""
MockConfigEntry(domain=NEATO_DOMAIN, data=VALID_CONFIG).add_to_hass(hass)
assert hass.config_entries.async_entries(NEATO_DOMAIN)
assert await async_setup_component(hass, NEATO_DOMAIN, {NEATO_DOMAIN: VALID_CONFIG})
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(NEATO_DOMAIN)
assert entries
assert entries[0].data[CONF_USERNAME] == USERNAME
assert entries[0].data[CONF_PASSWORD] == PASSWORD
assert entries[0].data[CONF_VENDOR] == VENDOR_NEATO
async def test_config_entries_not_in_sync(hass, config_flow, hub):
"""The config entry and configuration.yaml are not in sync."""
MockConfigEntry(domain=NEATO_DOMAIN, data=DIFFERENT_CONFIG).add_to_hass(hass)
assert hass.config_entries.async_entries(NEATO_DOMAIN)
assert await async_setup_component(hass, NEATO_DOMAIN, {NEATO_DOMAIN: VALID_CONFIG})
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(NEATO_DOMAIN)
assert entries
assert entries[0].data[CONF_USERNAME] == USERNAME
assert entries[0].data[CONF_PASSWORD] == PASSWORD
assert entries[0].data[CONF_VENDOR] == VENDOR_NEATO
async def test_config_entries_not_in_sync_error(hass):
"""The config entry and configuration.yaml are not in sync, the new configuration is wrong."""
MockConfigEntry(domain=NEATO_DOMAIN, data=VALID_CONFIG).add_to_hass(hass)
assert hass.config_entries.async_entries(NEATO_DOMAIN)
with patch(
"homeassistant.components.neato.config_flow.Account",
side_effect=NeatoLoginException(),
):
assert not await async_setup_component(
hass, NEATO_DOMAIN, {NEATO_DOMAIN: DIFFERENT_CONFIG}
)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(NEATO_DOMAIN)
assert entries
assert entries[0].data[CONF_USERNAME] == USERNAME
assert entries[0].data[CONF_PASSWORD] == PASSWORD
assert entries[0].data[CONF_VENDOR] == VENDOR_NEATO