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
This commit is contained in:
Santobert 2019-10-06 13:05:51 +02:00 committed by Martin Hjelmare
parent 476f24e451
commit bd6bbcd5af
17 changed files with 691 additions and 190 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -43,6 +43,7 @@ FLOWS = [
"met",
"mobile_app",
"mqtt",
"neato",
"nest",
"notion",
"opentherm_gw",

View file

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

View file

@ -122,6 +122,7 @@ TEST_REQUIREMENTS = (
"py-canary",
"py17track",
"pyblackbird",
"pybotvac",
"pychromecast",
"pydeconz",
"pydispatcher",

View file

@ -0,0 +1 @@
"""Tests for the Neato component."""

View file

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

View file

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