First commit
This commit is contained in:
253
custom_components/daily/__init__.py
Normal file
253
custom_components/daily/__init__.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""The Daily Sensor integration."""
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.event import async_track_time_change
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import (
|
||||
CONF_AUTO_RESET,
|
||||
CONF_INPUT_SENSOR,
|
||||
CONF_INTERVAL,
|
||||
CONF_NAME,
|
||||
CONF_OPERATION,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
COORDINATOR,
|
||||
DEFAULT_AUTO_RESET,
|
||||
DOMAIN,
|
||||
EVENT_RESET,
|
||||
EVENT_UPDATE,
|
||||
PLATFORMS,
|
||||
SERVICE_RESET,
|
||||
SERVICE_UPDATE,
|
||||
STARTUP_MESSAGE,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict):
|
||||
"""Set up this integration using YAML is not supported."""
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Set up this integration using UI."""
|
||||
if hass.data.get(DOMAIN) is None:
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
_LOGGER.info(STARTUP_MESSAGE)
|
||||
name = entry.data.get(CONF_NAME)
|
||||
name_no_spaces_but_underscores = name.replace(" ", "_")
|
||||
input_sensor = entry.data.get(CONF_INPUT_SENSOR)
|
||||
operation = entry.data.get(CONF_OPERATION)
|
||||
interval = entry.data.get(CONF_INTERVAL)
|
||||
unit_of_measurement = entry.data.get(CONF_UNIT_OF_MEASUREMENT)
|
||||
auto_reset = entry.data.get(CONF_AUTO_RESET, DEFAULT_AUTO_RESET)
|
||||
|
||||
# update listener for options flow
|
||||
hass_data = dict(entry.data)
|
||||
unsub_options_update_listener = entry.add_update_listener(options_update_listener)
|
||||
hass_data["unsub_options_update_listener"] = unsub_options_update_listener
|
||||
hass.data[DOMAIN][entry.entry_id] = hass_data
|
||||
|
||||
# logic here is: if options are set that do not agree with the data settings, use the options
|
||||
# handle options flow data
|
||||
if CONF_INPUT_SENSOR in entry.options and entry.options.get(
|
||||
CONF_INPUT_SENSOR
|
||||
) != entry.data.get(CONF_INPUT_SENSOR):
|
||||
input_sensor = hass.data[DOMAIN][entry.entry_id][CONF_INPUT_SENSOR] = (
|
||||
entry.options.get(CONF_INPUT_SENSOR)
|
||||
)
|
||||
if CONF_AUTO_RESET in entry.options and entry.options.get(
|
||||
CONF_AUTO_RESET
|
||||
) != entry.data.get(CONF_AUTO_RESET):
|
||||
auto_reset = hass.data[DOMAIN][entry.entry_id][CONF_AUTO_RESET] = (
|
||||
entry.options.get(CONF_AUTO_RESET)
|
||||
)
|
||||
if CONF_INTERVAL in entry.options and entry.options.get(
|
||||
CONF_INTERVAL
|
||||
) != entry.data.get(CONF_INTERVAL):
|
||||
interval = hass.data[DOMAIN][entry.entry_id][CONF_INTERVAL] = entry.options.get(
|
||||
CONF_INTERVAL
|
||||
)
|
||||
if CONF_OPERATION in entry.options and entry.options.get(
|
||||
CONF_OPERATION
|
||||
) != entry.data.get(CONF_OPERATION):
|
||||
operation = hass.data[DOMAIN][entry.entry_id][CONF_OPERATION] = (
|
||||
entry.options.get(CONF_OPERATION)
|
||||
)
|
||||
if CONF_UNIT_OF_MEASUREMENT in entry.options and entry.options.get(
|
||||
CONF_UNIT_OF_MEASUREMENT
|
||||
) != entry.data.get(CONF_UNIT_OF_MEASUREMENT):
|
||||
unit_of_measurement = hass.data[DOMAIN][entry.entry_id][
|
||||
CONF_UNIT_OF_MEASUREMENT
|
||||
] = entry.options.get(CONF_UNIT_OF_MEASUREMENT)
|
||||
# set up coordinator
|
||||
coordinator = DailySensorUpdateCoordinator(
|
||||
hass,
|
||||
name=name,
|
||||
input_sensor=input_sensor,
|
||||
operation=operation,
|
||||
interval=interval,
|
||||
unit_of_measurement=unit_of_measurement,
|
||||
auto_reset=auto_reset,
|
||||
)
|
||||
|
||||
await coordinator.async_refresh()
|
||||
|
||||
if not coordinator.last_update_success:
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id][COORDINATOR] = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# add update listener if not already added.
|
||||
# if weakref.ref(async_reload_entry) not in entry.update_listeners:
|
||||
# entry.add_update_listener(async_reload_entry)
|
||||
|
||||
# register services
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
f"{name_no_spaces_but_underscores}_{SERVICE_RESET}",
|
||||
coordinator.handle_reset,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
f"{name_no_spaces_but_underscores}_{SERVICE_UPDATE}",
|
||||
coordinator.handle_update,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Reload config entry."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR]
|
||||
if coordinator.entry_setup_completed:
|
||||
await async_unload_entry(hass, entry)
|
||||
await async_setup_entry(hass, entry)
|
||||
|
||||
|
||||
async def options_update_listener(hass, config_entry):
|
||||
"""Handle options update."""
|
||||
hass.data[DOMAIN][config_entry.entry_id][CONF_INTERVAL] = config_entry.options.get(
|
||||
CONF_INTERVAL
|
||||
)
|
||||
hass.data[DOMAIN][config_entry.entry_id][CONF_INPUT_SENSOR] = (
|
||||
config_entry.options.get(CONF_INPUT_SENSOR)
|
||||
)
|
||||
hass.data[DOMAIN][config_entry.entry_id][CONF_AUTO_RESET] = (
|
||||
config_entry.options.get(CONF_AUTO_RESET)
|
||||
)
|
||||
hass.data[DOMAIN][config_entry.entry_id][CONF_OPERATION] = config_entry.options.get(
|
||||
CONF_OPERATION
|
||||
)
|
||||
hass.data[DOMAIN][config_entry.entry_id][CONF_UNIT_OF_MEASUREMENT] = (
|
||||
config_entry.options.get(CONF_UNIT_OF_MEASUREMENT)
|
||||
)
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Handle removal of an entry."""
|
||||
if DOMAIN in hass.data and entry.entry_id in hass.data[DOMAIN]:
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR]
|
||||
unloaded = all(
|
||||
await asyncio.gather(
|
||||
*[
|
||||
hass.config_entries.async_forward_entry_unload(entry, platform)
|
||||
for platform in PLATFORMS
|
||||
if platform in coordinator.platforms
|
||||
]
|
||||
)
|
||||
)
|
||||
if unloaded:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unloaded
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_entry(hass, entry):
|
||||
"""Remove Daily sensor config entry."""
|
||||
if DOMAIN in hass.data and entry.entry_id in hass.data[DOMAIN]:
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
|
||||
await coordinator.async_delete_config()
|
||||
del hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
|
||||
class DailySensorUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Class to store settings."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass,
|
||||
name,
|
||||
input_sensor,
|
||||
operation,
|
||||
interval,
|
||||
unit_of_measurement,
|
||||
auto_reset,
|
||||
):
|
||||
"""Initialize."""
|
||||
self.name = name
|
||||
self.input_sensor = input_sensor
|
||||
self.operation = operation
|
||||
self.interval = int(interval)
|
||||
self.unit_of_measurement = unit_of_measurement
|
||||
self.auto_reset = auto_reset
|
||||
self.hass = hass
|
||||
self.entities = {}
|
||||
self.platforms = []
|
||||
self.entry_setup_completed = False
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=self.interval)
|
||||
super().__init__(hass, _LOGGER, name=name, update_interval=SCAN_INTERVAL)
|
||||
|
||||
# reset happens at midnight
|
||||
_LOGGER.info("auto_reset: {0}".format(self.auto_reset))
|
||||
if self.auto_reset:
|
||||
async_track_time_change(
|
||||
hass,
|
||||
self._async_reset,
|
||||
hour=0,
|
||||
minute=0,
|
||||
second=0,
|
||||
)
|
||||
_LOGGER.info("registered for time change.")
|
||||
self.entry_setup_completed = True
|
||||
|
||||
def register_entity(self, thetype, entity):
|
||||
"""Register an entity."""
|
||||
self.entities[thetype] = entity
|
||||
|
||||
def fire_event(self, event):
|
||||
"""Fire an event."""
|
||||
event_to_fire = f"{self.name}_{event}"
|
||||
self.hass.bus.fire(event_to_fire)
|
||||
|
||||
def handle_reset(self, call):
|
||||
"""Hande the reset service call."""
|
||||
self.fire_event(EVENT_RESET)
|
||||
|
||||
def handle_update(self, call):
|
||||
"""Handle the update service call."""
|
||||
self.fire_event(EVENT_UPDATE)
|
||||
|
||||
async def _async_reset(self, *args):
|
||||
_LOGGER.info("Resetting daily sensor {}!".format(self.name))
|
||||
self.fire_event(EVENT_RESET)
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""Update data."""
|
||||
_LOGGER.info("Updating Daily Sensor {}".format(self.name))
|
||||
# fire an event so the sensor can update itself.
|
||||
self.fire_event(EVENT_UPDATE)
|
||||
BIN
custom_components/daily/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
custom_components/daily/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
custom_components/daily/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
custom_components/daily/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
custom_components/daily/__pycache__/config_flow.cpython-312.pyc
Normal file
BIN
custom_components/daily/__pycache__/config_flow.cpython-312.pyc
Normal file
Binary file not shown.
BIN
custom_components/daily/__pycache__/config_flow.cpython-313.pyc
Normal file
BIN
custom_components/daily/__pycache__/config_flow.cpython-313.pyc
Normal file
Binary file not shown.
BIN
custom_components/daily/__pycache__/const.cpython-312.pyc
Normal file
BIN
custom_components/daily/__pycache__/const.cpython-312.pyc
Normal file
Binary file not shown.
BIN
custom_components/daily/__pycache__/const.cpython-313.pyc
Normal file
BIN
custom_components/daily/__pycache__/const.cpython-313.pyc
Normal file
Binary file not shown.
BIN
custom_components/daily/__pycache__/entity.cpython-312.pyc
Normal file
BIN
custom_components/daily/__pycache__/entity.cpython-312.pyc
Normal file
Binary file not shown.
BIN
custom_components/daily/__pycache__/entity.cpython-313.pyc
Normal file
BIN
custom_components/daily/__pycache__/entity.cpython-313.pyc
Normal file
Binary file not shown.
BIN
custom_components/daily/__pycache__/exceptions.cpython-312.pyc
Normal file
BIN
custom_components/daily/__pycache__/exceptions.cpython-312.pyc
Normal file
Binary file not shown.
BIN
custom_components/daily/__pycache__/exceptions.cpython-313.pyc
Normal file
BIN
custom_components/daily/__pycache__/exceptions.cpython-313.pyc
Normal file
Binary file not shown.
BIN
custom_components/daily/__pycache__/helpers.cpython-312.pyc
Normal file
BIN
custom_components/daily/__pycache__/helpers.cpython-312.pyc
Normal file
Binary file not shown.
BIN
custom_components/daily/__pycache__/helpers.cpython-313.pyc
Normal file
BIN
custom_components/daily/__pycache__/helpers.cpython-313.pyc
Normal file
Binary file not shown.
BIN
custom_components/daily/__pycache__/options_flow.cpython-312.pyc
Normal file
BIN
custom_components/daily/__pycache__/options_flow.cpython-312.pyc
Normal file
Binary file not shown.
BIN
custom_components/daily/__pycache__/options_flow.cpython-313.pyc
Normal file
BIN
custom_components/daily/__pycache__/options_flow.cpython-313.pyc
Normal file
Binary file not shown.
BIN
custom_components/daily/__pycache__/sensor.cpython-312.pyc
Normal file
BIN
custom_components/daily/__pycache__/sensor.cpython-312.pyc
Normal file
Binary file not shown.
BIN
custom_components/daily/__pycache__/sensor.cpython-313.pyc
Normal file
BIN
custom_components/daily/__pycache__/sensor.cpython-313.pyc
Normal file
Binary file not shown.
121
custom_components/daily/config_flow.py
Normal file
121
custom_components/daily/config_flow.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Config flow for Daily Sensor integration."""
|
||||
|
||||
from homeassistant.core import callback
|
||||
from .const import ( # pylint: disable=unused-import
|
||||
DOMAIN,
|
||||
CONF_INPUT_SENSOR,
|
||||
CONF_OPERATION,
|
||||
CONF_NAME,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_INTERVAL,
|
||||
CONF_AUTO_RESET,
|
||||
NAME,
|
||||
VALID_OPERATIONS,
|
||||
DEFAULT_INTERVAL,
|
||||
DEFAULT_AUTO_RESET,
|
||||
)
|
||||
from .exceptions import SensorNotFound, OperationNotFound, IntervalNotValid, NotUnique
|
||||
from .options_flow import DailySensorOptionsFlowHandler
|
||||
import logging
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DailySensorConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Daily Sensor."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize."""
|
||||
self._name = NAME
|
||||
self._operation = ""
|
||||
self._input_sensor = ""
|
||||
self._unit_of_measurement = "unknown"
|
||||
self._errors = {}
|
||||
self._auto_reset = DEFAULT_AUTO_RESET
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a flow initialized by the user."""
|
||||
self._errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
await self._check_unique(user_input[CONF_NAME])
|
||||
|
||||
# check input sensor exists
|
||||
status = self.hass.states.get(user_input[CONF_INPUT_SENSOR])
|
||||
if status is None:
|
||||
raise SensorNotFound
|
||||
|
||||
# check the operation
|
||||
if user_input[CONF_OPERATION] not in VALID_OPERATIONS:
|
||||
raise OperationNotFound
|
||||
# check the interval
|
||||
if (
|
||||
not (isinstance(user_input[CONF_INTERVAL], int))
|
||||
or user_input[CONF_INTERVAL] <= 0
|
||||
):
|
||||
raise IntervalNotValid
|
||||
self._name = user_input[CONF_NAME]
|
||||
self._auto_reset = user_input[CONF_AUTO_RESET]
|
||||
|
||||
return self.async_create_entry(title=self._name, data=user_input)
|
||||
|
||||
except NotUnique:
|
||||
_LOGGER.error("Instance name is not unique.")
|
||||
self._errors["base"] = "name"
|
||||
except SensorNotFound:
|
||||
_LOGGER.error(
|
||||
"Input sensor {} not found.".format(user_input[CONF_INPUT_SENSOR])
|
||||
)
|
||||
self._errors["base"] = "sensornotfound"
|
||||
except OperationNotFound:
|
||||
_LOGGER.error(
|
||||
"Specified operation {} not valid.".format(
|
||||
user_input[CONF_OPERATION]
|
||||
),
|
||||
)
|
||||
self._errors["base"] = "operationnotfound"
|
||||
except IntervalNotValid:
|
||||
_LOGGER.error(
|
||||
"Specified interval {} not valid.".format(
|
||||
user_input[CONF_INTERVAL]
|
||||
),
|
||||
)
|
||||
self._errors["base"] = "intervalnotvalid"
|
||||
|
||||
return await self._show_config_form(user_input)
|
||||
return await self._show_config_form(user_input)
|
||||
|
||||
async def _show_config_form(self, user_input):
|
||||
"""Show the configuration form to edit info."""
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME, default=NAME): str,
|
||||
vol.Required(CONF_INPUT_SENSOR): str,
|
||||
vol.Required(CONF_OPERATION): vol.In(VALID_OPERATIONS),
|
||||
vol.Required(CONF_UNIT_OF_MEASUREMENT): str,
|
||||
vol.Required(CONF_INTERVAL, default=DEFAULT_INTERVAL): int,
|
||||
vol.Required(CONF_AUTO_RESET, default=DEFAULT_AUTO_RESET): bool,
|
||||
}
|
||||
),
|
||||
errors=self._errors,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
"""Get options flow."""
|
||||
return DailySensorOptionsFlowHandler(config_entry)
|
||||
|
||||
async def _check_unique(self, thename):
|
||||
"""Test if the specified name is not already claimed."""
|
||||
await self.async_set_unique_id(thename)
|
||||
self._abort_if_unique_id_configured()
|
||||
68
custom_components/daily/const.py
Normal file
68
custom_components/daily/const.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Constants for the Daily Sensor integration."""
|
||||
|
||||
DOMAIN = "daily"
|
||||
NAME = "Daily Sensor"
|
||||
DOMAIN_DATA = f"{DOMAIN}_data"
|
||||
VERSION = "v2024.10.1"
|
||||
COORDINATOR = "coordinator"
|
||||
ISSUE_URL = "https://github.com/jeroenterheerdt/HADailySensor/issues"
|
||||
|
||||
# Icons
|
||||
ICON = "mdi:timetable"
|
||||
|
||||
# Platforms
|
||||
SENSOR = "sensor"
|
||||
PLATFORMS = [SENSOR]
|
||||
|
||||
# Localization
|
||||
LANGUAGE_FILES_DIR = "translations"
|
||||
SUPPORTED_LANGUAGES = ["da", "el", "en", "es", "fr", "nb", "nl", "sk", "sl"]
|
||||
|
||||
# Config
|
||||
CONF_INPUT_SENSOR = "sensor"
|
||||
CONF_OPERATION = "operation"
|
||||
CONF_NAME = "name"
|
||||
CONF_INTERVAL = "interval"
|
||||
CONF_UNIT_OF_MEASUREMENT = "unit_of_measurement"
|
||||
CONF_AUTO_RESET = "auto_reset"
|
||||
|
||||
# Attributes
|
||||
ATTR_DATETIME_OF_OCCURRENCE = "datetime_of_occurrence"
|
||||
|
||||
# Operations
|
||||
CONF_MAX = "max"
|
||||
CONF_MIN = "min"
|
||||
CONF_MEAN = "mean"
|
||||
CONF_MEDIAN = "median"
|
||||
CONF_STDEV = "stdev"
|
||||
CONF_VARIANCE = "variance"
|
||||
CONF_SUM = "sum"
|
||||
VALID_OPERATIONS = [
|
||||
CONF_MAX,
|
||||
CONF_MIN,
|
||||
CONF_MEAN,
|
||||
CONF_MEDIAN,
|
||||
CONF_STDEV,
|
||||
CONF_VARIANCE,
|
||||
CONF_SUM,
|
||||
]
|
||||
|
||||
# Defaults
|
||||
DEFAULT_INTERVAL = 1800.0 # seconds
|
||||
DEFAULT_AUTO_RESET = True
|
||||
# Services
|
||||
SERVICE_RESET = "reset"
|
||||
SERVICE_UPDATE = "update"
|
||||
|
||||
# Events
|
||||
EVENT_RESET = "reset"
|
||||
EVENT_UPDATE = "update"
|
||||
|
||||
STARTUP_MESSAGE = f"""
|
||||
-------------------------------------------------------------------
|
||||
{NAME}
|
||||
Version: {VERSION}
|
||||
If you have any issues with this you need to open an issue here:
|
||||
{ISSUE_URL}
|
||||
-------------------------------------------------------------------
|
||||
"""
|
||||
40
custom_components/daily/entity.py
Normal file
40
custom_components/daily/entity.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Daily Sensor class."""
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
import logging
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DailySensorEntity(RestoreEntity):
|
||||
"""Daily Sensor Entity."""
|
||||
|
||||
def __init__(self, coordinator, config_entry):
|
||||
"""Initialize dailysensorentity."""
|
||||
self.coordinator = coordinator
|
||||
self.config_entry = config_entry
|
||||
self.entity_id = f"sensor.{coordinator.name}"
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No need to poll. Coordinator notifies entity of updates."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return if entity is available."""
|
||||
return self.coordinator.last_update_success
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique ID to use for this entity."""
|
||||
return self.config_entry.entry_id
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Connect to dispatcher listening for entity data notifications."""
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(self.async_write_ha_state)
|
||||
)
|
||||
|
||||
async def async_update(self):
|
||||
"""Update Coordinator entity."""
|
||||
await self.coordinator.async_request_refresh()
|
||||
17
custom_components/daily/exceptions.py
Normal file
17
custom_components/daily/exceptions.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from homeassistant import exceptions
|
||||
|
||||
|
||||
class SensorNotFound(exceptions.HomeAssistantError):
|
||||
"""Error to indicate a sensor is not found."""
|
||||
|
||||
|
||||
class OperationNotFound(exceptions.HomeAssistantError):
|
||||
"""Error to indicate the operation specified is not valid."""
|
||||
|
||||
|
||||
class IntervalNotValid(exceptions.HomeAssistantError):
|
||||
"""Error to indicate the interval specified is not valid."""
|
||||
|
||||
|
||||
class NotUnique(exceptions.HomeAssistantError):
|
||||
"""Error to indicate that the name is not unique."""
|
||||
35
custom_components/daily/helpers.py
Normal file
35
custom_components/daily/helpers.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import _LOGGER
|
||||
|
||||
|
||||
def is_number(s):
|
||||
if s:
|
||||
try:
|
||||
float(s)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def parse_sensor_state(state):
|
||||
if not state:
|
||||
return STATE_UNKNOWN
|
||||
if is_number(state.state):
|
||||
return state.state
|
||||
if not state or not state.state or state.state == STATE_UNAVAILABLE:
|
||||
return STATE_UNAVAILABLE
|
||||
return STATE_UNKNOWN
|
||||
|
||||
|
||||
def convert_to_float(float_value):
|
||||
"""Convert to Float."""
|
||||
try:
|
||||
return float(float_value)
|
||||
except ValueError:
|
||||
_LOGGER.error(
|
||||
"unable to convert {} to float. Please check the source sensor is available.".format(
|
||||
float_value
|
||||
)
|
||||
)
|
||||
raise ValueError
|
||||
50
custom_components/daily/localize.py
Normal file
50
custom_components/daily/localize.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
from .const import LANGUAGE_FILES_DIR, SUPPORTED_LANGUAGES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def localize(string, language):
|
||||
# try opening language file
|
||||
language = language.lower()
|
||||
translated_string = None
|
||||
main_path = os.path.dirname(__file__)
|
||||
stringpath = string.split(".")
|
||||
try:
|
||||
# if the language is not english and the language is supported
|
||||
if language != "en" and language in SUPPORTED_LANGUAGES:
|
||||
with open(
|
||||
os.path.join(
|
||||
main_path, LANGUAGE_FILES_DIR + os.sep + language + ".json"
|
||||
)
|
||||
) as f:
|
||||
data = json.load(f)
|
||||
translated_string = get_string_from_data(stringpath, data)
|
||||
# fallback to english in case string wasn't found
|
||||
if language == "en" or not isinstance(translated_string, str):
|
||||
with open(
|
||||
os.path.join(main_path, LANGUAGE_FILES_DIR + os.sep + "en.json")
|
||||
) as f:
|
||||
data = json.load(f)
|
||||
translated_string = get_string_from_data(stringpath, data)
|
||||
# if still not found, just return the string parameter
|
||||
if isinstance(translated_string, str):
|
||||
return translated_string
|
||||
else:
|
||||
return string
|
||||
except OSError:
|
||||
_LOGGER.error(
|
||||
"Couldn't load translations language file for {}".format(language)
|
||||
)
|
||||
|
||||
|
||||
def get_string_from_data(stringpath, data):
|
||||
data_to_walk = data
|
||||
for p in stringpath:
|
||||
if isinstance(data_to_walk, str):
|
||||
return data_to_walk
|
||||
if p in data_to_walk:
|
||||
data_to_walk = data_to_walk[p]
|
||||
return data_to_walk
|
||||
15
custom_components/daily/manifest.json
Normal file
15
custom_components/daily/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"domain": "daily",
|
||||
"name": "Daily Sensor",
|
||||
"codeowners": ["@jeroenterheerdt"],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/jeroenterheerdt/HADailySensor",
|
||||
"homekit": {},
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/jeroenterheerdt/HADailySensor/issues",
|
||||
"requirements": [],
|
||||
"ssdp": [],
|
||||
"version": "v2024.10.1",
|
||||
"zeroconf": []
|
||||
}
|
||||
118
custom_components/daily/options_flow.py
Normal file
118
custom_components/daily/options_flow.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from homeassistant.helpers.selector import selector
|
||||
import logging
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from .const import ( # pylint: disable=unused-import
|
||||
DOMAIN,
|
||||
CONF_INPUT_SENSOR,
|
||||
CONF_OPERATION,
|
||||
CONF_NAME,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_INTERVAL,
|
||||
CONF_AUTO_RESET,
|
||||
NAME,
|
||||
VALID_OPERATIONS,
|
||||
DEFAULT_INTERVAL,
|
||||
DEFAULT_AUTO_RESET,
|
||||
)
|
||||
from .exceptions import SensorNotFound, OperationNotFound, IntervalNotValid
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DailySensorOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Daily Sensor options flow options handler."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
|
||||
|
||||
def __init__(self, config_entry):
|
||||
"""Initialize HACS options flow."""
|
||||
self.config_entry = config_entry
|
||||
self.options = dict(config_entry.options)
|
||||
self._errors = {}
|
||||
self._operation = self.options.get(
|
||||
CONF_OPERATION, config_entry.data.get(CONF_OPERATION)
|
||||
)
|
||||
self._input_sensor = self.options.get(
|
||||
CONF_INPUT_SENSOR, config_entry.data.get(CONF_INPUT_SENSOR)
|
||||
)
|
||||
self._auto_reset = self.options.get(
|
||||
CONF_AUTO_RESET, config_entry.data.get(CONF_AUTO_RESET)
|
||||
)
|
||||
self._interval = self.options.get(
|
||||
CONF_INTERVAL, config_entry.data.get(CONF_INTERVAL)
|
||||
)
|
||||
self._unit_of_measurement = self.options.get(
|
||||
CONF_UNIT_OF_MEASUREMENT, config_entry.data.get(CONF_UNIT_OF_MEASUREMENT)
|
||||
)
|
||||
|
||||
async def async_step_init(self, user_input=None): # pylint: disable=unused-argument
|
||||
"""Manage the options."""
|
||||
self._errors = {}
|
||||
# set default values based on config
|
||||
if user_input is not None:
|
||||
try:
|
||||
# check input sensor exists
|
||||
status = self.hass.states.get(user_input[CONF_INPUT_SENSOR])
|
||||
if status is None:
|
||||
raise SensorNotFound
|
||||
|
||||
# check the operation
|
||||
if user_input[CONF_OPERATION] not in VALID_OPERATIONS:
|
||||
raise OperationNotFound
|
||||
# check the interval
|
||||
if (
|
||||
not (isinstance(user_input[CONF_INTERVAL], int))
|
||||
or user_input[CONF_INTERVAL] <= 0
|
||||
):
|
||||
raise IntervalNotValid
|
||||
self._auto_reset = user_input[CONF_AUTO_RESET]
|
||||
self._interval = user_input[CONF_INTERVAL]
|
||||
self._unit_of_measurement = user_input[CONF_UNIT_OF_MEASUREMENT]
|
||||
self._operation = user_input[CONF_OPERATION]
|
||||
self._input_sensor = user_input[CONF_INPUT_SENSOR]
|
||||
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
except SensorNotFound:
|
||||
_LOGGER.error(
|
||||
"Input sensor {} not found.".format(user_input[CONF_INPUT_SENSOR])
|
||||
)
|
||||
self._errors["base"] = "sensornotfound"
|
||||
except OperationNotFound:
|
||||
_LOGGER.error(
|
||||
"Specified operation {} not valid.".format(
|
||||
user_input[CONF_OPERATION]
|
||||
),
|
||||
)
|
||||
self._errors["base"] = "operationnotfound"
|
||||
except IntervalNotValid:
|
||||
_LOGGER.error(
|
||||
"Specified interval {} not valid.".format(
|
||||
user_input[CONF_INTERVAL]
|
||||
),
|
||||
)
|
||||
self._errors["base"] = "intervalnotvalid"
|
||||
|
||||
return await self._show_config_form(user_input)
|
||||
return await self._show_config_form(user_input)
|
||||
|
||||
async def _show_config_form(self, user_input):
|
||||
"""Show the configuration form to edit info."""
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_INPUT_SENSOR, default=self._input_sensor): str,
|
||||
vol.Required(CONF_OPERATION, default=self._operation): vol.In(
|
||||
VALID_OPERATIONS
|
||||
),
|
||||
vol.Required(
|
||||
CONF_UNIT_OF_MEASUREMENT, default=self._unit_of_measurement
|
||||
): str,
|
||||
vol.Required(CONF_INTERVAL, default=self._interval): int,
|
||||
vol.Required(CONF_AUTO_RESET, default=self._auto_reset): bool,
|
||||
}
|
||||
),
|
||||
errors=self._errors,
|
||||
)
|
||||
0
custom_components/daily/requirements.txt
Normal file
0
custom_components/daily/requirements.txt
Normal file
186
custom_components/daily/sensor.py
Normal file
186
custom_components/daily/sensor.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Sensor platform for Daily Sensor."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from statistics import StatisticsError, median, stdev, variance
|
||||
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import Event, callback
|
||||
|
||||
from .const import ( # pylint: disable=unused-import
|
||||
ATTR_DATETIME_OF_OCCURRENCE,
|
||||
CONF_AUTO_RESET,
|
||||
CONF_INPUT_SENSOR,
|
||||
CONF_INTERVAL,
|
||||
CONF_MAX,
|
||||
CONF_MEAN,
|
||||
CONF_MEDIAN,
|
||||
CONF_MIN,
|
||||
CONF_OPERATION,
|
||||
CONF_STDEV,
|
||||
CONF_SUM,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_VARIANCE,
|
||||
COORDINATOR,
|
||||
DOMAIN,
|
||||
EVENT_RESET,
|
||||
EVENT_UPDATE,
|
||||
ICON,
|
||||
)
|
||||
from .entity import DailySensorEntity
|
||||
|
||||
# from homeassistant.helpers import entity_registry as er
|
||||
from .helpers import parse_sensor_state, convert_to_float
|
||||
import contextlib
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_devices):
|
||||
"""Set up the platform and add to HA."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR]
|
||||
|
||||
async_add_devices([DailySensor(hass, coordinator, entry)])
|
||||
|
||||
|
||||
class DailySensor(DailySensorEntity):
|
||||
"""DailySensor class."""
|
||||
|
||||
def __init__(self, hass, coordinator, entity):
|
||||
"""Init for DailySensor."""
|
||||
super(DailySensor, self).__init__(coordinator, entity)
|
||||
self._state = None
|
||||
self._values = []
|
||||
self._occurrence = None
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Complete the initialization."""
|
||||
await super().async_added_to_hass()
|
||||
# register this sensor in the coordinator
|
||||
self.coordinator.register_entity(self.name, self.entity_id)
|
||||
|
||||
# listen to the update event and reset event
|
||||
event_to_listen = f"{self.coordinator.name}_{EVENT_RESET}"
|
||||
self.hass.bus.async_listen(
|
||||
event_to_listen,
|
||||
lambda event: self._handle_reset( # pylint: disable=unnecessary-lambda
|
||||
event
|
||||
),
|
||||
)
|
||||
event_to_listen_2 = f"{self.coordinator.name}_{EVENT_UPDATE}"
|
||||
self.hass.bus.async_listen(
|
||||
event_to_listen_2,
|
||||
lambda event: self._handle_update( # pylint: disable=unnecessary-lambda
|
||||
event
|
||||
),
|
||||
)
|
||||
|
||||
state = await self.async_get_last_state()
|
||||
self._state = parse_sensor_state(state)
|
||||
|
||||
@callback
|
||||
def _handle_reset(self, event: Event):
|
||||
"""Receive the reset event."""
|
||||
# reset the sensor
|
||||
self._state = None
|
||||
self._occurrence = None
|
||||
self._values = []
|
||||
self.hass.add_job(self.async_write_ha_state)
|
||||
|
||||
@callback
|
||||
def _handle_update(self, event: Event):
|
||||
"""Receive the update event."""
|
||||
# update the sensor
|
||||
input_state = self.hass.states.get(self.coordinator.input_sensor)
|
||||
state_minmax_changed = False
|
||||
try:
|
||||
if input_state not in (None, STATE_UNKNOWN, STATE_UNAVAILABLE):
|
||||
input_state = parse_sensor_state(input_state)
|
||||
the_val = convert_to_float(input_state)
|
||||
if self._state not in (None, STATE_UNKNOWN, STATE_UNAVAILABLE):
|
||||
self._state = convert_to_float(self._state)
|
||||
# apply the operation and update self._state
|
||||
if self.coordinator.operation == CONF_SUM:
|
||||
if self._state in (None, STATE_UNKNOWN, STATE_UNAVAILABLE):
|
||||
self._state = the_val
|
||||
else:
|
||||
self._state = self._state + the_val
|
||||
elif self.coordinator.operation == CONF_MAX:
|
||||
if (
|
||||
self._state in (None, STATE_UNKNOWN, STATE_UNAVAILABLE)
|
||||
or the_val > self._state
|
||||
):
|
||||
self._state = the_val
|
||||
state_minmax_changed = True
|
||||
elif self.coordinator.operation == CONF_MIN:
|
||||
if (
|
||||
self._state in (None, STATE_UNKNOWN, STATE_UNAVAILABLE)
|
||||
or the_val < self._state
|
||||
):
|
||||
self._state = the_val
|
||||
state_minmax_changed = True
|
||||
elif self.coordinator.operation == CONF_MEAN:
|
||||
self._values.append(the_val)
|
||||
self._state = round(
|
||||
(sum(self._values) * 1.0) / len(self._values), 1
|
||||
)
|
||||
elif self.coordinator.operation == CONF_MEDIAN:
|
||||
self._values.append(the_val)
|
||||
self._state = median(self._values)
|
||||
elif self.coordinator.operation == CONF_STDEV:
|
||||
self._values.append(the_val)
|
||||
self._state = stdev(self._values)
|
||||
elif self.coordinator.operation == CONF_VARIANCE:
|
||||
self._values.append(the_val)
|
||||
with contextlib.suppress(StatisticsError):
|
||||
self._state = variance(self._values)
|
||||
if state_minmax_changed:
|
||||
self._occurrence = datetime.now()
|
||||
self.hass.add_job(self.async_write_ha_state)
|
||||
else:
|
||||
# sensor is unknown at startup, state which comes after is considered as initial state
|
||||
_LOGGER.debug(
|
||||
"Initial state for {} is {}".format(
|
||||
self.coordinator.input_sensor, input_state
|
||||
)
|
||||
)
|
||||
return
|
||||
except ValueError:
|
||||
_LOGGER.error(
|
||||
"unable to convert to float. Please check the source sensor ({}) is available.".format(
|
||||
self.coordinator.input_sensor
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return f"{self.coordinator.name}"
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the sensor."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
"""Return the unit of measurement for the sensor."""
|
||||
return self.coordinator.unit_of_measurement
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
CONF_INPUT_SENSOR: self.coordinator.input_sensor,
|
||||
CONF_OPERATION: self.coordinator.operation,
|
||||
CONF_INTERVAL: self.coordinator.interval,
|
||||
CONF_UNIT_OF_MEASUREMENT: self.unit_of_measurement,
|
||||
CONF_AUTO_RESET: self.coordinator.auto_reset,
|
||||
ATTR_DATETIME_OF_OCCURRENCE: self._occurrence,
|
||||
}
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon of the sensor."""
|
||||
return ICON
|
||||
2
custom_components/daily/services.yaml
Normal file
2
custom_components/daily/services.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
reset:
|
||||
update:
|
||||
27
custom_components/daily/strings.json
Normal file
27
custom_components/daily/strings.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "Daily Sensor",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Daily Sensor",
|
||||
"description": "If you need help with the configuration, please see https://github.com/jeroenterheerdt/HADailySensor",
|
||||
"data": {
|
||||
"name": "Unique name of the instance",
|
||||
"sensor": "The entity that will provide input to the daily sensor",
|
||||
"operation": "The operation to be applied to the sensor",
|
||||
"unit_of_measurement": "The unit of measurement",
|
||||
"interval": "Refresh interval in seconds",
|
||||
"auto_reset": "Automatically reset at 00:00 ?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"intervalnotvalid": "Specified interval is not valid. Needs to be >0 seconds.",
|
||||
"name": "Specify an unique name for this instance.",
|
||||
"operationnotfound": "Specify a supported operation",
|
||||
"sensornotfound": "One or more of the sensors specified do not exist",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {}
|
||||
}
|
||||
}
|
||||
61
custom_components/daily/translations/da.json
Normal file
61
custom_components/daily/translations/da.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"title": "Daglig Sensor",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Daglig Sensor",
|
||||
"description": "Hvis du har brug for hjælp til konfigurationen, se venligst https://github.com/jeroenterheerdt/HADailySensor",
|
||||
"data": {
|
||||
"name": "Unikt navn på din sensor",
|
||||
"sensor": "Enheden, der vil levere input til den daglige sensor",
|
||||
"operation": "Den handling, der skal anvendes på sensoren",
|
||||
"unit_of_measurement": "Måleenheden",
|
||||
"interval": "Opdateringsinterval i sekunder",
|
||||
"auto_reset": "Nulstil automatisk kl 00:00"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"intervalnotvalid": "Det angivne interval er ikke gyldigt. Skal være >0 sekunder.",
|
||||
"name": "Angiv et unikt navn for denne sensor.",
|
||||
"operationnotfound": "Angiv en understøttet handling",
|
||||
"sensornotfound": "En eller flere af de angivne sensorer findes ikke",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Daglig Sensor",
|
||||
"description": "Hvis du har brug for hjælp til konfigurationen, se venligst https://github.com/jeroenterheerdt/HADailySensor",
|
||||
"data": {
|
||||
"name": "Unikt navn på din sensor",
|
||||
"sensor": "Enheden, der vil levere input til den daglige sensor",
|
||||
"operation": "Den handling, der skal anvendes på sensoren",
|
||||
"unit_of_measurement": "Måleenheden",
|
||||
"interval": "Opdateringsinterval i sekunder",
|
||||
"auto_reset": "Nulstil automatisk kl 00:00 ?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"intervalnotvalid": "Det angivne interval er ikke gyldigt. Skal være >0 sekunder.",
|
||||
"name": "Angiv et unikt navn for denne sensor.",
|
||||
"operationnotfound": "Angiv en understøttet handling",
|
||||
"sensornotfound": "En eller flere af de angivne sensorer findes ikke",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {}
|
||||
},
|
||||
"services": {
|
||||
"reset": {
|
||||
"name": "Nulstil",
|
||||
"description": "Nulstil værdien til 'ukendt'"
|
||||
},
|
||||
"update": {
|
||||
"name": "Opdater",
|
||||
"description": "Opdater den daglige sensor"
|
||||
}
|
||||
}
|
||||
}
|
||||
27
custom_components/daily/translations/el.json
Normal file
27
custom_components/daily/translations/el.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "Ημερήσιος αισθητήρας",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Ημερήσιος αισθητήρας",
|
||||
"description": "Εάν χρειάζεστε βοήθεια με τη διαμόρφωση, παρακαλώ ανατρέξτε στο https://github.com/jeroenterheerdt/HADailySensor",
|
||||
"data": {
|
||||
"name": "Μοναδικό όνομα της υπόστασης",
|
||||
"sensor": "Η οντότητα που θα παρέχει είσοδο στον ημερήσιο αισθητήρα",
|
||||
"operation": "Η λειτουργία που θα εφαρμοστεί στον αισθητήρα",
|
||||
"unit_of_measurement": "Η μονάδα μέτρησης",
|
||||
"interval": "Διάστημα ανανέωσης σε δευτερόλεπτα",
|
||||
"auto_reset": "Αυτόματη επαναφορά στις 00:00"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"intervalnotvalid": "Το καθορισμένο διάστημα δεν είναι έγκυρο. Πρέπει να είναι >0 δευτερόλεπτα.",
|
||||
"name": "Καθορίστε ένα μοναδικό όνομα για αυτήν την υπόσταση.",
|
||||
"operationnotfound": "Καθορίστε μια υποστηριζόμενη λειτουργία",
|
||||
"sensornotfound": "Ενας ή περισσότεροι από τους καθορισμένους αισθητήρες δεν υπάρχουν",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {}
|
||||
}
|
||||
}
|
||||
61
custom_components/daily/translations/en.json
Normal file
61
custom_components/daily/translations/en.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"title": "Daily Sensor",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Daily Sensor",
|
||||
"description": "If you need help with the configuration, please see https://github.com/jeroenterheerdt/HADailySensor",
|
||||
"data": {
|
||||
"name": "Unique name of the instance",
|
||||
"sensor": "The entity that will provide input to the daily sensor",
|
||||
"operation": "The operation to be applied to the sensor",
|
||||
"unit_of_measurement": "The unit of measurement",
|
||||
"interval": "Refresh interval in seconds",
|
||||
"auto_reset": "Automatically reset at 00:00"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"intervalnotvalid": "Specified interval is not valid. Needs to be >0 seconds.",
|
||||
"name": "Specify an unique name for this instance.",
|
||||
"operationnotfound": "Specify a supported operation",
|
||||
"sensornotfound": "One or more of the sensors specified do not exist",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Daily Sensor",
|
||||
"description": "If you need help with the configuration, please see https://github.com/jeroenterheerdt/HADailySensor",
|
||||
"data": {
|
||||
"name": "Unique name of the instance",
|
||||
"sensor": "The entity that will provide input to the daily sensor",
|
||||
"operation": "The operation to be applied to the sensor",
|
||||
"unit_of_measurement": "The unit of measurement",
|
||||
"interval": "Refresh interval in seconds",
|
||||
"auto_reset": "Automatically reset at 00:00 ?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"intervalnotvalid": "Specified interval is not valid. Needs to be >0 seconds.",
|
||||
"name": "Specify an unique name for this instance.",
|
||||
"operationnotfound": "Specify a supported operation",
|
||||
"sensornotfound": "One or more of the sensors specified do not exist",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {}
|
||||
},
|
||||
"services": {
|
||||
"reset": {
|
||||
"name": "Reset",
|
||||
"description": "Reset the value to 'unknown'"
|
||||
},
|
||||
"update": {
|
||||
"name": "Update",
|
||||
"description": "Update the daily sensor"
|
||||
}
|
||||
}
|
||||
}
|
||||
51
custom_components/daily/translations/es.json
Normal file
51
custom_components/daily/translations/es.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"title": "Daily Sensor",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Daily Sensor",
|
||||
"description": "Si necesitas ayuda con la configuración, por favor visita: https://github.com/jeroenterheerdt/HADailySensor",
|
||||
"data": {
|
||||
"name": "Nombre único de la instancia",
|
||||
"sensor": "Entidad que provee la entrada para el sensor diario",
|
||||
"operation": "Operación a aplicar al sensor",
|
||||
"unit_of_measurement": "Unidad de medida",
|
||||
"interval": "Intervalo de refresco en segundos",
|
||||
"auto_reset": "Resetear automáticamente a las 00:00"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"intervalnotvalid": "El intervalo especificado no es válido. Necesita ser >0 segundos.",
|
||||
"name": "Especifica un nombre único para esta instancia.",
|
||||
"operationnotfound": "Especifica una operación soportada.",
|
||||
"sensornotfound": "Uno o más de los sensores especificados no existe.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Daily Sensor",
|
||||
"description": "Si necesita ayuda con la configuración, consulte https://github.com/jeroenterheerdt/HADailySensor",
|
||||
"data": {
|
||||
"name": "Nombre único de la instancia",
|
||||
"sensor": "La entidad que proporcionará información al sensor diario",
|
||||
"operation": "La operación que se aplicará al sensor",
|
||||
"unit_of_measurement": "La unidad de medida",
|
||||
"interval": "Intervalo de actualización en segundos",
|
||||
"auto_reset": "Reiniciar automáticamente a las 00:00?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"intervalnotvalid": "El intervalo especificado no es válido. Debe ser >0 segundos.",
|
||||
"name": "Especifique un nombre único para esta instancia.",
|
||||
"operationnotfound": "Especificar una operación admitida",
|
||||
"sensornotfound": "Uno o más de los sensores especificados no existen",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {}
|
||||
}
|
||||
}
|
||||
27
custom_components/daily/translations/fr.json
Normal file
27
custom_components/daily/translations/fr.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "Capteur journalier",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Capteur journalier",
|
||||
"description": "Si vous avez besoin d'aide pour la configuration, référez-vous ici: https://github.com/jeroenterheerdt/HADailySensor",
|
||||
"data": {
|
||||
"name": "Nom unique de l'instance",
|
||||
"sensor": "L'entité qui fournira les données d'entrée au capteur journalier",
|
||||
"operation": "L'opération à appliquer au capteur",
|
||||
"unit_of_measurement": "L'unité de mesure",
|
||||
"interval": "L'intervalle de mise à jour en secondes",
|
||||
"auto_reset": "Reset automatique à minuit ?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"intervalnotvalid": "L'intervalle spécifié n'est pas valide: il faut un nombre >0 de secondes.",
|
||||
"name": "Spécifier un nom unique pour cette instance.",
|
||||
"operationnotfound": "Spécifier une opération supportée",
|
||||
"sensornotfound": "Au moins un des capteurs spécifiés n'existe pas",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {}
|
||||
}
|
||||
}
|
||||
27
custom_components/daily/translations/nb.json
Normal file
27
custom_components/daily/translations/nb.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "Daglig sensor",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Daglig sensorr",
|
||||
"description": "Hvis du trenger hjelp med konfigurasjonen, kan du se https://github.com/jeroenterheerdt/HADailySensor",
|
||||
"data": {
|
||||
"name": "Unikt navn på forekomsten",
|
||||
"sensor": "Enheten som vil gi innspill til den daglige sensoren",
|
||||
"operation": "Operasjonen som skal brukes på sensoren",
|
||||
"unit_of_measurement": "Måleenheten",
|
||||
"interval": "Oppdater intervall i sekunder",
|
||||
"auto_reset": "Återställs automatiskt vid midnatt?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"intervalnotvalid": "Spesifisert intervall er ikke gyldig. Må være >0 sekunder.",
|
||||
"name": "Angi et unikt navn for denne forekomsten.",
|
||||
"operationnotfound": "Spesifiser en støttet operasjon",
|
||||
"sensornotfound": "En eller flere av sensorene som er spesifisert, eksisterer ikke",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {}
|
||||
}
|
||||
}
|
||||
27
custom_components/daily/translations/nl.json
Normal file
27
custom_components/daily/translations/nl.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "Dagelijkse Sensor",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Dagelijkse Sensor",
|
||||
"description": "Voor hulp bij de configuratie, ga naar https://github.com/jeroenterheerdt/HADailySensor",
|
||||
"data": {
|
||||
"name": "Unieke naam van deze instantie",
|
||||
"sensor": "De entiteit die de input voor de dagelijkse sensor levert",
|
||||
"operation": "De operatie die op de dagelijkse sensor wordt gedaan",
|
||||
"unit_of_measurement": "De eenheid van de sensor",
|
||||
"interval": "Ververs interval in seconden",
|
||||
"auto_reset": "Automatische reset om 00:00"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"intervalnotvalid": "Het gegeven interval is niet correct. Het moet >0 seconden zijn.",
|
||||
"name": "Geef een unieke naam voor deze instantie.",
|
||||
"operationnotfound": "Kies een operatie die wordt ondersteund.",
|
||||
"sensornotfound": "Een of meer van de gegeven sensors bestaan niet.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {}
|
||||
}
|
||||
}
|
||||
27
custom_components/daily/translations/sk.json
Normal file
27
custom_components/daily/translations/sk.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "Denný snímač",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Denný snímač",
|
||||
"description": "Ak potrebujete pomoc s konfiguráciou, pozrite si https://github.com/jeroenterheerdt/HADailySensor",
|
||||
"data": {
|
||||
"name": "Jedinečný názov inštancie",
|
||||
"sensor": "Entita, ktorá bude poskytovať vstup pre denný senzor",
|
||||
"operation": "Operácia, ktorá sa má použiť na snímač",
|
||||
"unit_of_measurement": "Jednotka merania",
|
||||
"interval": "Interval obnovovania v sekund",
|
||||
"auto_reset": "Automaticky resetovať o 00:00"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"intervalnotvalid": "Zadaný interval nie je platný. Musí byť > 0 sekund.",
|
||||
"name": "Zadajte jedinečný názov pre túto inštanciu.",
|
||||
"operationnotfound": "Zadajte podporovanú operáciu",
|
||||
"sensornotfound": "Jeden alebo viacero špecifikovaných snímačov neexistuje",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {}
|
||||
}
|
||||
}
|
||||
61
custom_components/daily/translations/sl.json
Normal file
61
custom_components/daily/translations/sl.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"title": "Dnevni senzor",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Dnevni senzor",
|
||||
"description": "If you need help with the configuration, please see https://github.com/jeroenterheerdt/HADailySensor",
|
||||
"data": {
|
||||
"name": "Unikatno ime senzorja",
|
||||
"sensor": "Entiteta, ki bo uporabljena za izračun dnevnega senzorja",
|
||||
"operation": "Operacija, ki se bo izvedla",
|
||||
"unit_of_measurement": "Enota meritve",
|
||||
"interval": "Interval osvežitve v sekundah",
|
||||
"auto_reset": "Samodejno ponastavi ob 00:00"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"intervalnotvalid": "Navedeni interval ni veljaven, mora biti >0 sekund.",
|
||||
"name": "Vpišite unikatno ime za novi senzor.",
|
||||
"operationnotfound": "Izberite podprto operacijo",
|
||||
"sensornotfound": "Eden ali več navedenih senzorjev ne obstaja",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Dnevni senzor",
|
||||
"description": "Če potrebujete pomoč pri konfiguraciji obiščite https://github.com/jeroenterheerdt/HADailySensor",
|
||||
"data": {
|
||||
"name": "Unikatno ime senzorja",
|
||||
"sensor": "Entiteta, ki bo uporabljena za izračun dnevnega senzorja",
|
||||
"operation": "Operacija, ki se bo izvedla",
|
||||
"unit_of_measurement": "Enota meritve",
|
||||
"interval": "Interval osvežitve v sekundah",
|
||||
"auto_reset": "Samodejno ponastavi ob 00:00"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"intervalnotvalid": "Navedeni interval ni veljaven, mora biti >0 sekund.",
|
||||
"name": "Vpišite unikatno ime za novi senzor.",
|
||||
"operationnotfound": "Izberite podprto operacijo",
|
||||
"sensornotfound": "Eden ali več navedenih senzorjev ne obstaja",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {}
|
||||
},
|
||||
"services": {
|
||||
"reset": {
|
||||
"name": "Resetiraj",
|
||||
"description": "Resetiraj vrednost na 'neznano'"
|
||||
},
|
||||
"update": {
|
||||
"name": "Posodobi",
|
||||
"description": "Posodobi dnevni senzor"
|
||||
}
|
||||
}
|
||||
}
|
||||
49
custom_components/dreame_vacuum/__init__.py
Normal file
49
custom_components/dreame_vacuum/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""The Dreame Vacuum component."""
|
||||
from __future__ import annotations
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from .const import DOMAIN
|
||||
from .coordinator import DreameVacuumDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = (
|
||||
Platform.VACUUM,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.BUTTON,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.CAMERA,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Dreame Vacuum from a config entry."""
|
||||
coordinator = DreameVacuumDataUpdateCoordinator(hass, entry=entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
# Set up all platforms for this device/entry.
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload Dreame Vacuum config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
coordinator: DreameVacuumDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator.device.listen(None)
|
||||
coordinator.device.disconnect()
|
||||
del coordinator.device
|
||||
coordinator._device = None
|
||||
del hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
217
custom_components/dreame_vacuum/button.py
Normal file
217
custom_components/dreame_vacuum/button.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""Support for Dreame Vacuum buttons."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable
|
||||
|
||||
from homeassistant.components.button import (
|
||||
ButtonEntity,
|
||||
ButtonEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
from .coordinator import DreameVacuumDataUpdateCoordinator
|
||||
from .entity import DreameVacuumEntity, DreameVacuumEntityDescription
|
||||
from .dreame import DreameVacuumAction
|
||||
|
||||
|
||||
@dataclass
|
||||
class DreameVacuumButtonEntityDescription(
|
||||
DreameVacuumEntityDescription, ButtonEntityDescription
|
||||
):
|
||||
"""Describes Dreame Vacuum Button entity."""
|
||||
|
||||
parameters_fn: Callable[[object], Any] = None
|
||||
action_fn: Callable[[object]] = None
|
||||
|
||||
|
||||
BUTTONS: tuple[ButtonEntityDescription, ...] = (
|
||||
DreameVacuumButtonEntityDescription(
|
||||
action_key=DreameVacuumAction.RESET_MAIN_BRUSH,
|
||||
icon="mdi:car-turbocharger",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
exists_fn=lambda description, device: bool(
|
||||
DreameVacuumEntityDescription().exists_fn(description, device)
|
||||
and device.status.main_brush_life is not None
|
||||
),
|
||||
),
|
||||
DreameVacuumButtonEntityDescription(
|
||||
action_key=DreameVacuumAction.RESET_SIDE_BRUSH,
|
||||
icon="mdi:pinwheel-outline",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
exists_fn=lambda description, device: bool(
|
||||
DreameVacuumEntityDescription().exists_fn(description, device)
|
||||
and device.status.side_brush_life is not None
|
||||
),
|
||||
),
|
||||
DreameVacuumButtonEntityDescription(
|
||||
action_key=DreameVacuumAction.RESET_FILTER,
|
||||
icon="mdi:air-filter",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
exists_fn=lambda description, device: bool(
|
||||
DreameVacuumEntityDescription().exists_fn(description, device)
|
||||
and device.status.filter_life is not None
|
||||
),
|
||||
),
|
||||
DreameVacuumButtonEntityDescription(
|
||||
action_key=DreameVacuumAction.RESET_SENSOR,
|
||||
icon="mdi:radar",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
exists_fn=lambda description, device: bool(
|
||||
DreameVacuumEntityDescription().exists_fn(description, device)
|
||||
and device.status.sensor_dirty_life is not None
|
||||
),
|
||||
),
|
||||
DreameVacuumButtonEntityDescription(
|
||||
action_key=DreameVacuumAction.RESET_MOP_PAD,
|
||||
icon="mdi:hydro-power",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
exists_fn=lambda description, device: bool(
|
||||
DreameVacuumEntityDescription().exists_fn(description, device)
|
||||
and device.status.mop_life is not None
|
||||
),
|
||||
),
|
||||
DreameVacuumButtonEntityDescription(
|
||||
action_key=DreameVacuumAction.RESET_SILVER_ION,
|
||||
icon="mdi:shimmer",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
exists_fn=lambda description, device: bool(
|
||||
DreameVacuumEntityDescription().exists_fn(description, device)
|
||||
and device.status.silver_ion_life is not None
|
||||
),
|
||||
),
|
||||
DreameVacuumButtonEntityDescription(
|
||||
action_key=DreameVacuumAction.RESET_DETERGENT,
|
||||
icon="mdi:chart-bubble",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
exists_fn=lambda description, device: bool(
|
||||
DreameVacuumEntityDescription().exists_fn(description, device)
|
||||
and device.status.detergent_life is not None
|
||||
),
|
||||
),
|
||||
DreameVacuumButtonEntityDescription(
|
||||
action_key=DreameVacuumAction.START_AUTO_EMPTY,
|
||||
icon_fn=lambda value, device: "mdi:delete-off"
|
||||
if not device.status.dust_collection_available
|
||||
else "mdi:delete-restore"
|
||||
if device.status.auto_emptying
|
||||
else "mdi:delete-empty",
|
||||
exists_fn=lambda description, device: bool(
|
||||
DreameVacuumEntityDescription().exists_fn(description, device)
|
||||
and device.status.auto_empty_base_available
|
||||
),
|
||||
),
|
||||
DreameVacuumButtonEntityDescription(
|
||||
action_key=DreameVacuumAction.CLEAR_WARNING,
|
||||
icon="mdi:clipboard-check-outline",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
parameters_fn=lambda device: [device.status.error.value],
|
||||
),
|
||||
DreameVacuumButtonEntityDescription(
|
||||
key="start_fast_mapping",
|
||||
icon="mdi:map-plus",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
available_fn=lambda device: device.status.mapping_available,
|
||||
action_fn=lambda device: device.start_fast_mapping(),
|
||||
exists_fn=lambda description, device: device.status.lidar_navigation,
|
||||
),
|
||||
DreameVacuumButtonEntityDescription(
|
||||
key="start_mapping",
|
||||
icon="mdi:broom",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
available_fn=lambda device: device.status.mapping_available,
|
||||
action_fn=lambda device: device.start_mapping(),
|
||||
entity_registry_enabled_default=False,
|
||||
exists_fn=lambda description, device: device.status.lidar_navigation,
|
||||
),
|
||||
DreameVacuumButtonEntityDescription(
|
||||
name="Self-Clean",
|
||||
key="self_clean",
|
||||
icon="mdi:washing-machine",
|
||||
available_fn=lambda device: bool(
|
||||
device.status.washing_available or device.status.returning_to_wash_paused or device.status.washing_paused),
|
||||
action_fn=lambda device: device.start_washing(),
|
||||
exists_fn=lambda description, device: device.status.self_wash_base_available,
|
||||
),
|
||||
DreameVacuumButtonEntityDescription(
|
||||
name="Self-Clean Pause",
|
||||
key="self_clean_pause",
|
||||
icon="mdi:washing-machine-off",
|
||||
available_fn=lambda device: device.status.washing,
|
||||
action_fn=lambda device: device.pause_washing(),
|
||||
exists_fn=lambda description, device: device.status.self_wash_base_available,
|
||||
),
|
||||
DreameVacuumButtonEntityDescription(
|
||||
key="start_drying",
|
||||
icon="mdi:weather-sunny",
|
||||
available_fn=lambda device: bool(
|
||||
device.status.drying_available and not device.status.drying),
|
||||
action_fn=lambda device: device.start_drying(),
|
||||
exists_fn=lambda description, device: device.status.self_wash_base_available,
|
||||
),
|
||||
DreameVacuumButtonEntityDescription(
|
||||
key="stop_drying",
|
||||
icon="mdi:weather-sunny-off",
|
||||
available_fn=lambda device: bool(
|
||||
device.status.drying_available and device.status.drying),
|
||||
action_fn=lambda device: device.stop_drying(),
|
||||
exists_fn=lambda description, device: device.status.self_wash_base_available,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Dreame Vacuum Button based on a config entry."""
|
||||
coordinator: DreameVacuumDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
DreameVacuumButtonEntity(coordinator, description)
|
||||
for description in BUTTONS
|
||||
if description.exists_fn(description, coordinator.device)
|
||||
)
|
||||
|
||||
|
||||
class DreameVacuumButtonEntity(DreameVacuumEntity, ButtonEntity):
|
||||
"""Defines a Dreame Vacuum Button entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DreameVacuumDataUpdateCoordinator,
|
||||
description: DreameVacuumButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Dreame Vacuum Button entity."""
|
||||
super().__init__(coordinator, description)
|
||||
|
||||
async def async_press(self, **kwargs: Any) -> None:
|
||||
"""Press the button."""
|
||||
if not self.available:
|
||||
raise HomeAssistantError("Entity unavailable")
|
||||
|
||||
parameters = None
|
||||
if self.entity_description.parameters_fn is not None:
|
||||
parameters = self.entity_description.parameters_fn(self.device)
|
||||
|
||||
if self.entity_description.action_key is not None:
|
||||
await self._try_command(
|
||||
"Unable to call %s",
|
||||
self.device.call_action,
|
||||
self.entity_description.action_key,
|
||||
parameters,
|
||||
)
|
||||
elif self.entity_description.action_fn is not None:
|
||||
await self._try_command(
|
||||
"Unable to call %s",
|
||||
self.entity_description.action_fn,
|
||||
self.device,
|
||||
)
|
||||
348
custom_components/dreame_vacuum/camera.py
Normal file
348
custom_components/dreame_vacuum/camera.py
Normal file
@@ -0,0 +1,348 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import time
|
||||
import asyncio
|
||||
from typing import Any, Dict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from aiohttp import web
|
||||
|
||||
from homeassistant.components.camera import Camera, CameraEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_UNAVAILABLE, CONTENT_TYPE_MULTIPART
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers import entity_registry
|
||||
|
||||
from .const import DOMAIN, CONF_COLOR_SCHEME, CONF_ICON_SET, CONF_MAP_OBJECTS, MAP_OBJECTS, ATTR_CALIBRATION, CONTENT_TYPE, LOGGER
|
||||
|
||||
from .coordinator import DreameVacuumDataUpdateCoordinator
|
||||
from .entity import DreameVacuumEntity, DreameVacuumEntityDescription
|
||||
from .dreame.map import DreameVacuumMapRenderer, DreameVacuumMapDataRenderer
|
||||
|
||||
@dataclass
|
||||
class DreameVacuumCameraEntityDescription(
|
||||
DreameVacuumEntityDescription, CameraEntityDescription
|
||||
):
|
||||
"""Describes Dreame Vacuum Camera entity."""
|
||||
map_data_json: bool = False
|
||||
|
||||
|
||||
CAMERAS: tuple[CameraEntityDescription, ...] = (
|
||||
DreameVacuumCameraEntityDescription(
|
||||
key="map", icon="mdi:map"
|
||||
),
|
||||
DreameVacuumCameraEntityDescription(
|
||||
key="map_data",
|
||||
icon="mdi:map",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
map_data_json=True,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Dreame Vacuum Camera based on a config entry."""
|
||||
coordinator: DreameVacuumDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
color_scheme = entry.options.get(CONF_COLOR_SCHEME)
|
||||
icon_set = entry.options.get(CONF_ICON_SET)
|
||||
map_objects = entry.options.get(CONF_MAP_OBJECTS, MAP_OBJECTS.keys())
|
||||
if coordinator.device.status.map_available:
|
||||
async_add_entities(
|
||||
DreameVacuumCameraEntity(coordinator, description, color_scheme, icon_set, map_objects)
|
||||
for description in CAMERAS
|
||||
)
|
||||
|
||||
update_map_cameras = partial(
|
||||
async_update_map_cameras, coordinator, {}, async_add_entities, color_scheme, icon_set, map_objects
|
||||
)
|
||||
coordinator.async_add_listener(update_map_cameras)
|
||||
update_map_cameras()
|
||||
|
||||
|
||||
@callback
|
||||
def async_update_map_cameras(
|
||||
coordinator: DreameVacuumDataUpdateCoordinator,
|
||||
current: dict[str, list[DreameVacuumCameraEntity]],
|
||||
async_add_entities,
|
||||
color_scheme: str,
|
||||
icon_set: str,
|
||||
map_objects: list[str],
|
||||
) -> None:
|
||||
new_indexes = set(
|
||||
[k for k in range(1, len(coordinator.device.status.map_list) + 1)])
|
||||
current_ids = set(current)
|
||||
new_entities = []
|
||||
|
||||
for map_index in current_ids - new_indexes:
|
||||
async_remove_map_cameras(map_index, coordinator, current)
|
||||
|
||||
for map_index in new_indexes - current_ids:
|
||||
current[map_index] = [
|
||||
DreameVacuumCameraEntity(
|
||||
coordinator,
|
||||
DreameVacuumCameraEntityDescription(
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
icon="mdi:map-search",
|
||||
),
|
||||
color_scheme,
|
||||
icon_set,
|
||||
map_objects,
|
||||
map_index,
|
||||
)
|
||||
]
|
||||
new_entities = new_entities + current[map_index]
|
||||
|
||||
if new_entities:
|
||||
async_add_entities(new_entities)
|
||||
|
||||
|
||||
def async_remove_map_cameras(
|
||||
map_index: str,
|
||||
coordinator: DreameVacuumDataUpdateCoordinator,
|
||||
current: dict[str, DreameVacuumCameraEntity],
|
||||
) -> None:
|
||||
registry = entity_registry.async_get(coordinator.hass)
|
||||
entities = current[map_index]
|
||||
for entity in entities:
|
||||
if entity.entity_id in registry.entities:
|
||||
registry.async_remove(entity.entity_id)
|
||||
del current[map_index]
|
||||
|
||||
|
||||
class DreameVacuumCameraEntity(DreameVacuumEntity, Camera):
|
||||
"""Defines a Dreame Vacuum Camera entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DreameVacuumDataUpdateCoordinator,
|
||||
description: DreameVacuumCameraEntityDescription,
|
||||
color_scheme: str = None,
|
||||
icon_set: str = None,
|
||||
map_objects: list[str] = None,
|
||||
map_index: int = 0,
|
||||
) -> None:
|
||||
"""Initialize a Dreame Vacuum Camera entity."""
|
||||
super().__init__(coordinator, description)
|
||||
self.content_type = CONTENT_TYPE
|
||||
self.stream = None
|
||||
self.access_tokens = collections.deque([], 2)
|
||||
self.async_update_token()
|
||||
self._rtsp_to_webrtc = False
|
||||
self._should_poll = True
|
||||
self._last_updated = -1
|
||||
self._frame_id = -1
|
||||
self._last_map_request = 0
|
||||
self._attr_is_streaming = True
|
||||
self._calibration_points = None
|
||||
|
||||
self._available = self.device.device_connected and self.device.cloud_connected
|
||||
if description.map_data_json:
|
||||
self._renderer = DreameVacuumMapDataRenderer()
|
||||
else:
|
||||
self._renderer = DreameVacuumMapRenderer(color_scheme, icon_set, map_objects, self.device.status.robot_shape)
|
||||
|
||||
self._image = self._renderer.default_map_image
|
||||
self._default_map = True
|
||||
self.map_index = map_index
|
||||
self._state = STATE_UNAVAILABLE
|
||||
|
||||
map_data = self._map_data
|
||||
if map_data:
|
||||
self._map_id = map_data.map_id
|
||||
|
||||
if self.map_index:
|
||||
if map_data:
|
||||
self._map_name = map_data.custom_name
|
||||
else:
|
||||
self._map_name = None
|
||||
self._set_map_name()
|
||||
self._attr_unique_id = f"{self.device.mac}_map_{self.map_index}"
|
||||
self.entity_id = f"camera.{self.device.name.lower()}_map_{self.map_index}"
|
||||
else:
|
||||
self._attr_name = f"{self.device.name} Current {description.name}"
|
||||
self._attr_unique_id = f"{self.device.mac}_map_{description.key}"
|
||||
self.entity_id = (
|
||||
f"camera.{self.device.name.lower()}_{description.key.lower()}"
|
||||
)
|
||||
|
||||
self.update()
|
||||
|
||||
def _set_map_name(self) -> None:
|
||||
name = (
|
||||
f"{self.map_index}"
|
||||
if self._map_name is None
|
||||
else f"{self._map_name.replace('_', ' ').replace('-', ' ').title()}"
|
||||
)
|
||||
self._attr_name = f"{self.device.name} Saved Map {name}"
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Fetch state from the device."""
|
||||
self._available = self.device.cloud_connected
|
||||
self._last_map_request = 0
|
||||
map_data = self._map_data
|
||||
if (
|
||||
map_data
|
||||
and self.available
|
||||
and (self.map_index > 0 or self.device.status.located)
|
||||
):
|
||||
if self.map_index > 0:
|
||||
if self._map_name != map_data.custom_name:
|
||||
self._map_name = map_data.custom_name
|
||||
self._set_map_name()
|
||||
|
||||
if self._map_id != map_data.map_id:
|
||||
self._map_id = map_data.map_id
|
||||
self._frame_id = None
|
||||
self._last_updated = None
|
||||
|
||||
if (
|
||||
self._default_map == True or
|
||||
self._frame_id != map_data.frame_id
|
||||
):
|
||||
self._frame_id = map_data.frame_id
|
||||
if not self.device.status.active:
|
||||
self.update()
|
||||
else:
|
||||
self.update()
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_camera_image(
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
if self._should_poll is True:
|
||||
self._should_poll = False
|
||||
now = time.time()
|
||||
if now - self._last_map_request >= self.frame_interval:
|
||||
self._last_map_request = now
|
||||
if self.map_index == 0:
|
||||
self.device.update_map()
|
||||
self.update()
|
||||
self._should_poll = True
|
||||
return self._image
|
||||
|
||||
async def handle_async_still_stream(
|
||||
self, request: web.Request, interval: float
|
||||
) -> web.StreamResponse:
|
||||
"""Generate an HTTP MJPEG stream from camera images."""
|
||||
response = web.StreamResponse()
|
||||
response.content_type = CONTENT_TYPE_MULTIPART.format(
|
||||
"--frameboundary")
|
||||
await response.prepare(request)
|
||||
|
||||
last_image = None
|
||||
while True:
|
||||
img_bytes = await self.async_camera_image()
|
||||
if not img_bytes:
|
||||
img_bytes = self._default_map_image
|
||||
|
||||
if img_bytes != last_image:
|
||||
# Always write twice, otherwise chrome ignores last frame and displays previous frame after second one
|
||||
for k in range(2):
|
||||
await response.write(
|
||||
bytes(
|
||||
"--frameboundary\r\n"
|
||||
"Content-Type: {}\r\n"
|
||||
"Content-Length: {}\r\n\r\n".format(
|
||||
self.content_type, len(img_bytes)),
|
||||
"utf-8",
|
||||
)
|
||||
+ img_bytes
|
||||
+ b"\r\n"
|
||||
)
|
||||
last_image = img_bytes
|
||||
await asyncio.sleep(interval)
|
||||
return response
|
||||
|
||||
def update(self) -> None:
|
||||
map_data = self._map_data
|
||||
if (
|
||||
map_data
|
||||
and self.available
|
||||
and (self.map_index > 0 or self.device.status.located)
|
||||
):
|
||||
if self.map_index == 0 and not self.entity_description.map_data_json and map_data.last_updated != self._last_updated and not self._renderer.render_complete:
|
||||
LOGGER.warning("Waiting render complete")
|
||||
|
||||
if (
|
||||
self._renderer.render_complete
|
||||
and map_data.last_updated != self._last_updated
|
||||
):
|
||||
if self.map_index == 0 and not self.entity_description.map_data_json:
|
||||
LOGGER.debug("Update map")
|
||||
|
||||
self._last_updated = map_data.last_updated
|
||||
self._frame_id = map_data.frame_id
|
||||
self._default_map = False
|
||||
if map_data.timestamp_ms and not map_data.saved_map:
|
||||
self._state = datetime.fromtimestamp(
|
||||
int(map_data.timestamp_ms / 1000)
|
||||
)
|
||||
elif map_data.last_updated:
|
||||
self._state = datetime.fromtimestamp(
|
||||
int(map_data.last_updated))
|
||||
|
||||
self.coordinator.hass.async_create_task(self._update_image(
|
||||
self.device.get_map_for_render(self.map_index), self.device.status.robot_status))
|
||||
elif not self._default_map:
|
||||
self._image = self._default_map_image
|
||||
self._default_map = True
|
||||
self._frame_id = -1
|
||||
self._last_updated = -1
|
||||
self._state = STATE_UNAVAILABLE
|
||||
|
||||
async def _update_image(self, map_data, robot_status) -> None:
|
||||
self._image = self._renderer.render_map(map_data, robot_status)
|
||||
if not self.entity_description.map_data_json and self._calibration_points != self._renderer.calibration_points:
|
||||
self._calibration_points = self._renderer.calibration_points
|
||||
self.coordinator.set_updated_data()
|
||||
|
||||
@property
|
||||
def _map_data(self) -> Any:
|
||||
return self.device.get_map(self.map_index)
|
||||
|
||||
@property
|
||||
def _default_map_image(self) -> Any:
|
||||
if self._image and (not self.device.device_connected or not self.device.cloud_connected):
|
||||
return self._renderer.disconnected_map_image
|
||||
return self._renderer.default_map_image
|
||||
|
||||
@property
|
||||
def frame_interval(self) -> float:
|
||||
return 0.25
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
"""Return the status of the map."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Dict[str, Any]:
|
||||
if not self.entity_description.map_data_json:
|
||||
map_data = self._map_data
|
||||
if (
|
||||
map_data
|
||||
and not map_data.empty_map
|
||||
and (self.map_index > 0 or self.device.status.located)
|
||||
):
|
||||
attributes = map_data.as_dict()
|
||||
if attributes:
|
||||
attributes[ATTR_CALIBRATION] = self._calibration_points if self._calibration_points else self._renderer.calibration_points
|
||||
return attributes
|
||||
elif self.available:
|
||||
return {ATTR_CALIBRATION: self._renderer.default_calibration_points}
|
||||
475
custom_components/dreame_vacuum/config_flow.py
Normal file
475
custom_components/dreame_vacuum/config_flow.py
Normal file
@@ -0,0 +1,475 @@
|
||||
"""Config flow for Dremae Vacuum."""
|
||||
from __future__ import annotations
|
||||
from typing import Any, Final
|
||||
import voluptuous as vol
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from collections.abc import Mapping
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_HOST,
|
||||
CONF_TOKEN,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
OptionsFlow,
|
||||
)
|
||||
|
||||
from .dreame import DreameVacuumProtocol, MAP_COLOR_SCHEME_LIST, MAP_ICON_SET_LIST
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_NOTIFY,
|
||||
CONF_COLOR_SCHEME,
|
||||
CONF_ICON_SET,
|
||||
CONF_COUNTRY,
|
||||
CONF_TYPE,
|
||||
CONF_MAC,
|
||||
CONF_MAP_OBJECTS,
|
||||
CONF_PREFER_CLOUD,
|
||||
NOTIFICATION,
|
||||
MAP_OBJECTS,
|
||||
NOTIFICATION_ID_2FA_LOGIN,
|
||||
NOTIFICATION_2FA_LOGIN,
|
||||
)
|
||||
|
||||
DREAME_MODELS = [
|
||||
"dreame.vacuum.r2205",
|
||||
"dreame.vacuum.r2243",
|
||||
"dreame.vacuum.r2240",
|
||||
"dreame.vacuum.r2250",
|
||||
"dreame.vacuum.p2009",
|
||||
"dreame.vacuum.r2312",
|
||||
"dreame.vacuum.p2259",
|
||||
"dreame.vacuum.r2312a",
|
||||
"dreame.vacuum.r2322",
|
||||
"dreame.vacuum.p2187",
|
||||
"dreame.vacuum.r2328",
|
||||
"dreame.vacuum.p2028a",
|
||||
#"dreame.vacuum.r2251a", Map private key missing
|
||||
"dreame.vacuum.p2029",
|
||||
"dreame.vacuum.r2257o",
|
||||
"dreame.vacuum.r2215o",
|
||||
"dreame.vacuum.r2216o",
|
||||
"dreame.vacuum.r2228o",
|
||||
"dreame.vacuum.r2228",
|
||||
"dreame.vacuum.r2246",
|
||||
"dreame.vacuum.r2233",
|
||||
"dreame.vacuum.r2247",
|
||||
"dreame.vacuum.r2211o",
|
||||
"dreame.vacuum.r2316",
|
||||
"dreame.vacuum.r2316p",
|
||||
"dreame.vacuum.r2313",
|
||||
"dreame.vacuum.r2355",
|
||||
"dreame.vacuum.r2332",
|
||||
"dreame.vacuum.p2027",
|
||||
"dreame.vacuum.r2104",
|
||||
"dreame.vacuum.r2251o",
|
||||
"dreame.vacuum.r2232a",
|
||||
"dreame.vacuum.r2317",
|
||||
"dreame.vacuum.r2345a",
|
||||
"dreame.vacuum.r2345h",
|
||||
"dreame.vacuum.r2215",
|
||||
"dreame.vacuum.r2235",
|
||||
"dreame.vacuum.r2263",
|
||||
"dreame.vacuum.r2253",
|
||||
"dreame.vacuum.p2028",
|
||||
"dreame.vacuum.p2157",
|
||||
"dreame.vacuum.p2156o",
|
||||
]
|
||||
|
||||
MIJIA_MODELS = [
|
||||
"dreame.vacuum.p2041",
|
||||
"dreame.vacuum.p2036",
|
||||
"dreame.vacuum.p2140",
|
||||
"dreame.vacuum.p2140a",
|
||||
"dreame.vacuum.p2114a",
|
||||
"dreame.vacuum.p2114o",
|
||||
#"dreame.vacuum.r2210", Map private key missing
|
||||
"dreame.vacuum.p2149o",
|
||||
"dreame.vacuum.p2150a",
|
||||
"dreame.vacuum.p2150b",
|
||||
"dreame.vacuum.p2150o",
|
||||
"dreame.vacuum.r2209",
|
||||
"dreame.vacuum.p2008",
|
||||
"dreame.vacuum.p2148o",
|
||||
"dreame.vacuum.p2140o",
|
||||
"dreame.vacuum.r2254",
|
||||
"dreame.vacuum.p2140p",
|
||||
"dreame.vacuum.p2140q",
|
||||
"dreame.vacuum.p2041o",
|
||||
]
|
||||
|
||||
WITH_MAP: Final = "With map (Automatic)"
|
||||
WITHOUT_MAP: Final = "Without map (Manual)"
|
||||
|
||||
|
||||
class DreameVacuumOptionsFlowHandler(OptionsFlow):
|
||||
"""Handle Dreame Vacuum options."""
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize Dreame Vacuum options flow."""
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage Dreame Vacuum options."""
|
||||
errors = {}
|
||||
data = self.config_entry.data
|
||||
options = self.config_entry.options
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data={**options, **user_input})
|
||||
|
||||
notify = options[CONF_NOTIFY]
|
||||
if isinstance(notify, bool):
|
||||
if notify is True:
|
||||
notify = list(NOTIFICATION.keys())
|
||||
else:
|
||||
notify = []
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{vol.Required(CONF_NOTIFY, default=notify): cv.multi_select(NOTIFICATION)}
|
||||
)
|
||||
if data[CONF_USERNAME]:
|
||||
data_schema = data_schema.extend(
|
||||
{
|
||||
vol.Required(CONF_COLOR_SCHEME, default=options[CONF_COLOR_SCHEME]): vol.In(list(MAP_COLOR_SCHEME_LIST.keys())),
|
||||
vol.Required(CONF_ICON_SET, default=options.get(CONF_ICON_SET, next(iter(MAP_ICON_SET_LIST)))): vol.In(list(MAP_ICON_SET_LIST.keys())),
|
||||
vol.Required(CONF_MAP_OBJECTS, default=options.get(CONF_MAP_OBJECTS, list(MAP_OBJECTS.keys()))): cv.multi_select(MAP_OBJECTS),
|
||||
vol.Required(CONF_PREFER_CLOUD, default=options.get(CONF_PREFER_CLOUD, False)): bool,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class DreameVacuumFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle config flow for an Dreame Vacuum device."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize."""
|
||||
self.entry: ConfigEntry | None = None
|
||||
self.mac: str | None = None
|
||||
self.model = None
|
||||
self.host: str | None = None
|
||||
self.token: str | None = None
|
||||
self.name: str | None = None
|
||||
self.username: str | None = None
|
||||
self.password: str | None = None
|
||||
self.country: str = "cn"
|
||||
self.with_map: bool = True
|
||||
self.device_id: int | None = None
|
||||
self.prefer_cloud: bool = False
|
||||
self.devices: dict[str, dict[str, Any]] = {}
|
||||
self.protocol: DreameVacuumProtocol | None = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
) -> DreameVacuumOptionsFlowHandler:
|
||||
"""Get the options flow for this handler."""
|
||||
return DreameVacuumOptionsFlowHandler(config_entry)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle a flow initialized by the user."""
|
||||
if user_input is not None:
|
||||
with_map = user_input.get(CONF_TYPE, WITH_MAP)
|
||||
self.with_map = True if with_map == WITH_MAP else False
|
||||
if self.with_map:
|
||||
return await self.async_step_with_map()
|
||||
return await self.async_step_without_map()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TYPE, default=WITH_MAP): vol.In(
|
||||
[WITH_MAP, WITHOUT_MAP]
|
||||
)
|
||||
}
|
||||
),
|
||||
errors={},
|
||||
)
|
||||
|
||||
async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult:
|
||||
"""Perform reauth upon an authentication error or missing cloud credentials."""
|
||||
self.name = user_input[CONF_NAME]
|
||||
self.host = user_input[CONF_HOST]
|
||||
self.token = user_input[CONF_TOKEN]
|
||||
self.username = user_input[CONF_USERNAME]
|
||||
self.password = user_input[CONF_PASSWORD]
|
||||
self.country = user_input[CONF_COUNTRY]
|
||||
self.prefer_cloud = user_input[CONF_PREFER_CLOUD]
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_cloud()
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
|
||||
async def async_step_connect(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Connect to a Dreame Vacuum device."""
|
||||
errors: dict[str, str] = {}
|
||||
if len(self.token) == 32:
|
||||
try:
|
||||
if self.protocol is None:
|
||||
self.protocol = DreameVacuumProtocol(self.host, self.token, self.username, self.password, self.country, self.prefer_cloud)
|
||||
else:
|
||||
self.protocol.set_credentials(self.host, self.token)
|
||||
|
||||
if self.protocol.device_cloud:
|
||||
self.protocol.device_cloud.device_id = self.device_id
|
||||
|
||||
info = await self.hass.async_add_executor_job(self.protocol.connect, 5)
|
||||
if info:
|
||||
self.mac = info["mac"]
|
||||
self.model = info["model"]
|
||||
except:
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
if self.prefer_cloud and self.username and self.password:
|
||||
return await self.async_step_with_map(errors=errors)
|
||||
else:
|
||||
if self.mac:
|
||||
await self.async_set_unique_id(format_mac(self.mac))
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={
|
||||
CONF_HOST: self.host,
|
||||
CONF_TOKEN: self.token,
|
||||
CONF_MAC: self.mac,
|
||||
}
|
||||
)
|
||||
|
||||
if self.model in DREAME_MODELS or self.model in MIJIA_MODELS:
|
||||
if self.name is None:
|
||||
self.name = self.model
|
||||
return await self.async_step_options()
|
||||
else:
|
||||
errors["base"] = "unsupported"
|
||||
else:
|
||||
errors["base"] = "wrong_token"
|
||||
|
||||
return await self.async_step_without_map(errors=errors)
|
||||
|
||||
async def async_step_without_map(
|
||||
self,
|
||||
user_input: dict[str, Any] | None = None,
|
||||
errors: dict[str, Any] | None = {},
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(user_input)
|
||||
|
||||
self.host = user_input[CONF_HOST]
|
||||
self.token = user_input[CONF_TOKEN]
|
||||
self.mac = None
|
||||
return await self.async_step_connect()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="without_map",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=self.host): str,
|
||||
vol.Required(CONF_TOKEN, default=self.token): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_with_map(
|
||||
self, user_input: dict[str, Any] | None = None, errors: dict[str, Any] | None = {}
|
||||
) -> FlowResult:
|
||||
"""Configure a dreame vacuum device through the Miio Cloud."""
|
||||
placeholders = {}
|
||||
if user_input is not None:
|
||||
username = user_input.get(CONF_USERNAME)
|
||||
password = user_input.get(CONF_PASSWORD)
|
||||
country = user_input.get(CONF_COUNTRY)
|
||||
|
||||
if username and password and country:
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.country = country
|
||||
self.prefer_cloud = user_input.get(CONF_PREFER_CLOUD, False)
|
||||
|
||||
self.protocol = DreameVacuumProtocol(username=self.username, password=self.password, country=self.country, prefer_cloud=self.prefer_cloud)
|
||||
await self.hass.async_add_executor_job(self.protocol.cloud.login)
|
||||
|
||||
if self.protocol.cloud.two_factor_url is not None:
|
||||
errors["base"] = "2fa_required"
|
||||
persistent_notification.create(
|
||||
self.hass,
|
||||
f"{NOTIFICATION_2FA_LOGIN}[{self.protocol.cloud.two_factor_url}]({self.protocol.cloud.two_factor_url})",
|
||||
f'Login to Dreame Vacuum: {self.username}',
|
||||
f'{DOMAIN}_{NOTIFICATION_ID_2FA_LOGIN}',
|
||||
)
|
||||
placeholders = {'url': self.protocol.cloud.two_factor_url }
|
||||
elif self.protocol.cloud.logged_in is False:
|
||||
errors["base"] = "login_error"
|
||||
elif self.protocol.cloud.logged_in:
|
||||
persistent_notification.dismiss(self.hass, f'{DOMAIN}_{NOTIFICATION_ID_2FA_LOGIN}')
|
||||
|
||||
devices = await self.hass.async_add_executor_job(
|
||||
self.protocol.cloud.get_devices
|
||||
)
|
||||
if devices:
|
||||
found = list(
|
||||
filter(
|
||||
lambda d: not d.get("parent_id")
|
||||
and (str(d["model"]) in DREAME_MODELS or str(d["model"]) in MIJIA_MODELS),
|
||||
devices["result"]["list"],
|
||||
)
|
||||
)
|
||||
|
||||
self.devices = {}
|
||||
for device in found:
|
||||
name = device["name"]
|
||||
model = device["model"]
|
||||
list_name = f"{name} - {model}"
|
||||
self.devices[list_name] = device
|
||||
|
||||
if self.host is not None:
|
||||
for device in self.devices.values():
|
||||
host = device.get("localip")
|
||||
if host == self.host:
|
||||
self.extract_info(device)
|
||||
return await self.async_step_connect()
|
||||
|
||||
if self.devices:
|
||||
if len(self.devices) == 1:
|
||||
self.extract_info(
|
||||
list(self.devices.values())[0])
|
||||
return await self.async_step_connect()
|
||||
return await self.async_step_devices()
|
||||
|
||||
errors["base"] = "no_devices"
|
||||
else:
|
||||
errors["base"] = "credentials_incomplete"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="with_map",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME, default=self.username): str,
|
||||
vol.Required(CONF_PASSWORD, default=self.password): str,
|
||||
vol.Required(CONF_COUNTRY, default=self.country): vol.In(
|
||||
["cn", "de", "us", "ru", "tw", "sg", "in", "i2"]
|
||||
),
|
||||
vol.Required(CONF_PREFER_CLOUD, default=self.prefer_cloud): bool,
|
||||
}
|
||||
),
|
||||
description_placeholders=placeholders,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_devices(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle multiple Dreame Vacuum devices found."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self.extract_info(self.devices[user_input["devices"]])
|
||||
return await self.async_step_connect()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="devices",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required("devices"): vol.In(list(self.devices))}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_options(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle Dreame Vacuum options step."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
self.name = user_input[CONF_NAME]
|
||||
|
||||
return self.async_create_entry(
|
||||
title=self.name,
|
||||
data={
|
||||
CONF_NAME: self.name,
|
||||
CONF_HOST: self.host,
|
||||
CONF_TOKEN: self.token,
|
||||
CONF_USERNAME: self.username,
|
||||
CONF_PASSWORD: self.password,
|
||||
CONF_COUNTRY: self.country,
|
||||
CONF_MAC: self.mac,
|
||||
},
|
||||
options={
|
||||
CONF_NOTIFY: user_input[CONF_NOTIFY],
|
||||
CONF_COLOR_SCHEME: user_input.get(CONF_COLOR_SCHEME),
|
||||
CONF_ICON_SET: user_input.get(CONF_ICON_SET),
|
||||
CONF_MAP_OBJECTS: user_input.get(CONF_MAP_OBJECTS),
|
||||
CONF_PREFER_CLOUD: self.prefer_cloud,
|
||||
},
|
||||
)
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME, default=self.name): str,
|
||||
vol.Required(CONF_NOTIFY, default=list(NOTIFICATION.keys())): cv.multi_select(NOTIFICATION),
|
||||
}
|
||||
)
|
||||
|
||||
if self.with_map:
|
||||
mijia = bool(self.model in MIJIA_MODELS)
|
||||
default_objects = list(MAP_OBJECTS.keys())
|
||||
if mijia:
|
||||
default_color_scheme = "Mijia Light"
|
||||
default_icon_set = "Mijia"
|
||||
default_objects.pop(1) # Room icons
|
||||
else:
|
||||
default_color_scheme = "Dreame Light"
|
||||
default_icon_set = "Dreame"
|
||||
|
||||
data_schema = data_schema.extend(
|
||||
{
|
||||
vol.Required(CONF_COLOR_SCHEME, default=default_color_scheme): vol.In(list(MAP_COLOR_SCHEME_LIST.keys())),
|
||||
vol.Required(CONF_ICON_SET, default=default_icon_set): vol.In(list(MAP_ICON_SET_LIST.keys())),
|
||||
vol.Required(CONF_MAP_OBJECTS, default=default_objects): cv.multi_select(MAP_OBJECTS),
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="options", data_schema=data_schema, errors=errors
|
||||
)
|
||||
|
||||
def extract_info(self, device_info: dict[str, Any]) -> None:
|
||||
"""Extract the device info."""
|
||||
if self.host is None:
|
||||
self.host = device_info["localip"]
|
||||
if self.mac is None:
|
||||
self.mac = device_info["mac"]
|
||||
if self.model is None:
|
||||
self.model = device_info["model"]
|
||||
if self.name is None:
|
||||
self.name = device_info["name"]
|
||||
self.token = device_info["token"]
|
||||
self.device_id = device_info["did"]
|
||||
162
custom_components/dreame_vacuum/const.py
Normal file
162
custom_components/dreame_vacuum/const.py
Normal file
@@ -0,0 +1,162 @@
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
DOMAIN = "dreame_vacuum"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
UNIT_MINUTES: Final = "min"
|
||||
UNIT_HOURS: Final = "hr"
|
||||
UNIT_PERCENT: Final = "%"
|
||||
UNIT_DAYS: Final = "dy"
|
||||
UNIT_AREA: Final = "m²"
|
||||
UNIT_TIMES: Final = "x"
|
||||
|
||||
ATTR_VALUE: Final = "value"
|
||||
ATTR_CALIBRATION = "calibration_points"
|
||||
|
||||
CONF_NOTIFY: Final = "notify"
|
||||
CONF_COLOR_SCHEME: Final = "color_scheme"
|
||||
CONF_ICON_SET: Final = "icon_set"
|
||||
CONF_COUNTRY: Final = "country"
|
||||
CONF_TYPE: Final = "configuration_type"
|
||||
CONF_MAC: Final = "mac"
|
||||
CONF_MAP_OBJECTS: Final = "map_objects"
|
||||
CONF_PREFER_CLOUD: Final = "prefer_cloud"
|
||||
|
||||
CONTENT_TYPE: Final = "image/png"
|
||||
|
||||
MAP_OBJECTS: Final = { "color": "Room Colors", "icon": "Room Icons", "name": "Room Names", "order": "Room Order", "suction_level": "Room Suction Level", "water_volume": "Room Water Volume", "cleaning_times": "Room Cleaning Times", "cleaning_mode": "Room Cleaning Mode", "path": "Path", "no_go": "No Go Zones", "no_mop": "No Mop Zones", "virtual_wall": "Virtual Walls", "active_area": "Active Areas", "active_point": "Active Points", "charger": "Charger Icon", "robot": "Robot Icon", "cleaning_direction": "Cleaning Direction", "obstacle": "AI Obstacle", "carpet": "Carpet Area" }
|
||||
NOTIFICATION: Final = { "cleanup_completed": "Cleanup Completed", "consumable": "Consumable", "information": "Information", "warning": "Warning", "error": "Error" }
|
||||
|
||||
FAN_SPEED_SILENT: Final = "Silent"
|
||||
FAN_SPEED_STANDARD: Final = "Standard"
|
||||
FAN_SPEED_STRONG: Final = "Strong"
|
||||
FAN_SPEED_TURBO: Final = "Turbo"
|
||||
|
||||
SERVICE_CLEAN_ZONE: Final = "vacuum_clean_zone"
|
||||
SERVICE_CLEAN_SEGMENT: Final = "vacuum_clean_segment"
|
||||
SERVICE_CLEAN_SPOT: Final = "vacuum_clean_spot"
|
||||
SERVICE_REQUEST_MAP: Final = "vacuum_request_map"
|
||||
SERVICE_SELECT_MAP: Final = "vacuum_select_map"
|
||||
SERVICE_DELETE_MAP: Final = "vacuum_delete_map"
|
||||
SERVICE_SET_RESTRICTED_ZONE: Final = "vacuum_set_restricted_zone"
|
||||
SERVICE_MOVE_REMOTE_CONTROL_STEP: Final = "vacuum_remote_control_move_step"
|
||||
SERVICE_RENAME_MAP: Final = "vacuum_rename_map"
|
||||
SERVICE_SAVE_TEMPORARY_MAP: Final = "vacuum_save_temporary_map"
|
||||
SERVICE_DISCARD_TEMPORARY_MAP: Final = "vacuum_discard_temporary_map"
|
||||
SERVICE_REPLACE_TEMPORARY_MAP: Final = "vacuum_replace_temporary_map"
|
||||
SERVICE_MERGE_SEGMENTS: Final = "vacuum_merge_segments"
|
||||
SERVICE_SPLIT_SEGMENTS: Final = "vacuum_split_segments"
|
||||
SERVICE_RENAME_SEGMENT: Final = "vacuum_rename_segment"
|
||||
SERVICE_SET_CLEANING_SEQUENCE: Final = "vacuum_set_cleaning_sequence"
|
||||
SERVICE_SET_CUSTOM_CLEANING: Final = "vacuum_set_custom_cleaning"
|
||||
SERVICE_SET_DND: Final = "vacuum_set_dnd"
|
||||
SERVICE_INSTALL_VOICE_PACK: Final = "vacuum_install_voice_pack"
|
||||
SERVICE_RESET_CONSUMABLE: Final = "vacuum_reset_consumable"
|
||||
SERVICE_SELECT_NEXT = "select_select_next"
|
||||
SERVICE_SELECT_PREVIOUS = "select_select_previous"
|
||||
SERVICE_SELECT_FIRST = "select_select_first"
|
||||
SERVICE_SELECT_LAST = "select_select_last"
|
||||
|
||||
INPUT_ROTATION: Final = "rotation"
|
||||
INPUT_VELOCITY: Final = "velocity"
|
||||
INPUT_MAP_ID: Final = "map_id"
|
||||
INPUT_MAP_NAME: Final = "map_name"
|
||||
INPUT_MAP_URL: Final = "map_url"
|
||||
INPUT_WALL_ARRAY: Final = "walls"
|
||||
INPUT_ZONE: Final = "zone"
|
||||
INPUT_ZONE_ARRAY: Final = "zones"
|
||||
INPUT_REPEATS: Final = "repeats"
|
||||
INPUT_SEGMENTS_ARRAY: Final = "segments"
|
||||
INPUT_SEGMENT: Final = "segment"
|
||||
INPUT_SEGMENT_ID: Final = "segment_id"
|
||||
INPUT_SEGMENT_NAME: Final = "segment_name"
|
||||
INPUT_LINE: Final = "line"
|
||||
INPUT_SUCTION_LEVEL: Final = "suction_level"
|
||||
INPUT_MOP_MODE: Final = "mop_mode"
|
||||
INPUT_MOP_ARRAY: Final = "no_mops"
|
||||
INPUT_LANGUAGE_ID: Final = "lang_id"
|
||||
INPUT_DELAY: Final = "delay"
|
||||
INPUT_URL: Final = "url"
|
||||
INPUT_MD5: Final = "md5"
|
||||
INPUT_SIZE: Final = "size"
|
||||
INPUT_CLEANING_SEQUENCE: Final = "cleaning_sequence"
|
||||
INPUT_DND_ENABLED: Final = "dnd_enabled"
|
||||
INPUT_DND_START: Final = "dnd_start"
|
||||
INPUT_DND_END: Final = "dnd_end"
|
||||
INPUT_WATER_VOLUME: Final = "water_volume"
|
||||
INPUT_CONSUMABLE: Final = "consumable"
|
||||
INPUT_CYCLE: Final = "cycle"
|
||||
INPUT_POINTS: Final = "points"
|
||||
|
||||
CONSUMABLE_MAIN_BRUSH = "main_brush"
|
||||
CONSUMABLE_SIDE_BRUSH = "side_brush"
|
||||
CONSUMABLE_FILTER = "filter"
|
||||
CONSUMABLE_SECONDARY_FILTER = "secondary_filter"
|
||||
CONSUMABLE_SENSOR = "sensor"
|
||||
CONSUMABLE_MOP_PAD = "mop_pad"
|
||||
CONSUMABLE_SILVER_ION = "silver_ion"
|
||||
CONSUMABLE_DETERGENT = "detergent"
|
||||
|
||||
NOTIFICATION_ID_DUST_COLLECTION: Final = "dust_collection"
|
||||
NOTIFICATION_ID_CLEANING_PAUSED: Final = "cleaning_paused"
|
||||
NOTIFICATION_ID_REPLACE_MAIN_BRUSH: Final = "replace_main_brush"
|
||||
NOTIFICATION_ID_REPLACE_SIDE_BRUSH: Final = "replace_side_brush"
|
||||
NOTIFICATION_ID_REPLACE_FILTER: Final = "replace_filter"
|
||||
NOTIFICATION_ID_CLEAN_SENSOR: Final = "clean_sensor"
|
||||
NOTIFICATION_ID_REPLACE_MOP: Final = "replace_mop"
|
||||
NOTIFICATION_ID_SILVER_ION: Final = "silver_ion"
|
||||
NOTIFICATION_ID_REPLACE_DETERGENT: Final = "replace_detergent"
|
||||
NOTIFICATION_ID_CLEANUP_COMPLETED: Final = "cleanup_completed"
|
||||
NOTIFICATION_ID_WARNING: Final = "warning"
|
||||
NOTIFICATION_ID_INFORMATION: Final = "information"
|
||||
NOTIFICATION_ID_CONSUMABLE: Final = "consumable"
|
||||
NOTIFICATION_ID_ERROR: Final = "error"
|
||||
NOTIFICATION_ID_REPLACE_TEMPORARY_MAP: Final = "replace_temporary_map"
|
||||
NOTIFICATION_ID_2FA_LOGIN: Final = "2fa_login"
|
||||
|
||||
NOTIFICATION_CLEANUP_COMPLETED: Final = "### Cleanup completed"
|
||||
NOTIFICATION_MAIN_BRUSH_NO_LIFE_LEFT: Final = (
|
||||
"### Main brush must be replaced\nChange main brush and reset the counter."
|
||||
)
|
||||
NOTIFICATION_SIDE_BRUSH_NO_LIFE_LEFT: Final = (
|
||||
"### Side brush must be replaced\nChange side brush and reset the counter."
|
||||
)
|
||||
NOTIFICATION_FILTER_NO_LIFE_LEFT: Final = (
|
||||
"### Filter must be replaced\nChange filter and reset the counter."
|
||||
)
|
||||
NOTIFICATION_SENSOR_NO_LIFE_LEFT: Final = (
|
||||
"### Sensors must be cleaned\nClean sensors and reset the counter."
|
||||
)
|
||||
NOTIFICATION_MOP_NO_LIFE_LEFT: Final = (
|
||||
"### Mop pad must be replaced\nChange mop pad and reset the counter."
|
||||
)
|
||||
NOTIFICATION_SILVER_ION_LIFE_LEFT: Final = (
|
||||
"### Silver Ion Sterilizer Deteriorated.\nPlease replace the silver ion sterilizer and reset the counter."
|
||||
)
|
||||
NOTIFICATION_DETERGENT_NO_LIFE_LEFT: Final = (
|
||||
"### The multi-surface floor cleaner performs well in the deep cleaning of the mop pad and floors, as well as removing dirt, grime and sticky messes.\nThe cleaner will be added automatically while cleaning. It is recommended to check the remaining amount through the app and replace it promptly."
|
||||
)
|
||||
NOTIFICATION_DUST_COLLECTION_NOT_PERFORMED: Final = (
|
||||
"### Dust collecting (Auto-empty) task not performed\nThe robot will not perform auto-empty tasks during the DND period."
|
||||
)
|
||||
NOTIFICATION_RESUME_CLEANING: Final = (
|
||||
"### Low battery\nThe robot will automatically resume unfinished cleaning tasks after charging its battery to 80%."
|
||||
)
|
||||
NOTIFICATION_RESUME_CLEANING_NOT_PERFORMED: Final = (
|
||||
"### The robot is in the DND period\nRobot will resume cleaning after the DND period ends."
|
||||
)
|
||||
NOTIFICATION_REPLACE_MAP: Final = (
|
||||
"### A new map has been generated\nYou need to save or discard map before using it."
|
||||
)
|
||||
NOTIFICATION_REPLACE_MULTI_MAP: Final = (
|
||||
"### A new map has been generated\nMulti-floor maps that can be saved have reached the upper limit. You need to replace or discard map before using it."
|
||||
)
|
||||
NOTIFICATION_2FA_LOGIN: Final = "### Additional authentication required.\nOpen following URL using device that has the same public IP, as your Home Assistant instance:\n"
|
||||
|
||||
EVENT_TASK_STATUS: Final = "task_status"
|
||||
EVENT_CONSUMABLE: Final = "consumable"
|
||||
EVENT_WARNING: Final = "warning"
|
||||
EVENT_ERROR: Final = "error"
|
||||
EVENT_INFORMATION: Final = "information"
|
||||
EVENT_2FA_LOGIN: Final = "2fa_login"
|
||||
378
custom_components/dreame_vacuum/coordinator.py
Normal file
378
custom_components/dreame_vacuum/coordinator.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""DataUpdateCoordinator for Dreame Vacuum."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import traceback
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_HOST,
|
||||
CONF_TOKEN,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
ATTR_ENTITY_ID
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import generate_entity_id
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from .dreame import DreameVacuumDevice, DreameVacuumProperty
|
||||
from .dreame.resources import CONSUMABLE_IMAGE
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
CONF_NOTIFY,
|
||||
CONF_COUNTRY,
|
||||
CONF_MAC,
|
||||
CONF_PREFER_CLOUD,
|
||||
CONTENT_TYPE,
|
||||
NOTIFICATION_CLEANUP_COMPLETED,
|
||||
NOTIFICATION_MAIN_BRUSH_NO_LIFE_LEFT,
|
||||
NOTIFICATION_SIDE_BRUSH_NO_LIFE_LEFT,
|
||||
NOTIFICATION_FILTER_NO_LIFE_LEFT,
|
||||
NOTIFICATION_SENSOR_NO_LIFE_LEFT,
|
||||
NOTIFICATION_MOP_NO_LIFE_LEFT,
|
||||
NOTIFICATION_SILVER_ION_LIFE_LEFT,
|
||||
NOTIFICATION_DETERGENT_NO_LIFE_LEFT,
|
||||
NOTIFICATION_DUST_COLLECTION_NOT_PERFORMED,
|
||||
NOTIFICATION_RESUME_CLEANING,
|
||||
NOTIFICATION_RESUME_CLEANING_NOT_PERFORMED,
|
||||
NOTIFICATION_REPLACE_MULTI_MAP,
|
||||
NOTIFICATION_REPLACE_MAP,
|
||||
NOTIFICATION_2FA_LOGIN,
|
||||
NOTIFICATION_ID_DUST_COLLECTION,
|
||||
NOTIFICATION_ID_CLEANING_PAUSED,
|
||||
NOTIFICATION_ID_REPLACE_MAIN_BRUSH,
|
||||
NOTIFICATION_ID_REPLACE_SIDE_BRUSH,
|
||||
NOTIFICATION_ID_REPLACE_FILTER,
|
||||
NOTIFICATION_ID_CLEAN_SENSOR,
|
||||
NOTIFICATION_ID_REPLACE_MOP,
|
||||
NOTIFICATION_ID_SILVER_ION,
|
||||
NOTIFICATION_ID_REPLACE_DETERGENT,
|
||||
NOTIFICATION_ID_CLEANUP_COMPLETED,
|
||||
NOTIFICATION_ID_WARNING,
|
||||
NOTIFICATION_ID_ERROR,
|
||||
NOTIFICATION_ID_INFORMATION,
|
||||
NOTIFICATION_ID_CONSUMABLE,
|
||||
NOTIFICATION_ID_REPLACE_TEMPORARY_MAP,
|
||||
NOTIFICATION_ID_2FA_LOGIN,
|
||||
EVENT_TASK_STATUS,
|
||||
EVENT_CONSUMABLE,
|
||||
EVENT_WARNING,
|
||||
EVENT_ERROR,
|
||||
EVENT_INFORMATION,
|
||||
EVENT_2FA_LOGIN,
|
||||
CONSUMABLE_MAIN_BRUSH,
|
||||
CONSUMABLE_SIDE_BRUSH,
|
||||
CONSUMABLE_FILTER,
|
||||
CONSUMABLE_SENSOR,
|
||||
CONSUMABLE_MOP_PAD,
|
||||
CONSUMABLE_SILVER_ION,
|
||||
CONSUMABLE_DETERGENT,
|
||||
)
|
||||
|
||||
|
||||
class DreameVacuumDataUpdateCoordinator(DataUpdateCoordinator[DreameVacuumDevice]):
|
||||
"""Class to manage fetching Dreame Vacuum data from single endpoint."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize global Dreame Vacuum data updater."""
|
||||
self._token = entry.data[CONF_TOKEN]
|
||||
self._host = entry.data[CONF_HOST]
|
||||
self._notify = entry.options.get(CONF_NOTIFY, True)
|
||||
self._entry = entry
|
||||
self._available = False
|
||||
self._has_warning = False
|
||||
self._has_temporary_map = None
|
||||
self._two_factor_url = None
|
||||
|
||||
self.device = DreameVacuumDevice(
|
||||
entry.data[CONF_NAME],
|
||||
self._host,
|
||||
self._token,
|
||||
entry.data.get(CONF_MAC),
|
||||
entry.data.get(CONF_USERNAME),
|
||||
entry.data.get(CONF_PASSWORD),
|
||||
entry.data.get(CONF_COUNTRY),
|
||||
entry.options.get(CONF_PREFER_CLOUD, False),
|
||||
)
|
||||
|
||||
self.device.listen(
|
||||
self._dust_collection_changed, DreameVacuumProperty.DUST_COLLECTION
|
||||
)
|
||||
self.device.listen(self._error_changed, DreameVacuumProperty.ERROR)
|
||||
self.device.listen(
|
||||
self._task_status_changed, DreameVacuumProperty.TASK_STATUS
|
||||
)
|
||||
self.device.listen(
|
||||
self._cleaning_paused_changed, DreameVacuumProperty.CLEANING_PAUSED
|
||||
)
|
||||
self.device.listen(self.set_updated_data)
|
||||
self.device.listen_error(self.set_update_error)
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=DOMAIN,
|
||||
)
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
persistent_notification.SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED,
|
||||
self._notification_dismiss_listener,
|
||||
)
|
||||
|
||||
def _dust_collection_changed(self, previous_value=None) -> None:
|
||||
if previous_value is not None:
|
||||
if self.device.status.auto_emptying_not_performed:
|
||||
self._fire_event(EVENT_INFORMATION, {EVENT_INFORMATION: NOTIFICATION_ID_DUST_COLLECTION})
|
||||
|
||||
self._create_persistent_notification(
|
||||
NOTIFICATION_DUST_COLLECTION_NOT_PERFORMED,
|
||||
NOTIFICATION_ID_DUST_COLLECTION,
|
||||
)
|
||||
else:
|
||||
self._remove_persistent_notification(
|
||||
NOTIFICATION_ID_DUST_COLLECTION)
|
||||
|
||||
def _cleaning_paused_changed(self, previous_value=None) -> None:
|
||||
if previous_value is not None and self.device.status.cleaning_paused:
|
||||
notification = NOTIFICATION_RESUME_CLEANING
|
||||
if self.device.status.battery_level >= 80:
|
||||
dnd_remaining = self.device.status.dnd_remaining
|
||||
if dnd_remaining:
|
||||
hour = math.floor(dnd_remaining / 3600)
|
||||
minute = math.floor((dnd_remaining - hour * 3600) / 60)
|
||||
notification = f"{NOTIFICATION_RESUME_CLEANING_NOT_PERFORMED}\n## Cleaning will start in {hour} hour(s) and {minute} minutes(s)"
|
||||
self._fire_event(EVENT_INFORMATION, {EVENT_INFORMATION: NOTIFICATION_ID_CLEANING_PAUSED})
|
||||
else:
|
||||
self._fire_event(EVENT_INFORMATION, {EVENT_INFORMATION: NOTIFICATION_ID_CLEANING_PAUSED})
|
||||
|
||||
self._create_persistent_notification(
|
||||
notification, NOTIFICATION_ID_CLEANING_PAUSED
|
||||
)
|
||||
else:
|
||||
self._remove_persistent_notification(
|
||||
NOTIFICATION_ID_CLEANING_PAUSED)
|
||||
|
||||
def _task_status_changed(self, previous_value=None) -> None:
|
||||
if previous_value is not None:
|
||||
if self.device.cleanup_completed:
|
||||
self._fire_event(EVENT_TASK_STATUS, self.device.status.job)
|
||||
self._create_persistent_notification(
|
||||
NOTIFICATION_CLEANUP_COMPLETED, NOTIFICATION_ID_CLEANUP_COMPLETED
|
||||
)
|
||||
|
||||
if self.device.status.main_brush_life == 0:
|
||||
self._create_persistent_notification(
|
||||
f'{NOTIFICATION_MAIN_BRUSH_NO_LIFE_LEFT}\n})',
|
||||
NOTIFICATION_ID_REPLACE_MAIN_BRUSH,
|
||||
)
|
||||
self._fire_event(EVENT_CONSUMABLE, {EVENT_CONSUMABLE: CONSUMABLE_MAIN_BRUSH})
|
||||
if self.device.status.side_brush_life == 0:
|
||||
self._create_persistent_notification(
|
||||
f'{NOTIFICATION_SIDE_BRUSH_NO_LIFE_LEFT}\n})',
|
||||
NOTIFICATION_ID_REPLACE_SIDE_BRUSH,
|
||||
)
|
||||
self._fire_event(EVENT_CONSUMABLE, {EVENT_CONSUMABLE: CONSUMABLE_SIDE_BRUSH})
|
||||
if self.device.status.filter_life == 0:
|
||||
self._create_persistent_notification(
|
||||
f'{NOTIFICATION_FILTER_NO_LIFE_LEFT}\n})',
|
||||
NOTIFICATION_ID_REPLACE_FILTER,
|
||||
)
|
||||
self._fire_event(EVENT_CONSUMABLE, {EVENT_CONSUMABLE: CONSUMABLE_FILTER})
|
||||
if self.device.status.sensor_dirty_life == 0:
|
||||
self._create_persistent_notification(
|
||||
f'{NOTIFICATION_SENSOR_NO_LIFE_LEFT}\n})',
|
||||
NOTIFICATION_ID_CLEAN_SENSOR,
|
||||
)
|
||||
self._fire_event(EVENT_CONSUMABLE, {EVENT_CONSUMABLE: CONSUMABLE_SENSOR})
|
||||
if self.device.status.mop_life == 0:
|
||||
self._create_persistent_notification(
|
||||
f'{NOTIFICATION_MOP_NO_LIFE_LEFT}\n})',
|
||||
NOTIFICATION_ID_REPLACE_MOP
|
||||
)
|
||||
self._fire_event(EVENT_CONSUMABLE, {EVENT_CONSUMABLE: CONSUMABLE_MOP_PAD})
|
||||
if self.device.status.silver_ion_life == 0:
|
||||
self._create_persistent_notification(
|
||||
f'{NOTIFICATION_SILVER_ION_LIFE_LEFT}\n})',
|
||||
NOTIFICATION_ID_SILVER_ION
|
||||
)
|
||||
self._fire_event(EVENT_CONSUMABLE, {EVENT_CONSUMABLE: CONSUMABLE_SILVER_ION})
|
||||
if self.device.status.detergent_life == 0:
|
||||
self._create_persistent_notification(
|
||||
NOTIFICATION_DETERGENT_NO_LIFE_LEFT, NOTIFICATION_ID_REPLACE_DETERGENT
|
||||
)
|
||||
self._fire_event(EVENT_CONSUMABLE, {EVENT_CONSUMABLE: CONSUMABLE_DETERGENT})
|
||||
|
||||
elif previous_value == 0 and not self.device.status.fast_mapping:
|
||||
self._fire_event(EVENT_TASK_STATUS, self.device.status.job)
|
||||
|
||||
def _error_changed(self, previous_value=None) -> None:
|
||||
has_warning = self.device.status.has_warning
|
||||
if has_warning:
|
||||
self._fire_event(EVENT_WARNING, {EVENT_WARNING: self.device.status.error_description[0], "code": self.device.status.error.value})
|
||||
|
||||
self._create_persistent_notification(
|
||||
self.device.status.error_description[0], NOTIFICATION_ID_WARNING
|
||||
)
|
||||
elif self._has_warning:
|
||||
self._has_warning = False
|
||||
self._remove_persistent_notification(NOTIFICATION_ID_WARNING)
|
||||
|
||||
if self.device.status.has_error:
|
||||
description = self.device.status.error_description
|
||||
self._fire_event(EVENT_ERROR, {EVENT_ERROR: description[0], "code": self.device.status.error.value})
|
||||
|
||||
description = f"### {description[0]}\n{description[1]}"
|
||||
image = self.device.status.error_image
|
||||
if image:
|
||||
description = f"{description}"
|
||||
self._create_persistent_notification(
|
||||
description, f"{NOTIFICATION_ID_ERROR}_{self.device.status.error.value}"
|
||||
)
|
||||
|
||||
self._has_warning = has_warning
|
||||
|
||||
def _has_temporary_map_changed(self, previous_value=None) -> None:
|
||||
if self.device.status.has_temporary_map:
|
||||
self._fire_event(EVENT_WARNING, {EVENT_WARNING: NOTIFICATION_REPLACE_MULTI_MAP})
|
||||
|
||||
self._create_persistent_notification(
|
||||
NOTIFICATION_REPLACE_MULTI_MAP
|
||||
if self.device.status.multi_map
|
||||
else NOTIFICATION_REPLACE_MAP,
|
||||
NOTIFICATION_ID_REPLACE_TEMPORARY_MAP,
|
||||
)
|
||||
else:
|
||||
self._fire_event(EVENT_WARNING, {EVENT_WARNING: NOTIFICATION_ID_REPLACE_TEMPORARY_MAP})
|
||||
|
||||
self._remove_persistent_notification(
|
||||
NOTIFICATION_ID_REPLACE_TEMPORARY_MAP)
|
||||
|
||||
def _create_persistent_notification(self, content, notification_id) -> None:
|
||||
if self._notify or notification_id == NOTIFICATION_ID_2FA_LOGIN:
|
||||
if isinstance(self._notify, list) and notification_id != NOTIFICATION_ID_2FA_LOGIN:
|
||||
if notification_id == NOTIFICATION_ID_CLEANUP_COMPLETED:
|
||||
if NOTIFICATION_ID_CLEANUP_COMPLETED not in self._notify:
|
||||
return
|
||||
elif (
|
||||
NOTIFICATION_ID_WARNING in notification_id
|
||||
):
|
||||
if NOTIFICATION_ID_WARNING not in self._notify:
|
||||
return
|
||||
elif NOTIFICATION_ID_ERROR in notification_id:
|
||||
if NOTIFICATION_ID_ERROR not in self._notify:
|
||||
return
|
||||
elif (
|
||||
notification_id == NOTIFICATION_ID_DUST_COLLECTION
|
||||
or notification_id == NOTIFICATION_ID_CLEANING_PAUSED
|
||||
):
|
||||
if NOTIFICATION_ID_INFORMATION not in self._notify:
|
||||
return
|
||||
elif (
|
||||
notification_id != NOTIFICATION_ID_REPLACE_TEMPORARY_MAP
|
||||
):
|
||||
if NOTIFICATION_ID_CONSUMABLE not in self._notify:
|
||||
return
|
||||
|
||||
|
||||
persistent_notification.create(
|
||||
self.hass,
|
||||
content,
|
||||
title=self.device.name,
|
||||
notification_id=f"{DOMAIN}_{self.device.mac}_{notification_id}"
|
||||
)
|
||||
|
||||
def _remove_persistent_notification(self, notification_id) -> None:
|
||||
persistent_notification.dismiss(
|
||||
self.hass, f"{DOMAIN}_{self.device.mac}_{notification_id}")
|
||||
|
||||
def _notification_dismiss_listener(self, type, data) -> None:
|
||||
if type == persistent_notification.UpdateType.REMOVED and self.device:
|
||||
notifications = self.hass.data.get(persistent_notification.DOMAIN)
|
||||
if self._has_warning:
|
||||
if (
|
||||
f"{DOMAIN}_{self.device.mac}_{NOTIFICATION_ID_WARNING}"
|
||||
not in notifications
|
||||
):
|
||||
if NOTIFICATION_ID_WARNING in self._notify:
|
||||
self.device.clear_warning()
|
||||
self._has_warning = self.device.status.has_warning
|
||||
if self._two_factor_url:
|
||||
if (
|
||||
f"{DOMAIN}_{self.device.mac}_{NOTIFICATION_ID_2FA_LOGIN}"
|
||||
not in notifications
|
||||
):
|
||||
self._two_factor_url = None
|
||||
|
||||
def _fire_event(self, event_id, data) -> None:
|
||||
event_data = {ATTR_ENTITY_ID: generate_entity_id("vacuum.{}", self.device.name, hass=self.hass)}
|
||||
if data:
|
||||
event_data.update(data)
|
||||
self.hass.bus.fire(f"{DOMAIN}_{event_id}", event_data)
|
||||
|
||||
async def _async_update_data(self) -> DreameVacuumDevice:
|
||||
"""Handle device update. This function is only called once when the integration is added to Home Assistant."""
|
||||
try:
|
||||
LOGGER.info("Integration starting...")
|
||||
await self.hass.async_add_executor_job(self.device.update)
|
||||
self.device.schedule_update()
|
||||
self.async_set_updated_data()
|
||||
return self.device
|
||||
except Exception as ex:
|
||||
LOGGER.warning("Integration start failed: %s", traceback.format_exc())
|
||||
if self.device is not None:
|
||||
self.device.listen(None)
|
||||
self.device.disconnect()
|
||||
del self.device
|
||||
self.device = None
|
||||
raise UpdateFailed(ex) from ex
|
||||
|
||||
def set_update_error(self, ex=None) -> None:
|
||||
self.hass.loop.call_soon_threadsafe(self.async_set_update_error, ex)
|
||||
|
||||
def set_updated_data(self, device=None) -> None:
|
||||
self.hass.loop.call_soon_threadsafe(self.async_set_updated_data, device)
|
||||
|
||||
@callback
|
||||
def async_set_updated_data(self, device=None) -> None:
|
||||
if self._has_temporary_map != self.device.status.has_temporary_map:
|
||||
self._has_temporary_map_changed(self._has_temporary_map)
|
||||
self._has_temporary_map = self.device.status.has_temporary_map
|
||||
|
||||
if self.device.token != self._token or self.device.host != self._host:
|
||||
data = self._entry.data.copy()
|
||||
self._host = self.device.host
|
||||
self._token = self.device.token
|
||||
data[CONF_HOST] = self._host
|
||||
data[CONF_TOKEN] = self._token
|
||||
self.hass.config_entries.async_update_entry(self._entry, data=data)
|
||||
|
||||
if self._two_factor_url != self.device.two_factor_url:
|
||||
if self.device.two_factor_url:
|
||||
self._create_persistent_notification(
|
||||
f"{NOTIFICATION_2FA_LOGIN}[{self.device.two_factor_url}]({self.device.two_factor_url})", NOTIFICATION_ID_2FA_LOGIN
|
||||
)
|
||||
self._fire_event(EVENT_2FA_LOGIN, {"url": self.device.two_factor_url})
|
||||
else:
|
||||
self._remove_persistent_notification(NOTIFICATION_ID_2FA_LOGIN)
|
||||
|
||||
self._two_factor_url = self.device.two_factor_url
|
||||
|
||||
self._available = self.device.available
|
||||
|
||||
super().async_set_updated_data(self.device)
|
||||
|
||||
@callback
|
||||
def async_set_update_error(self, ex) -> None:
|
||||
if self._available:
|
||||
self._available = self.device.available
|
||||
super().async_set_update_error(ex)
|
||||
31
custom_components/dreame_vacuum/dreame/__init__.py
Normal file
31
custom_components/dreame_vacuum/dreame/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from .types import (
|
||||
DreameVacuumProperty,
|
||||
DreameVacuumAction,
|
||||
DreameVacuumRelocationStatus,
|
||||
DreameVacuumAutoEmptyStatus,
|
||||
DreameVacuumSuctionLevel,
|
||||
DreameVacuumCleaningMode,
|
||||
DreameVacuumWaterVolume,
|
||||
DreameVacuumMopPadHumidity,
|
||||
DreameVacuumCarpetSensitivity,
|
||||
DreameVacuumTaskStatus,
|
||||
DreameVacuumState,
|
||||
DreameVacuumSelfCleanArea,
|
||||
DreameVacuumMopWashLevel,
|
||||
DreameVacuumMoppingType,
|
||||
PROPERTY_AVAILABILITY,
|
||||
ACTION_AVAILABILITY,
|
||||
MAP_COLOR_SCHEME_LIST,
|
||||
MAP_ICON_SET_LIST,
|
||||
)
|
||||
from .const import (
|
||||
SUCTION_LEVEL_CODE_TO_NAME,
|
||||
WATER_VOLUME_CODE_TO_NAME,
|
||||
MOP_PAD_HUMIDITY_CODE_TO_NAME,
|
||||
PROPERTY_TO_NAME,
|
||||
ACTION_TO_NAME,
|
||||
SUCTION_LEVEL_QUIET,
|
||||
)
|
||||
from .device import DreameVacuumDevice
|
||||
from .protocol import DreameVacuumProtocol
|
||||
from .exceptions import DeviceException, DeviceUpdateFailedException, InvalidActionException, InvalidValueException
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1119
custom_components/dreame_vacuum/dreame/const.py
Normal file
1119
custom_components/dreame_vacuum/dreame/const.py
Normal file
File diff suppressed because it is too large
Load Diff
3560
custom_components/dreame_vacuum/dreame/device.py
Normal file
3560
custom_components/dreame_vacuum/dreame/device.py
Normal file
File diff suppressed because it is too large
Load Diff
14
custom_components/dreame_vacuum/dreame/exceptions.py
Normal file
14
custom_components/dreame_vacuum/dreame/exceptions.py
Normal file
@@ -0,0 +1,14 @@
|
||||
class DeviceException(Exception):
|
||||
"""Exception wrapping any communication errors with the device."""
|
||||
|
||||
|
||||
class DeviceUpdateFailedException(DeviceException):
|
||||
""" """
|
||||
|
||||
|
||||
class InvalidValueException(ValueError):
|
||||
""" """
|
||||
|
||||
|
||||
class InvalidActionException(ValueError):
|
||||
""" """
|
||||
6496
custom_components/dreame_vacuum/dreame/map.py
Normal file
6496
custom_components/dreame_vacuum/dreame/map.py
Normal file
File diff suppressed because it is too large
Load Diff
596
custom_components/dreame_vacuum/dreame/protocol.py
Normal file
596
custom_components/dreame_vacuum/dreame/protocol.py
Normal file
@@ -0,0 +1,596 @@
|
||||
import logging
|
||||
import random
|
||||
import hashlib
|
||||
import json
|
||||
import base64
|
||||
import hmac
|
||||
import time, locale, datetime
|
||||
import tzlocal
|
||||
import requests
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from .exceptions import DeviceException
|
||||
from typing import Any, Optional, Tuple
|
||||
from miio.miioprotocol import MiIOProtocol
|
||||
from Crypto.Cipher import ARC4
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
class DreameVacuumDeviceProtocol(MiIOProtocol):
|
||||
def __init__(self, ip: str, token: str) -> None:
|
||||
super().__init__(ip, token, 0, 0, True, 2)
|
||||
self.ip = None
|
||||
self.token = None
|
||||
self.set_credentials(ip, token)
|
||||
|
||||
def set_credentials(self, ip: str, token: str):
|
||||
if self.ip != ip or self.token != token:
|
||||
self.ip = ip
|
||||
self.port = 54321
|
||||
self.token = token
|
||||
|
||||
if token is None or token == "":
|
||||
token = 32 * "0"
|
||||
self.token = bytes.fromhex(token)
|
||||
self._discovered = False
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._discovered
|
||||
|
||||
def disconnect(self):
|
||||
self._discovered = False
|
||||
|
||||
class DreameVacuumCloudProtocol:
|
||||
def __init__(self, username: str, password: str, country: str) -> None:
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._country = country
|
||||
self._session = requests.session()
|
||||
self._sign = None
|
||||
self._ssecurity = None
|
||||
self._userId = None
|
||||
self._cUserId = None
|
||||
self._passToken = None
|
||||
self._location = None
|
||||
self._code = None
|
||||
self._serviceToken = None
|
||||
self._logged_in = None
|
||||
self.user_id = None
|
||||
self.device_id = None
|
||||
self.two_factor_url = None
|
||||
self._useragent = f"Android-7.1.1-1.0.0-ONEPLUS A3010-136-{DreameVacuumCloudProtocol.get_random_agent_id()} APP/xiaomi.smarthome APPV/62830"
|
||||
self._locale = locale.getdefaultlocale()[0]
|
||||
|
||||
self._fail_count = 0
|
||||
self._connected = False
|
||||
try:
|
||||
timezone = datetime.datetime.now(tzlocal.get_localzone()).strftime("%z")
|
||||
timezone = "GMT{0}:{1}".format(timezone[:-2], timezone[-2:])
|
||||
except:
|
||||
timezone = "GMT+00:00"
|
||||
self._timezone = timezone
|
||||
|
||||
def _api_call(self, url, params):
|
||||
return self.request(f"{self.get_api_url()}/{url}", {"data": json.dumps(params, separators=(",", ":"))})
|
||||
|
||||
@property
|
||||
def logged_in(self) -> bool:
|
||||
return self._logged_in
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
def login_step_1(self) -> bool:
|
||||
url = "https://account.xiaomi.com/pass/serviceLogin?sid=xiaomiio&_json=true"
|
||||
headers = {
|
||||
"User-Agent": self._useragent,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
cookies = {"userId": self._username}
|
||||
try:
|
||||
response = self._session.get(
|
||||
url, headers=headers, cookies=cookies, timeout=2
|
||||
)
|
||||
except:
|
||||
response = None
|
||||
successful = (
|
||||
response is not None
|
||||
and response.status_code == 200
|
||||
and "_sign" in self.to_json(response.text)
|
||||
)
|
||||
if successful:
|
||||
self._sign = self.to_json(response.text)["_sign"]
|
||||
return successful
|
||||
|
||||
def login_step_2(self) -> bool:
|
||||
url = "https://account.xiaomi.com/pass/serviceLoginAuth2"
|
||||
headers = {
|
||||
"User-Agent": self._useragent,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
fields = {
|
||||
"sid": "xiaomiio",
|
||||
"hash": hashlib.md5(str.encode(self._password)).hexdigest().upper(),
|
||||
"callback": "https://sts.api.io.mi.com/sts",
|
||||
"qs": "%3Fsid%3Dxiaomiio%26_json%3Dtrue",
|
||||
"user": self._username,
|
||||
"_json": "true",
|
||||
}
|
||||
if self._sign:
|
||||
fields['_sign'] = self._sign
|
||||
|
||||
try:
|
||||
response = self._session.post(
|
||||
url, headers=headers, params=fields, timeout=2
|
||||
)
|
||||
except:
|
||||
response = None
|
||||
successful = response is not None and response.status_code == 200
|
||||
if successful:
|
||||
json_resp = self.to_json(response.text)
|
||||
successful = (
|
||||
"ssecurity" in json_resp and len(
|
||||
str(json_resp["ssecurity"])) > 4
|
||||
)
|
||||
if successful:
|
||||
self._ssecurity = json_resp["ssecurity"]
|
||||
self._userId = json_resp["userId"]
|
||||
self._cUserId = json_resp["cUserId"]
|
||||
self._passToken = json_resp["passToken"]
|
||||
self._location = json_resp["location"]
|
||||
self._code = json_resp["code"]
|
||||
self.two_factor_url = None
|
||||
else:
|
||||
if "notificationUrl" in json_resp and self.two_factor_url is None:
|
||||
self.two_factor_url = json_resp["notificationUrl"]
|
||||
if self.two_factor_url[:4] != 'http':
|
||||
self.two_factor_url = f'https://account.xiaomi.com{self.two_factor_url}'
|
||||
|
||||
_LOGGER.error(
|
||||
"Additional authentication required. Open following URL using device that has the same public IP, as your Home Assistant instance: %s ",
|
||||
self.two_factor_url
|
||||
)
|
||||
|
||||
successful = False
|
||||
|
||||
if successful:
|
||||
self.two_factor_url = None
|
||||
|
||||
return successful
|
||||
|
||||
def login_step_3(self) -> bool:
|
||||
headers = {
|
||||
"User-Agent": self._useragent,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
try:
|
||||
response = self._session.get(
|
||||
self._location, headers=headers, timeout=2)
|
||||
except:
|
||||
response = None
|
||||
successful = (
|
||||
response is not None
|
||||
and response.status_code == 200
|
||||
and "serviceToken" in response.cookies
|
||||
)
|
||||
if successful:
|
||||
self._serviceToken = response.cookies.get("serviceToken")
|
||||
return successful
|
||||
|
||||
def login(self) -> bool:
|
||||
self._session.close()
|
||||
self._session = requests.session()
|
||||
self._device_id = DreameVacuumCloudProtocol.generate_device_id()
|
||||
self._session.cookies.set(
|
||||
"sdkVersion", "3.8.6", domain="mi.com")
|
||||
self._session.cookies.set(
|
||||
"sdkVersion", "3.8.6", domain="xiaomi.com"
|
||||
)
|
||||
self._session.cookies.set("deviceId", self._device_id, domain="mi.com")
|
||||
self._session.cookies.set(
|
||||
"deviceId", self._device_id, domain="xiaomi.com")
|
||||
self._logged_in = (
|
||||
self.login_step_1() and self.login_step_2() and self.login_step_3()
|
||||
)
|
||||
if self._logged_in:
|
||||
self._fail_count = 0
|
||||
self._connected = True
|
||||
return self._logged_in
|
||||
|
||||
def get_file(self, url: str = "") -> Any:
|
||||
try:
|
||||
response = self._session.get(url, timeout=2)
|
||||
except Exception as ex:
|
||||
_LOGGER.warning("Unable to get file at %s: %s", url, ex)
|
||||
response = None
|
||||
if response is not None and response.status_code == 200:
|
||||
return response.content
|
||||
return None
|
||||
|
||||
def get_file_url(self, object_name: str = "") -> Any:
|
||||
api_response = self._api_call("home/getfileurl", {"obj_name": object_name})
|
||||
_LOGGER.info("Get file url result: %s", api_response)
|
||||
if (
|
||||
api_response is None
|
||||
or "result" not in api_response
|
||||
or "url" not in api_response["result"]
|
||||
):
|
||||
return None
|
||||
|
||||
return api_response
|
||||
|
||||
def get_interim_file_url(self, object_name: str = "") -> Any:
|
||||
_LOGGER.debug("Get interim file url: %s", object_name)
|
||||
api_response = self._api_call("v2/home/get_interim_file_url", {"obj_name": object_name})
|
||||
if (
|
||||
api_response is None
|
||||
or not api_response.get("result")
|
||||
or "url" not in api_response["result"]
|
||||
):
|
||||
return None
|
||||
|
||||
return api_response
|
||||
|
||||
def send(self, method, parameters) -> Any:
|
||||
api_response = self.request(f"{self.get_api_url()}/v2/home/rpc/{self.device_id}", {"data": json.dumps({"method": method, "params": parameters}, separators=(",", ":"))})
|
||||
if api_response is None or "result" not in api_response:
|
||||
return None
|
||||
return api_response["result"]
|
||||
|
||||
def get_device_property(self, key, limit=1, time_start=0, time_end=9999999999):
|
||||
return self.get_device_data(key, "prop", limit, time_start, time_end)
|
||||
|
||||
def get_device_event(self, key, limit=1, time_start=0, time_end=9999999999):
|
||||
return self.get_device_data(key, "event", limit, time_start, time_end)
|
||||
|
||||
def get_device_data(self, key, type, limit=1, time_start=0, time_end=9999999999):
|
||||
api_response = self._api_call("user/get_user_device_data", {
|
||||
"uid": str(self.user_id),
|
||||
"did": str(self.device_id),
|
||||
"time_end": time_end,
|
||||
"time_start": time_start,
|
||||
"limit": limit,
|
||||
"key": key,
|
||||
"type": type,
|
||||
})
|
||||
if api_response is None or "result" not in api_response:
|
||||
return None
|
||||
|
||||
return api_response["result"]
|
||||
|
||||
def get_info(self, mac: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
countries_to_check = ["cn", "de", "us", "ru", "tw", "sg", "in", "i2"]
|
||||
if self._country is not None:
|
||||
countries_to_check = [self._country]
|
||||
for self._country in countries_to_check:
|
||||
devices = self.get_devices()
|
||||
if devices is None:
|
||||
continue
|
||||
found = list(
|
||||
filter(lambda d: str(d["mac"]) ==
|
||||
mac, devices["result"]["list"])
|
||||
)
|
||||
if len(found) > 0:
|
||||
self.user_id = found[0]["uid"]
|
||||
self.device_id = found[0]["did"]
|
||||
return found[0]["token"], found[0]["localip"]
|
||||
return None, None
|
||||
|
||||
def get_devices(self) -> Any:
|
||||
return self._api_call("home/device_list", {"getVirtualModel":False,"getHuamiDevices":0})
|
||||
|
||||
def get_batch_device_datas(self, props) -> Any:
|
||||
api_response = self._api_call("device/batchdevicedatas",[{
|
||||
"did": self.device_id,
|
||||
"props": props
|
||||
}])
|
||||
if api_response is None or self.device_id not in api_response:
|
||||
return None
|
||||
return api_response[self.device_id]
|
||||
|
||||
def set_batch_device_datas(self, props) -> Any:
|
||||
api_response = self._api_call("v2/device/batch_set_props", [{
|
||||
"did": self.device_id,
|
||||
"props": props
|
||||
}])
|
||||
if api_response is None or "result" not in api_response:
|
||||
return None
|
||||
return api_response["result"]
|
||||
|
||||
def request(self, url: str, params: Dict[str, str]) -> Any:
|
||||
headers = {
|
||||
'User-Agent': self._useragent,
|
||||
'Accept-Encoding': 'identity',
|
||||
'x-xiaomi-protocal-flag-cli': 'PROTOCAL-HTTP2',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'MIOT-ENCRYPT-ALGORITHM': 'ENCRYPT-RC4'
|
||||
}
|
||||
cookies = {
|
||||
'userId': str(self._userId),
|
||||
'yetAnotherServiceToken': self._serviceToken,
|
||||
'serviceToken': self._serviceToken,
|
||||
'locale': str(self._locale),
|
||||
'timezone': str(self._timezone),
|
||||
'is_daylight': str(time.daylight),
|
||||
'dst_offset': str(time.localtime().tm_isdst*60*60*1000),
|
||||
'channel': 'MI_APP_STORE'
|
||||
}
|
||||
|
||||
nonce = self.generate_nonce()
|
||||
signed_nonce = self.signed_nonce(nonce)
|
||||
fields = self.generate_enc_params(
|
||||
url, "POST", signed_nonce, nonce, params, self._ssecurity
|
||||
)
|
||||
try:
|
||||
response = self._session.post(url, headers=headers, cookies=cookies, data=fields, timeout=3)
|
||||
self._fail_count = 0
|
||||
self._connected = True
|
||||
except Exception as ex:
|
||||
if self._connected:
|
||||
_LOGGER.warning("Error while executing request: %s %s", url, str(ex))
|
||||
|
||||
if self._fail_count == 5:
|
||||
self._connected = False
|
||||
else:
|
||||
self._fail_count = self._fail_count + 1
|
||||
return None
|
||||
|
||||
if response is not None:
|
||||
if response.status_code == 200:
|
||||
decoded = self.decrypt_rc4(
|
||||
self.signed_nonce(fields["_nonce"]), response.text
|
||||
)
|
||||
return json.loads(decoded)
|
||||
_LOGGER.warn("Execute api call failed with response: %s", response.text())
|
||||
return None
|
||||
|
||||
def get_api_url(self) -> str:
|
||||
return (
|
||||
"https://"
|
||||
+ ("" if self._country == "cn" else (self._country + "."))
|
||||
+ "api.io.mi.com/app"
|
||||
)
|
||||
|
||||
def signed_nonce(self, nonce: str) -> str:
|
||||
hash_object = hashlib.sha256(
|
||||
base64.b64decode(self._ssecurity) + base64.b64decode(nonce)
|
||||
)
|
||||
return base64.b64encode(hash_object.digest()).decode("utf-8")
|
||||
|
||||
def disconnect(self):
|
||||
self._session.close()
|
||||
self._connected = False
|
||||
self._logged_in = False
|
||||
|
||||
@staticmethod
|
||||
def generate_nonce():
|
||||
millis = int(round(time.time() * 1000))
|
||||
b = (random.getrandbits(64) - 2**63).to_bytes(8, 'big', signed=True)
|
||||
part2 = int(millis / 60000)
|
||||
b += part2.to_bytes(((part2.bit_length()+7)//8), 'big')
|
||||
return base64.b64encode(b).decode('utf-8')
|
||||
|
||||
@staticmethod
|
||||
def generate_device_id() -> str:
|
||||
return "".join((chr(random.randint(97, 122)) for _ in range(6)))
|
||||
|
||||
@staticmethod
|
||||
def generate_signature(
|
||||
url, signed_nonce: str, nonce: str, params: Dict[str, str]
|
||||
) -> str:
|
||||
signature_params = [url.split("com")[1], signed_nonce, nonce]
|
||||
for k, v in params.items():
|
||||
signature_params.append(f"{k}={v}")
|
||||
signature_string = "&".join(signature_params)
|
||||
signature = hmac.new(
|
||||
base64.b64decode(signed_nonce),
|
||||
msg=signature_string.encode(),
|
||||
digestmod=hashlib.sha256,
|
||||
)
|
||||
return base64.b64encode(signature.digest()).decode()
|
||||
|
||||
@staticmethod
|
||||
def generate_enc_signature(
|
||||
url, method: str, signed_nonce: str, params: Dict[str, str]
|
||||
) -> str:
|
||||
signature_params = [
|
||||
str(method).upper(),
|
||||
url.split("com")[1].replace("/app/", "/"),
|
||||
]
|
||||
for k, v in params.items():
|
||||
signature_params.append(f"{k}={v}")
|
||||
signature_params.append(signed_nonce)
|
||||
signature_string = "&".join(signature_params)
|
||||
return base64.b64encode(
|
||||
hashlib.sha1(signature_string.encode("utf-8")).digest()
|
||||
).decode()
|
||||
|
||||
@staticmethod
|
||||
def generate_enc_params(
|
||||
url: str,
|
||||
method: str,
|
||||
signed_nonce: str,
|
||||
nonce: str,
|
||||
params: Dict[str, str],
|
||||
ssecurity: str,
|
||||
) -> Dict[str, str]:
|
||||
params["rc4_hash__"] = DreameVacuumCloudProtocol.generate_enc_signature(
|
||||
url, method, signed_nonce, params
|
||||
)
|
||||
for k, v in params.items():
|
||||
params[k] = DreameVacuumCloudProtocol.encrypt_rc4(signed_nonce, v)
|
||||
params.update(
|
||||
{
|
||||
"signature": DreameVacuumCloudProtocol.generate_enc_signature(
|
||||
url, method, signed_nonce, params
|
||||
),
|
||||
"ssecurity": ssecurity,
|
||||
"_nonce": nonce,
|
||||
}
|
||||
)
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def to_json(response_text: str) -> Any:
|
||||
return json.loads(response_text.replace("&&&START&&&", ""))
|
||||
|
||||
@staticmethod
|
||||
def encrypt_rc4(password: str, payload: str) -> str:
|
||||
r = ARC4.new(base64.b64decode(password))
|
||||
r.encrypt(bytes(1024))
|
||||
return base64.b64encode(r.encrypt(payload.encode())).decode()
|
||||
|
||||
@staticmethod
|
||||
def decrypt_rc4(password: str, payload: str) -> bytes:
|
||||
r = ARC4.new(base64.b64decode(password))
|
||||
r.encrypt(bytes(1024))
|
||||
return r.encrypt(base64.b64decode(payload))
|
||||
|
||||
@staticmethod
|
||||
def get_random_agent_id() -> str:
|
||||
letters = "ABCDEF"
|
||||
result_str = "".join(random.choice(letters) for i in range(13))
|
||||
return result_str
|
||||
|
||||
class DreameVacuumProtocol:
|
||||
def __init__(
|
||||
self,
|
||||
ip: str = None,
|
||||
token: str = None,
|
||||
username: str = None,
|
||||
password: str = None,
|
||||
country: str = None,
|
||||
prefer_cloud: bool = False,
|
||||
) -> None:
|
||||
self.prefer_cloud = prefer_cloud
|
||||
self._connected = False
|
||||
self._mac = None
|
||||
|
||||
if ip and token:
|
||||
self.device = DreameVacuumDeviceProtocol(ip, token)
|
||||
else:
|
||||
self.prefer_cloud = True
|
||||
self.device = None
|
||||
|
||||
if username and password and country:
|
||||
self.cloud = DreameVacuumCloudProtocol(username, password, country)
|
||||
else:
|
||||
self.prefer_cloud = False
|
||||
self.cloud = None
|
||||
|
||||
self.device_cloud = DreameVacuumCloudProtocol(username, password, country) if prefer_cloud else None
|
||||
|
||||
def set_credentials(self, ip: str, token: str, mac: str = None):
|
||||
self._mac = mac;
|
||||
if ip and token:
|
||||
if self.device:
|
||||
self.device.set_credentials(ip, token)
|
||||
else:
|
||||
self.device = DreameVacuumDeviceProtocol(ip, token)
|
||||
else:
|
||||
self.device = None
|
||||
|
||||
def connect(self, retry_count=1) -> Any:
|
||||
response = self.send("miIO.info", retry_count=retry_count)
|
||||
if (self.prefer_cloud or not self.device) and self.device_cloud and response:
|
||||
self._connected = True
|
||||
return response
|
||||
|
||||
def disconnect(self):
|
||||
if self.device is not None:
|
||||
self.device.disconnect()
|
||||
if self.cloud is not None:
|
||||
self.cloud.disconnect()
|
||||
if self.device_cloud is not None:
|
||||
self.device_cloud.disconnect()
|
||||
self._connected = False
|
||||
|
||||
def send(self, method, parameters: Any = None, retry_count: int = 1) -> Any:
|
||||
if (self.prefer_cloud or not self.device) and self.device_cloud:
|
||||
if not self.device_cloud.logged_in:
|
||||
# Use different session for device cloud
|
||||
self.device_cloud.login()
|
||||
if self.device_cloud.logged_in and not self.device_cloud.device_id:
|
||||
if self.cloud.device_id:
|
||||
self.device_cloud.device_id = self.cloud.device_id
|
||||
elif self._mac:
|
||||
self.device_cloud.get_info(self._mac)
|
||||
|
||||
if not self.device_cloud.logged_in:
|
||||
raise DeviceException("Unable to login to device over cloud")
|
||||
|
||||
response = None
|
||||
for i in range(retry_count + 1):
|
||||
response = self.device_cloud.send(method, parameters=parameters)
|
||||
if response is not None:
|
||||
break
|
||||
|
||||
if response is None:
|
||||
self._connected = False
|
||||
raise DeviceException("Unable to discover the device over cloud") from None
|
||||
self._connected = True
|
||||
return response
|
||||
|
||||
if self.device:
|
||||
return self.device.send(method, parameters=parameters, retry_count=retry_count)
|
||||
|
||||
def get_properties(
|
||||
self,
|
||||
parameters: Any = None,
|
||||
retry_count: int = 1
|
||||
) -> Any:
|
||||
return self.send("get_properties", parameters=parameters, retry_count=retry_count)
|
||||
|
||||
def set_property(
|
||||
self,
|
||||
siid: int,
|
||||
piid: int,
|
||||
value: Any = None,
|
||||
retry_count: int = 1
|
||||
) -> Any:
|
||||
return self.set_properties([{
|
||||
"did": f'{siid}.{piid}',
|
||||
"siid": siid,
|
||||
"piid": piid,
|
||||
"value": value,
|
||||
}
|
||||
], retry_count=retry_count)
|
||||
|
||||
def set_properties(
|
||||
self,
|
||||
parameters: Any = None,
|
||||
retry_count: int = 1
|
||||
) -> Any:
|
||||
return self.send("set_properties", parameters=parameters, retry_count=retry_count)
|
||||
|
||||
def action(
|
||||
self,
|
||||
siid: int,
|
||||
aiid: int,
|
||||
parameters=[],
|
||||
retry_count: int = 1
|
||||
) -> Any:
|
||||
if parameters is None:
|
||||
parameters = []
|
||||
|
||||
return self.send(
|
||||
"action",
|
||||
parameters={
|
||||
"did": f'{siid}.{aiid}',
|
||||
"siid": siid,
|
||||
"aiid": aiid,
|
||||
"in": parameters,
|
||||
},
|
||||
retry_count=retry_count,
|
||||
)
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
if (self.prefer_cloud or not self.device) and self.device_cloud:
|
||||
return self.device_cloud.logged_in and self._connected
|
||||
|
||||
if self.device:
|
||||
return self.device.connected
|
||||
|
||||
return False
|
||||
243
custom_components/dreame_vacuum/dreame/resources.py
Normal file
243
custom_components/dreame_vacuum/dreame/resources.py
Normal file
File diff suppressed because one or more lines are too long
1652
custom_components/dreame_vacuum/dreame/types.py
Normal file
1652
custom_components/dreame_vacuum/dreame/types.py
Normal file
File diff suppressed because it is too large
Load Diff
180
custom_components/dreame_vacuum/entity.py
Normal file
180
custom_components/dreame_vacuum/entity.py
Normal file
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .coordinator import DreameVacuumDataUpdateCoordinator
|
||||
from .const import DOMAIN, LOGGER, ATTR_VALUE
|
||||
from .dreame import (
|
||||
DreameVacuumDevice,
|
||||
DreameVacuumProperty,
|
||||
DreameVacuumAction,
|
||||
DeviceException,
|
||||
DeviceUpdateFailedException,
|
||||
InvalidActionException,
|
||||
InvalidValueException,
|
||||
PROPERTY_TO_NAME,
|
||||
ACTION_TO_NAME,
|
||||
PROPERTY_AVAILABILITY,
|
||||
ACTION_AVAILABILITY,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DreameVacuumEntityDescription:
|
||||
key: str = None
|
||||
name: str = None
|
||||
entity_category: str = None
|
||||
property_key: DreameVacuumProperty = None
|
||||
action_key: DreameVacuumAction = None
|
||||
exists_fn: Callable[[object, object], bool] = lambda description, device: bool(
|
||||
(
|
||||
description.action_key is not None
|
||||
and description.action_key in device.action_mapping
|
||||
)
|
||||
or description.property_key is None
|
||||
or description.property_key.value in device.data
|
||||
)
|
||||
value_fn: Callable[[object, object], Any] = None
|
||||
format_fn: Callable[[str, object], Any] = None
|
||||
available_fn: Callable[[object], bool] = None
|
||||
icon_fn: Callable[[str, object], str] = None
|
||||
attrs_fn: Callable[[object, Dict]] = None
|
||||
|
||||
|
||||
class DreameVacuumEntity(CoordinatorEntity[DreameVacuumDataUpdateCoordinator]):
|
||||
"""Defines a base Dreame Vacuum entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DreameVacuumDataUpdateCoordinator,
|
||||
description: DreameVacuumEntityDescription = None,
|
||||
) -> None:
|
||||
if description is not None:
|
||||
if description.key is None:
|
||||
if description.property_key is not None:
|
||||
name = PROPERTY_TO_NAME.get(description.property_key)
|
||||
if name:
|
||||
description.key = name[0]
|
||||
description.name = name[1]
|
||||
else:
|
||||
description.key = description.property_key.name.lower()
|
||||
elif description.action_key is not None:
|
||||
name = ACTION_TO_NAME.get(description.action_key)
|
||||
if name:
|
||||
description.key = name[0]
|
||||
description.name = name[1]
|
||||
else:
|
||||
description.key = description.action_key.name.lower()
|
||||
|
||||
if description.name is None and description.key is not None:
|
||||
description.name = description.key.replace("_", " ").title()
|
||||
elif description.key is None and description.name is not None:
|
||||
description.key = description.name.lower().replace(" ", "_").replace("-", "_")
|
||||
|
||||
if description.available_fn is None:
|
||||
if description.property_key is not None:
|
||||
description.available_fn = PROPERTY_AVAILABILITY.get(
|
||||
description.property_key)
|
||||
elif description.action_key is not None:
|
||||
description.available_fn = ACTION_AVAILABILITY.get(
|
||||
description.action_key)
|
||||
|
||||
super().__init__(coordinator=coordinator)
|
||||
if description:
|
||||
if description.key is not None:
|
||||
self._attr_translation_key = description.key
|
||||
self.entity_description = description
|
||||
self._set_id()
|
||||
|
||||
def _set_id(self) -> None:
|
||||
if self.entity_description:
|
||||
if self.entity_description.icon_fn is not None:
|
||||
self._attr_icon = self.entity_description.icon_fn(
|
||||
self.native_value, self.device
|
||||
)
|
||||
|
||||
self._attr_name = f"{self.device.name} {self.entity_description.name}"
|
||||
self._attr_unique_id = f"{self.device.mac}_{self.entity_description.key}"
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
if self.entity_description.icon_fn is not None:
|
||||
self._attr_icon = self.entity_description.icon_fn(
|
||||
self.native_value, self.device
|
||||
)
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
async def _try_command(self, mask_error, func, *args, **kwargs) -> bool:
|
||||
"""Call a vacuum command handling error messages."""
|
||||
try:
|
||||
await self.hass.async_add_executor_job(partial(func, *args, **kwargs))
|
||||
return True
|
||||
except (InvalidActionException, InvalidValueException) as exc:
|
||||
LOGGER.error(mask_error, exc)
|
||||
raise ValueError(str(exc)) from None
|
||||
except (DeviceUpdateFailedException, DeviceException) as exc:
|
||||
if self.coordinator._available:
|
||||
raise HomeAssistantError(str(exc)) from None
|
||||
return False
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device information about this Dreame Vacuum device."""
|
||||
return DeviceInfo(
|
||||
connections={(CONNECTION_NETWORK_MAC, self.device.mac)},
|
||||
identifiers={(DOMAIN, self.device.mac)},
|
||||
name=self.device.name,
|
||||
manufacturer=self.device.info.manufacturer,
|
||||
model=self.device.info.model,
|
||||
sw_version=self.device.info.firmware_version,
|
||||
hw_version=self.device.info.hardware_version,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
if not self.device.device_connected:
|
||||
return False
|
||||
|
||||
if self.entity_description.available_fn is not None:
|
||||
return self.entity_description.available_fn(self.device)
|
||||
return self._attr_available
|
||||
|
||||
@property
|
||||
def native_value(self) -> Any:
|
||||
"""Return the native value of the entity."""
|
||||
value = None
|
||||
if self.entity_description.property_key is not None:
|
||||
value = self.device.get_property(
|
||||
self.entity_description.property_key)
|
||||
if self.entity_description.value_fn is not None:
|
||||
return self.entity_description.value_fn(value, self.device)
|
||||
return value
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, str] | None:
|
||||
"""Return the extra state attributes of the entity."""
|
||||
attrs = None
|
||||
if self.entity_description.value_fn is not None:
|
||||
if self.entity_description.property_key is not None:
|
||||
attrs = {
|
||||
ATTR_VALUE: self.device.get_property(
|
||||
self.entity_description.property_key
|
||||
)
|
||||
}
|
||||
elif self.entity_description.attrs_fn is not None:
|
||||
attrs = self.entity_description.attrs_fn(self.device)
|
||||
return attrs
|
||||
|
||||
@property
|
||||
def device(self) -> DreameVacuumDevice:
|
||||
return self.coordinator.device
|
||||
23
custom_components/dreame_vacuum/manifest.json
Normal file
23
custom_components/dreame_vacuum/manifest.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"domain": "dreame_vacuum",
|
||||
"name": "Dreame Vacuum",
|
||||
"codeowners": [
|
||||
"@tasshack"
|
||||
],
|
||||
"config_flow": true,
|
||||
"documentation": "https://github.com/Tasshack/dreame-vacuum",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/Tasshack/dreame-vacuum/issues",
|
||||
"requirements": [
|
||||
"pillow",
|
||||
"numpy",
|
||||
"pybase64",
|
||||
"requests",
|
||||
"pycryptodome",
|
||||
"python-miio",
|
||||
"py-mini-racer",
|
||||
"tzlocal",
|
||||
"paho-mqtt"
|
||||
],
|
||||
"version": "v1.0.4"
|
||||
}
|
||||
177
custom_components/dreame_vacuum/number.py
Normal file
177
custom_components/dreame_vacuum/number.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Support for Dreame Vacuum numbers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
NumberMode,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_platform
|
||||
|
||||
from .const import DOMAIN, UNIT_MINUTES
|
||||
|
||||
from .coordinator import DreameVacuumDataUpdateCoordinator
|
||||
from .entity import DreameVacuumEntity, DreameVacuumEntityDescription
|
||||
from .dreame import DreameVacuumAction, DreameVacuumProperty
|
||||
|
||||
|
||||
@dataclass
|
||||
class DreameVacuumNumberEntityDescription(
|
||||
DreameVacuumEntityDescription, NumberEntityDescription
|
||||
):
|
||||
"""Describes Dreame Vacuum Number entity."""
|
||||
|
||||
mode: NumberMode = NumberMode.AUTO
|
||||
post_action: DreameVacuumAction = None
|
||||
|
||||
|
||||
NUMBERS: tuple[DreameVacuumNumberEntityDescription, ...] = (
|
||||
DreameVacuumNumberEntityDescription(
|
||||
property_key=DreameVacuumProperty.VOLUME,
|
||||
icon_fn=lambda value, device: "mdi:volume-off"
|
||||
if value == 0
|
||||
else "mdi:volume-high",
|
||||
mode=NumberMode.SLIDER,
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_step=1,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
post_action=DreameVacuumAction.TEST_SOUND,
|
||||
),
|
||||
DreameVacuumNumberEntityDescription(
|
||||
property_key=DreameVacuumProperty.MOP_CLEANING_REMAINDER,
|
||||
icon="mdi:alarm-check",
|
||||
mode=NumberMode.BOX,
|
||||
native_unit_of_measurement=UNIT_MINUTES,
|
||||
native_min_value=0,
|
||||
native_max_value=180,
|
||||
native_step=15,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumNumberEntityDescription(
|
||||
property_key=DreameVacuumProperty.DND_START,
|
||||
key="dnd_start_hour",
|
||||
icon="mdi:clock-start",
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=0,
|
||||
native_max_value=23,
|
||||
native_step=1,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda value, device: value.split(":")[0],
|
||||
format_fn=lambda value, device: "{:02d}:".format(value)
|
||||
+ device.status.dnd_start.split(":")[1],
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumNumberEntityDescription(
|
||||
property_key=DreameVacuumProperty.DND_START,
|
||||
key="dnd_start_minute",
|
||||
icon="mdi:clock-start",
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=0,
|
||||
native_max_value=59,
|
||||
native_step=1,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda value, device: value.split(":")[1],
|
||||
format_fn=lambda value, device: device.status.dnd_start.split(":")[0]
|
||||
+ ":{:02d}".format(value),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumNumberEntityDescription(
|
||||
property_key=DreameVacuumProperty.DND_END,
|
||||
key="dnd_end_hour",
|
||||
icon="mdi:clock-end",
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=0,
|
||||
native_max_value=23,
|
||||
native_step=1,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda value, device: value.split(":")[0],
|
||||
format_fn=lambda value, device: "{:02d}:".format(value)
|
||||
+ device.status.dnd_end.split(":")[1],
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumNumberEntityDescription(
|
||||
property_key=DreameVacuumProperty.DND_END,
|
||||
key="dnd_end_minute",
|
||||
icon="mdi:clock-end",
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=0,
|
||||
native_max_value=59,
|
||||
native_step=1,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda value, device: value.split(":")[1],
|
||||
format_fn=lambda value, device: device.status.dnd_end.split(":")[0]
|
||||
+ ":{:02d}".format(value),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Dreame Vacuum number based on a config entry."""
|
||||
coordinator: DreameVacuumDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
DreameVacuumNumberEntity(coordinator, description)
|
||||
for description in NUMBERS
|
||||
if description.exists_fn(description, coordinator.device)
|
||||
)
|
||||
|
||||
|
||||
class DreameVacuumNumberEntity(DreameVacuumEntity, NumberEntity):
|
||||
"""Defines a Dreame Vacuum number."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DreameVacuumDataUpdateCoordinator,
|
||||
description: DreameVacuumNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize Dreame Vacuum ."""
|
||||
super().__init__(coordinator, description)
|
||||
self._attr_mode = description.mode
|
||||
self._attr_native_value = super().native_value
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
self._attr_native_value = super().native_value
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the Dreame Vacuum number value."""
|
||||
if not self.available:
|
||||
raise HomeAssistantError("Entity unavailable")
|
||||
|
||||
value = int(value)
|
||||
if self.entity_description.format_fn is not None:
|
||||
value = self.entity_description.format_fn(value, self.device)
|
||||
|
||||
if value is None:
|
||||
raise HomeAssistantError("Invalid value")
|
||||
|
||||
if await self._try_command(
|
||||
"Unable to call %s",
|
||||
self.device.set_property,
|
||||
self.entity_description.property_key,
|
||||
value,
|
||||
):
|
||||
if self.entity_description.post_action is not None:
|
||||
await self._try_command(
|
||||
"Unable to call %s",
|
||||
self.device.call_action,
|
||||
self.entity_description.post_action,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
"""Return the current Dreame Vacuum number value."""
|
||||
return self._attr_native_value
|
||||
736
custom_components/dreame_vacuum/select.py
Normal file
736
custom_components/dreame_vacuum/select.py
Normal file
@@ -0,0 +1,736 @@
|
||||
"""Support for Dreame Vacuum selects."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import voluptuous as vol
|
||||
from typing import Any
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
|
||||
from homeassistant.components.select import (
|
||||
SelectEntity,
|
||||
SelectEntityDescription,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_platform, entity_registry
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
UNIT_HOURS,
|
||||
UNIT_TIMES,
|
||||
INPUT_CYCLE,
|
||||
SERVICE_SELECT_NEXT,
|
||||
SERVICE_SELECT_PREVIOUS,
|
||||
SERVICE_SELECT_FIRST,
|
||||
SERVICE_SELECT_LAST,
|
||||
)
|
||||
|
||||
from .coordinator import DreameVacuumDataUpdateCoordinator
|
||||
from .entity import (
|
||||
DreameVacuumEntity,
|
||||
DreameVacuumEntityDescription,
|
||||
)
|
||||
|
||||
from .dreame import (
|
||||
DreameVacuumProperty,
|
||||
DreameVacuumSuctionLevel,
|
||||
DreameVacuumCleaningMode,
|
||||
DreameVacuumWaterVolume,
|
||||
DreameVacuumSelfCleanArea,
|
||||
DreameVacuumMopPadHumidity,
|
||||
DreameVacuumCarpetSensitivity,
|
||||
DreameVacuumMopWashLevel,
|
||||
DreameVacuumMoppingType,
|
||||
SUCTION_LEVEL_CODE_TO_NAME,
|
||||
WATER_VOLUME_CODE_TO_NAME,
|
||||
MOP_PAD_HUMIDITY_CODE_TO_NAME,
|
||||
)
|
||||
|
||||
SUCTION_LEVEL_TO_ICON = {
|
||||
DreameVacuumSuctionLevel.QUIET: "mdi:fan-speed-1",
|
||||
DreameVacuumSuctionLevel.STANDARD: "mdi:fan-speed-2",
|
||||
DreameVacuumSuctionLevel.STRONG: "mdi:fan-speed-3",
|
||||
DreameVacuumSuctionLevel.TURBO: "mdi:weather-windy",
|
||||
}
|
||||
|
||||
WATER_VOLUME_TO_ICON = {
|
||||
DreameVacuumWaterVolume.LOW: "mdi:water-minus",
|
||||
DreameVacuumWaterVolume.MEDIUM: "mdi:water",
|
||||
DreameVacuumWaterVolume.HIGH: "mdi:water-plus",
|
||||
}
|
||||
|
||||
MOP_PAD_HUMIDITY_TO_ICON = {
|
||||
DreameVacuumMopPadHumidity.SLIGHTLY_DRY: "mdi:water-minus",
|
||||
DreameVacuumMopPadHumidity.MOIST: "mdi:water",
|
||||
DreameVacuumMopPadHumidity.WET: "mdi:water-plus",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DreameVacuumSelectEntityDescription(
|
||||
DreameVacuumEntityDescription, SelectEntityDescription
|
||||
):
|
||||
"""Describes Dreame Vacuum Select entity."""
|
||||
|
||||
set_fn: Callable[[object, int, int]] = None
|
||||
options: Callable[[object, object], list[str]] = None
|
||||
value_int_fn: Callable[[object, str], int] = None
|
||||
|
||||
|
||||
SELECTS: tuple[DreameVacuumSelectEntityDescription, ...] = (
|
||||
DreameVacuumSelectEntityDescription(
|
||||
property_key=DreameVacuumProperty.SUCTION_LEVEL,
|
||||
device_class=f"{DOMAIN}__suction_level",
|
||||
icon_fn=lambda value, device: "mdi:fan-off"
|
||||
if device.status.cleaning_mode is DreameVacuumCleaningMode.MOPPING
|
||||
else SUCTION_LEVEL_TO_ICON.get(device.status.suction_level, "mdi:fan"),
|
||||
options=lambda device, segment: list(device.status.suction_level_list),
|
||||
value_int_fn=lambda value, device: DreameVacuumSuctionLevel[value.upper()],
|
||||
),
|
||||
DreameVacuumSelectEntityDescription(
|
||||
property_key=DreameVacuumProperty.WATER_VOLUME,
|
||||
device_class=f"{DOMAIN}__water_volume",
|
||||
icon_fn=lambda value, device: "mdi:water-off"
|
||||
if (
|
||||
not device.status.water_tank_or_mop_installed
|
||||
or device.status.cleaning_mode is DreameVacuumCleaningMode.SWEEPING
|
||||
)
|
||||
else WATER_VOLUME_TO_ICON.get(device.status.water_volume, "mdi:water"),
|
||||
options=lambda device, segment: list(device.status.water_volume_list),
|
||||
value_int_fn=lambda value, device: DreameVacuumWaterVolume[value.upper()],
|
||||
exists_fn=lambda description, device: bool(
|
||||
not device.status.self_wash_base_available and
|
||||
DreameVacuumEntityDescription().exists_fn(description, device)
|
||||
),
|
||||
),
|
||||
DreameVacuumSelectEntityDescription(
|
||||
property_key=DreameVacuumProperty.CLEANING_MODE,
|
||||
device_class=f"{DOMAIN}__cleaning_mode",
|
||||
icon_fn=lambda value, device: "mdi:hydro-power"
|
||||
if device.status.cleaning_mode is DreameVacuumCleaningMode.SWEEPING_AND_MOPPING
|
||||
else "mdi:cup-water"
|
||||
if device.status.cleaning_mode is DreameVacuumCleaningMode.MOPPING
|
||||
else "mdi:broom",
|
||||
options=lambda device, segment: list(device.status.cleaning_mode_list),
|
||||
value_fn=lambda value, device: device.status.cleaning_mode_name,
|
||||
value_int_fn=lambda value, device: DreameVacuumCleaningMode[value.upper()],
|
||||
set_fn=lambda device, map_id, value: device.set_cleaning_mode(value),
|
||||
),
|
||||
DreameVacuumSelectEntityDescription(
|
||||
property_key=DreameVacuumProperty.CARPET_SENSITIVITY,
|
||||
device_class=f"{DOMAIN}__carpet_sensitivity",
|
||||
icon="mdi:rug",
|
||||
options=lambda device, segment: list(device.status.carpet_sensitivity_list),
|
||||
value_int_fn=lambda value, device: DreameVacuumCarpetSensitivity[value.upper()],
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSelectEntityDescription(
|
||||
property_key=DreameVacuumProperty.AUTO_EMPTY_FREQUENCY,
|
||||
icon_fn=lambda value, device: f"mdi:numeric-{value[0]}-box-multiple-outline",
|
||||
options=lambda device, segment: [f"{i}{UNIT_TIMES}" for i in range(1, 4)],
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda value, device: f"{value}{UNIT_TIMES}",
|
||||
value_int_fn=lambda value, device: int(value[0]),
|
||||
),
|
||||
DreameVacuumSelectEntityDescription(
|
||||
property_key=DreameVacuumProperty.DRYING_TIME,
|
||||
icon="mdi:hair-dryer",
|
||||
options=lambda device, segment: [f"{i}h" for i in range(2, 5)],
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda value, device: f"{value}h",
|
||||
value_int_fn=lambda value, device: int(value[0]),
|
||||
),
|
||||
DreameVacuumSelectEntityDescription(
|
||||
property_key=DreameVacuumProperty.MOP_WASH_LEVEL,
|
||||
device_class=f"{DOMAIN}__mop_wash_level",
|
||||
icon="mdi:water-opacity",
|
||||
options=lambda device, segment: list(device.status.mop_wash_level_list),
|
||||
value_int_fn=lambda value, device: DreameVacuumMopWashLevel[value.upper()],
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSelectEntityDescription(
|
||||
key="mop_pad_humidity",
|
||||
device_class=f"{DOMAIN}__mop_pad_humidity",
|
||||
icon_fn=lambda value, device: "mdi:water-off"
|
||||
if (
|
||||
not device.status.water_tank_or_mop_installed
|
||||
or device.status.cleaning_mode is DreameVacuumCleaningMode.SWEEPING
|
||||
)
|
||||
else MOP_PAD_HUMIDITY_TO_ICON.get(device.status.mop_pad_humidity, "mdi:water-percent"),
|
||||
options=lambda device, segment: list(device.status.mop_pad_humidity_list),
|
||||
value_fn=lambda value, device: device.status.mop_pad_humidity_name,
|
||||
value_int_fn=lambda value, device: DreameVacuumMopPadHumidity[value.upper()],
|
||||
exists_fn=lambda description, device: device.status.self_wash_base_available,
|
||||
available_fn=lambda device: device.status.water_tank_or_mop_installed and not device.status.sweeping and not (device.status.customized_cleaning and not (device.status.zone_cleaning or device.status.spot_cleaning)) and not device.status.fast_mapping and not device.status.started,
|
||||
set_fn=lambda device, map_id, value: device.set_mop_pad_humidity(value),
|
||||
),
|
||||
DreameVacuumSelectEntityDescription(
|
||||
key="self_clean_area",
|
||||
device_class=f"{DOMAIN}__self_clean_area",
|
||||
icon="mdi:texture-box",
|
||||
options=lambda device, segment: list(device.status.self_clean_area_list),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda value, device: device.status.self_clean_area_name,
|
||||
value_int_fn=lambda value, device: DreameVacuumSelfCleanArea[value.upper()],
|
||||
exists_fn=lambda description, device: device.status.self_wash_base_available,
|
||||
available_fn=lambda device: device.status.self_clean and not device.status.started and not device.status.fast_mapping and not device.status.cleaning_paused,
|
||||
set_fn=lambda device, map_id, value: device.set_self_clean_area(value),
|
||||
),
|
||||
DreameVacuumSelectEntityDescription(
|
||||
key="mopping_type",
|
||||
device_class=f"{DOMAIN}__mopping_type",
|
||||
icon="mdi:spray-bottle",
|
||||
options=lambda device, segment: list(device.status.mopping_type_list),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda value, device: device.status.mopping_type_name,
|
||||
value_int_fn=lambda value, device: DreameVacuumMoppingType[value.upper()],
|
||||
exists_fn=lambda description, device: device.status.auto_switch_settings_available and device.status.mopping_type is not None,
|
||||
available_fn=lambda device: not device.status.started and not device.status.fast_mapping and not device.status.cleaning_paused,
|
||||
set_fn=lambda device, map_id, value: device.set_mopping_type(value),
|
||||
),
|
||||
DreameVacuumSelectEntityDescription(
|
||||
key="map_rotation",
|
||||
icon="mdi:crop-rotate",
|
||||
options=lambda device, segment: ["0", "90", "180", "270"],
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda value, device: str(device.status.selected_map.rotation)
|
||||
if device.status.selected_map
|
||||
and device.status.selected_map.rotation is not None
|
||||
else "",
|
||||
exists_fn=lambda description, device: device.status.map_available,
|
||||
available_fn=lambda device: bool(
|
||||
device.status.selected_map is not None
|
||||
and device.status.selected_map.rotation is not None
|
||||
and not device.status.fast_mapping
|
||||
and device.status.has_saved_map
|
||||
),
|
||||
set_fn=lambda device, map_id, value: device.set_map_rotation(
|
||||
device.status.selected_map.map_id, value
|
||||
),
|
||||
),
|
||||
DreameVacuumSelectEntityDescription(
|
||||
key="selected_map",
|
||||
icon="mdi:map-check",
|
||||
options=lambda device, segment: [
|
||||
v.map_name for k, v in device.status.map_data_list.items()
|
||||
],
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda value, device: device.status.selected_map.map_name
|
||||
if device.status.selected_map and device.status.selected_map.map_name
|
||||
else "",
|
||||
exists_fn=lambda description, device: device.status.map_available,# and device.status.lidar_navigation,
|
||||
available_fn=lambda device: bool(
|
||||
device.status.multi_map
|
||||
and not device.status.fast_mapping
|
||||
and device.status.map_list
|
||||
and device.status.selected_map
|
||||
and device.status.selected_map.map_name
|
||||
and device.status.selected_map.map_id in device.status.map_list
|
||||
),
|
||||
value_int_fn=lambda value, device: next(
|
||||
(k for k, v in device.status.map_data_list.items() if v.map_name == value),
|
||||
None,
|
||||
),
|
||||
set_fn=lambda device, map_id, value: device.select_map(value),
|
||||
attrs_fn=lambda device: {"map_id": device.status.selected_map.map_id, "map_index": device.status.selected_map.map_index}
|
||||
if device.status.selected_map
|
||||
else None,
|
||||
),
|
||||
)
|
||||
|
||||
SEGMENT_SELECTS: tuple[DreameVacuumSelectEntityDescription, ...] = (
|
||||
DreameVacuumSelectEntityDescription(
|
||||
key="suction_level",
|
||||
device_class=f"{DOMAIN}__suction_level",
|
||||
icon_fn=lambda value, segment: SUCTION_LEVEL_TO_ICON.get(
|
||||
segment.suction_level, "mdi:fan"
|
||||
)
|
||||
if segment
|
||||
else "mdi:fan-off",
|
||||
options=lambda device, segment: list(device.status.suction_level_list),
|
||||
available_fn=lambda device: bool(
|
||||
device.status.segments
|
||||
and next(iter(device.status.segments.values())).suction_level is not None
|
||||
and device.status.customized_cleaning
|
||||
and not (device.status.zone_cleaning or device.status.spot_cleaning)
|
||||
and not device.status.fast_mapping
|
||||
),
|
||||
value_fn=lambda device, segment: SUCTION_LEVEL_CODE_TO_NAME.get(
|
||||
segment.suction_level, STATE_UNKNOWN
|
||||
),
|
||||
value_int_fn=lambda value, self: DreameVacuumSuctionLevel[value.upper()],
|
||||
set_fn=lambda device, segment_id, value: device.set_segment_suction_level(
|
||||
segment_id, value
|
||||
),
|
||||
exists_fn=lambda description, device: device.status.customized_cleaning_available,
|
||||
),
|
||||
DreameVacuumSelectEntityDescription(
|
||||
key="water_volume",
|
||||
device_class=f"{DOMAIN}__water_volume",
|
||||
icon_fn=lambda value, segment: WATER_VOLUME_TO_ICON.get(
|
||||
segment.water_volume, "mdi:water"
|
||||
)
|
||||
if segment
|
||||
else "mdi:water-off",
|
||||
options=lambda device, segment: list(device.status.water_volume_list),
|
||||
available_fn=lambda device: bool(
|
||||
device.status.segments
|
||||
and next(iter(device.status.segments.values())).water_volume is not None
|
||||
and device.status.customized_cleaning
|
||||
and not (device.status.zone_cleaning or device.status.spot_cleaning)
|
||||
and not device.status.fast_mapping
|
||||
),
|
||||
value_fn=lambda device, segment: WATER_VOLUME_CODE_TO_NAME.get(
|
||||
segment.water_volume, STATE_UNKNOWN
|
||||
),
|
||||
value_int_fn=lambda value, self: DreameVacuumWaterVolume[value.upper()],
|
||||
set_fn=lambda device, segment_id, value: device.set_segment_water_volume(
|
||||
segment_id, value
|
||||
),
|
||||
exists_fn=lambda description, device: device.status.customized_cleaning_available and not device.status.self_wash_base_available,
|
||||
),
|
||||
DreameVacuumSelectEntityDescription(
|
||||
key="mop_pad_humidity",
|
||||
device_class=f"{DOMAIN}__mop_pad_humidity",
|
||||
icon_fn=lambda value, segment: MOP_PAD_HUMIDITY_TO_ICON.get(
|
||||
segment.water_volume, "mdi:water-percent"
|
||||
)
|
||||
if segment
|
||||
else "mdi:water-off",
|
||||
options=lambda device, segment: list(device.status.mop_pad_humidity_list),
|
||||
available_fn=lambda device: bool(
|
||||
device.status.segments
|
||||
and next(iter(device.status.segments.values())).mop_pad_humidity is not None
|
||||
and device.status.customized_cleaning
|
||||
and not (device.status.zone_cleaning or device.status.spot_cleaning)
|
||||
and not device.status.fast_mapping
|
||||
),
|
||||
value_fn=lambda device, segment: MOP_PAD_HUMIDITY_CODE_TO_NAME.get(
|
||||
segment.mop_pad_humidity, STATE_UNKNOWN
|
||||
),
|
||||
value_int_fn=lambda value, self: DreameVacuumMopPadHumidity[value.upper()],
|
||||
set_fn=lambda device, segment_id, value: device.set_segment_mop_pad_humidity(
|
||||
segment_id, value
|
||||
),
|
||||
exists_fn=lambda description, device: device.status.customized_cleaning_available and device.status.self_wash_base_available,
|
||||
),
|
||||
DreameVacuumSelectEntityDescription(
|
||||
key="cleaning_times",
|
||||
icon_fn=lambda value, segment: "mdi:home-floor-" + str(segment.cleaning_times)
|
||||
if segment and segment.cleaning_times and segment.cleaning_times < 4
|
||||
else "mdi:home-floor-0",
|
||||
options=lambda device, segment: [f"{i}{UNIT_TIMES}" for i in range(1, 4)],
|
||||
available_fn=lambda device: bool(
|
||||
device.status.segments
|
||||
and next(iter(device.status.segments.values())).cleaning_times is not None
|
||||
and device.status.customized_cleaning
|
||||
and not (device.status.zone_cleaning or device.status.spot_cleaning)
|
||||
and not device.status.started
|
||||
and not device.status.fast_mapping
|
||||
),
|
||||
value_fn=lambda device, segment: f"{segment.cleaning_times}{UNIT_TIMES}",
|
||||
value_int_fn=lambda value, self: int(value[0]),
|
||||
set_fn=lambda device, segment_id, value: device.set_segment_cleaning_times(
|
||||
segment_id, value
|
||||
),
|
||||
exists_fn=lambda description, device: device.status.customized_cleaning_available,
|
||||
),
|
||||
DreameVacuumSelectEntityDescription(
|
||||
key="order",
|
||||
options=lambda device, segment: [
|
||||
str(i) for i in range(1, len(device.status.segments.values()) + 1)
|
||||
]
|
||||
if device.status.segments
|
||||
else [STATE_UNAVAILABLE],
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
available_fn=lambda device: bool(
|
||||
device.status.segments
|
||||
and next(iter(device.status.segments.values())).order is not None
|
||||
and not device.status.started
|
||||
and device.status.custom_order
|
||||
and device.status.has_saved_map
|
||||
and not device.status.fast_mapping
|
||||
),
|
||||
value_fn=lambda device, segment: str(segment.order) if segment.order else STATE_UNAVAILABLE,
|
||||
set_fn=lambda device, segment_id, value: device.set_segment_order(
|
||||
segment_id, value
|
||||
)
|
||||
if value > 0
|
||||
else None,
|
||||
exists_fn=lambda description, device: device.status.customized_cleaning_available,
|
||||
),
|
||||
DreameVacuumSelectEntityDescription(
|
||||
name="",
|
||||
key="name",
|
||||
options=lambda device, segment: list(segment.name_list(device.status.segments)),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
available_fn=lambda device: bool(
|
||||
device.status.segments
|
||||
and not device.status.fast_mapping
|
||||
and not device.status.has_temporary_map
|
||||
),
|
||||
value_fn=lambda device, segment: device.status.segments[segment.segment_id].name
|
||||
if segment.segment_id in device.status.segments
|
||||
else None,
|
||||
value_int_fn=lambda value, self: next(
|
||||
(
|
||||
type
|
||||
for name, type in self.segment.name_list(
|
||||
self.device.status.segments
|
||||
).items()
|
||||
if name == value
|
||||
),
|
||||
None,
|
||||
),
|
||||
set_fn=lambda device, segment_id, value: device.set_segment_name(
|
||||
segment_id, value
|
||||
),
|
||||
attrs_fn=lambda segment: {
|
||||
"room_id": segment.segment_id,
|
||||
"index": segment.index,
|
||||
"type": segment.type,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Dreame Vacuum select based on a config entry."""
|
||||
coordinator: DreameVacuumDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
DreameVacuumSelectEntity(coordinator, description)
|
||||
for description in SELECTS
|
||||
if description.exists_fn(description, coordinator.device)
|
||||
)
|
||||
platform = entity_platform.current_platform.get()
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SELECT_NEXT,
|
||||
{vol.Optional(INPUT_CYCLE, default=True): bool},
|
||||
DreameVacuumSelectEntity.async_next.__name__,
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SELECT_PREVIOUS,
|
||||
{vol.Optional(INPUT_CYCLE, default=True): bool},
|
||||
DreameVacuumSelectEntity.async_previous.__name__,
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SELECT_FIRST, {}, DreameVacuumSelectEntity.async_first.__name__
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SELECT_LAST, {}, DreameVacuumSelectEntity.async_last.__name__
|
||||
)
|
||||
|
||||
update_segment_selects = partial(
|
||||
async_update_segment_selects, coordinator, {}, async_add_entities
|
||||
)
|
||||
coordinator.async_add_listener(update_segment_selects)
|
||||
update_segment_selects()
|
||||
|
||||
|
||||
@callback
|
||||
def async_update_segment_selects(
|
||||
coordinator: DreameVacuumDataUpdateCoordinator,
|
||||
current: dict[str, list[DreameVacuumSegmentSelectEntity]],
|
||||
async_add_entities,
|
||||
) -> None:
|
||||
new_ids = []
|
||||
if coordinator.device and coordinator.device.status.map_list:
|
||||
for (k, v) in coordinator.device.status.map_data_list.items():
|
||||
for (j, s) in v.segments.items():
|
||||
if j not in new_ids:
|
||||
new_ids.append(j)
|
||||
|
||||
new_ids = set(new_ids)
|
||||
current_ids = set(current)
|
||||
|
||||
for segment_id in current_ids - new_ids:
|
||||
async_remove_segment_selects(segment_id, coordinator, current)
|
||||
|
||||
new_entities = []
|
||||
for segment_id in new_ids - current_ids:
|
||||
current[segment_id] = [
|
||||
DreameVacuumSegmentSelectEntity(coordinator, description, segment_id)
|
||||
for description in SEGMENT_SELECTS
|
||||
if description.exists_fn(description, coordinator.device)
|
||||
]
|
||||
new_entities = new_entities + current[segment_id]
|
||||
|
||||
if new_entities:
|
||||
async_add_entities(new_entities)
|
||||
|
||||
|
||||
def async_remove_segment_selects(
|
||||
segment_id: str,
|
||||
coordinator: DreameVacuumDataUpdateCoordinator,
|
||||
current: dict[str, DreameVacuumSegmentSelectEntity],
|
||||
) -> None:
|
||||
registry = entity_registry.async_get(coordinator.hass)
|
||||
entities = current[segment_id]
|
||||
for entity in entities:
|
||||
if entity.entity_id in registry.entities:
|
||||
registry.async_remove(entity.entity_id)
|
||||
del current[segment_id]
|
||||
|
||||
|
||||
class DreameVacuumSelectEntity(DreameVacuumEntity, SelectEntity):
|
||||
"""Defines a Dreame Vacuum select."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DreameVacuumDataUpdateCoordinator,
|
||||
description: SelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize Dreame Vacuum select."""
|
||||
super().__init__(coordinator, description)
|
||||
if description.property_key is not None and description.value_fn is None:
|
||||
prop = f'{description.property_key.name.lower()}_name'
|
||||
if hasattr(coordinator.device.status, prop):
|
||||
description.value_fn = lambda value, device: getattr(device.status, prop)
|
||||
|
||||
self._attr_options = description.options(coordinator.device, None)
|
||||
self._attr_current_option = self.native_value
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
self._attr_options = self.entity_description.options(self.device, None)
|
||||
self._attr_current_option = self.native_value
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@callback
|
||||
async def async_select_index(self, idx: int) -> None:
|
||||
"""Select new option by index."""
|
||||
new_index = idx % len(self._attr_options)
|
||||
await self.async_select_option(self._attr_options[new_index])
|
||||
|
||||
@callback
|
||||
async def async_offset_index(self, offset: int, cycle: bool) -> None:
|
||||
"""Offset current index."""
|
||||
current_index = (self._attr_options.index(self._attr_current_option))
|
||||
new_index = current_index + offset
|
||||
if cycle:
|
||||
new_index = new_index % len(self._attr_options)
|
||||
elif new_index < 0:
|
||||
new_index = 0
|
||||
elif new_index >= len(self._attr_options):
|
||||
new_index = len(self._attr_options) - 1
|
||||
|
||||
if cycle or current_index != new_index:
|
||||
await self.async_select_option(self._attr_options[new_index])
|
||||
|
||||
@callback
|
||||
async def async_first(self) -> None:
|
||||
"""Select first option."""
|
||||
await self.async_select_index(0)
|
||||
|
||||
@callback
|
||||
async def async_last(self) -> None:
|
||||
"""Select last option."""
|
||||
await self.async_select_index(-1)
|
||||
|
||||
@callback
|
||||
async def async_next(self, cycle: bool) -> None:
|
||||
"""Select next option."""
|
||||
await self.async_offset_index(1, cycle)
|
||||
|
||||
@callback
|
||||
async def async_previous(self, cycle: bool) -> None:
|
||||
"""Select previous option."""
|
||||
await self.async_offset_index(-1, cycle)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
if not self.available:
|
||||
raise HomeAssistantError("Entity unavailable")
|
||||
|
||||
if option not in self._attr_options:
|
||||
raise HomeAssistantError(
|
||||
f"Invalid option for {self.entity_description.name} {option}. Valid options: {self._attr_options}"
|
||||
)
|
||||
|
||||
value = option
|
||||
if self.entity_description.value_int_fn is not None:
|
||||
value = self.entity_description.value_int_fn(option, self.device)
|
||||
|
||||
if value is None:
|
||||
raise HomeAssistantError(
|
||||
f"Invalid option for {self.entity_description.name} {option}. Valid options: {self._attr_options}"
|
||||
)
|
||||
|
||||
if self.entity_description.set_fn is not None:
|
||||
await self._try_command(
|
||||
"Unable to call %s",
|
||||
self.entity_description.set_fn,
|
||||
self.device,
|
||||
0,
|
||||
int(value),
|
||||
)
|
||||
elif self.entity_description.property_key is not None:
|
||||
await self._try_command(
|
||||
"Unable to call %s",
|
||||
self.device.set_property,
|
||||
self.entity_description.property_key,
|
||||
int(value),
|
||||
)
|
||||
|
||||
class DreameVacuumSegmentSelectEntity(DreameVacuumEntity, SelectEntity):
|
||||
"""Defines a Dreame Vacuum Segment select."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DreameVacuumDataUpdateCoordinator,
|
||||
description: DreameVacuumSelectEntityDescription,
|
||||
segment_id: int,
|
||||
) -> None:
|
||||
"""Initialize Dreame Vacuum Segment Select."""
|
||||
self.segment_id = segment_id
|
||||
self.segment = None
|
||||
self.segments = None
|
||||
if coordinator.device:
|
||||
self.segments = copy.deepcopy(coordinator.device.status.segments)
|
||||
if segment_id in self.segments:
|
||||
self.segment = self.segments[segment_id]
|
||||
|
||||
super().__init__(coordinator, description)
|
||||
self._attr_unique_id = f"{self.device.mac}_room_{segment_id}_{description.key.lower()}"
|
||||
self.entity_id = f"select.{self.device.name.lower()}_room_{segment_id}_{description.key.lower()}"
|
||||
self._attr_options = []
|
||||
self._attr_current_option = "unavailable"
|
||||
if self.segment:
|
||||
self._attr_options = description.options(coordinator.device, self.segment)
|
||||
self._attr_current_option = self.native_value
|
||||
|
||||
def _set_id(self) -> None:
|
||||
"""Set name, unique id and icon of the entity"""
|
||||
if self.entity_description.name == "":
|
||||
name = f"room_{self.segment_id}_{self.entity_description.key}"
|
||||
elif self.segment:
|
||||
name = f"{self.entity_description.key}_{self.segment.name}"
|
||||
else:
|
||||
name = f"{self.entity_description.key}_room_unavailable"
|
||||
|
||||
self._attr_name = f"{self.device.name} {name.replace('_', ' ').title()}"
|
||||
|
||||
if self.entity_description.icon_fn is not None:
|
||||
self._attr_icon = self.entity_description.icon_fn(
|
||||
self.native_value, self.segment
|
||||
)
|
||||
elif self.segment:
|
||||
self._attr_icon = self.segment.icon
|
||||
else:
|
||||
self._attr_icon = "mdi:home-off-outline"
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
if self.segments != self.device.status.segments:
|
||||
self.segments = copy.deepcopy(self.device.status.segments)
|
||||
if self.segments and self.segment_id in self.segments:
|
||||
if self.segment != self.segments[self.segment_id]:
|
||||
self.segment = self.segments[self.segment_id]
|
||||
self._attr_current_option = self.native_value
|
||||
self._set_id()
|
||||
self._attr_options = self.entity_description.options(
|
||||
self.device, self.segment
|
||||
)
|
||||
elif self.segment:
|
||||
self._attr_options = []
|
||||
self.segment = None
|
||||
self._set_id()
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
async def async_select_index(self, idx: int) -> None:
|
||||
"""Select new option by index."""
|
||||
new_index = idx % len(self._attr_options)
|
||||
await self.async_select_option(self._attr_options[new_index])
|
||||
|
||||
@callback
|
||||
async def async_offset_index(self, offset: int, cycle: bool) -> None:
|
||||
"""Offset current index."""
|
||||
current_index = (self._attr_options.index(self._attr_current_option))
|
||||
new_index = current_index + offset
|
||||
if cycle:
|
||||
new_index = new_index % len(self._attr_options)
|
||||
elif new_index < 0:
|
||||
new_index = 0
|
||||
elif new_index >= len(self._attr_options):
|
||||
new_index = len(self._attr_options) - 1
|
||||
|
||||
if cycle or current_index != new_index:
|
||||
await self.async_select_option(self._attr_options[new_index])
|
||||
|
||||
@callback
|
||||
async def async_first(self) -> None:
|
||||
"""Select first option."""
|
||||
await self.async_select_index(0)
|
||||
|
||||
@callback
|
||||
async def async_last(self) -> None:
|
||||
"""Select last option."""
|
||||
await self.async_select_index(-1)
|
||||
|
||||
@callback
|
||||
async def async_next(self, cycle: bool) -> None:
|
||||
"""Select next option."""
|
||||
await self.async_offset_index(1, cycle)
|
||||
|
||||
@callback
|
||||
async def async_previous(self, cycle: bool) -> None:
|
||||
"""Select previous option."""
|
||||
await self.async_offset_index(-1, cycle)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set the Dreame Vacuum Segment Select value."""
|
||||
if not self.available:
|
||||
raise HomeAssistantError("Entity unavailable")
|
||||
|
||||
value = option
|
||||
if self.entity_description.value_int_fn is not None:
|
||||
value = self.entity_description.value_int_fn(value, self)
|
||||
|
||||
if value is None:
|
||||
raise HomeAssistantError(
|
||||
"(%s) Invalid option (%s). Valid options: %s",
|
||||
self.entity_description.name,
|
||||
option,
|
||||
self._attr_options,
|
||||
)
|
||||
|
||||
await self._try_command(
|
||||
"Unable to call %s",
|
||||
self.entity_description.set_fn,
|
||||
self.device,
|
||||
self.segment_id,
|
||||
int(value),
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
if super().available:
|
||||
return bool(self.segment is not None)
|
||||
return False
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the extra state attributes of the entity."""
|
||||
if self.entity_description.attrs_fn is not None and self.segment:
|
||||
return self.entity_description.attrs_fn(self.segment)
|
||||
return None
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the current Dreame Vacuum number value."""
|
||||
if self.segment:
|
||||
return self.entity_description.value_fn(self.device, self.segment)
|
||||
340
custom_components/dreame_vacuum/sensor.py
Normal file
340
custom_components/dreame_vacuum/sensor.py
Normal file
@@ -0,0 +1,340 @@
|
||||
"""Support for Dreame Vacuum sensors."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, UNIT_MINUTES, UNIT_HOURS, UNIT_PERCENT, UNIT_AREA, UNIT_TIMES, UNIT_DAYS
|
||||
from .dreame import (
|
||||
DreameVacuumProperty,
|
||||
DreameVacuumRelocationStatus,
|
||||
)
|
||||
|
||||
from .coordinator import DreameVacuumDataUpdateCoordinator
|
||||
from .entity import DreameVacuumEntity, DreameVacuumEntityDescription
|
||||
|
||||
|
||||
@dataclass
|
||||
class DreameVacuumSensorEntityDescription(
|
||||
DreameVacuumEntityDescription, SensorEntityDescription
|
||||
):
|
||||
"""Describes DreameVacuum sensor entity."""
|
||||
|
||||
|
||||
SENSORS: tuple[DreameVacuumSensorEntityDescription, ...] = (
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.CLEANING_TIME,
|
||||
icon="mdi:timer-sand",
|
||||
native_unit_of_measurement=UNIT_MINUTES,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.CLEANING_TIME,
|
||||
name="Mapping Time",
|
||||
key="mapping_time",
|
||||
icon="mdi:map-clock",
|
||||
native_unit_of_measurement=UNIT_MINUTES,
|
||||
available_fn=lambda device: device.status.fast_mapping,
|
||||
exists_fn=lambda description, device: device.status.lidar_navigation,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.CLEANED_AREA,
|
||||
icon="mdi:ruler-square",
|
||||
native_unit_of_measurement=UNIT_AREA,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.STATE,
|
||||
device_class=f"{DOMAIN}__state",
|
||||
icon="mdi:robot-vacuum",
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.STATUS,
|
||||
device_class=f"{DOMAIN}__status",
|
||||
icon="mdi:vacuum",
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.RELOCATION_STATUS,
|
||||
device_class=f"{DOMAIN}__relocation_status",
|
||||
icon_fn=lambda value, device: "mdi:map-marker-distance"
|
||||
if device.status.relocation_status is DreameVacuumRelocationStatus.LOCATING
|
||||
else "mdi:map-marker-alert"
|
||||
if device.status.relocation_status is DreameVacuumRelocationStatus.FAILED
|
||||
else "mdi:map-marker-check"
|
||||
if device.status.relocation_status is DreameVacuumRelocationStatus.SUCCESS
|
||||
else "mdi:map-marker-radius",
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.TASK_STATUS,
|
||||
device_class=f"{DOMAIN}__task_status",
|
||||
icon="mdi:file-tree",
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.WATER_TANK,
|
||||
device_class=f"{DOMAIN}__water_tank_and_mop",
|
||||
icon_fn=lambda value, device: "mdi:water-pump-off"
|
||||
if not device.status.water_tank_or_mop_installed
|
||||
else "mdi:water-pump",
|
||||
exists_fn=lambda description, device: not device.status.self_wash_base_available and DreameVacuumEntityDescription().exists_fn(description, device),
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
key="mop_pad",
|
||||
device_class=f"{DOMAIN}__water_tank_and_mop",
|
||||
icon="mdi:google-circles-communities",
|
||||
value_fn=lambda value, device: device.status.water_tank_name,
|
||||
exists_fn=lambda description, device: device.status.self_wash_base_available,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.DUST_COLLECTION,
|
||||
device_class=f"{DOMAIN}__dust_collection",
|
||||
icon_fn=lambda value, device: "mdi:delete-off"
|
||||
if not device.status.dust_collection
|
||||
else "mdi:delete-sweep",
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.AUTO_EMPTY_STATUS,
|
||||
device_class=f"{DOMAIN}__auto_empty_status",
|
||||
icon_fn=lambda value, device: "mdi:delete-clock"
|
||||
if device.status.auto_emptying_not_performed
|
||||
else "mdi:delete-restore"
|
||||
if device.status.auto_emptying
|
||||
else "mdi:delete",
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.SELF_WASH_BASE_STATUS,
|
||||
device_class=f"{DOMAIN}__self_wash_base_status",
|
||||
icon="mdi:dishwasher",
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.ERROR,
|
||||
device_class=f"{DOMAIN}__error",
|
||||
icon_fn=lambda value, device: "mdi:alert-circle-outline"
|
||||
if device.status.has_error
|
||||
else "mdi:alert-outline"
|
||||
if device.status.has_warning
|
||||
else "mdi:check-circle-outline",
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.CHARGING_STATUS,
|
||||
device_class=f"{DOMAIN}__charging_status",
|
||||
icon="mdi:home-lightning-bolt",
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.BATTERY_LEVEL,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=UNIT_PERCENT,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.MAIN_BRUSH_LEFT,
|
||||
icon="mdi:car-turbocharger",
|
||||
native_unit_of_measurement=UNIT_PERCENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.MAIN_BRUSH_TIME_LEFT,
|
||||
icon="mdi:car-turbocharger",
|
||||
native_unit_of_measurement=UNIT_HOURS,
|
||||
#device_class=SensorDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.SIDE_BRUSH_LEFT,
|
||||
icon="mdi:pinwheel-outline",
|
||||
native_unit_of_measurement=UNIT_PERCENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.SIDE_BRUSH_TIME_LEFT,
|
||||
icon="mdi:pinwheel-outline",
|
||||
native_unit_of_measurement=UNIT_HOURS,
|
||||
#device_class=SensorDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.FILTER_LEFT,
|
||||
icon="mdi:air-filter",
|
||||
native_unit_of_measurement=UNIT_PERCENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.FILTER_TIME_LEFT,
|
||||
icon="mdi:air-filter",
|
||||
native_unit_of_measurement=UNIT_HOURS,
|
||||
#device_class=SensorDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.SENSOR_DIRTY_LEFT,
|
||||
icon="mdi:radar",
|
||||
native_unit_of_measurement=UNIT_PERCENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.SENSOR_DIRTY_TIME_LEFT,
|
||||
icon="mdi:radar",
|
||||
native_unit_of_measurement=UNIT_HOURS,
|
||||
#device_class=SensorDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.SECONDARY_FILTER_LEFT,
|
||||
icon="mdi:air-filter",
|
||||
native_unit_of_measurement=UNIT_PERCENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.SECONDARY_FILTER_TIME_LEFT,
|
||||
icon="mdi:air-filter",
|
||||
native_unit_of_measurement=UNIT_HOURS,
|
||||
#device_class=SensorDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.MOP_PAD_LEFT,
|
||||
icon="mdi:hydro-power",
|
||||
native_unit_of_measurement=UNIT_PERCENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.MOP_PAD_TIME_LEFT,
|
||||
icon="mdi:hydro-power",
|
||||
native_unit_of_measurement=UNIT_HOURS,
|
||||
#device_class=SensorDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.SILVER_ION_LEFT,
|
||||
icon="mdi:shimmer",
|
||||
native_unit_of_measurement=UNIT_PERCENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.SILVER_ION_TIME_LEFT,
|
||||
icon="mdi:shimmer",
|
||||
native_unit_of_measurement=UNIT_DAYS,
|
||||
#device_class=SensorDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.DETERGENT_LEFT,
|
||||
icon="mdi:water-opacity",
|
||||
native_unit_of_measurement=UNIT_PERCENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.DETERGENT_TIME_LEFT,
|
||||
icon="mdi:water-opacity",
|
||||
native_unit_of_measurement=UNIT_DAYS,
|
||||
#device_class=SensorDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.FIRST_CLEANING_DATE,
|
||||
icon="mdi:calendar-start",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda value, device: datetime.fromtimestamp(value).replace(tzinfo=datetime.now().astimezone().tzinfo),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.TOTAL_CLEANING_TIME,
|
||||
icon="mdi:timer-outline",
|
||||
native_unit_of_measurement=UNIT_MINUTES,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.CLEANING_COUNT,
|
||||
icon="mdi:counter",
|
||||
native_unit_of_measurement=UNIT_TIMES,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
property_key=DreameVacuumProperty.TOTAL_CLEANED_AREA,
|
||||
icon="mdi:set-square",
|
||||
native_unit_of_measurement=UNIT_AREA,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
name="Current Room",
|
||||
key="current_room",
|
||||
icon="mdi:home-map-marker",
|
||||
value_fn=lambda value, device: device.status.current_room.name,
|
||||
exists_fn=lambda description, device: device.status.map_available and device.status.lidar_navigation,
|
||||
available_fn=lambda device: bool(
|
||||
device.status.current_room is not None and not device.status.fast_mapping
|
||||
),
|
||||
attrs_fn=lambda device: {
|
||||
"room_id": device.status.current_room.segment_id,
|
||||
"room_icon": device.status.current_room.icon,
|
||||
},
|
||||
),
|
||||
DreameVacuumSensorEntityDescription(
|
||||
name="Cleaning History",
|
||||
key="cleaning_history",
|
||||
icon="mdi:clipboard-text-clock",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda value, device: device.status.last_cleaning_time,
|
||||
exists_fn=lambda description, device: device.status.map_available,
|
||||
available_fn=lambda device: bool(device.status.last_cleaning_time is not None),
|
||||
attrs_fn=lambda device: device.status.cleaning_history,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Dreame Vacuum sensor based on a config entry."""
|
||||
coordinator: DreameVacuumDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
DreameVacuumSensorEntity(coordinator, description)
|
||||
for description in SENSORS
|
||||
if description.exists_fn(description, coordinator.device)
|
||||
)
|
||||
|
||||
|
||||
class DreameVacuumSensorEntity(DreameVacuumEntity, SensorEntity):
|
||||
"""Defines a Dreame Vacuum sensor entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DreameVacuumDataUpdateCoordinator,
|
||||
description: DreameVacuumSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Dreame Vacuum sensor entity."""
|
||||
super().__init__(coordinator, description)
|
||||
|
||||
if description.property_key is not None and description.value_fn is None:
|
||||
prop = f'{description.property_key.name.lower()}_name'
|
||||
if hasattr(coordinator.device.status, prop):
|
||||
description.value_fn = lambda value, device: getattr(
|
||||
device.status, prop)
|
||||
387
custom_components/dreame_vacuum/services.yaml
Normal file
387
custom_components/dreame_vacuum/services.yaml
Normal file
@@ -0,0 +1,387 @@
|
||||
vacuum_clean_segment:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
segments:
|
||||
example: "[3,2] or 3"
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
repeats:
|
||||
example: "[1,2] or 1"
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 3
|
||||
suction_level:
|
||||
example: "[0,3] or 0"
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 3
|
||||
water_volume:
|
||||
example: "[1,3] or 1"
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 3
|
||||
|
||||
vacuum_clean_zone:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
zone:
|
||||
example: "[819,-263,4424,2105] or [[819,-263,4424,2105],[-2001,-3050,-542,515]]"
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
repeats:
|
||||
example: "[1,3] or 1"
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 3
|
||||
|
||||
vacuum_clean_spot:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
points:
|
||||
example: "[819,-263] or [[819,-263],[819,-263]]"
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
repeats:
|
||||
example: "[1,2] or 1"
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 3
|
||||
suction_level:
|
||||
example: "[0,3] or 0"
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 3
|
||||
water_volume:
|
||||
example: "[1,3] or 1"
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 3
|
||||
|
||||
vacuum_set_dnd:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
dnd_enabled:
|
||||
example: "true"
|
||||
required: true
|
||||
selector:
|
||||
boolean:
|
||||
dnd_start:
|
||||
example: "22:00"
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
dnd_end:
|
||||
example: "6:30"
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
vacuum_remote_control_move_step:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
rotation:
|
||||
example: 64
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
min: -128
|
||||
max: 128
|
||||
mode: box
|
||||
velocity:
|
||||
example: 100
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
min: -300
|
||||
max: 100
|
||||
mode: box
|
||||
|
||||
vacuum_install_voice_pack:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
lang_id:
|
||||
example: "DE"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
url:
|
||||
example: "http://awsde0.fds.api.xiaomi.com/dreame-product/dreame.vacuum.p2009/voices/package/deyu.tar.gz"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
md5:
|
||||
example: "d25986c1f608c0897475707e77d856f9"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
size:
|
||||
example: 4067845
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
|
||||
vacuum_request_map:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
|
||||
vacuum_select_map:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
map_id:
|
||||
example: 14
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
|
||||
vacuum_delete_map:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
map_id:
|
||||
example: 14
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
|
||||
vacuum_save_temporary_map:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
|
||||
vacuum_discard_temporary_map:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
|
||||
vacuum_replace_temporary_map:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
map_id:
|
||||
example: 14
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
|
||||
vacuum_rename_map:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
map_id:
|
||||
example: "14"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
map_name:
|
||||
example: "Ground Floor"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
|
||||
vacuum_merge_segments:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
map_id:
|
||||
example: "14"
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
segments:
|
||||
example: "[3,2]"
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
|
||||
vacuum_split_segments:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
map_id:
|
||||
example: "14"
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
segment:
|
||||
example: "3"
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 60
|
||||
mode: box
|
||||
line:
|
||||
example: "[819,-263,4424,2105]"
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
|
||||
vacuum_rename_segment:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
segment_id:
|
||||
example: "3"
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 60
|
||||
mode: box
|
||||
segment_name:
|
||||
example: "Playroom"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
|
||||
vacuum_set_cleaning_sequence:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
cleaning_sequence:
|
||||
example: "[5,3,2,1,4] or []"
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
|
||||
vacuum_set_custom_cleaning:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
segment_id:
|
||||
example: "[1,2,3,4,5]"
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
suction_level:
|
||||
example: "[0,0,2,3,1]"
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
water_volume:
|
||||
example: "[1,1,2,3,1]"
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
repeats:
|
||||
example: "[2,2,1,3,1]"
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
|
||||
vacuum_set_restricted_zone:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
walls:
|
||||
example: "[[819,-263,4424,2105],[-2001,-3050,-542,515]]"
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
zones:
|
||||
example: "[[819,-263,4424,2105],[-2001,-3050,-542,515]]"
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
no_mops:
|
||||
example: "[[819,-263,4424,2105],[-2001,-3050,-542,515]]"
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
vacuum_reset_consumable:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
consumable:
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "main_brush"
|
||||
- "side_brush"
|
||||
- "filter"
|
||||
- "sensor"
|
||||
- "mop_pad"
|
||||
- "silver_ion"
|
||||
- "detergent"
|
||||
|
||||
select_select_previous:
|
||||
target:
|
||||
entity:
|
||||
integration: dreame_vacuum
|
||||
domain: select
|
||||
fields:
|
||||
cycle:
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
select_select_first:
|
||||
target:
|
||||
entity:
|
||||
integration: dreame_vacuum
|
||||
domain: select
|
||||
|
||||
select_select_last:
|
||||
target:
|
||||
entity:
|
||||
integration: dreame_vacuum
|
||||
domain: select
|
||||
|
||||
select_select_next:
|
||||
target:
|
||||
entity:
|
||||
integration: dreame_vacuum
|
||||
domain: select
|
||||
fields:
|
||||
cycle:
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
931
custom_components/dreame_vacuum/strings.json
Normal file
931
custom_components/dreame_vacuum/strings.json
Normal file
@@ -0,0 +1,931 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
"cannot_connect": "Failed to connect."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect.",
|
||||
"unsupported": "Device is not supported",
|
||||
"wrong_token": "Checksum error, wrong token",
|
||||
"2fa_required": "2FA Login required\n{url}",
|
||||
"credentials_incomplete": "Credentials incomplete, please fill in username, password and country",
|
||||
"login_error": "Could not login to Xiaomi Miio Cloud, check the credentials.",
|
||||
"no_devices": "No supported devices found in this Xiaomi Miio cloud account on selected country."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"configuration_type": "Configuration Type"
|
||||
},
|
||||
"description": "Map feature requires cloud connection and provides automatic configuration. If you don't want to use the map feature, you can select manual configuration."
|
||||
},
|
||||
"without_map": {
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"token": "Token"
|
||||
},
|
||||
"description": "You will need the 32 character API Token, see https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token for instructions."
|
||||
},
|
||||
"with_map": {
|
||||
"data": {
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"country": "Server country",
|
||||
"prefer_cloud": "Prefer cloud connection"
|
||||
},
|
||||
"description": "Log in to the Xiaomi Miio cloud, see https://www.openhab.org/addons/bindings/miio/#country-servers for the cloud server to use."
|
||||
},
|
||||
"devices": {
|
||||
"data": {
|
||||
"devices": "Supported Devices"
|
||||
},
|
||||
"description": "Please select the Dreame Vacuum device you want to setup."
|
||||
},
|
||||
"options": {
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"color_scheme": "Map color scheme",
|
||||
"icon_set": "Map icon set",
|
||||
"notify": "Notification",
|
||||
"map_objects": "Map objects",
|
||||
"low_resolution": "Low resolution map",
|
||||
"square": "Square map"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "Dreame Vacuum integration needs to re-authenticate your account in order to update the tokens or add missing cloud credentials.",
|
||||
"title": "Reauthenticate Integration"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"color_scheme": "Map color scheme",
|
||||
"icon_set": "Map icon set",
|
||||
"notify": "Notification",
|
||||
"map_objects": "Map objects",
|
||||
"low_resolution": "Low resolution map",
|
||||
"square": "Square map",
|
||||
"configuration_type": "Configuration type",
|
||||
"prefer_cloud": "Prefer cloud connection"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"select": {
|
||||
"suction_level": {
|
||||
"state": {
|
||||
"quiet": "Quiet",
|
||||
"standard": "Standard",
|
||||
"strong": "Strong",
|
||||
"turbo": "Turbo"
|
||||
}
|
||||
},
|
||||
"water_volume": {
|
||||
"state": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
}
|
||||
},
|
||||
"mop_pad_humidity": {
|
||||
"state": {
|
||||
"slightly_dry": "Slightly dry",
|
||||
"moist": "Moist",
|
||||
"wet": "Wet"
|
||||
}
|
||||
},
|
||||
"cleaning_mode": {
|
||||
"state": {
|
||||
"sweeping": "Sweeping",
|
||||
"mopping": "Mopping",
|
||||
"sweeping_and_mopping": "Sweeping and mopping",
|
||||
"mopping_after_sweeping": "Mopping after sweeping"
|
||||
}
|
||||
},
|
||||
"carpet_sensitivity": {
|
||||
"state": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
}
|
||||
},
|
||||
"carpet_cleaning": {
|
||||
"state": {
|
||||
"avoidance": "Avoidance",
|
||||
"adaptation": "Adaptation",
|
||||
"remove_mop": "Remove Mop"
|
||||
}
|
||||
},
|
||||
"mop_wash_level": {
|
||||
"state": {
|
||||
"water_saving": "Water saving",
|
||||
"daily": "Daily",
|
||||
"deep": "Deep"
|
||||
}
|
||||
},
|
||||
"mopping_type": {
|
||||
"state": {
|
||||
"accurate": "Accurate",
|
||||
"daily": "Daily",
|
||||
"deep": "Deep"
|
||||
}
|
||||
},
|
||||
"wider_corner_coverage": {
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"high_frequency": "High Frequency",
|
||||
"low_frequency": "Low Frequency"
|
||||
}
|
||||
},
|
||||
"mop_pad_swing": {
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"auto": "Auto",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly"
|
||||
}
|
||||
},
|
||||
"floor_material": {
|
||||
"state": {
|
||||
"none": "None",
|
||||
"tile": "Floor tile",
|
||||
"wood": "Wood floor"
|
||||
}
|
||||
},
|
||||
"voice_assistant_language": {
|
||||
"state": {
|
||||
"default": "Default",
|
||||
"english": "English",
|
||||
"german": "German",
|
||||
"chinese": "Chinese"
|
||||
}
|
||||
},
|
||||
"order": {
|
||||
"state": {
|
||||
"not_set": "Not Set"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"state": {
|
||||
"state": {
|
||||
"unknown": "Unknown",
|
||||
"sweeping": "Sweeping",
|
||||
"charging": "Charging",
|
||||
"error": "Error",
|
||||
"idle": "Idle",
|
||||
"paused": "Paused",
|
||||
"returning": "Returning to dock",
|
||||
"mopping": "Mopping",
|
||||
"drying": "Drying",
|
||||
"washing": "Washing",
|
||||
"returning_to_wash": "Returning to wash",
|
||||
"building": "Building",
|
||||
"sweeping_and_mopping": "Sweeping and mopping",
|
||||
"charging_completed": "Charging completed",
|
||||
"upgrading": "Upgrading",
|
||||
"clean_summon": "Summon to clean",
|
||||
"station_reset": "Station reset",
|
||||
"returning_install_mop": "Returning to install mop",
|
||||
"returning_remove_mop": "Returning to remove mop",
|
||||
"water_check": "Water checking",
|
||||
"clean_add_water": "Cleaning and adding water",
|
||||
"washing_paused": "Washing paused",
|
||||
"auto_emptying": "Auto-Emptying",
|
||||
"remote_control": "Remote controlling",
|
||||
"smart_charging": "Smart charging",
|
||||
"second_cleaning": "Second time cleaning",
|
||||
"human_following": "Human following",
|
||||
"spot_cleaning": "Spot cleaning",
|
||||
"returning_auto_empty": "Returning to Auto-Empty",
|
||||
"shortcut": "Shortcut",
|
||||
"monitoring": "Monitoring",
|
||||
"monitoring_paused": "Monitoring paused"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"state": {
|
||||
"unknown": "Unknown",
|
||||
"idle": "Idle",
|
||||
"paused": "Paused",
|
||||
"cleaning": "Cleaning",
|
||||
"returning": "Returning to dock",
|
||||
"spot_cleaning": "Spot cleaning",
|
||||
"follow_wall_cleaning": "Follow wall cleaning",
|
||||
"charging": "Charging",
|
||||
"ota": "OTA",
|
||||
"fct": "FCT",
|
||||
"wifi_set": "WiFi set",
|
||||
"power_off": "Power off",
|
||||
"factory": "Factory",
|
||||
"error": "Error",
|
||||
"remote_control": "Remote control",
|
||||
"sleeping": "Sleeping",
|
||||
"self_test": "Self test",
|
||||
"factory_test": "Factory test",
|
||||
"standby": "Standby",
|
||||
"room_cleaning": "Room cleaning",
|
||||
"zone_cleaning": "Zone cleaning",
|
||||
"fast_mapping": "Fast mapping",
|
||||
"cruising_path": "Cruising on path",
|
||||
"cruising_point": "Cruising to a point",
|
||||
"summon_clean": "Summon to clean",
|
||||
"shortcut": "Shortcut",
|
||||
"person_follow": "Person follow",
|
||||
"water_check": "Water checking"
|
||||
}
|
||||
},
|
||||
"task_status": {
|
||||
"state": {
|
||||
"unknown": "Unknown",
|
||||
"completed": "Completed",
|
||||
"cleaning": "Cleaning",
|
||||
"zone_cleaning": "Zone cleaning",
|
||||
"room_cleaning": "Room cleaning",
|
||||
"spot_cleaning": "Spot cleaning",
|
||||
"fast_mapping": "Fast mapping",
|
||||
"cleaning_paused": "Cleaning paused",
|
||||
"room_cleaning_paused": "Room cleaning paused",
|
||||
"zone_cleaning_paused": "Zone cleaning paused",
|
||||
"spot_cleaning_paused": "Spot cleaning paused",
|
||||
"map_cleaning_paused": "Map cleaning paused",
|
||||
"docking_paused": "Docking paused",
|
||||
"mopping_paused": "Mopping paused",
|
||||
"zone_mopping_paused": "Zone mopping paused",
|
||||
"room_mopping_paused": "Room mopping paused",
|
||||
"zone_docking_paused": "Zone docking paused",
|
||||
"room_docking_paused": "Room docking paused",
|
||||
"cruising_path": "Cruising on path",
|
||||
"cruising_path_paused": "Cruising on path paused",
|
||||
"cruising_point": "Cruising to a point",
|
||||
"cruising_point_paused": "Cruising to a point paused",
|
||||
"summon_clean_paused": "Summon to clean paused",
|
||||
"returning_to_install_mop": "Returning to install mop",
|
||||
"returning_to_remove_mop": "Returning to remove mop"
|
||||
}
|
||||
},
|
||||
"water_tank": {
|
||||
"state": {
|
||||
"unknown": "Unknown",
|
||||
"installed": "Installed",
|
||||
"not_installed": "Not installed",
|
||||
"mop_installed": "Mop installed",
|
||||
"in_station": "In station"
|
||||
}
|
||||
},
|
||||
"mop_pad": {
|
||||
"state": {
|
||||
"unknown": "Unknown",
|
||||
"installed": "Installed",
|
||||
"not_installed": "Not installed",
|
||||
"mop_installed": "Mop installed",
|
||||
"in_station": "In station"
|
||||
}
|
||||
},
|
||||
"dust_collection": {
|
||||
"state": {
|
||||
"unknown": "Unknown",
|
||||
"not_available": "Not available",
|
||||
"available": "Available"
|
||||
}
|
||||
},
|
||||
"auto_empty_status": {
|
||||
"state": {
|
||||
"unknown": "Unknown",
|
||||
"idle": "Idle",
|
||||
"active": "Active",
|
||||
"not_performed": "Not performed"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"state": {
|
||||
"unknown": "Unknown error code",
|
||||
"no_error": "No error",
|
||||
"drop": "Wheels are suspended",
|
||||
"cliff": "Cliff sensor error",
|
||||
"bumper": "Collision sensor is stuck",
|
||||
"gesture": "Robot is tilted",
|
||||
"bumper_repeat": "Collision sensor is stuck",
|
||||
"drop_repeat": "Wheels are suspended",
|
||||
"optical_flow": "Optical flow sensor error",
|
||||
"no_box": "Dust bin not installed",
|
||||
"no_tank_box": "Water tank not installed",
|
||||
"water_box_empty": "Water tank is empty",
|
||||
"box_full": "The filter not dry or blocked",
|
||||
"brush": "The main brush wrapped",
|
||||
"side_brush": "The side brush wrapped",
|
||||
"fan": "The filter not dry or blocked",
|
||||
"left_wheel_motor": "The robot is stuck, or its left wheel may be blocked by foreign objects",
|
||||
"right_wheel_motor": "The robot is stuck, or its right wheel may be blocked by foreign objects",
|
||||
"turn_suffocate": "The robot is stuck, or cannot turn",
|
||||
"forward_suffocate": "The robot is stuck, or cannot go forward",
|
||||
"charger_get": "Cannot find base",
|
||||
"battery_low": "Low battery",
|
||||
"charge_fault": "Charging error",
|
||||
"battery_percentage": "Battery level error",
|
||||
"heart": "Internal error",
|
||||
"camera_occlusion": "Visual positioning sensor error",
|
||||
"move": "Move sensor error",
|
||||
"flow_shielding": "Optical sensor error",
|
||||
"infrared_shielding": "Infrared shielding error",
|
||||
"charge_no_electric": "The charging dock is not powered on",
|
||||
"battery_fault": "Battery error",
|
||||
"fan_speed_error": "Fan speed sensor error",
|
||||
"left_wheell_speed": "Left wheel may be blocked by foreign objects",
|
||||
"right_wheell_speed": "Right wheel may be blocked by foreign objects",
|
||||
"bmi055_acce": "Accelerometer error",
|
||||
"bmi055_gyro": "Gyro error",
|
||||
"xv7001": "Gyro error",
|
||||
"left_magnet": "Left magnet sensor error",
|
||||
"right_magnet": "Right magnet sensor error",
|
||||
"flow_error": "Flow sensor error",
|
||||
"infrared_fault": "Infrared error",
|
||||
"camera_fault": "Camera error",
|
||||
"strong_magnet": "Strong magnetic field detected",
|
||||
"water_pump": "Water pump error",
|
||||
"rtc": "RTC error",
|
||||
"auto_key_trig": "Internal error",
|
||||
"p3v3": "Internal error",
|
||||
"camera_idle": "Internal error",
|
||||
"blocked": "Cleanup route is blocked, returning to the dock.",
|
||||
"lds_error": "Laser distance sensor error",
|
||||
"lds_bumper": "Laser distance sensor bumper error",
|
||||
"filter_blocked": "The filter not dry or blocked",
|
||||
"edge": "Edge sensor error",
|
||||
"carpet": "Please start the robot in non-carpet area.",
|
||||
"laser": "The 3D obstacle avoidance sensor is malfunctioning.",
|
||||
"ultrasonic": "The ultrasonic sensor is malfunctioning.",
|
||||
"no_go_zone": "No-Go zone or virtual wall detected.",
|
||||
"route": "Cleanup route is blocked.",
|
||||
"restricted": "Detected that the vacuum-mop is in a restricted area.",
|
||||
"remove_mop": "Mopping completed. Please remove and clean the mop in time.",
|
||||
"mop_removed": "The mop pad comes off during the cleaning task.",
|
||||
"mop_pad_stop_rotate": "The mop pad has stopped rotating.",
|
||||
"bin_full": "The dust collection bag is full, or the air duct is blocked.",
|
||||
"bin_open": "The upper cover of auto-empty base is not closed, or the dust collection bag is not installed.",
|
||||
"water_tank": "The clean water tank is not installed.",
|
||||
"dirty_water_tank": "The dirty water tank is full or not installed.",
|
||||
"water_tank_dry": "Low water level in the clean water tank, please fill with water timely.",
|
||||
"dirty_water_tank_blocked": "Dirty water tank blocked.",
|
||||
"dirty_water_tank_pump": "Dirty water tank pump error.",
|
||||
"mop_pad": "The washboard is not installed properly.",
|
||||
"wet_mop_pad": "The water level of the washboard is abnormal, please clean the washboard timely.",
|
||||
"clean_mop_pad": "The cleaning task is complete, please clean the mop pad washboard.",
|
||||
"clean_tank_level": "Check and fill the clean water tank.",
|
||||
"station_disconnected": "Base station not powered on",
|
||||
"dirty_tank_level": "The water level in the used water tank is too high.",
|
||||
"washboard_level": "Water level in the washboard is too high.",
|
||||
"no_mop_in_station": "Mop pad is not in the station.",
|
||||
"dust_bag_full": "Dust bag is full or vents are blocked."
|
||||
}
|
||||
},
|
||||
"charging_status": {
|
||||
"state": {
|
||||
"unknown": "Unknown",
|
||||
"charging": "Charging",
|
||||
"not_charging": "Not charging",
|
||||
"return_to_charge": "Return to charge",
|
||||
"charging_completed": "Charging completed"
|
||||
}
|
||||
},
|
||||
"relocation_status": {
|
||||
"state": {
|
||||
"unknown": "Unknown",
|
||||
"located": "Located",
|
||||
"locating": "Locating",
|
||||
"failed": "Failed",
|
||||
"success": "Success"
|
||||
}
|
||||
},
|
||||
"self_wash_base_status": {
|
||||
"state": {
|
||||
"unknown": "Unknown",
|
||||
"idle": "Idle",
|
||||
"washing": "Washing",
|
||||
"drying": "Drying",
|
||||
"paused": "Paused",
|
||||
"returning": "Returning to wash",
|
||||
"clean_add_water": "Cleaning and adding water",
|
||||
"adding_water": "Adding water"
|
||||
}
|
||||
},
|
||||
"low_water_warning": {
|
||||
"state": {
|
||||
"no_warning": "No warning",
|
||||
"no_water_left_dismiss": "Please check the clean water tank.",
|
||||
"no_water_left": "The water in the clean water tank is about to be used up. Check and fill the clean water tank promptly.",
|
||||
"no_water_left_after_clean": "Mop pad has been cleaned. Detected that the water in the clean water tank is insufficient, please fill the clean water tank and empty the used water tank.",
|
||||
"no_water_for_clean": "Low water level in the clean water tank. Robot has switched to Vacuuming Mode.",
|
||||
"low_water": "About to run out of water. Please fill the clean water tank.",
|
||||
"tank_not_installed": "The clean water tank is not installed."
|
||||
}
|
||||
},
|
||||
"stream_status": {
|
||||
"state": {
|
||||
"unknown": "Unknown",
|
||||
"idle": "Idle",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"recording": "Recording"
|
||||
}
|
||||
},
|
||||
"drainage_status": {
|
||||
"state": {
|
||||
"unknown": "Unknown",
|
||||
"idle": "Idle",
|
||||
"draining": "Draining",
|
||||
"draining_successful": "Draining successful",
|
||||
"draining_failed": "Draining failed"
|
||||
}
|
||||
},
|
||||
"task_type": {
|
||||
"state": {
|
||||
"standard": "Standard cleaning",
|
||||
"standard_paused": "Standard cleaning paused",
|
||||
"custom": "Custom cleaning",
|
||||
"custom_paused": "Custom cleaning paused",
|
||||
"shortcut": "Shortcut cleaning",
|
||||
"shortcut_paused": "Shortcut cleaning paused",
|
||||
"scheduled": "Scheduled cleaning",
|
||||
"scheduled_paused": "Scheduled cleaning paused",
|
||||
"smart": "Smart cleaning",
|
||||
"smart_paused": "Smart cleaning paused",
|
||||
"partial": "Partial cleaning",
|
||||
"partial_paused": "Partial cleaning paused",
|
||||
"summon": "Summon cleaning",
|
||||
"summon_paused": "Summon cleaning paused"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"vacuum_clean_segment": {
|
||||
"name": "Clean Segment",
|
||||
"description": "Start the cleaning operation in the selected rooms.",
|
||||
"fields": {
|
||||
"segments": {
|
||||
"name": "Segments",
|
||||
"description": "List of rooms to be cleaned. Only room's Character or an array of room's character, cleaning times, fan speed and mop mode to override the default values per room."
|
||||
},
|
||||
"repeats": {
|
||||
"name": "Repeats",
|
||||
"description": "Number of cleaning passes for every selected room (unless it is overridden by customized cleaning parameter)."
|
||||
},
|
||||
"suction_level": {
|
||||
"name": "Suction Level",
|
||||
"description": "Fan speed for every selected room (unless it is overridden by customized cleaning parameter)."
|
||||
},
|
||||
"water_volume": {
|
||||
"name": "Water Volume",
|
||||
"description": "Water level for every selected room (unless it is overridden by customized cleaning parameter)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_clean_zone": {
|
||||
"name": "Clean Zone",
|
||||
"description": "Start the cleaning operation in the selected area.",
|
||||
"fields": {
|
||||
"zone": {
|
||||
"name": "Zone",
|
||||
"description": "Coordinates."
|
||||
},
|
||||
"repeats": {
|
||||
"name": "Repeats",
|
||||
"description": "Number of cleaning passes for every selected zone."
|
||||
},
|
||||
"suction_level": {
|
||||
"name": "Suction Level",
|
||||
"description": "Fan speed for every selected zone."
|
||||
},
|
||||
"water_volume": {
|
||||
"name": "Water Volume",
|
||||
"description": "Water level for every selected zone."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_clean_spot": {
|
||||
"name": "Clean Spot",
|
||||
"description": "Start the cleaning operation in the selected points on the map.",
|
||||
"fields": {
|
||||
"points": {
|
||||
"name": "Points",
|
||||
"description": "List of coordinates to be cleaned."
|
||||
},
|
||||
"repeats": {
|
||||
"name": "Repeats",
|
||||
"description": "Number of cleaning passes for every selected zone."
|
||||
},
|
||||
"suction_level": {
|
||||
"name": "Suction Level",
|
||||
"description": "Fan speed for every selected zone."
|
||||
},
|
||||
"water_volume": {
|
||||
"name": "Water Volume",
|
||||
"description": "Water level for every selected zone."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_set_dnd": {
|
||||
"name": "Set DnD",
|
||||
"description": "Enable/disable DnD functionality and/or setting its start and end time.",
|
||||
"fields": {
|
||||
"dnd_enabled": {
|
||||
"name": "DnD Enabled",
|
||||
"description": "Enable or disable DnD feature."
|
||||
},
|
||||
"dnd_start": {
|
||||
"name": "DnD Start",
|
||||
"description": "Start time of DnD feature."
|
||||
},
|
||||
"dnd_end": {
|
||||
"name": "Dnd End",
|
||||
"description": "End time of DnD feature."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_goto": {
|
||||
"name": "Go To",
|
||||
"description": "Go to the coordinate of the map and stop.",
|
||||
"fields": {
|
||||
"x": {
|
||||
"name": "X",
|
||||
"description": "X Coordinate of the point."
|
||||
},
|
||||
"y": {
|
||||
"name": "Y",
|
||||
"description": "Y Coordinate of the point."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_follow_path": {
|
||||
"name": "Follow Path",
|
||||
"description": "Follow list of coordinates on the map and return to base. (Only supported on vacuums with camera)",
|
||||
"fields": {
|
||||
"points": {
|
||||
"name": "Points",
|
||||
"description": "List of coordinates of the path."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_remote_control_move_step": {
|
||||
"name": "Remote Control Move Step",
|
||||
"description": "Remotely control move the bot one step.",
|
||||
"fields": {
|
||||
"rotation": {
|
||||
"name": "Rotation",
|
||||
"description": "Turn in binary degrees between -128 and 128."
|
||||
},
|
||||
"velocity": {
|
||||
"name": "Velocity",
|
||||
"description": "Move speed, from 100 (forward) to -300 (backward)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_install_voice_pack": {
|
||||
"name": "Install Voice Pack",
|
||||
"description": "Install official or custom voice pack.",
|
||||
"fields": {
|
||||
"lang_id": {
|
||||
"name": "Language ID",
|
||||
"description": "Language ID of this pack."
|
||||
},
|
||||
"url": {
|
||||
"name": "URL",
|
||||
"description": "Url of this pack it should be reachable by the vacuum bot."
|
||||
},
|
||||
"md5": {
|
||||
"name": "MD5",
|
||||
"description": "MD5 checksum of the language pack."
|
||||
},
|
||||
"size": {
|
||||
"name": "Size",
|
||||
"description": "Size of the language pack in bytes."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_request_map": {
|
||||
"name": "Request Map",
|
||||
"description": "Request map data"
|
||||
},
|
||||
"vacuum_select_map": {
|
||||
"name": "Select Map",
|
||||
"description": "Select current map. Used when having multiple maps/floors.",
|
||||
"fields": {
|
||||
"map_id": {
|
||||
"name": "Map ID",
|
||||
"description": "ID of the map to be selected."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_delete_map": {
|
||||
"name": "Delete map",
|
||||
"description": "Delete a map.",
|
||||
"fields": {
|
||||
"map_id": {
|
||||
"name": "Map ID",
|
||||
"description": "ID of the map to be deleted."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_save_temporary_map": {
|
||||
"name": "Save Temporary Map",
|
||||
"description": "Save the temporary map."
|
||||
},
|
||||
"vacuum_discard_temporary_map": {
|
||||
"name": "Discard Temporary Map",
|
||||
"description": "Discard the temporary map."
|
||||
},
|
||||
"vacuum_replace_temporary_map": {
|
||||
"name": "Replace Temporary Map",
|
||||
"description": "Replace the temporary map with another saved map.",
|
||||
"fields": {
|
||||
"map_id": {
|
||||
"name": "Map ID",
|
||||
"description": "ID of the map to be replaced with."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_rename_map": {
|
||||
"name": "Rename map",
|
||||
"description": "Rename a map.",
|
||||
"fields": {
|
||||
"map_id": {
|
||||
"name": "Map ID",
|
||||
"description": "ID of the map."
|
||||
},
|
||||
"map_name": {
|
||||
"name": "Map Name",
|
||||
"description": "New name of the map."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_restore_map": {
|
||||
"name": "Restore Map",
|
||||
"description": "Restore a map.",
|
||||
"fields": {
|
||||
"map_id": {
|
||||
"name": "Map ID",
|
||||
"description": "ID of the map to be restored."
|
||||
},
|
||||
"recovery_map_index": {
|
||||
"name": "Recovery Map Index",
|
||||
"description": "Index of the saved recovery map"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_restore_map_from_file": {
|
||||
"name": "Restore Map From File",
|
||||
"description": "Restore a map from file.",
|
||||
"fields": {
|
||||
"map_id": {
|
||||
"name": "Map ID",
|
||||
"description": "ID of the map to be restored."
|
||||
},
|
||||
"file_url": {
|
||||
"name": "File Url",
|
||||
"description": "Url of the saved bz2.gz or tar.gz file"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_backup_map": {
|
||||
"name": "Backup Map",
|
||||
"description": "Backup a map to cloud.",
|
||||
"fields": {
|
||||
"map_id": {
|
||||
"name": "Map ID",
|
||||
"description": "ID of the map to be restored."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_merge_segments": {
|
||||
"name": "Merge Segments",
|
||||
"description": "Merge rooms.",
|
||||
"fields": {
|
||||
"map_id": {
|
||||
"name": "Map ID",
|
||||
"description": "ID of the map."
|
||||
},
|
||||
"segments": {
|
||||
"name": "Segments",
|
||||
"description": "Room IDs."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_split_segments": {
|
||||
"name": "Split Segments",
|
||||
"description": "Split rooms.",
|
||||
"fields": {
|
||||
"map_id": {
|
||||
"name": "Map ID",
|
||||
"description": "ID of the map."
|
||||
},
|
||||
"segment": {
|
||||
"name": "Segment",
|
||||
"description": "Room ID."
|
||||
},
|
||||
"line": {
|
||||
"name": "Line",
|
||||
"description": "Split line coordinates."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_rename_segment": {
|
||||
"name": "Rename Segment",
|
||||
"description": "Rename a segment.",
|
||||
"fields": {
|
||||
"segment_id": {
|
||||
"name": "Segment ID",
|
||||
"description": "ID of the segment"
|
||||
},
|
||||
"segment_name": {
|
||||
"name": "Segment Name",
|
||||
"description": "New name of the segment"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_set_cleaning_sequence": {
|
||||
"name": "Set Cleaning Sequence",
|
||||
"description": "Set room cleaning sequence. (Only on supported devices)",
|
||||
"fields": {
|
||||
"cleaning_sequence": {
|
||||
"name": "Cleanin sequence",
|
||||
"description": "Segment ID list of cleaning sequence."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_set_custom_cleaning": {
|
||||
"name": "Set Custom Cleaning",
|
||||
"description": "Set custom cleaning parameters. (Only on supported devices)",
|
||||
"fields": {
|
||||
"segment_id": {
|
||||
"name": "Segment ID",
|
||||
"description": "Room IDs."
|
||||
},
|
||||
"suction_level": {
|
||||
"name": "Suction Level",
|
||||
"description": "Suction level for each room."
|
||||
},
|
||||
"water_volume": {
|
||||
"name": "Water Volume",
|
||||
"description": "Water volume for each room."
|
||||
},
|
||||
"cleaning_mode": {
|
||||
"name": "Cleaning Mode",
|
||||
"description": "Cleaning for each room (only supported on vacuums with mop pad lifting feature)."
|
||||
},
|
||||
"repeats": {
|
||||
"name": "Repeats",
|
||||
"description": "Cleaning times for each room."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_set_restricted_zone": {
|
||||
"name": "Set Restriced Zone",
|
||||
"description": "Define virtual walls, restricted zones, and/or no mop zones.",
|
||||
"fields": {
|
||||
"walls": {
|
||||
"name": "Walls",
|
||||
"description": "Virtual walls."
|
||||
},
|
||||
"zones": {
|
||||
"name": "Zones",
|
||||
"description": "No go zones."
|
||||
},
|
||||
"no_mops": {
|
||||
"name": "No Mops",
|
||||
"description": "No mop zones."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_reset_consumable": {
|
||||
"name": "Reset Consumable",
|
||||
"description": "Reset a consumable.",
|
||||
"fields": {
|
||||
"consumable": {
|
||||
"name": "Consumable",
|
||||
"description": "Consumable type."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_rename_shortcut": {
|
||||
"name": "Rename Shortcut",
|
||||
"description": "Rename a shortcut. (Only on supported devices)",
|
||||
"fields": {
|
||||
"shortcut_id": {
|
||||
"name": "Shortcut ID",
|
||||
"description": "ID of the shortcut."
|
||||
},
|
||||
"shortcut_name": {
|
||||
"name": "Shortcut Name",
|
||||
"description": "New name of the shortcut."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_set_carpet_area": {
|
||||
"name": "Set Carpet Area",
|
||||
"description": "Define carpets and ignored carpets. (Only on supported devices)",
|
||||
"fields": {
|
||||
"carpets": {
|
||||
"name": "Carpets",
|
||||
"description": "Carpet areas."
|
||||
},
|
||||
"ignored_carpets": {
|
||||
"name": "Ignored Carpets",
|
||||
"description": "Ignored carpet areas for deleting the automatically detected carpets."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_set_pathway": {
|
||||
"name": "Set Pathway",
|
||||
"description": "Define pathways.",
|
||||
"fields": {
|
||||
"pathways": {
|
||||
"name": "Pathways",
|
||||
"description": "Pathway line coordinates."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_set_predefined_points": {
|
||||
"name": "Set Predefined Points",
|
||||
"description": "Define predefined coordinates on current map. (Only supported on vacuums with camera)",
|
||||
"fields": {
|
||||
"points": {
|
||||
"name": "Points",
|
||||
"description": "List of coordinates to be saved."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_set_obstacle_ignore": {
|
||||
"name": "Set Obstacle Ignore",
|
||||
"description": "Set ignore status of an obstacle. (Only supported on vacuums with AI Obstacle detection feature)",
|
||||
"fields": {
|
||||
"x": {
|
||||
"name": "X",
|
||||
"description": "X Coordinate of the obstacle."
|
||||
},
|
||||
"y": {
|
||||
"name": "Y",
|
||||
"description": "Y Coordinate of the obstacle."
|
||||
},
|
||||
"obstacle_type": {
|
||||
"name": "Obstacle Type",
|
||||
"description": "Type of the obstacle that will be ignored."
|
||||
},
|
||||
"obstacle_ignored": {
|
||||
"name": "Obstacle Ignored",
|
||||
"description": "Obstacle is ignored or not."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vacuum_set_router_position": {
|
||||
"name": "Set router position",
|
||||
"description": "Set router position on current map. (Only supported on vacuums with WiFi map feature)",
|
||||
"fields": {
|
||||
"x": {
|
||||
"name": "X",
|
||||
"description": "X Coordinate of the router."
|
||||
},
|
||||
"y": {
|
||||
"name": "Y",
|
||||
"description": "Y Coordinate of the router."
|
||||
}
|
||||
}
|
||||
},
|
||||
"select_select_previous": {
|
||||
"name": "Select Previous",
|
||||
"description": "Select the previous options of an select entity.",
|
||||
"fields": {
|
||||
"cycle": {
|
||||
"name": "Cycle",
|
||||
"description": "If the option should cycle from the first to the last."
|
||||
}
|
||||
}
|
||||
},
|
||||
"select_select_first": {
|
||||
"name": "Select First",
|
||||
"description": "Select the first option of an select entity."
|
||||
},
|
||||
"select_select_last": {
|
||||
"name": "Select Last",
|
||||
"description": "Select the last option of an select entity."
|
||||
},
|
||||
"select_select_next": {
|
||||
"name": "Select Next",
|
||||
"description": "Select the next options of an select entity.",
|
||||
"fields": {
|
||||
"cycle": {
|
||||
"name": "Cycle",
|
||||
"description": "If the option should cycle from the first to the last."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
326
custom_components/dreame_vacuum/switch.py
Normal file
326
custom_components/dreame_vacuum/switch.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""Support for Dreame Vacuum switches."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
SwitchEntity,
|
||||
SwitchEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
from .coordinator import DreameVacuumDataUpdateCoordinator
|
||||
from .entity import DreameVacuumEntity, DreameVacuumEntityDescription
|
||||
from .dreame import DreameVacuumProperty
|
||||
|
||||
|
||||
@dataclass
|
||||
class DreameVacuumSwitchEntityDescription(
|
||||
DreameVacuumEntityDescription, SwitchEntityDescription
|
||||
):
|
||||
"""Describes Dreame Vacuum Switch entity."""
|
||||
|
||||
set_fn: Callable[[object, int]] = None
|
||||
|
||||
|
||||
SWITCHES: tuple[DreameVacuumSwitchEntityDescription, ...] = (
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.RESUME_CLEANING,
|
||||
icon="mdi:play-pause",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.CARPET_BOOST,
|
||||
icon_fn=lambda value, device: "mdi:upload-off" if value == 0 else "mdi:upload",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.OBSTACLE_AVOIDANCE,
|
||||
icon_fn=lambda value, device: "mdi:video-3d-off"
|
||||
if value == 0
|
||||
else "mdi:video-3d",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.CUSTOMIZED_CLEANING,
|
||||
icon="mdi:home-search",
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.CHILD_LOCK,
|
||||
icon_fn=lambda value, device: "mdi:lock-off" if value == 0 else "mdi:lock",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.TIGHT_MOPPING,
|
||||
icon="mdi:heating-coil",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.DND,
|
||||
name="DND",
|
||||
icon_fn=lambda value, device: "mdi:minus-circle-off-outline"
|
||||
if not value
|
||||
else "mdi:minus-circle-outline",
|
||||
format_fn=lambda value, device: bool(value),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.MULTI_FLOOR_MAP,
|
||||
icon_fn=lambda value, device: "mdi:layers-off" if value == 0 else "mdi:layers",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_fn=lambda device, value: device.set_multi_map(value),
|
||||
#exists_fn=lambda description, device: bool(
|
||||
# DreameVacuumEntityDescription().exists_fn(description, device)
|
||||
# and device.status.lidar_navigation
|
||||
#),
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.AUTO_DUST_COLLECTING,
|
||||
icon_fn=lambda value, device: "mdi:autorenew-off"
|
||||
if value == 0
|
||||
else "mdi:autorenew",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.CARPET_RECOGNITION,
|
||||
icon="mdi:rug",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.SELF_CLEAN,
|
||||
icon="mdi:water-sync",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.WATER_ELECTROLYSIS,
|
||||
icon="mdi:lightning-bolt",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.AUTO_WATER_REFILLING,
|
||||
icon="mdi:water-boiler-auto",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.INTELLIGENT_RECOGNITION,
|
||||
icon="mdi:wifi-marker",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
exists_fn=lambda description, device: device.status.auto_switch_settings_available and DreameVacuumEntityDescription().exists_fn(description, device),
|
||||
available_fn=lambda device: device.status.multi_map,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
key="auto_drying",
|
||||
icon="mdi:hair-dryer",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda value, device: bool(device.status.auto_drying),
|
||||
exists_fn=lambda description, device: device.status.auto_drying is not None,
|
||||
set_fn=lambda device, value: device.set_auto_drying(value),
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.CARPET_AVOIDANCE,
|
||||
icon="mdi:close-box-outline",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda value, device: device.status.carpet_avoidance,
|
||||
format_fn=lambda value, device: 1 if value else 2,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.AUTO_ADD_DETERGENT,
|
||||
icon="mdi:chart-bubble",
|
||||
value_fn=lambda value, device: device.status.auto_add_detergent,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
format_fn=lambda value, device: int(value),
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.MAP_SAVING,
|
||||
icon="mdi:map-legend",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
format_fn=lambda value, device: int(value),
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
property_key=DreameVacuumProperty.AUTO_MOUNT_MOP,
|
||||
icon="mdi:google-circles-group",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
format_fn=lambda value, device: int(value),
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
key="cleaning_sequence",
|
||||
icon="mdi:order-numeric-ascending",
|
||||
value_fn=lambda value, device: device.status.custom_order,
|
||||
exists_fn=lambda description, device: device.status.customized_cleaning_available and device.status.map_available,
|
||||
available_fn=lambda device: bool(
|
||||
not device.status.started
|
||||
and device.status.has_saved_map
|
||||
and device.status.segments
|
||||
and next(iter(device.status.segments.values())).order is not None
|
||||
),
|
||||
set_fn=lambda device, value: device.set_segment_order(next(iter(device.status.segments.values())).segment_id, value),
|
||||
format_fn=lambda value, device: int(value),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
name="AI Obstacle Detection",
|
||||
key="ai_obstacle_detection",
|
||||
icon_fn=lambda value, device: "mdi:robot-off" if not value else "mdi:robot",
|
||||
value_fn=lambda value, device: device.status.ai_obstacle_detection,
|
||||
exists_fn=lambda description, device: device.status.ai_detection_available,
|
||||
set_fn=lambda device, value: device.set_ai_obstacle_detection(value),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
name="AI Image Upload",
|
||||
key="ai_obstacle_image_upload",
|
||||
icon="mdi:cloud-upload",
|
||||
value_fn=lambda value, device: device.status.obstacle_image_upload,
|
||||
exists_fn=lambda description, device: bool(device.status.ai_detection_available and device.status.obstacle_image_upload is not None),
|
||||
available_fn=lambda device: device.status.ai_obstacle_detection,
|
||||
set_fn=lambda device, value: device.set_obstacle_image_upload(value),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
name="AI Obstacle Picture",
|
||||
key="ai_obstacle_picture",
|
||||
icon_fn=lambda value, device: "mdi:camera-off" if not value else "mdi:camera",
|
||||
value_fn=lambda value, device: device.status.obstacle_picture,
|
||||
exists_fn=lambda description, device: device.status.ai_detection_available and device.status.obstacle_picture is not None,
|
||||
available_fn=lambda device: device.status.ai_obstacle_detection,
|
||||
set_fn=lambda device, value: device.set_obstacle_picture(value),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
name="AI Pet Detection",
|
||||
key="ai_pet_detection",
|
||||
icon_fn=lambda value, device: "mdi:dog-side-off" if not value else "mdi:dog-side",
|
||||
value_fn=lambda value, device: device.status.pet_detection,
|
||||
exists_fn=lambda description, device: bool(device.status.ai_detection_available and device.status.pet_detection is not None),
|
||||
available_fn=lambda device: device.status.ai_obstacle_detection,
|
||||
set_fn=lambda device, value: device.set_pet_detection(value),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
name="AI Human Detection",
|
||||
key="ai_human_detection",
|
||||
icon_fn=lambda value, device: "mdi:account-off" if not value else "mdi:account",
|
||||
value_fn=lambda value, device: device.status.human_detection,
|
||||
exists_fn=lambda description, device: bool(device.status.ai_detection_available and device.status.human_detection is not None),
|
||||
available_fn=lambda device: device.status.ai_obstacle_detection,
|
||||
set_fn=lambda device, value: device.set_human_detection(value),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
name="AI Furniture Detection",
|
||||
key="ai_furniture_detection",
|
||||
icon="mdi:table-furniture",
|
||||
value_fn=lambda value, device: device.status.furniture_detection,
|
||||
exists_fn=lambda description, device: bool(device.status.ai_detection_available and device.status.furniture_detection is not None),
|
||||
available_fn=lambda device: device.status.ai_obstacle_detection,
|
||||
set_fn=lambda device, value: device.set_furniture_detection(value),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
name="AI Fluid Detection",
|
||||
key="ai_fluid_detection",
|
||||
icon_fn=lambda value, device: "mdi:water-off-outline" if not value else "mdi:water-outline",
|
||||
value_fn=lambda value, device: device.status.fluid_detection,
|
||||
exists_fn=lambda description, device: bool(device.status.ai_detection_available and device.status.fluid_detection is not None),
|
||||
available_fn=lambda device: device.status.ai_obstacle_detection,
|
||||
set_fn=lambda device, value: device.set_fluid_detection(value),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
key="fill_light",
|
||||
icon_fn=lambda value, device: "mdi:brightness-2" if not value else "mdi:brightness-auto",
|
||||
value_fn=lambda value, device: device.status.fill_light,
|
||||
exists_fn=lambda description, device: bool(device.status.auto_switch_settings_available and device.status.fill_light is not None),
|
||||
set_fn=lambda device, value: device.set_fill_light(value),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
key="collision_avoidance",
|
||||
icon_fn=lambda value, device: "mdi:sign-direction-remove" if not value else "mdi:sign-direction",
|
||||
value_fn=lambda value, device: device.status.collision_avoidance,
|
||||
exists_fn=lambda description, device: bool(device.status.auto_switch_settings_available and device.status.collision_avoidance is not None),
|
||||
set_fn=lambda device, value: device.set_collision_avoidance(value),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
DreameVacuumSwitchEntityDescription(
|
||||
key="stain_avoidance",
|
||||
icon="mdi:liquid-spot",
|
||||
value_fn=lambda value, device: bool(device.status.stain_avoidance == 2),
|
||||
exists_fn=lambda description, device: device.status.ai_detection_available and device.status.auto_switch_settings_available and device.status.stain_avoidance is not None,
|
||||
set_fn=lambda device, value: device.set_stain_avoidance(2 if value else 1),
|
||||
available_fn=lambda device: device.status.fluid_detection,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Dreame Vacuum switch based on a config entry."""
|
||||
coordinator: DreameVacuumDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
DreameVacuumSwitchEntity(coordinator, description)
|
||||
for description in SWITCHES
|
||||
if description.exists_fn(description, coordinator.device)
|
||||
)
|
||||
|
||||
|
||||
class DreameVacuumSwitchEntity(DreameVacuumEntity, SwitchEntity):
|
||||
"""Defines a Dreame Vacuum Switch entity."""
|
||||
|
||||
entity_description: DreameVacuumSwitchEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DreameVacuumDataUpdateCoordinator,
|
||||
description: DreameVacuumSwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Dreame Vacuum switch entity."""
|
||||
super().__init__(coordinator, description)
|
||||
self._attr_is_on = bool(self.native_value)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
self._attr_is_on = bool(self.native_value)
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the Dreame Vacuum sync receive switch."""
|
||||
await self.async_set_state(0)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the Dreame Vacuum sync receive switch."""
|
||||
await self.async_set_state(1)
|
||||
|
||||
async def async_set_state(self, state) -> None:
|
||||
"""Turn on or off the Dreame Vacuum sync receive switch."""
|
||||
if not self.available:
|
||||
raise HomeAssistantError("Entity unavailable")
|
||||
|
||||
value = int(state)
|
||||
if self.entity_description.format_fn is not None:
|
||||
value = self.entity_description.format_fn(state, self.device)
|
||||
|
||||
if self.entity_description.property_key is not None:
|
||||
await self._try_command(
|
||||
"Unable to call: %s",
|
||||
self.device.set_property,
|
||||
self.entity_description.property_key,
|
||||
value,
|
||||
)
|
||||
elif self.entity_description.set_fn is not None:
|
||||
await self._try_command(
|
||||
"Unable to call: %s", self.entity_description.set_fn, self.device, value
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user