diff --git a/CODEOWNERS b/CODEOWNERS index cc178079326..cdb61c59104 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -314,7 +314,7 @@ homeassistant/components/rfxtrx/* @danielhiversen homeassistant/components/ring/* @balloob homeassistant/components/rmvtransport/* @cgtobi homeassistant/components/roku/* @ctalkington -homeassistant/components/roomba/* @pschmitt +homeassistant/components/roomba/* @pschmitt @cyr-ius homeassistant/components/safe_mode/* @home-assistant/core homeassistant/components/saj/* @fredericvl homeassistant/components/salt/* @bjornorri diff --git a/homeassistant/components/roomba/.translations/en.json b/homeassistant/components/roomba/.translations/en.json new file mode 100644 index 00000000000..69e4c0c5760 --- /dev/null +++ b/homeassistant/components/roomba/.translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "title": "iRobot Roomba", + "step": { + "user": { + "title": "Connect to the device", + "description": "Currently retrieving the BLID and password is a manual process. Please follow the steps outlined in the documentation at: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials", + "data": { + "host": "Hostname or IP Address", + "blid": "BLID", + "password": "Password", + "certificate": "Certificate", + "continuous": "Continuous", + "delay": "Delay" + } + } + }, + "error": { + "unknown" : "Unexpected error", + "cannot_connect": "Failed to connect, please try again" + } + }, + "options": { + "step": { + "init": { + "data": { + "continuous": "Continuous", + "delay": "Delay" + } + } + } + } +} diff --git a/homeassistant/components/roomba/.translations/fr.json b/homeassistant/components/roomba/.translations/fr.json new file mode 100644 index 00000000000..5f59144727f --- /dev/null +++ b/homeassistant/components/roomba/.translations/fr.json @@ -0,0 +1,33 @@ +{ + "config": { + "title": "iRobot Roomba", + "step": { + "user": { + "title": "Connexion au périphérique", + "description": "Actuellement la récupération du BLID et du mot de passe nécessite une procédure manuelle. Veuillez suivre les étapes décrites dans la documentation sur: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials", + "data": { + "host": "Nom ou Addresse IP", + "blid": "BLID", + "password": "Mot de passe", + "certificate": "Certificat", + "continuous": "Continue", + "delay": "Delais" + } + } + }, + "error": { + "unknown" : "Erreur imprévu", + "cannot_connect": "Impossible de se connecter" + } + }, + "options": { + "step": { + "init": { + "data": { + "continuous": "Continue", + "delay": "Delais" + } + } + } + } +} diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index c0e5f68483e..2b4582610e1 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -1 +1,191 @@ """The roomba component.""" +import asyncio +import logging + +import async_timeout +from roomba import Roomba, RoombaConnectionError +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv + +from .const import ( + BLID, + COMPONENTS, + CONF_BLID, + CONF_CERT, + CONF_CONTINUOUS, + CONF_DELAY, + CONF_NAME, + DEFAULT_CERT, + DEFAULT_CONTINUOUS, + DEFAULT_DELAY, + DOMAIN, + ROOMBA_SESSION, +) + +_LOGGER = logging.getLogger(__name__) + + +def _has_all_unique_bilds(value): + """Validate that each vacuum configured has a unique bild. + + Uniqueness is determined case-independently. + """ + bilds = [device[CONF_BLID] for device in value] + schema = vol.Schema(vol.Unique()) + schema(bilds) + return value + + +DEVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_BLID): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_CERT, default=DEFAULT_CERT): str, + vol.Optional(CONF_CONTINUOUS, default=DEFAULT_CONTINUOUS): bool, + vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): int, + }, +) + + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [DEVICE_SCHEMA], _has_all_unique_bilds)}, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the roomba environment.""" + hass.data.setdefault(DOMAIN, {}) + + if DOMAIN not in config: + return True + for index, conf in enumerate(config[DOMAIN]): + _LOGGER.debug("Importing Roomba #%d - %s", index, conf[CONF_HOST]) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf, + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set the config entry up.""" + # Set up roomba platforms with config entry + + if not config_entry.options: + hass.config_entries.async_update_entry( + config_entry, + options={ + "continuous": config_entry.data[CONF_CONTINUOUS], + "delay": config_entry.data[CONF_DELAY], + }, + ) + + roomba = Roomba( + address=config_entry.data[CONF_HOST], + blid=config_entry.data[CONF_BLID], + password=config_entry.data[CONF_PASSWORD], + cert_name=config_entry.data[CONF_CERT], + continuous=config_entry.options[CONF_CONTINUOUS], + delay=config_entry.options[CONF_DELAY], + ) + + try: + if not await async_connect_or_timeout(hass, roomba): + return False + except CannotConnect: + raise exceptions.ConfigEntryNotReady + + hass.data[DOMAIN][config_entry.entry_id] = { + ROOMBA_SESSION: roomba, + BLID: config_entry.data[CONF_BLID], + } + + for component in COMPONENTS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + if not config_entry.update_listeners: + config_entry.add_update_listener(async_update_options) + + return True + + +async def async_connect_or_timeout(hass, roomba): + """Connect to vacuum.""" + try: + name = None + with async_timeout.timeout(10): + _LOGGER.debug("Initialize connection to vacuum") + await hass.async_add_job(roomba.connect) + while not roomba.roomba_connected or name is None: + # Waiting for connection and check datas ready + name = roomba_reported_state(roomba).get("name", None) + if name: + break + await asyncio.sleep(1) + except RoombaConnectionError: + _LOGGER.error("Error to connect to vacuum") + raise CannotConnect + except asyncio.TimeoutError: + # api looping if user or password incorrect and roomba exist + await async_disconnect_or_timeout(hass, roomba) + _LOGGER.error("Timeout expired") + raise CannotConnect + + return {ROOMBA_SESSION: roomba, CONF_NAME: name} + + +async def async_disconnect_or_timeout(hass, roomba): + """Disconnect to vacuum.""" + _LOGGER.debug("Disconnect vacuum") + with async_timeout.timeout(3): + await hass.async_add_job(roomba.disconnect) + return True + + +async def async_update_options(hass, config_entry): + """Update options.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in COMPONENTS + ] + ) + ) + if unload_ok: + domain_data = hass.data[DOMAIN][config_entry.entry_id] + await async_disconnect_or_timeout(hass, roomba=domain_data[ROOMBA_SESSION]) + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +def roomba_reported_state(roomba): + """Roomba report.""" + return roomba.master_state.get("state", {}).get("reported", {}) + + +@callback +def _async_find_matching_config_entry(hass, prefix): + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.unique_id == prefix: + return entry + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py new file mode 100644 index 00000000000..4ed3ab02418 --- /dev/null +++ b/homeassistant/components/roomba/binary_sensor.py @@ -0,0 +1,74 @@ +"""Roomba binary sensor entities.""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice + +from . import roomba_reported_state +from .const import BLID, DOMAIN, ROOMBA_SESSION + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the iRobot Roomba vacuum cleaner.""" + domain_data = hass.data[DOMAIN][config_entry.entry_id] + roomba = domain_data[ROOMBA_SESSION] + blid = domain_data[BLID] + status = roomba_reported_state(roomba).get("bin", {}) + if "full" in status: + roomba_vac = RoombaBinStatus(roomba, blid) + async_add_entities([roomba_vac], True) + + +class RoombaBinStatus(BinarySensorDevice): + """Class to hold Roomba Sensor basic info.""" + + ICON = "mdi:delete-variant" + + def __init__(self, roomba, blid): + """Initialize the sensor object.""" + self.vacuum = roomba + self.vacuum_state = roomba_reported_state(roomba) + self._blid = blid + self._name = self.vacuum_state.get("name") + self._identifier = f"roomba_{self._blid}" + self._bin_status = None + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._name} Bin Full" + + @property + def unique_id(self): + """Return the ID of this sensor.""" + return f"bin_{self._blid}" + + @property + def icon(self): + """Return the icon of this sensor.""" + return self.ICON + + @property + def state(self): + """Return the state of the sensor.""" + return self._bin_status + + @property + def device_info(self): + """Return the device info of the vacuum cleaner.""" + return { + "identifiers": {(DOMAIN, self._identifier)}, + "name": str(self._name), + } + + async def async_update(self): + """Return the update info of the vacuum cleaner.""" + # No data, no update + if not self.vacuum.master_state: + _LOGGER.debug("Roomba %s has no data yet. Skip update", self.name) + return + self._bin_status = ( + roomba_reported_state(self.vacuum).get("bin", {}).get("full", False) + ) + _LOGGER.debug("Update Full Bin status from the vacuum: %s", self._bin_status) diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py new file mode 100644 index 00000000000..3668984a41f --- /dev/null +++ b/homeassistant/components/roomba/config_flow.py @@ -0,0 +1,131 @@ +"""Config flow to configure roomba component.""" +import logging + +from roomba import Roomba +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import callback + +from . import CannotConnect, async_connect_or_timeout, async_disconnect_or_timeout +from .const import ( + CONF_BLID, + CONF_CERT, + CONF_CONTINUOUS, + CONF_DELAY, + CONF_NAME, + DEFAULT_CERT, + DEFAULT_CONTINUOUS, + DEFAULT_DELAY, + ROOMBA_SESSION, +) +from .const import DOMAIN # pylint:disable=unused-import + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_BLID): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_CERT, default=DEFAULT_CERT): str, + vol.Optional(CONF_CONTINUOUS, default=DEFAULT_CONTINUOUS): bool, + vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): int, + } +) + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + roomba = Roomba( + address=data[CONF_HOST], + blid=data[CONF_BLID], + password=data[CONF_PASSWORD], + cert_name=data[CONF_CERT], + continuous=data[CONF_CONTINUOUS], + delay=data[CONF_DELAY], + ) + + info = await async_connect_or_timeout(hass, roomba) + + return { + ROOMBA_SESSION: info[ROOMBA_SESSION], + CONF_NAME: info[CONF_NAME], + CONF_HOST: data[CONF_HOST], + } + + +class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Roomba configuration flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + async def async_step_import(self, import_info): + """Set the config entry up from yaml.""" + return await self.async_step_user(import_info) + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_BLID]) + self._abort_if_unique_id_configured() + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors = {"base": "cannot_connect"} + except Exception: # pylint: disable=broad-except + errors = {"base": "unknown"} + + if "base" not in errors: + await async_disconnect_or_timeout(self.hass, info[ROOMBA_SESSION]) + return self.async_create_entry(title=info[CONF_NAME], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle options.""" + + def __init__(self, config_entry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_CONTINUOUS, + default=self.config_entry.options.get( + CONF_CONTINUOUS, DEFAULT_CONTINUOUS + ), + ): bool, + vol.Optional( + CONF_DELAY, + default=self.config_entry.options.get( + CONF_DELAY, DEFAULT_DELAY + ), + ): int, + } + ), + ) diff --git a/homeassistant/components/roomba/const.py b/homeassistant/components/roomba/const.py new file mode 100644 index 00000000000..06684e63bdc --- /dev/null +++ b/homeassistant/components/roomba/const.py @@ -0,0 +1,13 @@ +"""The roomba constants.""" +DOMAIN = "roomba" +COMPONENTS = ["sensor", "binary_sensor", "vacuum"] +CONF_CERT = "certificate" +CONF_CONTINUOUS = "continuous" +CONF_DELAY = "delay" +CONF_NAME = "name" +CONF_BLID = "blid" +DEFAULT_CERT = "/etc/ssl/certs/ca-certificates.crt" +DEFAULT_CONTINUOUS = True +DEFAULT_DELAY = 1 +ROOMBA_SESSION = "roomba_session" +BLID = "blid_key" diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 942ebd08426..6ef71bb9524 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -1,7 +1,9 @@ { "domain": "roomba", "name": "iRobot Roomba", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/roomba", - "requirements": ["roombapy==1.4.3"], - "codeowners": ["@pschmitt"] + "requirements": ["roombapy==1.5.0"], + "dependencies": [], + "codeowners": ["@pschmitt", "@cyr-ius"] } diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py new file mode 100644 index 00000000000..2f3a6c53555 --- /dev/null +++ b/homeassistant/components/roomba/sensor.py @@ -0,0 +1,76 @@ +"""Sensor for checking the battery level of Roomba.""" +import logging + +from homeassistant.const import DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE +from homeassistant.helpers.entity import Entity + +from . import roomba_reported_state +from .const import BLID, DOMAIN, ROOMBA_SESSION + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the iRobot Roomba vacuum cleaner.""" + domain_data = hass.data[DOMAIN][config_entry.entry_id] + roomba = domain_data[ROOMBA_SESSION] + blid = domain_data[BLID] + roomba_vac = RoombaBattery(roomba, blid) + async_add_entities([roomba_vac], True) + + +class RoombaBattery(Entity): + """Class to hold Roomba Sensor basic info.""" + + def __init__(self, roomba, blid): + """Initialize the sensor object.""" + self.vacuum = roomba + self.vacuum_state = roomba_reported_state(roomba) + self._blid = blid + self._name = self.vacuum_state.get("name") + self._identifier = f"roomba_{self._blid}" + self._battery_level = None + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._name} Battery Level" + + @property + def unique_id(self): + """Return the ID of this sensor.""" + return f"battery_{self._blid}" + + @property + def device_class(self): + """Return the device class of the sensor.""" + return DEVICE_CLASS_BATTERY + + @property + def unit_of_measurement(self): + """Return the unit_of_measurement of the device.""" + return UNIT_PERCENTAGE + + @property + def state(self): + """Return the state of the sensor.""" + return self._battery_level + + @property + def device_info(self): + """Return the device info of the vacuum cleaner.""" + return { + "identifiers": {(DOMAIN, self._identifier)}, + "name": str(self._name), + } + + async def async_update(self): + """Return the update info of the vacuum cleaner.""" + # No data, no update + if not self.vacuum.master_state: + _LOGGER.debug("Roomba %s has no data yet. Skip update", self.name) + return + self._battery_level = roomba_reported_state(self.vacuum).get("batPct") + _LOGGER.debug( + "Update battery level status from the vacuum: %s", self._battery_level + ) diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json new file mode 100644 index 00000000000..403d1980b94 --- /dev/null +++ b/homeassistant/components/roomba/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "title": "iRobot Roomba", + "step": { + "user": { + "title": "Connect to the device", + "description": "Currently retrieving the BLID and password is a manual process. Please follow the steps outlined in the documentation at: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials", + "data": { + "host": "Hostname or IP Address", + "blid": "BLID", + "password": "Password", + "certificate": "Certificate", + "continuous": "Continuous", + "delay": "Delay" + } + } + }, + "error": { + "unknown": "Unexpected error", + "cannot_connect": "Failed to connect, please try again" + } + }, + "options": { + "step": { + "init": { + "data": { + "continuous": "Continuous", + "delay": "Delay" + } + } + } + } +} diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index 172a494b602..80dbbc312ea 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -1,13 +1,7 @@ """Support for Wi-Fi enabled iRobot Roombas.""" -import asyncio import logging -import async_timeout -from roomba import Roomba -import voluptuous as vol - from homeassistant.components.vacuum import ( - PLATFORM_SCHEMA, SUPPORT_BATTERY, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, @@ -20,9 +14,9 @@ from homeassistant.components.vacuum import ( SUPPORT_TURN_ON, VacuumDevice, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME -from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv + +from . import roomba_reported_state +from .const import BLID, DOMAIN, ROOMBA_SESSION _LOGGER = logging.getLogger(__name__) @@ -37,34 +31,11 @@ ATTR_SOFTWARE_VERSION = "software_version" CAP_POSITION = "position" CAP_CARPET_BOOST = "carpet_boost" -CONF_CERT = "certificate" -CONF_CONTINUOUS = "continuous" -CONF_DELAY = "delay" - -DEFAULT_CERT = "/etc/ssl/certs/ca-certificates.crt" -DEFAULT_CONTINUOUS = True -DEFAULT_DELAY = 1 -DEFAULT_NAME = "Roomba" - -PLATFORM = "roomba" - FAN_SPEED_AUTOMATIC = "Automatic" FAN_SPEED_ECO = "Eco" FAN_SPEED_PERFORMANCE = "Performance" FAN_SPEEDS = [FAN_SPEED_AUTOMATIC, FAN_SPEED_ECO, FAN_SPEED_PERFORMANCE] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_CERT, default=DEFAULT_CERT): cv.string, - vol.Optional(CONF_CONTINUOUS, default=DEFAULT_CONTINUOUS): cv.boolean, - vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): cv.positive_int, - }, - extra=vol.ALLOW_EXTRA, -) # Commonly supported features SUPPORT_ROOMBA = ( @@ -83,57 +54,49 @@ SUPPORT_ROOMBA = ( SUPPORT_ROOMBA_CARPET_BOOST = SUPPORT_ROOMBA | SUPPORT_FAN_SPEED -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the iRobot Roomba vacuum cleaner platform.""" - - if PLATFORM not in hass.data: - hass.data[PLATFORM] = {} - - host = config.get(CONF_HOST) - name = config.get(CONF_NAME) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - certificate = config.get(CONF_CERT) - continuous = config.get(CONF_CONTINUOUS) - delay = config.get(CONF_DELAY) - - roomba = Roomba( - address=host, - blid=username, - password=password, - cert_name=certificate, - continuous=continuous, - delay=delay, - ) - _LOGGER.debug("Initializing communication with host %s", host) - - try: - with async_timeout.timeout(9): - await hass.async_add_job(roomba.connect) - except asyncio.TimeoutError: - raise PlatformNotReady - - roomba_vac = RoombaVacuum(name, roomba) - hass.data[PLATFORM][host] = roomba_vac - +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the iRobot Roomba vacuum cleaner.""" + domain_data = hass.data[DOMAIN][config_entry.entry_id] + roomba = domain_data[ROOMBA_SESSION] + blid = domain_data[BLID] + roomba_vac = RoombaVacuum(roomba, blid) async_add_entities([roomba_vac], True) class RoombaVacuum(VacuumDevice): """Representation of a Roomba Vacuum cleaner robot.""" - def __init__(self, name, roomba): + def __init__(self, roomba, blid): """Initialize the Roomba handler.""" self._available = False self._battery_level = None self._capabilities = {} self._fan_speed = None self._is_on = False - self._name = name self._state_attrs = {} self._status = None self.vacuum = roomba - self.vacuum_state = None + self.vacuum_state = roomba_reported_state(roomba) + self._blid = blid + self._name = self.vacuum_state.get("name") + self._version = self.vacuum_state.get("softwareVer") + self._sku = self.vacuum_state.get("sku") + + @property + def unique_id(self): + """Return the uniqueid of the vacuum cleaner.""" + return f"roomba_{self._blid}" + + @property + def device_info(self): + """Return the device info of the vacuum cleaner.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "manufacturer": "iRobot", + "name": str(self._name), + "sw_version": self._version, + "model": self._sku, + } @property def supported_features(self): diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index bbc6c7d2cfb..0e78ddecc4b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -97,6 +97,7 @@ FLOWS = [ "rainmachine", "ring", "roku", + "roomba", "samsungtv", "sense", "sentry", diff --git a/requirements_all.txt b/requirements_all.txt index 608809177e9..b104ae232a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1815,7 +1815,7 @@ rocketchat-API==0.6.1 roku==4.1.0 # homeassistant.components.roomba -roombapy==1.4.3 +roombapy==1.5.0 # homeassistant.components.rova rova==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e7a36f0d77..86e9c9a001b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -684,6 +684,9 @@ ring_doorbell==0.6.0 # homeassistant.components.roku roku==4.1.0 +# homeassistant.components.roomba +roombapy==1.5.0 + # homeassistant.components.yamaha rxv==0.6.0 diff --git a/tests/components/roomba/__init__.py b/tests/components/roomba/__init__.py new file mode 100644 index 00000000000..a255e21c709 --- /dev/null +++ b/tests/components/roomba/__init__.py @@ -0,0 +1 @@ +"""Tests for the iRobot Roomba integration.""" diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py new file mode 100644 index 00000000000..c9a0d8fde17 --- /dev/null +++ b/tests/components/roomba/test_config_flow.py @@ -0,0 +1,159 @@ +"""Test the iRobot Roomba config flow.""" +from asynctest import MagicMock, PropertyMock, patch +from roomba import RoombaConnectionError + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.roomba.const import ( + CONF_BLID, + CONF_CERT, + CONF_CONTINUOUS, + CONF_DELAY, + DOMAIN, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD + +from tests.common import MockConfigEntry + +VALID_CONFIG = {CONF_HOST: "1.2.3.4", CONF_BLID: "blid", CONF_PASSWORD: "password"} + +VALID_YAML_CONFIG = { + CONF_HOST: "1.2.3.4", + CONF_BLID: "blid", + CONF_PASSWORD: "password", + CONF_CERT: "/etc/ssl/certs/ca-certificates.crt", + CONF_CONTINUOUS: True, + CONF_DELAY: 1, +} + + +def _create_mocked_roomba( + roomba_connected=None, master_state=None, connect=None, disconnect=None +): + mocked_roomba = MagicMock() + type(mocked_roomba).roomba_connected = PropertyMock(return_value=roomba_connected) + type(mocked_roomba).master_state = PropertyMock(return_value=master_state) + type(mocked_roomba).connect = MagicMock(side_effect=connect) + type(mocked_roomba).disconnect = MagicMock(side_effect=disconnect) + return mocked_roomba + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + mocked_roomba = _create_mocked_roomba( + roomba_connected=True, + master_state={"state": {"reported": {"name": "myroomba"}}}, + ) + + with patch( + "homeassistant.components.roomba.config_flow.Roomba", + return_value=mocked_roomba, + ), patch( + "homeassistant.components.roomba.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.roomba.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], VALID_CONFIG, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "myroomba" + + assert result2["result"].unique_id == "blid" + assert result2["data"] == { + CONF_BLID: "blid", + CONF_CERT: "/etc/ssl/certs/ca-certificates.crt", + CONF_CONTINUOUS: True, + CONF_DELAY: 1, + CONF_HOST: "1.2.3.4", + CONF_PASSWORD: "password", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mocked_roomba = _create_mocked_roomba( + connect=RoombaConnectionError, + roomba_connected=True, + master_state={"state": {"reported": {"name": "myroomba"}}}, + ) + + with patch( + "homeassistant.components.roomba.config_flow.Roomba", + return_value=mocked_roomba, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], VALID_CONFIG, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_import(hass): + """Test we can import yaml config.""" + + mocked_roomba = _create_mocked_roomba( + roomba_connected=True, + master_state={"state": {"reported": {"name": "imported_roomba"}}}, + ) + + with patch( + "homeassistant.components.roomba.config_flow.Roomba", + return_value=mocked_roomba, + ), patch( + "homeassistant.components.roomba.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.roomba.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=VALID_YAML_CONFIG.copy(), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == "blid" + assert result["title"] == "imported_roomba" + assert result["data"] == { + CONF_BLID: "blid", + CONF_CERT: "/etc/ssl/certs/ca-certificates.crt", + CONF_CONTINUOUS: True, + CONF_DELAY: 1, + CONF_HOST: "1.2.3.4", + CONF_PASSWORD: "password", + } + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_import_dupe(hass): + """Test we get abort on duplicate import.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, unique_id="blid") + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=VALID_YAML_CONFIG.copy(), + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured"