From ff3d3088eee588b508dffe880b609aea11829ae8 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 31 Aug 2022 05:33:05 +0200 Subject: [PATCH] Add Aqara FP1 support to deCONZ integration (#77568) --- homeassistant/components/deconz/button.py | 39 +++- homeassistant/components/deconz/const.py | 1 + .../components/deconz/deconz_event.py | 50 +++- .../components/deconz/device_trigger.py | 10 +- homeassistant/components/deconz/gateway.py | 4 +- homeassistant/components/deconz/select.py | 143 ++++++++++++ tests/components/deconz/test_button.py | 45 +++- tests/components/deconz/test_deconz_event.py | 103 ++++++++ tests/components/deconz/test_diagnostics.py | 1 + tests/components/deconz/test_gateway.py | 8 +- tests/components/deconz/test_select.py | 219 ++++++++++++++++++ 11 files changed, 610 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/deconz/select.py create mode 100644 tests/components/deconz/test_select.py diff --git a/homeassistant/components/deconz/button.py b/homeassistant/components/deconz/button.py index 552723d6f9c..b45d7955517 100644 --- a/homeassistant/components/deconz/button.py +++ b/homeassistant/components/deconz/button.py @@ -6,9 +6,11 @@ from dataclasses import dataclass from pydeconz.models.event import EventType from pydeconz.models.scene import Scene as PydeconzScene +from pydeconz.models.sensor.presence import Presence from homeassistant.components.button import ( DOMAIN, + ButtonDeviceClass, ButtonEntity, ButtonEntityDescription, ) @@ -17,7 +19,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .deconz_device import DeconzSceneMixin +from .deconz_device import DeconzDevice, DeconzSceneMixin from .gateway import DeconzGateway, get_gateway_from_config_entry @@ -61,7 +63,7 @@ async def async_setup_entry( """Add scene button from deCONZ.""" scene = gateway.api.scenes[scene_id] async_add_entities( - DeconzButton(scene, gateway, description) + DeconzSceneButton(scene, gateway, description) for description in ENTITY_DESCRIPTIONS.get(PydeconzScene, []) ) @@ -70,8 +72,20 @@ async def async_setup_entry( gateway.api.scenes, ) + @callback + def async_add_presence_sensor(_: EventType, sensor_id: str) -> None: + """Add presence sensor reset button from deCONZ.""" + sensor = gateway.api.sensors.presence[sensor_id] + if sensor.presence_event is not None: + async_add_entities([DeconzPresenceResetButton(sensor, gateway)]) -class DeconzButton(DeconzSceneMixin, ButtonEntity): + gateway.register_platform_add_device_callback( + async_add_presence_sensor, + gateway.api.sensors.presence, + ) + + +class DeconzSceneButton(DeconzSceneMixin, ButtonEntity): """Representation of a deCONZ button entity.""" TYPE = DOMAIN @@ -99,3 +113,22 @@ class DeconzButton(DeconzSceneMixin, ButtonEntity): def get_device_identifier(self) -> str: """Return a unique identifier for this scene.""" return f"{super().get_device_identifier()}-{self.entity_description.key}" + + +class DeconzPresenceResetButton(DeconzDevice[Presence], ButtonEntity): + """Representation of a deCONZ presence reset button entity.""" + + _name_suffix = "Reset Presence" + unique_id_suffix = "reset_presence" + + _attr_entity_category = EntityCategory.CONFIG + _attr_device_class = ButtonDeviceClass.RESTART + + TYPE = DOMAIN + + async def async_press(self) -> None: + """Store reset presence state.""" + await self.gateway.api.sensors.presence.set_config( + id=self._device.resource_id, + reset_presence=True, + ) diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index 60ae610bd5a..6070f83871f 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -35,6 +35,7 @@ PLATFORMS = [ Platform.LOCK, Platform.NUMBER, Platform.SCENE, + Platform.SELECT, Platform.SENSOR, Platform.SIREN, Platform.SWITCH, diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py index 6f7b6f9038a..35e1ba79948 100644 --- a/homeassistant/components/deconz/deconz_event.py +++ b/homeassistant/components/deconz/deconz_event.py @@ -9,6 +9,7 @@ from pydeconz.models.sensor.ancillary_control import ( AncillaryControl, AncillaryControlAction, ) +from pydeconz.models.sensor.presence import Presence, PresenceStatePresenceEvent from pydeconz.models.sensor.switch import Switch from homeassistant.const import ( @@ -28,6 +29,7 @@ from .gateway import DeconzGateway CONF_DECONZ_EVENT = "deconz_event" CONF_DECONZ_ALARM_EVENT = "deconz_alarm_event" +CONF_DECONZ_PRESENCE_EVENT = "deconz_presence_event" SUPPORTED_DECONZ_ALARM_EVENTS = { AncillaryControlAction.EMERGENCY, @@ -35,6 +37,16 @@ SUPPORTED_DECONZ_ALARM_EVENTS = { AncillaryControlAction.INVALID_CODE, AncillaryControlAction.PANIC, } +SUPPORTED_DECONZ_PRESENCE_EVENTS = { + PresenceStatePresenceEvent.ENTER, + PresenceStatePresenceEvent.LEAVE, + PresenceStatePresenceEvent.ENTER_LEFT, + PresenceStatePresenceEvent.RIGHT_LEAVE, + PresenceStatePresenceEvent.ENTER_RIGHT, + PresenceStatePresenceEvent.LEFT_LEAVE, + PresenceStatePresenceEvent.APPROACHING, + PresenceStatePresenceEvent.ABSENTING, +} async def async_setup_events(gateway: DeconzGateway) -> None: @@ -43,7 +55,7 @@ async def async_setup_events(gateway: DeconzGateway) -> None: @callback def async_add_sensor(_: EventType, sensor_id: str) -> None: """Create DeconzEvent.""" - new_event: DeconzAlarmEvent | DeconzEvent + new_event: DeconzAlarmEvent | DeconzEvent | DeconzPresenceEvent sensor = gateway.api.sensors[sensor_id] if isinstance(sensor, Switch): @@ -52,6 +64,11 @@ async def async_setup_events(gateway: DeconzGateway) -> None: elif isinstance(sensor, AncillaryControl): new_event = DeconzAlarmEvent(sensor, gateway) + elif isinstance(sensor, Presence): + if sensor.presence_event is None: + return + new_event = DeconzPresenceEvent(sensor, gateway) + gateway.hass.async_create_task(new_event.async_update_device_registry()) gateway.events.append(new_event) @@ -63,6 +80,10 @@ async def async_setup_events(gateway: DeconzGateway) -> None: async_add_sensor, gateway.api.sensors.ancillary_control, ) + gateway.register_platform_add_device_callback( + async_add_sensor, + gateway.api.sensors.presence, + ) @callback @@ -83,7 +104,7 @@ class DeconzEventBase(DeconzBase): def __init__( self, - device: AncillaryControl | Switch, + device: AncillaryControl | Presence | Switch, gateway: DeconzGateway, ) -> None: """Register callback that will be used for signals.""" @@ -181,3 +202,28 @@ class DeconzAlarmEvent(DeconzEventBase): } self.gateway.hass.bus.async_fire(CONF_DECONZ_ALARM_EVENT, data) + + +class DeconzPresenceEvent(DeconzEventBase): + """Presence event.""" + + _device: Presence + + @callback + def async_update_callback(self) -> None: + """Fire the event if reason is new action is updated.""" + if ( + self.gateway.ignore_state_updates + or "presenceevent" not in self._device.changed_keys + or self._device.presence_event not in SUPPORTED_DECONZ_PRESENCE_EVENTS + ): + return + + data = { + CONF_ID: self.event_id, + CONF_UNIQUE_ID: self.serial, + CONF_DEVICE_ID: self.device_id, + CONF_EVENT: self._device.presence_event.value, + } + + self.gateway.hass.bus.async_fire(CONF_DECONZ_PRESENCE_EVENT, data) diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index 601fe95616b..e4d9a818a4e 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -22,7 +22,13 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import DOMAIN -from .deconz_event import CONF_DECONZ_EVENT, CONF_GESTURE, DeconzAlarmEvent, DeconzEvent +from .deconz_event import ( + CONF_DECONZ_EVENT, + CONF_GESTURE, + DeconzAlarmEvent, + DeconzEvent, + DeconzPresenceEvent, +) from .gateway import DeconzGateway CONF_SUBTYPE = "subtype" @@ -622,7 +628,7 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( def _get_deconz_event_from_device( hass: HomeAssistant, device: dr.DeviceEntry, -) -> DeconzAlarmEvent | DeconzEvent: +) -> DeconzAlarmEvent | DeconzEvent | DeconzPresenceEvent: """Resolve deconz event from device.""" gateways: dict[str, DeconzGateway] = hass.data.get(DOMAIN, {}) for gateway in gateways.values(): diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 6f29cef5190..1c381bc194a 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -41,7 +41,7 @@ from .const import ( from .errors import AuthenticationRequired, CannotConnect if TYPE_CHECKING: - from .deconz_event import DeconzAlarmEvent, DeconzEvent + from .deconz_event import DeconzAlarmEvent, DeconzEvent, DeconzPresenceEvent SENSORS = ( sensors.SensorResourceManager, @@ -93,7 +93,7 @@ class DeconzGateway: self.deconz_ids: dict[str, str] = {} self.entities: dict[str, set[str]] = {} - self.events: list[DeconzAlarmEvent | DeconzEvent] = [] + self.events: list[DeconzAlarmEvent | DeconzEvent | DeconzPresenceEvent] = [] self.clip_sensors: set[tuple[Callable[[EventType, str], None], str]] = set() self.deconz_groups: set[tuple[Callable[[EventType, str], None], str]] = set() self.ignored_devices: set[tuple[Callable[[EventType, str], None], str]] = set() diff --git a/homeassistant/components/deconz/select.py b/homeassistant/components/deconz/select.py new file mode 100644 index 00000000000..8027a4aa822 --- /dev/null +++ b/homeassistant/components/deconz/select.py @@ -0,0 +1,143 @@ +"""Support for deCONZ select entities.""" + +from __future__ import annotations + +from pydeconz.models.event import EventType +from pydeconz.models.sensor.presence import ( + Presence, + PresenceConfigDeviceMode, + PresenceConfigSensitivity, + PresenceConfigTriggerDistance, +) + +from homeassistant.components.select import DOMAIN, SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .deconz_device import DeconzDevice +from .gateway import get_gateway_from_config_entry + +SENSITIVITY_TO_DECONZ = { + "High": PresenceConfigSensitivity.HIGH.value, + "Medium": PresenceConfigSensitivity.MEDIUM.value, + "Low": PresenceConfigSensitivity.LOW.value, +} +DECONZ_TO_SENSITIVITY = {value: key for key, value in SENSITIVITY_TO_DECONZ.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the deCONZ button entity.""" + gateway = get_gateway_from_config_entry(hass, config_entry) + gateway.entities[DOMAIN] = set() + + @callback + def async_add_presence_sensor(_: EventType, sensor_id: str) -> None: + """Add presence select entity from deCONZ.""" + sensor = gateway.api.sensors.presence[sensor_id] + if sensor.presence_event is not None: + async_add_entities( + [ + DeconzPresenceDeviceModeSelect(sensor, gateway), + DeconzPresenceSensitivitySelect(sensor, gateway), + DeconzPresenceTriggerDistanceSelect(sensor, gateway), + ] + ) + + gateway.register_platform_add_device_callback( + async_add_presence_sensor, + gateway.api.sensors.presence, + ) + + +class DeconzPresenceDeviceModeSelect(DeconzDevice[Presence], SelectEntity): + """Representation of a deCONZ presence device mode entity.""" + + _name_suffix = "Device Mode" + unique_id_suffix = "device_mode" + _update_key = "devicemode" + + _attr_entity_category = EntityCategory.CONFIG + _attr_options = [ + PresenceConfigDeviceMode.LEFT_AND_RIGHT.value, + PresenceConfigDeviceMode.UNDIRECTED.value, + ] + + TYPE = DOMAIN + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + if self._device.device_mode is not None: + return self._device.device_mode.value + return None + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.gateway.api.sensors.presence.set_config( + id=self._device.resource_id, + device_mode=PresenceConfigDeviceMode(option), + ) + + +class DeconzPresenceSensitivitySelect(DeconzDevice[Presence], SelectEntity): + """Representation of a deCONZ presence sensitivity entity.""" + + _name_suffix = "Sensitivity" + unique_id_suffix = "sensitivity" + _update_key = "sensitivity" + + _attr_entity_category = EntityCategory.CONFIG + _attr_options = list(SENSITIVITY_TO_DECONZ) + + TYPE = DOMAIN + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + if self._device.sensitivity is not None: + return DECONZ_TO_SENSITIVITY[self._device.sensitivity] + return None + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.gateway.api.sensors.presence.set_config( + id=self._device.resource_id, + sensitivity=SENSITIVITY_TO_DECONZ[option], + ) + + +class DeconzPresenceTriggerDistanceSelect(DeconzDevice[Presence], SelectEntity): + """Representation of a deCONZ presence trigger distance entity.""" + + _name_suffix = "Trigger Distance" + unique_id_suffix = "trigger_distance" + _update_key = "triggerdistance" + + _attr_entity_category = EntityCategory.CONFIG + _attr_options = [ + PresenceConfigTriggerDistance.FAR.value, + PresenceConfigTriggerDistance.MEDIUM.value, + PresenceConfigTriggerDistance.NEAR.value, + ] + + TYPE = DOMAIN + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + if self._device.trigger_distance is not None: + return self._device.trigger_distance.value + return None + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.gateway.api.sensors.presence.set_config( + id=self._device.resource_id, + trigger_distance=PresenceConfigTriggerDistance(option), + ) diff --git a/tests/components/deconz/test_button.py b/tests/components/deconz/test_button.py index 804a93d5ea4..c3cdf8160c8 100644 --- a/tests/components/deconz/test_button.py +++ b/tests/components/deconz/test_button.py @@ -48,6 +48,49 @@ TEST_DATA = [ "friendly_name": "Light group Scene Store Current Scene", }, "request": "/groups/1/scenes/1/store", + "request_data": {}, + }, + ), + ( # Presence reset button + { + "sensors": { + "1": { + "config": { + "devicemode": "undirected", + "on": True, + "reachable": True, + "sensitivity": 3, + "triggerdistance": "medium", + }, + "etag": "13ff209f9401b317987d42506dd4cd79", + "lastannounced": None, + "lastseen": "2022-06-28T23:13Z", + "manufacturername": "aqara", + "modelid": "lumi.motion.ac01", + "name": "Aqara FP1", + "state": { + "lastupdated": "2022-06-28T23:13:38.577", + "presence": True, + "presenceevent": "leave", + }, + "swversion": "20210121", + "type": "ZHAPresence", + "uniqueid": "xx:xx:xx:xx:xx:xx:xx:xx-01-0406", + } + } + }, + { + "entity_count": 5, + "device_count": 3, + "entity_id": "button.aqara_fp1_reset_presence", + "unique_id": "xx:xx:xx:xx:xx:xx:xx:xx-01-0406-reset_presence", + "entity_category": EntityCategory.CONFIG, + "attributes": { + "device_class": "restart", + "friendly_name": "Aqara FP1 Reset Presence", + }, + "request": "/sensors/1/config", + "request_data": {"resetpresence": True}, }, ), ] @@ -92,7 +135,7 @@ async def test_button(hass, aioclient_mock, raw_data, expected): {ATTR_ENTITY_ID: expected["entity_id"]}, blocking=True, ) - assert aioclient_mock.mock_calls[1][2] == {} + assert aioclient_mock.mock_calls[1][2] == expected["request_data"] # Unload entry diff --git a/tests/components/deconz/test_deconz_event.py b/tests/components/deconz/test_deconz_event.py index c326892aef2..0d99d33e571 100644 --- a/tests/components/deconz/test_deconz_event.py +++ b/tests/components/deconz/test_deconz_event.py @@ -6,11 +6,13 @@ from pydeconz.models.sensor.ancillary_control import ( AncillaryControlAction, AncillaryControlPanel, ) +from pydeconz.models.sensor.presence import PresenceStatePresenceEvent from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN from homeassistant.components.deconz.deconz_event import ( CONF_DECONZ_ALARM_EVENT, CONF_DECONZ_EVENT, + CONF_DECONZ_PRESENCE_EVENT, ) from homeassistant.const import ( CONF_DEVICE_ID, @@ -412,6 +414,107 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): assert len(hass.states.async_all()) == 0 +async def test_deconz_presence_events(hass, aioclient_mock, mock_deconz_websocket): + """Test successful creation of deconz presence events.""" + data = { + "sensors": { + "1": { + "config": { + "devicemode": "undirected", + "on": True, + "reachable": True, + "sensitivity": 3, + "triggerdistance": "medium", + }, + "etag": "13ff209f9401b317987d42506dd4cd79", + "lastannounced": None, + "lastseen": "2022-06-28T23:13Z", + "manufacturername": "aqara", + "modelid": "lumi.motion.ac01", + "name": "Aqara FP1", + "state": { + "lastupdated": "2022-06-28T23:13:38.577", + "presence": True, + "presenceevent": "leave", + }, + "swversion": "20210121", + "type": "ZHAPresence", + "uniqueid": "xx:xx:xx:xx:xx:xx:xx:xx-01-0406", + } + } + } + with patch.dict(DECONZ_WEB_REQUEST, data): + config_entry = await setup_deconz_integration(hass, aioclient_mock) + + device_registry = dr.async_get(hass) + + assert len(hass.states.async_all()) == 5 + assert ( + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) + == 3 + ) + + device = device_registry.async_get_device( + identifiers={(DECONZ_DOMAIN, "xx:xx:xx:xx:xx:xx:xx:xx")} + ) + + captured_events = async_capture_events(hass, CONF_DECONZ_PRESENCE_EVENT) + + for presence_event in ( + PresenceStatePresenceEvent.ABSENTING, + PresenceStatePresenceEvent.APPROACHING, + PresenceStatePresenceEvent.ENTER, + PresenceStatePresenceEvent.ENTER_LEFT, + PresenceStatePresenceEvent.ENTER_RIGHT, + PresenceStatePresenceEvent.LEAVE, + PresenceStatePresenceEvent.LEFT_LEAVE, + PresenceStatePresenceEvent.RIGHT_LEAVE, + ): + event_changed_sensor = { + "t": "event", + "e": "changed", + "r": "sensors", + "id": "1", + "state": {"presenceevent": presence_event}, + } + await mock_deconz_websocket(data=event_changed_sensor) + await hass.async_block_till_done() + + assert len(captured_events) == 1 + assert captured_events[0].data == { + CONF_ID: "aqara_fp1", + CONF_UNIQUE_ID: "xx:xx:xx:xx:xx:xx:xx:xx", + CONF_DEVICE_ID: device.id, + CONF_EVENT: presence_event.value, + } + captured_events.clear() + + # Unsupported presence event + + event_changed_sensor = { + "t": "event", + "e": "changed", + "r": "sensors", + "id": "1", + "state": {"presenceevent": PresenceStatePresenceEvent.NINE}, + } + await mock_deconz_websocket(data=event_changed_sensor) + await hass.async_block_till_done() + + assert len(captured_events) == 0 + + await hass.config_entries.async_unload(config_entry.entry_id) + + states = hass.states.async_all() + assert len(hass.states.async_all()) == 5 + for state in states: + assert state.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 + + async def test_deconz_events_bad_unique_id(hass, aioclient_mock): """Verify no devices are created if unique id is bad or missing.""" data = { diff --git a/tests/components/deconz/test_diagnostics.py b/tests/components/deconz/test_diagnostics.py index 459e0e910ab..45298ca090d 100644 --- a/tests/components/deconz/test_diagnostics.py +++ b/tests/components/deconz/test_diagnostics.py @@ -57,6 +57,7 @@ async def test_entry_diagnostics( str(Platform.LOCK): [], str(Platform.NUMBER): [], str(Platform.SCENE): [], + str(Platform.SELECT): [], str(Platform.SENSOR): [], str(Platform.SIREN): [], str(Platform.SWITCH): [], diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py index 9471752eb8d..c69c51d13a6 100644 --- a/tests/components/deconz/test_gateway.py +++ b/tests/components/deconz/test_gateway.py @@ -28,6 +28,7 @@ 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.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN from homeassistant.components.ssdp import ( @@ -169,9 +170,10 @@ async def test_gateway_setup(hass, aioclient_mock): assert forward_entry_setup.mock_calls[7][1] == (config_entry, LOCK_DOMAIN) assert forward_entry_setup.mock_calls[8][1] == (config_entry, NUMBER_DOMAIN) assert forward_entry_setup.mock_calls[9][1] == (config_entry, SCENE_DOMAIN) - assert forward_entry_setup.mock_calls[10][1] == (config_entry, SENSOR_DOMAIN) - assert forward_entry_setup.mock_calls[11][1] == (config_entry, SIREN_DOMAIN) - assert forward_entry_setup.mock_calls[12][1] == (config_entry, SWITCH_DOMAIN) + assert forward_entry_setup.mock_calls[10][1] == (config_entry, SELECT_DOMAIN) + assert forward_entry_setup.mock_calls[11][1] == (config_entry, SENSOR_DOMAIN) + assert forward_entry_setup.mock_calls[12][1] == (config_entry, SIREN_DOMAIN) + assert forward_entry_setup.mock_calls[13][1] == (config_entry, SWITCH_DOMAIN) device_registry = dr.async_get(hass) gateway_entry = device_registry.async_get_device( diff --git a/tests/components/deconz/test_select.py b/tests/components/deconz/test_select.py new file mode 100644 index 00000000000..c23f08794a9 --- /dev/null +++ b/tests/components/deconz/test_select.py @@ -0,0 +1,219 @@ +"""deCONZ select platform tests.""" + +from unittest.mock import patch + +from pydeconz.models.sensor.presence import ( + PresenceConfigDeviceMode, + PresenceConfigTriggerDistance, +) +import pytest + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory + +from .test_gateway import ( + DECONZ_WEB_REQUEST, + mock_deconz_put_request, + setup_deconz_integration, +) + + +async def test_no_select_entities(hass, aioclient_mock): + """Test that no sensors in deconz results in no sensor entities.""" + await setup_deconz_integration(hass, aioclient_mock) + assert len(hass.states.async_all()) == 0 + + +TEST_DATA = [ + ( # Presence Device Mode + { + "sensors": { + "1": { + "config": { + "devicemode": "undirected", + "on": True, + "reachable": True, + "sensitivity": 3, + "triggerdistance": "medium", + }, + "etag": "13ff209f9401b317987d42506dd4cd79", + "lastannounced": None, + "lastseen": "2022-06-28T23:13Z", + "manufacturername": "aqara", + "modelid": "lumi.motion.ac01", + "name": "Aqara FP1", + "state": { + "lastupdated": "2022-06-28T23:13:38.577", + "presence": True, + "presenceevent": "leave", + }, + "swversion": "20210121", + "type": "ZHAPresence", + "uniqueid": "xx:xx:xx:xx:xx:xx:xx:xx-01-0406", + } + } + }, + { + "entity_count": 5, + "device_count": 3, + "entity_id": "select.aqara_fp1_device_mode", + "unique_id": "xx:xx:xx:xx:xx:xx:xx:xx-01-0406-device_mode", + "entity_category": EntityCategory.CONFIG, + "attributes": { + "friendly_name": "Aqara FP1 Device Mode", + "options": ["leftright", "undirected"], + }, + "option": PresenceConfigDeviceMode.LEFT_AND_RIGHT.value, + "request": "/sensors/1/config", + "request_data": {"devicemode": "leftright"}, + }, + ), + ( # Presence Sensitivity + { + "sensors": { + "1": { + "config": { + "devicemode": "undirected", + "on": True, + "reachable": True, + "sensitivity": 3, + "triggerdistance": "medium", + }, + "etag": "13ff209f9401b317987d42506dd4cd79", + "lastannounced": None, + "lastseen": "2022-06-28T23:13Z", + "manufacturername": "aqara", + "modelid": "lumi.motion.ac01", + "name": "Aqara FP1", + "state": { + "lastupdated": "2022-06-28T23:13:38.577", + "presence": True, + "presenceevent": "leave", + }, + "swversion": "20210121", + "type": "ZHAPresence", + "uniqueid": "xx:xx:xx:xx:xx:xx:xx:xx-01-0406", + } + } + }, + { + "entity_count": 5, + "device_count": 3, + "entity_id": "select.aqara_fp1_sensitivity", + "unique_id": "xx:xx:xx:xx:xx:xx:xx:xx-01-0406-sensitivity", + "entity_category": EntityCategory.CONFIG, + "attributes": { + "friendly_name": "Aqara FP1 Sensitivity", + "options": ["High", "Medium", "Low"], + }, + "option": "Medium", + "request": "/sensors/1/config", + "request_data": {"sensitivity": 2}, + }, + ), + ( # Presence Trigger Distance + { + "sensors": { + "1": { + "config": { + "devicemode": "undirected", + "on": True, + "reachable": True, + "sensitivity": 3, + "triggerdistance": "medium", + }, + "etag": "13ff209f9401b317987d42506dd4cd79", + "lastannounced": None, + "lastseen": "2022-06-28T23:13Z", + "manufacturername": "aqara", + "modelid": "lumi.motion.ac01", + "name": "Aqara FP1", + "state": { + "lastupdated": "2022-06-28T23:13:38.577", + "presence": True, + "presenceevent": "leave", + }, + "swversion": "20210121", + "type": "ZHAPresence", + "uniqueid": "xx:xx:xx:xx:xx:xx:xx:xx-01-0406", + } + } + }, + { + "entity_count": 5, + "device_count": 3, + "entity_id": "select.aqara_fp1_trigger_distance", + "unique_id": "xx:xx:xx:xx:xx:xx:xx:xx-01-0406-trigger_distance", + "entity_category": EntityCategory.CONFIG, + "attributes": { + "friendly_name": "Aqara FP1 Trigger Distance", + "options": ["far", "medium", "near"], + }, + "option": PresenceConfigTriggerDistance.FAR.value, + "request": "/sensors/1/config", + "request_data": {"triggerdistance": "far"}, + }, + ), +] + + +@pytest.mark.parametrize("raw_data, expected", TEST_DATA) +async def test_select(hass, aioclient_mock, raw_data, expected): + """Test successful creation of button entities.""" + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + + with patch.dict(DECONZ_WEB_REQUEST, raw_data): + config_entry = await setup_deconz_integration(hass, aioclient_mock) + + assert len(hass.states.async_all()) == expected["entity_count"] + + # Verify state data + + button = hass.states.get(expected["entity_id"]) + assert button.attributes == expected["attributes"] + + # Verify entity registry data + + 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 data + + assert ( + len(dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)) + == expected["device_count"] + ) + + # Verify selecting option + + mock_deconz_put_request(aioclient_mock, config_entry.data, expected["request"]) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: expected["entity_id"], + ATTR_OPTION: expected["option"], + }, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == expected["request_data"] + + # Unload entry + + await hass.config_entries.async_unload(config_entry.entry_id) + assert hass.states.get(expected["entity_id"]).state == STATE_UNAVAILABLE + + # Remove entry + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0