diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index a764a20c39c..cc3d842840c 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -41,4 +41,4 @@ DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT} MIN_REQUIRED_PROTECT_V = Version("1.20.0") OUTDATED_LOG_MESSAGE = "You are running v%s of UniFi Protect. Minimum required version is v%s. Please upgrade UniFi Protect and then retry" -PLATFORMS = [Platform.BUTTON, Platform.CAMERA, Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.BUTTON, Platform.CAMERA, Platform.LIGHT, Platform.MEDIA_PLAYER] diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py new file mode 100644 index 00000000000..20479a5d600 --- /dev/null +++ b/homeassistant/components/unifiprotect/light.py @@ -0,0 +1,89 @@ +"""This component provides Lights for UniFi Protect.""" +from __future__ import annotations + +import logging +from typing import Any + +from pyunifiprotect.data import Light + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .data import ProtectData +from .entity import ProtectDeviceEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up lights for UniFi Protect integration.""" + data: ProtectData = hass.data[DOMAIN][entry.entry_id] + entities = [ + ProtectLight( + data, + device, + ) + for device in data.api.bootstrap.lights.values() + ] + + if not entities: + return + + async_add_entities(entities) + + +def unifi_brightness_to_hass(value: int) -> int: + """Convert unifi brightness 1..6 to hass format 0..255.""" + return min(255, round((value / 6) * 255)) + + +def hass_to_unifi_brightness(value: int) -> int: + """Convert hass brightness 0..255 to unifi 1..6 scale.""" + return max(1, round((value / 255) * 6)) + + +class ProtectLight(ProtectDeviceEntity, LightEntity): + """A Ubiquiti UniFi Protect Light Entity.""" + + def __init__( + self, + data: ProtectData, + device: Light, + ) -> None: + """Initialize an UniFi light.""" + self.device: Light = device + super().__init__(data) + self._attr_icon = "mdi:spotlight-beam" + self._attr_supported_features = SUPPORT_BRIGHTNESS + + @callback + def _async_update_device_from_protect(self) -> None: + super()._async_update_device_from_protect() + self._attr_is_on = self.device.is_light_on + self._attr_brightness = unifi_brightness_to_hass( + self.device.light_device_settings.led_level + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + hass_brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness) + unifi_brightness = hass_to_unifi_brightness(hass_brightness) + + _LOGGER.debug("Turning on light with brightness %s", unifi_brightness) + await self.device.set_light(True, unifi_brightness) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + _LOGGER.debug("Turning off light") + await self.device.set_light(False) diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index cbc3dff5431..b2f3315e095 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -10,8 +10,7 @@ from typing import Any, Callable from unittest.mock import AsyncMock, Mock, patch import pytest -from pyunifiprotect.data import Camera, Version -from pyunifiprotect.data.websocket import WSSubscriptionMessage +from pyunifiprotect.data import Camera, Light, Version, WSSubscriptionMessage from homeassistant.components.unifiprotect.const import DOMAIN, MIN_REQUIRED_PROTECT_V from homeassistant.core import HomeAssistant @@ -131,6 +130,17 @@ def mock_camera(): yield Camera.from_unifi_dict(**data) +@pytest.fixture +def mock_light(): + """Mock UniFi Protect Camera device.""" + + path = Path(__file__).parent / "sample_data" / "sample_light.json" + with open(path, encoding="utf-8") as json_file: + data = json.load(json_file) + + yield Light.from_unifi_dict(**data) + + async def time_changed(hass: HomeAssistant, seconds: int) -> None: """Trigger time changed.""" next_update = dt_util.utcnow() + timedelta(seconds) diff --git a/tests/components/unifiprotect/sample_data/sample_light.json b/tests/components/unifiprotect/sample_data/sample_light.json new file mode 100644 index 00000000000..599c26f4f0c --- /dev/null +++ b/tests/components/unifiprotect/sample_data/sample_light.json @@ -0,0 +1,53 @@ +{ + "mac": "D7F1C8D3FCDD", + "host": "192.168.10.86", + "connectionHost": "192.168.178.217", + "type": "UP FloodLight", + "name": "Byyfbpe Ufoka", + "upSince": 1638128991022, + "uptime": 1894890, + "lastSeen": 1640023881022, + "connectedSince": 1640020579711, + "state": "CONNECTED", + "hardwareRevision": null, + "firmwareVersion": "1.9.3", + "latestFirmwareVersion": "1.9.3", + "firmwareBuild": "g990c553.211105.251", + "isUpdating": false, + "isAdopting": false, + "isAdopted": true, + "isAdoptedByOther": false, + "isProvisioned": false, + "isRebooting": false, + "isSshEnabled": true, + "canAdopt": false, + "isAttemptingToConnect": false, + "isPirMotionDetected": false, + "lastMotion": 1640022006069, + "isDark": false, + "isLightOn": false, + "isLocating": false, + "wiredConnectionState": { + "phyRate": 100 + }, + "lightDeviceSettings": { + "isIndicatorEnabled": true, + "ledLevel": 6, + "luxSensitivity": "medium", + "pirDuration": 120000, + "pirSensitivity": 46 + }, + "lightOnSettings": { + "isLedForceOn": false + }, + "lightModeSettings": { + "mode": "off", + "enableAt": "fulltime" + }, + "camera": "193be66559c03ec5629f54cd", + "id": "37dd610720816cfb5c547967", + "isConnected": true, + "isCameraPaired": true, + "marketName": "UP FloodLight", + "modelKey": "light" +} diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py new file mode 100644 index 00000000000..c26b00f6da4 --- /dev/null +++ b/tests/components/unifiprotect/test_light.py @@ -0,0 +1,141 @@ +"""Test the UniFi Protect light platform.""" +# pylint: disable=protected-access +from __future__ import annotations + +from copy import copy +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from pyunifiprotect.data import Light + +from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import MockEntityFixture + + +@pytest.fixture(name="light") +async def light_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light +): + """Fixture for a single light with only the button platform active, no extra setup.""" + + # disable pydantic validation so mocking can happen + Light.__config__.validate_assignment = False + + light_obj = mock_light.copy(deep=True) + light_obj._api = mock_entry.api + light_obj.name = "Test Light" + light_obj.is_light_on = False + + mock_entry.api.bootstrap.lights = { + light_obj.id: light_obj, + } + + with patch("homeassistant.components.unifiprotect.PLATFORMS", [Platform.LIGHT]): + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + assert len(hass.states.async_all()) == 1 + assert len(entity_registry.entities) == 1 + + yield (light_obj, "light.test_light") + + Light.__config__.validate_assignment = True + + +async def test_light_setup( + hass: HomeAssistant, + light: tuple[Light, str], +): + """Test light entity setup.""" + + unique_id = light[0].id + entity_id = light[1] + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_light_update( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + light: tuple[Light, str], +): + """Test light entity update.""" + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_light = light[0].copy() + new_light.is_light_on = True + new_light.light_device_settings.led_level = 3 + + mock_msg = Mock() + mock_msg.new_obj = new_light + + new_bootstrap.lights = {new_light.id: new_light} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(light[1]) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_BRIGHTNESS] == 128 + + +async def test_light_turn_on( + hass: HomeAssistant, + light: tuple[Light, str], +): + """Test light entity turn off.""" + + entity_id = light[1] + light[0].__fields__["set_light"] = Mock() + light[0].set_light = AsyncMock() + + await hass.services.async_call( + "light", + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + light[0].set_light.assert_called_once_with(True, 3) + + +async def test_light_turn_off( + hass: HomeAssistant, + light: tuple[Light, str], +): + """Test light entity turn on.""" + + entity_id = light[1] + light[0].__fields__["set_light"] = Mock() + light[0].set_light = AsyncMock() + + await hass.services.async_call( + "light", + "turn_off", + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + light[0].set_light.assert_called_once_with(False)