Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c93b673
Add OpenRGB profile select entity
felipecrs Oct 18, 2025
39d40ca
Move server device registration to select.py
felipecrs Oct 18, 2025
67ccbdb
Merge branch 'dev' of https://github.com/home-assistant/core into ope…
felipecrs Oct 18, 2025
a321553
Remove now useless test
felipecrs Oct 18, 2025
efa6703
Align unique id separator
felipecrs Oct 18, 2025
f3f122b
Add comment about no way to retrieve data
felipecrs Oct 18, 2025
24d3e60
Only import DeviceEntryType
felipecrs Oct 18, 2025
80be89d
Merge branch 'dev' into openrgb-profiles
felipecrs Oct 18, 2025
73ed447
Assume current profile until some state has changed
felipecrs Oct 19, 2025
f94b056
Merge branch 'dev' into openrgb-profiles
felipecrs Oct 19, 2025
3003b91
Avoid calculating state hash when useless
felipecrs Oct 19, 2025
1fc66c7
Merge branch 'dev' into openrgb-profiles
felipecrs Oct 21, 2025
24bc83d
Merge branch 'dev' into openrgb-profiles
felipecrs Oct 22, 2025
e54ecbe
Merge branch 'dev' into openrgb-profiles
felipecrs Oct 24, 2025
f8b8950
Merge branch 'dev' into openrgb-profiles
felipecrs Oct 28, 2025
82ed5eb
Merge branch 'dev' into openrgb-profiles
felipecrs Nov 5, 2025
b5b4738
Merge branch 'dev' of https://github.com/home-assistant/core into ope…
felipecrs Nov 7, 2025
a54db9e
Improve hashing mechanism
felipecrs Nov 7, 2025
bd00897
Merge branch 'dev' into openrgb-profiles
felipecrs Nov 11, 2025
9b1d62c
Merge branch 'dev' into openrgb-profiles
felipecrs Dec 3, 2025
efda077
Move server device initialization back to __init__.py
felipecrs Dec 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion homeassistant/components/openrgb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,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(
Expand All @@ -37,6 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenRGBConfigEntry) -> b

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
Expand Down
2 changes: 2 additions & 0 deletions homeassistant/components/openrgb/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

DOMAIN = "openrgb"

UID_SEPARATOR = "||"

# Defaults
DEFAULT_PORT = 6742
DEFAULT_CLIENT_NAME = "Home Assistant"
Expand Down
10 changes: 8 additions & 2 deletions homeassistant/components/openrgb/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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."""
Expand Down
109 changes: 109 additions & 0 deletions homeassistant/components/openrgb/select.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""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.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import CONNECTION_ERRORS, DOMAIN, UID_SEPARATOR
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

_state_hash: int | None = None
_pending_profile: str | None = None

def __init__(
self, coordinator: OpenRGBCoordinator, entry: OpenRGBConfigEntry
) -> None:
"""Initialize the select entity."""
super().__init__(coordinator)
self._attr_unique_id = UID_SEPARATOR.join([entry.entry_id, "profile"])
self._attr_device_info = {
"identifiers": {(DOMAIN, entry.entry_id)},
}
self._update_attrs()

def _compute_state_hash(self) -> int:
"""Compute a hash of device states."""
return hash(
"\n".join(str(device.data) for device in self.coordinator.client.devices)
)

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

# 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
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
Comment on lines +63 to +70
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need the hash? Can't we check based on name?

Copy link
Copy Markdown
Contributor Author

@felipecrs felipecrs Oct 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which name?

OpenRGB SDK does not expose data of the current profile. I crafted this method so that I can at least determine whether the applied profile is still active.

Please read this discussion for a better context.

Copy link
Copy Markdown
Contributor Author

@felipecrs felipecrs Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I improved the hash function a little bit by reusing @jath03's idea.

Also, this will be simplified in future. The author of OpenRGB accepted the suggestion of tracking this within OpenRGB itself.


@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 bool(self._attr_options)

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

self._pending_profile = option
await self.coordinator.async_refresh()
8 changes: 8 additions & 0 deletions homeassistant/components/openrgb/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@
}
}
}
},
"select": {
"profile": {
"name": "Profile"
}
}
},
"exceptions": {
Expand All @@ -77,6 +82,9 @@
"openrgb_error": {
"message": "An OpenRGB error occurred: {error}"
},
"profile_not_found": {
"message": "Profile `{profile}` not found"
},
"unsupported_effect": {
"message": "Effect `{effect}` is not supported by {device_name}"
}
Expand Down
7 changes: 7 additions & 0 deletions tests/components/openrgb/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -96,11 +101,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
Expand Down
32 changes: 0 additions & 32 deletions tests/components/openrgb/snapshots/test_init.ambr

This file was deleted.

54 changes: 54 additions & 0 deletions tests/components/openrgb/snapshots/test_select.ambr
Original file line number Diff line number Diff line change
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.test_computer_profile',
'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': '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': <ANY>,
'entity_id': 'select.test_computer_profile',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
51 changes: 23 additions & 28 deletions tests/components/openrgb/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@
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
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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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."""
Expand All @@ -135,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",
Expand Down Expand Up @@ -171,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",
Expand Down
Loading
Loading