Skip to content

Commit 18c63e3

Browse files
felipecrsCopilottr4nt0rjoostlekNoRi2909
authored
Introduce the OpenRGB integration (home-assistant#153373)
Co-authored-by: Copilot <[email protected]> Co-authored-by: Manu <[email protected]> Co-authored-by: Joost Lekkerkerker <[email protected]> Co-authored-by: Norbert Rittel <[email protected]>
1 parent cf47718 commit 18c63e3

22 files changed

+2888
-0
lines changed

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""The OpenRGB integration."""
2+
3+
from __future__ import annotations
4+
5+
from homeassistant.const import CONF_NAME, Platform
6+
from homeassistant.core import HomeAssistant
7+
from homeassistant.helpers import device_registry as dr
8+
9+
from .const import DOMAIN
10+
from .coordinator import OpenRGBConfigEntry, OpenRGBCoordinator
11+
12+
PLATFORMS: list[Platform] = [Platform.LIGHT]
13+
14+
15+
def _setup_server_device_registry(
16+
hass: HomeAssistant, entry: OpenRGBConfigEntry, coordinator: OpenRGBCoordinator
17+
):
18+
"""Set up device registry for the OpenRGB SDK server."""
19+
device_registry = dr.async_get(hass)
20+
21+
# Create the parent OpenRGB SDK server device
22+
device_registry.async_get_or_create(
23+
config_entry_id=entry.entry_id,
24+
identifiers={(DOMAIN, entry.entry_id)},
25+
name=entry.data[CONF_NAME],
26+
model="OpenRGB SDK Server",
27+
manufacturer="OpenRGB",
28+
sw_version=coordinator.get_client_protocol_version(),
29+
entry_type=dr.DeviceEntryType.SERVICE,
30+
)
31+
32+
33+
async def async_setup_entry(hass: HomeAssistant, entry: OpenRGBConfigEntry) -> bool:
34+
"""Set up OpenRGB from a config entry."""
35+
coordinator = OpenRGBCoordinator(hass, entry)
36+
37+
await coordinator.async_config_entry_first_refresh()
38+
39+
_setup_server_device_registry(hass, entry, coordinator)
40+
41+
entry.runtime_data = coordinator
42+
43+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
44+
45+
return True
46+
47+
48+
async def async_unload_entry(hass: HomeAssistant, entry: OpenRGBConfigEntry) -> bool:
49+
"""Unload a config entry."""
50+
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Config flow for the OpenRGB integration."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
from openrgb import OpenRGBClient
9+
import voluptuous as vol
10+
11+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
12+
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
13+
from homeassistant.core import HomeAssistant
14+
from homeassistant.helpers import config_validation as cv
15+
16+
from .const import CONNECTION_ERRORS, DEFAULT_CLIENT_NAME, DEFAULT_PORT, DOMAIN
17+
18+
_LOGGER = logging.getLogger(__name__)
19+
20+
STEP_USER_DATA_SCHEMA = vol.Schema(
21+
{
22+
vol.Required(CONF_NAME): str,
23+
vol.Required(CONF_HOST): str,
24+
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
25+
}
26+
)
27+
28+
29+
async def validate_input(hass: HomeAssistant, host: str, port: int) -> None:
30+
"""Validate the user input allows us to connect."""
31+
32+
def _try_connect(host: str, port: int) -> None:
33+
client = OpenRGBClient(host, port, DEFAULT_CLIENT_NAME)
34+
client.disconnect()
35+
36+
await hass.async_add_executor_job(_try_connect, host, port)
37+
38+
39+
class OpenRGBConfigFlow(ConfigFlow, domain=DOMAIN):
40+
"""Handle a config flow for OpenRGB."""
41+
42+
async def async_step_user(
43+
self, user_input: dict[str, Any] | None = None
44+
) -> ConfigFlowResult:
45+
"""Handle the initial step."""
46+
errors: dict[str, str] = {}
47+
if user_input is not None:
48+
name = user_input[CONF_NAME]
49+
host = user_input[CONF_HOST]
50+
port = user_input[CONF_PORT]
51+
52+
# Prevent duplicate entries
53+
self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port})
54+
55+
try:
56+
await validate_input(self.hass, host, port)
57+
except CONNECTION_ERRORS:
58+
errors["base"] = "cannot_connect"
59+
except Exception:
60+
_LOGGER.exception(
61+
"Unknown error while connecting to OpenRGB SDK server at %s",
62+
f"{host}:{port}",
63+
)
64+
errors["base"] = "unknown"
65+
else:
66+
return self.async_create_entry(
67+
title=name,
68+
data={
69+
CONF_NAME: name,
70+
CONF_HOST: host,
71+
CONF_PORT: port,
72+
},
73+
)
74+
75+
return self.async_show_form(
76+
step_id="user",
77+
data_schema=self.add_suggested_values_to_schema(
78+
data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
79+
),
80+
errors=errors,
81+
)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Constants for the OpenRGB integration."""
2+
3+
from datetime import timedelta
4+
from enum import StrEnum
5+
import socket
6+
7+
from openrgb.utils import (
8+
ControllerParsingError,
9+
DeviceType,
10+
OpenRGBDisconnected,
11+
SDKVersionError,
12+
)
13+
14+
DOMAIN = "openrgb"
15+
16+
# Defaults
17+
DEFAULT_PORT = 6742
18+
DEFAULT_CLIENT_NAME = "Home Assistant"
19+
20+
# Update interval
21+
SCAN_INTERVAL = timedelta(seconds=15)
22+
23+
DEFAULT_COLOR = (255, 255, 255)
24+
DEFAULT_BRIGHTNESS = 255
25+
OFF_COLOR = (0, 0, 0)
26+
27+
28+
class OpenRGBMode(StrEnum):
29+
"""OpenRGB modes."""
30+
31+
OFF = "Off"
32+
STATIC = "Static"
33+
DIRECT = "Direct"
34+
CUSTOM = "Custom"
35+
36+
37+
EFFECT_OFF_OPENRGB_MODES = {OpenRGBMode.STATIC, OpenRGBMode.DIRECT, OpenRGBMode.CUSTOM}
38+
39+
DEVICE_TYPE_ICONS: dict[DeviceType, str] = {
40+
DeviceType.MOTHERBOARD: "mdi:developer-board",
41+
DeviceType.DRAM: "mdi:memory",
42+
DeviceType.GPU: "mdi:expansion-card",
43+
DeviceType.COOLER: "mdi:fan",
44+
DeviceType.LEDSTRIP: "mdi:led-variant-on",
45+
DeviceType.KEYBOARD: "mdi:keyboard",
46+
DeviceType.MOUSE: "mdi:mouse",
47+
DeviceType.MOUSEMAT: "mdi:rug",
48+
DeviceType.HEADSET: "mdi:headset",
49+
DeviceType.HEADSET_STAND: "mdi:headset-dock",
50+
DeviceType.GAMEPAD: "mdi:gamepad-variant",
51+
DeviceType.SPEAKER: "mdi:speaker",
52+
DeviceType.STORAGE: "mdi:harddisk",
53+
DeviceType.CASE: "mdi:desktop-tower",
54+
DeviceType.MICROPHONE: "mdi:microphone",
55+
DeviceType.KEYPAD: "mdi:dialpad",
56+
}
57+
58+
CONNECTION_ERRORS = (
59+
ConnectionRefusedError,
60+
OpenRGBDisconnected,
61+
ControllerParsingError,
62+
TimeoutError,
63+
socket.gaierror, # DNS errors
64+
SDKVersionError, # The OpenRGB SDK server version is incompatible with the client
65+
)
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""DataUpdateCoordinator for OpenRGB."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import logging
7+
8+
from openrgb import OpenRGBClient
9+
from openrgb.orgb import Device
10+
11+
from homeassistant.config_entries import ConfigEntry
12+
from homeassistant.const import CONF_HOST, CONF_PORT
13+
from homeassistant.core import HomeAssistant
14+
from homeassistant.helpers.debounce import Debouncer
15+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
16+
17+
from .const import CONNECTION_ERRORS, DEFAULT_CLIENT_NAME, DOMAIN, SCAN_INTERVAL
18+
19+
_LOGGER = logging.getLogger(__name__)
20+
21+
type OpenRGBConfigEntry = ConfigEntry[OpenRGBCoordinator]
22+
23+
24+
class OpenRGBCoordinator(DataUpdateCoordinator[dict[str, Device]]):
25+
"""Class to manage fetching OpenRGB data."""
26+
27+
client: OpenRGBClient
28+
29+
def __init__(
30+
self,
31+
hass: HomeAssistant,
32+
config_entry: OpenRGBConfigEntry,
33+
) -> None:
34+
"""Initialize the coordinator."""
35+
super().__init__(
36+
hass,
37+
_LOGGER,
38+
name=DOMAIN,
39+
update_interval=SCAN_INTERVAL,
40+
config_entry=config_entry,
41+
request_refresh_debouncer=Debouncer(
42+
hass, _LOGGER, cooldown=0.5, immediate=False
43+
),
44+
)
45+
self.host = config_entry.data[CONF_HOST]
46+
self.port = config_entry.data[CONF_PORT]
47+
self.entry_id = config_entry.entry_id
48+
self.server_address = f"{self.host}:{self.port}"
49+
self.client_lock = asyncio.Lock()
50+
51+
config_entry.async_on_unload(self.async_client_disconnect)
52+
53+
async def _async_setup(self) -> None:
54+
"""Set up the coordinator by connecting to the OpenRGB SDK server."""
55+
try:
56+
self.client = await self.hass.async_add_executor_job(
57+
OpenRGBClient,
58+
self.host,
59+
self.port,
60+
DEFAULT_CLIENT_NAME,
61+
)
62+
except CONNECTION_ERRORS as err:
63+
raise UpdateFailed(
64+
translation_domain=DOMAIN,
65+
translation_key="cannot_connect",
66+
translation_placeholders={
67+
"server_address": self.server_address,
68+
"error": str(err),
69+
},
70+
) from err
71+
72+
async def _async_update_data(self) -> dict[str, Device]:
73+
"""Fetch data from OpenRGB."""
74+
async with self.client_lock:
75+
try:
76+
await self.hass.async_add_executor_job(self._client_update)
77+
except CONNECTION_ERRORS as err:
78+
raise UpdateFailed(
79+
translation_domain=DOMAIN,
80+
translation_key="communication_error",
81+
translation_placeholders={
82+
"server_address": self.server_address,
83+
"error": str(err),
84+
},
85+
) from err
86+
87+
# Return devices indexed by their key
88+
return {self._get_device_key(device): device for device in self.client.devices}
89+
90+
def _client_update(self) -> None:
91+
try:
92+
self.client.update()
93+
except CONNECTION_ERRORS:
94+
# Try to reconnect once
95+
self.client.disconnect()
96+
self.client.connect()
97+
self.client.update()
98+
99+
def _get_device_key(self, device: Device) -> str:
100+
"""Build a stable device key.
101+
102+
Note: the OpenRGB device.id is intentionally not used because it is just
103+
a positional index that can change when devices are added or removed.
104+
"""
105+
parts = (
106+
self.entry_id,
107+
device.type.name,
108+
device.metadata.vendor or "none",
109+
device.metadata.description or "none",
110+
device.metadata.serial or "none",
111+
device.metadata.location or "none",
112+
)
113+
# Double pipe is readable and is unlikely to appear in metadata
114+
return "||".join(parts)
115+
116+
async def async_client_disconnect(self, *args) -> None:
117+
"""Disconnect the OpenRGB client."""
118+
if not hasattr(self, "client"):
119+
# If async_config_entry_first_refresh failed, client will not exist
120+
return
121+
122+
async with self.client_lock:
123+
await self.hass.async_add_executor_job(self.client.disconnect)
124+
125+
def get_client_protocol_version(self) -> str:
126+
"""Get the OpenRGB client protocol version."""
127+
return f"{self.client.protocol_version} (Protocol)"
128+
129+
def get_device_name(self, device_key: str) -> str:
130+
"""Get device name with suffix if there are duplicates."""
131+
device = self.data[device_key]
132+
device_name = device.name
133+
134+
devices_with_same_name = [
135+
(key, dev) for key, dev in self.data.items() if dev.name == device_name
136+
]
137+
138+
if len(devices_with_same_name) == 1:
139+
return device_name
140+
141+
# Sort duplicates by device.id
142+
devices_with_same_name.sort(key=lambda x: x[1].id)
143+
144+
# Return name with numeric suffix based on the sorted order
145+
for idx, (key, _) in enumerate(devices_with_same_name, start=1):
146+
if key == device_key:
147+
return f"{device_name} {idx}"
148+
149+
# Should never reach here, but just in case
150+
return device_name # pragma: no cover
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"entity": {
3+
"light": {
4+
"openrgb_light": {
5+
"state_attributes": {
6+
"effect": {
7+
"state": {
8+
"breathing": "mdi:heart-pulse",
9+
"chase": "mdi:run-fast",
10+
"chase_fade": "mdi:run",
11+
"cram": "mdi:grid",
12+
"flashing": "mdi:flash",
13+
"music": "mdi:music-note",
14+
"neon": "mdi:lightbulb-fluorescent-tube",
15+
"rainbow": "mdi:looks",
16+
"random": "mdi:dice-multiple",
17+
"random_flicker": "mdi:shimmer",
18+
"scan": "mdi:radar",
19+
"spectrum_cycle": "mdi:gradient-horizontal",
20+
"spring": "mdi:flower",
21+
"stack": "mdi:layers",
22+
"strobe": "mdi:led-strip-variant",
23+
"water": "mdi:waves",
24+
"wave": "mdi:sine-wave"
25+
}
26+
}
27+
}
28+
}
29+
}
30+
}
31+
}

0 commit comments

Comments
 (0)