1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Add LED control for supported UniFi network devices (#152649)

This commit is contained in:
Sebastian Schneider
2025-10-14 17:20:47 +02:00
committed by GitHub
parent 1d6c6628f4
commit 681eb6b594
6 changed files with 574 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ PLATFORMS = [
Platform.BUTTON,
Platform.DEVICE_TRACKER,
Platform.IMAGE,
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,

View File

@@ -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

View File

@@ -34,6 +34,11 @@
}
},
"entity": {
"light": {
"led_control": {
"name": "LED"
}
},
"sensor": {
"device_state": {
"state": {

View File

@@ -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([
<ColorMode.RGB: 'rgb'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ColorMode.RGB: 'rgb'>,
'friendly_name': 'Device with LED LED',
'hs_color': tuple(
240.0,
100.0,
),
'rgb_color': tuple(
0,
0,
255,
),
'supported_color_modes': list([
<ColorMode.RGB: 'rgb'>,
]),
'supported_features': <LightEntityFeature: 0>,
'xy_color': tuple(
0.136,
0.04,
),
}),
'context': <ANY>,
'entity_id': 'light.device_with_led_led',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@@ -41,6 +41,7 @@ async def test_hub_setup(
Platform.BUTTON,
Platform.DEVICE_TRACKER,
Platform.IMAGE,
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,

View File

@@ -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)