First commit

This commit is contained in:
2024-12-18 13:26:06 +01:00
commit 96830baee3
2568 changed files with 363730 additions and 0 deletions

View 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)

View 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()

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

View 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()

View 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."""

View 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

View 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

View 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": []
}

View 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,
)

View File

View 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

View File

@@ -0,0 +1,2 @@
reset:
update:

View 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": {}
}
}

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

View 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": {}
}
}

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

View 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": {}
}
}

View 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": {}
}
}

View 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": {}
}
}

View 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": {}
}
}

View 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": {}
}
}

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

View 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)

View 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,
)

View 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}

View 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"]

View 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 = ""
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"

View 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![image](data:{CONTENT_TYPE};base64,{CONSUMABLE_IMAGE.get(CONSUMABLE_MAIN_BRUSH)})',
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![image](data:{CONTENT_TYPE};base64,{CONSUMABLE_IMAGE.get(CONSUMABLE_SIDE_BRUSH)})',
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![image](data:{CONTENT_TYPE};base64,{CONSUMABLE_IMAGE.get(CONSUMABLE_FILTER)})',
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![image](data:{CONTENT_TYPE};base64,{CONSUMABLE_IMAGE.get(CONSUMABLE_SENSOR)})',
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![image](data:{CONTENT_TYPE};base64,{CONSUMABLE_IMAGE.get(CONSUMABLE_MOP_PAD)})',
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![image](data:{CONTENT_TYPE};base64,{CONSUMABLE_IMAGE.get(CONSUMABLE_SILVER_ION)})',
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}![image](data:{CONTENT_TYPE};base64,{image})"
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)

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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):
""" """

File diff suppressed because it is too large Load Diff

View 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

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View 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

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

View 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

View 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)

View 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)

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

View 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."
}
}
}
}
}

View 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