diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 055067cc36f..09d248756fc 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -9,7 +9,7 @@ from pydeconz.interfaces.sensors import SensorResources from pydeconz.models.event import EventType from pydeconz.models.sensor.air_quality import AirQuality from pydeconz.models.sensor.consumption import Consumption -from pydeconz.models.sensor.daylight import Daylight +from pydeconz.models.sensor.daylight import DAYLIGHT_STATUS, Daylight from pydeconz.models.sensor.generic_status import GenericStatus from pydeconz.models.sensor.humidity import Humidity from pydeconz.models.sensor.light_level import LightLevel @@ -41,21 +41,23 @@ from homeassistant.const import ( 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 homeassistant.helpers.typing import StateType import homeassistant.util.dt as dt_util -from .const import ATTR_DARK, ATTR_ON +from .const import ATTR_DARK, ATTR_ON, DOMAIN as DECONZ_DOMAIN from .deconz_device import DeconzDevice from .gateway import DeconzGateway, get_gateway_from_config_entry PROVIDES_EXTRA_ATTRIBUTES = ( "battery", "consumption", - "status", + "daylight_status", "humidity", "light_level", "power", "pressure", + "status", "temperature", ) @@ -119,8 +121,8 @@ ENTITY_DESCRIPTIONS = { ], Daylight: [ DeconzSensorDescription( - key="status", - value_fn=lambda device: device.status + key="daylight_status", + value_fn=lambda device: DAYLIGHT_STATUS[device.daylight_status] if isinstance(device, Daylight) else None, update_key="status", @@ -232,6 +234,27 @@ COMMON_SENSOR_DESCRIPTIONS = [ ] +@callback +def async_update_unique_id( + hass: HomeAssistant, unique_id: str, description: DeconzSensorDescription +) -> None: + """Update unique ID to always have a suffix. + + Introduced with release 2022.9. + """ + 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 + + if description.suffix: + unique_id = f'{unique_id.split("-", 1)[0]}-{description.suffix.lower()}' + + 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( hass: HomeAssistant, config_entry: ConfigEntry, @@ -241,29 +264,46 @@ async def async_setup_entry( gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() + known_device_entities: dict[str, set[str]] = { + description.key: set() for description in COMMON_SENSOR_DESCRIPTIONS + } + @callback def async_add_sensor(_: EventType, sensor_id: str) -> None: """Add sensor from deCONZ.""" sensor = gateway.api.sensors[sensor_id] entities: list[DeconzSensor] = [] - if sensor.battery is None and not sensor.type.startswith("CLIP"): - DeconzBatteryTracker(sensor_id, gateway, async_add_entities) - - known_entities = set(gateway.entities[DOMAIN]) - for description in ( ENTITY_DESCRIPTIONS.get(type(sensor), []) + COMMON_SENSOR_DESCRIPTIONS ): + no_sensor_data = False if ( not hasattr(sensor, description.key) or description.value_fn(sensor) is None ): + no_sensor_data = True + + if description in COMMON_SENSOR_DESCRIPTIONS: + if ( + sensor.type.startswith("CLIP") + or (no_sensor_data and description.key != "battery") + or ( + (unique_id := sensor.unique_id.rsplit("-", 1)[0]) + in known_device_entities[description.key] + ) + ): + continue + known_device_entities[description.key].add(unique_id) + if no_sensor_data and description.key == "battery": + DeconzBatteryTracker(sensor_id, gateway, async_add_entities) + continue + + if no_sensor_data: continue - entity = DeconzSensor(sensor, gateway, description) - if entity.unique_id not in known_entities: - entities.append(entity) + async_update_unique_id(hass, sensor.unique_id, description) + entities.append(DeconzSensor(sensor, gateway, description)) async_add_entities(entities) @@ -301,21 +341,7 @@ class DeconzSensor(DeconzDevice[SensorResources], SensorEntity): @property def unique_id(self) -> str: """Return a unique identifier for this device.""" - if ( - self.entity_description.key == "battery" - and self._device.manufacturer == "Danfoss" - and self._device.model_id - in [ - "0x8030", - "0x8031", - "0x8034", - "0x8035", - ] - ): - return f"{super().unique_id}-battery" - if self.entity_description.suffix: - return f"{self.serial}-{self.entity_description.suffix.lower()}" - return super().unique_id + return f"{self._device.unique_id}-{self.entity_description.key}" @property def native_value(self) -> StateType | datetime: @@ -386,9 +412,6 @@ class DeconzBatteryTracker: """Update the device's state.""" if "battery" in self.sensor.changed_keys: self.unsubscribe() - known_entities = set(self.gateway.entities[DOMAIN]) - entity = DeconzSensor( - self.sensor, self.gateway, COMMON_SENSOR_DESCRIPTIONS[0] - ) - if entity.unique_id not in known_entities: - self.async_add_entities([entity]) + desc = COMMON_SENSOR_DESCRIPTIONS[0] + async_update_unique_id(self.gateway.hass, self.sensor.unique_id, desc) + self.async_add_entities([DeconzSensor(self.sensor, self.gateway, desc)]) diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 5f11a4d7b0b..1078d888c0e 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -5,7 +5,10 @@ from unittest.mock import patch import pytest -from homeassistant.components.deconz.const import CONF_ALLOW_CLIP_SENSOR +from homeassistant.components.deconz.const import ( + CONF_ALLOW_CLIP_SENSOR, + DOMAIN as DECONZ_DOMAIN, +) from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, @@ -54,7 +57,8 @@ TEST_DATA = [ "entity_count": 2, "device_count": 3, "entity_id": "sensor.bosch_air_quality_sensor", - "unique_id": "00:12:4b:00:14:4d:00:07-02-fdef", + "unique_id": "00:12:4b:00:14:4d:00:07-02-fdef-air_quality", + "old_unique_id": "00:12:4b:00:14:4d:00:07-02-fdef", "state": "poor", "entity_category": None, "device_class": None, @@ -92,7 +96,8 @@ TEST_DATA = [ "entity_count": 2, "device_count": 3, "entity_id": "sensor.bosch_air_quality_sensor_ppb", - "unique_id": "00:12:4b:00:14:4d:00:07-ppb", + "unique_id": "00:12:4b:00:14:4d:00:07-02-fdef-air_quality_ppb", + "old_unique_id": "00:12:4b:00:14:4d:00:07-ppb", "state": "809", "entity_category": None, "device_class": SensorDeviceClass.AQI, @@ -131,7 +136,8 @@ TEST_DATA = [ "entity_count": 1, "device_count": 3, "entity_id": "sensor.fyrtur_block_out_roller_blind_battery", - "unique_id": "00:0d:6f:ff:fe:01:23:45-battery", + "unique_id": "00:0d:6f:ff:fe:01:23:45-01-0001-battery", + "old_unique_id": "00:0d:6f:ff:fe:01:23:45-battery", "state": "100", "entity_category": EntityCategory.DIAGNOSTIC, "device_class": SensorDeviceClass.BATTERY, @@ -167,7 +173,8 @@ TEST_DATA = [ "entity_count": 1, "device_count": 3, "entity_id": "sensor.consumption_15", - "unique_id": "00:0d:6f:00:0b:7a:64:29-01-0702", + "unique_id": "00:0d:6f:00:0b:7a:64:29-01-0702-consumption", + "old_unique_id": "00:0d:6f:00:0b:7a:64:29-01-0702", "state": "11.342", "entity_category": None, "device_class": SensorDeviceClass.ENERGY, @@ -203,13 +210,15 @@ TEST_DATA = [ }, "swversion": "1.0", "type": "Daylight", + "uniqueid": "01:23:4E:FF:FF:56:78:9A-01", }, { "enable_entity": True, "entity_count": 1, - "device_count": 2, + "device_count": 3, "entity_id": "sensor.daylight", - "unique_id": "", + "unique_id": "01:23:4E:FF:FF:56:78:9A-01-daylight_status", + "old-unique_id": "01:23:4E:FF:FF:56:78:9A-01", "state": "solar_noon", "entity_category": None, "device_class": None, @@ -246,7 +255,8 @@ TEST_DATA = [ "entity_count": 1, "device_count": 2, "entity_id": "sensor.fsm_state_motion_stair", - "unique_id": "fsm-state-1520195376277", + "unique_id": "fsm-state-1520195376277-status", + "old_unique_id": "fsm-state-1520195376277", "state": "0", "entity_category": None, "device_class": None, @@ -284,7 +294,8 @@ TEST_DATA = [ "entity_count": 2, "device_count": 3, "entity_id": "sensor.mi_temperature_1", - "unique_id": "00:15:8d:00:02:45:dc:53-01-0405", + "unique_id": "00:15:8d:00:02:45:dc:53-01-0405-humidity", + "old_unique_id": "00:15:8d:00:02:45:dc:53-01-0405", "state": "35.5", "entity_category": None, "device_class": SensorDeviceClass.HUMIDITY, @@ -333,7 +344,8 @@ TEST_DATA = [ "entity_count": 2, "device_count": 3, "entity_id": "sensor.motion_sensor_4", - "unique_id": "00:17:88:01:03:28:8c:9b-02-0400", + "unique_id": "00:17:88:01:03:28:8c:9b-02-0400-light_level", + "old_unique_id": "00:17:88:01:03:28:8c:9b-02-0400", "state": "5.0", "entity_category": None, "device_class": SensorDeviceClass.ILLUMINANCE, @@ -375,7 +387,8 @@ TEST_DATA = [ "entity_count": 1, "device_count": 3, "entity_id": "sensor.power_16", - "unique_id": "00:0d:6f:00:0b:7a:64:29-01-0b04", + "unique_id": "00:0d:6f:00:0b:7a:64:29-01-0b04-power", + "old_unique_id": "00:0d:6f:00:0b:7a:64:29-01-0b04", "state": "64", "entity_category": None, "device_class": SensorDeviceClass.POWER, @@ -417,7 +430,8 @@ TEST_DATA = [ "entity_count": 2, "device_count": 3, "entity_id": "sensor.mi_temperature_1", - "unique_id": "00:15:8d:00:02:45:dc:53-01-0403", + "unique_id": "00:15:8d:00:02:45:dc:53-01-0403-pressure", + "old_unique_id": "00:15:8d:00:02:45:dc:53-01-0403", "state": "1010", "entity_category": None, "device_class": SensorDeviceClass.PRESSURE, @@ -458,7 +472,8 @@ TEST_DATA = [ "entity_count": 2, "device_count": 3, "entity_id": "sensor.mi_temperature_1", - "unique_id": "00:15:8d:00:02:45:dc:53-01-0402", + "unique_id": "00:15:8d:00:02:45:dc:53-01-0402-temperature", + "old_unique_id": "00:15:8d:00:02:45:dc:53-01-0402", "state": "21.8", "entity_category": None, "device_class": SensorDeviceClass.TEMPERATURE, @@ -501,7 +516,8 @@ TEST_DATA = [ "entity_count": 2, "device_count": 3, "entity_id": "sensor.etrv_sejour", - "unique_id": "cc:cc:cc:ff:fe:38:4d:b3-01-000a", + "unique_id": "cc:cc:cc:ff:fe:38:4d:b3-01-000a-last_set", + "old_unique_id": "cc:cc:cc:ff:fe:38:4d:b3-01-000a", "state": "2020-11-19T08:07:08+00:00", "entity_category": None, "device_class": SensorDeviceClass.TIMESTAMP, @@ -515,7 +531,7 @@ TEST_DATA = [ "next_state": "2020-12-14T10:12:14+00:00", }, ), - ( # Secondary temperature sensor + ( # Internal temperature sensor { "config": { "battery": 100, @@ -542,7 +558,8 @@ TEST_DATA = [ "entity_count": 3, "device_count": 3, "entity_id": "sensor.alarm_10_temperature", - "unique_id": "00:15:8d:00:02:b5:d1:80-temperature", + "unique_id": "00:15:8d:00:02:b5:d1:80-01-0500-internal_temperature", + "old_unique_id": "00:15:8d:00:02:b5:d1:80-temperature", "state": "26.0", "entity_category": None, "device_class": SensorDeviceClass.TEMPERATURE, @@ -583,7 +600,8 @@ TEST_DATA = [ "entity_count": 1, "device_count": 3, "entity_id": "sensor.dimmer_switch_3_battery", - "unique_id": "00:17:88:01:02:0e:32:a3-battery", + "unique_id": "00:17:88:01:02:0e:32:a3-02-fc00-battery", + "old_unique_id": "00:17:88:01:02:0e:32:a3-battery", "state": "90", "entity_category": EntityCategory.DIAGNOSTIC, "device_class": SensorDeviceClass.BATTERY, @@ -611,6 +629,15 @@ async def test_sensors( 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( + SENSOR_DOMAIN, + DECONZ_DOMAIN, + expected["old_unique_id"], + suggested_object_id=expected["entity_id"].replace("sensor.", ""), + ) + with patch.dict(DECONZ_WEB_REQUEST, {"sensors": {"1": sensor_data}}): config_entry = await setup_deconz_integration( hass, aioclient_mock, options={CONF_ALLOW_CLIP_SENSOR: True} @@ -848,7 +875,11 @@ async def test_air_quality_sensor_without_ppb(hass, aioclient_mock): async def test_add_battery_later(hass, aioclient_mock, mock_deconz_websocket): - """Test that a sensor without an initial battery state creates a battery sensor once state exist.""" + """Test that a battery sensor can be created later on. + + Without an initial battery state a battery sensor + can be created once a value is reported. + """ data = { "sensors": { "1": { @@ -856,15 +887,33 @@ async def test_add_battery_later(hass, aioclient_mock, mock_deconz_websocket): "type": "ZHASwitch", "state": {"buttonevent": 1000}, "config": {}, - "uniqueid": "00:00:00:00:00:00:00:00-00", - } + "uniqueid": "00:00:00:00:00:00:00:00-00-0000", + }, + "2": { + "name": "Switch 2", + "type": "ZHASwitch", + "state": {"buttonevent": 1000}, + "config": {}, + "uniqueid": "00:00:00:00:00:00:00:00-00-0001", + }, } } with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) assert len(hass.states.async_all()) == 0 - assert not hass.states.get("sensor.switch_1_battery") + + event_changed_sensor = { + "t": "event", + "e": "changed", + "r": "sensors", + "id": "2", + "config": {"battery": 50}, + } + await mock_deconz_websocket(data=event_changed_sensor) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 event_changed_sensor = { "t": "event",