Add deCONZ number config entity for Hue motion sensor delay (#58076)

* First working draft of number platform

* Replace duration with delay for Hue motion sensors
Improve tests

* Bump dependency to v85

* Use constant for entity category

* Use type rather than using __class__

* Fix unique ID
This commit is contained in:
Robert Svensson 2021-10-20 11:23:24 +02:00 committed by GitHub
parent 25f4f2d86e
commit 008b784fc5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 241 additions and 7 deletions

View file

@ -10,6 +10,7 @@ from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN
@ -40,6 +41,7 @@ PLATFORMS = [
FAN_DOMAIN,
LIGHT_DOMAIN,
LOCK_DOMAIN,
NUMBER_DOMAIN,
SCENE_DOMAIN,
SENSOR_DOMAIN,
SIREN_DOMAIN,

View file

@ -4,7 +4,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/deconz",
"requirements": [
"pydeconz==84"
"pydeconz==85"
],
"ssdp": [
{

View file

@ -0,0 +1,126 @@
"""Support for configuring different deCONZ sensors."""
from __future__ import annotations
from dataclasses import dataclass
from pydeconz.sensor import PRESENCE_DELAY, Presence
from homeassistant.components.number import (
DOMAIN,
NumberEntity,
NumberEntityDescription,
)
from homeassistant.const import ENTITY_CATEGORY_CONFIG
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .deconz_device import DeconzDevice
from .gateway import get_gateway_from_config_entry
@dataclass
class DeconzNumberEntityDescription(NumberEntityDescription):
"""Class describing deCONZ number entities."""
entity_category = ENTITY_CATEGORY_CONFIG
device_property: str | None = None
suffix: str | None = None
update_key: str | None = None
max_value: int | None = None
min_value: int | None = None
step: int | None = None
ENTITY_DESCRIPTIONS = {
Presence: [
DeconzNumberEntityDescription(
key="delay",
device_property="delay",
suffix="Delay",
update_key=PRESENCE_DELAY,
max_value=65535,
min_value=0,
step=1,
)
]
}
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the deCONZ number entity."""
gateway = get_gateway_from_config_entry(hass, config_entry)
gateway.entities[DOMAIN] = set()
@callback
def async_add_sensor(sensors=gateway.api.sensors.values()):
"""Add number config sensor from deCONZ."""
entities = []
for sensor in sensors:
if sensor.type.startswith("CLIP"):
continue
known_number_entities = set(gateway.entities[DOMAIN])
for description in ENTITY_DESCRIPTIONS.get(type(sensor), []):
if getattr(sensor, description.device_property) is None:
continue
new_number_entity = DeconzNumber(sensor, gateway, description)
if new_number_entity.unique_id not in known_number_entities:
entities.append(new_number_entity)
if entities:
async_add_entities(entities)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
gateway.signal_new_sensor,
async_add_sensor,
)
)
async_add_sensor(
[gateway.api.sensors[key] for key in sorted(gateway.api.sensors, key=int)]
)
class DeconzNumber(DeconzDevice, NumberEntity):
"""Representation of a deCONZ number entity."""
TYPE = DOMAIN
def __init__(self, device, gateway, description):
"""Initialize deCONZ number entity."""
self.entity_description = description
super().__init__(device, gateway)
self._attr_name = f"{self._device.name} {description.suffix}"
self._attr_max_value = description.max_value
self._attr_min_value = description.min_value
self._attr_step = description.step
@callback
def async_update_callback(self, force_update: bool = False) -> None:
"""Update the number value."""
keys = {self.entity_description.update_key, "reachable"}
if force_update or self._device.changed_keys.intersection(keys):
super().async_update_callback(force_update=force_update)
@property
def value(self) -> float:
"""Return the value of the sensor property."""
return getattr(self._device, self.entity_description.device_property)
async def async_set_value(self, value: float) -> None:
"""Set sensor config."""
data = {self.entity_description.device_property: int(value)}
await self._device.set_config(**data)
@property
def unique_id(self) -> str:
"""Return a unique identifier for this entity."""
return f"{self.serial}-{self.entity_description.suffix.lower()}"

View file

@ -1417,7 +1417,7 @@ pydaikin==2.6.0
pydanfossair==0.1.0
# homeassistant.components.deconz
pydeconz==84
pydeconz==85
# homeassistant.components.delijn
pydelijn==0.6.1

View file

@ -838,7 +838,7 @@ pycoolmasternet-async==0.1.2
pydaikin==2.6.0
# homeassistant.components.deconz
pydeconz==84
pydeconz==85
# homeassistant.components.dexcom
pydexcom==0.2.0

View file

@ -23,6 +23,7 @@ from homeassistant.components.deconz.gateway import (
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN
@ -162,10 +163,11 @@ async def test_gateway_setup(hass, aioclient_mock):
assert forward_entry_setup.mock_calls[4][1] == (config_entry, FAN_DOMAIN)
assert forward_entry_setup.mock_calls[5][1] == (config_entry, LIGHT_DOMAIN)
assert forward_entry_setup.mock_calls[6][1] == (config_entry, LOCK_DOMAIN)
assert forward_entry_setup.mock_calls[7][1] == (config_entry, SCENE_DOMAIN)
assert forward_entry_setup.mock_calls[8][1] == (config_entry, SENSOR_DOMAIN)
assert forward_entry_setup.mock_calls[9][1] == (config_entry, SIREN_DOMAIN)
assert forward_entry_setup.mock_calls[10][1] == (config_entry, SWITCH_DOMAIN)
assert forward_entry_setup.mock_calls[7][1] == (config_entry, NUMBER_DOMAIN)
assert forward_entry_setup.mock_calls[8][1] == (config_entry, SCENE_DOMAIN)
assert forward_entry_setup.mock_calls[9][1] == (config_entry, SENSOR_DOMAIN)
assert forward_entry_setup.mock_calls[10][1] == (config_entry, SIREN_DOMAIN)
assert forward_entry_setup.mock_calls[11][1] == (config_entry, SWITCH_DOMAIN)
async def test_gateway_retry(hass):

View file

@ -0,0 +1,104 @@
"""deCONZ number platform tests."""
from unittest.mock import patch
import pytest
from homeassistant.components.number import (
ATTR_VALUE,
DOMAIN as NUMBER_DOMAIN,
SERVICE_SET_VALUE,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE
from .test_gateway import (
DECONZ_WEB_REQUEST,
mock_deconz_put_request,
setup_deconz_integration,
)
async def test_no_number_entities(hass, aioclient_mock):
"""Test that no sensors in deconz results in no number entities."""
await setup_deconz_integration(hass, aioclient_mock)
assert len(hass.states.async_all()) == 0
async def test_binary_sensors(hass, aioclient_mock, mock_deconz_websocket):
"""Test successful creation of binary sensor entities."""
data = {
"sensors": {
"0": {
"name": "Presence sensor",
"type": "ZHAPresence",
"state": {"dark": False, "presence": False},
"config": {
"delay": 0,
"on": True,
"reachable": True,
"temperature": 10,
},
"uniqueid": "00:00:00:00:00:00:00:00-00",
},
}
}
with patch.dict(DECONZ_WEB_REQUEST, data):
config_entry = await setup_deconz_integration(hass, aioclient_mock)
assert len(hass.states.async_all()) == 3
assert hass.states.get("number.presence_sensor_delay").state == "0"
event_changed_sensor = {
"t": "event",
"e": "changed",
"r": "sensors",
"id": "0",
"config": {"delay": 10},
}
await mock_deconz_websocket(data=event_changed_sensor)
await hass.async_block_till_done()
assert hass.states.get("number.presence_sensor_delay").state == "10"
# Verify service calls
mock_deconz_put_request(aioclient_mock, config_entry.data, "/sensors/0/config")
# Service set supported value
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: "number.presence_sensor_delay", ATTR_VALUE: 111},
blocking=True,
)
assert aioclient_mock.mock_calls[1][2] == {"delay": 111}
# Service set float value
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: "number.presence_sensor_delay", ATTR_VALUE: 0.1},
blocking=True,
)
assert aioclient_mock.mock_calls[2][2] == {"delay": 0}
# Service set value beyond the supported range
with pytest.raises(ValueError):
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: "number.presence_sensor_delay", ATTR_VALUE: 66666},
blocking=True,
)
await hass.config_entries.async_unload(config_entry.entry_id)
assert hass.states.get("number.presence_sensor_delay").state == STATE_UNAVAILABLE
await hass.config_entries.async_remove(config_entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 0