From f704a1a05a81c47d57ece1a92f7ef96c86f9d53a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 17 Jan 2024 23:19:49 +0100 Subject: [PATCH] Remove legacy VacuumEntity base class support (#108189) --- homeassistant/components/vacuum/__init__.py | 320 ++++--------------- homeassistant/components/vacuum/strings.json | 10 - tests/components/vacuum/test_init.py | 166 ++-------- 3 files changed, 101 insertions(+), 395 deletions(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index f15d59e9455..1bd9719c51c 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -1,13 +1,12 @@ """Support for vacuum cleaner robots (botvacs).""" from __future__ import annotations -import asyncio from collections.abc import Mapping from datetime import timedelta from enum import IntFlag from functools import partial import logging -from typing import TYPE_CHECKING, Any, final +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -22,28 +21,18 @@ from homeassistant.const import ( # noqa: F401 # STATE_PAUSED/IDLE are API STATE_ON, STATE_PAUSED, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) -from homeassistant.helpers.entity import ( - Entity, - EntityDescription, - ToggleEntity, - ToggleEntityDescription, -) +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import ( - async_get_issue_tracker, - async_suggest_report_issue, - bind_hass, -) +from homeassistant.loader import bind_hass if TYPE_CHECKING: from functools import cached_property @@ -131,38 +120,12 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the vacuum component.""" - component = hass.data[DOMAIN] = EntityComponent[_BaseVacuum]( + component = hass.data[DOMAIN] = EntityComponent[StateVacuumEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_TURN_ON, - {}, - "async_turn_on", - [VacuumEntityFeature.TURN_ON], - ) - component.async_register_entity_service( - SERVICE_TURN_OFF, - {}, - "async_turn_off", - [VacuumEntityFeature.TURN_OFF], - ) - component.async_register_entity_service( - SERVICE_TOGGLE, - {}, - "async_toggle", - [VacuumEntityFeature.TURN_OFF | VacuumEntityFeature.TURN_ON], - ) - # start_pause is a legacy service, only supported by VacuumEntity, and only needs - # VacuumEntityFeature.PAUSE - component.async_register_entity_service( - SERVICE_START_PAUSE, - {}, - "async_start_pause", - [VacuumEntityFeature.PAUSE], - ) component.async_register_entity_service( SERVICE_START, {}, @@ -220,30 +183,36 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[_BaseVacuum] = hass.data[DOMAIN] + component: EntityComponent[StateVacuumEntity] = hass.data[DOMAIN] return await component.async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[_BaseVacuum] = hass.data[DOMAIN] + component: EntityComponent[StateVacuumEntity] = hass.data[DOMAIN] return await component.async_unload_entry(entry) -BASE_CACHED_PROPERTIES_WITH_ATTR_ = { +class StateVacuumEntityDescription(EntityDescription, frozen_or_thawed=True): + """A class that describes vacuum entities.""" + + +STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = { "supported_features", "battery_level", "battery_icon", "fan_speed", "fan_speed_list", + "state", } -class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): - """Representation of a base vacuum. +class StateVacuumEntity( + Entity, cached_properties=STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ +): + """Representation of a vacuum cleaner robot that supports states.""" - Contains common properties and functions for all vacuum devices. - """ + entity_description: StateVacuumEntityDescription _entity_component_unrecorded_attributes = frozenset({ATTR_FAN_SPEED_LIST}) @@ -251,8 +220,60 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): _attr_battery_level: int | None = None _attr_fan_speed: str | None = None _attr_fan_speed_list: list[str] + _attr_state: str | None = None _attr_supported_features: VacuumEntityFeature = VacuumEntityFeature(0) + @cached_property + def battery_level(self) -> int | None: + """Return the battery level of the vacuum cleaner.""" + return self._attr_battery_level + + @property + def battery_icon(self) -> str: + """Return the battery icon for the vacuum cleaner.""" + charging = bool(self.state == STATE_DOCKED) + + return icon_for_battery_level( + battery_level=self.battery_level, charging=charging + ) + + @property + def capability_attributes(self) -> Mapping[str, Any] | None: + """Return capability attributes.""" + if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: + return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} + return None + + @cached_property + def fan_speed(self) -> str | None: + """Return the fan speed of the vacuum cleaner.""" + return self._attr_fan_speed + + @cached_property + def fan_speed_list(self) -> list[str]: + """Get the list of available fan speed steps of the vacuum cleaner.""" + return self._attr_fan_speed_list + + @property + def state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the vacuum cleaner.""" + data: dict[str, Any] = {} + supported_features = self.supported_features_compat + + if VacuumEntityFeature.BATTERY in supported_features: + data[ATTR_BATTERY_LEVEL] = self.battery_level + data[ATTR_BATTERY_ICON] = self.battery_icon + + if VacuumEntityFeature.FAN_SPEED in supported_features: + data[ATTR_FAN_SPEED] = self.fan_speed + + return data + + @cached_property + def state(self) -> str | None: + """Return the state of the vacuum cleaner.""" + return self._attr_state + @cached_property def supported_features(self) -> VacuumEntityFeature: """Flag vacuum cleaner features that are supported.""" @@ -271,48 +292,6 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): return new_features return features - @cached_property - def battery_level(self) -> int | None: - """Return the battery level of the vacuum cleaner.""" - return self._attr_battery_level - - @cached_property - def battery_icon(self) -> str: - """Return the battery icon for the vacuum cleaner.""" - return self._attr_battery_icon - - @cached_property - def fan_speed(self) -> str | None: - """Return the fan speed of the vacuum cleaner.""" - return self._attr_fan_speed - - @cached_property - def fan_speed_list(self) -> list[str]: - """Get the list of available fan speed steps of the vacuum cleaner.""" - return self._attr_fan_speed_list - - @property - def capability_attributes(self) -> Mapping[str, Any] | None: - """Return capability attributes.""" - if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: - return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} - return None - - @property - def state_attributes(self) -> dict[str, Any]: - """Return the state attributes of the vacuum cleaner.""" - data: dict[str, Any] = {} - supported_features = self.supported_features_compat - - if VacuumEntityFeature.BATTERY in supported_features: - data[ATTR_BATTERY_LEVEL] = self.battery_level - data[ATTR_BATTERY_ICON] = self.battery_icon - - if VacuumEntityFeature.FAN_SPEED in supported_features: - data[ATTR_FAN_SPEED] = self.fan_speed - - return data - def stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" raise NotImplementedError() @@ -393,163 +372,6 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): partial(self.send_command, command, params=params, **kwargs) ) - -class VacuumEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): - """A class that describes vacuum entities.""" - - -VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = { - "status", -} - - -class VacuumEntity( - _BaseVacuum, ToggleEntity, cached_properties=VACUUM_CACHED_PROPERTIES_WITH_ATTR_ -): - """Representation of a vacuum cleaner robot.""" - - @callback - def add_to_platform_start( - self, - hass: HomeAssistant, - platform: EntityPlatform, - parallel_updates: asyncio.Semaphore | None, - ) -> None: - """Start adding an entity to a platform.""" - super().add_to_platform_start(hass, platform, parallel_updates) - translation_key = "deprecated_vacuum_base_class" - translation_placeholders = {"platform": self.platform.platform_name} - issue_tracker = async_get_issue_tracker( - hass, - integration_domain=self.platform.platform_name, - module=type(self).__module__, - ) - if issue_tracker: - translation_placeholders["issue_tracker"] = issue_tracker - translation_key = "deprecated_vacuum_base_class_url" - ir.async_create_issue( - hass, - DOMAIN, - f"deprecated_vacuum_base_class_{self.platform.platform_name}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - is_persistent=False, - issue_domain=self.platform.platform_name, - severity=ir.IssueSeverity.WARNING, - translation_key=translation_key, - translation_placeholders=translation_placeholders, - ) - - report_issue = async_suggest_report_issue( - hass, - integration_domain=self.platform.platform_name, - module=type(self).__module__, - ) - _LOGGER.warning( - ( - "%s::%s is extending the deprecated base class VacuumEntity instead of " - "StateVacuumEntity, this is not valid and will be unsupported " - "from Home Assistant 2024.2. Please %s" - ), - self.platform.platform_name, - self.__class__.__name__, - report_issue, - ) - - entity_description: VacuumEntityDescription - _attr_status: str | None = None - - @cached_property - def status(self) -> str | None: - """Return the status of the vacuum cleaner.""" - return self._attr_status - - @property - def battery_icon(self) -> str: - """Return the battery icon for the vacuum cleaner.""" - charging = False - if self.status is not None: - charging = "charg" in self.status.lower() - return icon_for_battery_level( - battery_level=self.battery_level, charging=charging - ) - - @final - @property - def state_attributes(self) -> dict[str, Any]: - """Return the state attributes of the vacuum cleaner.""" - data = super().state_attributes - - if VacuumEntityFeature.STATUS in self.supported_features_compat: - data[ATTR_STATUS] = self.status - - return data - - def turn_on(self, **kwargs: Any) -> None: - """Turn the vacuum on and start cleaning.""" - raise NotImplementedError() - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the vacuum on and start cleaning. - - This method must be run in the event loop. - """ - await self.hass.async_add_executor_job(partial(self.turn_on, **kwargs)) - - def turn_off(self, **kwargs: Any) -> None: - """Turn the vacuum off stopping the cleaning and returning home.""" - raise NotImplementedError() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the vacuum off stopping the cleaning and returning home. - - This method must be run in the event loop. - """ - await self.hass.async_add_executor_job(partial(self.turn_off, **kwargs)) - - def start_pause(self, **kwargs: Any) -> None: - """Start, pause or resume the cleaning task.""" - raise NotImplementedError() - - async def async_start_pause(self, **kwargs: Any) -> None: - """Start, pause or resume the cleaning task. - - This method must be run in the event loop. - """ - await self.hass.async_add_executor_job(partial(self.start_pause, **kwargs)) - - -class StateVacuumEntityDescription(EntityDescription, frozen_or_thawed=True): - """A class that describes vacuum entities.""" - - -STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = { - "state", -} - - -class StateVacuumEntity( - _BaseVacuum, cached_properties=STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ -): - """Representation of a vacuum cleaner robot that supports states.""" - - entity_description: StateVacuumEntityDescription - _attr_state: str | None = None - - @cached_property - def state(self) -> str | None: - """Return the state of the vacuum cleaner.""" - return self._attr_state - - @property - def battery_icon(self) -> str: - """Return the battery icon for the vacuum cleaner.""" - charging = bool(self.state == STATE_DOCKED) - - return icon_for_battery_level( - battery_level=self.battery_level, charging=charging - ) - def start(self) -> None: """Start or resume the cleaning task.""" raise NotImplementedError() diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 15ba2076060..673c76b7f8d 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -29,16 +29,6 @@ } } }, - "issues": { - "deprecated_vacuum_base_class": { - "title": "The {platform} custom integration is using deprecated vacuum feature", - "description": "The custom integration `{platform}` is extending the deprecated base class `VacuumEntity` instead of `StateVacuumEntity`.\n\nPlease report it to the author of the `{platform}` custom integration.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." - }, - "deprecated_vacuum_base_class_url": { - "title": "[%key:component::vacuum::issues::deprecated_vacuum_base_class::title%]", - "description": "The custom integration `{platform}` is extending the deprecated base class `VacuumEntity` instead of `StateVacuumEntity`.\n\nPlease create a bug report at {issue_tracker}.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." - } - }, "services": { "turn_on": { "name": "[%key:common::action::turn_on%]", diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 0b44476989b..0da4470c762 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -1,147 +1,41 @@ """The tests for the Vacuum entity integration.""" from __future__ import annotations -from collections.abc import Generator - -import pytest - -from homeassistant.components.vacuum import ( - DOMAIN as VACUUM_DOMAIN, - VacuumEntity, - VacuumEntityFeature, -) -from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.components.vacuum import StateVacuumEntity, VacuumEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from tests.common import ( - MockConfigEntry, - MockModule, - MockPlatform, - mock_config_flow, - mock_integration, - mock_platform, -) - -TEST_DOMAIN = "test" -class MockFlow(ConfigFlow): - """Test flow.""" +async def test_supported_features_compat(hass: HomeAssistant) -> None: + """Test StateVacuumEntity using deprecated feature constants features.""" - -@pytest.fixture(autouse=True) -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: - """Mock config flow.""" - mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - - with mock_config_flow(TEST_DOMAIN, MockFlow): - yield - - -ISSUE_TRACKER = "https://blablabla.com" - - -@pytest.mark.parametrize( - ("manifest_extra", "translation_key", "translation_placeholders_extra"), - [ - ( - {}, - "deprecated_vacuum_base_class", - {}, - ), - ( - {"issue_tracker": ISSUE_TRACKER}, - "deprecated_vacuum_base_class_url", - {"issue_tracker": ISSUE_TRACKER}, - ), - ], -) -async def test_deprecated_base_class( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - manifest_extra: dict[str, str], - translation_key: str, - translation_placeholders_extra: dict[str, str], -) -> None: - """Test warnings when adding VacuumEntity to the state machine.""" - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup(config_entry, VACUUM_DOMAIN) - return True - - mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - mock_integration( - hass, - MockModule( - TEST_DOMAIN, - async_setup_entry=async_setup_entry_init, - partial_manifest=manifest_extra, - ), - built_in=False, + features = ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE ) - entity1 = VacuumEntity() - entity1.entity_id = "vacuum.test1" + class _LegacyConstantsStateVacuum(StateVacuumEntity): + _attr_supported_features = int(features) + _attr_fan_speed_list = ["silent", "normal", "pet hair"] - async def async_setup_entry_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test vacuum platform via config entry.""" - async_add_entities([entity1]) - - mock_platform( - hass, - f"{TEST_DOMAIN}.{VACUUM_DOMAIN}", - MockPlatform(async_setup_entry=async_setup_entry_platform), + entity = _LegacyConstantsStateVacuum() + assert isinstance(entity.supported_features, int) + assert entity.supported_features == int(features) + assert entity.supported_features_compat is ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE ) - - config_entry = MockConfigEntry(domain=TEST_DOMAIN) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert hass.states.get(entity1.entity_id) - - assert ( - "test::VacuumEntity is extending the deprecated base class VacuumEntity" - in caplog.text - ) - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - VACUUM_DOMAIN, f"deprecated_vacuum_base_class_{TEST_DOMAIN}" - ) - assert issue.issue_domain == TEST_DOMAIN - assert issue.issue_id == f"deprecated_vacuum_base_class_{TEST_DOMAIN}" - assert issue.translation_key == translation_key - assert ( - issue.translation_placeholders - == {"platform": "test"} | translation_placeholders_extra - ) - - -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockVacuumEntity(VacuumEntity): - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockVacuumEntity() - assert entity.supported_features_compat is VacuumEntityFeature(1) - assert "MockVacuumEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "VacuumEntityFeature.TURN_ON" in caplog.text - caplog.clear() - assert entity.supported_features_compat is VacuumEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text + assert entity.state_attributes == { + "battery_level": None, + "battery_icon": "mdi:battery-unknown", + "fan_speed": None, + } + assert entity.capability_attributes == { + "fan_speed_list": ["silent", "normal", "pet hair"] + } + assert entity._deprecated_supported_features_reported