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."""
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"

View File

@ -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:

View File

@ -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)

View File

@ -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