Remove legacy VacuumEntity base class support (#108189)

This commit is contained in:
Jan Bouwhuis 2024-01-17 23:19:49 +01:00 committed by GitHub
parent a27eea9b9f
commit f704a1a05a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 101 additions and 395 deletions

View file

@ -1,13 +1,12 @@
"""Support for vacuum cleaner robots (botvacs).""" """Support for vacuum cleaner robots (botvacs)."""
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Mapping from collections.abc import Mapping
from datetime import timedelta from datetime import timedelta
from enum import IntFlag from enum import IntFlag
from functools import partial from functools import partial
import logging import logging
from typing import TYPE_CHECKING, Any, final from typing import TYPE_CHECKING, Any
import voluptuous as vol import voluptuous as vol
@ -22,28 +21,18 @@ from homeassistant.const import ( # noqa: F401 # STATE_PAUSED/IDLE are API
STATE_ON, STATE_ON,
STATE_PAUSED, STATE_PAUSED,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.config_validation import ( # noqa: F401
PLATFORM_SCHEMA, PLATFORM_SCHEMA,
PLATFORM_SCHEMA_BASE, PLATFORM_SCHEMA_BASE,
make_entity_service_schema, make_entity_service_schema,
) )
from homeassistant.helpers.entity import ( from homeassistant.helpers.entity import Entity, EntityDescription
Entity,
EntityDescription,
ToggleEntity,
ToggleEntityDescription,
)
from homeassistant.helpers.entity_component import EntityComponent 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.icon import icon_for_battery_level
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import ( from homeassistant.loader import bind_hass
async_get_issue_tracker,
async_suggest_report_issue,
bind_hass,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from functools import cached_property 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: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the vacuum component.""" """Set up the vacuum component."""
component = hass.data[DOMAIN] = EntityComponent[_BaseVacuum]( component = hass.data[DOMAIN] = EntityComponent[StateVacuumEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL _LOGGER, DOMAIN, hass, SCAN_INTERVAL
) )
await component.async_setup(config) 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( component.async_register_entity_service(
SERVICE_START, 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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry.""" """Set up a config entry."""
component: EntityComponent[_BaseVacuum] = hass.data[DOMAIN] component: EntityComponent[StateVacuumEntity] = hass.data[DOMAIN]
return await component.async_setup_entry(entry) return await component.async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
component: EntityComponent[_BaseVacuum] = hass.data[DOMAIN] component: EntityComponent[StateVacuumEntity] = hass.data[DOMAIN]
return await component.async_unload_entry(entry) 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", "supported_features",
"battery_level", "battery_level",
"battery_icon", "battery_icon",
"fan_speed", "fan_speed",
"fan_speed_list", "fan_speed_list",
"state",
} }
class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): class StateVacuumEntity(
"""Representation of a base vacuum. 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}) _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_battery_level: int | None = None
_attr_fan_speed: str | None = None _attr_fan_speed: str | None = None
_attr_fan_speed_list: list[str] _attr_fan_speed_list: list[str]
_attr_state: str | None = None
_attr_supported_features: VacuumEntityFeature = VacuumEntityFeature(0) _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 @cached_property
def supported_features(self) -> VacuumEntityFeature: def supported_features(self) -> VacuumEntityFeature:
"""Flag vacuum cleaner features that are supported.""" """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 new_features
return 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: def stop(self, **kwargs: Any) -> None:
"""Stop the vacuum cleaner.""" """Stop the vacuum cleaner."""
raise NotImplementedError() raise NotImplementedError()
@ -393,163 +372,6 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_):
partial(self.send_command, command, params=params, **kwargs) 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: def start(self) -> None:
"""Start or resume the cleaning task.""" """Start or resume the cleaning task."""
raise NotImplementedError() raise NotImplementedError()

View file

@ -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": { "services": {
"turn_on": { "turn_on": {
"name": "[%key:common::action::turn_on%]", "name": "[%key:common::action::turn_on%]",

View file

@ -1,147 +1,41 @@
"""The tests for the Vacuum entity integration.""" """The tests for the Vacuum entity integration."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Generator from homeassistant.components.vacuum import StateVacuumEntity, VacuumEntityFeature
import pytest
from homeassistant.components.vacuum import (
DOMAIN as VACUUM_DOMAIN,
VacuumEntity,
VacuumEntityFeature,
)
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.core import HomeAssistant 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): async def test_supported_features_compat(hass: HomeAssistant) -> None:
"""Test flow.""" """Test StateVacuumEntity using deprecated feature constants features."""
features = (
@pytest.fixture(autouse=True) VacuumEntityFeature.BATTERY
def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: | VacuumEntityFeature.FAN_SPEED
"""Mock config flow.""" | VacuumEntityFeature.START
mock_platform(hass, f"{TEST_DOMAIN}.config_flow") | VacuumEntityFeature.STOP
| VacuumEntityFeature.PAUSE
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,
) )
entity1 = VacuumEntity() class _LegacyConstantsStateVacuum(StateVacuumEntity):
entity1.entity_id = "vacuum.test1" _attr_supported_features = int(features)
_attr_fan_speed_list = ["silent", "normal", "pet hair"]
async def async_setup_entry_platform( entity = _LegacyConstantsStateVacuum()
hass: HomeAssistant, assert isinstance(entity.supported_features, int)
config_entry: ConfigEntry, assert entity.supported_features == int(features)
async_add_entities: AddEntitiesCallback, assert entity.supported_features_compat is (
) -> None: VacuumEntityFeature.BATTERY
"""Set up test vacuum platform via config entry.""" | VacuumEntityFeature.FAN_SPEED
async_add_entities([entity1]) | VacuumEntityFeature.START
| VacuumEntityFeature.STOP
mock_platform( | VacuumEntityFeature.PAUSE
hass,
f"{TEST_DOMAIN}.{VACUUM_DOMAIN}",
MockPlatform(async_setup_entry=async_setup_entry_platform),
) )
assert entity.state_attributes == {
config_entry = MockConfigEntry(domain=TEST_DOMAIN) "battery_level": None,
config_entry.add_to_hass(hass) "battery_icon": "mdi:battery-unknown",
assert await hass.config_entries.async_setup(config_entry.entry_id) "fan_speed": None,
await hass.async_block_till_done() }
assert entity.capability_attributes == {
assert hass.states.get(entity1.entity_id) "fan_speed_list": ["silent", "normal", "pet hair"]
}
assert ( assert entity._deprecated_supported_features_reported
"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