Improve Deconz sensors (#65259)

This commit is contained in:
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(
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 = [
AirQuality: [
DeconzSensorDescription(
key="temperature",
required_attr="secondary_temperature",
value_fn=lambda device: device.secondary_temperature,
suffix="Temperature",
update_key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
key="air_quality",
value_fn=lambda device: device.air_quality, # type: ignore[no-any-return]
update_key="airquality",
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,
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,
),
DeconzSensorDescription(
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,
),
]
@ -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,
},

View File

@ -3,12 +3,17 @@
from datetime import timedelta
from unittest.mock import patch
import pytest
from homeassistant.components.deconz.const import CONF_ALLOW_CLIP_SENSOR
from homeassistant.components.deconz.sensor import ATTR_DAYLIGHT
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY
from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.entity import EntityCategory
from homeassistant.util import dt
@ -23,159 +28,639 @@ async def test_no_sensors(hass, aioclient_mock):
assert len(hass.states.async_all()) == 0
async def test_sensors(hass, aioclient_mock, mock_deconz_websocket):
"""Test successful creation of sensor entities."""
data = {
"sensors": {
"1": {
"name": "Light level sensor",
"type": "ZHALightLevel",
"state": {"daylight": 6955, "lightlevel": 30000, "dark": False},
"config": {"on": True, "reachable": True, "temperature": 10},
"uniqueid": "00:00:00:00:00:00:00:00-00",
TEST_DATA = [
( # Air quality sensor
{
"config": {
"on": True,
"reachable": True,
},
"2": {
"name": "Presence sensor",
"type": "ZHAPresence",
"state": {"presence": False},
"config": {},
"uniqueid": "00:00:00:00:00:00:00:01-00",
"ep": 2,
"etag": "c2d2e42396f7c78e11e46c66e2ec0200",
"lastseen": "2020-11-20T22:48Z",
"manufacturername": "BOSCH",
"modelid": "AIR",
"name": "BOSCH Air quality sensor",
"state": {
"airquality": "poor",
"airqualityppb": 809,
"lastupdated": "2020-11-20T22:48:00.209",
},
"3": {
"name": "Switch 1",
"type": "ZHASwitch",
"state": {"buttonevent": 1000},
"config": {},
"uniqueid": "00:00:00:00:00:00:00:02-00",
"swversion": "20200402",
"type": "ZHAAirQuality",
"uniqueid": "00:12:4b:00:14:4d:00:07-02-fdef",
},
"4": {
"name": "Switch 2",
"type": "ZHASwitch",
"state": {"buttonevent": 1000},
"config": {"battery": 100},
"uniqueid": "00:00:00:00:00:00:00:03-00",
{
"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",
"state": "poor",
"entity_category": None,
"device_class": None,
"state_class": SensorStateClass.MEASUREMENT,
"attributes": {
"state_class": "measurement",
"friendly_name": "BOSCH Air quality sensor",
},
"5": {
"name": "Power sensor",
"type": "ZHAPower",
"state": {"current": 2, "power": 6, "voltage": 3},
"config": {"reachable": True},
"uniqueid": "00:00:00:00:00:00:00:05-00",
"websocket_event": {"state": {"airquality": "excellent"}},
"next_state": "excellent",
},
),
( # Air quality PPB sensor
{
"config": {
"on": True,
"reachable": True,
},
"ep": 2,
"etag": "c2d2e42396f7c78e11e46c66e2ec0200",
"lastseen": "2020-11-20T22:48Z",
"manufacturername": "BOSCH",
"modelid": "AIR",
"name": "BOSCH Air quality sensor",
"state": {
"airquality": "poor",
"airqualityppb": 809,
"lastupdated": "2020-11-20T22:48:00.209",
},
"swversion": "20200402",
"type": "ZHAAirQuality",
"uniqueid": "00:12:4b:00:14:4d:00:07-02-fdef",
},
{
"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",
"state": "809",
"entity_category": None,
"device_class": SensorDeviceClass.AQI,
"state_class": SensorStateClass.MEASUREMENT,
"attributes": {
"state_class": "measurement",
"unit_of_measurement": "ppb",
"device_class": "aqi",
"friendly_name": "BOSCH Air quality sensor PPB",
},
"websocket_event": {"state": {"airqualityppb": 1000}},
"next_state": "1000",
},
),
( # Battery sensor
{
"config": {
"alert": "none",
"on": True,
"reachable": True,
},
"ep": 1,
"etag": "23a8659f1cb22df2f51bc2da0e241bb4",
"manufacturername": "IKEA of Sweden",
"modelid": "FYRTUR block-out roller blind",
"name": "FYRTUR block-out roller blind",
"state": {
"battery": 100,
"lastupdated": "none",
},
"swversion": "2.2.007",
"type": "ZHABattery",
"uniqueid": "00:0d:6f:ff:fe:01:23:45-01-0001",
},
{
"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",
"state": "100",
"entity_category": EntityCategory.DIAGNOSTIC,
"device_class": SensorDeviceClass.BATTERY,
"state_class": SensorStateClass.MEASUREMENT,
"attributes": {
"state_class": "measurement",
"on": True,
"unit_of_measurement": "%",
"device_class": "battery",
"friendly_name": "FYRTUR block-out roller blind Battery",
},
"websocket_event": {"state": {"battery": 50}},
"next_state": "50",
},
),
( # Consumption sensor
{
"config": {"on": True, "reachable": True},
"ep": 1,
"etag": "a99e5bc463d15c23af7e89946e784cca",
"manufacturername": "Heiman",
"modelid": "SmartPlug",
"name": "Consumption 15",
"state": {
"consumption": 11342,
"lastupdated": "2018-03-12T19:19:08",
"power": 123,
},
"6": {
"name": "Consumption sensor",
"type": "ZHAConsumption",
"state": {"consumption": 2, "power": 6},
"config": {"reachable": True},
"uniqueid": "00:00:00:00:00:00:00:06-00",
"uniqueid": "00:0d:6f:00:0b:7a:64:29-01-0702",
},
"7": {
"id": "CLIP light sensor id",
"name": "CLIP light level sensor",
"type": "CLIPLightLevel",
"state": {"lightlevel": 30000},
"config": {"reachable": True},
"uniqueid": "00:00:00:00:00:00:00:07-00",
{
"entity_count": 1,
"device_count": 3,
"entity_id": "sensor.consumption_15",
"unique_id": "00:0d:6f:00:0b:7a:64:29-01-0702",
"state": "11.342",
"entity_category": None,
"device_class": SensorDeviceClass.ENERGY,
"state_class": SensorStateClass.TOTAL_INCREASING,
"attributes": {
"state_class": "total_increasing",
"on": True,
"power": 123,
"unit_of_measurement": "kWh",
"device_class": "energy",
"friendly_name": "Consumption 15",
},
}
}
"websocket_event": {"state": {"consumption": 10000}},
"next_state": "10.0",
},
),
( # Daylight sensor
{
"config": {
"configured": True,
"on": True,
"sunriseoffset": 30,
"sunsetoffset": -30,
},
"etag": "55047cf652a7e594d0ee7e6fae01dd38",
"manufacturername": "Philips",
"modelid": "PHDL00",
"name": "Daylight",
"state": {
"daylight": True,
"lastupdated": "2018-03-24T17:26:12",
"status": 170,
},
"swversion": "1.0",
"type": "Daylight",
},
{
"enable_entity": True,
"entity_count": 1,
"device_count": 2,
"entity_id": "sensor.daylight",
"unique_id": "",
"state": "solar_noon",
"entity_category": None,
"device_class": None,
"state_class": None,
"attributes": {
"on": True,
"daylight": True,
"icon": "mdi:white-balance-sunny",
"friendly_name": "Daylight",
},
"websocket_event": {"state": {"status": 210}},
"next_state": "dusk",
},
),
( # Generic status sensor
{
"config": {
"on": True,
"reachable": True,
},
"etag": "aacc83bc7d6e4af7e44014e9f776b206",
"manufacturername": "Phoscon",
"modelid": "PHOSCON_FSM_STATE",
"name": "FSM_STATE Motion stair",
"state": {
"lastupdated": "2019-04-24T00:00:25",
"status": 0,
},
"swversion": "1.0",
"type": "CLIPGenericStatus",
"uniqueid": "fsm-state-1520195376277",
},
{
"entity_count": 1,
"device_count": 2,
"entity_id": "sensor.fsm_state_motion_stair",
"unique_id": "fsm-state-1520195376277",
"state": "0",
"entity_category": None,
"device_class": None,
"state_class": None,
"attributes": {
"on": True,
"friendly_name": "FSM_STATE Motion stair",
},
"websocket_event": {"state": {"status": 1}},
"next_state": "1",
},
),
( # Humidity sensor
{
"config": {
"battery": 100,
"offset": 0,
"on": True,
"reachable": True,
},
"ep": 1,
"etag": "1220e5d026493b6e86207993703a8a71",
"manufacturername": "LUMI",
"modelid": "lumi.weather",
"name": "Mi temperature 1",
"state": {
"humidity": 3555,
"lastupdated": "2019-05-05T14:39:00",
},
"swversion": "20161129",
"type": "ZHAHumidity",
"uniqueid": "00:15:8d:00:02:45:dc:53-01-0405",
},
{
"entity_count": 2,
"device_count": 3,
"entity_id": "sensor.mi_temperature_1",
"unique_id": "00:15:8d:00:02:45:dc:53-01-0405",
"state": "35.5",
"entity_category": None,
"device_class": SensorDeviceClass.HUMIDITY,
"state_class": SensorStateClass.MEASUREMENT,
"attributes": {
"state_class": "measurement",
"on": True,
"unit_of_measurement": "%",
"device_class": "humidity",
"friendly_name": "Mi temperature 1",
},
"websocket_event": {"state": {"humidity": 1000}},
"next_state": "10.0",
},
),
( # Light level sensor
{
"config": {
"alert": "none",
"battery": 100,
"ledindication": False,
"on": True,
"pending": [],
"reachable": True,
"tholddark": 12000,
"tholdoffset": 7000,
"usertest": False,
},
"ep": 2,
"etag": "5cfb81765e86aa53ace427cfd52c6d52",
"manufacturername": "Philips",
"modelid": "SML001",
"name": "Motion sensor 4",
"state": {
"dark": True,
"daylight": False,
"lastupdated": "2019-05-05T14:37:06",
"lightlevel": 6955,
"lux": 5,
},
"swversion": "6.1.0.18912",
"type": "ZHALightLevel",
"uniqueid": "00:17:88:01:03:28:8c:9b-02-0400",
},
{
"entity_count": 2,
"device_count": 3,
"entity_id": "sensor.motion_sensor_4",
"unique_id": "00:17:88:01:03:28:8c:9b-02-0400",
"state": "5.0",
"entity_category": None,
"device_class": SensorDeviceClass.ILLUMINANCE,
"state_class": None,
"attributes": {
"on": True,
"dark": True,
"daylight": False,
"unit_of_measurement": "lx",
"device_class": "illuminance",
"friendly_name": "Motion sensor 4",
},
"websocket_event": {"state": {"lightlevel": 1000}},
"next_state": "1.3",
},
),
( # Power sensor
{
"config": {
"on": True,
"reachable": True,
},
"ep": 1,
"etag": "96e71c7db4685b334d3d0decc3f11868",
"manufacturername": "Heiman",
"modelid": "SmartPlug",
"name": "Power 16",
"state": {
"current": 34,
"lastupdated": "2018-03-12T19:22:13",
"power": 64,
"voltage": 231,
},
"type": "ZHAPower",
"uniqueid": "00:0d:6f:00:0b:7a:64:29-01-0b04",
},
{
"entity_count": 1,
"device_count": 3,
"entity_id": "sensor.power_16",
"unique_id": "00:0d:6f:00:0b:7a:64:29-01-0b04",
"state": "64",
"entity_category": None,
"device_class": SensorDeviceClass.POWER,
"state_class": SensorStateClass.MEASUREMENT,
"attributes": {
"state_class": "measurement",
"on": True,
"current": 34,
"voltage": 231,
"unit_of_measurement": "W",
"device_class": "power",
"friendly_name": "Power 16",
},
"websocket_event": {"state": {"power": 1000}},
"next_state": "1000",
},
),
( # Pressure sensor
{
"config": {
"battery": 100,
"on": True,
"reachable": True,
},
"ep": 1,
"etag": "1220e5d026493b6e86207993703a8a71",
"manufacturername": "LUMI",
"modelid": "lumi.weather",
"name": "Mi temperature 1",
"state": {
"lastupdated": "2019-05-05T14:39:00",
"pressure": 1010,
},
"swversion": "20161129",
"type": "ZHAPressure",
"uniqueid": "00:15:8d:00:02:45:dc:53-01-0403",
},
{
"entity_count": 2,
"device_count": 3,
"entity_id": "sensor.mi_temperature_1",
"unique_id": "00:15:8d:00:02:45:dc:53-01-0403",
"state": "1010",
"entity_category": None,
"device_class": SensorDeviceClass.PRESSURE,
"state_class": SensorStateClass.MEASUREMENT,
"attributes": {
"state_class": "measurement",
"on": True,
"unit_of_measurement": "hPa",
"device_class": "pressure",
"friendly_name": "Mi temperature 1",
},
"websocket_event": {"state": {"pressure": 500}},
"next_state": "500",
},
),
( # Temperature sensor
{
"config": {
"battery": 100,
"offset": 0,
"on": True,
"reachable": True,
},
"ep": 1,
"etag": "1220e5d026493b6e86207993703a8a71",
"manufacturername": "LUMI",
"modelid": "lumi.weather",
"name": "Mi temperature 1",
"state": {
"lastupdated": "2019-05-05T14:39:00",
"temperature": 2182,
},
"swversion": "20161129",
"type": "ZHATemperature",
"uniqueid": "00:15:8d:00:02:45:dc:53-01-0402",
},
{
"entity_count": 2,
"device_count": 3,
"entity_id": "sensor.mi_temperature_1",
"unique_id": "00:15:8d:00:02:45:dc:53-01-0402",
"state": "21.8",
"entity_category": None,
"device_class": SensorDeviceClass.TEMPERATURE,
"state_class": SensorStateClass.MEASUREMENT,
"attributes": {
"state_class": "measurement",
"on": True,
"unit_of_measurement": "°C",
"device_class": "temperature",
"friendly_name": "Mi temperature 1",
},
"websocket_event": {"state": {"temperature": 1800}},
"next_state": "18.0",
},
),
( # Time sensor
{
"config": {
"battery": 40,
"on": True,
"reachable": True,
},
"ep": 1,
"etag": "28e796678d9a24712feef59294343bb6",
"lastseen": "2020-11-22T11:26Z",
"manufacturername": "Danfoss",
"modelid": "eTRV0100",
"name": "eTRV Séjour",
"state": {
"lastset": "2020-11-19T08:07:08Z",
"lastupdated": "2020-11-22T10:51:03.444",
"localtime": "2020-11-22T10:51:01",
"utc": "2020-11-22T10:51:01Z",
},
"swversion": "20200429",
"type": "ZHATime",
"uniqueid": "cc:cc:cc:ff:fe:38:4d:b3-01-000a",
},
{
"entity_count": 2,
"device_count": 3,
"entity_id": "sensor.etrv_sejour",
"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,
"state_class": SensorStateClass.TOTAL_INCREASING,
"attributes": {
"state_class": "total_increasing",
"device_class": "timestamp",
"friendly_name": "eTRV Séjour",
},
"websocket_event": {"state": {"lastset": "2020-12-14T10:12:14Z"}},
"next_state": "2020-12-14T10:12:14+00:00",
},
),
( # Secondary temperature sensor
{
"config": {
"battery": 100,
"on": True,
"reachable": True,
"temperature": 2600,
},
"ep": 1,
"etag": "18c0f3c2100904e31a7f938db2ba9ba9",
"manufacturername": "dresden elektronik",
"modelid": "lumi.sensor_motion.aq2",
"name": "Alarm 10",
"state": {
"alarm": False,
"lastupdated": "none",
"lowbattery": None,
"tampered": None,
},
"swversion": "20170627",
"type": "ZHAAlarm",
"uniqueid": "00:15:8d:00:02:b5:d1:80-01-0500",
},
{
"entity_count": 3,
"device_count": 3,
"entity_id": "sensor.alarm_10_temperature",
"unique_id": "00:15:8d:00:02:b5:d1:80-temperature",
"state": "26.0",
"entity_category": None,
"device_class": SensorDeviceClass.TEMPERATURE,
"state_class": SensorStateClass.MEASUREMENT,
"attributes": {
"state_class": "measurement",
"unit_of_measurement": "°C",
"device_class": "temperature",
"friendly_name": "Alarm 10 Temperature",
},
"websocket_event": {"state": {"temperature": 1800}},
"next_state": "26.0",
},
),
( # Battery from switch
{
"config": {
"battery": 90,
"group": "201",
"on": True,
"reachable": True,
},
"ep": 2,
"etag": "233ae541bbb7ac98c42977753884b8d2",
"manufacturername": "Philips",
"mode": 1,
"modelid": "RWL021",
"name": "Dimmer switch 3",
"state": {
"buttonevent": 1002,
"lastupdated": "2019-04-28T20:29:13",
},
"swversion": "5.45.1.17846",
"type": "ZHASwitch",
"uniqueid": "00:17:88:01:02:0e:32:a3-02-fc00",
},
{
"entity_count": 1,
"device_count": 3,
"entity_id": "sensor.dimmer_switch_3_battery",
"unique_id": "00:17:88:01:02:0e:32:a3-battery",
"state": "90",
"entity_category": EntityCategory.DIAGNOSTIC,
"device_class": SensorDeviceClass.BATTERY,
"state_class": SensorStateClass.MEASUREMENT,
"attributes": {
"state_class": "measurement",
"on": True,
"event_id": "dimmer_switch_3",
"unit_of_measurement": "%",
"device_class": "battery",
"friendly_name": "Dimmer switch 3 Battery",
},
"websocket_event": {"config": {"battery": 80}},
"next_state": "80",
},
),
]
with patch.dict(DECONZ_WEB_REQUEST, data):
config_entry = await setup_deconz_integration(hass, aioclient_mock)
assert len(hass.states.async_all()) == 6
@pytest.mark.parametrize("sensor_data, expected", TEST_DATA)
async def test_sensors(
hass, aioclient_mock, mock_deconz_websocket, sensor_data, expected
):
"""Test successful creation of sensor entities."""
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
light_level_sensor = hass.states.get("sensor.light_level_sensor")
assert light_level_sensor.state == "999.8"
assert (
light_level_sensor.attributes[ATTR_DEVICE_CLASS]
== SensorDeviceClass.ILLUMINANCE
)
assert light_level_sensor.attributes[ATTR_DAYLIGHT] == 6955
light_level_temp = hass.states.get("sensor.light_level_sensor_temperature")
assert light_level_temp.state == "0.1"
assert (
light_level_temp.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE
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}
)
assert not hass.states.get("sensor.presence_sensor")
assert not hass.states.get("sensor.switch_1")
assert not hass.states.get("sensor.switch_1_battery_level")
assert not hass.states.get("sensor.switch_2")
# Enable in entity registry
if expected.get("enable_entity"):
ent_reg.async_update_entity(entity_id=expected["entity_id"], disabled_by=None)
await hass.async_block_till_done()
switch_2_battery_level = hass.states.get("sensor.switch_2_battery_level")
assert switch_2_battery_level.state == "100"
assert (
switch_2_battery_level.attributes[ATTR_DEVICE_CLASS]
== SensorDeviceClass.BATTERY
async_fire_time_changed(
hass,
dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == expected["entity_count"]
# Verify entity state
sensor = hass.states.get(expected["entity_id"])
assert sensor.state == expected["state"]
assert sensor.attributes.get(ATTR_DEVICE_CLASS) == expected["device_class"]
assert sensor.attributes == expected["attributes"]
# Verify entity registry
assert (
ent_reg.async_get("sensor.switch_2_battery_level").entity_category
== EntityCategory.DIAGNOSTIC
ent_reg.async_get(expected["entity_id"]).entity_category
is expected["entity_category"]
)
ent_reg_entry = ent_reg.async_get(expected["entity_id"])
assert ent_reg_entry.entity_category is expected["entity_category"]
assert ent_reg_entry.unique_id == expected["unique_id"]
# Verify device registry
assert (
len(dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id))
== expected["device_count"]
)
assert not hass.states.get("sensor.daylight_sensor")
# Change state
power_sensor = hass.states.get("sensor.power_sensor")
assert power_sensor.state == "6"
assert power_sensor.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.POWER
consumption_sensor = hass.states.get("sensor.consumption_sensor")
assert consumption_sensor.state == "0.002"
assert consumption_sensor.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENERGY
assert not hass.states.get("sensor.clip_light_level_sensor")
# Event signals new light level
event_changed_sensor = {
"t": "event",
"e": "changed",
"r": "sensors",
"id": "1",
"state": {"lightlevel": 2000},
}
event_changed_sensor = {"t": "event", "e": "changed", "r": "sensors", "id": "1"}
event_changed_sensor |= expected["websocket_event"]
await mock_deconz_websocket(data=event_changed_sensor)
assert hass.states.get("sensor.light_level_sensor").state == "1.6"
# Event signals new temperature value
event_changed_sensor = {
"t": "event",
"e": "changed",
"r": "sensors",
"id": "1",
"config": {"temperature": 100},
}
await mock_deconz_websocket(data=event_changed_sensor)
assert hass.states.get("sensor.light_level_sensor_temperature").state == "1.0"
# Event signals new battery level
event_changed_sensor = {
"t": "event",
"e": "changed",
"r": "sensors",
"id": "4",
"config": {"battery": 75},
}
await mock_deconz_websocket(data=event_changed_sensor)
assert hass.states.get("sensor.switch_2_battery_level").state == "75"
await hass.async_block_till_done()
assert hass.states.get(expected["entity_id"]).state == expected["next_state"]
# Unload entry
await hass.config_entries.async_unload(config_entry.entry_id)
states = hass.states.async_all()
assert len(states) == 6
for state in states:
assert state.state == STATE_UNAVAILABLE
assert hass.states.get(expected["entity_id"]).state == STATE_UNAVAILABLE
# Remove entry
@ -184,6 +669,28 @@ async def test_sensors(hass, aioclient_mock, mock_deconz_websocket):
assert len(hass.states.async_all()) == 0
async def test_not_allow_clip_sensor(hass, aioclient_mock):
"""Test that CLIP sensors are not allowed."""
data = {
"sensors": {
"1": {
"name": "CLIP temperature sensor",
"type": "CLIPTemperature",
"state": {"temperature": 2600},
"config": {},
"uniqueid": "00:00:00:00:00:00:00:02-00",
},
}
}
with patch.dict(DECONZ_WEB_REQUEST, data):
await setup_deconz_integration(
hass, aioclient_mock, options={CONF_ALLOW_CLIP_SENSOR: False}
)
assert len(hass.states.async_all()) == 0
async def test_allow_clip_sensors(hass, aioclient_mock):
"""Test that CLIP sensors can be allowed."""
data = {
@ -295,7 +802,7 @@ async def test_add_battery_later(hass, aioclient_mock, mock_deconz_websocket):
await setup_deconz_integration(hass, aioclient_mock)
assert len(hass.states.async_all()) == 0
assert not hass.states.get("sensor.switch_1_battery_level")
assert not hass.states.get("sensor.switch_1_battery")
event_changed_sensor = {
"t": "event",
@ -309,10 +816,11 @@ async def test_add_battery_later(hass, aioclient_mock, mock_deconz_websocket):
assert len(hass.states.async_all()) == 1
assert hass.states.get("sensor.switch_1_battery_level").state == "50"
assert hass.states.get("sensor.switch_1_battery").state == "50"
async def test_special_danfoss_battery_creation(hass, aioclient_mock):
@pytest.mark.parametrize("model_id", ["0x8030", "0x8031", "0x8034", "0x8035"])
async def test_special_danfoss_battery_creation(hass, aioclient_mock, model_id):
"""Test the special Danfoss battery creation works.
Normally there should only be one battery sensor per device from deCONZ.
@ -334,7 +842,7 @@ async def test_special_danfoss_battery_creation(hass, aioclient_mock):
"etag": "982d9acc38bee5b251e24a9be26558e4",
"lastseen": "2021-02-15T12:23Z",
"manufacturername": "Danfoss",
"modelid": "0x8030",
"modelid": model_id,
"name": "0x8030",
"state": {
"lastupdated": "2021-02-15T12:23:07.994",
@ -359,7 +867,7 @@ async def test_special_danfoss_battery_creation(hass, aioclient_mock):
"etag": "62f12749f9f51c950086aff37dd02b61",
"lastseen": "2021-02-15T12:23Z",
"manufacturername": "Danfoss",
"modelid": "0x8030",
"modelid": model_id,
"name": "0x8030",
"state": {
"lastupdated": "2021-02-15T12:23:22.399",
@ -384,7 +892,7 @@ async def test_special_danfoss_battery_creation(hass, aioclient_mock):
"etag": "f50061174bb7f18a3d95789bab8b646d",
"lastseen": "2021-02-15T12:23Z",
"manufacturername": "Danfoss",
"modelid": "0x8030",
"modelid": model_id,
"name": "0x8030",
"state": {
"lastupdated": "2021-02-15T12:23:25.466",
@ -409,7 +917,7 @@ async def test_special_danfoss_battery_creation(hass, aioclient_mock):
"etag": "eea97adf8ce1b971b8b6a3a31793f96b",
"lastseen": "2021-02-15T12:23Z",
"manufacturername": "Danfoss",
"modelid": "0x8030",
"modelid": model_id,
"name": "0x8030",
"state": {
"lastupdated": "2021-02-15T12:23:41.939",
@ -434,7 +942,7 @@ async def test_special_danfoss_battery_creation(hass, aioclient_mock):
"etag": "1f7cd1a5d66dc27ac5eb44b8c47362fb",
"lastseen": "2021-02-15T12:23Z",
"manufacturername": "Danfoss",
"modelid": "0x8030",
"modelid": model_id,
"name": "0x8030",
"state": {"lastupdated": "none", "on": False, "temperature": 2325},
"swversion": "YYYYMMDD",
@ -450,120 +958,6 @@ async def test_special_danfoss_battery_creation(hass, aioclient_mock):
assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 5
async def test_air_quality_sensor(hass, aioclient_mock):
"""Test successful creation of air quality sensor entities."""
data = {
"sensors": {
"0": {
"config": {"on": True, "reachable": True},
"ep": 2,
"etag": "c2d2e42396f7c78e11e46c66e2ec0200",
"lastseen": "2020-11-20T22:48Z",
"manufacturername": "BOSCH",
"modelid": "AIR",
"name": "Air quality",
"state": {
"airquality": "poor",
"airqualityppb": 809,
"lastupdated": "2020-11-20T22:48:00.209",
},
"swversion": "20200402",
"type": "ZHAAirQuality",
"uniqueid": "00:12:4b:00:14:4d:00:07-02-fdef",
}
}
}
with patch.dict(DECONZ_WEB_REQUEST, data):
await setup_deconz_integration(hass, aioclient_mock)
assert len(hass.states.async_all()) == 2
assert hass.states.get("sensor.air_quality").state == "poor"
assert hass.states.get("sensor.air_quality_ppb").state == "809"
async def test_daylight_sensor(hass, aioclient_mock):
"""Test daylight sensor is disabled by default and when created has expected attributes."""
data = {
"sensors": {
"0": {
"config": {
"configured": True,
"on": True,
"sunriseoffset": 30,
"sunsetoffset": -30,
},
"etag": "55047cf652a7e594d0ee7e6fae01dd38",
"manufacturername": "Philips",
"modelid": "PHDL00",
"name": "Daylight sensor",
"state": {
"daylight": True,
"lastupdated": "2018-03-24T17:26:12",
"status": 170,
},
"swversion": "1.0",
"type": "Daylight",
"uniqueid": "00:00:00:00:00:00:00:00-00",
}
}
}
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.daylight_sensor")
# Enable in entity registry
entity_registry = er.async_get(hass)
entity_registry.async_update_entity(
entity_id="sensor.daylight_sensor", disabled_by=None
)
await hass.async_block_till_done()
async_fire_time_changed(
hass,
dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 1
assert hass.states.get("sensor.daylight_sensor")
assert hass.states.get("sensor.daylight_sensor").attributes[ATTR_DAYLIGHT]
async def test_time_sensor(hass, aioclient_mock):
"""Test successful creation of time sensor entities."""
data = {
"sensors": {
"0": {
"config": {"battery": 40, "on": True, "reachable": True},
"ep": 1,
"etag": "28e796678d9a24712feef59294343bb6",
"lastseen": "2020-11-22T11:26Z",
"manufacturername": "Danfoss",
"modelid": "eTRV0100",
"name": "Time",
"state": {
"lastset": "2020-11-19T08:07:08Z",
"lastupdated": "2020-11-22T10:51:03.444",
"localtime": "2020-11-22T10:51:01",
"utc": "2020-11-22T10:51:01Z",
},
"swversion": "20200429",
"type": "ZHATime",
"uniqueid": "cc:cc:cc:ff:fe:38:4d:b3-01-000a",
}
}
}
with patch.dict(DECONZ_WEB_REQUEST, data):
await setup_deconz_integration(hass, aioclient_mock)
assert len(hass.states.async_all()) == 2
assert hass.states.get("sensor.time").state == "2020-11-19T08:07:08Z"
assert hass.states.get("sensor.time_battery_level").state == "40"
async def test_unsupported_sensor(hass, aioclient_mock):
"""Test that unsupported sensors doesn't break anything."""
data = {