From bd6bbcd5affaeac0076357937c3da94dee8f987f Mon Sep 17 00:00:00 2001 From: Santobert Date: Sun, 6 Oct 2019 13:05:51 +0200 Subject: [PATCH] Neato config flow (#26579) * initial commit * Minor changes * add async setup entry * Add translations and some other stuff * add and remove entry * use async_setup_entry * Update config_flows.py * dshokouhi's changes * Improve workflow * Add valid_vendors * Add entity registry * Add device registry * Update entry from configuration.yaml * Revert unneccesary changes * Update .coveragerc * Prepared tests * Add dshokouhi and Santobert as codeowners * Fix unload entry and abort when already_configured * First tests * Add test for abort cases * Add test for invalid credentials on import * Add one last test * Add test_init.py with some tests * Address reviews, part 1 * Update outdated entry * await instead of add_job * run IO inside an executor * remove faulty test * Fix pylint issues * Move IO out of constructur * Edit error translations * Edit imports * Minor changes * Remove test for invalid vendor * Async setup platform * Edit login function * Moved IO out if init * Update switches after added to hass * Revert update outdated entry * try and update new entrys from config.yaml * Add test invalid vendor * Default to neato --- .coveragerc | 4 +- CODEOWNERS | 1 + .../components/neato/.translations/en.json | 26 ++ homeassistant/components/neato/__init__.py | 271 +++++++----------- homeassistant/components/neato/camera.py | 20 +- homeassistant/components/neato/config_flow.py | 112 ++++++++ homeassistant/components/neato/const.py | 150 ++++++++++ homeassistant/components/neato/manifest.json | 8 +- homeassistant/components/neato/strings.json | 26 ++ homeassistant/components/neato/switch.py | 29 +- homeassistant/components/neato/vacuum.py | 29 +- homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/neato/__init__.py | 1 + tests/components/neato/test_config_flow.py | 129 +++++++++ tests/components/neato/test_init.py | 70 +++++ 17 files changed, 691 insertions(+), 190 deletions(-) create mode 100644 homeassistant/components/neato/.translations/en.json create mode 100644 homeassistant/components/neato/config_flow.py create mode 100644 homeassistant/components/neato/const.py create mode 100644 homeassistant/components/neato/strings.json create mode 100644 tests/components/neato/__init__.py create mode 100644 tests/components/neato/test_config_flow.py create mode 100644 tests/components/neato/test_init.py diff --git a/.coveragerc b/.coveragerc index 5c2d2e02f45..6f3dfbc94a8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -420,7 +420,9 @@ omit = homeassistant/components/n26/* homeassistant/components/nad/media_player.py homeassistant/components/nanoleaf/light.py - homeassistant/components/neato/* + homeassistant/components/neato/camera.py + homeassistant/components/neato/vacuum.py + homeassistant/components/neato/switch.py homeassistant/components/nederlandse_spoorwegen/sensor.py homeassistant/components/nello/lock.py homeassistant/components/nest/* diff --git a/CODEOWNERS b/CODEOWNERS index 935d68033e3..ba4058d5acf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -187,6 +187,7 @@ homeassistant/components/mpd/* @fabaff homeassistant/components/mqtt/* @home-assistant/core homeassistant/components/mysensors/* @MartinHjelmare homeassistant/components/mystrom/* @fabaff +homeassistant/components/neato/* @dshokouhi @Santobert homeassistant/components/nello/* @pschmitt homeassistant/components/ness_alarm/* @nickw444 homeassistant/components/nest/* @awarecan diff --git a/homeassistant/components/neato/.translations/en.json b/homeassistant/components/neato/.translations/en.json new file mode 100644 index 00000000000..dc13242cc1d --- /dev/null +++ b/homeassistant/components/neato/.translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "title": "Neato", + "step": { + "user": { + "title": "Neato Account Info", + "data": { + "username": "Username", + "password": "Password", + "vendor": "Vendor" + }, + "description": "See [Neato documentation]({docs_url})." + } + }, + "error": { + "invalid_credentials": "Invalid credentials" + }, + "create_entry": { + "default": "See [Neato documentation]({docs_url})." + }, + "abort": { + "already_configured": "Already configured", + "invalid_credentials": "Invalid credentials" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/neato/__init__.py b/homeassistant/components/neato/__init__.py index e17c562171a..8fd545c59bb 100644 --- a/homeassistant/components/neato/__init__.py +++ b/homeassistant/components/neato/__init__.py @@ -1,194 +1,125 @@ """Support for Neato botvac connected vacuum cleaners.""" +import asyncio import logging from datetime import timedelta -from urllib.error import HTTPError +from requests.exceptions import HTTPError, ConnectionError as ConnError import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv from homeassistant.util import Throttle +from .config_flow import NeatoConfigFlow +from .const import ( + CONF_VENDOR, + NEATO_CONFIG, + NEATO_DOMAIN, + NEATO_LOGIN, + NEATO_ROBOTS, + NEATO_PERSISTENT_MAPS, + NEATO_MAP_DATA, + VALID_VENDORS, +) + _LOGGER = logging.getLogger(__name__) -CONF_VENDOR = "vendor" -DOMAIN = "neato" -NEATO_ROBOTS = "neato_robots" -NEATO_LOGIN = "neato_login" -NEATO_MAP_DATA = "neato_map_data" -NEATO_PERSISTENT_MAPS = "neato_persistent_maps" CONFIG_SCHEMA = vol.Schema( { - DOMAIN: vol.Schema( + NEATO_DOMAIN: vol.Schema( { vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_VENDOR, default="neato"): vol.In( - ["neato", "vorwerk"] - ), + vol.Optional(CONF_VENDOR, default="neato"): vol.In(VALID_VENDORS), } ) }, extra=vol.ALLOW_EXTRA, ) -MODE = {1: "Eco", 2: "Turbo"} -ACTION = { - 0: "Invalid", - 1: "House Cleaning", - 2: "Spot Cleaning", - 3: "Manual Cleaning", - 4: "Docking", - 5: "User Menu Active", - 6: "Suspended Cleaning", - 7: "Updating", - 8: "Copying logs", - 9: "Recovering Location", - 10: "IEC test", - 11: "Map cleaning", - 12: "Exploring map (creating a persistent map)", - 13: "Acquiring Persistent Map IDs", - 14: "Creating & Uploading Map", - 15: "Suspended Exploration", -} - -ERRORS = { - "ui_error_battery_battundervoltlithiumsafety": "Replace battery", - "ui_error_battery_critical": "Replace battery", - "ui_error_battery_invalidsensor": "Replace battery", - "ui_error_battery_lithiumadapterfailure": "Replace battery", - "ui_error_battery_mismatch": "Replace battery", - "ui_error_battery_nothermistor": "Replace battery", - "ui_error_battery_overtemp": "Replace battery", - "ui_error_battery_overvolt": "Replace battery", - "ui_error_battery_undercurrent": "Replace battery", - "ui_error_battery_undertemp": "Replace battery", - "ui_error_battery_undervolt": "Replace battery", - "ui_error_battery_unplugged": "Replace battery", - "ui_error_brush_stuck": "Brush stuck", - "ui_error_brush_overloaded": "Brush overloaded", - "ui_error_bumper_stuck": "Bumper stuck", - "ui_error_check_battery_switch": "Check battery", - "ui_error_corrupt_scb": "Call customer service corrupt board", - "ui_error_deck_debris": "Deck debris", - "ui_error_dflt_app": "Check Neato app", - "ui_error_disconnect_chrg_cable": "Disconnected charge cable", - "ui_error_disconnect_usb_cable": "Disconnected USB cable", - "ui_error_dust_bin_missing": "Dust bin missing", - "ui_error_dust_bin_full": "Dust bin full", - "ui_error_dust_bin_emptied": "Dust bin emptied", - "ui_error_hardware_failure": "Hardware failure", - "ui_error_ldrop_stuck": "Clear my path", - "ui_error_lds_jammed": "Clear my path", - "ui_error_lds_bad_packets": "Check Neato app", - "ui_error_lds_disconnected": "Check Neato app", - "ui_error_lds_missed_packets": "Check Neato app", - "ui_error_lwheel_stuck": "Clear my path", - "ui_error_navigation_backdrop_frontbump": "Clear my path", - "ui_error_navigation_backdrop_leftbump": "Clear my path", - "ui_error_navigation_backdrop_wheelextended": "Clear my path", - "ui_error_navigation_noprogress": "Clear my path", - "ui_error_navigation_origin_unclean": "Clear my path", - "ui_error_navigation_pathproblems": "Cannot return to base", - "ui_error_navigation_pinkycommsfail": "Clear my path", - "ui_error_navigation_falling": "Clear my path", - "ui_error_navigation_noexitstogo": "Clear my path", - "ui_error_navigation_nomotioncommands": "Clear my path", - "ui_error_navigation_rightdrop_leftbump": "Clear my path", - "ui_error_navigation_undockingfailed": "Clear my path", - "ui_error_picked_up": "Picked up", - "ui_error_qa_fail": "Check Neato app", - "ui_error_rdrop_stuck": "Clear my path", - "ui_error_reconnect_failed": "Reconnect failed", - "ui_error_rwheel_stuck": "Clear my path", - "ui_error_stuck": "Stuck!", - "ui_error_unable_to_return_to_base": "Unable to return to base", - "ui_error_unable_to_see": "Clean vacuum sensors", - "ui_error_vacuum_slip": "Clear my path", - "ui_error_vacuum_stuck": "Clear my path", - "ui_error_warning": "Error check app", - "batt_base_connect_fail": "Battery failed to connect to base", - "batt_base_no_power": "Battery base has no power", - "batt_low": "Battery low", - "batt_on_base": "Battery on base", - "clean_tilt_on_start": "Clean the tilt on start", - "dustbin_full": "Dust bin full", - "dustbin_missing": "Dust bin missing", - "gen_picked_up": "Picked up", - "hw_fail": "Hardware failure", - "hw_tof_sensor_sensor": "Hardware sensor disconnected", - "lds_bad_packets": "Bad packets", - "lds_deck_debris": "Debris on deck", - "lds_disconnected": "Disconnected", - "lds_jammed": "Jammed", - "lds_missed_packets": "Missed packets", - "maint_brush_stuck": "Brush stuck", - "maint_brush_overload": "Brush overloaded", - "maint_bumper_stuck": "Bumper stuck", - "maint_customer_support_qa": "Contact customer support", - "maint_vacuum_stuck": "Vacuum is stuck", - "maint_vacuum_slip": "Vacuum is stuck", - "maint_left_drop_stuck": "Vacuum is stuck", - "maint_left_wheel_stuck": "Vacuum is stuck", - "maint_right_drop_stuck": "Vacuum is stuck", - "maint_right_wheel_stuck": "Vacuum is stuck", - "not_on_charge_base": "Not on the charge base", - "nav_robot_falling": "Clear my path", - "nav_no_path": "Clear my path", - "nav_path_problem": "Clear my path", - "nav_backdrop_frontbump": "Clear my path", - "nav_backdrop_leftbump": "Clear my path", - "nav_backdrop_wheelextended": "Clear my path", - "nav_mag_sensor": "Clear my path", - "nav_no_exit": "Clear my path", - "nav_no_movement": "Clear my path", - "nav_rightdrop_leftbump": "Clear my path", - "nav_undocking_failed": "Clear my path", -} - -ALERTS = { - "ui_alert_dust_bin_full": "Please empty dust bin", - "ui_alert_recovering_location": "Returning to start", - "ui_alert_battery_chargebasecommerr": "Battery error", - "ui_alert_busy_charging": "Busy charging", - "ui_alert_charging_base": "Base charging", - "ui_alert_charging_power": "Charging power", - "ui_alert_connect_chrg_cable": "Connect charge cable", - "ui_alert_info_thank_you": "Thank you", - "ui_alert_invalid": "Invalid check app", - "ui_alert_old_error": "Old error", - "ui_alert_swupdate_fail": "Update failed", - "dustbin_full": "Please empty dust bin", - "maint_brush_change": "Change the brush", - "maint_filter_change": "Change the filter", - "clean_completed_to_start": "Cleaning completed", - "nav_floorplan_not_created": "No floorplan found", - "nav_floorplan_load_fail": "Failed to load floorplan", - "nav_floorplan_localization_fail": "Failed to load floorplan", - "clean_incomplete_to_start": "Cleaning incomplete", - "log_upload_failed": "Logs failed to upload", -} - - -def setup(hass, config): +async def async_setup(hass, config): """Set up the Neato component.""" + + if NEATO_DOMAIN not in config: + # There is an entry and nothing in configuration.yaml + return True + + entries = hass.config_entries.async_entries(NEATO_DOMAIN) + hass.data[NEATO_CONFIG] = config[NEATO_DOMAIN] + + if entries: + # There is an entry and something in the configuration.yaml + entry = entries[0] + conf = config[NEATO_DOMAIN] + if ( + entry.data[CONF_USERNAME] == conf[CONF_USERNAME] + and entry.data[CONF_PASSWORD] == conf[CONF_PASSWORD] + and entry.data[CONF_VENDOR] == conf[CONF_VENDOR] + ): + # 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 + + +async def async_setup_entry(hass, entry): + """Set up config entry.""" from pybotvac import Account, Neato, Vorwerk - if config[DOMAIN][CONF_VENDOR] == "neato": - hass.data[NEATO_LOGIN] = NeatoHub(hass, config[DOMAIN], Account, Neato) - elif config[DOMAIN][CONF_VENDOR] == "vorwerk": - hass.data[NEATO_LOGIN] = NeatoHub(hass, config[DOMAIN], Account, Vorwerk) + if entry.data[CONF_VENDOR] == "neato": + hass.data[NEATO_LOGIN] = NeatoHub(hass, entry.data, Account, Neato) + elif entry.data[CONF_VENDOR] == "vorwerk": + hass.data[NEATO_LOGIN] = NeatoHub(hass, entry.data, Account, Vorwerk) + hub = hass.data[NEATO_LOGIN] - if not hub.login(): + await hass.async_add_executor_job(hub.login) + if not hub.logged_in: _LOGGER.debug("Failed to login to Neato API") return False - hub.update_robots() - for component in ("camera", "vacuum", "switch"): - discovery.load_platform(hass, component, DOMAIN, {}, config) + await hass.async_add_executor_job(hub.update_robots) + for component in ("camera", "vacuum", "switch"): + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass, entry): + """Unload config entry.""" + hass.data.pop(NEATO_LOGIN) + await asyncio.gather( + hass.config_entries.async_forward_entry_unload(entry, "camera"), + hass.config_entries.async_forward_entry_unload(entry, "vacuum"), + hass.config_entries.async_forward_entry_unload(entry, "switch"), + ) return True @@ -202,12 +133,8 @@ class NeatoHub: self._hass = hass self._vendor = vendor - self.my_neato = neato( - domain_config[CONF_USERNAME], domain_config[CONF_PASSWORD], vendor - ) - self._hass.data[NEATO_ROBOTS] = self.my_neato.robots - self._hass.data[NEATO_PERSISTENT_MAPS] = self.my_neato.persistent_maps - self._hass.data[NEATO_MAP_DATA] = self.my_neato.maps + self.my_neato = None + self.logged_in = False def login(self): """Login to My Neato.""" @@ -216,10 +143,16 @@ class NeatoHub: self.my_neato = self._neato( self.config[CONF_USERNAME], self.config[CONF_PASSWORD], self._vendor ) - return True - except HTTPError: + self.logged_in = True + except (HTTPError, ConnError): _LOGGER.error("Unable to connect to Neato API") - return False + self.logged_in = False + return + + _LOGGER.debug("Successfully connected to Neato API") + self._hass.data[NEATO_ROBOTS] = self.my_neato.robots + self._hass.data[NEATO_PERSISTENT_MAPS] = self.my_neato.persistent_maps + self._hass.data[NEATO_MAP_DATA] = self.my_neato.maps @Throttle(timedelta(seconds=300)) def update_robots(self): diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py index 5d4e0057960..c565fa3d9ac 100644 --- a/homeassistant/components/neato/camera.py +++ b/homeassistant/components/neato/camera.py @@ -4,21 +4,30 @@ import logging from homeassistant.components.camera import Camera -from . import NEATO_LOGIN, NEATO_MAP_DATA, NEATO_ROBOTS +from .const import NEATO_DOMAIN, NEATO_MAP_DATA, NEATO_ROBOTS, NEATO_LOGIN _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=10) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Neato Camera.""" + pass + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Neato camera with config entry.""" dev = [] for robot in hass.data[NEATO_ROBOTS]: if "maps" in robot.traits: dev.append(NeatoCleaningMap(hass, robot)) + + if not dev: + return + _LOGGER.debug("Adding robots for cleaning maps %s", dev) - add_entities(dev, True) + async_add_entities(dev, True) class NeatoCleaningMap(Camera): @@ -61,3 +70,8 @@ class NeatoCleaningMap(Camera): def unique_id(self): """Return unique ID.""" return self._robot_serial + + @property + def device_info(self): + """Device info for neato robot.""" + return {"identifiers": {(NEATO_DOMAIN, self._robot_serial)}} diff --git a/homeassistant/components/neato/config_flow.py b/homeassistant/components/neato/config_flow.py new file mode 100644 index 00000000000..0c71cdbd069 --- /dev/null +++ b/homeassistant/components/neato/config_flow.py @@ -0,0 +1,112 @@ +"""Config flow to configure Neato integration.""" + +import logging + +import voluptuous as vol +from requests.exceptions import HTTPError, ConnectionError as ConnError + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +# pylint: disable=unused-import +from .const import CONF_VENDOR, NEATO_DOMAIN, VALID_VENDORS + + +DOCS_URL = "https://www.home-assistant.io/components/neato" +DEFAULT_VENDOR = "neato" + +_LOGGER = logging.getLogger(__name__) + + +class NeatoConfigFlow(config_entries.ConfigFlow, domain=NEATO_DOMAIN): + """Neato integration config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize flow.""" + self._username = vol.UNDEFINED + self._password = vol.UNDEFINED + self._vendor = vol.UNDEFINED + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + + if user_input is not None: + self._username = user_input["username"] + self._password = user_input["password"] + self._vendor = user_input["vendor"] + + error = await self.hass.async_add_executor_job( + self.try_login, self._username, self._password, self._vendor + ) + if error: + 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( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_VENDOR, default="neato"): vol.In(VALID_VENDORS), + } + ), + description_placeholders={"docs_url": DOCS_URL}, + errors=errors, + ) + + 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.""" + from pybotvac import Account, Neato, Vorwerk + + this_vendor = None + if vendor == "vorwerk": + this_vendor = Vorwerk() + else: # Neato + this_vendor = Neato() + + try: + Account(username, password, this_vendor) + except (HTTPError, ConnError): + return "invalid_credentials" + + return None diff --git a/homeassistant/components/neato/const.py b/homeassistant/components/neato/const.py new file mode 100644 index 00000000000..6fb41bda710 --- /dev/null +++ b/homeassistant/components/neato/const.py @@ -0,0 +1,150 @@ +"""Constants for Neato integration.""" + +NEATO_DOMAIN = "neato" + +CONF_VENDOR = "vendor" +NEATO_ROBOTS = "neato_robots" +NEATO_LOGIN = "neato_login" +NEATO_CONFIG = "neato_config" +NEATO_MAP_DATA = "neato_map_data" +NEATO_PERSISTENT_MAPS = "neato_persistent_maps" + +VALID_VENDORS = ["neato", "vorwerk"] + +MODE = {1: "Eco", 2: "Turbo"} + +ACTION = { + 0: "Invalid", + 1: "House Cleaning", + 2: "Spot Cleaning", + 3: "Manual Cleaning", + 4: "Docking", + 5: "User Menu Active", + 6: "Suspended Cleaning", + 7: "Updating", + 8: "Copying logs", + 9: "Recovering Location", + 10: "IEC test", + 11: "Map cleaning", + 12: "Exploring map (creating a persistent map)", + 13: "Acquiring Persistent Map IDs", + 14: "Creating & Uploading Map", + 15: "Suspended Exploration", +} + +ERRORS = { + "ui_error_battery_battundervoltlithiumsafety": "Replace battery", + "ui_error_battery_critical": "Replace battery", + "ui_error_battery_invalidsensor": "Replace battery", + "ui_error_battery_lithiumadapterfailure": "Replace battery", + "ui_error_battery_mismatch": "Replace battery", + "ui_error_battery_nothermistor": "Replace battery", + "ui_error_battery_overtemp": "Replace battery", + "ui_error_battery_overvolt": "Replace battery", + "ui_error_battery_undercurrent": "Replace battery", + "ui_error_battery_undertemp": "Replace battery", + "ui_error_battery_undervolt": "Replace battery", + "ui_error_battery_unplugged": "Replace battery", + "ui_error_brush_stuck": "Brush stuck", + "ui_error_brush_overloaded": "Brush overloaded", + "ui_error_bumper_stuck": "Bumper stuck", + "ui_error_check_battery_switch": "Check battery", + "ui_error_corrupt_scb": "Call customer service corrupt board", + "ui_error_deck_debris": "Deck debris", + "ui_error_dflt_app": "Check Neato app", + "ui_error_disconnect_chrg_cable": "Disconnected charge cable", + "ui_error_disconnect_usb_cable": "Disconnected USB cable", + "ui_error_dust_bin_missing": "Dust bin missing", + "ui_error_dust_bin_full": "Dust bin full", + "ui_error_dust_bin_emptied": "Dust bin emptied", + "ui_error_hardware_failure": "Hardware failure", + "ui_error_ldrop_stuck": "Clear my path", + "ui_error_lds_jammed": "Clear my path", + "ui_error_lds_bad_packets": "Check Neato app", + "ui_error_lds_disconnected": "Check Neato app", + "ui_error_lds_missed_packets": "Check Neato app", + "ui_error_lwheel_stuck": "Clear my path", + "ui_error_navigation_backdrop_frontbump": "Clear my path", + "ui_error_navigation_backdrop_leftbump": "Clear my path", + "ui_error_navigation_backdrop_wheelextended": "Clear my path", + "ui_error_navigation_noprogress": "Clear my path", + "ui_error_navigation_origin_unclean": "Clear my path", + "ui_error_navigation_pathproblems": "Cannot return to base", + "ui_error_navigation_pinkycommsfail": "Clear my path", + "ui_error_navigation_falling": "Clear my path", + "ui_error_navigation_noexitstogo": "Clear my path", + "ui_error_navigation_nomotioncommands": "Clear my path", + "ui_error_navigation_rightdrop_leftbump": "Clear my path", + "ui_error_navigation_undockingfailed": "Clear my path", + "ui_error_picked_up": "Picked up", + "ui_error_qa_fail": "Check Neato app", + "ui_error_rdrop_stuck": "Clear my path", + "ui_error_reconnect_failed": "Reconnect failed", + "ui_error_rwheel_stuck": "Clear my path", + "ui_error_stuck": "Stuck!", + "ui_error_unable_to_return_to_base": "Unable to return to base", + "ui_error_unable_to_see": "Clean vacuum sensors", + "ui_error_vacuum_slip": "Clear my path", + "ui_error_vacuum_stuck": "Clear my path", + "ui_error_warning": "Error check app", + "batt_base_connect_fail": "Battery failed to connect to base", + "batt_base_no_power": "Battery base has no power", + "batt_low": "Battery low", + "batt_on_base": "Battery on base", + "clean_tilt_on_start": "Clean the tilt on start", + "dustbin_full": "Dust bin full", + "dustbin_missing": "Dust bin missing", + "gen_picked_up": "Picked up", + "hw_fail": "Hardware failure", + "hw_tof_sensor_sensor": "Hardware sensor disconnected", + "lds_bad_packets": "Bad packets", + "lds_deck_debris": "Debris on deck", + "lds_disconnected": "Disconnected", + "lds_jammed": "Jammed", + "lds_missed_packets": "Missed packets", + "maint_brush_stuck": "Brush stuck", + "maint_brush_overload": "Brush overloaded", + "maint_bumper_stuck": "Bumper stuck", + "maint_customer_support_qa": "Contact customer support", + "maint_vacuum_stuck": "Vacuum is stuck", + "maint_vacuum_slip": "Vacuum is stuck", + "maint_left_drop_stuck": "Vacuum is stuck", + "maint_left_wheel_stuck": "Vacuum is stuck", + "maint_right_drop_stuck": "Vacuum is stuck", + "maint_right_wheel_stuck": "Vacuum is stuck", + "not_on_charge_base": "Not on the charge base", + "nav_robot_falling": "Clear my path", + "nav_no_path": "Clear my path", + "nav_path_problem": "Clear my path", + "nav_backdrop_frontbump": "Clear my path", + "nav_backdrop_leftbump": "Clear my path", + "nav_backdrop_wheelextended": "Clear my path", + "nav_mag_sensor": "Clear my path", + "nav_no_exit": "Clear my path", + "nav_no_movement": "Clear my path", + "nav_rightdrop_leftbump": "Clear my path", + "nav_undocking_failed": "Clear my path", +} + +ALERTS = { + "ui_alert_dust_bin_full": "Please empty dust bin", + "ui_alert_recovering_location": "Returning to start", + "ui_alert_battery_chargebasecommerr": "Battery error", + "ui_alert_busy_charging": "Busy charging", + "ui_alert_charging_base": "Base charging", + "ui_alert_charging_power": "Charging power", + "ui_alert_connect_chrg_cable": "Connect charge cable", + "ui_alert_info_thank_you": "Thank you", + "ui_alert_invalid": "Invalid check app", + "ui_alert_old_error": "Old error", + "ui_alert_swupdate_fail": "Update failed", + "dustbin_full": "Please empty dust bin", + "maint_brush_change": "Change the brush", + "maint_filter_change": "Change the filter", + "clean_completed_to_start": "Cleaning completed", + "nav_floorplan_not_created": "No floorplan found", + "nav_floorplan_load_fail": "Failed to load floorplan", + "nav_floorplan_localization_fail": "Failed to load floorplan", + "clean_incomplete_to_start": "Cleaning incomplete", + "log_upload_failed": "Logs failed to upload", +} diff --git a/homeassistant/components/neato/manifest.json b/homeassistant/components/neato/manifest.json index 8b0c5acc723..160f194cd63 100644 --- a/homeassistant/components/neato/manifest.json +++ b/homeassistant/components/neato/manifest.json @@ -1,10 +1,14 @@ { "domain": "neato", "name": "Neato", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/neato", "requirements": [ "pybotvac==0.0.15" ], "dependencies": [], - "codeowners": [] -} + "codeowners": [ + "@dshokouhi", + "@Santobert" + ] +} \ No newline at end of file diff --git a/homeassistant/components/neato/strings.json b/homeassistant/components/neato/strings.json new file mode 100644 index 00000000000..dc13242cc1d --- /dev/null +++ b/homeassistant/components/neato/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "title": "Neato", + "step": { + "user": { + "title": "Neato Account Info", + "data": { + "username": "Username", + "password": "Password", + "vendor": "Vendor" + }, + "description": "See [Neato documentation]({docs_url})." + } + }, + "error": { + "invalid_credentials": "Invalid credentials" + }, + "create_entry": { + "default": "See [Neato documentation]({docs_url})." + }, + "abort": { + "already_configured": "Already configured", + "invalid_credentials": "Invalid credentials" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/neato/switch.py b/homeassistant/components/neato/switch.py index 539e8cb748c..3efee11853d 100644 --- a/homeassistant/components/neato/switch.py +++ b/homeassistant/components/neato/switch.py @@ -7,7 +7,7 @@ import requests from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.helpers.entity import ToggleEntity -from . import NEATO_LOGIN, NEATO_ROBOTS +from .const import NEATO_DOMAIN, NEATO_LOGIN, NEATO_ROBOTS _LOGGER = logging.getLogger(__name__) @@ -18,14 +18,23 @@ SWITCH_TYPE_SCHEDULE = "schedule" SWITCH_TYPES = {SWITCH_TYPE_SCHEDULE: ["Schedule"]} -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Neato switches.""" + pass + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Neato switch with config entry.""" dev = [] for robot in hass.data[NEATO_ROBOTS]: for type_name in SWITCH_TYPES: dev.append(NeatoConnectedSwitch(hass, robot, type_name)) + + if not dev: + return + _LOGGER.debug("Adding switches %s", dev) - add_entities(dev) + async_add_entities(dev, True) class NeatoConnectedSwitch(ToggleEntity): @@ -37,14 +46,7 @@ class NeatoConnectedSwitch(ToggleEntity): self.robot = robot self.neato = hass.data[NEATO_LOGIN] self._robot_name = "{} {}".format(self.robot.name, SWITCH_TYPES[self.type][0]) - try: - self._state = self.robot.state - except ( - requests.exceptions.ConnectionError, - requests.exceptions.HTTPError, - ) as ex: - _LOGGER.warning("Neato connection error: %s", ex) - self._state = None + self._state = None self._schedule_state = None self._clean_state = None self._robot_serial = self.robot.serial @@ -94,6 +96,11 @@ class NeatoConnectedSwitch(ToggleEntity): return True return False + @property + def device_info(self): + """Device info for neato robot.""" + return {"identifiers": {(NEATO_DOMAIN, self._robot_serial)}} + def turn_on(self, **kwargs): """Turn the switch on.""" if self.type == SWITCH_TYPE_SCHEDULE: diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index f284b2eda1e..96c4e8f3c5f 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -31,12 +31,13 @@ from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import extract_entity_ids -from . import ( +from .const import ( ACTION, ALERTS, ERRORS, MODE, NEATO_LOGIN, + NEATO_DOMAIN, NEATO_MAP_DATA, NEATO_PERSISTENT_MAPS, NEATO_ROBOTS, @@ -83,8 +84,13 @@ SERVICE_NEATO_CUSTOM_CLEANING_SCHEMA = vol.Schema( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Neato vacuum.""" + pass + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Neato vacuum with config entry.""" dev = [] for robot in hass.data[NEATO_ROBOTS]: dev.append(NeatoConnectedVacuum(hass, robot)) @@ -93,7 +99,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return _LOGGER.debug("Adding vacuums %s", dev) - add_entities(dev, True) + async_add_entities(dev, True) def neato_custom_cleaning_service(call): """Zone cleaning service that allows user to change options.""" @@ -111,7 +117,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): entities = [entity for entity in dev if entity.entity_id in entity_ids] return entities - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_NEATO_CUSTOM_CLEANING, neato_custom_cleaning_service, @@ -144,10 +150,14 @@ class NeatoConnectedVacuum(StateVacuumDevice): self._robot_maps = hass.data[NEATO_PERSISTENT_MAPS] self._robot_boundaries = {} self._robot_has_map = self.robot.has_persistent_maps + self._robot_stats = None def update(self): """Update the states of Neato Vacuums.""" _LOGGER.debug("Running Neato Vacuums update") + if self._robot_stats is None: + self._robot_stats = self.robot.get_robot_info().json() + self.neato.update_robots() try: self._state = self.robot.state @@ -290,6 +300,17 @@ class NeatoConnectedVacuum(StateVacuumDevice): return data + @property + def device_info(self): + """Device info for neato robot.""" + return { + "identifiers": {(NEATO_DOMAIN, self._robot_serial)}, + "name": self._name, + "manufacturer": self._robot_stats["data"]["mfg_name"], + "model": self._robot_stats["data"]["modelName"], + "sw_version": self._state["meta"]["firmware"], + } + def start(self): """Start cleaning or resume cleaning.""" if self._state["state"] == 1: diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1eb08709741..4a4effc36ce 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -43,6 +43,7 @@ FLOWS = [ "met", "mobile_app", "mqtt", + "neato", "nest", "notion", "opentherm_gw", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 60780ec7c55..8627ddb0d86 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -296,6 +296,9 @@ pyMetno==0.4.6 # homeassistant.components.blackbird pyblackbird==0.5 +# homeassistant.components.neato +pybotvac==0.0.15 + # homeassistant.components.cast pychromecast==4.0.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 70c81c66025..3c0941fc887 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -122,6 +122,7 @@ TEST_REQUIREMENTS = ( "py-canary", "py17track", "pyblackbird", + "pybotvac", "pychromecast", "pydeconz", "pydispatcher", diff --git a/tests/components/neato/__init__.py b/tests/components/neato/__init__.py new file mode 100644 index 00000000000..7927918395c --- /dev/null +++ b/tests/components/neato/__init__.py @@ -0,0 +1 @@ +"""Tests for the Neato component.""" diff --git a/tests/components/neato/test_config_flow.py b/tests/components/neato/test_config_flow.py new file mode 100644 index 00000000000..99691c101a6 --- /dev/null +++ b/tests/components/neato/test_config_flow.py @@ -0,0 +1,129 @@ +"""Tests for the Neato config flow.""" +import pytest +from unittest.mock import patch + +from homeassistant import data_entry_flow +from homeassistant.components.neato import config_flow +from homeassistant.components.neato.const import NEATO_DOMAIN, CONF_VENDOR +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +USERNAME = "myUsername" +PASSWORD = "myPassword" +VENDOR_NEATO = "neato" +VENDOR_VORWERK = "vorwerk" +VENDOR_INVALID = "invalid" + + +@pytest.fixture(name="account") +def mock_controller_login(): + """Mock a successful login.""" + with patch("pybotvac.Account", return_value=True): + yield + + +def init_config_flow(hass): + """Init a configuration flow.""" + flow = config_flow.NeatoConfigFlow() + 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, + }, + ).add_to_hass(hass) + + # Should fail, same USERNAME (import) + 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"] == "already_configured" + + # Should fail, same USERNAME (flow) + result = await flow.async_step_user( + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_VENDOR: VENDOR_NEATO} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_abort_on_invalid_credentials(hass): + """Test when we have invalid credentials.""" + from requests.exceptions import HTTPError + + flow = init_config_flow(hass) + + with patch("pybotvac.Account", side_effect=HTTPError()): + 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": "invalid_credentials"} + + 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"] == "invalid_credentials" diff --git a/tests/components/neato/test_init.py b/tests/components/neato/test_init.py new file mode 100644 index 00000000000..be7e43fdc0a --- /dev/null +++ b/tests/components/neato/test_init.py @@ -0,0 +1,70 @@ +"""Tests for the Neato init file.""" +import pytest +from unittest.mock import patch + +from homeassistant.components.neato.const import NEATO_DOMAIN, CONF_VENDOR +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.setup import async_setup_component + +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, +} + +INVALID_CONFIG = { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_VENDOR: VENDOR_INVALID, +} + + +@pytest.fixture(name="account") +def mock_controller_login(): + """Mock a successful login.""" + with patch("pybotvac.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_config_entries_in_sync(hass, account): + """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, account): + """The config entry and configuration.yaml are not in sync.""" + MockConfigEntry(domain=NEATO_DOMAIN, data=INVALID_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