From a006327d65113a64e92ee4fd9e123929b76b3665 Mon Sep 17 00:00:00 2001 From: Michael Graf Date: Sun, 24 Jan 2021 14:22:10 +0100 Subject: [PATCH 1/3] Adds vorwerk component --- .coveragerc | 3 + CODEOWNERS | 1 + homeassistant/components/vorwerk/__init__.py | 112 +++++++ .../components/vorwerk/authsession.py | 18 + .../components/vorwerk/config_flow.py | 126 +++++++ homeassistant/components/vorwerk/const.py | 164 +++++++++ .../components/vorwerk/manifest.json | 16 + .../components/vorwerk/services.yaml | 18 + homeassistant/components/vorwerk/strings.json | 28 ++ homeassistant/components/vorwerk/vacuum.py | 310 ++++++++++++++++++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + tests/components/vorwerk/__init__.py | 1 + tests/components/vorwerk/test_config_flow.py | 234 +++++++++++++ 15 files changed, 1034 insertions(+) create mode 100644 homeassistant/components/vorwerk/__init__.py create mode 100644 homeassistant/components/vorwerk/authsession.py create mode 100644 homeassistant/components/vorwerk/config_flow.py create mode 100644 homeassistant/components/vorwerk/const.py create mode 100644 homeassistant/components/vorwerk/manifest.json create mode 100644 homeassistant/components/vorwerk/services.yaml create mode 100644 homeassistant/components/vorwerk/strings.json create mode 100644 homeassistant/components/vorwerk/vacuum.py create mode 100644 tests/components/vorwerk/__init__.py create mode 100644 tests/components/vorwerk/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 2113ea0c2022e7..3e72a30eb869a1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1250,6 +1250,9 @@ omit = homeassistant/components/volumio/browse_media.py homeassistant/components/volumio/media_player.py homeassistant/components/volvooncall/* + homeassistant/components/vorwerk/__init__.py + homeassistant/components/vorwerk/authsession.py + homeassistant/components/vorwerk/vacuum.py homeassistant/components/w800rf32/* homeassistant/components/waqi/sensor.py homeassistant/components/waterfurnace/* diff --git a/CODEOWNERS b/CODEOWNERS index bbcfadaf3bf9c8..b64272372f3e30 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1013,6 +1013,7 @@ homeassistant/components/volkszaehler/* @fabaff homeassistant/components/volumio/* @OnFreund tests/components/volumio/* @OnFreund homeassistant/components/volvooncall/* @molobrakos @decompil3d +homeassistant/components/vorwerk/* @trunneml homeassistant/components/wake_on_lan/* @ntilley905 tests/components/wake_on_lan/* @ntilley905 homeassistant/components/wallbox/* @hesselonline diff --git a/homeassistant/components/vorwerk/__init__.py b/homeassistant/components/vorwerk/__init__.py new file mode 100644 index 00000000000000..70a3cf16fa95d1 --- /dev/null +++ b/homeassistant/components/vorwerk/__init__.py @@ -0,0 +1,112 @@ +"""Support for botvac connected Vorwerk vacuum cleaners.""" +import asyncio +import logging + +from pybotvac.exceptions import NeatoException +from pybotvac.robot import Robot +from pybotvac.vorwerk import Vorwerk +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.exceptions import ConfigEntryNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .const import ( + VORWERK_DOMAIN, + VORWERK_PLATFORMS, + VORWERK_ROBOT_ENDPOINT, + VORWERK_ROBOT_NAME, + VORWERK_ROBOT_SECRET, + VORWERK_ROBOT_SERIAL, + VORWERK_ROBOT_TRAITS, + VORWERK_ROBOTS, +) + +_LOGGER = logging.getLogger(__name__) + + +VORWERK_SCHEMA = vol.Schema( + vol.All( + { + vol.Required(VORWERK_ROBOT_NAME): cv.string, + vol.Required(VORWERK_ROBOT_SERIAL): cv.string, + vol.Required(VORWERK_ROBOT_SECRET): cv.string, + vol.Optional( + VORWERK_ROBOT_ENDPOINT, default="https://nucleo.ksecosys.com:4443" + ): cv.string, + } + ) +) + +CONFIG_SCHEMA = vol.Schema( + {VORWERK_DOMAIN: vol.Schema(vol.All(cv.ensure_list, [VORWERK_SCHEMA]))}, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up the Vorwerk component.""" + hass.data[VORWERK_DOMAIN] = {} + + if VORWERK_DOMAIN in config: + hass.async_create_task( + hass.config_entries.flow.async_init( + VORWERK_DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[VORWERK_DOMAIN], + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up config entry.""" + + def create_robot(config): + return Robot( + serial=config[VORWERK_ROBOT_SERIAL], + secret=config[VORWERK_ROBOT_SECRET], + traits=config.get(VORWERK_ROBOT_TRAITS, []), + vendor=Vorwerk(), + name=config[VORWERK_ROBOT_NAME], + endpoint=config[VORWERK_ROBOT_ENDPOINT], + ) + + try: + robots = await asyncio.gather( + *( + hass.async_add_executor_job(create_robot, robot_conf) + for robot_conf in entry.data[VORWERK_ROBOTS] + ), + return_exceptions=False, + ) + hass.data[VORWERK_DOMAIN][entry.entry_id] = {VORWERK_ROBOTS: robots} + except NeatoException as ex: + _LOGGER.warning( + "Failed to connect to robot %s: %s", entry.data[VORWERK_ROBOT_NAME], ex + ) + raise ConfigEntryNotReady from ex + + for component in VORWERK_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Unload config entry.""" + unload_ok: bool = all( + await asyncio.gather( + *( + hass.config_entries.async_forward_entry_unload(entry, component) + for component in VORWERK_PLATFORMS + ) + ) + ) + if unload_ok: + hass.data[VORWERK_DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/vorwerk/authsession.py b/homeassistant/components/vorwerk/authsession.py new file mode 100644 index 00000000000000..6a8cd9ac678265 --- /dev/null +++ b/homeassistant/components/vorwerk/authsession.py @@ -0,0 +1,18 @@ +"""Auth sessions for pybotvac.""" +import pybotvac + + +class VorwerkSession(pybotvac.PasswordlessSession): + """PasswordlessSession pybotvac session for Vorwerk cloud.""" + + # The client_id is the same for all users. + CLIENT_ID = "KY4YbVAvtgB7lp8vIbWQ7zLk3hssZlhR" + + def __init__(self): + """Initialize Vorwerk cloud session.""" + super().__init__(client_id=VorwerkSession.CLIENT_ID, vendor=pybotvac.Vorwerk()) + + @property + def token(self): + """Return the token dict. Contains id_token, access_token and refresh_token.""" + return self._token diff --git a/homeassistant/components/vorwerk/config_flow.py b/homeassistant/components/vorwerk/config_flow.py new file mode 100644 index 00000000000000..e158572ca38ddf --- /dev/null +++ b/homeassistant/components/vorwerk/config_flow.py @@ -0,0 +1,126 @@ +"""Config flow to configure Vorwerk integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pybotvac.exceptions import NeatoException +from requests.models import HTTPError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_TOKEN + +from . import authsession +from homeassistant.data_entry_flow import FlowResult + +# pylint: disable=unused-import +from .const import ( + VORWERK_DOMAIN, + VORWERK_ROBOT_ENDPOINT, + VORWERK_ROBOT_NAME, + VORWERK_ROBOT_SECRET, + VORWERK_ROBOT_SERIAL, + VORWERK_ROBOT_TRAITS, + VORWERK_ROBOTS, +) + +DOCS_URL = "https://www.home-assistant.io/integrations/vorwerk" + +_LOGGER = logging.getLogger(__name__) + + +class VorwerkConfigFlow(config_entries.ConfigFlow, domain=VORWERK_DOMAIN): + """Vorwerk integration config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize the config flow.""" + self._email: str | None = None + self._session = authsession.VorwerkSession() + + async def async_step_user(self, user_input=None): + """Step when user initializes a integration.""" + + if user_input is not None: + self._email = user_input.get(CONF_EMAIL) + if self._email: + await self.async_set_unique_id(self._email) + self._abort_if_unique_id_configured() + return await self.async_step_code() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL): str, + } + ), + description_placeholders={"docs_url": DOCS_URL}, + ) + + async def async_step_code(self, user_input: dict[str, Any] = None) -> FlowResult: + """Step when user enters OTP Code from email.""" + assert self._email is not None # typing + errors = {} + code = user_input.get(CONF_CODE) if user_input else None + if code: + try: + robots = await self.hass.async_add_executor_job( + self._get_robots, self._email, code + ) + return self.async_create_entry( + title=self._email, + data={ + CONF_EMAIL: self._email, + CONF_TOKEN: self._session.token, + VORWERK_ROBOTS: robots, + }, + ) + except (HTTPError, NeatoException): + errors["base"] = "invalid_auth" + + await self.hass.async_add_executor_job( + self._session.send_email_otp, self._email + ) + + return self.async_show_form( + step_id="code", + data_schema=vol.Schema( + { + vol.Required(CONF_CODE): str, + } + ), + description_placeholders={"docs_url": DOCS_URL}, + errors=errors, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Import a config flow from configuration.""" + unique_id = "from configuration" + data = {VORWERK_ROBOTS: user_input} + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured(data) + + _LOGGER.info("Creating new Vorwerk robot config entry") + return self.async_create_entry( + title="from configuration", + data=data, + ) + + def _get_robots(self, email: str, code: str): + """Fetch the robot list from vorwerk.""" + self._session.fetch_token_passwordless(email, code) + return [ + { + VORWERK_ROBOT_NAME: robot["name"], + VORWERK_ROBOT_SERIAL: robot["serial"], + VORWERK_ROBOT_SECRET: robot["secret_key"], + VORWERK_ROBOT_TRAITS: robot["traits"], + VORWERK_ROBOT_ENDPOINT: robot["nucleo_url"], + } + for robot in self._session.get("users/me/robots").json() + ] diff --git a/homeassistant/components/vorwerk/const.py b/homeassistant/components/vorwerk/const.py new file mode 100644 index 00000000000000..62592d110819d9 --- /dev/null +++ b/homeassistant/components/vorwerk/const.py @@ -0,0 +1,164 @@ +"""Constants for Vorwerk integration.""" + +VORWERK_DOMAIN = "vorwerk" + +VORWERK_ROBOTS = "robots" + +VORWERK_ROBOT_NAME = "name" +VORWERK_ROBOT_SERIAL = "serial" +VORWERK_ROBOT_SECRET = "secret" +VORWERK_ROBOT_TRAITS = "traits" +VORWERK_ROBOT_ENDPOINT = "endpoint" + +VORWERK_PLATFORMS = ["vacuum"] + +SCAN_INTERVAL_MINUTES = 1 + +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 MyKobold 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 MyKobold app", + "ui_error_lds_disconnected": "Check MyKobold app", + "ui_error_lds_missed_packets": "Check MyKobold 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 MyKobold 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", +} + +ATTR_NAVIGATION = "navigation" +ATTR_CATEGORY = "category" +ATTR_ZONE = "zone" + + +ROBOT_STATE_INVALID = 0 +ROBOT_STATE_IDLE = 1 +ROBOT_STATE_BUSY = 2 +ROBOT_STATE_PAUSE = 3 +ROBOT_STATE_ERROR = 4 diff --git a/homeassistant/components/vorwerk/manifest.json b/homeassistant/components/vorwerk/manifest.json new file mode 100644 index 00000000000000..7b33beb2d59408 --- /dev/null +++ b/homeassistant/components/vorwerk/manifest.json @@ -0,0 +1,16 @@ +{ + "domain": "vorwerk", + "name": "Vorwerk Kobold", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/vorwerk", + "requirements": [ + "pybotvac==0.0.20" + ], + "codeowners": [ + "@trunneml" + ], + "dependencies": [ + "http" + ], + "iot_class": "cloud_polling" +} \ No newline at end of file diff --git a/homeassistant/components/vorwerk/services.yaml b/homeassistant/components/vorwerk/services.yaml new file mode 100644 index 00000000000000..42d566026298a9 --- /dev/null +++ b/homeassistant/components/vorwerk/services.yaml @@ -0,0 +1,18 @@ +custom_cleaning: + description: Zone Cleaning service call specific to Vorwerk Kobolds. + fields: + entity_id: + description: Name of the vacuum entity. [Required] + example: "vacuum.mein_vr" + mode: + description: "Set the cleaning mode: 1 for eco and 2 for turbo. Defaults to turbo if not set." + example: 2 + navigation: + description: "Set the navigation mode: 1 for normal, 2 for extra care, 3 for deep. Defaults to normal if not set." + example: 1 + category: + description: "Whether to use a persistent map or not for cleaning (i.e. No go lines): 2 for no map, 4 for map. Default to using map if not set (and fallback to no map if no map is found)." + example: 2 + zone: + description: Only supported on the VR300. Name of the zone to clean. Defaults to no zone i.e. complete house cleanup. + example: "Kitchen" diff --git a/homeassistant/components/vorwerk/strings.json b/homeassistant/components/vorwerk/strings.json new file mode 100644 index 00000000000000..441391aad27eb6 --- /dev/null +++ b/homeassistant/components/vorwerk/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "title": "Vorwerk Account Info", + "data": { + "email": "[%key:common::config_flow::data::email%]" + }, + "description": "To recieve an authentication code, enter the email address of your vorwerk account.\n\nSee [Vorwerk documentation]({docs_url})." + }, + "code": { + "title": "Vorwerk Account Info", + "data": { + "code": "Code" + }, + "description": "Enter the code you received by email.\n\nSee [Vorwerk documentation]({docs_url})." + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + } + }, + "title": "Vorwerk Botvac" +} \ No newline at end of file diff --git a/homeassistant/components/vorwerk/vacuum.py b/homeassistant/components/vorwerk/vacuum.py new file mode 100644 index 00000000000000..13a927dcd41a5b --- /dev/null +++ b/homeassistant/components/vorwerk/vacuum.py @@ -0,0 +1,310 @@ +"""Support for Neato Connected Vacuums.""" +from datetime import timedelta +import logging + +from pybotvac.exceptions import NeatoRobotException +import voluptuous as vol + +from homeassistant.components.vacuum import ( + ATTR_STATUS, + STATE_CLEANING, + STATE_DOCKED, + STATE_ERROR, + STATE_IDLE, + STATE_PAUSED, + STATE_RETURNING, + SUPPORT_BATTERY, + SUPPORT_CLEAN_SPOT, + SUPPORT_LOCATE, + SUPPORT_PAUSE, + SUPPORT_RETURN_HOME, + SUPPORT_START, + SUPPORT_STATE, + SUPPORT_STOP, + StateVacuumEntity, +) +from homeassistant.const import ATTR_MODE +from homeassistant.helpers import config_validation as cv, entity_platform + +from .const import ( + ACTION, + ALERTS, + ATTR_CATEGORY, + ATTR_NAVIGATION, + ATTR_ZONE, + ERRORS, + MODE, + ROBOT_STATE_BUSY, + ROBOT_STATE_ERROR, + ROBOT_STATE_IDLE, + ROBOT_STATE_PAUSE, + SCAN_INTERVAL_MINUTES, + VORWERK_DOMAIN, + VORWERK_ROBOTS, +) + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=SCAN_INTERVAL_MINUTES) + +SUPPORT_VORWERK = ( + SUPPORT_BATTERY + | SUPPORT_PAUSE + | SUPPORT_RETURN_HOME + | SUPPORT_STOP + | SUPPORT_START + | SUPPORT_CLEAN_SPOT + | SUPPORT_STATE + | SUPPORT_LOCATE +) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Vorwerk vacuum with config entry.""" + + _LOGGER.debug("Adding vorwerk vacuums") + async_add_entities( + [ + VorwerkConnectedVacuum(robot) + for robot in hass.data[VORWERK_DOMAIN][entry.entry_id][VORWERK_ROBOTS] + ], + True, + ) + + platform = entity_platform.current_platform.get() + assert platform is not None + + platform.async_register_entity_service( + "custom_cleaning", + { + vol.Optional(ATTR_MODE, default=2): cv.positive_int, + vol.Optional(ATTR_NAVIGATION, default=1): cv.positive_int, + vol.Optional(ATTR_CATEGORY, default=4): cv.positive_int, + vol.Optional(ATTR_ZONE): cv.string, + }, + "vorwerk_custom_cleaning", + ) + + +class VorwerkConnectedVacuum(StateVacuumEntity): + """Representation of a Vorwerk Connected Vacuum.""" + + def __init__(self, robot): + """Initialize the Vorwerk Connected Vacuum.""" + self.robot = robot + self._available = False + self._name = f"{self.robot.name}" + self._robot_has_map = False + self._robot_serial = self.robot.serial + self._status_state = None + self._clean_state = None + self._state = None + self._battery_level = None + self._robot_boundaries = [] + self._robot_stats = None + + def update(self): + """Update the states of Vorwerk Vacuums.""" + _LOGGER.debug("Running Vorwerk Vacuums update for '%s'", self.entity_id) + try: + if self._robot_stats is None: + self._robot_stats = self.robot.get_general_info().json().get("data") + except NeatoRobotException: + _LOGGER.warning("Couldn't fetch robot information of %s", self.entity_id) + + try: + self._state = self.robot.state + except NeatoRobotException as ex: + if self._available: # print only once when available + _LOGGER.error( + "Vorwerk vacuum connection error for '%s': %s", self.entity_id, ex + ) + self._state = None + self._available = False + return + + self._available = True + _LOGGER.debug("self._state=%s", self._state) + if "alert" in self._state: + robot_alert = ALERTS.get(self._state["alert"]) + else: + robot_alert = None + if self._state["state"] == ROBOT_STATE_IDLE: + if self._state["details"]["isCharging"]: + self._clean_state = STATE_DOCKED + self._status_state = "Charging" + elif ( + self._state["details"]["isDocked"] + and not self._state["details"]["isCharging"] + ): + self._clean_state = STATE_DOCKED + self._status_state = "Docked" + else: + self._clean_state = STATE_IDLE + self._status_state = "Stopped" + + if robot_alert is not None: + self._status_state = robot_alert + elif self._state["state"] == ROBOT_STATE_BUSY: + if robot_alert is None: + self._clean_state = STATE_CLEANING + self._status_state = ( + f"{MODE.get(self._state['cleaning']['mode'])} " + f"{ACTION.get(self._state['action'])}" + ) + if ( + "boundary" in self._state["cleaning"] + and "name" in self._state["cleaning"]["boundary"] + ): + self._status_state += ( + f" {self._state['cleaning']['boundary']['name']}" + ) + else: + self._status_state = robot_alert + elif self._state["state"] == ROBOT_STATE_PAUSE: + self._clean_state = STATE_PAUSED + self._status_state = "Paused" + elif self._state["state"] == ROBOT_STATE_ERROR: + self._clean_state = STATE_ERROR + self._status_state = ERRORS.get(self._state["error"]) + + self._battery_level = self._state["details"]["charge"] + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def supported_features(self): + """Flag vacuum cleaner robot features that are supported.""" + return SUPPORT_VORWERK + + @property + def battery_level(self): + """Return the battery level of the vacuum cleaner.""" + return self._battery_level + + @property + def available(self): + """Return if the robot is available.""" + return self._available + + @property + def icon(self): + """Return specific icon.""" + return "mdi:robot-vacuum-variant" + + @property + def state(self): + """Return the status of the vacuum cleaner.""" + return self._clean_state + + @property + def unique_id(self): + """Return a unique ID.""" + return self._robot_serial + + @property + def device_state_attributes(self): + """Return the state attributes of the vacuum cleaner.""" + data = {} + + if self._status_state: + data[ATTR_STATUS] = self._status_state + + return data + + @property + def device_info(self): + """Device info for robot.""" + info = { + "identifiers": {(VORWERK_DOMAIN, self._robot_serial)}, + "name": self._name, + } + if self._robot_stats: + info["manufacturer"] = self._robot_stats["battery"]["vendor"] + info["model"] = self._robot_stats["model"] + info["sw_version"] = self._robot_stats["firmware"] + return info + + def start(self): + """Start cleaning or resume cleaning.""" + try: + if self._state["state"] == ROBOT_STATE_IDLE: + self.robot.start_cleaning() + elif self._state["state"] == ROBOT_STATE_PAUSE: + self.robot.resume_cleaning() + except NeatoRobotException as ex: + _LOGGER.error( + "Vorwerk vacuum connection error for '%s': %s", self.entity_id, ex + ) + + def pause(self): + """Pause the vacuum.""" + try: + self.robot.pause_cleaning() + except NeatoRobotException as ex: + _LOGGER.error( + "Vorwerk vacuum connection error for '%s': %s", self.entity_id, ex + ) + + def return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock.""" + try: + if self._clean_state == STATE_CLEANING: + self.robot.pause_cleaning() + self._clean_state = STATE_RETURNING + self.robot.send_to_base() + except NeatoRobotException as ex: + _LOGGER.error( + "Vorwerk vacuum connection error for '%s': %s", self.entity_id, ex + ) + + def stop(self, **kwargs): + """Stop the vacuum cleaner.""" + try: + self.robot.stop_cleaning() + except NeatoRobotException as ex: + _LOGGER.error( + "Vorwerk vacuum connection error for '%s': %s", self.entity_id, ex + ) + + def locate(self, **kwargs): + """Locate the robot by making it emit a sound.""" + try: + self.robot.locate() + except NeatoRobotException as ex: + _LOGGER.error( + "Vorwerk vacuum connection error for '%s': %s", self.entity_id, ex + ) + + def clean_spot(self, **kwargs): + """Run a spot cleaning starting from the base.""" + try: + self.robot.start_spot_cleaning() + except NeatoRobotException as ex: + _LOGGER.error( + "Vorwerk vacuum connection error for '%s': %s", self.entity_id, ex + ) + + def vorwerk_custom_cleaning(self, mode, navigation, category, zone=None): + """Zone cleaning service call.""" + boundary_id = None + if zone is not None: + for boundary in self._robot_boundaries: + if zone in boundary["name"]: + boundary_id = boundary["id"] + if boundary_id is None: + _LOGGER.error( + "Zone '%s' was not found for the robot '%s'", zone, self.entity_id + ) + return + + self._clean_state = STATE_CLEANING + try: + self.robot.start_cleaning(mode, navigation, category, boundary_id) + except NeatoRobotException as ex: + _LOGGER.error( + "Vorwerk vacuum connection error for '%s': %s", self.entity_id, ex + ) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b2888d7d8b46d0..571dc704eb2ded 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -339,6 +339,7 @@ "vizio", "vlc_telnet", "volumio", + "vorwerk", "wallbox", "watttime", "waze_travel_time", diff --git a/requirements_all.txt b/requirements_all.txt index df0ae387418fb9..435fde23ffcaed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1398,6 +1398,7 @@ pyblackbird==0.5 # pybluez==0.22 # homeassistant.components.neato +# homeassistant.components.vorwerk pybotvac==0.0.22 # homeassistant.components.nissan_leaf diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b394918f4fca6c..ca8579980edb96 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -854,6 +854,7 @@ pybalboa==0.13 pyblackbird==0.5 # homeassistant.components.neato +# homeassistant.components.vorwerk pybotvac==0.0.22 # homeassistant.components.cloudflare diff --git a/tests/components/vorwerk/__init__.py b/tests/components/vorwerk/__init__.py new file mode 100644 index 00000000000000..900b3408877290 --- /dev/null +++ b/tests/components/vorwerk/__init__.py @@ -0,0 +1 @@ +"""Tests vorwerk component.""" diff --git a/tests/components/vorwerk/test_config_flow.py b/tests/components/vorwerk/test_config_flow.py new file mode 100644 index 00000000000000..d07ab22559fa38 --- /dev/null +++ b/tests/components/vorwerk/test_config_flow.py @@ -0,0 +1,234 @@ +"""Test vorwerk config flow.""" +from unittest.mock import MagicMock, patch + +from requests.models import HTTPError + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.vorwerk.const import ( + VORWERK_DOMAIN, + VORWERK_ROBOT_ENDPOINT, + VORWERK_ROBOT_NAME, + VORWERK_ROBOT_SECRET, + VORWERK_ROBOT_SERIAL, + VORWERK_ROBOT_TRAITS, + VORWERK_ROBOTS, +) +from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_TOKEN +from homeassistant.helpers.typing import HomeAssistantType + +from tests.common import MockConfigEntry + + +def _create_mocked_vorwerk_session(): + mocked_vorwerk_session = MagicMock() + return mocked_vorwerk_session + + +def _patch_config_flow_vorwerksession(mocked_vorwerk_session): + return patch( + "homeassistant.components.vorwerk.authsession.VorwerkSession", + return_value=mocked_vorwerk_session, + ) + + +def _patch_setup(): + return patch( + "homeassistant.components.vorwerk.async_setup_entry", + return_value=True, + ) + + +def _flow_next(hass, flow_id): + return next( + flow + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == flow_id + ) + + +async def test_import_abort_if_already_setup(hass: HomeAssistantType): + """Test we abort if Vorwerk configuration is already setup.""" + entry = MockConfigEntry( + domain=VORWERK_DOMAIN, + unique_id="from configuration", + data={VORWERK_ROBOTS: {}}, + ) + entry.add_to_hass(hass) + + # Should fail + data = [ + { + VORWERK_ROBOT_NAME: "Mein VR", + VORWERK_ROBOT_SERIAL: "S3R14L", + VORWERK_ROBOT_SECRET: "S3CR3+", + VORWERK_ROBOT_ENDPOINT: "http://nucleo_url", + } + ] + result = await hass.config_entries.flow.async_init( + VORWERK_DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=data + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + # Check that the robots are updated in the config entry + assert dict(entry.data) == {VORWERK_ROBOTS: data} + + +async def test_import_success(hass: HomeAssistantType): + """Test Vorwerk setup from configuration.yaml.""" + data = [ + { + VORWERK_ROBOT_NAME: "Mein VR", + VORWERK_ROBOT_SERIAL: "S3R14L", + VORWERK_ROBOT_SECRET: "S3CR3+", + VORWERK_ROBOT_ENDPOINT: "http://nucleo_url", + } + ] + # Should success + result = await hass.config_entries.flow.async_init( + VORWERK_DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=data + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "from configuration" + assert result["result"].unique_id == "from configuration" + assert result["result"].data == {VORWERK_ROBOTS: data} + assert result["data"] == {VORWERK_ROBOTS: data} + + +async def test_user_full(hass: HomeAssistantType): + """Test user initialized flow.""" + mock_session = _create_mocked_vorwerk_session() + mock_session.token = { + "id_token": "id_token", + "refresh_token": "refresh_token", + } + mock_session.get.return_value.json.return_value = [ + { + "name": "Mein VR", + "serial": "S3R14L", + "secret_key": "S3CR3+", + "traits": ["trait1", "trait2"], + "nucleo_url": "https://example.com", + } + ] + email = "testuser@example.com" + result_data = { + CONF_TOKEN: mock_session.token, + CONF_EMAIL: email, + VORWERK_ROBOTS: [ + { + VORWERK_ROBOT_NAME: "Mein VR", + VORWERK_ROBOT_SERIAL: "S3R14L", + VORWERK_ROBOT_SECRET: "S3CR3+", + VORWERK_ROBOT_TRAITS: ["trait1", "trait2"], + VORWERK_ROBOT_ENDPOINT: "https://example.com", + } + ], + } + with _patch_config_flow_vorwerksession(mock_session), _patch_setup(): + result = await hass.config_entries.flow.async_init( + VORWERK_DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert not result["errors"] + _flow_next(hass, result["flow_id"]) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: email}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "code" + mock_session.send_email_otp.assert_called_once_with(email) + _flow_next(hass, result["flow_id"]) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: "123456"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == email + assert result["result"].unique_id == email + assert dict(result["result"].data) == result_data + mock_session.fetch_token_passwordless.assert_called_once_with(email, "123456") + mock_session.get.assert_called_once_with("users/me/robots") + + +async def test_user_code_invalid_2nd_try(hass: HomeAssistantType): + """Test user initialized flow.""" + mock_session = _create_mocked_vorwerk_session() + mock_session.token = { + "id_token": "id_token", + "refresh_token": "refresh_token", + } + mock_session.fetch_token_passwordless.side_effect = [HTTPError(), None] + mock_session.get.return_value.json.return_value = [ + { + "name": "Mein VR", + "serial": "S3R14L", + "secret_key": "S3CR3+", + "traits": ["trait1", "trait2"], + "nucleo_url": "https://example.com", + } + ] + email = "testuser@example.com" + result_data = { + CONF_TOKEN: mock_session.token, + CONF_EMAIL: email, + VORWERK_ROBOTS: [ + { + VORWERK_ROBOT_NAME: "Mein VR", + VORWERK_ROBOT_SERIAL: "S3R14L", + VORWERK_ROBOT_SECRET: "S3CR3+", + VORWERK_ROBOT_TRAITS: ["trait1", "trait2"], + VORWERK_ROBOT_ENDPOINT: "https://example.com", + } + ], + } + with _patch_config_flow_vorwerksession(mock_session), _patch_setup(): + + result = await hass.config_entries.flow.async_init( + VORWERK_DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert not result["errors"] + _flow_next(hass, result["flow_id"]) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: email}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "code" + assert not result["errors"] + mock_session.send_email_otp.assert_called_once_with(email) + mock_session.reset_mock() + _flow_next(hass, result["flow_id"]) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: "123456"}, + ) + mock_session.fetch_token_passwordless.assert_called_once_with(email, "123456") + mock_session.get.assert_not_called() + mock_session.send_email_otp.assert_called_once_with(email) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "code" + assert result["errors"] == {"base": "invalid_auth"} + _flow_next(hass, result["flow_id"]) + + mock_session.reset_mock() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: "123456"}, + ) + mock_session.send_email_otp.assert_not_called() + mock_session.fetch_token_passwordless.assert_called_once_with(email, "123456") + mock_session.get.assert_called_once_with("users/me/robots") + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == email + assert result["result"].unique_id == email + assert dict(result["result"].data) == result_data From e77213e919f3736702411911cbf18bd03a675cee Mon Sep 17 00:00:00 2001 From: Michael Graf Date: Tue, 27 Apr 2021 09:40:20 +0000 Subject: [PATCH 2/3] Use DataUpdateCoordinator --- .coveragerc | 1 - homeassistant/components/vorwerk/__init__.py | 243 +++++++++++++++++- .../components/vorwerk/authsession.py | 18 -- .../components/vorwerk/config_flow.py | 22 +- homeassistant/components/vorwerk/const.py | 90 ++++--- homeassistant/components/vorwerk/strings.json | 4 +- homeassistant/components/vorwerk/vacuum.py | 146 +++-------- tests/components/vorwerk/test_config_flow.py | 2 +- 8 files changed, 344 insertions(+), 182 deletions(-) delete mode 100644 homeassistant/components/vorwerk/authsession.py diff --git a/.coveragerc b/.coveragerc index 3e72a30eb869a1..22d2fb4eed369c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1251,7 +1251,6 @@ omit = homeassistant/components/volumio/media_player.py homeassistant/components/volvooncall/* homeassistant/components/vorwerk/__init__.py - homeassistant/components/vorwerk/authsession.py homeassistant/components/vorwerk/vacuum.py homeassistant/components/w800rf32/* homeassistant/components/waqi/sensor.py diff --git a/homeassistant/components/vorwerk/__init__.py b/homeassistant/components/vorwerk/__init__.py index 70a3cf16fa95d1..0c4deb92adfc8d 100644 --- a/homeassistant/components/vorwerk/__init__.py +++ b/homeassistant/components/vorwerk/__init__.py @@ -1,20 +1,44 @@ """Support for botvac connected Vorwerk vacuum cleaners.""" +from __future__ import annotations + import asyncio import logging +from typing import Any -from pybotvac.exceptions import NeatoException +from pybotvac.exceptions import NeatoException, NeatoRobotException from pybotvac.robot import Robot from pybotvac.vorwerk import Vorwerk import voluptuous as vol +from homeassistant.components.vacuum import ( + STATE_CLEANING, + STATE_DOCKED, + STATE_ERROR, + STATE_IDLE, + STATE_PAUSED, + STATE_RETURNING, +) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( + ACTION, + ALERTS, + ERRORS, + MIN_TIME_BETWEEN_UPDATES, + MODE, + ROBOT_CLEANING_ACTIONS, + ROBOT_STATE_BUSY, + ROBOT_STATE_ERROR, + ROBOT_STATE_IDLE, + ROBOT_STATE_PAUSE, VORWERK_DOMAIN, VORWERK_PLATFORMS, + VORWERK_ROBOT_API, + VORWERK_ROBOT_COORDINATOR, VORWERK_ROBOT_ENDPOINT, VORWERK_ROBOT_NAME, VORWERK_ROBOT_SECRET, @@ -63,7 +87,45 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: """Set up config entry.""" + robots = await _async_create_robots(hass, entry.data[VORWERK_ROBOTS]) + + robot_states = [VorwerkState(robot) for robot in robots] + + hass.data[VORWERK_DOMAIN][entry.entry_id] = { + VORWERK_ROBOTS: [ + { + VORWERK_ROBOT_API: r, + VORWERK_ROBOT_COORDINATOR: _create_coordinator(hass, r), + } + for r in robot_states + ] + } + + for component in VORWERK_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + +def _create_coordinator( + hass: HomeAssistantType, robot_state: VorwerkState +) -> DataUpdateCoordinator: + async def async_update_data(): + """Fetch data from API endpoint.""" + await hass.async_add_executor_job(robot_state.update) + + return DataUpdateCoordinator( + hass, + _LOGGER, + name=robot_state.robot.name, + update_method=async_update_data, + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + + +async def _async_create_robots(hass, robot_confs): def create_robot(config): return Robot( serial=config[VORWERK_ROBOT_SERIAL], @@ -74,27 +136,19 @@ def create_robot(config): endpoint=config[VORWERK_ROBOT_ENDPOINT], ) + robots = [] try: robots = await asyncio.gather( *( hass.async_add_executor_job(create_robot, robot_conf) - for robot_conf in entry.data[VORWERK_ROBOTS] + for robot_conf in robot_confs ), return_exceptions=False, ) - hass.data[VORWERK_DOMAIN][entry.entry_id] = {VORWERK_ROBOTS: robots} except NeatoException as ex: - _LOGGER.warning( - "Failed to connect to robot %s: %s", entry.data[VORWERK_ROBOT_NAME], ex - ) + _LOGGER.error("Failed to connect to robots: %s", ex) raise ConfigEntryNotReady from ex - - for component in VORWERK_PLATFORMS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, component) - ) - - return True + return robots async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: @@ -110,3 +164,166 @@ async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> boo if unload_ok: hass.data[VORWERK_DOMAIN].pop(entry.entry_id) return unload_ok + + +class VorwerkState: + """Class to convert robot_state dict to more useful object.""" + + def __init__(self, robot: Robot) -> None: + """Initialize new vorwerk vacuum state.""" + self.robot = robot + self.robot_state: dict[Any, Any] = {} + self.robot_info: dict[Any, Any] = {} + + @property + def available(self) -> bool: + """Return true when robot state is available.""" + return bool(self.robot_state) + + def update(self): + """Update robot state and robot info.""" + _LOGGER.debug("Running Vorwerk Vacuums update for '%s'", self.robot.name) + self._update_robot_info() + self._update_state() + + def _update_robot_info(self): + try: + if not self.robot_info: + self.robot_info = self.robot.get_general_info().json().get("data") + except NeatoRobotException: + _LOGGER.warning("Couldn't fetch robot information of %s", self.robot.name) + + def _update_state(self): + try: + self.robot_state = self.robot.state + _LOGGER.debug(self.robot_state) + except NeatoRobotException as ex: + if self.available: # print only once when available + _LOGGER.error( + "Vorwerk vacuum connection error for '%s': %s", self.robot.name, ex + ) + self.robot_state = {} + return + + @property + def docked(self) -> bool | None: + """Vacuum is docked.""" + if not self.available: + return None + return ( + self.robot_state["state"] == ROBOT_STATE_IDLE + and self.robot_state["details"]["isDocked"] + ) + + @property + def charging(self) -> bool | None: + """Vacuum is charging.""" + if not self.available: + return None + return ( + self.robot_state.get("state") == ROBOT_STATE_IDLE + and self.robot_state["details"]["isCharging"] + ) + + @property + def state(self) -> str | None: + """Return Home Assistant vacuum state.""" + if not self.available: + return None + robot_state = self.robot_state.get("state") + state = None + if self.charging or self.docked: + state = STATE_DOCKED + elif robot_state == ROBOT_STATE_IDLE: + state = STATE_IDLE + elif robot_state == ROBOT_STATE_BUSY: + action = self.robot_state.get("action") + if action in ROBOT_CLEANING_ACTIONS: + state = STATE_CLEANING + else: + state = STATE_RETURNING + elif robot_state == ROBOT_STATE_PAUSE: + state = STATE_PAUSED + elif robot_state == ROBOT_STATE_ERROR: + state = STATE_ERROR + return state + + @property + def alert(self) -> str | None: + """Return vacuum alert message.""" + if not self.available: + return None + if "alert" in self.robot_state: + return ALERTS.get(self.robot_state["alert"], self.robot_state["alert"]) + return None + + @property + def status(self) -> str | None: + """Return vacuum status message.""" + if not self.available: + return None + + status = None + if self.state == STATE_ERROR: + status = self._error_status() + elif self.alert: + status = self.alert + elif self.state == STATE_DOCKED: + if self.charging: + status = "Charging" + if self.docked: + status = "Docked" + elif self.state == STATE_IDLE: + status = "Stopped" + elif self.state == STATE_CLEANING: + status = self._cleaning_status() + elif self.state == STATE_PAUSED: + status = "Paused" + elif self.state == STATE_RETURNING: + status = "Returning" + + return status + + def _error_status(self): + """Return error status.""" + return ERRORS.get(self.robot_state["error"], self.robot_state["error"]) + + def _cleaning_status(self): + """Return cleaning status.""" + status_items = [ + MODE.get(self.robot_state["cleaning"]["mode"]), + ACTION.get(self.robot_state["action"]), + ] + if ( + "boundary" in self.robot_state["cleaning"] + and "name" in self.robot_state["cleaning"]["boundary"] + ): + status_items.append(self.robot_state["cleaning"]["boundary"]["name"]) + return " ".join(s for s in status_items if s) + + @property + def battery_level(self) -> str | None: + """Return the battery level of the vacuum cleaner.""" + if not self.available: + return None + return self.robot_state["details"]["charge"] + + @property + def device_info(self) -> dict[str, str]: + """Device info for robot.""" + info = { + "identifiers": {(VORWERK_DOMAIN, self.robot.serial)}, + "name": self.robot.name, + } + if self.robot_info: + info["manufacturer"] = self.robot_info["battery"]["vendor"] + info["model"] = self.robot_info["model"] + info["sw_version"] = self.robot_info["firmware"] + return info + + @property + def schedule_enabled(self): + """Return True when schedule is enabled.""" + if not self.available: + return None + return bool(self.robot_state["details"]["isScheduleEnabled"]) diff --git a/homeassistant/components/vorwerk/authsession.py b/homeassistant/components/vorwerk/authsession.py deleted file mode 100644 index 6a8cd9ac678265..00000000000000 --- a/homeassistant/components/vorwerk/authsession.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Auth sessions for pybotvac.""" -import pybotvac - - -class VorwerkSession(pybotvac.PasswordlessSession): - """PasswordlessSession pybotvac session for Vorwerk cloud.""" - - # The client_id is the same for all users. - CLIENT_ID = "KY4YbVAvtgB7lp8vIbWQ7zLk3hssZlhR" - - def __init__(self): - """Initialize Vorwerk cloud session.""" - super().__init__(client_id=VorwerkSession.CLIENT_ID, vendor=pybotvac.Vorwerk()) - - @property - def token(self): - """Return the token dict. Contains id_token, access_token and refresh_token.""" - return self._token diff --git a/homeassistant/components/vorwerk/config_flow.py b/homeassistant/components/vorwerk/config_flow.py index e158572ca38ddf..389e0f2ef483ae 100644 --- a/homeassistant/components/vorwerk/config_flow.py +++ b/homeassistant/components/vorwerk/config_flow.py @@ -4,6 +4,7 @@ import logging from typing import Any +import pybotvac from pybotvac.exceptions import NeatoException from requests.models import HTTPError import voluptuous as vol @@ -11,11 +12,11 @@ from homeassistant import config_entries from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_TOKEN -from . import authsession from homeassistant.data_entry_flow import FlowResult # pylint: disable=unused-import from .const import ( + VORWERK_CLIENT_ID, VORWERK_DOMAIN, VORWERK_ROBOT_ENDPOINT, VORWERK_ROBOT_NAME, @@ -25,8 +26,6 @@ VORWERK_ROBOTS, ) -DOCS_URL = "https://www.home-assistant.io/integrations/vorwerk" - _LOGGER = logging.getLogger(__name__) @@ -39,7 +38,7 @@ class VorwerkConfigFlow(config_entries.ConfigFlow, domain=VORWERK_DOMAIN): def __init__(self): """Initialize the config flow.""" self._email: str | None = None - self._session = authsession.VorwerkSession() + self._session = VorwerkSession() async def async_step_user(self, user_input=None): """Step when user initializes a integration.""" @@ -58,7 +57,6 @@ async def async_step_user(self, user_input=None): vol.Required(CONF_EMAIL): str, } ), - description_placeholders={"docs_url": DOCS_URL}, ) async def async_step_code(self, user_input: dict[str, Any] = None) -> FlowResult: @@ -93,7 +91,6 @@ async def async_step_code(self, user_input: dict[str, Any] = None) -> FlowResult vol.Required(CONF_CODE): str, } ), - description_placeholders={"docs_url": DOCS_URL}, errors=errors, ) @@ -124,3 +121,16 @@ def _get_robots(self, email: str, code: str): } for robot in self._session.get("users/me/robots").json() ] + + +class VorwerkSession(pybotvac.PasswordlessSession): + """PasswordlessSession pybotvac session for Vorwerk cloud.""" + + def __init__(self): + """Initialize Vorwerk cloud session.""" + super().__init__(client_id=VORWERK_CLIENT_ID, vendor=pybotvac.Vorwerk()) + + @property + def token(self): + """Return the token dict. Contains id_token, access_token and refresh_token.""" + return self._token diff --git a/homeassistant/components/vorwerk/const.py b/homeassistant/components/vorwerk/const.py index 62592d110819d9..a139b697cad42a 100644 --- a/homeassistant/components/vorwerk/const.py +++ b/homeassistant/components/vorwerk/const.py @@ -1,8 +1,11 @@ """Constants for Vorwerk integration.""" +from datetime import timedelta VORWERK_DOMAIN = "vorwerk" VORWERK_ROBOTS = "robots" +VORWERK_ROBOT_API = "robot_api" +VORWERK_ROBOT_COORDINATOR = "robot_coordinator" VORWERK_ROBOT_NAME = "name" VORWERK_ROBOT_SERIAL = "serial" @@ -12,29 +15,69 @@ VORWERK_PLATFORMS = ["vacuum"] -SCAN_INTERVAL_MINUTES = 1 +# The client_id is the same for all users. +VORWERK_CLIENT_ID = "KY4YbVAvtgB7lp8vIbWQ7zLk3hssZlhR" -MODE = {1: "Eco", 2: "Turbo"} +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) + +ATTR_NAVIGATION = "navigation" +ATTR_CATEGORY = "category" +ATTR_ZONE = "zone" + +ROBOT_STATE_INVALID = 0 +ROBOT_STATE_IDLE = 1 +ROBOT_STATE_BUSY = 2 +ROBOT_STATE_PAUSE = 3 +ROBOT_STATE_ERROR = 4 + +ROBOT_ACTION_INVALID = 0 +ROBOT_ACTION_HOUSE_CLEANING = 1 +ROBOT_ACTION_SPOT_CLEANING = 2 +ROBOT_ACTION_MANUAL_CLEANING = 3 +ROBOT_ACTION_DOCKING = 4 +ROBOT_ACTION_USER_MENU = 5 +ROBOT_ACTION_SUSPENDED_CLEANING = 6 +ROBOT_ACTION_UPDATING = 7 +ROBOT_ACTION_COPY_LOGS = 8 +ROBOT_ACTION_RECOVERING_LOCATION = 9 +ROBOT_ACTION_IEC_TEST = 10 +ROBOT_ACTION_MAP_CLEANING = 11 +ROBOT_ACTION_EXPLORING_MAP = 12 +ROBOT_ACTION_ACQUIRING_MAP_IDS = 13 +ROBOT_ACTION_UPLOADING_MAP = 14 +ROBOT_ACTION_SUSPENDED_EXPLORATION = 15 + +ROBOT_CLEANING_ACTIONS = [ + ROBOT_ACTION_HOUSE_CLEANING, + ROBOT_ACTION_SPOT_CLEANING, + ROBOT_ACTION_MANUAL_CLEANING, + ROBOT_ACTION_SUSPENDED_CLEANING, + ROBOT_ACTION_MAP_CLEANING, + ROBOT_ACTION_EXPLORING_MAP, + ROBOT_ACTION_SUSPENDED_EXPLORATION, +] 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", + ROBOT_ACTION_INVALID: "Invalid", + ROBOT_ACTION_HOUSE_CLEANING: "House Cleaning", + ROBOT_ACTION_SPOT_CLEANING: "Spot Cleaning", + ROBOT_ACTION_MANUAL_CLEANING: "Manual Cleaning", + ROBOT_ACTION_DOCKING: "Docking", + ROBOT_ACTION_USER_MENU: "User Menu Active", + ROBOT_ACTION_SUSPENDED_CLEANING: "Suspended Cleaning", + ROBOT_ACTION_UPDATING: "Updating", + ROBOT_ACTION_COPY_LOGS: "Copying logs", + ROBOT_ACTION_RECOVERING_LOCATION: "Recovering Location", + ROBOT_ACTION_IEC_TEST: "IEC test", + ROBOT_ACTION_MAP_CLEANING: "Map cleaning", + ROBOT_ACTION_EXPLORING_MAP: "Exploring map (creating a persistent map)", + ROBOT_ACTION_ACQUIRING_MAP_IDS: "Acquiring Persistent Map IDs", + ROBOT_ACTION_UPLOADING_MAP: "Creating & Uploading Map", + ROBOT_ACTION_SUSPENDED_EXPLORATION: "Suspended Exploration", } +MODE = {1: "Eco", 2: "Turbo"} + ERRORS = { "ui_error_battery_battundervoltlithiumsafety": "Replace battery", "ui_error_battery_critical": "Replace battery", @@ -151,14 +194,3 @@ "clean_incomplete_to_start": "Cleaning incomplete", "log_upload_failed": "Logs failed to upload", } - -ATTR_NAVIGATION = "navigation" -ATTR_CATEGORY = "category" -ATTR_ZONE = "zone" - - -ROBOT_STATE_INVALID = 0 -ROBOT_STATE_IDLE = 1 -ROBOT_STATE_BUSY = 2 -ROBOT_STATE_PAUSE = 3 -ROBOT_STATE_ERROR = 4 diff --git a/homeassistant/components/vorwerk/strings.json b/homeassistant/components/vorwerk/strings.json index 441391aad27eb6..673525acb16129 100644 --- a/homeassistant/components/vorwerk/strings.json +++ b/homeassistant/components/vorwerk/strings.json @@ -6,14 +6,14 @@ "data": { "email": "[%key:common::config_flow::data::email%]" }, - "description": "To recieve an authentication code, enter the email address of your vorwerk account.\n\nSee [Vorwerk documentation]({docs_url})." + "description": "To recieve an authentication code, enter the email address of your vorwerk account." }, "code": { "title": "Vorwerk Account Info", "data": { "code": "Code" }, - "description": "Enter the code you received by email.\n\nSee [Vorwerk documentation]({docs_url})." + "description": "Enter the code you received by email." } }, "error": { diff --git a/homeassistant/components/vorwerk/vacuum.py b/homeassistant/components/vorwerk/vacuum.py index 13a927dcd41a5b..113c1f6aefb5e9 100644 --- a/homeassistant/components/vorwerk/vacuum.py +++ b/homeassistant/components/vorwerk/vacuum.py @@ -1,18 +1,18 @@ """Support for Neato Connected Vacuums.""" -from datetime import timedelta +from __future__ import annotations + import logging +from typing import Any from pybotvac.exceptions import NeatoRobotException +from pybotvac.robot import Robot import voluptuous as vol from homeassistant.components.vacuum import ( ATTR_STATUS, STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, STATE_IDLE, STATE_PAUSED, - STATE_RETURNING, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_LOCATE, @@ -25,27 +25,24 @@ ) from homeassistant.const import ATTR_MODE from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) +from . import VorwerkState from .const import ( - ACTION, - ALERTS, ATTR_CATEGORY, ATTR_NAVIGATION, ATTR_ZONE, - ERRORS, - MODE, - ROBOT_STATE_BUSY, - ROBOT_STATE_ERROR, - ROBOT_STATE_IDLE, - ROBOT_STATE_PAUSE, - SCAN_INTERVAL_MINUTES, VORWERK_DOMAIN, + VORWERK_ROBOT_API, + VORWERK_ROBOT_COORDINATOR, VORWERK_ROBOTS, ) _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(minutes=SCAN_INTERVAL_MINUTES) SUPPORT_VORWERK = ( SUPPORT_BATTERY @@ -65,7 +62,9 @@ async def async_setup_entry(hass, entry, async_add_entities): _LOGGER.debug("Adding vorwerk vacuums") async_add_entities( [ - VorwerkConnectedVacuum(robot) + VorwerkConnectedVacuum( + robot[VORWERK_ROBOT_API], robot[VORWERK_ROBOT_COORDINATOR] + ) for robot in hass.data[VORWERK_DOMAIN][entry.entry_id][VORWERK_ROBOTS] ], True, @@ -86,89 +85,20 @@ async def async_setup_entry(hass, entry, async_add_entities): ) -class VorwerkConnectedVacuum(StateVacuumEntity): +class VorwerkConnectedVacuum(CoordinatorEntity, StateVacuumEntity): """Representation of a Vorwerk Connected Vacuum.""" - def __init__(self, robot): + def __init__( + self, robot_state: VorwerkState, coordinator: DataUpdateCoordinator[Any] + ) -> None: """Initialize the Vorwerk Connected Vacuum.""" - self.robot = robot - self._available = False + super().__init__(coordinator) + self.robot: Robot = robot_state.robot + self._state: VorwerkState = robot_state + self._name = f"{self.robot.name}" - self._robot_has_map = False self._robot_serial = self.robot.serial - self._status_state = None - self._clean_state = None - self._state = None - self._battery_level = None - self._robot_boundaries = [] - self._robot_stats = None - - def update(self): - """Update the states of Vorwerk Vacuums.""" - _LOGGER.debug("Running Vorwerk Vacuums update for '%s'", self.entity_id) - try: - if self._robot_stats is None: - self._robot_stats = self.robot.get_general_info().json().get("data") - except NeatoRobotException: - _LOGGER.warning("Couldn't fetch robot information of %s", self.entity_id) - - try: - self._state = self.robot.state - except NeatoRobotException as ex: - if self._available: # print only once when available - _LOGGER.error( - "Vorwerk vacuum connection error for '%s': %s", self.entity_id, ex - ) - self._state = None - self._available = False - return - - self._available = True - _LOGGER.debug("self._state=%s", self._state) - if "alert" in self._state: - robot_alert = ALERTS.get(self._state["alert"]) - else: - robot_alert = None - if self._state["state"] == ROBOT_STATE_IDLE: - if self._state["details"]["isCharging"]: - self._clean_state = STATE_DOCKED - self._status_state = "Charging" - elif ( - self._state["details"]["isDocked"] - and not self._state["details"]["isCharging"] - ): - self._clean_state = STATE_DOCKED - self._status_state = "Docked" - else: - self._clean_state = STATE_IDLE - self._status_state = "Stopped" - - if robot_alert is not None: - self._status_state = robot_alert - elif self._state["state"] == ROBOT_STATE_BUSY: - if robot_alert is None: - self._clean_state = STATE_CLEANING - self._status_state = ( - f"{MODE.get(self._state['cleaning']['mode'])} " - f"{ACTION.get(self._state['action'])}" - ) - if ( - "boundary" in self._state["cleaning"] - and "name" in self._state["cleaning"]["boundary"] - ): - self._status_state += ( - f" {self._state['cleaning']['boundary']['name']}" - ) - else: - self._status_state = robot_alert - elif self._state["state"] == ROBOT_STATE_PAUSE: - self._clean_state = STATE_PAUSED - self._status_state = "Paused" - elif self._state["state"] == ROBOT_STATE_ERROR: - self._clean_state = STATE_ERROR - self._status_state = ERRORS.get(self._state["error"]) - - self._battery_level = self._state["details"]["charge"] + self._robot_boundaries: list[str] = [] @property def name(self): @@ -183,12 +113,12 @@ def supported_features(self): @property def battery_level(self): """Return the battery level of the vacuum cleaner.""" - return self._battery_level + return self._state.battery_level @property def available(self): """Return if the robot is available.""" - return self._available + return self._state.available @property def icon(self): @@ -198,7 +128,7 @@ def icon(self): @property def state(self): """Return the status of the vacuum cleaner.""" - return self._clean_state + return self._state.state if self._state else None @property def unique_id(self): @@ -210,30 +140,24 @@ def device_state_attributes(self): """Return the state attributes of the vacuum cleaner.""" data = {} - if self._status_state: - data[ATTR_STATUS] = self._status_state + if self._state.status is not None: + data[ATTR_STATUS] = self._state.status return data @property def device_info(self): """Device info for robot.""" - info = { - "identifiers": {(VORWERK_DOMAIN, self._robot_serial)}, - "name": self._name, - } - if self._robot_stats: - info["manufacturer"] = self._robot_stats["battery"]["vendor"] - info["model"] = self._robot_stats["model"] - info["sw_version"] = self._robot_stats["firmware"] - return info + return self._state.device_info def start(self): """Start cleaning or resume cleaning.""" + if not self._state: + return try: - if self._state["state"] == ROBOT_STATE_IDLE: + if self._state.state == STATE_IDLE: self.robot.start_cleaning() - elif self._state["state"] == ROBOT_STATE_PAUSE: + elif self._state.state == STATE_PAUSED: self.robot.resume_cleaning() except NeatoRobotException as ex: _LOGGER.error( @@ -252,9 +176,8 @@ def pause(self): def return_to_base(self, **kwargs): """Set the vacuum cleaner to return to the dock.""" try: - if self._clean_state == STATE_CLEANING: + if self._state.state == STATE_CLEANING: self.robot.pause_cleaning() - self._clean_state = STATE_RETURNING self.robot.send_to_base() except NeatoRobotException as ex: _LOGGER.error( @@ -301,7 +224,6 @@ def vorwerk_custom_cleaning(self, mode, navigation, category, zone=None): ) return - self._clean_state = STATE_CLEANING try: self.robot.start_cleaning(mode, navigation, category, boundary_id) except NeatoRobotException as ex: diff --git a/tests/components/vorwerk/test_config_flow.py b/tests/components/vorwerk/test_config_flow.py index d07ab22559fa38..1a844ac3343043 100644 --- a/tests/components/vorwerk/test_config_flow.py +++ b/tests/components/vorwerk/test_config_flow.py @@ -26,7 +26,7 @@ def _create_mocked_vorwerk_session(): def _patch_config_flow_vorwerksession(mocked_vorwerk_session): return patch( - "homeassistant.components.vorwerk.authsession.VorwerkSession", + "homeassistant.components.vorwerk.config_flow.VorwerkSession", return_value=mocked_vorwerk_session, ) From 4b9b7aaa154d9622ad4e51b3a2d93694cff06ba7 Mon Sep 17 00:00:00 2001 From: Michael Graf Date: Sun, 26 Dec 2021 15:02:41 +0100 Subject: [PATCH 3/3] Use DeviceInfo, pybotvac, CODEOWNERS and remove device_state_attributes --- CODEOWNERS | 1 + homeassistant/components/vorwerk/__init__.py | 19 ++++---- .../components/vorwerk/manifest.json | 2 +- homeassistant/components/vorwerk/vacuum.py | 43 ++++++++++--------- 4 files changed, 34 insertions(+), 31 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index b64272372f3e30..cd3fa811eb17bf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1014,6 +1014,7 @@ homeassistant/components/volumio/* @OnFreund tests/components/volumio/* @OnFreund homeassistant/components/volvooncall/* @molobrakos @decompil3d homeassistant/components/vorwerk/* @trunneml +tests/components/vorwerk/* @trunneml homeassistant/components/wake_on_lan/* @ntilley905 tests/components/wake_on_lan/* @ntilley905 homeassistant/components/wallbox/* @hesselonline diff --git a/homeassistant/components/vorwerk/__init__.py b/homeassistant/components/vorwerk/__init__.py index 0c4deb92adfc8d..7ecf1fd72e5925 100644 --- a/homeassistant/components/vorwerk/__init__.py +++ b/homeassistant/components/vorwerk/__init__.py @@ -21,6 +21,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -309,17 +310,15 @@ def battery_level(self) -> str | None: return self.robot_state["details"]["charge"] @property - def device_info(self) -> dict[str, str]: + def device_info(self) -> DeviceInfo: """Device info for robot.""" - info = { - "identifiers": {(VORWERK_DOMAIN, self.robot.serial)}, - "name": self.robot.name, - } - if self.robot_info: - info["manufacturer"] = self.robot_info["battery"]["vendor"] - info["model"] = self.robot_info["model"] - info["sw_version"] = self.robot_info["firmware"] - return info + return DeviceInfo( + identifiers={(VORWERK_DOMAIN, self.robot.serial)}, + manufacturer=self.robot_info["battery"]["vendor"] if self.robot_info else None, + model=self.robot_info["model"] if self.robot_info else None, + name=self.robot.name, + sw_version=self.robot_info["firmware"] if self.robot_info else None, + ) @property def schedule_enabled(self): diff --git a/homeassistant/components/vorwerk/manifest.json b/homeassistant/components/vorwerk/manifest.json index 7b33beb2d59408..0f3acc85b00971 100644 --- a/homeassistant/components/vorwerk/manifest.json +++ b/homeassistant/components/vorwerk/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vorwerk", "requirements": [ - "pybotvac==0.0.20" + "pybotvac==0.0.22" ], "codeowners": [ "@trunneml" diff --git a/homeassistant/components/vorwerk/vacuum.py b/homeassistant/components/vorwerk/vacuum.py index 113c1f6aefb5e9..691b06bb631cdb 100644 --- a/homeassistant/components/vorwerk/vacuum.py +++ b/homeassistant/components/vorwerk/vacuum.py @@ -4,8 +4,8 @@ import logging from typing import Any +from pybotvac import Robot from pybotvac.exceptions import NeatoRobotException -from pybotvac.robot import Robot import voluptuous as vol from homeassistant.components.vacuum import ( @@ -25,11 +25,11 @@ ) from homeassistant.const import ATTR_MODE from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) - from . import VorwerkState from .const import ( ATTR_CATEGORY, @@ -101,44 +101,44 @@ def __init__( self._robot_boundaries: list[str] = [] @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._name @property - def supported_features(self): + def supported_features(self) -> int: """Flag vacuum cleaner robot features that are supported.""" return SUPPORT_VORWERK @property - def battery_level(self): + def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" - return self._state.battery_level + return int(self._state.battery_level) if self._state.battery_level else None @property - def available(self): + def available(self) -> bool: """Return if the robot is available.""" return self._state.available @property - def icon(self): + def icon(self) -> str: """Return specific icon.""" return "mdi:robot-vacuum-variant" @property - def state(self): + def state(self) -> str | None: """Return the status of the vacuum cleaner.""" return self._state.state if self._state else None @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique ID.""" return self._robot_serial @property - def device_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" - data = {} + data: dict[str, Any] = {} if self._state.status is not None: data[ATTR_STATUS] = self._state.status @@ -146,11 +146,11 @@ def device_state_attributes(self): return data @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info for robot.""" return self._state.device_info - def start(self): + def start(self) -> None: """Start cleaning or resume cleaning.""" if not self._state: return @@ -164,7 +164,7 @@ def start(self): "Vorwerk vacuum connection error for '%s': %s", self.entity_id, ex ) - def pause(self): + def pause(self) -> None: """Pause the vacuum.""" try: self.robot.pause_cleaning() @@ -173,7 +173,7 @@ def pause(self): "Vorwerk vacuum connection error for '%s': %s", self.entity_id, ex ) - def return_to_base(self, **kwargs): + def return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" try: if self._state.state == STATE_CLEANING: @@ -184,7 +184,7 @@ def return_to_base(self, **kwargs): "Vorwerk vacuum connection error for '%s': %s", self.entity_id, ex ) - def stop(self, **kwargs): + def stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" try: self.robot.stop_cleaning() @@ -193,7 +193,7 @@ def stop(self, **kwargs): "Vorwerk vacuum connection error for '%s': %s", self.entity_id, ex ) - def locate(self, **kwargs): + def locate(self, **kwargs: Any) -> None: """Locate the robot by making it emit a sound.""" try: self.robot.locate() @@ -202,7 +202,7 @@ def locate(self, **kwargs): "Vorwerk vacuum connection error for '%s': %s", self.entity_id, ex ) - def clean_spot(self, **kwargs): + def clean_spot(self, **kwargs: Any) -> None: """Run a spot cleaning starting from the base.""" try: self.robot.start_spot_cleaning() @@ -211,7 +211,9 @@ def clean_spot(self, **kwargs): "Vorwerk vacuum connection error for '%s': %s", self.entity_id, ex ) - def vorwerk_custom_cleaning(self, mode, navigation, category, zone=None): + def vorwerk_custom_cleaning( + self, mode: str, navigation: str, category: str, zone: str | None = None + ) -> None: """Zone cleaning service call.""" boundary_id = None if zone is not None: @@ -223,6 +225,7 @@ def vorwerk_custom_cleaning(self, mode, navigation, category, zone=None): "Zone '%s' was not found for the robot '%s'", zone, self.entity_id ) return + _LOGGER.info("Start cleaning zone '%s' with robot %s", zone, self.entity_id) try: self.robot.start_cleaning(mode, navigation, category, boundary_id)