From 8c8708d5bca36cd585a43ff965b77c4ad467ef41 Mon Sep 17 00:00:00 2001 From: Sebastian Schneider Date: Fri, 28 Nov 2025 17:33:15 +0100 Subject: [PATCH] Support UniFi LED control for devices without RGB (#156812) --- homeassistant/components/unifi/light.py | 89 +++++++++++++------- tests/components/unifi/test_light.py | 105 ++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/unifi/light.py b/homeassistant/components/unifi/light.py index 9327dcc160e..d719d86cc03 100644 --- a/homeassistant/components/unifi/light.py +++ b/homeassistant/components/unifi/light.py @@ -35,11 +35,29 @@ if TYPE_CHECKING: from .hub import UnifiHub +def convert_brightness_to_unifi(ha_brightness: int) -> int: + """Convert Home Assistant brightness (0-255) to UniFi brightness (0-100).""" + return round((ha_brightness / 255) * 100) + + +def convert_brightness_to_ha( + unifi_brightness: int, +) -> int: + """Convert UniFi brightness (0-100) to Home Assistant brightness (0-255).""" + return round((unifi_brightness / 100) * 255) + + +def get_device_brightness_or_default(device: Device) -> int: + """Get device's current LED brightness. Defaults to 100 (full brightness) if not set.""" + value = device.led_override_color_brightness + return value if value is not None else 100 + + @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 + return device.led_override is not None or device.supports_led_ring @callback @@ -56,17 +74,24 @@ async def async_device_led_control_fn( 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 - ) + # Only send brightness and RGB if device has LED_RING hardware support + if device.supports_led_ring: + # Use provided brightness or fall back to device's current brightness + if ATTR_BRIGHTNESS in kwargs: + brightness = convert_brightness_to_unifi(kwargs[ATTR_BRIGHTNESS]) + else: + brightness = get_device_brightness_or_default(device) - 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 - ) + # Use provided RGB color or fall back to device's current color + color: str | None + if ATTR_RGB_COLOR in kwargs: + rgb = kwargs[ATTR_RGB_COLOR] + color = f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}" + else: + color = device.led_override_color + else: + brightness = None + color = None await hub.api.request( DeviceSetLedStatus.create( @@ -127,12 +152,19 @@ class UnifiLightEntity[HandlerT: APIHandler, ApiItemT: ApiItem]( 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.""" + device = cast(Device, self.entity_description.object_fn(self.api, self._obj_id)) + + if device.supports_led_ring: + self._attr_supported_color_modes = {ColorMode.RGB} + self._attr_color_mode = ColorMode.RGB + else: + self._attr_supported_color_modes = {ColorMode.ONOFF} + self._attr_color_mode = ColorMode.ONOFF + self.async_update_state(ItemEvent.ADDED, self._obj_id) async def async_turn_on(self, **kwargs: Any) -> None: @@ -150,23 +182,24 @@ class UnifiLightEntity[HandlerT: APIHandler, ApiItemT: ApiItem]( """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 - ) + # Only set brightness and RGB if device has LED_RING hardware support + if device.supports_led_ring: + self._attr_brightness = convert_brightness_to_ha( + get_device_brightness_or_default(device) + ) - 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 + # Parse hex color from device and convert to RGB tuple + 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/tests/components/unifi/test_light.py b/tests/components/unifi/test_light.py index 6ee40c9a91d..146b088103e 100644 --- a/tests/components/unifi/test_light.py +++ b/tests/components/unifi/test_light.py @@ -86,6 +86,24 @@ DEVICE_LED_OFF = { "hw_caps": 2, } +DEVICE_WITH_LED_NO_RGB = { + "board_rev": 2, + "device_id": "mock-id-4", + "ip": "10.0.0.4", + "last_seen": 1562600145, + "mac": "10:00:00:00:01:04", + "model": "US-16-150W", + "name": "Device LED No RGB", + "next_interval": 20, + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + "led_override": "on", + "led_override_color": "#ffffff", + "led_override_color_brightness": 100, + "hw_caps": 0, +} + @pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED, DEVICE_WITHOUT_LED]]) @pytest.mark.usefixtures("config_entry_setup") @@ -321,3 +339,90 @@ async def test_light_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) + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED_NO_RGB]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_onoff_mode_only( + hass: HomeAssistant, +) -> None: + """Test light with ONOFF mode only (no LED ring support).""" + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 + + light_entity = hass.states.get("light.device_led_no_rgb_led") + assert light_entity is not None + assert light_entity.state == STATE_ON + # Device without LED ring support should not expose brightness or RGB + assert light_entity.attributes.get("brightness") is None + assert light_entity.attributes.get("rgb_color") is None + assert light_entity.attributes.get("supported_color_modes") == ["onoff"] + assert light_entity.attributes.get("color_mode") == "onoff" + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED_NO_RGB]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_onoff_mode_turn_on_off( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, +) -> None: + """Test ONOFF-only light 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-4", + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.device_led_no_rgb_led"}, + blocking=True, + ) + + assert aioclient_mock.call_count == 1 + call_data = aioclient_mock.mock_calls[0][2] + assert call_data["led_override"] == "off" + # Should not send brightness or color for ONOFF-only devices + assert call_data.get("led_override_color_brightness") is None + assert call_data.get("led_override_color") is None + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.device_led_no_rgb_led"}, + blocking=True, + ) + + assert aioclient_mock.call_count == 2 + call_data = aioclient_mock.mock_calls[1][2] + assert call_data["led_override"] == "on" + # Should not send brightness or color for ONOFF-only devices + assert call_data.get("led_override_color_brightness") is None + assert call_data.get("led_override_color") is None + + +@pytest.mark.parametrize("device_payload", [[DEVICE_WITH_LED, DEVICE_WITH_LED_NO_RGB]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_light_rgb_vs_onoff_modes( + hass: HomeAssistant, +) -> None: + """Test that RGB and ONOFF modes are correctly assigned based on device capabilities.""" + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 2 + + # Device with LED ring support should have RGB mode + rgb_light = hass.states.get("light.device_with_led_led") + assert rgb_light is not None + assert rgb_light.state == STATE_ON + assert rgb_light.attributes.get("supported_color_modes") == ["rgb"] + assert rgb_light.attributes.get("color_mode") == "rgb" + assert rgb_light.attributes.get("brightness") == 204 + assert rgb_light.attributes.get("rgb_color") == (0, 0, 255) + + # Device without LED ring support should have ONOFF mode + onoff_light = hass.states.get("light.device_led_no_rgb_led") + assert onoff_light is not None + assert onoff_light.state == STATE_ON + assert onoff_light.attributes.get("supported_color_modes") == ["onoff"] + assert onoff_light.attributes.get("color_mode") == "onoff" + assert onoff_light.attributes.get("brightness") is None + assert onoff_light.attributes.get("rgb_color") is None