diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index f0372273253..09f0cd15141 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -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, diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index a3dae8f5470..68b89b70b9c 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/deconz", "requirements": [ - "pydeconz==84" + "pydeconz==85" ], "ssdp": [ { diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py new file mode 100644 index 00000000000..e7b9bf274e7 --- /dev/null +++ b/homeassistant/components/deconz/number.py @@ -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()}" diff --git a/requirements_all.txt b/requirements_all.txt index 12034253ce8..52c34405a78 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b95685930c2..c926bc73d8c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py index 4ee071f10d3..dc57c679fb7 100644 --- a/tests/components/deconz/test_gateway.py +++ b/tests/components/deconz/test_gateway.py @@ -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): diff --git a/tests/components/deconz/test_number.py b/tests/components/deconz/test_number.py new file mode 100644 index 00000000000..0cf0650e3d1 --- /dev/null +++ b/tests/components/deconz/test_number.py @@ -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