Initial working version
This commit is contained in:
parent
771f91e362
commit
fb1f2869f2
115
custom_components/vorwerk/__init__.py
Normal file
115
custom_components/vorwerk/__init__.py
Normal file
@ -0,0 +1,115 @@
|
||||
"""Support for botvac connected Vorwerk vacuum cleaners."""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
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 homeassistant.util import Throttle
|
||||
|
||||
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."""
|
||||
|
||||
@Throttle(timedelta(minutes=1))
|
||||
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
|
18
custom_components/vorwerk/authsession.py
Normal file
18
custom_components/vorwerk/authsession.py
Normal file
@ -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
|
118
custom_components/vorwerk/config_flow.py
Normal file
118
custom_components/vorwerk/config_flow.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""Config flow to configure Vorwerk integration."""
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
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
|
||||
|
||||
# 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: Optional[str] = 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] = {}) -> Dict[str, Any]:
|
||||
"""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.async_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"
|
||||
|
||||
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]) -> Dict[str, Any]:
|
||||
"""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,
|
||||
)
|
||||
|
||||
async def async_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()
|
||||
]
|
164
custom_components/vorwerk/const.py
Normal file
164
custom_components/vorwerk/const.py
Normal file
@ -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", "switch", "sensor"]
|
||||
|
||||
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_CLEAN_START = "clean_start"
|
||||
ATTR_CLEAN_STOP = "clean_stop"
|
||||
ATTR_CLEAN_AREA = "clean_area"
|
||||
ATTR_CLEAN_BATTERY_START = "battery_level_at_clean_start"
|
||||
ATTR_CLEAN_BATTERY_END = "battery_level_at_clean_end"
|
||||
ATTR_CLEAN_SUSP_COUNT = "clean_suspension_count"
|
||||
ATTR_CLEAN_SUSP_TIME = "clean_suspension_time"
|
||||
ATTR_CLEAN_PAUSE_TIME = "clean_pause_time"
|
||||
ATTR_CLEAN_ERROR_TIME = "clean_error_time"
|
||||
ATTR_LAUNCHED_FROM = "launched_from"
|
15
custom_components/vorwerk/manifest.json
Normal file
15
custom_components/vorwerk/manifest.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
92
custom_components/vorwerk/sensor.py
Normal file
92
custom_components/vorwerk/sensor.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""Support for Vorwerk sensors."""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pybotvac.exceptions import NeatoRobotException
|
||||
|
||||
from homeassistant.components.sensor import DEVICE_CLASS_BATTERY
|
||||
from homeassistant.const import PERCENTAGE
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import SCAN_INTERVAL_MINUTES, VORWERK_DOMAIN, VORWERK_ROBOTS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=SCAN_INTERVAL_MINUTES)
|
||||
|
||||
BATTERY = "Battery"
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_entities):
|
||||
"""Set up the Vorwerk sensor using config entry."""
|
||||
_LOGGER.debug("Adding sensors for vorwerk robots")
|
||||
async_add_entities(
|
||||
[
|
||||
VorwerkSensor(robot)
|
||||
for robot in hass.data[VORWERK_DOMAIN][entry.entry_id][VORWERK_ROBOTS]
|
||||
],
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
class VorwerkSensor(Entity):
|
||||
"""Vorwerk sensor."""
|
||||
|
||||
def __init__(self, robot):
|
||||
"""Initialize Vorwerk sensor."""
|
||||
self.robot = robot
|
||||
self._available = False
|
||||
self._robot_name = f"{self.robot.name} {BATTERY}"
|
||||
self._robot_serial = self.robot.serial
|
||||
self._state = None
|
||||
|
||||
def update(self):
|
||||
"""Update Vorwerk Sensor."""
|
||||
try:
|
||||
self._state = self.robot.state
|
||||
except NeatoRobotException as ex:
|
||||
if self._available:
|
||||
_LOGGER.error(
|
||||
"Vorwerk sensor 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)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of this sensor."""
|
||||
return self._robot_name
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return unique ID."""
|
||||
return self._robot_serial
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the device class."""
|
||||
return DEVICE_CLASS_BATTERY
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return availability."""
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state."""
|
||||
return self._state["details"]["charge"]
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
"""Return unit of measurement."""
|
||||
return PERCENTAGE
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Device info for robot."""
|
||||
return {"identifiers": {(VORWERK_DOMAIN, self._robot_serial)}}
|
18
custom_components/vorwerk/services.yaml
Normal file
18
custom_components/vorwerk/services.yaml
Normal file
@ -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"
|
22
custom_components/vorwerk/strings.json
Normal file
22
custom_components/vorwerk/strings.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Vorwerk Account Info",
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"code": "Code"
|
||||
},
|
||||
"description": "To recieve an authentication code, enter the email address of your vorwerk account.\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"
|
||||
}
|
123
custom_components/vorwerk/switch.py
Normal file
123
custom_components/vorwerk/switch.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""Support for Vorwerk Connected Vacuums switches."""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pybotvac.exceptions import NeatoRobotException
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
|
||||
from .const import SCAN_INTERVAL_MINUTES, VORWERK_DOMAIN, VORWERK_ROBOTS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=SCAN_INTERVAL_MINUTES)
|
||||
|
||||
SWITCH_TYPE_SCHEDULE = "schedule"
|
||||
|
||||
SWITCH_TYPES = {SWITCH_TYPE_SCHEDULE: ["Schedule"]}
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_entities):
|
||||
"""Set up Vorwerk switch with config entry."""
|
||||
_LOGGER.debug("Adding switches for vorwerk (%s)", entry.title)
|
||||
|
||||
dev = [
|
||||
VorwerkConnectedSwitch(robot, switch_type)
|
||||
for robot in hass.data[VORWERK_DOMAIN][entry.entry_id][VORWERK_ROBOTS]
|
||||
for switch_type in SWITCH_TYPES
|
||||
]
|
||||
|
||||
if not dev:
|
||||
return
|
||||
|
||||
async_add_entities(dev, True)
|
||||
|
||||
|
||||
class VorwerkConnectedSwitch(ToggleEntity):
|
||||
"""Vorwerk Connected Switches."""
|
||||
|
||||
def __init__(self, robot, switch_type):
|
||||
"""Initialize the Vorwerk Connected switches."""
|
||||
self.type = switch_type
|
||||
self.robot = robot
|
||||
self._available = False
|
||||
self._robot_name = f"{self.robot.name} {SWITCH_TYPES[self.type][0]}"
|
||||
self._state = None
|
||||
self._schedule_state = None
|
||||
self._clean_state = None
|
||||
self._robot_serial = self.robot.serial
|
||||
|
||||
def update(self):
|
||||
"""Update the states of Vorwerk switches."""
|
||||
_LOGGER.debug("Running Vorwerk switch update for '%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 switch 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 self.type == SWITCH_TYPE_SCHEDULE:
|
||||
_LOGGER.debug("State: %s", self._state)
|
||||
if self._state["details"]["isScheduleEnabled"]:
|
||||
self._schedule_state = STATE_ON
|
||||
else:
|
||||
self._schedule_state = STATE_OFF
|
||||
_LOGGER.debug(
|
||||
"Schedule state for '%s': %s", self.entity_id, self._schedule_state
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the switch."""
|
||||
return self._robot_name
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return True if entity is available."""
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique ID."""
|
||||
return self._robot_serial
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if switch is on."""
|
||||
if self.type == SWITCH_TYPE_SCHEDULE:
|
||||
if self._schedule_state == STATE_ON:
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Device info for robot."""
|
||||
return {"identifiers": {(VORWERK_DOMAIN, self._robot_serial)}}
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
"""Turn the switch on."""
|
||||
if self.type == SWITCH_TYPE_SCHEDULE:
|
||||
try:
|
||||
self.robot.enable_schedule()
|
||||
except NeatoRobotException as ex:
|
||||
_LOGGER.error(
|
||||
"Vorwerk switch connection error '%s': %s", self.entity_id, ex
|
||||
)
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
"""Turn the switch off."""
|
||||
if self.type == SWITCH_TYPE_SCHEDULE:
|
||||
try:
|
||||
self.robot.disable_schedule()
|
||||
except NeatoRobotException as ex:
|
||||
_LOGGER.error(
|
||||
"Vorwerk switch connection error '%s': %s", self.entity_id, ex
|
||||
)
|
29
custom_components/vorwerk/translations/de.json
Normal file
29
custom_components/vorwerk/translations/de.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Vorwerk-Kontoinformationen",
|
||||
"data": {
|
||||
"email": "E-Mailaddresse"
|
||||
},
|
||||
"description": "Um einen Authentifizierungscode per E-Mail zu erhalten, gib die E-Mailadresse deines Vorwerk-Accounts ein.\n\nSiehe [Vorwerk-Dokumentation]({docs_url})."
|
||||
},
|
||||
"code": {
|
||||
"title": "Vorwerk Account Info",
|
||||
"data": {
|
||||
"code": "Code"
|
||||
},
|
||||
"description": "Gib den per E-Mail erhaltenen Code ein.\n\nSee [Vorwerk documentation]({docs_url})."
|
||||
}
|
||||
},
|
||||
},
|
||||
"error": {
|
||||
"invalid_auth": "Ung\u00fcltige Authentifizierung"
|
||||
},
|
||||
"abort": {
|
||||
"invalid_auth": "Ung\u00fcltige Authentifizierung",
|
||||
"already_configured": "Bereits konfiguriert"
|
||||
}
|
||||
},
|
||||
"title": "Vorwerk Kobold"
|
||||
}
|
28
custom_components/vorwerk/translations/en.json
Normal file
28
custom_components/vorwerk/translations/en.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Vorwerk Account Info",
|
||||
"data": {
|
||||
"email": "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": "Invalid authentication"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Account is already configured",
|
||||
"invalid_auth": "Invalid authentication"
|
||||
}
|
||||
},
|
||||
"title": "Vorwerk Kobold"
|
||||
}
|
348
custom_components/vorwerk/vacuum.py
Normal file
348
custom_components/vorwerk/vacuum.py
Normal file
@ -0,0 +1,348 @@
|
||||
"""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_CLEAN_AREA,
|
||||
ATTR_CLEAN_BATTERY_END,
|
||||
ATTR_CLEAN_BATTERY_START,
|
||||
ATTR_CLEAN_ERROR_TIME,
|
||||
ATTR_CLEAN_PAUSE_TIME,
|
||||
ATTR_CLEAN_START,
|
||||
ATTR_CLEAN_STOP,
|
||||
ATTR_CLEAN_SUSP_COUNT,
|
||||
ATTR_CLEAN_SUSP_TIME,
|
||||
ATTR_LAUNCHED_FROM,
|
||||
ERRORS,
|
||||
MODE,
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
ATTR_NAVIGATION = "navigation"
|
||||
ATTR_CATEGORY = "category"
|
||||
ATTR_ZONE = "zone"
|
||||
|
||||
|
||||
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._clean_time_start = None
|
||||
self._clean_time_stop = None
|
||||
self._clean_area = None
|
||||
self._clean_battery_start = None
|
||||
self._clean_battery_end = None
|
||||
self._clean_susp_charge_count = None
|
||||
self._clean_susp_time = None
|
||||
self._clean_pause_time = None
|
||||
self._clean_error_time = None
|
||||
self._launched_from = 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"] == 1:
|
||||
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"] == 2:
|
||||
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"] == 3:
|
||||
self._clean_state = STATE_PAUSED
|
||||
self._status_state = "Paused"
|
||||
elif self._state["state"] == 4:
|
||||
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 is not None:
|
||||
data[ATTR_STATUS] = self._status_state
|
||||
if self._clean_time_start is not None:
|
||||
data[ATTR_CLEAN_START] = self._clean_time_start
|
||||
if self._clean_time_stop is not None:
|
||||
data[ATTR_CLEAN_STOP] = self._clean_time_stop
|
||||
if self._clean_area is not None:
|
||||
data[ATTR_CLEAN_AREA] = self._clean_area
|
||||
if self._clean_susp_charge_count is not None:
|
||||
data[ATTR_CLEAN_SUSP_COUNT] = self._clean_susp_charge_count
|
||||
if self._clean_susp_time is not None:
|
||||
data[ATTR_CLEAN_SUSP_TIME] = self._clean_susp_time
|
||||
if self._clean_pause_time is not None:
|
||||
data[ATTR_CLEAN_PAUSE_TIME] = self._clean_pause_time
|
||||
if self._clean_error_time is not None:
|
||||
data[ATTR_CLEAN_ERROR_TIME] = self._clean_error_time
|
||||
if self._clean_battery_start is not None:
|
||||
data[ATTR_CLEAN_BATTERY_START] = self._clean_battery_start
|
||||
if self._clean_battery_end is not None:
|
||||
data[ATTR_CLEAN_BATTERY_END] = self._clean_battery_end
|
||||
if self._launched_from is not None:
|
||||
data[ATTR_LAUNCHED_FROM] = self._launched_from
|
||||
|
||||
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"] == 1:
|
||||
self.robot.start_cleaning()
|
||||
elif self._state["state"] == 3:
|
||||
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
|
||||
)
|
Loading…
Reference in New Issue
Block a user