From afd0005a318440cab99841c5d8b29afe0a6e2630 Mon Sep 17 00:00:00 2001 From: Patrik Lindgren <21142447+ggravlingen@users.noreply.github.com> Date: Tue, 8 Feb 2022 02:21:22 +0100 Subject: [PATCH] Add sensor for filter time left on Tradfri fan platform (#65877) * Add support for filter time left * Fix test for fan platform * Remove debug code * Add unique id migration tool * Convert to hours * Fix tests * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Add comment, check migration * Refactor migration helper * Refactor migration helper * Move definition of new unique id * Return after warning * Add test for unique id migration Co-authored-by: Martin Hjelmare --- homeassistant/components/tradfri/const.py | 4 + homeassistant/components/tradfri/sensor.py | 114 ++++++++++++++++++--- tests/components/tradfri/test_fan.py | 4 +- tests/components/tradfri/test_sensor.py | 84 ++++++++++++++- 4 files changed, 185 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/tradfri/const.py b/homeassistant/components/tradfri/const.py index c87d2097929..3d68ebbaee0 100644 --- a/homeassistant/components/tradfri/const.py +++ b/homeassistant/components/tradfri/const.py @@ -1,4 +1,6 @@ """Consts used by Tradfri.""" +from typing import Final + from homeassistant.components.light import SUPPORT_BRIGHTNESS, SUPPORT_TRANSITION from homeassistant.const import ( # noqa: F401 pylint: disable=unused-import CONF_HOST, @@ -43,3 +45,5 @@ SCAN_INTERVAL = 60 # Interval for updating the coordinator COORDINATOR = "coordinator" COORDINATOR_LIST = "coordinator_list" GROUPS_LIST = "groups_list" + +ATTR_FILTER_LIFE_REMAINING: Final = "filter_life_remaining" diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 693ebeead00..1654b780124 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +import logging from typing import Any, cast from pytradfri.command import Command @@ -12,16 +13,32 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + PERCENTAGE, + TIME_HOURS, + Platform, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry from homeassistant.helpers.entity_platform import AddEntitiesCallback from .base_class import TradfriBaseEntity -from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API +from .const import ( + ATTR_FILTER_LIFE_REMAINING, + CONF_GATEWAY_ID, + COORDINATOR, + COORDINATOR_LIST, + DOMAIN, + KEY_API, +) from .coordinator import TradfriDeviceDataUpdateCoordinator +_LOGGER = logging.getLogger(__name__) + @dataclass class TradfriSensorEntityDescriptionMixin: @@ -48,21 +65,71 @@ def _get_air_quality(device: Device) -> int | None: return cast(int, device.air_purifier_control.air_purifiers[0].air_quality) -SENSOR_DESCRIPTION_AQI = TradfriSensorEntityDescription( - device_class=SensorDeviceClass.AQI, - native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - key=SensorDeviceClass.AQI, - value=_get_air_quality, +def _get_filter_time_left(device: Device) -> int: + """Fetch the filter's remaining life (in hours).""" + return round( + device.air_purifier_control.air_purifiers[0].filter_lifetime_remaining / 60 + ) + + +SENSOR_DESCRIPTIONS_BATTERY: tuple[TradfriSensorEntityDescription, ...] = ( + TradfriSensorEntityDescription( + key="battery_level", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + value=lambda device: cast(int, device.device_info.battery_level), + ), ) -SENSOR_DESCRIPTION_BATTERY = TradfriSensorEntityDescription( - device_class=SensorDeviceClass.BATTERY, - native_unit_of_measurement=PERCENTAGE, - key=SensorDeviceClass.BATTERY, - value=lambda device: cast(int, device.device_info.battery_level), + +SENSOR_DESCRIPTIONS_FAN: tuple[TradfriSensorEntityDescription, ...] = ( + TradfriSensorEntityDescription( + key="aqi", + name="air quality", + device_class=SensorDeviceClass.AQI, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + value=_get_air_quality, + ), + TradfriSensorEntityDescription( + key=ATTR_FILTER_LIFE_REMAINING, + name="filter time left", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=TIME_HOURS, + icon="mdi:clock-outline", + value=_get_filter_time_left, + ), ) +@callback +def _migrate_old_unique_ids(hass: HomeAssistant, old_unique_id: str, key: str) -> None: + """Migrate unique IDs to the new format.""" + ent_reg = entity_registry.async_get(hass) + + entity_id = ent_reg.async_get_entity_id(Platform.SENSOR, DOMAIN, old_unique_id) + + if entity_id is None: + return + + new_unique_id = f"{old_unique_id}-{key}" + + try: + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) + except ValueError: + _LOGGER.warning( + "Skip migration of id [%s] to [%s] because it already exists", + old_unique_id, + new_unique_id, + ) + return + + _LOGGER.debug( + "Migrating unique_id from [%s] to [%s]", + old_unique_id, + new_unique_id, + ) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -76,18 +143,26 @@ async def async_setup_entry( entities: list[TradfriSensor] = [] for device_coordinator in coordinator_data[COORDINATOR_LIST]: - description = None if ( not device_coordinator.device.has_light_control and not device_coordinator.device.has_socket_control and not device_coordinator.device.has_signal_repeater_control and not device_coordinator.device.has_air_purifier_control ): - description = SENSOR_DESCRIPTION_BATTERY + descriptions = SENSOR_DESCRIPTIONS_BATTERY elif device_coordinator.device.has_air_purifier_control: - description = SENSOR_DESCRIPTION_AQI + descriptions = SENSOR_DESCRIPTIONS_FAN + else: + continue + + for description in descriptions: + # Added in Home assistant 2022.3 + _migrate_old_unique_ids( + hass=hass, + old_unique_id=f"{gateway_id}-{device_coordinator.device.id}", + key=description.key, + ) - if description: entities.append( TradfriSensor( device_coordinator, @@ -121,6 +196,11 @@ class TradfriSensor(TradfriBaseEntity, SensorEntity): self.entity_description = description + self._attr_unique_id = f"{self._attr_unique_id}-{description.key}" + + if description.name: + self._attr_name = f"{self._attr_name}: {description.name}" + self._refresh() # Set initial state def _refresh(self) -> None: diff --git a/tests/components/tradfri/test_fan.py b/tests/components/tradfri/test_fan.py index 4db4ed4e585..63e6a6558c9 100644 --- a/tests/components/tradfri/test_fan.py +++ b/tests/components/tradfri/test_fan.py @@ -129,8 +129,8 @@ async def test_set_percentage( responses = mock_gateway.mock_responses mock_gateway_response = responses[0] - # A KeyError is raised if we don't add the 5908 response code - mock_gateway_response["15025"][0].update({"5908": 10, "5907": 12}) + # A KeyError is raised if we don't this to the response code + mock_gateway_response["15025"][0].update({"5908": 10, "5907": 12, "5910": 20}) # Use the callback function to update the fan state. dev = Device(mock_gateway_response) diff --git a/tests/components/tradfri/test_sensor.py b/tests/components/tradfri/test_sensor.py index 04f65344125..a36ff93a607 100644 --- a/tests/components/tradfri/test_sensor.py +++ b/tests/components/tradfri/test_sensor.py @@ -1,10 +1,17 @@ """Tradfri sensor platform tests.""" +from __future__ import annotations from unittest.mock import MagicMock, Mock +from homeassistant.components import tradfri +from homeassistant.helpers import entity_registry as er + +from . import GATEWAY_ID from .common import setup_integration from .test_fan import mock_fan +from tests.common import MockConfigEntry + def mock_sensor(test_state: list, device_number=0): """Mock a tradfri sensor.""" @@ -69,17 +76,42 @@ async def test_cover_battery_sensor(hass, mock_gateway, mock_api_factory): async def test_air_quality_sensor(hass, mock_gateway, mock_api_factory): """Test that a battery sensor is correctly added.""" mock_gateway.mock_devices.append( - mock_fan(test_state={"fan_speed": 10, "air_quality": 42}) + mock_fan( + test_state={ + "fan_speed": 10, + "air_quality": 42, + "filter_lifetime_remaining": 120, + } + ) ) await setup_integration(hass) - sensor_1 = hass.states.get("sensor.tradfri_fan_0") + sensor_1 = hass.states.get("sensor.tradfri_fan_0_air_quality") assert sensor_1 is not None assert sensor_1.state == "42" assert sensor_1.attributes["unit_of_measurement"] == "µg/m³" assert sensor_1.attributes["device_class"] == "aqi" +async def test_filter_time_left_sensor(hass, mock_gateway, mock_api_factory): + """Test that a battery sensor is correctly added.""" + mock_gateway.mock_devices.append( + mock_fan( + test_state={ + "fan_speed": 10, + "air_quality": 42, + "filter_lifetime_remaining": 120, + } + ) + ) + await setup_integration(hass) + + sensor_1 = hass.states.get("sensor.tradfri_fan_0_filter_time_left") + assert sensor_1 is not None + assert sensor_1.state == "2" + assert sensor_1.attributes["unit_of_measurement"] == "h" + + async def test_sensor_observed(hass, mock_gateway, mock_api_factory): """Test that sensors are correctly observed.""" sensor = mock_sensor(test_state=[{"attribute": "battery_level", "value": 60}]) @@ -106,3 +138,51 @@ async def test_sensor_available(hass, mock_gateway, mock_api_factory): assert hass.states.get("sensor.tradfri_sensor_1").state == "60" assert hass.states.get("sensor.tradfri_sensor_2").state == "unavailable" + + +async def test_unique_id_migration(hass, mock_gateway, mock_api_factory): + """Test unique ID is migrated from old format to new.""" + ent_reg = er.async_get(hass) + old_unique_id = f"{GATEWAY_ID}-mock-sensor-id-0" + entry = MockConfigEntry( + domain=tradfri.DOMAIN, + data={ + "host": "mock-host", + "identity": "mock-identity", + "key": "mock-key", + "import_groups": False, + "gateway_id": GATEWAY_ID, + }, + ) + entry.add_to_hass(hass) + + # Version 1 + sensor_name = "sensor.tradfri_sensor_0" + entity_name = sensor_name.split(".")[1] + + entity_entry = ent_reg.async_get_or_create( + "sensor", + tradfri.DOMAIN, + old_unique_id, + suggested_object_id=entity_name, + config_entry=entry, + original_name=entity_name, + ) + + assert entity_entry.entity_id == sensor_name + assert entity_entry.unique_id == old_unique_id + + # Add a sensor to the gateway so that it populates coordinator list + sensor = mock_sensor( + test_state=[{"attribute": "battery_level", "value": 60}], + ) + mock_gateway.mock_devices.append(sensor) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Check that new RegistryEntry is using new unique ID format + entity_entry = ent_reg.async_get(sensor_name) + new_unique_id = f"{GATEWAY_ID}-mock-sensor-id-0-battery_level" + assert entity_entry.unique_id == new_unique_id + assert ent_reg.async_get_entity_id("sensor", tradfri.DOMAIN, old_unique_id) is None