diff --git a/custom_components/deebot/binary_sensor.py b/custom_components/deebot/binary_sensor.py index 7014c65..a0f2b79 100644 --- a/custom_components/deebot/binary_sensor.py +++ b/custom_components/deebot/binary_sensor.py @@ -1,6 +1,9 @@ """Binary sensor module.""" -import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic +from deebot_client.capabilities import CapabilityEvent from deebot_client.events.water_info import WaterInfoEvent from homeassistant.components.binary_sensor import ( BinarySensorEntity, @@ -13,50 +16,63 @@ from .const import DOMAIN from .controller import DeebotController -from .entity import DeebotEntity +from .entity import DeebotEntity, DeebotEntityDescription, EventT -_LOGGER = logging.getLogger(__name__) +@dataclass +class DeebotBinarySensorEntityMixin(Generic[EventT]): + """Deebot binary sensor entity mixin.""" -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Add entities for passed config_entry in HA.""" - controller: DeebotController = hass.data[DOMAIN][config_entry.entry_id] - - new_devices = [] - for vacbot in controller.vacuum_bots: - new_devices.append(DeebotMopAttachedBinarySensor(vacbot)) + value_fn: Callable[[EventT], bool | None] + icon_fn: Callable[[bool | None], str | None] - if new_devices: - async_add_entities(new_devices) +@dataclass +class DeebotBinarySensorEntityDescription( + BinarySensorEntityDescription, # type: ignore + DeebotEntityDescription, + DeebotBinarySensorEntityMixin[EventT], +): + """Class describing Deebot binary sensor entity.""" -class DeebotMopAttachedBinarySensor(DeebotEntity, BinarySensorEntity): # type: ignore - """Deebot mop attached binary sensor.""" - entity_description = BinarySensorEntityDescription( +ENTITY_DESCRIPTIONS: tuple[DeebotBinarySensorEntityDescription, ...] = ( + DeebotBinarySensorEntityDescription[WaterInfoEvent]( + capability_fn=lambda caps: caps.water, + value_fn=lambda e: e.mop_attached, + icon_fn=lambda is_on: "mdi:water" if is_on else "mdi:water-off", key="mop_attached", translation_key="mop_attached", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: DeebotController = hass.data[DOMAIN][config_entry.entry_id] + controller.register_platform_add_entities( + DeebotBinarySensor, ENTITY_DESCRIPTIONS, async_add_entities ) - @property - def icon(self) -> str | None: - """Return the icon to use in the frontend, if any.""" - return "mdi:water" if self.is_on else "mdi:water-off" + +class DeebotBinarySensor(DeebotEntity[CapabilityEvent[EventT], DeebotBinarySensorEntityDescription], BinarySensorEntity): # type: ignore + """Deebot binary sensor.""" async def async_added_to_hass(self) -> None: """Set up the event listeners now that hass is ready.""" await super().async_added_to_hass() - async def on_event(event: WaterInfoEvent) -> None: - self._attr_is_on = event.mop_attached + async def on_event(event: EventT) -> None: + self._attr_is_on = self.entity_description.value_fn(event) + self._attr_icon = self.entity_description.icon_fn(self._attr_is_on) self.async_write_ha_state() self.async_on_remove( - self._vacuum_bot.events.subscribe(WaterInfoEvent, on_event) + self._vacuum_bot.events.subscribe(self._capability.event, on_event) ) diff --git a/custom_components/deebot/button.py b/custom_components/deebot/button.py index 6548d7b..1d15d19 100644 --- a/custom_components/deebot/button.py +++ b/custom_components/deebot/button.py @@ -1,7 +1,8 @@ """Binary sensor module.""" -import logging +from collections.abc import Sequence +from dataclasses import dataclass -from deebot_client.commands import ResetLifeSpan, SetRelocationState +from deebot_client.capabilities import CapabilityExecute from deebot_client.events import LifeSpan from deebot_client.vacuum_bot import VacuumBot from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -12,9 +13,27 @@ from .const import DOMAIN from .controller import DeebotController -from .entity import DeebotEntity +from .entity import DeebotEntity, DeebotEntityDescription -_LOGGER = logging.getLogger(__name__) + +@dataclass +class DeebotButtonEntityDescription( + ButtonEntityDescription, # type: ignore + DeebotEntityDescription, +): + """Class describing debbot button entity.""" + + +ENTITY_DESCRIPTIONS: tuple[DeebotButtonEntityDescription, ...] = ( + DeebotButtonEntityDescription( + capability_fn=lambda caps: caps.map.relocation if caps.map else None, + key="relocate", + translation_key="relocate", + icon="mdi:map-marker-question", + entity_registry_enabled_default=True, # Can be enabled as they don't poll data + entity_category=EntityCategory.DIAGNOSTIC, + ), +) async def async_setup_entry( @@ -24,18 +43,27 @@ async def async_setup_entry( ) -> None: """Add entities for passed config_entry in HA.""" controller: DeebotController = hass.data[DOMAIN][config_entry.entry_id] + controller.register_platform_add_entities( + DeebotButtonEntity, ENTITY_DESCRIPTIONS, async_add_entities + ) - new_devices = [] - for vacbot in controller.vacuum_bots: - for component in LifeSpan: - new_devices.append(DeebotResetLifeSpanButtonEntity(vacbot, component)) - new_devices.append(DeebotRelocateButtonEntity(vacbot)) + def generate_reset_life_span( + device: VacuumBot, + ) -> Sequence[DeebotResetLifeSpanButtonEntity]: + return [ + DeebotResetLifeSpanButtonEntity(device, component) + for component in device.capabilities.life_span.types + ] - if new_devices: - async_add_entities(new_devices) + controller.register_platform_add_entities_generator( + async_add_entities, generate_reset_life_span + ) -class DeebotResetLifeSpanButtonEntity(DeebotEntity, ButtonEntity): # type: ignore +class DeebotResetLifeSpanButtonEntity( + DeebotEntity[None, ButtonEntityDescription], + ButtonEntity, # type: ignore +): """Deebot reset life span button entity.""" def __init__(self, vacuum_bot: VacuumBot, component: LifeSpan): @@ -47,25 +75,20 @@ def __init__(self, vacuum_bot: VacuumBot, component: LifeSpan): entity_registry_enabled_default=True, # Can be enabled as they don't poll data entity_category=EntityCategory.CONFIG, ) - super().__init__(vacuum_bot, entity_description) - self._component = component + super().__init__(vacuum_bot, None, entity_description) + self._command = vacuum_bot.capabilities.life_span.reset(component) async def async_press(self) -> None: """Press the button.""" - await self._vacuum_bot.execute_command(ResetLifeSpan(self._component)) - + await self._vacuum_bot.execute_command(self._command) -class DeebotRelocateButtonEntity(DeebotEntity, ButtonEntity): # type: ignore - """Deebot relocate button entity.""" - entity_description = ButtonEntityDescription( - key="relocate", - translation_key="relocate", - icon="mdi:map-marker-question", - entity_registry_enabled_default=True, # Can be enabled as they don't poll data - entity_category=EntityCategory.DIAGNOSTIC, - ) +class DeebotButtonEntity( + DeebotEntity[CapabilityExecute, DeebotButtonEntityDescription], + ButtonEntity, # type: ignore +): + """Deebot button entity.""" async def async_press(self) -> None: """Press the button.""" - await self._vacuum_bot.execute_command(SetRelocationState()) + await self._vacuum_bot.execute_command(self._capability.execute()) diff --git a/custom_components/deebot/config_flow.py b/custom_components/deebot/config_flow.py index 6e614ab..09d8e2f 100644 --- a/custom_components/deebot/config_flow.py +++ b/custom_components/deebot/config_flow.py @@ -218,11 +218,12 @@ def _get_options_schema( select_options = [] for entry in devices: - label = entry.get("nick", entry["name"]) + api_info = entry.api_device_info + label = api_info.get("nick", api_info["name"]) if not label: - label = entry["name"] + label = api_info["name"] select_options.append( - selector.SelectOptionDict(value=entry["name"], label=label) + selector.SelectOptionDict(value=api_info["name"], label=label) ) return vol.Schema( diff --git a/custom_components/deebot/controller.py b/custom_components/deebot/controller.py index 5463284..85c51a0 100644 --- a/custom_components/deebot/controller.py +++ b/custom_components/deebot/controller.py @@ -2,13 +2,13 @@ import logging import random import string -from collections.abc import Mapping +from collections.abc import Callable, Mapping, Sequence from typing import Any from deebot_client.api_client import ApiClient from deebot_client.authentication import Authenticator from deebot_client.exceptions import InvalidAuthenticationError -from deebot_client.models import Configuration +from deebot_client.models import ApiDeviceInfo, Configuration from deebot_client.mqtt_client import MqttClient, MqttConfiguration from deebot_client.util import md5 from deebot_client.vacuum_bot import VacuumBot @@ -21,6 +21,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from custom_components.deebot.entity import DeebotEntity, DeebotEntityDescription from .const import CONF_CLIENT_DEVICE_ID, CONF_CONTINENT, CONF_COUNTRY @@ -33,7 +38,7 @@ class DeebotController: def __init__(self, hass: HomeAssistant, config: Mapping[str, Any]): self._hass_config: Mapping[str, Any] = config self._hass: HomeAssistant = hass - self.vacuum_bots: list[VacuumBot] = [] + self._devices: list[VacuumBot] = [] verify_ssl = config.get(CONF_VERIFY_SSL, True) device_id = config.get(CONF_CLIENT_DEVICE_ID) @@ -71,11 +76,15 @@ async def initialize(self) -> None: await self._mqtt.connect() for device in devices: - if device["name"] in self._hass_config.get(CONF_DEVICES, []): + if device.api_device_info["name"] in self._hass_config.get( + CONF_DEVICES, [] + ): bot = VacuumBot(device, self._authenticator) - _LOGGER.debug("New vacbot found: %s", device["name"]) + _LOGGER.debug( + "New vacbot found: %s", device.api_device_info["name"] + ) await bot.initialize(self._mqtt) - self.vacuum_bots.append(bot) + self._devices.append(bot) _LOGGER.debug("Controller initialize complete") except InvalidAuthenticationError as ex: @@ -85,9 +94,50 @@ async def initialize(self) -> None: _LOGGER.error(msg, exc_info=True) raise ConfigEntryNotReady(msg) from ex + def register_platform_add_entities( + self, + entity_class: type[DeebotEntity], + descriptions: tuple[DeebotEntityDescription, ...], + async_add_entities: AddEntitiesCallback, + ) -> None: + """Create entities from descriptions and add them.""" + new_entites: list[DeebotEntity] = [] + + for device in self._devices: + for description in descriptions: + if capability := description.capability_fn(device.capabilities): + new_entites.append(entity_class(device, capability, description)) + + if new_entites: + async_add_entities(new_entites) + + def register_platform_add_entities_generator( + self, + async_add_entities: AddEntitiesCallback, + func: Callable[[VacuumBot], Sequence[DeebotEntity[Any, EntityDescription]]], + ) -> None: + """Add entities generated through the provided function.""" + new_entites: list[DeebotEntity[Any, EntityDescription]] = [] + + for device in self._devices: + new_entites.extend(func(device)) + + if new_entites: + async_add_entities(new_entites) + + def get_device_info(self, device: DeviceEntry) -> ApiDeviceInfo | dict[str, str]: + """Get the device info for the given entry.""" + for bot in self._devices: + for identifier in device.identifiers: + if bot.device_info.did == identifier[1]: + return bot.device_info.api_device_info + + _LOGGER.error("Could not find the device with entry: %s", device.json_repr) + return {"error": "Could not find the device"} + async def teardown(self) -> None: """Disconnect controller.""" - for bot in self.vacuum_bots: + for bot in self._devices: await bot.teardown() await self._mqtt.disconnect() await self._authenticator.teardown() diff --git a/custom_components/deebot/diagnostics.py b/custom_components/deebot/diagnostics.py index 7ebfc5b..53ab23b 100644 --- a/custom_components/deebot/diagnostics.py +++ b/custom_components/deebot/diagnostics.py @@ -25,10 +25,8 @@ async def async_get_device_diagnostics( "config": async_redact_data(config_entry.as_dict(), REDACT_CONFIG) } - for bot in controller.vacuum_bots: - for identifier in device.identifiers: - if bot.device_info.did == identifier[1]: - diag["device"] = async_redact_data(bot.device_info, REDACT_DEVICE) - break + diag["device"] = async_redact_data( + controller.get_device_info(device), REDACT_DEVICE + ) return diag diff --git a/custom_components/deebot/entity.py b/custom_components/deebot/entity.py index 7257268..2996561 100644 --- a/custom_components/deebot/entity.py +++ b/custom_components/deebot/entity.py @@ -1,16 +1,43 @@ """Deebot entity module.""" -from typing import Any +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Generic, TypeVar +from deebot_client.capabilities import Capabilities from deebot_client.events import AvailabilityEvent +from deebot_client.events.base import Event from deebot_client.vacuum_bot import VacuumBot from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription -from . import DOMAIN +from .const import DOMAIN +_EntityDescriptionT = TypeVar("_EntityDescriptionT", bound=EntityDescription) +CapabilityT = TypeVar("CapabilityT") +EventT = TypeVar("EventT", bound=Event) -class DeebotEntity(Entity): # type: ignore # lgtm [py/missing-equals] + +@dataclass +class DeebotDescription(Generic[CapabilityT]): + """Deebot description.""" + + capability_fn: Callable[[Capabilities], CapabilityT | None] + + +@dataclass +class DeebotEntityDescription( + EntityDescription, # type: ignore + DeebotDescription[CapabilityT], +): + """Deebot Entity Description.""" + + always_available: bool = False + + +class DeebotEntity(Entity, Generic[CapabilityT, _EntityDescriptionT]): # type: ignore """Deebot entity.""" + entity_description: _EntityDescriptionT + _attr_should_poll = False _always_available: bool = False _attr_has_entity_name = True @@ -18,10 +45,11 @@ class DeebotEntity(Entity): # type: ignore # lgtm [py/missing-equals] def __init__( self, vacuum_bot: VacuumBot, - entity_description: EntityDescription | None = None, + capability: CapabilityT, + entity_description: _EntityDescriptionT | None = None, **kwargs: Any, ): - """Initialize the Sensor.""" + """Initialize entity.""" super().__init__(**kwargs) if entity_description: self.entity_description = entity_description @@ -31,6 +59,7 @@ def __init__( ) self._vacuum_bot: VacuumBot = vacuum_bot + self._capability = capability device_info = self._vacuum_bot.device_info self._attr_unique_id = device_info.did @@ -48,11 +77,11 @@ def device_info(self) -> DeviceInfo | None: sw_version=self._vacuum_bot.fw_version, ) - if "nick" in device: - info["name"] = device["nick"] + if nick := device.api_device_info.get("nick"): + info["name"] = nick - if "deviceName" in device: - info["model"] = device["deviceName"] + if model := device.api_device_info.get("deviceName"): + info["model"] = model return info diff --git a/custom_components/deebot/image.py b/custom_components/deebot/image.py index a627fab..d0c9e0f 100644 --- a/custom_components/deebot/image.py +++ b/custom_components/deebot/image.py @@ -1,9 +1,9 @@ -"""Support for Deebot Vacuums.""" +"""Support for Deebot image entities.""" import base64 -import logging -from collections.abc import MutableMapping +from collections.abc import MutableMapping, Sequence from typing import Any +from deebot_client.capabilities import CapabilityMap from deebot_client.events.map import CachedMapInfoEvent, MapChangedEvent from deebot_client.vacuum_bot import VacuumBot from homeassistant.components.image import ImageEntity @@ -16,8 +16,6 @@ from .controller import DeebotController from .entity import DeebotEntity -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, @@ -27,32 +25,41 @@ async def async_setup_entry( """Add entities for passed config_entry in HA.""" controller: DeebotController = hass.data[DOMAIN][config_entry.entry_id] - new_devices = [] + def image_entity_generator( + device: VacuumBot, + ) -> Sequence[DeebotMap]: + new_entities = [] + if caps := device.capabilities.map: + new_entities.append(DeebotMap(hass, device, caps)) - for vacbot in controller.vacuum_bots: - new_devices.append(DeebotMap(hass, vacbot)) + return new_entities - if new_devices: - async_add_entities(new_devices) + controller.register_platform_add_entities_generator( + async_add_entities, image_entity_generator + ) -class DeebotMap(DeebotEntity, ImageEntity): # type: ignore +class DeebotMap( + DeebotEntity[CapabilityMap, EntityDescription], + ImageEntity, # type: ignore +): """Deebot map.""" - entity_description = EntityDescription( - key="map", - translation_key="map", - entity_registry_enabled_default=False, - ) - _attr_should_poll = True def __init__( - self, - hass: HomeAssistant, - vacuum_bot: VacuumBot, + self, hass: HomeAssistant, device: VacuumBot, capability: CapabilityMap ): - super().__init__(vacuum_bot, hass=hass) + super().__init__( + device, + capability, + EntityDescription( + key="map", + translation_key="map", + entity_registry_enabled_default=False, + ), + hass=hass, + ) self._attr_extra_state_attributes: MutableMapping[str, Any] = {} def image(self) -> bytes | None: @@ -73,8 +80,12 @@ async def on_changed(event: MapChangedEvent) -> None: self.async_write_ha_state() subscriptions = [ - self._vacuum_bot.events.subscribe(CachedMapInfoEvent, on_info), - self._vacuum_bot.events.subscribe(MapChangedEvent, on_changed), + self._vacuum_bot.events.subscribe( + self._capability.chached_info.event, on_info + ), + self._vacuum_bot.events.subscribe( + self._capability.changed.event, on_changed + ), ] def on_remove() -> None: diff --git a/custom_components/deebot/manifest.json b/custom_components/deebot/manifest.json index 85fca0e..ebac977 100644 --- a/custom_components/deebot/manifest.json +++ b/custom_components/deebot/manifest.json @@ -14,7 +14,7 @@ "deebot_client" ], "requirements": [ - "deebot-client==3.0.2", + "git+https://github.com/DeebotUniverse/client.py@dev#deebot-client==4.0.0b0", "numpy>=1.23.2" ], "version": "v0.0.0" diff --git a/custom_components/deebot/number.py b/custom_components/deebot/number.py index 86c9bd1..14caeaf 100644 --- a/custom_components/deebot/number.py +++ b/custom_components/deebot/number.py @@ -1,6 +1,9 @@ """Number module.""" -from deebot_client.commands import SetCleanCount, SetVolume -from deebot_client.events import CleanCountEvent, VolumeEvent +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic + +from deebot_client.capabilities import CapabilitySet from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -10,105 +13,110 @@ from .const import DOMAIN from .controller import DeebotController -from .entity import DeebotEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Add entities for passed config_entry in HA.""" - controller: DeebotController = hass.data[DOMAIN][config_entry.entry_id] +from .entity import DeebotEntity, DeebotEntityDescription, EventT - new_devices = [] - for vacbot in controller.vacuum_bots: - new_devices.append(VolumeEntity(vacbot)) - new_devices.append(CleanCountEntity(vacbot)) - if new_devices: - async_add_entities(new_devices) +@dataclass +class DeebotNumberEntityMixin(Generic[EventT]): + """Deebot number entity mixin.""" + value_fn: Callable[[EventT], float | None] -class VolumeEntity(DeebotEntity, NumberEntity): # type: ignore - """Volume number entity.""" - entity_description = NumberEntityDescription( - key="volume", - translation_key="volume", - entity_registry_enabled_default=False, - entity_category=EntityCategory.CONFIG, - ) - - _attr_native_min_value = 0 - _attr_native_max_value = 10 - _attr_native_step = 1.0 - _attr_native_value: float | None = None +@dataclass +class DeebotNumberEntityDescription( + NumberEntityDescription, # type: ignore + DeebotEntityDescription, + DeebotNumberEntityMixin[EventT], +): + """Deebot number entity description.""" - async def async_added_to_hass(self) -> None: - """Set up the event listeners now that hass is ready.""" - await super().async_added_to_hass() + native_max_value_fn: Callable[[EventT], float | None] = lambda _: None + icon_fn: Callable[["DeebotNumberEntity"], str | None] = lambda _: None - async def on_volume(event: VolumeEvent) -> None: - if event.maximum is not None: - self._attr_native_max_value = event.maximum - self._attr_native_value = event.volume - self.async_write_ha_state() - self.async_on_remove(self._vacuum_bot.events.subscribe(VolumeEvent, on_volume)) - - @property - def icon(self) -> str | None: - """Return the icon to use in the frontend, if any.""" - if self._attr_native_value is not None: - arrays = array_split( - range(self._attr_native_min_value + 1, self._attr_native_max_value + 1), - 3, - ) - if self._attr_native_value == self._attr_native_min_value: - return "mdi:volume-off" - if self._attr_native_value in arrays[0]: - return "mdi:volume-low" - if self._attr_native_value in arrays[1]: - return "mdi:volume-medium" - if self._attr_native_value in arrays[2]: - return "mdi:volume-high" - - return "mdi:volume-medium" +def _volume_icon(instance: "DeebotNumberEntity") -> str | None: + """Return the icon for the volume number.""" + value = instance.native_value + if value is not None: + min_value = instance.native_min_value - async def async_set_native_value(self, value: float) -> None: - """Set new value.""" - await self._vacuum_bot.execute_command(SetVolume(int(value))) + arrays = array_split(range(min_value + 1, instance.native_max_value + 1), 3) + if value == min_value: + return "mdi:volume-off" + if value in arrays[0]: + return "mdi:volume-low" + if value in arrays[1]: + return "mdi:volume-medium" + if value in arrays[2]: + return "mdi:volume-high" + return "mdi:volume-medium" -class CleanCountEntity(DeebotEntity, NumberEntity): # type: ignore - """Clean count number entity.""" - entity_description = NumberEntityDescription( +ENTITY_DESCRIPTIONS: tuple[DeebotNumberEntityDescription, ...] = ( + DeebotNumberEntityDescription( + capability_fn=lambda caps: caps.settings.volume, + value_fn=lambda e: e.volume, + native_max_value_fn=lambda e: e.maximum if e.maximum else None, + key="volume", + translation_key="volume", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=10, + native_step=1.0, + icon_fn=_volume_icon, + ), + DeebotNumberEntityDescription( + capability_fn=lambda caps: caps.clean.count, + value_fn=lambda e: e.count, key="clean_count", translation_key="clean_count", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, + native_min_value=1, + native_max_value=4, + native_step=1.0, + icon="mdi:counter", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: DeebotController = hass.data[DOMAIN][config_entry.entry_id] + controller.register_platform_add_entities( + DeebotNumberEntity, ENTITY_DESCRIPTIONS, async_add_entities ) - _attr_native_min_value = 1 - _attr_native_max_value = 4 - _attr_native_step = 1.0 - _attr_native_value: float | None = None - _attr_icon = "mdi:counter" + +class DeebotNumberEntity( + DeebotEntity[CapabilitySet[EventT, int], DeebotNumberEntityDescription], + NumberEntity, # type: ignore +): + """Deebot number entity.""" async def async_added_to_hass(self) -> None: """Set up the event listeners now that hass is ready.""" await super().async_added_to_hass() - async def on_clean_count(event: CleanCountEvent) -> None: - self._attr_native_value = event.count + async def on_event(event: EventT) -> None: + self._attr_native_value = self.entity_description.value_fn(event) + if maximum := self.entity_description.native_max_value_fn(event): + self._attr_native_max_value = maximum + if icon := self.entity_description.icon_fn(self): + self._attr_icon = icon self.async_write_ha_state() self.async_on_remove( - self._vacuum_bot.events.subscribe(CleanCountEvent, on_clean_count) + self._vacuum_bot.events.subscribe(self._capability.event, on_event) ) async def async_set_native_value(self, value: float) -> None: """Set new value.""" - await self._vacuum_bot.execute_command(SetCleanCount(int(value))) + await self._vacuum_bot.execute_command(self._capability.set(int(value))) diff --git a/custom_components/deebot/select.py b/custom_components/deebot/select.py index 92324e5..9721f14 100644 --- a/custom_components/deebot/select.py +++ b/custom_components/deebot/select.py @@ -1,8 +1,10 @@ """Select module.""" -import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Generic -from deebot_client.commands import SetWaterInfo -from deebot_client.events import WaterAmount, WaterInfoEvent +from deebot_client.capabilities import CapabilitySetTypes +from deebot_client.vacuum_bot import VacuumBot from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -11,53 +13,82 @@ from .const import DOMAIN from .controller import DeebotController -from .entity import DeebotEntity +from .entity import DeebotEntity, DeebotEntityDescription, EventT -_LOGGER = logging.getLogger(__name__) +@dataclass +class DeebotSelectEntityMixin(Generic[EventT]): + """Deebot select entity mixin.""" -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Add entities for passed config_entry in HA.""" - controller: DeebotController = hass.data[DOMAIN][config_entry.entry_id] - - new_devices = [] - for vacbot in controller.vacuum_bots: - new_devices.append(WaterInfoSelect(vacbot)) + current_option_fn: Callable[[EventT], str | None] + options_fn: Callable[[CapabilitySetTypes], list[str]] - if new_devices: - async_add_entities(new_devices) +@dataclass +class DeebotSelectEntityDescription( + SelectEntityDescription, # type: ignore + DeebotEntityDescription, + DeebotSelectEntityMixin[EventT], +): + """Deebot select entity description.""" -class WaterInfoSelect(DeebotEntity, SelectEntity): # type: ignore - """Water info select entity.""" - entity_description = SelectEntityDescription( +ENTITY_DESCRIPTIONS: tuple[DeebotSelectEntityDescription, ...] = ( + DeebotSelectEntityDescription( + capability_fn=lambda caps: caps.water, + current_option_fn=lambda e: e.amount.display_name, + options_fn=lambda water: [amount.display_name for amount in water.types], key="water_amount", translation_key="water_amount", entity_registry_enabled_default=False, icon="mdi:water", entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: DeebotController = hass.data[DOMAIN][config_entry.entry_id] + controller.register_platform_add_entities( + DeebotSelectEntity, ENTITY_DESCRIPTIONS, async_add_entities ) - _attr_options = [amount.display_name for amount in WaterAmount] + +class DeebotSelectEntity( + DeebotEntity[CapabilitySetTypes[EventT, str], DeebotSelectEntityDescription], + SelectEntity, # type: ignore +): + """Deebot select entity.""" + _attr_current_option: str | None = None + def __init__( + self, + vacuum_bot: VacuumBot, + capability: CapabilitySetTypes[EventT, str], + entity_description: DeebotSelectEntityDescription | None = None, + **kwargs: Any, + ): + super().__init__(vacuum_bot, capability, entity_description, **kwargs) + self._attr_options = self.entity_description.options_fn(capability) + async def async_added_to_hass(self) -> None: """Set up the event listeners now that hass is ready.""" await super().async_added_to_hass() - async def on_water_info(event: WaterInfoEvent) -> None: - self._attr_current_option = event.amount.display_name + async def on_water_info(event: EventT) -> None: + self._attr_current_option = self.entity_description.current_option_fn(event) self.async_write_ha_state() self.async_on_remove( - self._vacuum_bot.events.subscribe(WaterInfoEvent, on_water_info) + self._vacuum_bot.events.subscribe(self._capability.event, on_water_info) ) async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await self._vacuum_bot.execute_command(SetWaterInfo(option)) + await self._vacuum_bot.execute_command(self._capability.set(option)) diff --git a/custom_components/deebot/sensor.py b/custom_components/deebot/sensor.py index 5cbda26..999c641 100644 --- a/custom_components/deebot/sensor.py +++ b/custom_components/deebot/sensor.py @@ -1,9 +1,10 @@ """Sensor module.""" -import logging -from collections.abc import Callable +from collections.abc import Callable, MutableMapping, Sequence +from dataclasses import dataclass from math import floor -from typing import TypeVar +from typing import Any, Generic, TypeVar +from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan from deebot_client.events import ( BatteryEvent, CleanLogEvent, @@ -37,237 +38,258 @@ from .const import DOMAIN, LAST_ERROR from .controller import DeebotController -from .entity import DeebotEntity +from .entity import DeebotEntity, DeebotEntityDescription, EventT -_LOGGER = logging.getLogger(__name__) +@dataclass +class DeebotSensorMixin(Generic[EventT]): + """Deebot sensor mixin.""" -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Add entities for passed config_entry in HA.""" - controller: DeebotController = hass.data[DOMAIN][config_entry.entry_id] + value_fn: Callable[[EventT], StateType] - new_devices = [] - for vacbot in controller.vacuum_bots: - for component in LifeSpan: - new_devices.append(LifeSpanSensor(vacbot, component)) - - new_devices.extend( - [ - LastCleaningJobSensor(vacbot), - LastErrorSensor(vacbot), - # Stats - DeebotGenericSensor( - vacbot, - SensorEntityDescription( - key="stats_area", - translation_key="stats_area", - icon="mdi:floor-plan", - native_unit_of_measurement=AREA_SQUARE_METERS, - entity_registry_enabled_default=False, - ), - StatsEvent, - lambda e: e.area, - ), - DeebotGenericSensor( - vacbot, - SensorEntityDescription( - key="stats_time", - translation_key="stats_time", - icon="mdi:timer-outline", - native_unit_of_measurement=TIME_MINUTES, - entity_registry_enabled_default=False, - ), - StatsEvent, - lambda e: round(e.time / 60) if e.time else None, - ), - DeebotGenericSensor( - vacbot, - SensorEntityDescription( - key="stats_type", - translation_key="stats_type", - icon="mdi:cog", - entity_registry_enabled_default=False, - ), - StatsEvent, - lambda e: e.type, - ), - # TotalStats - DeebotGenericSensor( - vacbot, - SensorEntityDescription( - key="stats_total_area", - translation_key="stats_total_area", - icon="mdi:floor-plan", - native_unit_of_measurement=AREA_SQUARE_METERS, - entity_registry_enabled_default=False, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TotalStatsEvent, - lambda e: e.area, - ), - DeebotGenericSensor( - vacbot, - SensorEntityDescription( - key="stats_total_time", - translation_key="stats_total_time", - icon="mdi:timer-outline", - native_unit_of_measurement=TIME_HOURS, - entity_registry_enabled_default=False, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TotalStatsEvent, - lambda e: round(e.time / 3600), - ), - DeebotGenericSensor( - vacbot, - SensorEntityDescription( - key="stats_total_cleanings", - translation_key="stats_total_cleanings", - icon="mdi:counter", - entity_registry_enabled_default=False, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TotalStatsEvent, - lambda e: e.cleanings, - ), - DeebotGenericSensor( - vacbot, - SensorEntityDescription( - key=ATTR_BATTERY_LEVEL, - translation_key=ATTR_BATTERY_LEVEL, - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.BATTERY, - entity_category=EntityCategory.DIAGNOSTIC, - ), - BatteryEvent, - lambda b: b.value, - ), - ] - ) - if new_devices: - async_add_entities(new_devices) +@dataclass +class DeebotSensorEntityDescription( + SensorEntityDescription, # type: ignore + DeebotEntityDescription, + DeebotSensorMixin[EventT], +): + """Deebot sensor entity description.""" + extra_state_attributes_fn: Callable[ + [EventT], MutableMapping[str, Any] + ] | None = None -T = TypeVar("T", bound=Event) +def _clean_log_event_value(event: CleanLogEvent) -> str | None: + if event.logs: + log = event.logs[0] + return log.stop_reason.display_name + return None -class DeebotGenericSensor(DeebotEntity, SensorEntity): # type: ignore - """Deebot generic sensor.""" - def __init__( - self, - vacuum_bot: VacuumBot, - entity_descrption: SensorEntityDescription, - event_type: type[T], - extract_value: Callable[[T], StateType], - ): - """Initialize the Sensor.""" - super().__init__(vacuum_bot, entity_descrption) - self._event_type = event_type - self._extract_value = extract_value - - async def async_added_to_hass(self) -> None: - """Set up the event listeners now that hass is ready.""" - await super().async_added_to_hass() - - async def on_event(event: T) -> None: - value = self._extract_value(event) - if value is not None: - self._attr_native_value = value - self.async_write_ha_state() - - self.async_on_remove( - self._vacuum_bot.events.subscribe(self._event_type, on_event) - ) +def _clean_log_event_attributes(event: CleanLogEvent) -> MutableMapping[str, Any]: + if event.logs: + log = event.logs[0] + return { + "timestamp": log.timestamp, + "image_url": log.image_url, + "type": log.type, + "area": log.area, + "duration": log.duration / 60, + } + return {} -class LastErrorSensor(DeebotEntity, SensorEntity): # type: ignore - """Last error sensor.""" - _always_available = True - entity_description = SensorEntityDescription( +ENTITY_DESCRIPTIONS: tuple[DeebotSensorEntityDescription, ...] = ( + # Stats + DeebotSensorEntityDescription[StatsEvent]( + key="stats_area", + capability_fn=lambda caps: caps.stats.clean, + value_fn=lambda e: e.area, + translation_key="stats_area", + icon="mdi:floor-plan", + native_unit_of_measurement=AREA_SQUARE_METERS, + entity_registry_enabled_default=False, + ), + DeebotSensorEntityDescription[StatsEvent]( + key="stats_time", + capability_fn=lambda caps: caps.stats.clean, + value_fn=lambda e: round(e.time / 60) if e.time else None, + translation_key="stats_time", + icon="mdi:timer-outline", + native_unit_of_measurement=TIME_MINUTES, + entity_registry_enabled_default=False, + ), + DeebotSensorEntityDescription[StatsEvent]( + capability_fn=lambda caps: caps.stats.clean, + value_fn=lambda e: e.type, + key="stats_type", + translation_key="stats_type", + icon="mdi:cog", + entity_registry_enabled_default=False, + ), + # TotalStats + DeebotSensorEntityDescription[TotalStatsEvent]( + capability_fn=lambda caps: caps.stats.total, + value_fn=lambda e: e.area, + key="stats_total_area", + translation_key="stats_total_area", + icon="mdi:floor-plan", + native_unit_of_measurement=AREA_SQUARE_METERS, + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DeebotSensorEntityDescription[TotalStatsEvent]( + capability_fn=lambda caps: caps.stats.total, + value_fn=lambda e: round(e.time / 3600), + key="stats_total_time", + translation_key="stats_total_time", + icon="mdi:timer-outline", + native_unit_of_measurement=TIME_HOURS, + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DeebotSensorEntityDescription[TotalStatsEvent]( + capability_fn=lambda caps: caps.stats.total, + value_fn=lambda e: e.cleanings, + key="stats_total_cleanings", + translation_key="stats_total_cleanings", + icon="mdi:counter", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DeebotSensorEntityDescription[BatteryEvent]( + capability_fn=lambda caps: caps.battery, + value_fn=lambda e: e.value, + key=ATTR_BATTERY_LEVEL, + translation_key=ATTR_BATTERY_LEVEL, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DeebotSensorEntityDescription[ErrorEvent]( + capability_fn=lambda caps: caps.error, + value_fn=lambda e: e.code, + extra_state_attributes_fn=lambda e: {CONF_DESCRIPTION: e.description}, + always_available=True, key=LAST_ERROR, translation_key=LAST_ERROR, icon="mdi:alert-circle", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, - ) + ), + DeebotSensorEntityDescription[CleanLogEvent]( + capability_fn=lambda caps: caps.clean.log, + value_fn=_clean_log_event_value, + extra_state_attributes_fn=_clean_log_event_attributes, + always_available=True, + key="last_cleaning", + translation_key="last_cleaning", + icon="mdi:history", + entity_registry_enabled_default=False, + ), +) - async def async_added_to_hass(self) -> None: - """Set up the event listeners now that hass is ready.""" - await super().async_added_to_hass() - async def on_event(event: ErrorEvent) -> None: - self._attr_native_value = event.code - self._attr_extra_state_attributes = {CONF_DESCRIPTION: event.description} - self.async_write_ha_state() +@dataclass +class DeebotLifeSpanSensorMixin: + """Deebot life span sensor mixin.""" - self.async_on_remove(self._vacuum_bot.events.subscribe(ErrorEvent, on_event)) + component: LifeSpan -class LifeSpanSensor(DeebotEntity, SensorEntity): # type: ignore - """Life span sensor.""" +@dataclass +class DeebotLifeSpanSensorEntityDescription( + SensorEntityDescription, DeebotLifeSpanSensorMixin # type: ignore +): + """Class describing Deebot sensor entity.""" + + +LIFE_SPAN_DESCRIPTIONS: tuple[DeebotLifeSpanSensorEntityDescription, ...] = ( + DeebotLifeSpanSensorEntityDescription( + component=LifeSpan.BRUSH, + key="life_span_brush", + translation_key="life_span_brush", + icon="mdi:broom", + entity_registry_enabled_default=False, + native_unit_of_measurement="%", + entity_category=EntityCategory.DIAGNOSTIC, + ), + DeebotLifeSpanSensorEntityDescription( + component=LifeSpan.FILTER, + key="life_span_filter", + translation_key="life_span_filter", + icon="mdi:air-filter", + entity_registry_enabled_default=False, + native_unit_of_measurement="%", + entity_category=EntityCategory.DIAGNOSTIC, + ), + DeebotLifeSpanSensorEntityDescription( + component=LifeSpan.SIDE_BRUSH, + key="life_span_side_brush", + translation_key="life_span_side_brush", + icon="mdi:broom", + entity_registry_enabled_default=False, + native_unit_of_measurement="%", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) - def __init__(self, vacuum_bot: VacuumBot, component: LifeSpan): - """Initialize the Sensor.""" - key = f"life_span_{component.name.lower()}" - entity_description = SensorEntityDescription( - key=key, - translation_key=key, - icon="mdi:air-filter" if component == LifeSpan.FILTER else "mdi:broom", - entity_registry_enabled_default=False, - native_unit_of_measurement="%", - entity_category=EntityCategory.DIAGNOSTIC, - ) - super().__init__(vacuum_bot, entity_description) - self._component = component + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: DeebotController = hass.data[DOMAIN][config_entry.entry_id] + controller.register_platform_add_entities( + DeebotSensor, ENTITY_DESCRIPTIONS, async_add_entities + ) + + def life_span_entity_generator( + device: VacuumBot, + ) -> Sequence[LifeSpanSensor]: + new_entities = [] + capability = device.capabilities.life_span + for description in LIFE_SPAN_DESCRIPTIONS: + if description.component in capability.types: + new_entities.append(LifeSpanSensor(device, capability, description)) + return new_entities + + controller.register_platform_add_entities_generator( + async_add_entities, life_span_entity_generator + ) + + +class DeebotSensor( + DeebotEntity[CapabilityEvent, DeebotSensorEntityDescription], + SensorEntity, # type: ignore +): + """Deebot sensor.""" async def async_added_to_hass(self) -> None: """Set up the event listeners now that hass is ready.""" await super().async_added_to_hass() - async def on_event(event: LifeSpanEvent) -> None: - if event.type == self._component: - self._attr_native_value = event.percent - self._attr_extra_state_attributes = { - "remaining": floor(event.remaining / 60) - } - self.async_write_ha_state() + async def on_event(event: Event) -> None: + value = self.entity_description.value_fn(event) + if value is None: + return - self.async_on_remove(self._vacuum_bot.events.subscribe(LifeSpanEvent, on_event)) + self._attr_native_value = value + if attr_fn := self.entity_description.extra_state_attributes_fn: + self._attr_extra_state_attributes = attr_fn(event) + self.async_write_ha_state() + self.async_on_remove( + self._vacuum_bot.events.subscribe(self._capability.event, on_event) + ) -class LastCleaningJobSensor(DeebotEntity, SensorEntity): # type: ignore - """Last cleaning job sensor.""" - _always_available = True - entity_description = SensorEntityDescription( - key="last_cleaning", - translation_key="last_cleaning", - icon="mdi:history", - entity_registry_enabled_default=False, - ) +T = TypeVar("T", bound=Event) + + +class LifeSpanSensor( + DeebotEntity[CapabilityLifeSpan, DeebotLifeSpanSensorEntityDescription], + SensorEntity, # type: ignore +): + """Life span sensor.""" async def async_added_to_hass(self) -> None: """Set up the event listeners now that hass is ready.""" await super().async_added_to_hass() - async def on_event(event: CleanLogEvent) -> None: - if event.logs: - log = event.logs[0] - self._attr_native_value = log.stop_reason.display_name + async def on_event(event: LifeSpanEvent) -> None: + if event.type == self.entity_description.component: + self._attr_native_value = event.percent self._attr_extra_state_attributes = { - "timestamp": log.timestamp, - "image_url": log.image_url, - "type": log.type, - "area": log.area, - "duration": log.duration / 60, + "remaining": floor(event.remaining / 60) } self.async_write_ha_state() - self.async_on_remove(self._vacuum_bot.events.subscribe(CleanLogEvent, on_event)) + self.async_on_remove( + self._vacuum_bot.events.subscribe(self._capability.event, on_event) + ) diff --git a/custom_components/deebot/switch.py b/custom_components/deebot/switch.py index e54f863..3d49f42 100644 --- a/custom_components/deebot/switch.py +++ b/custom_components/deebot/switch.py @@ -1,35 +1,70 @@ """Switch module.""" -import logging +from dataclasses import dataclass from typing import Any -from deebot_client.commands import ( - SetAdvancedMode, - SetCarpetAutoFanBoost, - SetCleanPreference, - SetContinuousCleaning, - SetTrueDetect, -) -from deebot_client.commands.common import SetEnableCommand -from deebot_client.events import ( - AdvancedModeEvent, - CarpetAutoFanBoostEvent, - CleanPreferenceEvent, - ContinuousCleaningEvent, - EnableEvent, - TrueDetectEvent, -) -from deebot_client.vacuum_bot import VacuumBot +from deebot_client.capabilities import CapabilitySetEnable +from deebot_client.events import EnableEvent from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityCategory, EntityDescription +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .controller import DeebotController -from .entity import DeebotEntity - -_LOGGER = logging.getLogger(__name__) +from .entity import DeebotEntity, DeebotEntityDescription + + +@dataclass +class DeebotSwitchEntityDescription( + SwitchEntityDescription, # type: ignore + DeebotEntityDescription, +): + """Deebot switch entity description.""" + + +ENTITY_DESCRIPTIONS: tuple[DeebotSwitchEntityDescription, ...] = ( + DeebotSwitchEntityDescription( + capability_fn=lambda c: c.settings.advanced_mode, + key="advanced_mode", + translation_key="advanced_mode", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + icon="mdi:tune", + ), + DeebotSwitchEntityDescription( + capability_fn=lambda c: c.clean.continuous, + key="continuous_cleaning", + translation_key="continuous_cleaning", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + icon="mdi:refresh-auto", + ), + DeebotSwitchEntityDescription( + capability_fn=lambda c: c.settings.carpet_auto_fan_boost, + key="carpet_auto_fan_speed_boost", + translation_key="carpet_auto_fan_speed_boost", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + icon="mdi:fan-auto", + ), + DeebotSwitchEntityDescription( + capability_fn=lambda c: c.clean.preference, + key="clean_preference", + translation_key="clean_preference", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + icon="mdi:broom", + ), + DeebotSwitchEntityDescription( + capability_fn=lambda c: c.settings.true_detect, + key="true_detect", + translation_key="true_detect", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + icon="mdi:laser-pointer", + ), +) async def async_setup_entry( @@ -39,94 +74,19 @@ async def async_setup_entry( ) -> None: """Add entities for passed config_entry in HA.""" controller: DeebotController = hass.data[DOMAIN][config_entry.entry_id] + controller.register_platform_add_entities( + DeebotSwitchEntity, ENTITY_DESCRIPTIONS, async_add_entities + ) - new_devices = [] - for vacbot in controller.vacuum_bots: - new_devices.extend( - [ - DeebotSwitchEntity( - vacbot, - SwitchEntityDescription( - key="advanced_mode", - translation_key="advanced_mode", - entity_registry_enabled_default=False, - entity_category=EntityCategory.CONFIG, - icon="mdi:tune", - ), - AdvancedModeEvent, - SetAdvancedMode, - ), - DeebotSwitchEntity( - vacbot, - SwitchEntityDescription( - key="continuous_cleaning", - translation_key="continuous_cleaning", - entity_registry_enabled_default=False, - entity_category=EntityCategory.CONFIG, - icon="mdi:refresh-auto", - ), - ContinuousCleaningEvent, - SetContinuousCleaning, - ), - DeebotSwitchEntity( - vacbot, - SwitchEntityDescription( - key="carpet_auto_fan_speed_boost", - translation_key="carpet_auto_fan_speed_boost", - entity_registry_enabled_default=False, - entity_category=EntityCategory.CONFIG, - icon="mdi:fan-auto", - ), - CarpetAutoFanBoostEvent, - SetCarpetAutoFanBoost, - ), - DeebotSwitchEntity( - vacbot, - SwitchEntityDescription( - key="clean_preference", - translation_key="clean_preference", - entity_registry_enabled_default=False, - entity_category=EntityCategory.CONFIG, - icon="mdi:broom", - ), - CleanPreferenceEvent, - SetCleanPreference, - ), - DeebotSwitchEntity( - vacbot, - SwitchEntityDescription( - key="true_detect", - translation_key="true_detect", - entity_registry_enabled_default=False, - entity_category=EntityCategory.CONFIG, - icon="mdi:laser-pointer", - ), - TrueDetectEvent, - SetTrueDetect, - ), - ] - ) - - if new_devices: - async_add_entities(new_devices) - -class DeebotSwitchEntity(DeebotEntity, SwitchEntity): # type: ignore +class DeebotSwitchEntity( + DeebotEntity[CapabilitySetEnable, DeebotSwitchEntityDescription], + SwitchEntity, # type: ignore +): """Deebot switch entity.""" _attr_is_on = False - def __init__( - self, - vacuum_bot: VacuumBot, - entity_description: EntityDescription, - event_type: type[EnableEvent], - set_command: type[SetEnableCommand], - ): - super().__init__(vacuum_bot, entity_description) - self._event_type = event_type - self._set_command = set_command - async def async_added_to_hass(self) -> None: """Set up the event listeners now that hass is ready.""" await super().async_added_to_hass() @@ -136,13 +96,13 @@ async def on_enable(event: EnableEvent) -> None: self.async_write_ha_state() self.async_on_remove( - self._vacuum_bot.events.subscribe(self._event_type, on_enable) + self._vacuum_bot.events.subscribe(self._capability.event, on_enable) ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self._vacuum_bot.execute_command(self._set_command(True)) + await self._vacuum_bot.execute_command(self._capability.set(True)) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self._vacuum_bot.execute_command(self._set_command(False)) + await self._vacuum_bot.execute_command(self._capability.set(False)) diff --git a/custom_components/deebot/vacuum.py b/custom_components/deebot/vacuum.py index 04cabc0..b9b02d2 100644 --- a/custom_components/deebot/vacuum.py +++ b/custom_components/deebot/vacuum.py @@ -1,20 +1,10 @@ """Support for Deebot Vacuums.""" import logging -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import Any import voluptuous as vol -from deebot_client.commands import ( - Charge, - Clean, - FanSpeedLevel, - PlaySound, - SetFanSpeed, - SetRelocationState, - SetWaterInfo, -) -from deebot_client.commands.clean import CleanAction, CleanArea, CleanMode -from deebot_client.commands.custom import CustomCommand +from deebot_client.capabilities import Capabilities from deebot_client.events import ( BatteryEvent, CustomCommandEvent, @@ -24,7 +14,7 @@ RoomsEvent, StateEvent, ) -from deebot_client.models import Room +from deebot_client.models import CleanAction, CleanMode, Room from deebot_client.vacuum_bot import VacuumBot from homeassistant.components.vacuum import ( StateVacuumEntity, @@ -74,12 +64,14 @@ async def async_setup_entry( """Add entities for passed config_entry in HA.""" controller: DeebotController = hass.data[DOMAIN][config_entry.entry_id] - new_devices = [] - for vacbot in controller.vacuum_bots: - new_devices.append(DeebotVacuum(vacbot)) + def vacuum_entity_generator( + device: VacuumBot, + ) -> Sequence[DeebotVacuum]: + return [DeebotVacuum(device)] - if new_devices: - async_add_entities(new_devices) + controller.register_platform_add_entities_generator( + async_add_entities, vacuum_entity_generator + ) platform = entity_platform.async_get_current_platform() @@ -90,7 +82,10 @@ async def async_setup_entry( ) -class DeebotVacuum(DeebotEntity, StateVacuumEntity): # type: ignore +class DeebotVacuum( + DeebotEntity[Capabilities, StateVacuumEntityDescription], + StateVacuumEntity, # type: ignore +): """Deebot Vacuum.""" _attr_supported_features = ( @@ -104,18 +99,23 @@ class DeebotVacuum(DeebotEntity, StateVacuumEntity): # type: ignore | VacuumEntityFeature.STATE | VacuumEntityFeature.START ) - _attr_fan_speed_list = [level.display_name for level in FanSpeedLevel] - def __init__(self, vacuum_bot: VacuumBot): + def __init__(self, device: VacuumBot): """Initialize the Deebot Vacuum.""" + capabilities = device.capabilities super().__init__( - vacuum_bot, + device, + capabilities, StateVacuumEntityDescription(key="", translation_key="bot", name=None), ) self._rooms: list[Room] = [] self._last_error: ErrorEvent | None = None + self._attr_fan_speed_list = [ + level.display_name for level in capabilities.fan_speed.types + ] + async def async_added_to_hass(self) -> None: """Set up the event listeners now that hass is ready.""" await super().async_added_to_hass() @@ -127,10 +127,6 @@ async def on_battery(event: BatteryEvent) -> None: async def on_custom_command(event: CustomCommandEvent) -> None: self.hass.bus.fire(EVENT_CUSTOM_COMMAND, dataclass_to_dict(event)) - async def on_error(event: ErrorEvent) -> None: - self._last_error = event - self.async_write_ha_state() - async def on_fan_speed(event: FanSpeedEvent) -> None: self._attr_fan_speed = event.speed.display_name self.async_write_ha_state() @@ -147,15 +143,27 @@ async def on_status(event: StateEvent) -> None: self.async_write_ha_state() subscriptions = [ - self._vacuum_bot.events.subscribe(BatteryEvent, on_battery), - self._vacuum_bot.events.subscribe(CustomCommandEvent, on_custom_command), - self._vacuum_bot.events.subscribe(ErrorEvent, on_error), - self._vacuum_bot.events.subscribe(FanSpeedEvent, on_fan_speed), - self._vacuum_bot.events.subscribe(ReportStatsEvent, on_report_stats), - self._vacuum_bot.events.subscribe(RoomsEvent, on_rooms), - self._vacuum_bot.events.subscribe(StateEvent, on_status), + self._vacuum_bot.events.subscribe( + self._capability.battery.event, on_battery + ), + self._vacuum_bot.events.subscribe( + self._capability.fan_speed.event, on_fan_speed + ), + self._vacuum_bot.events.subscribe( + self._capability.stats.report.event, on_report_stats + ), + self._vacuum_bot.events.subscribe(self._capability.state.event, on_status), ] + if custom := self._capability.custom: + subscriptions.append( + self._vacuum_bot.events.subscribe(custom.event, on_custom_command) + ) + if map_caps := self._capability.map: + subscriptions.append( + self._vacuum_bot.events.subscribe(map_caps.rooms.event, on_rooms) + ) + def unsubscribe() -> None: for sub in subscriptions: sub() @@ -195,27 +203,34 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" - await self._vacuum_bot.execute_command(SetFanSpeed(fan_speed)) + await self._vacuum_bot.execute_command( + self._capability.fan_speed.set(fan_speed) + ) async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" - await self._vacuum_bot.execute_command(Charge()) + await self._vacuum_bot.execute_command(self._capability.charge.execute()) async def async_stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" - await self._vacuum_bot.execute_command(Clean(CleanAction.STOP)) + await self._clean_command(CleanAction.STOP) async def async_pause(self) -> None: """Pause the vacuum cleaner.""" - await self._vacuum_bot.execute_command(Clean(CleanAction.PAUSE)) + await self._clean_command(CleanAction.PAUSE) async def async_start(self) -> None: """Start the vacuum cleaner.""" - await self._vacuum_bot.execute_command(Clean(CleanAction.START)) + await self._clean_command(CleanAction.START) + + async def _clean_command(self, action: CleanAction) -> None: + await self._vacuum_bot.execute_command( + self._capability.clean.action.command(action) + ) async def async_locate(self, **kwargs: Any) -> None: """Locate the vacuum cleaner.""" - await self._vacuum_bot.execute_command(PlaySound()) + await self._vacuum_bot.execute_command(self._capability.play_sound.execute()) async def async_send_command( self, command: str, params: dict[str, Any] | None = None, **kwargs: Any @@ -223,39 +238,30 @@ async def async_send_command( """Send a command to a vacuum cleaner.""" _LOGGER.debug("async_send_command %s with %s", command, params) - if command in ["relocate", SetRelocationState.name]: - _LOGGER.warning("DEPRECATED! Please use relocate button entity instead.") - await self._vacuum_bot.execute_command(SetRelocationState()) - elif command == "auto_clean": - clean_type = params.get("type", "auto") if params else "auto" - if clean_type == "auto": - _LOGGER.warning('DEPRECATED! Please use "vacuum.start" instead.') - await self.async_start() - elif command in ["spot_area", "custom_area", "set_water"]: + if command in ["spot_area", "custom_area"]: if params is None: raise RuntimeError("Params are required!") if command in "spot_area": await self._vacuum_bot.execute_command( - CleanArea( - mode=CleanMode.SPOT_AREA, - area=str(params["rooms"]), - cleanings=params.get("cleanings", 1), + self._capability.clean.action.area( + CleanMode.SPOT_AREA, + str(params["rooms"]), + params.get("cleanings", 1), ) ) elif command == "custom_area": await self._vacuum_bot.execute_command( - CleanArea( - mode=CleanMode.CUSTOM_AREA, - area=str(params["coordinates"]), - cleanings=params.get("cleanings", 1), + self._capability.clean.action.area( + CleanMode.CUSTOM_AREA, + str(params["coordinates"]), + params.get("cleanings", 1), ) ) - elif command == "set_water": - _LOGGER.warning("DEPRECATED! Please use water select entity instead.") - await self._vacuum_bot.execute_command(SetWaterInfo(params["amount"])) else: - await self._vacuum_bot.execute_command(CustomCommand(command, params)) + await self._vacuum_bot.execute_command( + self._capability.custom.set(command, params) + ) async def service_refresh(self, category: str) -> None: """Service to manually refresh.""" diff --git a/requirements.txt b/requirements.txt index 1446c81..5ff21e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # if code from specific branch is needed -#git+https://github.com/DeebotUniverse/client.py@main#deebot-client==2.1.0b0 -deebot-client==3.0.2 +git+https://github.com/DeebotUniverse/client.py@dev#deebot-client==4.0.0b0 +#deebot-client==3.0.2 homeassistant>=2023.8.0b0 mypy==1.6.1