diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py index 9baa54efb56..789a155477a 100644 --- a/homeassistant/components/deconz/number.py +++ b/homeassistant/components/deconz/number.py @@ -1,11 +1,15 @@ -"""Support for configuring different deCONZ sensors.""" +"""Support for configuring different deCONZ numbers.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass +from typing import Any, Generic, TypeVar +from pydeconz.gateway import DeconzSession +from pydeconz.interfaces.sensors import SensorResources from pydeconz.models.event import EventType +from pydeconz.models.sensor import SensorBase as PydeconzSensorBase from pydeconz.models.sensor.presence import Presence from homeassistant.components.number import ( @@ -17,39 +21,76 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er +from .const import DOMAIN as DECONZ_DOMAIN from .deconz_device import DeconzDevice from .gateway import DeconzGateway, get_gateway_from_config_entry +T = TypeVar("T", Presence, PydeconzSensorBase) + @dataclass -class DeconzNumberDescriptionMixin: +class DeconzNumberDescriptionMixin(Generic[T]): """Required values when describing deCONZ number entities.""" - suffix: str + instance_check: type[T] + name_suffix: str + set_fn: Callable[[DeconzSession, str, int], Coroutine[Any, Any, dict[str, Any]]] update_key: str - value_fn: Callable[[Presence], float | None] + value_fn: Callable[[T], float | None] @dataclass -class DeconzNumberDescription(NumberEntityDescription, DeconzNumberDescriptionMixin): +class DeconzNumberDescription(NumberEntityDescription, DeconzNumberDescriptionMixin[T]): """Class describing deCONZ number entities.""" -ENTITY_DESCRIPTIONS = { - Presence: [ - DeconzNumberDescription( - key="delay", - value_fn=lambda device: device.delay, - suffix="Delay", - update_key="delay", - native_max_value=65535, - native_min_value=0, - native_step=1, - entity_category=EntityCategory.CONFIG, - ) - ] -} +ENTITY_DESCRIPTIONS: tuple[DeconzNumberDescription, ...] = ( + DeconzNumberDescription[Presence]( + key="delay", + instance_check=Presence, + name_suffix="Delay", + set_fn=lambda api, id, v: api.sensors.presence.set_config(id=id, delay=v), + update_key="delay", + value_fn=lambda device: device.delay, + native_max_value=65535, + native_min_value=0, + native_step=1, + entity_category=EntityCategory.CONFIG, + ), + DeconzNumberDescription[Presence]( + key="duration", + instance_check=Presence, + name_suffix="Duration", + set_fn=lambda api, id, v: api.sensors.presence.set_config(id=id, duration=v), + update_key="duration", + value_fn=lambda device: device.duration, + native_max_value=65535, + native_min_value=0, + native_step=1, + entity_category=EntityCategory.CONFIG, + ), +) + + +@callback +def async_update_unique_id( + hass: HomeAssistant, unique_id: str, description: DeconzNumberDescription +) -> None: + """Update unique ID base to be on full unique ID rather than device serial. + + Introduced with release 2022.11. + """ + ent_reg = er.async_get(hass) + + new_unique_id = f"{unique_id}-{description.key}" + if ent_reg.async_get_entity_id(DOMAIN, DECONZ_DOMAIN, new_unique_id): + return + + unique_id = f'{unique_id.split("-", 1)[0]}-{description.key}' + if entity_id := ent_reg.async_get_entity_id(DOMAIN, DECONZ_DOMAIN, unique_id): + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) async def async_setup_entry( @@ -66,12 +107,14 @@ async def async_setup_entry( """Add sensor from deCONZ.""" sensor = gateway.api.sensors.presence[sensor_id] - for description in ENTITY_DESCRIPTIONS.get(type(sensor), []): + for description in ENTITY_DESCRIPTIONS: if ( - not hasattr(sensor, description.key) + not isinstance(sensor, description.instance_check) or description.value_fn(sensor) is None ): continue + if description.key == "delay": + async_update_unique_id(hass, sensor.unique_id, description) async_add_entities([DeconzNumber(sensor, gateway, description)]) gateway.register_platform_add_device_callback( @@ -81,21 +124,23 @@ async def async_setup_entry( ) -class DeconzNumber(DeconzDevice[Presence], NumberEntity): +class DeconzNumber(DeconzDevice[SensorResources], NumberEntity): """Representation of a deCONZ number entity.""" TYPE = DOMAIN + entity_description: DeconzNumberDescription def __init__( self, - device: Presence, + device: SensorResources, gateway: DeconzGateway, description: DeconzNumberDescription, ) -> None: """Initialize deCONZ number entity.""" - self.entity_description: DeconzNumberDescription = description - self._update_key = self.entity_description.update_key - self._name_suffix = description.suffix + self.entity_description = description + self.unique_id_suffix = description.key + self._name_suffix = description.name_suffix + self._update_key = description.update_key super().__init__(device, gateway) @property @@ -105,12 +150,8 @@ class DeconzNumber(DeconzDevice[Presence], NumberEntity): async def async_set_native_value(self, value: float) -> None: """Set sensor config.""" - await self.gateway.api.sensors.presence.set_config( - id=self._device.resource_id, - delay=int(value), + await self.entity_description.set_fn( + self.gateway.api, + self._device.resource_id, + int(value), ) - - @property - def unique_id(self) -> str: - """Return a unique identifier for this entity.""" - return f"{self.serial}-{self.entity_description.suffix.lower()}" diff --git a/tests/components/deconz/test_number.py b/tests/components/deconz/test_number.py index e0c469a1ba2..63dac8dde37 100644 --- a/tests/components/deconz/test_number.py +++ b/tests/components/deconz/test_number.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest +from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, @@ -44,7 +45,8 @@ TEST_DATA = [ "entity_count": 3, "device_count": 3, "entity_id": "number.presence_sensor_delay", - "unique_id": "00:00:00:00:00:00:00:00-delay", + "unique_id": "00:00:00:00:00:00:00:00-00-delay", + "old_unique_id": "00:00:00:00:00:00:00:00-delay", "state": "0", "entity_category": EntityCategory.CONFIG, "attributes": { @@ -62,7 +64,43 @@ TEST_DATA = [ "unsupported_service_response": {"delay": 0}, "out_of_range_service_value": 66666, }, - ) + ), + ( # Presence sensor - duration configuration + { + "name": "Presence sensor", + "type": "ZHAPresence", + "state": {"dark": False, "presence": False}, + "config": { + "duration": 0, + "on": True, + "reachable": True, + "temperature": 10, + }, + "uniqueid": "00:00:00:00:00:00:00:00-00", + }, + { + "entity_count": 3, + "device_count": 3, + "entity_id": "number.presence_sensor_duration", + "unique_id": "00:00:00:00:00:00:00:00-00-duration", + "state": "0", + "entity_category": EntityCategory.CONFIG, + "attributes": { + "min": 0, + "max": 65535, + "step": 1, + "mode": "auto", + "friendly_name": "Presence sensor Duration", + }, + "websocket_event": {"config": {"duration": 10}}, + "next_state": "10", + "supported_service_value": 111, + "supported_service_response": {"duration": 111}, + "unsupported_service_value": 0.1, + "unsupported_service_response": {"duration": 0}, + "out_of_range_service_value": 66666, + }, + ), ] @@ -74,6 +112,15 @@ async def test_number_entities( ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) + # Create entity entry to migrate to new unique ID + if "old_unique_id" in expected: + ent_reg.async_get_or_create( + NUMBER_DOMAIN, + DECONZ_DOMAIN, + expected["old_unique_id"], + suggested_object_id=expected["entity_id"].replace("number.", ""), + ) + with patch.dict(DECONZ_WEB_REQUEST, {"sensors": {"0": sensor_data}}): config_entry = await setup_deconz_integration(hass, aioclient_mock) @@ -105,8 +152,7 @@ async def test_number_entities( "e": "changed", "r": "sensors", "id": "0", - "config": {"delay": 10}, - } + } | expected["websocket_event"] await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() assert hass.states.get(expected["entity_id"]).state == expected["next_state"]