From 553d8968997f3b44cda651109e1dc3c0b9413f80 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 6 Oct 2025 14:42:01 +0200 Subject: [PATCH] Add Ecovacs active map select entity (#153748) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/ecovacs/icons.json | 3 + homeassistant/components/ecovacs/select.py | 90 ++++++- homeassistant/components/ecovacs/strings.json | 3 + .../ecovacs/snapshots/test_select.ambr | 234 +++++++++++++++++- tests/components/ecovacs/test_init.py | 2 +- tests/components/ecovacs/test_select.py | 19 ++ 6 files changed, 345 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index b0e2a0595bf..09f6fcfcca2 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -116,6 +116,9 @@ } }, "select": { + "active_map": { + "default": "mdi:floor-plan" + }, "water_amount": { "default": "mdi:water" }, diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py index 440141bbcee..d3b5ca34022 100644 --- a/homeassistant/components/ecovacs/select.py +++ b/homeassistant/components/ecovacs/select.py @@ -2,12 +2,13 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any -from deebot_client.capabilities import CapabilitySetTypes +from deebot_client.capabilities import CapabilityMap, CapabilitySet, CapabilitySetTypes from deebot_client.device import Device from deebot_client.events import WorkModeEvent from deebot_client.events.base import Event +from deebot_client.events.map import CachedMapInfoEvent, MajorMapEvent from deebot_client.events.water_info import WaterAmountEvent from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -16,7 +17,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry -from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity +from .entity import ( + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EcovacsEntity, +) from .util import get_name_key, get_supported_entities @@ -66,6 +71,12 @@ async def async_setup_entry( entities = get_supported_entities( controller, EcovacsSelectEntity, ENTITY_DESCRIPTIONS ) + entities.extend( + EcovacsActiveMapSelectEntity(device, device.capabilities.map) + for device in controller.devices + if (map_cap := device.capabilities.map) + and isinstance(map_cap.major, CapabilitySet) + ) if entities: async_add_entities(entities) @@ -103,3 +114,76 @@ class EcovacsSelectEntity[EventT: Event]( async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self._device.execute_command(self._capability.set(option)) + + +class EcovacsActiveMapSelectEntity( + EcovacsEntity[CapabilityMap], + SelectEntity, +): + """Ecovacs active map select entity.""" + + entity_description = SelectEntityDescription( + key="active_map", + translation_key="active_map", + entity_category=EntityCategory.CONFIG, + ) + + def __init__( + self, + device: Device, + capability: CapabilityMap, + **kwargs: Any, + ) -> None: + """Initialize entity.""" + super().__init__(device, capability, **kwargs) + self._option_to_id: dict[str, str] = {} + self._id_to_option: dict[str, str] = {} + + self._handle_on_cached_map( + device.events.get_last_event(CachedMapInfoEvent) + or CachedMapInfoEvent(set()) + ) + + def _handle_on_cached_map(self, event: CachedMapInfoEvent) -> None: + self._id_to_option.clear() + self._option_to_id.clear() + + for map_info in event.maps: + name = map_info.name if map_info.name else map_info.id + self._id_to_option[map_info.id] = name + self._option_to_id[name] = map_info.id + + if map_info.using: + self._attr_current_option = name + + if self._attr_current_option not in self._option_to_id: + self._attr_current_option = None + + # Sort named maps first, then numeric IDs (unnamed maps during building) in ascending order. + self._attr_options = sorted( + self._option_to_id.keys(), key=lambda x: (x.isdigit(), x.lower()) + ) + + 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_cached_map(event: CachedMapInfoEvent) -> None: + self._handle_on_cached_map(event) + self.async_write_ha_state() + + self._subscribe(self._capability.cached_info.event, on_cached_map) + + async def on_major_map(event: MajorMapEvent) -> None: + self._attr_current_option = self._id_to_option.get(event.map_id) + self.async_write_ha_state() + + self._subscribe(self._capability.major.event, on_major_map) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + if TYPE_CHECKING: + assert isinstance(self._capability.major, CapabilitySet) + await self._device.execute_command( + self._capability.major.set(self._option_to_id[option]) + ) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index e69da61799f..106acf8c8bb 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -178,6 +178,9 @@ } }, "select": { + "active_map": { + "name": "Active map" + }, "water_amount": { "name": "[%key:component::ecovacs::entity::number::water_amount::name%]", "state": { diff --git a/tests/components/ecovacs/snapshots/test_select.ambr b/tests/components/ecovacs/snapshots/test_select.ambr index f8e269593d9..be03e609812 100644 --- a/tests/components/ecovacs/snapshots/test_select.ambr +++ b/tests/components/ecovacs/snapshots/test_select.ambr @@ -1,5 +1,62 @@ # serializer version: 1 -# name: test_selects[n0vyif-entity_ids1][select.x8_pro_omni_work_mode:entity-registry] +# name: test_selects[n0vyif-entity_ids2][select.x8_pro_omni_active_map:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Map 2', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.x8_pro_omni_active_map', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Active map', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_map', + 'unique_id': 'E1234567890000000009_active_map', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[n0vyif-entity_ids2][select.x8_pro_omni_active_map:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'X8 PRO OMNI Active map', + 'options': list([ + 'Map 2', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.x8_pro_omni_active_map', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Map 2', + }) +# --- +# name: test_selects[n0vyif-entity_ids2][select.x8_pro_omni_work_mode:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -41,7 +98,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_selects[n0vyif-entity_ids1][select.x8_pro_omni_work_mode:state] +# name: test_selects[n0vyif-entity_ids2][select.x8_pro_omni_work_mode:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'X8 PRO OMNI Work mode', @@ -60,6 +117,179 @@ 'state': 'vacuum', }) # --- +# name: test_selects[qhe2o2-entity_ids1][select.dusty_active_map:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Map 2', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.dusty_active_map', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Active map', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_map', + 'unique_id': '8516fbb1-17f1-4194-0000001_active_map', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[qhe2o2-entity_ids1][select.dusty_active_map:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Active map', + 'options': list([ + 'Map 2', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.dusty_active_map', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Map 2', + }) +# --- +# name: test_selects[qhe2o2-entity_ids1][select.dusty_water_flow_level:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.dusty_water_flow_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water flow level', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_amount', + 'unique_id': '8516fbb1-17f1-4194-0000001_water_amount', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[qhe2o2-entity_ids1][select.dusty_water_flow_level:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Water flow level', + 'options': list([ + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.dusty_water_flow_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_selects[yna5x1-entity_ids0][select.ozmo_950_active_map:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Map 2', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.ozmo_950_active_map', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Active map', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_map', + 'unique_id': 'E1234567890000000001_active_map', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[yna5x1-entity_ids0][select.ozmo_950_active_map:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Active map', + 'options': list([ + 'Map 2', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.ozmo_950_active_map', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Map 2', + }) +# --- # name: test_selects[yna5x1-entity_ids0][select.ozmo_950_water_flow_level:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 5965398bd0c..3f3af62f22b 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -105,7 +105,7 @@ async def test_devices_in_dr( @pytest.mark.parametrize( ("device_fixture", "entities"), [ - ("yna5x1", 26), + ("yna5x1", 27), ("5xu9h3", 25), ("123", 3), ], diff --git a/tests/components/ecovacs/test_select.py b/tests/components/ecovacs/test_select.py index 538ab66bed0..f840e3dfc10 100644 --- a/tests/components/ecovacs/test_select.py +++ b/tests/components/ecovacs/test_select.py @@ -3,6 +3,7 @@ from deebot_client.command import Command from deebot_client.commands.json import SetWaterInfo from deebot_client.event_bus import EventBus +from deebot_client.events.map import CachedMapInfoEvent, MajorMapEvent, Map from deebot_client.events.water_info import WaterAmount, WaterAmountEvent from deebot_client.events.work_mode import WorkMode, WorkModeEvent import pytest @@ -36,6 +37,15 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): """Notify events.""" event_bus.notify(WaterAmountEvent(WaterAmount.ULTRAHIGH)) event_bus.notify(WorkModeEvent(WorkMode.VACUUM)) + event_bus.notify( + CachedMapInfoEvent( + { + Map(id="1", name="", using=False, built=False), + Map(id="2", name="Map 2", using=True, built=True), + } + ) + ) + event_bus.notify(MajorMapEvent("2", [], requested=False)) await block_till_done(hass, event_bus) @@ -47,12 +57,21 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): "yna5x1", [ "select.ozmo_950_water_flow_level", + "select.ozmo_950_active_map", + ], + ), + ( + "qhe2o2", + [ + "select.dusty_water_flow_level", + "select.dusty_active_map", ], ), ( "n0vyif", [ "select.x8_pro_omni_work_mode", + "select.x8_pro_omni_active_map", ], ), ],