mirror of
https://github.com/home-assistant/core.git
synced 2025-04-29 03:37:51 +00:00
Add OAuth to Neato (#44031)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
fd24baa1f6
commit
d0ebc00684
@ -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
|
||||||
|
@ -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):
|
||||||
|
55
homeassistant/components/neato/api.py
Normal file
55
homeassistant/components/neato/api.py
Normal 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"
|
@ -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
|
||||||
|
@ -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
|
|
||||||
|
@ -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 = {
|
||||||
|
@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
@ -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
|
||||||
|
@ -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"
|
||||||
}
|
}
|
@ -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
|
||||||
|
@ -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"
|
||||||
}
|
}
|
@ -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"
|
||||||
}
|
}
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
|
||||||
|
@ -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
|
|
Loading…
x
Reference in New Issue
Block a user