Deprecate tplink alarm button entities (#126349)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Steven B. 2024-09-25 20:47:40 +01:00 committed by GitHub
parent a1e6d4b693
commit 4f0211cdd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 330 additions and 14 deletions

View File

@ -75,6 +75,7 @@ async def async_setup_entry(
device = parent_coordinator.device
entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
hass=hass,
device=device,
coordinator=parent_coordinator,
feature_type=Feature.Type.BinarySensor,

View File

@ -7,11 +7,17 @@ from typing import Final
from kasa import Feature
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.components.button import (
DOMAIN as BUTTON_DOMAIN,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import TPLinkConfigEntry
from .deprecate import DeprecatedInfo, async_cleanup_deprecated
from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription
@ -25,9 +31,19 @@ class TPLinkButtonEntityDescription(
BUTTON_DESCRIPTIONS: Final = [
TPLinkButtonEntityDescription(
key="test_alarm",
deprecated_info=DeprecatedInfo(
platform=BUTTON_DOMAIN,
new_platform=SIREN_DOMAIN,
breaks_in_ha_version="2025.4.0",
),
),
TPLinkButtonEntityDescription(
key="stop_alarm",
deprecated_info=DeprecatedInfo(
platform=BUTTON_DOMAIN,
new_platform=SIREN_DOMAIN,
breaks_in_ha_version="2025.4.0",
),
),
]
@ -46,6 +62,7 @@ async def async_setup_entry(
device = parent_coordinator.device
entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
hass=hass,
device=device,
coordinator=parent_coordinator,
feature_type=Feature.Type.Action,
@ -53,6 +70,7 @@ async def async_setup_entry(
descriptions=BUTTON_DESCRIPTIONS_MAP,
child_coordinators=children_coordinators,
)
async_cleanup_deprecated(hass, BUTTON_DOMAIN, config_entry.entry_id, entities)
async_add_entities(entities)

View File

@ -0,0 +1,111 @@
"""Helper class for deprecating entities."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN
if TYPE_CHECKING:
from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription
@dataclass(slots=True)
class DeprecatedInfo:
"""Class to define deprecation info for deprecated entities."""
platform: str
new_platform: str
breaks_in_ha_version: str
def async_check_create_deprecated(
hass: HomeAssistant,
unique_id: str,
entity_description: TPLinkFeatureEntityDescription,
) -> bool:
"""Return true if the entity should be created based on the deprecated_info.
If deprecated_info is not defined will return true.
If entity not yet created will return false.
If entity disabled will return false.
"""
if not entity_description.deprecated_info:
return True
deprecated_info = entity_description.deprecated_info
platform = deprecated_info.platform
ent_reg = er.async_get(hass)
entity_id = ent_reg.async_get_entity_id(
platform,
DOMAIN,
unique_id,
)
if not entity_id:
return False
entity_entry = ent_reg.async_get(entity_id)
assert entity_entry
return not entity_entry.disabled
def async_cleanup_deprecated(
hass: HomeAssistant,
platform: str,
entry_id: str,
entities: Sequence[CoordinatedTPLinkFeatureEntity],
) -> None:
"""Remove disabled deprecated entities or create issues if necessary."""
ent_reg = er.async_get(hass)
for entity in entities:
if not (deprecated_info := entity.entity_description.deprecated_info):
continue
assert entity.unique_id
entity_id = ent_reg.async_get_entity_id(
platform,
DOMAIN,
entity.unique_id,
)
assert entity_id
# Check for issues that need to be created
entity_automations = automations_with_entity(hass, entity_id)
entity_scripts = scripts_with_entity(hass, entity_id)
for item in entity_automations + entity_scripts:
async_create_issue(
hass,
DOMAIN,
f"deprecated_entity_{entity_id}_{item}",
breaks_in_ha_version=deprecated_info.breaks_in_ha_version,
is_fixable=False,
is_persistent=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_entity",
translation_placeholders={
"entity": entity_id,
"info": item,
"platform": platform,
"new_platform": deprecated_info.new_platform,
},
)
# Remove entities that are no longer provided and have been disabled.
unique_ids = {entity.unique_id for entity in entities}
for entity_entry in er.async_entries_for_config_entry(ent_reg, entry_id):
if (
entity_entry.domain == platform
and entity_entry.disabled
and entity_entry.unique_id not in unique_ids
):
ent_reg.async_remove(entity_entry.entity_id)
continue

View File

@ -18,7 +18,7 @@ from kasa import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
@ -36,6 +36,7 @@ from .const import (
PRIMARY_STATE_ID,
)
from .coordinator import TPLinkDataUpdateCoordinator
from .deprecate import DeprecatedInfo, async_check_create_deprecated
_LOGGER = logging.getLogger(__name__)
@ -87,6 +88,8 @@ LEGACY_KEY_MAPPING = {
class TPLinkFeatureEntityDescription(EntityDescription):
"""Base class for a TPLink feature based entity description."""
deprecated_info: DeprecatedInfo | None = None
def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P](
func: Callable[Concatenate[_T, _P], Awaitable[None]],
@ -251,18 +254,25 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC):
def _get_unique_id(self) -> str:
"""Return unique ID for the entity."""
key = self.entity_description.key
return self._get_feature_unique_id(self._device, self.entity_description)
@staticmethod
def _get_feature_unique_id(
device: Device, entity_description: TPLinkFeatureEntityDescription
) -> str:
"""Return unique ID for the entity."""
key = entity_description.key
# The unique id for the state feature in the switch platform is the
# device_id
if key == PRIMARY_STATE_ID:
return legacy_device_id(self._device)
return legacy_device_id(device)
# Historically the legacy device emeter attributes which are now
# replaced with features used slightly different keys. This ensures
# that those entities are not orphaned. Returns the mapped key or the
# provided key if not mapped.
key = LEGACY_KEY_MAPPING.get(key, key)
return f"{legacy_device_id(self._device)}_{key}"
return f"{legacy_device_id(device)}_{key}"
@classmethod
def _category_for_feature(cls, feature: Feature | None) -> EntityCategory | None:
@ -334,6 +344,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC):
_D: TPLinkFeatureEntityDescription,
](
cls,
hass: HomeAssistant,
device: Device,
coordinator: TPLinkDataUpdateCoordinator,
*,
@ -368,6 +379,11 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC):
feat, descriptions, device=device, parent=parent
)
)
and async_check_create_deprecated(
hass,
cls._get_feature_unique_id(device, desc),
desc,
)
]
return entities
@ -377,6 +393,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC):
_D: TPLinkFeatureEntityDescription,
](
cls,
hass: HomeAssistant,
device: Device,
coordinator: TPLinkDataUpdateCoordinator,
*,
@ -393,6 +410,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC):
# Add parent entities before children so via_device id works.
entities.extend(
cls._entities_for_device(
hass,
device,
coordinator=coordinator,
feature_type=feature_type,
@ -412,6 +430,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC):
child_coordinator = coordinator
entities.extend(
cls._entities_for_device(
hass,
child,
coordinator=child_coordinator,
feature_type=feature_type,

View File

@ -67,6 +67,7 @@ async def async_setup_entry(
children_coordinators = data.children_coordinators
device = parent_coordinator.device
entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
hass=hass,
device=device,
coordinator=parent_coordinator,
feature_type=Feature.Type.Number,

View File

@ -54,6 +54,7 @@ async def async_setup_entry(
device = parent_coordinator.device
entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
hass=hass,
device=device,
coordinator=parent_coordinator,
feature_type=Feature.Type.Choice,

View File

@ -8,6 +8,7 @@ from typing import cast
from kasa import Feature
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@ -18,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import TPLinkConfigEntry
from .const import UNIT_MAPPING
from .deprecate import async_cleanup_deprecated
from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription
@ -128,6 +130,7 @@ async def async_setup_entry(
device = parent_coordinator.device
entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
hass=hass,
device=device,
coordinator=parent_coordinator,
feature_type=Feature.Type.Sensor,
@ -135,6 +138,7 @@ async def async_setup_entry(
descriptions=SENSOR_DESCRIPTIONS_MAP,
child_coordinators=children_coordinators,
)
async_cleanup_deprecated(hass, SENSOR_DOMAIN, config_entry.entry_id, entities)
async_add_entities(entities)

View File

@ -311,5 +311,11 @@
"device_authentication": {
"message": "Device authentication error {func}: {exc}"
}
},
"issues": {
"deprecated_entity": {
"title": "Detected deprecated `{platform}` entity usage",
"description": "We detected that entity `{entity}` is being used in `{info}`\n\nWe have created a new `{new_platform}` entity and you should migrate `{info}` to use this new entity.\n\nWhen you are done migrating `{info}` and are ready to have the deprecated `{entity}` entity removed, disable the entity and restart Home Assistant."
}
}
}

View File

@ -64,7 +64,8 @@ async def async_setup_entry(
device = parent_coordinator.device
entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
device,
hass=hass,
device=device,
coordinator=parent_coordinator,
feature_type=Feature.Switch,
entity_class=TPLinkSwitch,

View File

@ -21,6 +21,7 @@ from kasa.protocol import BaseProtocol
from kasa.smart.modules.alarm import Alarm
from syrupy import SnapshotAssertion
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
from homeassistant.components.tplink import (
CONF_AES_KEYS,
CONF_ALIAS,
@ -184,6 +185,21 @@ async def snapshot_platform(
), f"state snapshot failed for {entity_entry.entity_id}"
async def setup_automation(hass: HomeAssistant, alias: str, entity_id: str) -> None:
"""Set up an automation for tests."""
assert await async_setup_component(
hass,
AUTOMATION_DOMAIN,
{
AUTOMATION_DOMAIN: {
"alias": alias,
"trigger": {"platform": "state", "entity_id": entity_id, "to": "on"},
"action": {"action": "notify.notify", "metadata": {}, "data": {}},
}
},
)
def _mock_protocol() -> BaseProtocol:
protocol = MagicMock(spec=BaseProtocol)
protocol.close = AsyncMock()

View File

@ -11,7 +11,11 @@ from homeassistant.components.tplink.const import DOMAIN
from homeassistant.components.tplink.entity import EXCLUDED_FEATURES
from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.setup import async_setup_component
from . import (
@ -22,6 +26,7 @@ from . import (
_mocked_strip_children,
_patch_connect,
_patch_discovery,
setup_automation,
setup_platform_for_device,
snapshot_platform,
)
@ -29,6 +34,53 @@ from . import (
from tests.common import MockConfigEntry
@pytest.fixture
def create_deprecated_button_entities(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
):
"""Create the entity so it is not ignored by the deprecation check."""
mock_config_entry.add_to_hass(hass)
def create_entry(device_name, device_id, key):
unique_id = f"{device_id}_{key}"
entity_registry.async_get_or_create(
domain=BUTTON_DOMAIN,
platform=DOMAIN,
unique_id=unique_id,
suggested_object_id=f"{device_name}_{key}",
config_entry=mock_config_entry,
)
create_entry("my_device", "123456789ABCDEFGH", "stop_alarm")
create_entry("my_device", "123456789ABCDEFGH", "test_alarm")
@pytest.fixture
def create_deprecated_child_button_entities(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
):
"""Create the entity so it is not ignored by the deprecation check."""
def create_entry(device_name, key):
for plug_id in range(2):
unique_id = f"PLUG{plug_id}DEVICEID_{key}"
entity_registry.async_get_or_create(
domain=BUTTON_DOMAIN,
platform=DOMAIN,
unique_id=unique_id,
suggested_object_id=f"my_device_plug{plug_id}_{key}",
config_entry=mock_config_entry,
)
create_entry("my_device", "stop_alarm")
create_entry("my_device", "test_alarm")
@pytest.fixture
def mocked_feature_button() -> Feature:
"""Return mocked tplink binary sensor feature."""
@ -47,6 +99,7 @@ async def test_states(
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
create_deprecated_button_entities,
) -> None:
"""Test a sensor unique ids."""
features = {description.key for description in BUTTON_DESCRIPTIONS}
@ -66,6 +119,7 @@ async def test_button(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mocked_feature_button: Feature,
create_deprecated_button_entities,
) -> None:
"""Test a sensor unique ids."""
mocked_feature = mocked_feature_button
@ -74,13 +128,13 @@ async def test_button(
)
already_migrated_config_entry.add_to_hass(hass)
plug = _mocked_device(alias="my_plug", features=[mocked_feature])
plug = _mocked_device(alias="my_device", features=[mocked_feature])
with _patch_discovery(device=plug), _patch_connect(device=plug):
await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}})
await hass.async_block_till_done()
# The entity_id is based on standard name from core.
entity_id = "button.my_plug_test_alarm"
entity_id = "button.my_device_test_alarm"
entity = entity_registry.async_get(entity_id)
assert entity
assert entity.unique_id == f"{DEVICE_ID}_{mocked_feature.id}"
@ -91,6 +145,8 @@ async def test_button_children(
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
mocked_feature_button: Feature,
create_deprecated_button_entities,
create_deprecated_child_button_entities,
) -> None:
"""Test a sensor unique ids."""
mocked_feature = mocked_feature_button
@ -99,7 +155,7 @@ async def test_button_children(
)
already_migrated_config_entry.add_to_hass(hass)
plug = _mocked_device(
alias="my_plug",
alias="my_device",
features=[mocked_feature],
children=_mocked_strip_children(features=[mocked_feature]),
)
@ -107,13 +163,13 @@ async def test_button_children(
await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}})
await hass.async_block_till_done()
entity_id = "button.my_plug_test_alarm"
entity_id = "button.my_device_test_alarm"
entity = entity_registry.async_get(entity_id)
assert entity
device = device_registry.async_get(entity.device_id)
for plug_id in range(2):
child_entity_id = f"button.my_plug_plug{plug_id}_test_alarm"
child_entity_id = f"button.my_device_plug{plug_id}_test_alarm"
child_entity = entity_registry.async_get(child_entity_id)
assert child_entity
assert child_entity.unique_id == f"PLUG{plug_id}DEVICEID_{mocked_feature.id}"
@ -127,6 +183,7 @@ async def test_button_press(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mocked_feature_button: Feature,
create_deprecated_button_entities,
) -> None:
"""Test a number entity limits and setting values."""
mocked_feature = mocked_feature_button
@ -134,12 +191,12 @@ async def test_button_press(
domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS
)
already_migrated_config_entry.add_to_hass(hass)
plug = _mocked_device(alias="my_plug", features=[mocked_feature])
plug = _mocked_device(alias="my_device", features=[mocked_feature])
with _patch_discovery(device=plug), _patch_connect(device=plug):
await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}})
await hass.async_block_till_done()
entity_id = "button.my_plug_test_alarm"
entity_id = "button.my_device_test_alarm"
entity = entity_registry.async_get(entity_id)
assert entity
assert entity.unique_id == f"{DEVICE_ID}_test_alarm"
@ -151,3 +208,84 @@ async def test_button_press(
blocking=True,
)
mocked_feature.set_value.assert_called_with(True)
async def test_button_not_exists_with_deprecation(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mocked_feature_button: Feature,
) -> None:
"""Test deprecated buttons are not created if they don't previously exist."""
config_entry = MockConfigEntry(
domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS
)
config_entry.add_to_hass(hass)
entity_id = "button.my_device_test_alarm"
assert not hass.states.get(entity_id)
mocked_feature = mocked_feature_button
dev = _mocked_device(alias="my_device", features=[mocked_feature])
with _patch_discovery(device=dev), _patch_connect(device=dev):
await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}})
await hass.async_block_till_done()
assert not entity_registry.async_get(entity_id)
assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)
assert not hass.states.get(entity_id)
@pytest.mark.parametrize(
("entity_disabled", "entity_has_automations"),
[
pytest.param(False, False, id="without-automations"),
pytest.param(False, True, id="with-automations"),
pytest.param(True, False, id="disabled"),
],
)
async def test_button_exists_with_deprecation(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
mocked_feature_button: Feature,
entity_disabled: bool,
entity_has_automations: bool,
) -> None:
"""Test the deprecated buttons are deleted or raise issues."""
config_entry = MockConfigEntry(
domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS
)
config_entry.add_to_hass(hass)
object_id = "my_device_test_alarm"
entity_id = f"button.{object_id}"
unique_id = f"{DEVICE_ID}_test_alarm"
issue_id = f"deprecated_entity_{entity_id}_automation.test_automation"
if entity_has_automations:
await setup_automation(hass, "test_automation", entity_id)
entity = entity_registry.async_get_or_create(
domain=BUTTON_DOMAIN,
platform=DOMAIN,
unique_id=unique_id,
suggested_object_id=object_id,
config_entry=config_entry,
disabled_by=er.RegistryEntryDisabler.USER if entity_disabled else None,
)
assert entity.entity_id == entity_id
assert not hass.states.get(entity_id)
mocked_feature = mocked_feature_button
dev = _mocked_device(alias="my_device", features=[mocked_feature])
with _patch_discovery(device=dev), _patch_connect(device=dev):
await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}})
await hass.async_block_till_done()
entity = entity_registry.async_get(entity_id)
# entity and state will be none if removed from registry
assert (entity is None) == entity_disabled
assert (hass.states.get(entity_id) is None) == entity_disabled
assert (
issue_registry.async_get_issue(DOMAIN, issue_id) is not None
) == entity_has_automations