From c93b673ca31a4f0477a0bbcad245fb5df04cad5e Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Sat, 18 Oct 2025 06:02:37 +0000 Subject: [PATCH 01/10] Add OpenRGB profile select entity --- homeassistant/components/openrgb/__init__.py | 2 +- homeassistant/components/openrgb/select.py | 95 ++++++ homeassistant/components/openrgb/strings.json | 8 + tests/components/openrgb/conftest.py | 2 + .../openrgb/snapshots/test_select.ambr | 54 +++ tests/components/openrgb/test_select.py | 316 ++++++++++++++++++ 6 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/openrgb/select.py create mode 100644 tests/components/openrgb/snapshots/test_select.ambr create mode 100644 tests/components/openrgb/test_select.py diff --git a/homeassistant/components/openrgb/__init__.py b/homeassistant/components/openrgb/__init__.py index 320a5aeebc5871..ffc56391d17332 100644 --- a/homeassistant/components/openrgb/__init__.py +++ b/homeassistant/components/openrgb/__init__.py @@ -9,7 +9,7 @@ from .const import DOMAIN from .coordinator import OpenRGBConfigEntry, OpenRGBCoordinator -PLATFORMS: list[Platform] = [Platform.LIGHT] +PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SELECT] def _setup_server_device_registry( diff --git a/homeassistant/components/openrgb/select.py b/homeassistant/components/openrgb/select.py new file mode 100644 index 00000000000000..ea776cbb712a66 --- /dev/null +++ b/homeassistant/components/openrgb/select.py @@ -0,0 +1,95 @@ +"""Select platform for OpenRGB integration.""" + +from __future__ import annotations + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONNECTION_ERRORS, DOMAIN +from .coordinator import OpenRGBConfigEntry, OpenRGBCoordinator + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OpenRGBConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the OpenRGB select platform.""" + coordinator = config_entry.runtime_data + async_add_entities([OpenRGBProfileSelect(coordinator, config_entry)]) + + +class OpenRGBProfileSelect(CoordinatorEntity[OpenRGBCoordinator], SelectEntity): + """Representation of an OpenRGB profile select entity.""" + + _attr_translation_key = "profile" + _attr_has_entity_name = True + _attr_current_option: str | None = None + + def __init__( + self, coordinator: OpenRGBCoordinator, entry: OpenRGBConfigEntry + ) -> None: + """Initialize the select entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{entry.entry_id}_profile" + # Set device info to link to the OpenRGB server device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + ) + # Initialize with current profiles from coordinator + self._update_attrs() + + @callback + def _update_attrs(self) -> None: + """Update the attributes based on the current profile list.""" + profiles = self.coordinator.client.profiles + self._attr_options = [profile.name for profile in profiles] + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attrs() + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return if the select is available.""" + return ( + super().available + and self._attr_options is not None + and len(self._attr_options) > 0 + ) + + async def async_select_option(self, option: str) -> None: + """Load the selected profile.""" + async with self.coordinator.client_lock: + try: + await self.hass.async_add_executor_job( + self.coordinator.client.load_profile, option + ) + except CONNECTION_ERRORS as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={ + "server_address": self.coordinator.server_address, + "error": str(err), + }, + ) from err + except ValueError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="openrgb_error", + translation_placeholders={ + "error": str(err), + }, + ) from err + + # Refresh immediately to ensure profile changes are reflected + await self.coordinator.async_refresh() diff --git a/homeassistant/components/openrgb/strings.json b/homeassistant/components/openrgb/strings.json index d6211191afb201..69393af964b4cd 100644 --- a/homeassistant/components/openrgb/strings.json +++ b/homeassistant/components/openrgb/strings.json @@ -65,6 +65,11 @@ } } } + }, + "select": { + "profile": { + "name": "Profile" + } } }, "exceptions": { @@ -79,6 +84,9 @@ }, "effect_no_color_support": { "message": "Effect `{effect}` does not support color control on {device_name}" + }, + "profile_not_found": { + "message": "Profile `{profile}` not found" } } } diff --git a/tests/components/openrgb/conftest.py b/tests/components/openrgb/conftest.py index 71208e3cc6efd2..e9f07daabdf027 100644 --- a/tests/components/openrgb/conftest.py +++ b/tests/components/openrgb/conftest.py @@ -96,11 +96,13 @@ def mock_openrgb_client(mock_openrgb_device: MagicMock) -> Generator[MagicMock]: # Attributes client.protocol_version = 4 client.devices = [mock_openrgb_device] + client.profiles = [] # Methods client.update = MagicMock() client.connect = MagicMock() client.disconnect = MagicMock() + client.load_profile = MagicMock() # Store the class mock so tests can set side_effect client.client_class_mock = client_mock diff --git a/tests/components/openrgb/snapshots/test_select.ambr b/tests/components/openrgb/snapshots/test_select.ambr new file mode 100644 index 00000000000000..e69f4f288fdce6 --- /dev/null +++ b/tests/components/openrgb/snapshots/test_select.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_entities[select.test_computer_profile-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_computer_profile', + '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': 'Profile', + 'platform': 'openrgb', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'profile', + 'unique_id': '01J0EXAMPLE0CONFIGENTRY00_profile', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[select.test_computer_profile-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Computer Profile', + 'options': list([ + ]), + }), + 'context': , + 'entity_id': 'select.test_computer_profile', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/openrgb/test_select.py b/tests/components/openrgb/test_select.py new file mode 100644 index 00000000000000..a69082bf25c494 --- /dev/null +++ b/tests/components/openrgb/test_select.py @@ -0,0 +1,316 @@ +"""Tests for the OpenRGB select platform.""" + +from collections.abc import Generator +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from openrgb.utils import OpenRGBDisconnected +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.openrgb.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_OPTION, + SERVICE_SELECT_OPTION, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +def select_only() -> Generator[None]: + """Enable only the select platform.""" + with patch( + "homeassistant.components.openrgb.PLATFORMS", + [Platform.SELECT], + ): + yield + + +@pytest.fixture +def mock_profiles() -> list[SimpleNamespace]: + """Return a list of mock profiles.""" + return [ + SimpleNamespace(name="Gaming"), + SimpleNamespace(name="Work"), + SimpleNamespace(name="Rainbow"), + ] + + +# Test basic entity setup and configuration +@pytest.mark.usefixtures("init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the select entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + # Ensure entity is correctly assigned to the OpenRGB server device + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert device_entry + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entity_entries) == 1 + assert entity_entries[0].device_id == device_entry.id + + +@pytest.mark.usefixtures("mock_openrgb_client") +async def test_select_with_profiles( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openrgb_client: MagicMock, + mock_profiles: list[SimpleNamespace], +) -> None: + """Test select entity with available profiles.""" + mock_openrgb_client.profiles = mock_profiles + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Verify select entity has profile options + state = hass.states.get("select.test_computer_profile") + assert state + assert state.attributes.get("options") == ["Gaming", "Work", "Rainbow"] + + +@pytest.mark.usefixtures("mock_openrgb_client") +async def test_select_with_no_profiles( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openrgb_client: MagicMock, +) -> None: + """Test select entity when no profiles are available.""" + mock_openrgb_client.profiles = [] + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Verify select entity is unavailable when no profiles exist + state = hass.states.get("select.test_computer_profile") + assert state + assert state.state == STATE_UNAVAILABLE + assert state.attributes.get("options") == [] + + +@pytest.mark.usefixtures("mock_openrgb_client") +async def test_select_option_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openrgb_client: MagicMock, + mock_profiles: list[SimpleNamespace], +) -> None: + """Test selecting a profile successfully.""" + mock_openrgb_client.profiles = mock_profiles + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Select a profile + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.test_computer_profile", + ATTR_OPTION: "Gaming", + }, + blocking=True, + ) + + # Verify load_profile was called with the correct profile name + mock_openrgb_client.load_profile.assert_called_once_with("Gaming") + + +@pytest.mark.usefixtures("mock_openrgb_client") +async def test_select_option_not_found( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openrgb_client: MagicMock, + mock_profiles: list[SimpleNamespace], +) -> None: + """Test selecting a profile that doesn't exist.""" + mock_openrgb_client.profiles = mock_profiles + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Try to select a non-existent profile + # The select platform will validate that the option is in the options list + with pytest.raises( + ServiceValidationError, + match="Option NonExistent is not valid for entity select.test_computer_profile", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.test_computer_profile", + ATTR_OPTION: "NonExistent", + }, + blocking=True, + ) + + +@pytest.mark.usefixtures("mock_openrgb_client") +async def test_select_option_connection_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openrgb_client: MagicMock, + mock_profiles: list[SimpleNamespace], +) -> None: + """Test selecting a profile with connection error.""" + mock_openrgb_client.profiles = mock_profiles + mock_openrgb_client.load_profile.side_effect = OpenRGBDisconnected( + "Connection lost" + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Try to select a profile - should raise HomeAssistantError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.test_computer_profile", + ATTR_OPTION: "Gaming", + }, + blocking=True, + ) + + +@pytest.mark.usefixtures("mock_openrgb_client") +async def test_select_option_value_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openrgb_client: MagicMock, + mock_profiles: list[SimpleNamespace], +) -> None: + """Test selecting a profile with ValueError.""" + mock_openrgb_client.profiles = mock_profiles + mock_openrgb_client.load_profile.side_effect = ValueError("Invalid profile data") + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Try to select a profile - should raise HomeAssistantError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.test_computer_profile", + ATTR_OPTION: "Gaming", + }, + blocking=True, + ) + + +@pytest.mark.usefixtures("mock_openrgb_client") +async def test_profiles_update_on_refresh( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openrgb_client: MagicMock, + mock_profiles: list[SimpleNamespace], + freezer: FrozenDateTimeFactory, +) -> None: + """Test that profile list updates when profiles change.""" + # Start with initial profiles + mock_openrgb_client.profiles = mock_profiles[:2] # Only Gaming and Work + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Verify initial profile options + state = hass.states.get("select.test_computer_profile") + assert state + assert state.attributes.get("options") == ["Gaming", "Work"] + + # Add a new profile + mock_openrgb_client.profiles = mock_profiles # All three profiles + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify updated profile options + state = hass.states.get("select.test_computer_profile") + assert state + assert state.attributes.get("options") == ["Gaming", "Work", "Rainbow"] + + +@pytest.mark.usefixtures("mock_openrgb_client") +async def test_select_becomes_unavailable_when_profiles_removed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openrgb_client: MagicMock, + mock_profiles: list[SimpleNamespace], + freezer: FrozenDateTimeFactory, +) -> None: + """Test select becomes unavailable when all profiles are removed.""" + # Start with profiles + mock_openrgb_client.profiles = mock_profiles + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Verify select entity is available with profiles + state = hass.states.get("select.test_computer_profile") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.attributes.get("options") == ["Gaming", "Work", "Rainbow"] + + # Remove all profiles + mock_openrgb_client.profiles = [] + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify select entity becomes unavailable + state = hass.states.get("select.test_computer_profile") + assert state + assert state.state == STATE_UNAVAILABLE + assert state.attributes.get("options") == [] From 39d40ca891fc3706a61c851df8f193c022079688 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Sat, 18 Oct 2025 15:56:22 +0000 Subject: [PATCH 02/10] Move server device registration to select.py --- homeassistant/components/openrgb/__init__.py | 24 +------------------- homeassistant/components/openrgb/select.py | 9 ++++++-- 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/openrgb/__init__.py b/homeassistant/components/openrgb/__init__.py index ffc56391d17332..f360b65cca9486 100644 --- a/homeassistant/components/openrgb/__init__.py +++ b/homeassistant/components/openrgb/__init__.py @@ -2,42 +2,20 @@ from __future__ import annotations -from homeassistant.const import CONF_NAME, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -from .const import DOMAIN from .coordinator import OpenRGBConfigEntry, OpenRGBCoordinator PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SELECT] -def _setup_server_device_registry( - hass: HomeAssistant, entry: OpenRGBConfigEntry, coordinator: OpenRGBCoordinator -): - """Set up device registry for the OpenRGB SDK server.""" - device_registry = dr.async_get(hass) - - # Create the parent OpenRGB SDK server device - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, entry.entry_id)}, - name=entry.data[CONF_NAME], - model="OpenRGB SDK Server", - manufacturer="OpenRGB", - sw_version=coordinator.get_client_protocol_version(), - entry_type=dr.DeviceEntryType.SERVICE, - ) - - async def async_setup_entry(hass: HomeAssistant, entry: OpenRGBConfigEntry) -> bool: """Set up OpenRGB from a config entry.""" coordinator = OpenRGBCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - _setup_server_device_registry(hass, entry, coordinator) - entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/openrgb/select.py b/homeassistant/components/openrgb/select.py index ea776cbb712a66..4bb95dee4563f9 100644 --- a/homeassistant/components/openrgb/select.py +++ b/homeassistant/components/openrgb/select.py @@ -3,8 +3,10 @@ from __future__ import annotations from homeassistant.components.select import SelectEntity +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -38,11 +40,14 @@ def __init__( """Initialize the select entity.""" super().__init__(coordinator) self._attr_unique_id = f"{entry.entry_id}_profile" - # Set device info to link to the OpenRGB server device self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, + name=entry.data[CONF_NAME], + model="OpenRGB SDK Server", + manufacturer="OpenRGB", + sw_version=coordinator.get_client_protocol_version(), + entry_type=dr.DeviceEntryType.SERVICE, ) - # Initialize with current profiles from coordinator self._update_attrs() @callback From a321553b8cea6a0a83d69c7904c285c58c28ec14 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Sat, 18 Oct 2025 16:03:16 +0000 Subject: [PATCH 03/10] Remove now useless test --- .../openrgb/snapshots/test_init.ambr | 32 ------------------- tests/components/openrgb/test_init.py | 27 ++-------------- 2 files changed, 2 insertions(+), 57 deletions(-) delete mode 100644 tests/components/openrgb/snapshots/test_init.ambr diff --git a/tests/components/openrgb/snapshots/test_init.ambr b/tests/components/openrgb/snapshots/test_init.ambr deleted file mode 100644 index 6c1d760c9cda77..00000000000000 --- a/tests/components/openrgb/snapshots/test_init.ambr +++ /dev/null @@ -1,32 +0,0 @@ -# serializer version: 1 -# name: test_server_device_registry - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'openrgb', - '01J0EXAMPLE0CONFIGENTRY00', - ), - }), - 'labels': set({ - }), - 'manufacturer': 'OpenRGB', - 'model': 'OpenRGB SDK Server', - 'model_id': None, - 'name': 'Test Computer', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'sw_version': '4 (Protocol)', - 'via_device_id': None, - }) -# --- diff --git a/tests/components/openrgb/test_init.py b/tests/components/openrgb/test_init.py index 9d1637da811d81..258c283734d26b 100644 --- a/tests/components/openrgb/test_init.py +++ b/tests/components/openrgb/test_init.py @@ -6,7 +6,6 @@ from freezegun.api import FrozenDateTimeFactory from openrgb.utils import ControllerParsingError, OpenRGBDisconnected, SDKVersionError import pytest -from syrupy.assertion import SnapshotAssertion from homeassistant.components.openrgb import async_remove_config_entry_device from homeassistant.components.openrgb.const import DOMAIN, SCAN_INTERVAL @@ -39,31 +38,9 @@ async def test_entry_setup_unload( @pytest.mark.usefixtures("mock_openrgb_client") -async def test_server_device_registry( - hass: HomeAssistant, - snapshot: SnapshotAssertion, - mock_config_entry: MockConfigEntry, - device_registry: dr.DeviceRegistry, -) -> None: - """Test server device is created in device registry.""" - mock_config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - - server_device = device_registry.async_get_device( - identifiers={(DOMAIN, mock_config_entry.entry_id)} - ) - - assert server_device == snapshot - - async def test_remove_config_entry_device_server( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_openrgb_client: MagicMock, ) -> None: """Test that server device cannot be removed.""" mock_config_entry.add_to_hass(hass) @@ -86,10 +63,10 @@ async def test_remove_config_entry_device_server( assert result is False +@pytest.mark.usefixtures("mock_openrgb_client") async def test_remove_config_entry_device_still_connected( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_openrgb_client: MagicMock, ) -> None: """Test that connected devices cannot be removed.""" mock_config_entry.add_to_hass(hass) @@ -116,10 +93,10 @@ async def test_remove_config_entry_device_still_connected( assert result is False +@pytest.mark.usefixtures("mock_openrgb_client") async def test_remove_config_entry_device_disconnected( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_openrgb_client: MagicMock, device_registry: dr.DeviceRegistry, ) -> None: """Test that disconnected devices can be removed.""" From efa670324ae756b3d5514b8d829f861a4648e3bc Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Sat, 18 Oct 2025 16:09:26 +0000 Subject: [PATCH 04/10] Align unique id separator --- homeassistant/components/openrgb/const.py | 2 ++ .../components/openrgb/coordinator.py | 10 ++++++-- homeassistant/components/openrgb/select.py | 4 ++-- .../openrgb/snapshots/test_select.ambr | 2 +- tests/components/openrgb/test_init.py | 24 ++++++++++++++++--- tests/components/openrgb/test_light.py | 12 +++++++++- 6 files changed, 45 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/openrgb/const.py b/homeassistant/components/openrgb/const.py index eb1360060370ca..6b082eb5f493ee 100644 --- a/homeassistant/components/openrgb/const.py +++ b/homeassistant/components/openrgb/const.py @@ -13,6 +13,8 @@ DOMAIN = "openrgb" +UID_SEPARATOR = "||" + # Defaults DEFAULT_PORT = 6742 DEFAULT_CLIENT_NAME = "Home Assistant" diff --git a/homeassistant/components/openrgb/coordinator.py b/homeassistant/components/openrgb/coordinator.py index 4a24fcb529e5b3..c5189d807ab4ec 100644 --- a/homeassistant/components/openrgb/coordinator.py +++ b/homeassistant/components/openrgb/coordinator.py @@ -14,7 +14,13 @@ from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONNECTION_ERRORS, DEFAULT_CLIENT_NAME, DOMAIN, SCAN_INTERVAL +from .const import ( + CONNECTION_ERRORS, + DEFAULT_CLIENT_NAME, + DOMAIN, + SCAN_INTERVAL, + UID_SEPARATOR, +) _LOGGER = logging.getLogger(__name__) @@ -111,7 +117,7 @@ def _get_device_key(self, device: Device) -> str: device.metadata.location or "none", ) # Double pipe is readable and is unlikely to appear in metadata - return "||".join(parts) + return UID_SEPARATOR.join(parts) async def async_client_disconnect(self, *args) -> None: """Disconnect the OpenRGB client.""" diff --git a/homeassistant/components/openrgb/select.py b/homeassistant/components/openrgb/select.py index 4bb95dee4563f9..9a4cbc75469d91 100644 --- a/homeassistant/components/openrgb/select.py +++ b/homeassistant/components/openrgb/select.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONNECTION_ERRORS, DOMAIN +from .const import CONNECTION_ERRORS, DOMAIN, UID_SEPARATOR from .coordinator import OpenRGBConfigEntry, OpenRGBCoordinator PARALLEL_UPDATES = 0 @@ -39,7 +39,7 @@ def __init__( ) -> None: """Initialize the select entity.""" super().__init__(coordinator) - self._attr_unique_id = f"{entry.entry_id}_profile" + self._attr_unique_id = UID_SEPARATOR.join([entry.entry_id, "profile"]) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, name=entry.data[CONF_NAME], diff --git a/tests/components/openrgb/snapshots/test_select.ambr b/tests/components/openrgb/snapshots/test_select.ambr index e69f4f288fdce6..3d065969fef027 100644 --- a/tests/components/openrgb/snapshots/test_select.ambr +++ b/tests/components/openrgb/snapshots/test_select.ambr @@ -33,7 +33,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'profile', - 'unique_id': '01J0EXAMPLE0CONFIGENTRY00_profile', + 'unique_id': '01J0EXAMPLE0CONFIGENTRY00||profile', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/openrgb/test_init.py b/tests/components/openrgb/test_init.py index 258c283734d26b..0f74af4cabe50f 100644 --- a/tests/components/openrgb/test_init.py +++ b/tests/components/openrgb/test_init.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.openrgb import async_remove_config_entry_device -from homeassistant.components.openrgb.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.openrgb.const import DOMAIN, SCAN_INTERVAL, UID_SEPARATOR from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -112,7 +112,16 @@ async def test_remove_config_entry_device_disconnected( identifiers={ ( DOMAIN, - f"{entry_id}||KEYBOARD||Old Vendor||Old Device||OLD123||Old Location", + UID_SEPARATOR.join( + [ + entry_id, + "KEYBOARD", + "Old Vendor", + "Old Device", + "OLD123", + "Old Location", + ] + ), ) }, name="Old Disconnected Device", @@ -148,7 +157,16 @@ async def test_remove_config_entry_device_with_multiple_identifiers( ("other_domain", "some_other_id"), # This should be skipped ( DOMAIN, - f"{entry_id}||DEVICE||Vendor||Name||SERIAL123||Location", + UID_SEPARATOR.join( + [ + entry_id, + "DEVICE", + "Vendor", + "Name", + "SERIAL123", + "Location", + ] + ), ), # This is a disconnected OpenRGB device }, name="Multi-Domain Device", diff --git a/tests/components/openrgb/test_light.py b/tests/components/openrgb/test_light.py index 7e1be565049a82..8a2c55f64a6922 100644 --- a/tests/components/openrgb/test_light.py +++ b/tests/components/openrgb/test_light.py @@ -25,6 +25,7 @@ DOMAIN, OFF_COLOR, SCAN_INTERVAL, + UID_SEPARATOR, OpenRGBMode, ) from homeassistant.config_entries import ConfigEntryState @@ -72,7 +73,16 @@ async def test_entities( identifiers={ ( DOMAIN, - f"{mock_config_entry.entry_id}||DRAM||ENE||ENE SMBus Device||none||I2C: PIIX4, address 0x70", + UID_SEPARATOR.join( + [ + mock_config_entry.entry_id, + "DRAM", + "ENE", + "ENE SMBus Device", + "none", + "I2C: PIIX4, address 0x70", + ] + ), ) } ) From f3f122b7106847a961928d7f046af3a99262f155 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Sat, 18 Oct 2025 16:21:15 +0000 Subject: [PATCH 05/10] Add comment about no way to retrieve data --- homeassistant/components/openrgb/select.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/openrgb/select.py b/homeassistant/components/openrgb/select.py index 9a4cbc75469d91..6c5d74ac46a192 100644 --- a/homeassistant/components/openrgb/select.py +++ b/homeassistant/components/openrgb/select.py @@ -32,6 +32,7 @@ class OpenRGBProfileSelect(CoordinatorEntity[OpenRGBCoordinator], SelectEntity): _attr_translation_key = "profile" _attr_has_entity_name = True + # https://gitlab.com/CalcProgrammer1/OpenRGB/-/issues/5178 _attr_current_option: str | None = None def __init__( From 24d3e6029f282785a3998014eba123e6285aca96 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Sat, 18 Oct 2025 17:15:24 +0000 Subject: [PATCH 06/10] Only import DeviceEntryType --- homeassistant/components/openrgb/select.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openrgb/select.py b/homeassistant/components/openrgb/select.py index 6c5d74ac46a192..3cbb2fff379a22 100644 --- a/homeassistant/components/openrgb/select.py +++ b/homeassistant/components/openrgb/select.py @@ -6,8 +6,7 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -47,7 +46,7 @@ def __init__( model="OpenRGB SDK Server", manufacturer="OpenRGB", sw_version=coordinator.get_client_protocol_version(), - entry_type=dr.DeviceEntryType.SERVICE, + entry_type=DeviceEntryType.SERVICE, ) self._update_attrs() From 73ed44705226d6de1e344bc733ebb8e7c36351e1 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Sun, 19 Oct 2025 16:27:25 +0000 Subject: [PATCH 07/10] Assume current profile until some state has changed --- homeassistant/components/openrgb/select.py | 38 ++++++-- tests/components/openrgb/test_select.py | 102 +++++++++++++++++++++ 2 files changed, 132 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/openrgb/select.py b/homeassistant/components/openrgb/select.py index 3cbb2fff379a22..ae4359f7981b68 100644 --- a/homeassistant/components/openrgb/select.py +++ b/homeassistant/components/openrgb/select.py @@ -31,8 +31,9 @@ class OpenRGBProfileSelect(CoordinatorEntity[OpenRGBCoordinator], SelectEntity): _attr_translation_key = "profile" _attr_has_entity_name = True - # https://gitlab.com/CalcProgrammer1/OpenRGB/-/issues/5178 - _attr_current_option: str | None = None + + _state_hash: int | None = None + _pending_profile: str | None = None def __init__( self, coordinator: OpenRGBCoordinator, entry: OpenRGBConfigEntry @@ -50,12 +51,37 @@ def __init__( ) self._update_attrs() + def _compute_state_hash(self) -> int: + """Compute a hash of device states (modes and all LED colors).""" + state_data = tuple( + ( + device.active_mode, + tuple((color.red, color.green, color.blue) for color in device.colors), + ) + for device in self.coordinator.client.devices + ) + return hash(state_data) + @callback def _update_attrs(self) -> None: """Update the attributes based on the current profile list.""" profiles = self.coordinator.client.profiles self._attr_options = [profile.name for profile in profiles] + # Compute current state hash + current_hash = self._compute_state_hash() + + # If a profile was just applied, set it as current + if self._pending_profile is not None: + self._attr_current_option = self._pending_profile + self._pending_profile = None + # Otherwise if state changed, we can no longer assume current profile + elif current_hash != self._state_hash: + self._attr_current_option = None + + # Update stored hash + self._state_hash = current_hash + @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" @@ -65,11 +91,7 @@ def _handle_coordinator_update(self) -> None: @property def available(self) -> bool: """Return if the select is available.""" - return ( - super().available - and self._attr_options is not None - and len(self._attr_options) > 0 - ) + return super().available and bool(self._attr_options) async def async_select_option(self, option: str) -> None: """Load the selected profile.""" @@ -96,5 +118,5 @@ async def async_select_option(self, option: str) -> None: }, ) from err - # Refresh immediately to ensure profile changes are reflected + self._pending_profile = option await self.coordinator.async_refresh() diff --git a/tests/components/openrgb/test_select.py b/tests/components/openrgb/test_select.py index a69082bf25c494..e18a0af2ab3177 100644 --- a/tests/components/openrgb/test_select.py +++ b/tests/components/openrgb/test_select.py @@ -144,6 +144,11 @@ async def test_select_option_success( # Verify load_profile was called with the correct profile name mock_openrgb_client.load_profile.assert_called_once_with("Gaming") + # Verify the current option is set to the selected profile + state = hass.states.get("select.test_computer_profile") + assert state + assert state.state == "Gaming" + @pytest.mark.usefixtures("mock_openrgb_client") async def test_select_option_not_found( @@ -314,3 +319,100 @@ async def test_select_becomes_unavailable_when_profiles_removed( assert state assert state.state == STATE_UNAVAILABLE assert state.attributes.get("options") == [] + + +@pytest.mark.usefixtures("mock_openrgb_client") +async def test_current_option_cleared_when_device_state_changes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openrgb_client: MagicMock, + mock_openrgb_device: MagicMock, + mock_profiles: list[SimpleNamespace], + freezer: FrozenDateTimeFactory, +) -> None: + """Test current option is cleared when device state changes externally.""" + mock_openrgb_client.profiles = mock_profiles + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Select a profile + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.test_computer_profile", + ATTR_OPTION: "Gaming", + }, + blocking=True, + ) + + # Verify the current option is set + state = hass.states.get("select.test_computer_profile") + assert state + assert state.state == "Gaming" + + # Simulate external device state change by modifying active_mode + mock_openrgb_device.active_mode = 0 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify current option is cleared because device state changed + state = hass.states.get("select.test_computer_profile") + assert state + assert state.state == "unknown" + + +@pytest.mark.usefixtures("mock_openrgb_client") +async def test_current_option_cleared_when_colors_change( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openrgb_client: MagicMock, + mock_openrgb_device: MagicMock, + mock_profiles: list[SimpleNamespace], + freezer: FrozenDateTimeFactory, +) -> None: + """Test current option is cleared when device colors change externally.""" + mock_openrgb_client.profiles = mock_profiles + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Select a profile + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.test_computer_profile", + ATTR_OPTION: "Work", + }, + blocking=True, + ) + + # Verify the current option is set + state = hass.states.get("select.test_computer_profile") + assert state + assert state.state == "Work" + + # Simulate external color change + mock_openrgb_device.colors[0].red = 0 + mock_openrgb_device.colors[0].green = 255 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify current option is cleared because device colors changed + state = hass.states.get("select.test_computer_profile") + assert state + assert state.state == "unknown" From 3003b9134c511d542e7267f47625557ff72820e1 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Sun, 19 Oct 2025 17:26:48 +0000 Subject: [PATCH 08/10] Avoid calculating state hash when useless --- homeassistant/components/openrgb/select.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/openrgb/select.py b/homeassistant/components/openrgb/select.py index ae4359f7981b68..3bfa4ff6a79eff 100644 --- a/homeassistant/components/openrgb/select.py +++ b/homeassistant/components/openrgb/select.py @@ -68,19 +68,18 @@ def _update_attrs(self) -> None: profiles = self.coordinator.client.profiles self._attr_options = [profile.name for profile in profiles] - # Compute current state hash - current_hash = self._compute_state_hash() - # If a profile was just applied, set it as current if self._pending_profile is not None: self._attr_current_option = self._pending_profile self._pending_profile = None - # Otherwise if state changed, we can no longer assume current profile - elif current_hash != self._state_hash: - self._attr_current_option = None - - # Update stored hash - self._state_hash = current_hash + self._state_hash = self._compute_state_hash() + # Only check for state changes if we have a current option to potentially clear + elif self._attr_current_option is not None: + current_hash = self._compute_state_hash() + # If state changed, we can no longer assume current profile + if current_hash != self._state_hash: + self._attr_current_option = None + self._state_hash = None @callback def _handle_coordinator_update(self) -> None: From a54db9e52f82d1ac911a8628c6255d40404e8060 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Fri, 7 Nov 2025 13:43:00 +0000 Subject: [PATCH 09/10] Improve hashing mechanism --- homeassistant/components/openrgb/select.py | 11 +++-------- tests/components/openrgb/conftest.py | 5 +++++ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/openrgb/select.py b/homeassistant/components/openrgb/select.py index 3bfa4ff6a79eff..23f399a79cd783 100644 --- a/homeassistant/components/openrgb/select.py +++ b/homeassistant/components/openrgb/select.py @@ -52,15 +52,10 @@ def __init__( self._update_attrs() def _compute_state_hash(self) -> int: - """Compute a hash of device states (modes and all LED colors).""" - state_data = tuple( - ( - device.active_mode, - tuple((color.red, color.green, color.blue) for color in device.colors), - ) - for device in self.coordinator.client.devices + """Compute a hash of device states.""" + return hash( + "\n".join(str(device.data) for device in self.coordinator.client.devices) ) - return hash(state_data) @callback def _update_attrs(self) -> None: diff --git a/tests/components/openrgb/conftest.py b/tests/components/openrgb/conftest.py index e9f07daabdf027..cd9305c6558498 100644 --- a/tests/components/openrgb/conftest.py +++ b/tests/components/openrgb/conftest.py @@ -66,6 +66,11 @@ def mock_openrgb_device() -> MagicMock: device = MagicMock(spec=device_obj) device.configure_mock(**vars(device_obj)) + # Used by select entity to track state changes + type(device).data = property( + lambda self: {attr: getattr(self, attr) for attr in vars(device_obj)} + ) + # Methods device.set_color = MagicMock() device.set_mode = MagicMock() From efda077f463eb1b3eb2ca7d838bb9f05e7eae954 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Wed, 3 Dec 2025 23:36:25 +0000 Subject: [PATCH 10/10] Move server device initialization back to __init__.py --- homeassistant/components/openrgb/__init__.py | 24 +++++++++++++++++++- homeassistant/components/openrgb/select.py | 13 +++-------- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/openrgb/__init__.py b/homeassistant/components/openrgb/__init__.py index 268f38245929f6..5b156e9e63c8bf 100644 --- a/homeassistant/components/openrgb/__init__.py +++ b/homeassistant/components/openrgb/__init__.py @@ -2,8 +2,9 @@ from __future__ import annotations -from homeassistant.const import Platform +from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN @@ -12,12 +13,33 @@ PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SELECT] +def _setup_server_device_registry( + hass: HomeAssistant, entry: OpenRGBConfigEntry, coordinator: OpenRGBCoordinator +): + """Set up device registry for the OpenRGB SDK server.""" + device_registry = dr.async_get(hass) + + # Create the parent OpenRGB SDK server device + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.data[CONF_NAME], + model="OpenRGB SDK Server", + manufacturer="OpenRGB", + sw_version=coordinator.get_client_protocol_version(), + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def async_setup_entry(hass: HomeAssistant, entry: OpenRGBConfigEntry) -> bool: """Set up OpenRGB from a config entry.""" coordinator = OpenRGBCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() + # The server device must be created first as other devices are children of it + _setup_server_device_registry(hass, entry, coordinator) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/openrgb/select.py b/homeassistant/components/openrgb/select.py index 23f399a79cd783..368ba6cf4b25a7 100644 --- a/homeassistant/components/openrgb/select.py +++ b/homeassistant/components/openrgb/select.py @@ -3,10 +3,8 @@ from __future__ import annotations from homeassistant.components.select import SelectEntity -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -41,14 +39,9 @@ def __init__( """Initialize the select entity.""" super().__init__(coordinator) self._attr_unique_id = UID_SEPARATOR.join([entry.entry_id, "profile"]) - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, - name=entry.data[CONF_NAME], - model="OpenRGB SDK Server", - manufacturer="OpenRGB", - sw_version=coordinator.get_client_protocol_version(), - entry_type=DeviceEntryType.SERVICE, - ) + self._attr_device_info = { + "identifiers": {(DOMAIN, entry.entry_id)}, + } self._update_attrs() def _compute_state_hash(self) -> int: