diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index bbd03b070a4..decbc8bb523 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -13,6 +13,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.IMAGE, + Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, diff --git a/homeassistant/components/unifi/light.py b/homeassistant/components/unifi/light.py new file mode 100644 index 00000000000..9327dcc160e --- /dev/null +++ b/homeassistant/components/unifi/light.py @@ -0,0 +1,172 @@ +"""Light platform for UniFi Network integration.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, cast + +from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent +from aiounifi.interfaces.devices import Devices +from aiounifi.models.api import ApiItem +from aiounifi.models.device import Device, DeviceSetLedStatus + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_RGB_COLOR, + ColorMode, + LightEntity, + LightEntityDescription, + LightEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.color import rgb_hex_to_rgb_list + +from . import UnifiConfigEntry +from .entity import ( + UnifiEntity, + UnifiEntityDescription, + async_device_available_fn, + async_device_device_info_fn, +) + +if TYPE_CHECKING: + from .hub import UnifiHub + + +@callback +def async_device_led_supported_fn(hub: UnifiHub, obj_id: str) -> bool: + """Check if device supports LED control.""" + device: Device = hub.api.devices[obj_id] + return device.supports_led_ring + + +@callback +def async_device_led_is_on_fn(hub: UnifiHub, device: Device) -> bool: + """Check if device LED is on.""" + return device.led_override == "on" + + +async def async_device_led_control_fn( + hub: UnifiHub, obj_id: str, turn_on: bool, **kwargs: Any +) -> None: + """Control device LED.""" + device = hub.api.devices[obj_id] + + status = "on" if turn_on else "off" + + brightness = ( + int((kwargs[ATTR_BRIGHTNESS] / 255) * 100) + if ATTR_BRIGHTNESS in kwargs + else device.led_override_color_brightness + ) + + color = ( + f"#{kwargs[ATTR_RGB_COLOR][0]:02x}{kwargs[ATTR_RGB_COLOR][1]:02x}{kwargs[ATTR_RGB_COLOR][2]:02x}" + if ATTR_RGB_COLOR in kwargs + else device.led_override_color + ) + + await hub.api.request( + DeviceSetLedStatus.create( + device=device, + status=status, + brightness=brightness, + color=color, + ) + ) + + +@dataclass(frozen=True, kw_only=True) +class UnifiLightEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( + LightEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT] +): + """Class describing UniFi light entity.""" + + control_fn: Callable[[UnifiHub, str, bool], Coroutine[Any, Any, None]] + is_on_fn: Callable[[UnifiHub, ApiItemT], bool] + + +ENTITY_DESCRIPTIONS: tuple[UnifiLightEntityDescription, ...] = ( + UnifiLightEntityDescription[Devices, Device]( + key="LED control", + translation_key="led_control", + allowed_fn=lambda hub, obj_id: True, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + control_fn=async_device_led_control_fn, + device_info_fn=async_device_device_info_fn, + is_on_fn=async_device_led_is_on_fn, + name_fn=lambda device: "LED", + object_fn=lambda api, obj_id: api.devices[obj_id], + supported_fn=async_device_led_supported_fn, + unique_id_fn=lambda hub, obj_id: f"led-{obj_id}", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: UnifiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up lights for UniFi Network integration.""" + config_entry.runtime_data.entity_loader.register_platform( + async_add_entities, + UnifiLightEntity, + ENTITY_DESCRIPTIONS, + requires_admin=True, + ) + + +class UnifiLightEntity[HandlerT: APIHandler, ApiItemT: ApiItem]( + UnifiEntity[HandlerT, ApiItemT], LightEntity +): + """Base representation of a UniFi light.""" + + entity_description: UnifiLightEntityDescription[HandlerT, ApiItemT] + _attr_supported_features = LightEntityFeature(0) + _attr_color_mode = ColorMode.RGB + _attr_supported_color_modes = {ColorMode.RGB} + + @callback + def async_initiate_state(self) -> None: + """Initiate entity state.""" + self.async_update_state(ItemEvent.ADDED, self._obj_id) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on light.""" + await self.entity_description.control_fn(self.hub, self._obj_id, True, **kwargs) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off light.""" + await self.entity_description.control_fn( + self.hub, self._obj_id, False, **kwargs + ) + + @callback + def async_update_state(self, event: ItemEvent, obj_id: str) -> None: + """Update entity state.""" + description = self.entity_description + device_obj = description.object_fn(self.api, self._obj_id) + + device = cast(Device, device_obj) + + self._attr_is_on = description.is_on_fn(self.hub, device_obj) + + brightness = device.led_override_color_brightness + self._attr_brightness = ( + int((int(brightness) / 100) * 255) if brightness is not None else None + ) + + hex_color = ( + device.led_override_color.lstrip("#") + if self._attr_is_on and device.led_override_color + else None + ) + if hex_color and len(hex_color) == 6: + rgb_list = rgb_hex_to_rgb_list(hex_color) + self._attr_rgb_color = (rgb_list[0], rgb_list[1], rgb_list[2]) + else: + self._attr_rgb_color = None diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 5b88055e62a..3e357ab645f 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -34,6 +34,11 @@ } }, "entity": { + "light": { + "led_control": { + "name": "LED" + } + }, "sensor": { "device_state": { "state": { diff --git a/tests/components/unifi/snapshots/test_light.ambr b/tests/components/unifi/snapshots/test_light.ambr new file mode 100644 index 00000000000..fc9d972f9be --- /dev/null +++ b/tests/components/unifi/snapshots/test_light.ambr @@ -0,0 +1,72 @@ +# serializer version: 1 +# name: test_light_platform_snapshot[device_payload0][light.device_with_led_led-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.device_with_led_led', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED', + 'platform': 'unifi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'led_control', + 'unique_id': 'led-10:00:00:00:01:01', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_platform_snapshot[device_payload0][light.device_with_led_led-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 204, + 'color_mode': , + 'friendly_name': 'Device with LED LED', + 'hs_color': tuple( + 240.0, + 100.0, + ), + 'rgb_color': tuple( + 0, + 0, + 255, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.136, + 0.04, + ), + }), + 'context': , + 'entity_id': 'light.device_with_led_led', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/unifi/test_hub.py b/tests/components/unifi/test_hub.py index 897eab2ae12..78f9d484619 100644 --- a/tests/components/unifi/test_hub.py +++ b/tests/components/unifi/test_hub.py @@ -41,6 +41,7 @@ async def test_hub_setup( Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.IMAGE, + Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, diff --git a/tests/components/unifi/test_light.py b/tests/components/unifi/test_light.py new file mode 100644 index 00000000000..6ee40c9a91d --- /dev/null +++ b/tests/components/unifi/test_light.py @@ -0,0 +1,323 @@ +"""UniFi Network light platform tests.""" + +from copy import deepcopy +from unittest.mock import patch + +from aiounifi.models.message import MessageKey +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_RGB_COLOR, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.unifi.const import CONF_SITE_ID +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import ( + ConfigEntryFactoryType, + WebsocketMessageMock, + WebsocketStateManager, +) + +from tests.common import MockConfigEntry, snapshot_platform +from tests.test_util.aiohttp import AiohttpClientMocker + +DEVICE_WITH_LED = { + "board_rev": 3, + "device_id": "mock-id", + "ip": "10.0.0.1", + "last_seen": 1562600145, + "mac": "10:00:00:00:01:01", + "model": "U6-Lite", + "name": "Device with LED", + "next_interval": 20, + "state": 1, + "type": "uap", + "version": "4.0.42.10433", + "led_override": "on", + "led_override_color": "#0000ff", + "led_override_color_brightness": 80, + "hw_caps": 2, +} + +DEVICE_WITHOUT_LED = { + "board_rev": 2, + "device_id": "mock-id-2", + "ip": "10.0.0.2", + "last_seen": 1562600145, + "mac": "10:00:00:00:01:02", + "model": "US-8-60W", + "name": "Device without LED", + "next_interval": 20, + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + "hw_caps": 0, +} + +DEVICE_LED_OFF = { + "board_rev": 3, + "device_id": "mock-id-3", + "ip": "10.0.0.3", + "last_seen": 1562600145, + "mac": "10:00:00:00:01:03", + "model": "U6-Pro", + "name": "Device LED Off", + "next_interval": 20, + "state": 1, + "type": "uap", + "version": "4.0.42.10433", + "led_override": "off", + "led_override_color": "#ffffff", + "led_override_color_brightness": 0, + "hw_caps": 2, +} + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED, DEVICE_WITHOUT_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_lights( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, +) -> None: + """Test lights.""" + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 + + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_ON + assert light_entity.attributes["brightness"] == 204 + assert light_entity.attributes["rgb_color"] == (0, 0, 255) + + assert hass.states.get("light.device_without_led_led") is None + + +@pytest.mark.parametrize("device_payload", [[DEVICE_LED_OFF]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_off_state( + hass: HomeAssistant, +) -> None: + """Test light off state.""" + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 + + light_entity = hass.states.get("light.device_led_off_led") + assert light_entity is not None + assert light_entity.state == STATE_OFF + assert light_entity.attributes.get("brightness") is None + assert light_entity.attributes.get("rgb_color") is None + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_turn_on_off( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, +) -> None: + """Test turn on and off.""" + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/device/mock-id", + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.device_with_led_led"}, + blocking=True, + ) + + assert aioclient_mock.call_count == 1 + call_data = aioclient_mock.mock_calls[0][2] + assert call_data["led_override"] == "off" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.device_with_led_led"}, + blocking=True, + ) + + assert aioclient_mock.call_count == 2 + call_data = aioclient_mock.mock_calls[1][2] + assert call_data["led_override"] == "on" + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_set_brightness( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, +) -> None: + """Test set brightness.""" + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/device/mock-id", + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.device_with_led_led", + ATTR_BRIGHTNESS: 127, + }, + blocking=True, + ) + + assert aioclient_mock.call_count == 1 + call_data = aioclient_mock.mock_calls[0][2] + assert call_data["led_override"] == "on" + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_set_rgb_color( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, +) -> None: + """Test set RGB color.""" + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/device/mock-id", + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.device_with_led_led", + ATTR_RGB_COLOR: (255, 0, 0), + }, + blocking=True, + ) + + assert aioclient_mock.call_count == 1 + call_data = aioclient_mock.mock_calls[0][2] + assert call_data["led_override"] == "on" + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_set_brightness_and_color( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, +) -> None: + """Test set brightness and color.""" + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/device/mock-id", + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.device_with_led_led", + ATTR_RGB_COLOR: (0, 255, 0), + ATTR_BRIGHTNESS: 191, + }, + blocking=True, + ) + + assert aioclient_mock.call_count == 1 + call_data = aioclient_mock.mock_calls[0][2] + assert call_data["led_override"] == "on" + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_state_update_via_websocket( + hass: HomeAssistant, + mock_websocket_message: WebsocketMessageMock, +) -> None: + """Test state update via websocket.""" + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_ON + assert light_entity.attributes["rgb_color"] == (0, 0, 255) + updated_device = deepcopy(DEVICE_WITH_LED) + updated_device["led_override"] = "off" + updated_device["led_override_color"] = "#ff0000" + updated_device["led_override_color_brightness"] = 100 + + mock_websocket_message(message=MessageKey.DEVICE, data=[updated_device]) + await hass.async_block_till_done() + + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_OFF + assert light_entity.attributes.get("rgb_color") is None + assert light_entity.attributes.get("brightness") is None + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_device_offline( + hass: HomeAssistant, + mock_websocket_message: WebsocketMessageMock, +) -> None: + """Test device offline.""" + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 + assert hass.states.get("light.device_with_led_led") is not None + + offline_device = deepcopy(DEVICE_WITH_LED) + offline_device["state"] = 0 + mock_websocket_message(message=MessageKey.DEVICE, data=[offline_device]) + await hass.async_block_till_done() + + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_ON + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_device_unavailable( + hass: HomeAssistant, + mock_websocket_state: WebsocketStateManager, +) -> None: + """Test device unavailable.""" + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_ON + + updated_device = deepcopy(DEVICE_WITH_LED) + updated_device["state"] = 0 + + await mock_websocket_state.disconnect() + await hass.async_block_till_done() + + light_entity = hass.states.get("light.device_with_led_led") + assert light_entity is not None + assert light_entity.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED]]) +async def test_light_platform_snapshot( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry_factory: ConfigEntryFactoryType, + snapshot: SnapshotAssertion, +) -> None: + """Test platform snapshot.""" + with patch("homeassistant.components.unifi.PLATFORMS", [Platform.LIGHT]): + config_entry = await config_entry_factory() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)