Improve Deconz sensors (#65259)

pull/66540/head
Robert Svensson 2022-02-15 08:32:56 +01:00 committed by GitHub
parent 334a8ab13f
commit 1bc936ca8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 849 additions and 463 deletions

View File

@ -3,10 +3,10 @@ from __future__ import annotations
from collections.abc import Callable, ValuesView
from dataclasses import dataclass
from datetime import datetime
from pydeconz.sensor import (
AirQuality,
Battery,
Consumption,
Daylight,
DeconzSensor as PydeconzSensor,
@ -17,7 +17,6 @@ from pydeconz.sensor import (
Pressure,
Switch,
Temperature,
Thermostat,
Time,
)
@ -48,22 +47,21 @@ from homeassistant.helpers.dispatcher import (
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
import homeassistant.util.dt as dt_util
from .const import ATTR_DARK, ATTR_ON
from .deconz_device import DeconzDevice
from .gateway import DeconzGateway, get_gateway_from_config_entry
DECONZ_SENSORS = (
AirQuality,
Consumption,
Daylight,
GenericStatus,
Humidity,
LightLevel,
Power,
Pressure,
Temperature,
Time,
PROVIDES_EXTRA_ATTRIBUTES = (
"battery",
"consumption",
"status",
"humidity",
"light_level",
"power",
"pressure",
"temperature",
)
ATTR_CURRENT = "current"
@ -76,9 +74,7 @@ ATTR_EVENT_ID = "event_id"
class DeconzSensorDescriptionMixin:
"""Required values when describing secondary sensor attributes."""
suffix: str
update_key: str
required_attr: str
value_fn: Callable[[PydeconzSensor], float | int | None]
@ -89,78 +85,133 @@ class DeconzSensorDescription(
):
"""Class describing deCONZ binary sensor entities."""
suffix: str = ""
ENTITY_DESCRIPTIONS = {
Battery: SensorEntityDescription(
AirQuality: [
DeconzSensorDescription(
key="air_quality",
value_fn=lambda device: device.air_quality, # type: ignore[no-any-return]
update_key="airquality",
state_class=SensorStateClass.MEASUREMENT,
),
DeconzSensorDescription(
key="air_quality_ppb",
value_fn=lambda device: device.air_quality_ppb, # type: ignore[no-any-return]
suffix="PPB",
update_key="airqualityppb",
device_class=SensorDeviceClass.AQI,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
),
],
Consumption: [
DeconzSensorDescription(
key="consumption",
value_fn=lambda device: device.scaled_consumption, # type: ignore[no-any-return]
update_key="consumption",
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
)
],
Daylight: [
DeconzSensorDescription(
key="status",
value_fn=lambda device: device.status, # type: ignore[no-any-return]
update_key="status",
icon="mdi:white-balance-sunny",
entity_registry_enabled_default=False,
)
],
GenericStatus: [
DeconzSensorDescription(
key="status",
value_fn=lambda device: device.status, # type: ignore[no-any-return]
update_key="status",
)
],
Humidity: [
DeconzSensorDescription(
key="humidity",
value_fn=lambda device: device.scaled_humidity, # type: ignore[no-any-return]
update_key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
)
],
LightLevel: [
DeconzSensorDescription(
key="light_level",
value_fn=lambda device: device.scaled_light_level, # type: ignore[no-any-return]
update_key="lightlevel",
device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement=LIGHT_LUX,
)
],
Power: [
DeconzSensorDescription(
key="power",
value_fn=lambda device: device.power, # type: ignore[no-any-return]
update_key="power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=POWER_WATT,
)
],
Pressure: [
DeconzSensorDescription(
key="pressure",
value_fn=lambda device: device.pressure, # type: ignore[no-any-return]
update_key="pressure",
device_class=SensorDeviceClass.PRESSURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PRESSURE_HPA,
)
],
Temperature: [
DeconzSensorDescription(
key="temperature",
value_fn=lambda device: device.temperature, # type: ignore[no-any-return]
update_key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=TEMP_CELSIUS,
)
],
Time: [
DeconzSensorDescription(
key="last_set",
value_fn=lambda device: device.last_set, # type: ignore[no-any-return]
update_key="lastset",
device_class=SensorDeviceClass.TIMESTAMP,
state_class=SensorStateClass.TOTAL_INCREASING,
)
],
}
SENSOR_DESCRIPTIONS = [
DeconzSensorDescription(
key="battery",
value_fn=lambda device: device.battery, # type: ignore[no-any-return]
suffix="Battery",
update_key="battery",
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
),
Consumption: SensorEntityDescription(
key="consumption",
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
),
Daylight: SensorEntityDescription(
key="daylight",
icon="mdi:white-balance-sunny",
entity_registry_enabled_default=False,
),
Humidity: SensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
),
LightLevel: SensorEntityDescription(
key="lightlevel",
device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement=LIGHT_LUX,
),
Power: SensorEntityDescription(
key="power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=POWER_WATT,
),
Pressure: SensorEntityDescription(
key="pressure",
device_class=SensorDeviceClass.PRESSURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PRESSURE_HPA,
),
Temperature: SensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=TEMP_CELSIUS,
),
}
SENSOR_DESCRIPTIONS = [
DeconzSensorDescription(
key="temperature",
required_attr="secondary_temperature",
value_fn=lambda device: device.secondary_temperature,
key="secondary_temperature",
value_fn=lambda device: device.secondary_temperature, # type: ignore[no-any-return]
suffix="Temperature",
update_key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=TEMP_CELSIUS,
),
DeconzSensorDescription(
key="air_quality_ppb",
required_attr="air_quality_ppb",
value_fn=lambda device: device.air_quality_ppb,
suffix="PPB",
update_key="airqualityppb",
device_class=SensorDeviceClass.AQI,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
),
]
@ -185,42 +236,33 @@ async def async_setup_entry(
Create DeconzBattery if sensor has a battery attribute.
Create DeconzSensor if not a battery, switch or thermostat and not a binary sensor.
"""
entities: list[DeconzBattery | DeconzSensor | DeconzPropertySensor] = []
entities: list[DeconzSensor] = []
for sensor in sensors:
if not gateway.option_allow_clip_sensor and sensor.type.startswith("CLIP"):
continue
if sensor.battery is not None:
battery_handler.remove_tracker(sensor)
known_batteries = set(gateway.entities[DOMAIN])
new_battery = DeconzBattery(sensor, gateway)
if new_battery.unique_id not in known_batteries:
entities.append(new_battery)
else:
if sensor.battery is None:
battery_handler.create_tracker(sensor)
if (
isinstance(sensor, DECONZ_SENSORS)
and not isinstance(sensor, Thermostat)
and sensor.unique_id not in gateway.entities[DOMAIN]
known_entities = set(gateway.entities[DOMAIN])
for description in (
ENTITY_DESCRIPTIONS.get(type(sensor), []) + SENSOR_DESCRIPTIONS
):
entities.append(DeconzSensor(sensor, gateway))
known_sensor_entities = set(gateway.entities[DOMAIN])
for sensor_description in SENSOR_DESCRIPTIONS:
if not hasattr(
sensor, sensor_description.required_attr
) or not sensor_description.value_fn(sensor):
if (
not hasattr(sensor, description.key)
or description.value_fn(sensor) is None
):
continue
new_sensor = DeconzPropertySensor(sensor, gateway, sensor_description)
if new_sensor.unique_id not in known_sensor_entities:
entities.append(new_sensor)
new_entity = DeconzSensor(sensor, gateway, description)
if new_entity.unique_id not in known_entities:
entities.append(new_entity)
if description.key == "battery":
battery_handler.remove_tracker(sensor)
if entities:
async_add_entities(entities)
@ -243,30 +285,66 @@ class DeconzSensor(DeconzDevice, SensorEntity):
TYPE = DOMAIN
_device: PydeconzSensor
entity_description: DeconzSensorDescription
def __init__(self, device: PydeconzSensor, gateway: DeconzGateway) -> None:
"""Initialize deCONZ binary sensor."""
def __init__(
self,
device: PydeconzSensor,
gateway: DeconzGateway,
description: DeconzSensorDescription,
) -> None:
"""Initialize deCONZ sensor."""
self.entity_description = description
super().__init__(device, gateway)
if entity_description := ENTITY_DESCRIPTIONS.get(type(device)):
self.entity_description = entity_description
if description.suffix:
self._attr_name = f"{device.name} {description.suffix}"
self._update_keys = {description.update_key, "reachable"}
if self.entity_description.key in PROVIDES_EXTRA_ATTRIBUTES:
self._update_keys.update({"on", "state"})
@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
@callback
def async_update_callback(self) -> None:
"""Update the sensor's state."""
keys = {"on", "reachable", "state"}
if self._device.changed_keys.intersection(keys):
if self._device.changed_keys.intersection(self._update_keys):
super().async_update_callback()
@property
def native_value(self) -> StateType:
def native_value(self) -> StateType | datetime:
"""Return the state of the sensor."""
return self._device.state # type: ignore[no-any-return]
if self.entity_description.device_class is SensorDeviceClass.TIMESTAMP:
return dt_util.parse_datetime(
self.entity_description.value_fn(self._device)
)
return self.entity_description.value_fn(self._device)
@property
def extra_state_attributes(self) -> dict[str, bool | float | int | None]:
"""Return the state attributes of the sensor."""
attr = {}
attr: dict[str, bool | float | int | None] = {}
if self.entity_description.key not in PROVIDES_EXTRA_ATTRIBUTES:
return attr
if self._device.on is not None:
attr[ATTR_ON] = self._device.on
@ -292,93 +370,7 @@ class DeconzSensor(DeconzDevice, SensorEntity):
attr[ATTR_CURRENT] = self._device.current
attr[ATTR_VOLTAGE] = self._device.voltage
return attr
class DeconzPropertySensor(DeconzDevice, SensorEntity):
"""Representation of a deCONZ secondary attribute sensor."""
TYPE = DOMAIN
_device: PydeconzSensor
entity_description: DeconzSensorDescription
def __init__(
self,
device: PydeconzSensor,
gateway: DeconzGateway,
description: DeconzSensorDescription,
) -> None:
"""Initialize deCONZ sensor."""
self.entity_description = description
super().__init__(device, gateway)
self._attr_name = f"{self._device.name} {description.suffix}"
self._update_keys = {description.update_key, "reachable"}
@property
def unique_id(self) -> str:
"""Return a unique identifier for this device."""
return f"{self.serial}-{self.entity_description.suffix.lower()}"
@callback
def async_update_callback(self) -> None:
"""Update the sensor's state."""
if self._device.changed_keys.intersection(self._update_keys):
super().async_update_callback()
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self._device)
class DeconzBattery(DeconzDevice, SensorEntity):
"""Battery class for when a device is only represented as an event."""
TYPE = DOMAIN
_device: PydeconzSensor
def __init__(self, device: PydeconzSensor, gateway: DeconzGateway) -> None:
"""Initialize deCONZ battery level sensor."""
super().__init__(device, gateway)
self.entity_description = ENTITY_DESCRIPTIONS[Battery]
self._attr_name = f"{self._device.name} Battery Level"
@callback
def async_update_callback(self) -> None:
"""Update the battery's state, if needed."""
keys = {"battery", "reachable"}
if self._device.changed_keys.intersection(keys):
super().async_update_callback()
@property
def unique_id(self) -> str:
"""Return a unique identifier for this device.
Normally there should only be one battery sensor per device from deCONZ.
With specific Danfoss devices each endpoint can report its own battery state.
"""
if self._device.manufacturer == "Danfoss" and self._device.model_id in [
"0x8030",
"0x8031",
"0x8034",
"0x8035",
]:
return f"{super().unique_id}-battery"
return f"{self.serial}-battery"
@property
def native_value(self) -> StateType:
"""Return the state of the battery."""
return self._device.battery # type: ignore[no-any-return]
@property
def extra_state_attributes(self) -> dict[str, str]:
"""Return the state attributes of the battery."""
attr = {}
if isinstance(self._device, Switch):
elif isinstance(self._device, Switch):
for event in self.gateway.events:
if self._device == event.device:
attr[ATTR_EVENT_ID] = event.event_id

View File

@ -111,7 +111,7 @@ async def test_simple_climate_device(hass, aioclient_mock, mock_deconz_websocket
assert climate_thermostat.attributes["current_temperature"] == 21.0
assert climate_thermostat.attributes["temperature"] == 21.0
assert climate_thermostat.attributes["locked"] is True
assert hass.states.get("sensor.thermostat_battery_level").state == "59"
assert hass.states.get("sensor.thermostat_battery").state == "59"
# Event signals thermostat configured off
@ -211,7 +211,7 @@ async def test_climate_device_without_cooling_support(
assert climate_thermostat.attributes["current_temperature"] == 22.6
assert climate_thermostat.attributes["temperature"] == 22.0
assert hass.states.get("sensor.thermostat") is None
assert hass.states.get("sensor.thermostat_battery_level").state == "100"
assert hass.states.get("sensor.thermostat_battery").state == "100"
assert hass.states.get("climate.presence_sensor") is None
assert hass.states.get("climate.clip_thermostat") is None
@ -385,7 +385,7 @@ async def test_climate_device_with_cooling_support(
]
assert climate_thermostat.attributes["current_temperature"] == 23.2
assert climate_thermostat.attributes["temperature"] == 22.2
assert hass.states.get("sensor.zen_01_battery_level").state == "25"
assert hass.states.get("sensor.zen_01_battery").state == "25"
# Event signals thermostat state cool
@ -787,4 +787,4 @@ async def test_add_new_climate_device(hass, aioclient_mock, mock_deconz_websocke
assert len(hass.states.async_all()) == 2
assert hass.states.get("climate.thermostat").state == HVAC_MODE_AUTO
assert hass.states.get("sensor.thermostat_battery_level").state == "100"
assert hass.states.get("sensor.thermostat_battery").state == "100"

View File

@ -80,9 +80,9 @@ async def test_deconz_events(hass, aioclient_mock, mock_deconz_websocket):
assert (
len(async_entries_for_config_entry(device_registry, config_entry.entry_id)) == 7
)
assert hass.states.get("sensor.switch_2_battery_level").state == "100"
assert hass.states.get("sensor.switch_3_battery_level").state == "100"
assert hass.states.get("sensor.switch_4_battery_level").state == "100"
assert hass.states.get("sensor.switch_2_battery").state == "100"
assert hass.states.get("sensor.switch_3_battery").state == "100"
assert hass.states.get("sensor.switch_4_battery").state == "100"
captured_events = async_capture_events(hass, CONF_DECONZ_EVENT)

View File

@ -120,7 +120,7 @@ async def test_get_triggers(hass, aioclient_mock):
{
CONF_DEVICE_ID: device.id,
CONF_DOMAIN: SENSOR_DOMAIN,
ATTR_ENTITY_ID: "sensor.tradfri_on_off_switch_battery_level",
ATTR_ENTITY_ID: "sensor.tradfri_on_off_switch_battery",
CONF_PLATFORM: "device",
CONF_TYPE: ATTR_BATTERY_LEVEL,
},

File diff suppressed because it is too large Load Diff