Skip to content

Commit 8c8708d

Browse files
Support UniFi LED control for devices without RGB (#156812)
1 parent ca35102 commit 8c8708d

File tree

2 files changed

+167
-29
lines changed

2 files changed

+167
-29
lines changed

homeassistant/components/unifi/light.py

Lines changed: 62 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,29 @@
3535
from .hub import UnifiHub
3636

3737

38+
def convert_brightness_to_unifi(ha_brightness: int) -> int:
39+
"""Convert Home Assistant brightness (0-255) to UniFi brightness (0-100)."""
40+
return round((ha_brightness / 255) * 100)
41+
42+
43+
def convert_brightness_to_ha(
44+
unifi_brightness: int,
45+
) -> int:
46+
"""Convert UniFi brightness (0-100) to Home Assistant brightness (0-255)."""
47+
return round((unifi_brightness / 100) * 255)
48+
49+
50+
def get_device_brightness_or_default(device: Device) -> int:
51+
"""Get device's current LED brightness. Defaults to 100 (full brightness) if not set."""
52+
value = device.led_override_color_brightness
53+
return value if value is not None else 100
54+
55+
3856
@callback
3957
def async_device_led_supported_fn(hub: UnifiHub, obj_id: str) -> bool:
4058
"""Check if device supports LED control."""
4159
device: Device = hub.api.devices[obj_id]
42-
return device.supports_led_ring
60+
return device.led_override is not None or device.supports_led_ring
4361

4462

4563
@callback
@@ -56,17 +74,24 @@ async def async_device_led_control_fn(
5674

5775
status = "on" if turn_on else "off"
5876

59-
brightness = (
60-
int((kwargs[ATTR_BRIGHTNESS] / 255) * 100)
61-
if ATTR_BRIGHTNESS in kwargs
62-
else device.led_override_color_brightness
63-
)
77+
# Only send brightness and RGB if device has LED_RING hardware support
78+
if device.supports_led_ring:
79+
# Use provided brightness or fall back to device's current brightness
80+
if ATTR_BRIGHTNESS in kwargs:
81+
brightness = convert_brightness_to_unifi(kwargs[ATTR_BRIGHTNESS])
82+
else:
83+
brightness = get_device_brightness_or_default(device)
6484

65-
color = (
66-
f"#{kwargs[ATTR_RGB_COLOR][0]:02x}{kwargs[ATTR_RGB_COLOR][1]:02x}{kwargs[ATTR_RGB_COLOR][2]:02x}"
67-
if ATTR_RGB_COLOR in kwargs
68-
else device.led_override_color
69-
)
85+
# Use provided RGB color or fall back to device's current color
86+
color: str | None
87+
if ATTR_RGB_COLOR in kwargs:
88+
rgb = kwargs[ATTR_RGB_COLOR]
89+
color = f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
90+
else:
91+
color = device.led_override_color
92+
else:
93+
brightness = None
94+
color = None
7095

7196
await hub.api.request(
7297
DeviceSetLedStatus.create(
@@ -127,12 +152,19 @@ class UnifiLightEntity[HandlerT: APIHandler, ApiItemT: ApiItem](
127152

128153
entity_description: UnifiLightEntityDescription[HandlerT, ApiItemT]
129154
_attr_supported_features = LightEntityFeature(0)
130-
_attr_color_mode = ColorMode.RGB
131-
_attr_supported_color_modes = {ColorMode.RGB}
132155

133156
@callback
134157
def async_initiate_state(self) -> None:
135158
"""Initiate entity state."""
159+
device = cast(Device, self.entity_description.object_fn(self.api, self._obj_id))
160+
161+
if device.supports_led_ring:
162+
self._attr_supported_color_modes = {ColorMode.RGB}
163+
self._attr_color_mode = ColorMode.RGB
164+
else:
165+
self._attr_supported_color_modes = {ColorMode.ONOFF}
166+
self._attr_color_mode = ColorMode.ONOFF
167+
136168
self.async_update_state(ItemEvent.ADDED, self._obj_id)
137169

138170
async def async_turn_on(self, **kwargs: Any) -> None:
@@ -150,23 +182,24 @@ def async_update_state(self, event: ItemEvent, obj_id: str) -> None:
150182
"""Update entity state."""
151183
description = self.entity_description
152184
device_obj = description.object_fn(self.api, self._obj_id)
153-
154185
device = cast(Device, device_obj)
155186

156187
self._attr_is_on = description.is_on_fn(self.hub, device_obj)
157188

158-
brightness = device.led_override_color_brightness
159-
self._attr_brightness = (
160-
int((int(brightness) / 100) * 255) if brightness is not None else None
161-
)
162-
163-
hex_color = (
164-
device.led_override_color.lstrip("#")
165-
if self._attr_is_on and device.led_override_color
166-
else None
167-
)
168-
if hex_color and len(hex_color) == 6:
169-
rgb_list = rgb_hex_to_rgb_list(hex_color)
170-
self._attr_rgb_color = (rgb_list[0], rgb_list[1], rgb_list[2])
171-
else:
172-
self._attr_rgb_color = None
189+
# Only set brightness and RGB if device has LED_RING hardware support
190+
if device.supports_led_ring:
191+
self._attr_brightness = convert_brightness_to_ha(
192+
get_device_brightness_or_default(device)
193+
)
194+
195+
# Parse hex color from device and convert to RGB tuple
196+
hex_color = (
197+
device.led_override_color.lstrip("#")
198+
if self._attr_is_on and device.led_override_color
199+
else None
200+
)
201+
if hex_color and len(hex_color) == 6:
202+
rgb_list = rgb_hex_to_rgb_list(hex_color)
203+
self._attr_rgb_color = (rgb_list[0], rgb_list[1], rgb_list[2])
204+
else:
205+
self._attr_rgb_color = None

tests/components/unifi/test_light.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,24 @@
8686
"hw_caps": 2,
8787
}
8888

89+
DEVICE_WITH_LED_NO_RGB = {
90+
"board_rev": 2,
91+
"device_id": "mock-id-4",
92+
"ip": "10.0.0.4",
93+
"last_seen": 1562600145,
94+
"mac": "10:00:00:00:01:04",
95+
"model": "US-16-150W",
96+
"name": "Device LED No RGB",
97+
"next_interval": 20,
98+
"state": 1,
99+
"type": "usw",
100+
"version": "4.0.42.10433",
101+
"led_override": "on",
102+
"led_override_color": "#ffffff",
103+
"led_override_color_brightness": 100,
104+
"hw_caps": 0,
105+
}
106+
89107

90108
@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED, DEVICE_WITHOUT_LED]])
91109
@pytest.mark.usefixtures("config_entry_setup")
@@ -321,3 +339,90 @@ async def test_light_platform_snapshot(
321339
with patch("homeassistant.components.unifi.PLATFORMS", [Platform.LIGHT]):
322340
config_entry = await config_entry_factory()
323341
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
342+
343+
344+
@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED_NO_RGB]])
345+
@pytest.mark.usefixtures("config_entry_setup")
346+
async def test_light_onoff_mode_only(
347+
hass: HomeAssistant,
348+
) -> None:
349+
"""Test light with ONOFF mode only (no LED ring support)."""
350+
assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1
351+
352+
light_entity = hass.states.get("light.device_led_no_rgb_led")
353+
assert light_entity is not None
354+
assert light_entity.state == STATE_ON
355+
# Device without LED ring support should not expose brightness or RGB
356+
assert light_entity.attributes.get("brightness") is None
357+
assert light_entity.attributes.get("rgb_color") is None
358+
assert light_entity.attributes.get("supported_color_modes") == ["onoff"]
359+
assert light_entity.attributes.get("color_mode") == "onoff"
360+
361+
362+
@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED_NO_RGB]])
363+
@pytest.mark.usefixtures("config_entry_setup")
364+
async def test_light_onoff_mode_turn_on_off(
365+
hass: HomeAssistant,
366+
aioclient_mock: AiohttpClientMocker,
367+
config_entry_setup: MockConfigEntry,
368+
) -> None:
369+
"""Test ONOFF-only light turn on and off."""
370+
aioclient_mock.clear_requests()
371+
aioclient_mock.put(
372+
f"https://{config_entry_setup.data[CONF_HOST]}:1234"
373+
f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/device/mock-id-4",
374+
)
375+
await hass.services.async_call(
376+
LIGHT_DOMAIN,
377+
SERVICE_TURN_OFF,
378+
{ATTR_ENTITY_ID: "light.device_led_no_rgb_led"},
379+
blocking=True,
380+
)
381+
382+
assert aioclient_mock.call_count == 1
383+
call_data = aioclient_mock.mock_calls[0][2]
384+
assert call_data["led_override"] == "off"
385+
# Should not send brightness or color for ONOFF-only devices
386+
assert call_data.get("led_override_color_brightness") is None
387+
assert call_data.get("led_override_color") is None
388+
389+
await hass.services.async_call(
390+
LIGHT_DOMAIN,
391+
SERVICE_TURN_ON,
392+
{ATTR_ENTITY_ID: "light.device_led_no_rgb_led"},
393+
blocking=True,
394+
)
395+
396+
assert aioclient_mock.call_count == 2
397+
call_data = aioclient_mock.mock_calls[1][2]
398+
assert call_data["led_override"] == "on"
399+
# Should not send brightness or color for ONOFF-only devices
400+
assert call_data.get("led_override_color_brightness") is None
401+
assert call_data.get("led_override_color") is None
402+
403+
404+
@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED, DEVICE_WITH_LED_NO_RGB]])
405+
@pytest.mark.usefixtures("config_entry_setup")
406+
async def test_light_rgb_vs_onoff_modes(
407+
hass: HomeAssistant,
408+
) -> None:
409+
"""Test that RGB and ONOFF modes are correctly assigned based on device capabilities."""
410+
assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 2
411+
412+
# Device with LED ring support should have RGB mode
413+
rgb_light = hass.states.get("light.device_with_led_led")
414+
assert rgb_light is not None
415+
assert rgb_light.state == STATE_ON
416+
assert rgb_light.attributes.get("supported_color_modes") == ["rgb"]
417+
assert rgb_light.attributes.get("color_mode") == "rgb"
418+
assert rgb_light.attributes.get("brightness") == 204
419+
assert rgb_light.attributes.get("rgb_color") == (0, 0, 255)
420+
421+
# Device without LED ring support should have ONOFF mode
422+
onoff_light = hass.states.get("light.device_led_no_rgb_led")
423+
assert onoff_light is not None
424+
assert onoff_light.state == STATE_ON
425+
assert onoff_light.attributes.get("supported_color_modes") == ["onoff"]
426+
assert onoff_light.attributes.get("color_mode") == "onoff"
427+
assert onoff_light.attributes.get("brightness") is None
428+
assert onoff_light.attributes.get("rgb_color") is None

0 commit comments

Comments
 (0)