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 <marhje52@gmail.com>

* 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 <marhje52@gmail.com>
This commit is contained in:
Patrik Lindgren 2022-02-08 02:21:22 +01:00 committed by GitHub
parent f943f30492
commit afd0005a31
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 185 additions and 21 deletions

View File

@ -1,4 +1,6 @@
"""Consts used by Tradfri.""" """Consts used by Tradfri."""
from typing import Final
from homeassistant.components.light import SUPPORT_BRIGHTNESS, SUPPORT_TRANSITION from homeassistant.components.light import SUPPORT_BRIGHTNESS, SUPPORT_TRANSITION
from homeassistant.const import ( # noqa: F401 pylint: disable=unused-import from homeassistant.const import ( # noqa: F401 pylint: disable=unused-import
CONF_HOST, CONF_HOST,
@ -43,3 +45,5 @@ SCAN_INTERVAL = 60 # Interval for updating the coordinator
COORDINATOR = "coordinator" COORDINATOR = "coordinator"
COORDINATOR_LIST = "coordinator_list" COORDINATOR_LIST = "coordinator_list"
GROUPS_LIST = "groups_list" GROUPS_LIST = "groups_list"
ATTR_FILTER_LIFE_REMAINING: Final = "filter_life_remaining"

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
import logging
from typing import Any, cast from typing import Any, cast
from pytradfri.command import Command from pytradfri.command import Command
@ -12,16 +13,32 @@ from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE from homeassistant.const import (
from homeassistant.core import HomeAssistant 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 homeassistant.helpers.entity_platform import AddEntitiesCallback
from .base_class import TradfriBaseEntity 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 from .coordinator import TradfriDeviceDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@dataclass @dataclass
class TradfriSensorEntityDescriptionMixin: 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) return cast(int, device.air_purifier_control.air_purifiers[0].air_quality)
SENSOR_DESCRIPTION_AQI = TradfriSensorEntityDescription( def _get_filter_time_left(device: Device) -> int:
device_class=SensorDeviceClass.AQI, """Fetch the filter's remaining life (in hours)."""
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, return round(
key=SensorDeviceClass.AQI, device.air_purifier_control.air_purifiers[0].filter_lifetime_remaining / 60
value=_get_air_quality, )
)
SENSOR_DESCRIPTION_BATTERY = TradfriSensorEntityDescription(
SENSOR_DESCRIPTIONS_BATTERY: tuple[TradfriSensorEntityDescription, ...] = (
TradfriSensorEntityDescription(
key="battery_level",
device_class=SensorDeviceClass.BATTERY, device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
key=SensorDeviceClass.BATTERY,
value=lambda device: cast(int, device.device_info.battery_level), 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
@ -76,18 +143,26 @@ async def async_setup_entry(
entities: list[TradfriSensor] = [] entities: list[TradfriSensor] = []
for device_coordinator in coordinator_data[COORDINATOR_LIST]: for device_coordinator in coordinator_data[COORDINATOR_LIST]:
description = None
if ( if (
not device_coordinator.device.has_light_control not device_coordinator.device.has_light_control
and not device_coordinator.device.has_socket_control and not device_coordinator.device.has_socket_control
and not device_coordinator.device.has_signal_repeater_control and not device_coordinator.device.has_signal_repeater_control
and not device_coordinator.device.has_air_purifier_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: 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( entities.append(
TradfriSensor( TradfriSensor(
device_coordinator, device_coordinator,
@ -121,6 +196,11 @@ class TradfriSensor(TradfriBaseEntity, SensorEntity):
self.entity_description = description 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 self._refresh() # Set initial state
def _refresh(self) -> None: def _refresh(self) -> None:

View File

@ -129,8 +129,8 @@ async def test_set_percentage(
responses = mock_gateway.mock_responses responses = mock_gateway.mock_responses
mock_gateway_response = responses[0] mock_gateway_response = responses[0]
# A KeyError is raised if we don't add the 5908 response code # A KeyError is raised if we don't this to the response code
mock_gateway_response["15025"][0].update({"5908": 10, "5907": 12}) mock_gateway_response["15025"][0].update({"5908": 10, "5907": 12, "5910": 20})
# Use the callback function to update the fan state. # Use the callback function to update the fan state.
dev = Device(mock_gateway_response) dev = Device(mock_gateway_response)

View File

@ -1,10 +1,17 @@
"""Tradfri sensor platform tests.""" """Tradfri sensor platform tests."""
from __future__ import annotations
from unittest.mock import MagicMock, Mock 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 .common import setup_integration
from .test_fan import mock_fan from .test_fan import mock_fan
from tests.common import MockConfigEntry
def mock_sensor(test_state: list, device_number=0): def mock_sensor(test_state: list, device_number=0):
"""Mock a tradfri sensor.""" """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): async def test_air_quality_sensor(hass, mock_gateway, mock_api_factory):
"""Test that a battery sensor is correctly added.""" """Test that a battery sensor is correctly added."""
mock_gateway.mock_devices.append( 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) 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 is not None
assert sensor_1.state == "42" assert sensor_1.state == "42"
assert sensor_1.attributes["unit_of_measurement"] == "µg/m³" assert sensor_1.attributes["unit_of_measurement"] == "µg/m³"
assert sensor_1.attributes["device_class"] == "aqi" 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): async def test_sensor_observed(hass, mock_gateway, mock_api_factory):
"""Test that sensors are correctly observed.""" """Test that sensors are correctly observed."""
sensor = mock_sensor(test_state=[{"attribute": "battery_level", "value": 60}]) 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_1").state == "60"
assert hass.states.get("sensor.tradfri_sensor_2").state == "unavailable" 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