Introduce the OpenRGB integration (#153373)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
This commit is contained in:
Felipe Santos
2025-10-14 14:03:58 -03:00
committed by GitHub
parent cf477186aa
commit 18c63e3b8f
22 changed files with 2888 additions and 0 deletions

2
CODEOWNERS generated
View File

@@ -1137,6 +1137,8 @@ build.json @home-assistant/supervisor
/tests/components/opengarage/ @danielhiversen
/homeassistant/components/openhome/ @bazwilliams
/tests/components/openhome/ @bazwilliams
/homeassistant/components/openrgb/ @felipecrs
/tests/components/openrgb/ @felipecrs
/homeassistant/components/opensky/ @joostlek
/tests/components/opensky/ @joostlek
/homeassistant/components/opentherm_gw/ @mvn23

View File

@@ -0,0 +1,50 @@
"""The OpenRGB integration."""
from __future__ import annotations
from homeassistant.const import CONF_NAME, 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]
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)
return True
async def async_unload_entry(hass: HomeAssistant, entry: OpenRGBConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,81 @@
"""Config flow for the OpenRGB integration."""
from __future__ import annotations
import logging
from typing import Any
from openrgb import OpenRGBClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from .const import CONNECTION_ERRORS, DEFAULT_CLIENT_NAME, DEFAULT_PORT, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_NAME): str,
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
}
)
async def validate_input(hass: HomeAssistant, host: str, port: int) -> None:
"""Validate the user input allows us to connect."""
def _try_connect(host: str, port: int) -> None:
client = OpenRGBClient(host, port, DEFAULT_CLIENT_NAME)
client.disconnect()
await hass.async_add_executor_job(_try_connect, host, port)
class OpenRGBConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for OpenRGB."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
name = user_input[CONF_NAME]
host = user_input[CONF_HOST]
port = user_input[CONF_PORT]
# Prevent duplicate entries
self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port})
try:
await validate_input(self.hass, host, port)
except CONNECTION_ERRORS:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception(
"Unknown error while connecting to OpenRGB SDK server at %s",
f"{host}:{port}",
)
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=name,
data={
CONF_NAME: name,
CONF_HOST: host,
CONF_PORT: port,
},
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
),
errors=errors,
)

View File

@@ -0,0 +1,65 @@
"""Constants for the OpenRGB integration."""
from datetime import timedelta
from enum import StrEnum
import socket
from openrgb.utils import (
ControllerParsingError,
DeviceType,
OpenRGBDisconnected,
SDKVersionError,
)
DOMAIN = "openrgb"
# Defaults
DEFAULT_PORT = 6742
DEFAULT_CLIENT_NAME = "Home Assistant"
# Update interval
SCAN_INTERVAL = timedelta(seconds=15)
DEFAULT_COLOR = (255, 255, 255)
DEFAULT_BRIGHTNESS = 255
OFF_COLOR = (0, 0, 0)
class OpenRGBMode(StrEnum):
"""OpenRGB modes."""
OFF = "Off"
STATIC = "Static"
DIRECT = "Direct"
CUSTOM = "Custom"
EFFECT_OFF_OPENRGB_MODES = {OpenRGBMode.STATIC, OpenRGBMode.DIRECT, OpenRGBMode.CUSTOM}
DEVICE_TYPE_ICONS: dict[DeviceType, str] = {
DeviceType.MOTHERBOARD: "mdi:developer-board",
DeviceType.DRAM: "mdi:memory",
DeviceType.GPU: "mdi:expansion-card",
DeviceType.COOLER: "mdi:fan",
DeviceType.LEDSTRIP: "mdi:led-variant-on",
DeviceType.KEYBOARD: "mdi:keyboard",
DeviceType.MOUSE: "mdi:mouse",
DeviceType.MOUSEMAT: "mdi:rug",
DeviceType.HEADSET: "mdi:headset",
DeviceType.HEADSET_STAND: "mdi:headset-dock",
DeviceType.GAMEPAD: "mdi:gamepad-variant",
DeviceType.SPEAKER: "mdi:speaker",
DeviceType.STORAGE: "mdi:harddisk",
DeviceType.CASE: "mdi:desktop-tower",
DeviceType.MICROPHONE: "mdi:microphone",
DeviceType.KEYPAD: "mdi:dialpad",
}
CONNECTION_ERRORS = (
ConnectionRefusedError,
OpenRGBDisconnected,
ControllerParsingError,
TimeoutError,
socket.gaierror, # DNS errors
SDKVersionError, # The OpenRGB SDK server version is incompatible with the client
)

View File

@@ -0,0 +1,150 @@
"""DataUpdateCoordinator for OpenRGB."""
from __future__ import annotations
import asyncio
import logging
from openrgb import OpenRGBClient
from openrgb.orgb import Device
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
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
_LOGGER = logging.getLogger(__name__)
type OpenRGBConfigEntry = ConfigEntry[OpenRGBCoordinator]
class OpenRGBCoordinator(DataUpdateCoordinator[dict[str, Device]]):
"""Class to manage fetching OpenRGB data."""
client: OpenRGBClient
def __init__(
self,
hass: HomeAssistant,
config_entry: OpenRGBConfigEntry,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
config_entry=config_entry,
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=0.5, immediate=False
),
)
self.host = config_entry.data[CONF_HOST]
self.port = config_entry.data[CONF_PORT]
self.entry_id = config_entry.entry_id
self.server_address = f"{self.host}:{self.port}"
self.client_lock = asyncio.Lock()
config_entry.async_on_unload(self.async_client_disconnect)
async def _async_setup(self) -> None:
"""Set up the coordinator by connecting to the OpenRGB SDK server."""
try:
self.client = await self.hass.async_add_executor_job(
OpenRGBClient,
self.host,
self.port,
DEFAULT_CLIENT_NAME,
)
except CONNECTION_ERRORS as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={
"server_address": self.server_address,
"error": str(err),
},
) from err
async def _async_update_data(self) -> dict[str, Device]:
"""Fetch data from OpenRGB."""
async with self.client_lock:
try:
await self.hass.async_add_executor_job(self._client_update)
except CONNECTION_ERRORS as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={
"server_address": self.server_address,
"error": str(err),
},
) from err
# Return devices indexed by their key
return {self._get_device_key(device): device for device in self.client.devices}
def _client_update(self) -> None:
try:
self.client.update()
except CONNECTION_ERRORS:
# Try to reconnect once
self.client.disconnect()
self.client.connect()
self.client.update()
def _get_device_key(self, device: Device) -> str:
"""Build a stable device key.
Note: the OpenRGB device.id is intentionally not used because it is just
a positional index that can change when devices are added or removed.
"""
parts = (
self.entry_id,
device.type.name,
device.metadata.vendor or "none",
device.metadata.description or "none",
device.metadata.serial or "none",
device.metadata.location or "none",
)
# Double pipe is readable and is unlikely to appear in metadata
return "||".join(parts)
async def async_client_disconnect(self, *args) -> None:
"""Disconnect the OpenRGB client."""
if not hasattr(self, "client"):
# If async_config_entry_first_refresh failed, client will not exist
return
async with self.client_lock:
await self.hass.async_add_executor_job(self.client.disconnect)
def get_client_protocol_version(self) -> str:
"""Get the OpenRGB client protocol version."""
return f"{self.client.protocol_version} (Protocol)"
def get_device_name(self, device_key: str) -> str:
"""Get device name with suffix if there are duplicates."""
device = self.data[device_key]
device_name = device.name
devices_with_same_name = [
(key, dev) for key, dev in self.data.items() if dev.name == device_name
]
if len(devices_with_same_name) == 1:
return device_name
# Sort duplicates by device.id
devices_with_same_name.sort(key=lambda x: x[1].id)
# Return name with numeric suffix based on the sorted order
for idx, (key, _) in enumerate(devices_with_same_name, start=1):
if key == device_key:
return f"{device_name} {idx}"
# Should never reach here, but just in case
return device_name # pragma: no cover

View File

@@ -0,0 +1,31 @@
{
"entity": {
"light": {
"openrgb_light": {
"state_attributes": {
"effect": {
"state": {
"breathing": "mdi:heart-pulse",
"chase": "mdi:run-fast",
"chase_fade": "mdi:run",
"cram": "mdi:grid",
"flashing": "mdi:flash",
"music": "mdi:music-note",
"neon": "mdi:lightbulb-fluorescent-tube",
"rainbow": "mdi:looks",
"random": "mdi:dice-multiple",
"random_flicker": "mdi:shimmer",
"scan": "mdi:radar",
"spectrum_cycle": "mdi:gradient-horizontal",
"spring": "mdi:flower",
"stack": "mdi:layers",
"strobe": "mdi:led-strip-variant",
"water": "mdi:waves",
"wave": "mdi:sine-wave"
}
}
}
}
}
}
}

View File

@@ -0,0 +1,409 @@
"""OpenRGB light platform."""
from __future__ import annotations
import asyncio
from typing import Any
from openrgb.orgb import Device
from openrgb.utils import ModeColors, ModeData, RGBColor
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_EFFECT,
ATTR_RGB_COLOR,
EFFECT_OFF,
ColorMode,
LightEntity,
LightEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from homeassistant.util.color import color_hs_to_RGB, color_RGB_to_hsv
from .const import (
CONNECTION_ERRORS,
DEFAULT_BRIGHTNESS,
DEFAULT_COLOR,
DEVICE_TYPE_ICONS,
DOMAIN,
EFFECT_OFF_OPENRGB_MODES,
OFF_COLOR,
OpenRGBMode,
)
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 light platform."""
coordinator = config_entry.runtime_data
known_device_keys: set[str] = set()
def _check_device() -> None:
"""Add entities for newly discovered OpenRGB devices."""
nonlocal known_device_keys
current_keys = set(coordinator.data.keys())
new_keys = current_keys - known_device_keys
if new_keys:
known_device_keys.update(new_keys)
async_add_entities(
[OpenRGBLight(coordinator, device_key) for device_key in new_keys]
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
class OpenRGBLight(CoordinatorEntity[OpenRGBCoordinator], LightEntity):
"""Representation of an OpenRGB light."""
_attr_has_entity_name = True
_attr_name = None # Use the device name
_attr_translation_key = "openrgb_light"
_mode: str | None = None
_supports_color_modes: list[str]
_preferred_no_effect_mode: str
_supports_off_mode: bool
_supports_effects: bool
_previous_brightness: int | None = None
_previous_rgb_color: tuple[int, int, int] | None = None
_previous_mode: str | None = None
_update_events: list[asyncio.Event] = []
_effect_to_mode: dict[str, str]
def __init__(self, coordinator: OpenRGBCoordinator, device_key: str) -> None:
"""Initialize the OpenRGB light."""
super().__init__(coordinator)
self.device_key = device_key
self._attr_unique_id = device_key
device_name = coordinator.get_device_name(device_key)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device_key)},
name=device_name,
manufacturer=self.device.metadata.vendor,
model=f"{self.device.metadata.description} ({self.device.type.name})",
sw_version=self.device.metadata.version,
serial_number=self.device.metadata.serial,
via_device=(DOMAIN, coordinator.entry_id),
)
modes = [mode.name for mode in self.device.modes]
if self.device.metadata.description == "ASRock Polychrome USB Device":
# https://gitlab.com/CalcProgrammer1/OpenRGB/-/issues/5145
self._preferred_no_effect_mode = OpenRGBMode.STATIC
else:
# https://gitlab.com/CalcProgrammer1/OpenRGB/-/blob/c71cc4f18a54f83d388165ef2ab4c4ad3e980b89/RGBController/RGBController.cpp#L2075-2081
self._preferred_no_effect_mode = (
OpenRGBMode.DIRECT
if OpenRGBMode.DIRECT in modes
else OpenRGBMode.CUSTOM
if OpenRGBMode.CUSTOM in modes
else OpenRGBMode.STATIC
)
# Determine if the device supports being turned off through modes
self._supports_off_mode = OpenRGBMode.OFF in modes
# Determine which modes supports color
self._supports_color_modes = [
mode.name
for mode in self.device.modes
if check_if_mode_supports_color(mode)
]
# Initialize effects from modes
self._effect_to_mode = {}
effects = []
for mode in modes:
if mode != OpenRGBMode.OFF and mode not in EFFECT_OFF_OPENRGB_MODES:
effect_name = slugify(mode)
effects.append(effect_name)
self._effect_to_mode[effect_name] = mode
if len(effects) > 0:
self._supports_effects = True
self._attr_supported_features = LightEntityFeature.EFFECT
self._attr_effect_list = [EFFECT_OFF, *effects]
else:
self._supports_effects = False
self._attr_icon = DEVICE_TYPE_ICONS.get(self.device.type)
self._update_attrs()
@callback
def _update_attrs(self) -> None:
"""Update the attributes based on the current device state."""
mode_data = self.device.modes[self.device.active_mode]
mode = mode_data.name
if mode == OpenRGBMode.OFF:
mode = None
mode_supports_colors = False
else:
mode_supports_colors = check_if_mode_supports_color(mode_data)
color_mode = None
rgb_color = None
brightness = None
on_by_color = True
if mode_supports_colors:
# Consider the first non-black LED color as the device color
openrgb_off_color = RGBColor(*OFF_COLOR)
openrgb_color = next(
(color for color in self.device.colors if color != openrgb_off_color),
openrgb_off_color,
)
if openrgb_color == openrgb_off_color:
on_by_color = False
else:
rgb_color = (
openrgb_color.red,
openrgb_color.green,
openrgb_color.blue,
)
# Derive color and brightness from the scaled color
hsv_color = color_RGB_to_hsv(*rgb_color)
rgb_color = color_hs_to_RGB(hsv_color[0], hsv_color[1])
brightness = round(255.0 * (hsv_color[2] / 100.0))
elif mode is None:
# If mode is Off, retain previous color mode to avoid changing the UI
color_mode = self._attr_color_mode
else:
# If the current mode is not Off and does not support color, change to ON/OFF mode
color_mode = ColorMode.ONOFF
if not on_by_color:
# If Off by color, retain previous color mode to avoid changing the UI
color_mode = self._attr_color_mode
if color_mode is None:
# If color mode is still None, default to RGB
color_mode = ColorMode.RGB
if self._attr_brightness is not None and self._attr_brightness != brightness:
self._previous_brightness = self._attr_brightness
if self._attr_rgb_color is not None and self._attr_rgb_color != rgb_color:
self._previous_rgb_color = self._attr_rgb_color
if self._mode is not None and self._mode != mode:
self._previous_mode = self._mode
self._attr_color_mode = color_mode
self._attr_supported_color_modes = {color_mode}
self._attr_rgb_color = rgb_color
self._attr_brightness = brightness
if not self._supports_effects or mode is None:
self._attr_effect = None
elif mode in EFFECT_OFF_OPENRGB_MODES:
self._attr_effect = EFFECT_OFF
else:
self._attr_effect = slugify(mode)
self._mode = mode
if mode is None:
# If the mode is Off, the light is off
self._attr_is_on = False
else:
self._attr_is_on = on_by_color
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self.available:
self._update_attrs()
super()._handle_coordinator_update()
# Signal that the update has completed for all waiting events
for event in self._update_events:
event.set()
self._update_events.clear()
@property
def available(self) -> bool:
"""Return if the light is available."""
return super().available and self.device_key in self.coordinator.data
@property
def device(self) -> Device:
"""Return the OpenRGB device."""
return self.coordinator.data[self.device_key]
async def _async_refresh_data(self) -> None:
"""Request a data refresh from the coordinator and wait for it to complete."""
update_event = asyncio.Event()
self._update_events.append(update_event)
await self.coordinator.async_request_refresh()
await update_event.wait()
async def _async_apply_color(
self, rgb_color: tuple[int, int, int], brightness: int
) -> None:
"""Apply the RGB color and brightness to the device."""
brightness_factor = brightness / 255.0
scaled_color = RGBColor(
int(rgb_color[0] * brightness_factor),
int(rgb_color[1] * brightness_factor),
int(rgb_color[2] * brightness_factor),
)
async with self.coordinator.client_lock:
try:
await self.hass.async_add_executor_job(
self.device.set_color, scaled_color, True
)
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
async def _async_apply_mode(self, mode: str) -> None:
"""Apply the given mode to the device."""
async with self.coordinator.client_lock:
try:
await self.hass.async_add_executor_job(self.device.set_mode, mode)
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
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
mode = None
if ATTR_EFFECT in kwargs:
effect = kwargs[ATTR_EFFECT]
if self._attr_effect_list is None or effect not in self._attr_effect_list:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unsupported_effect",
translation_placeholders={
"effect": effect,
"device_name": self.device.name,
},
)
if effect == EFFECT_OFF:
mode = self._preferred_no_effect_mode
else:
mode = self._effect_to_mode[effect]
elif self._mode is None or (
self._attr_rgb_color is None and self._attr_brightness is None
):
# Restore previous mode when turning on from Off mode or black color
mode = self._previous_mode or self._preferred_no_effect_mode
# Check if current or new mode supports colors
if mode is None:
# When not applying a new mode, check if the current mode supports color
mode_supports_color = self._mode in self._supports_color_modes
else:
mode_supports_color = mode in self._supports_color_modes
color_or_brightness_requested = (
ATTR_RGB_COLOR in kwargs or ATTR_BRIGHTNESS in kwargs
)
if color_or_brightness_requested and not mode_supports_color:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="effect_no_color_support",
translation_placeholders={
"effect": slugify(mode or self._mode or ""),
"device_name": self.device.name,
},
)
# Apply color even if switching from Off mode to a color-capable mode
# because there is no guarantee that the device won't go back to black
need_to_apply_color = color_or_brightness_requested or (
mode_supports_color
and (self._attr_brightness is None or self._attr_rgb_color is None)
)
# If color/brightness restoration require color support but mode doesn't support it,
# switch to a color-capable mode
if need_to_apply_color and not mode_supports_color:
mode = self._preferred_no_effect_mode
if mode is not None:
await self._async_apply_mode(mode)
if need_to_apply_color:
brightness = None
if ATTR_BRIGHTNESS in kwargs:
brightness = kwargs[ATTR_BRIGHTNESS]
elif self._attr_brightness is None:
# Restore previous brightness when turning on
brightness = self._previous_brightness
if brightness is None:
# Retain current brightness or use default if still None
brightness = self._attr_brightness or DEFAULT_BRIGHTNESS
color = None
if ATTR_RGB_COLOR in kwargs:
color = kwargs[ATTR_RGB_COLOR]
elif self._attr_rgb_color is None:
# Restore previous color when turning on
color = self._previous_rgb_color
if color is None:
# Retain current color or use default if still None
color = self._attr_rgb_color or DEFAULT_COLOR
await self._async_apply_color(color, brightness)
await self._async_refresh_data()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
if self._supports_off_mode:
await self._async_apply_mode(OpenRGBMode.OFF)
else:
# If the device does not support Off mode, set color to black
await self._async_apply_color(OFF_COLOR, 0)
await self._async_refresh_data()
def check_if_mode_supports_color(mode: ModeData) -> bool:
"""Return True if the mode supports colors."""
return mode.color_mode == ModeColors.PER_LED

View File

@@ -0,0 +1,11 @@
{
"domain": "openrgb",
"name": "OpenRGB",
"codeowners": ["@felipecrs"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/openrgb",
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["openrgb-python==0.3.5"]
}

View File

@@ -0,0 +1,80 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not explicitly subscribe to events
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration has no options
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: Integration does not require authentication
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery:
status: exempt
comment: Integration does not support discovery
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices: done
entity-category:
status: exempt
comment: Integration does not expose entities that would require a category
entity-device-class:
status: exempt
comment: Integration only exposes light entities, which do not have a device class
entity-disabled-by-default:
status: exempt
comment: Integration does not expose entities that would need to be disabled by default
entity-translations: todo
exception-translations: done
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession:
status: exempt
comment: Integration does not make HTTP requests
strict-typing: todo

View File

@@ -0,0 +1,70 @@
{
"config": {
"step": {
"user": {
"description": "Set up your OpenRGB SDK server to allow control from within Home Assistant.",
"data": {
"name": "[%key:common::config_flow::data::name%]",
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"name": "A name for this integration entry, like the name of the computer running the OpenRGB SDK server.",
"host": "The IP address or hostname of the computer running the OpenRGB SDK server.",
"port": "The port number that the OpenRGB SDK server is running on."
}
}
},
"error": {
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"entity": {
"light": {
"openrgb_light": {
"state_attributes": {
"effect": {
"state": {
"breathing": "Breathing",
"chase": "Chase",
"chase_fade": "Chase fade",
"cram": "Cram",
"flashing": "Flashing",
"music": "Music",
"neon": "Neon",
"rainbow": "Rainbow",
"random": "Random",
"random_flicker": "Random flicker",
"scan": "Scan",
"spectrum_cycle": "Spectrum cycle",
"spring": "Spring",
"stack": "Stack",
"strobe": "Strobe",
"water": "Water",
"wave": "Wave"
}
}
}
}
}
},
"exceptions": {
"communication_error": {
"message": "Failed to communicate with OpenRGB SDK server {server_address}: {error}"
},
"openrgb_error": {
"message": "An OpenRGB error occurred: {error}"
},
"unsupported_effect": {
"message": "Effect `{effect}` is not supported by {device_name}"
},
"effect_no_color_support": {
"message": "Effect `{effect}` does not support color control on {device_name}"
}
}
}

View File

@@ -471,6 +471,7 @@ FLOWS = {
"openexchangerates",
"opengarage",
"openhome",
"openrgb",
"opensky",
"opentherm_gw",
"openuv",

View File

@@ -4757,6 +4757,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
"openrgb": {
"name": "OpenRGB",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling"
},
"opensensemap": {
"name": "openSenseMap",
"integration_type": "hub",

3
requirements_all.txt generated
View File

@@ -1645,6 +1645,9 @@ openevsewifi==1.1.2
# homeassistant.components.openhome
openhomedevice==2.2.0
# homeassistant.components.openrgb
openrgb-python==0.3.5
# homeassistant.components.opensensemap
opensensemap-api==0.2.0

View File

@@ -1413,6 +1413,9 @@ openerz-api==0.3.0
# homeassistant.components.openhome
openhomedevice==2.2.0
# homeassistant.components.openrgb
openrgb-python==0.3.5
# homeassistant.components.enigma2
openwebifpy==4.3.1

View File

@@ -0,0 +1 @@
"""Tests for the OpenRGB integration."""

View File

@@ -0,0 +1,123 @@
"""Fixtures for OpenRGB integration tests."""
from collections.abc import Generator
import importlib
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.openrgb.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_json_object_fixture
def _process_openrgb_dump(dump: Any) -> Any:
"""Reconstruct OpenRGB objects from dump."""
if isinstance(dump, dict):
# Reconstruct Enums
if "__enum__" in dump:
module_name, class_name = dump["__enum__"].rsplit(".", 1)
return getattr(importlib.import_module(module_name), class_name)(
dump["value"]
)
return SimpleNamespace(**{k: _process_openrgb_dump(v) for k, v in dump.items()})
if isinstance(dump, list):
return [_process_openrgb_dump(item) for item in dump]
return dump
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="Test Computer",
data={
CONF_NAME: "Test Computer",
CONF_HOST: "127.0.0.1",
CONF_PORT: 6742,
},
entry_id="01J0EXAMPLE0CONFIGENTRY00",
)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Mock setting up a config entry."""
with patch(
"homeassistant.components.openrgb.async_setup_entry", return_value=True
) as mock_setup:
yield mock_setup
@pytest.fixture
def mock_openrgb_device() -> MagicMock:
"""Return a mocked OpenRGB device."""
# Restore object from dump
device_obj = _process_openrgb_dump(
load_json_object_fixture("device_ene_dram.json", DOMAIN)
)
# Create mock from object
device = MagicMock(spec=device_obj)
device.configure_mock(**vars(device_obj))
# Methods
device.set_color = MagicMock()
device.set_mode = MagicMock()
return device
@pytest.fixture
def mock_openrgb_client(mock_openrgb_device: MagicMock) -> Generator[MagicMock]:
"""Return a mocked OpenRGB client."""
with (
patch(
"homeassistant.components.openrgb.coordinator.OpenRGBClient",
autospec=True,
) as client_mock,
patch(
"homeassistant.components.openrgb.config_flow.OpenRGBClient",
new=client_mock,
),
# Patch Debouncer to remove delays in tests
patch(
"homeassistant.components.openrgb.coordinator.Debouncer",
return_value=None,
),
):
client = client_mock.return_value
# Attributes
client.protocol_version = 4
client.devices = [mock_openrgb_device]
# Methods
client.update = MagicMock()
client.connect = MagicMock()
client.disconnect = MagicMock()
# Store the class mock so tests can set side_effect
client.client_class_mock = client_mock
yield client
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_client: MagicMock,
) -> MockConfigEntry:
"""Set up the OpenRGB integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry

View File

@@ -0,0 +1,476 @@
{
"active_mode": 2,
"colors": [
{
"blue": 0,
"green": 0,
"red": 255
},
{
"blue": 0,
"green": 0,
"red": 255
},
{
"blue": 0,
"green": 0,
"red": 255
},
{
"blue": 0,
"green": 0,
"red": 255
},
{
"blue": 0,
"green": 0,
"red": 255
}
],
"device_id": 0,
"id": 0,
"leds": [
{
"colors": [
{
"blue": 0,
"green": 0,
"red": 255
}
],
"device_id": 0,
"id": 0,
"name": "DRAM LED 1"
},
{
"colors": [
{
"blue": 0,
"green": 0,
"red": 255
}
],
"device_id": 0,
"id": 1,
"name": "DRAM LED 2"
},
{
"colors": [
{
"blue": 0,
"green": 0,
"red": 255
}
],
"device_id": 0,
"id": 2,
"name": "DRAM LED 3"
},
{
"colors": [
{
"blue": 0,
"green": 0,
"red": 255
}
],
"device_id": 0,
"id": 3,
"name": "DRAM LED 4"
},
{
"colors": [
{
"blue": 0,
"green": 0,
"red": 255
}
],
"device_id": 0,
"id": 4,
"name": "DRAM LED 5"
}
],
"metadata": {
"description": "ENE SMBus Device",
"location": "I2C: PIIX4, address 0x70",
"serial": "",
"vendor": "ENE",
"version": "DIMM_LED-0103"
},
"modes": [
{
"brightness": null,
"brightness_max": null,
"brightness_min": null,
"color_mode": {
"__enum__": "openrgb.utils.ModeColors",
"name": "PER_LED",
"value": 1
},
"colors": null,
"colors_max": null,
"colors_min": null,
"direction": null,
"flags": {
"__enum__": "openrgb.utils.ModeFlags",
"name": "HAS_PER_LED_COLOR",
"value": 32
},
"id": 0,
"name": "Direct",
"speed": null,
"speed_max": null,
"speed_min": null,
"value": 65535
},
{
"brightness": null,
"brightness_max": null,
"brightness_min": null,
"color_mode": {
"__enum__": "openrgb.utils.ModeColors",
"name": "NONE",
"value": 0
},
"colors": null,
"colors_max": null,
"colors_min": null,
"direction": null,
"flags": {
"__enum__": "openrgb.utils.ModeFlags",
"name": null,
"value": 0
},
"id": 1,
"name": "Off",
"speed": null,
"speed_max": null,
"speed_min": null,
"value": 0
},
{
"brightness": null,
"brightness_max": null,
"brightness_min": null,
"color_mode": {
"__enum__": "openrgb.utils.ModeColors",
"name": "PER_LED",
"value": 1
},
"colors": null,
"colors_max": null,
"colors_min": null,
"direction": null,
"flags": {
"__enum__": "openrgb.utils.ModeFlags",
"name": "HAS_PER_LED_COLOR",
"value": 32
},
"id": 2,
"name": "Static",
"speed": null,
"speed_max": null,
"speed_min": null,
"value": 1
},
{
"brightness": null,
"brightness_max": null,
"brightness_min": null,
"color_mode": {
"__enum__": "openrgb.utils.ModeColors",
"name": "PER_LED",
"value": 1
},
"colors": null,
"colors_max": null,
"colors_min": null,
"direction": null,
"flags": {
"__enum__": "openrgb.utils.ModeFlags",
"name": "HAS_SPEED|HAS_PER_LED_COLOR|HAS_RANDOM_COLOR",
"value": 161
},
"id": 3,
"name": "Breathing",
"speed": 2,
"speed_max": 0,
"speed_min": 4,
"value": 2
},
{
"brightness": null,
"brightness_max": null,
"brightness_min": null,
"color_mode": {
"__enum__": "openrgb.utils.ModeColors",
"name": "PER_LED",
"value": 1
},
"colors": null,
"colors_max": null,
"colors_min": null,
"direction": null,
"flags": {
"__enum__": "openrgb.utils.ModeFlags",
"name": "HAS_SPEED|HAS_PER_LED_COLOR",
"value": 33
},
"id": 4,
"name": "Flashing",
"speed": 2,
"speed_max": 0,
"speed_min": 4,
"value": 3
},
{
"brightness": null,
"brightness_max": null,
"brightness_min": null,
"color_mode": {
"__enum__": "openrgb.utils.ModeColors",
"name": "NONE",
"value": 0
},
"colors": null,
"colors_max": null,
"colors_min": null,
"direction": null,
"flags": {
"__enum__": "openrgb.utils.ModeFlags",
"name": "HAS_SPEED",
"value": 1
},
"id": 5,
"name": "Spectrum Cycle",
"speed": 2,
"speed_max": 0,
"speed_min": 4,
"value": 4
},
{
"brightness": null,
"brightness_max": null,
"brightness_min": null,
"color_mode": {
"__enum__": "openrgb.utils.ModeColors",
"name": "NONE",
"value": 0
},
"colors": null,
"colors_max": null,
"colors_min": null,
"direction": {
"__enum__": "openrgb.utils.ModeDirections",
"name": "LEFT",
"value": 0
},
"flags": {
"__enum__": "openrgb.utils.ModeFlags",
"name": "HAS_SPEED|HAS_DIRECTION_LR",
"value": 3
},
"id": 6,
"name": "Rainbow",
"speed": 0,
"speed_max": 0,
"speed_min": 4,
"value": 5
},
{
"brightness": null,
"brightness_max": null,
"brightness_min": null,
"color_mode": {
"__enum__": "openrgb.utils.ModeColors",
"name": "PER_LED",
"value": 1
},
"colors": null,
"colors_max": null,
"colors_min": null,
"direction": {
"__enum__": "openrgb.utils.ModeDirections",
"name": "LEFT",
"value": 0
},
"flags": {
"__enum__": "openrgb.utils.ModeFlags",
"name": "HAS_SPEED|HAS_DIRECTION_LR|HAS_PER_LED_COLOR|HAS_RANDOM_COLOR",
"value": 163
},
"id": 7,
"name": "Chase Fade",
"speed": 2,
"speed_max": 0,
"speed_min": 4,
"value": 7
},
{
"brightness": null,
"brightness_max": null,
"brightness_min": null,
"color_mode": {
"__enum__": "openrgb.utils.ModeColors",
"name": "PER_LED",
"value": 1
},
"colors": null,
"colors_max": null,
"colors_min": null,
"direction": {
"__enum__": "openrgb.utils.ModeDirections",
"name": "LEFT",
"value": 0
},
"flags": {
"__enum__": "openrgb.utils.ModeFlags",
"name": "HAS_SPEED|HAS_DIRECTION_LR|HAS_PER_LED_COLOR|HAS_RANDOM_COLOR",
"value": 163
},
"id": 8,
"name": "Chase",
"speed": 2,
"speed_max": 0,
"speed_min": 4,
"value": 9
},
{
"brightness": null,
"brightness_max": null,
"brightness_min": null,
"color_mode": {
"__enum__": "openrgb.utils.ModeColors",
"name": "NONE",
"value": 0
},
"colors": null,
"colors_max": null,
"colors_min": null,
"direction": null,
"flags": {
"__enum__": "openrgb.utils.ModeFlags",
"name": "HAS_SPEED",
"value": 1
},
"id": 9,
"name": "Random Flicker",
"speed": 2,
"speed_max": 0,
"speed_min": 4,
"value": 13
}
],
"name": "ENE DRAM",
"type": {
"__enum__": "openrgb.utils.DeviceType",
"name": "DRAM",
"value": 1
},
"zones": [
{
"colors": [
{
"blue": 0,
"green": 0,
"red": 255
},
{
"blue": 0,
"green": 0,
"red": 255
},
{
"blue": 0,
"green": 0,
"red": 255
},
{
"blue": 0,
"green": 0,
"red": 255
},
{
"blue": 0,
"green": 0,
"red": 255
}
],
"device_id": 0,
"id": 0,
"leds": [
{
"colors": [
{
"blue": 0,
"green": 0,
"red": 255
}
],
"device_id": 0,
"id": 0,
"name": "DRAM LED 1"
},
{
"colors": [
{
"blue": 0,
"green": 0,
"red": 255
}
],
"device_id": 0,
"id": 1,
"name": "DRAM LED 2"
},
{
"colors": [
{
"blue": 0,
"green": 0,
"red": 255
}
],
"device_id": 0,
"id": 2,
"name": "DRAM LED 3"
},
{
"colors": [
{
"blue": 0,
"green": 0,
"red": 255
}
],
"device_id": 0,
"id": 3,
"name": "DRAM LED 4"
},
{
"colors": [
{
"blue": 0,
"green": 0,
"red": 255
}
],
"device_id": 0,
"id": 4,
"name": "DRAM LED 5"
}
],
"mat_height": null,
"mat_width": null,
"matrix_map": null,
"name": "DRAM",
"segments": [],
"type": {
"__enum__": "openrgb.utils.ZoneType",
"name": "LINEAR",
"value": 1
}
}
]
}

View File

@@ -0,0 +1,32 @@
# serializer version: 1
# name: test_server_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': <DeviceEntryType.SERVICE: 'service'>,
'hw_version': None,
'id': <ANY>,
'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': <ANY>,
'serial_number': None,
'sw_version': '4 (Protocol)',
'via_device_id': None,
})
# ---

View File

@@ -0,0 +1,94 @@
# serializer version: 1
# name: test_entities[light.ene_dram-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'effect_list': list([
'off',
'breathing',
'flashing',
'spectrum_cycle',
'rainbow',
'chase_fade',
'chase',
'random_flicker',
]),
'supported_color_modes': list([
<ColorMode.RGB: 'rgb'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': None,
'entity_id': 'light.ene_dram',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:memory',
'original_name': None,
'platform': 'openrgb',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <LightEntityFeature: 4>,
'translation_key': 'openrgb_light',
'unique_id': '01J0EXAMPLE0CONFIGENTRY00||DRAM||ENE||ENE SMBus Device||none||I2C: PIIX4, address 0x70',
'unit_of_measurement': None,
})
# ---
# name: test_entities[light.ene_dram-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'brightness': 255,
'color_mode': <ColorMode.RGB: 'rgb'>,
'effect': 'off',
'effect_list': list([
'off',
'breathing',
'flashing',
'spectrum_cycle',
'rainbow',
'chase_fade',
'chase',
'random_flicker',
]),
'friendly_name': 'ENE DRAM',
'hs_color': tuple(
0.0,
100.0,
),
'icon': 'mdi:memory',
'rgb_color': tuple(
255,
0,
0,
),
'supported_color_modes': list([
<ColorMode.RGB: 'rgb'>,
]),
'supported_features': <LightEntityFeature: 4>,
'xy_color': tuple(
0.701,
0.299,
),
}),
'context': <ANY>,
'entity_id': 'light.ene_dram',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@@ -0,0 +1,114 @@
"""Tests for the OpenRGB config flow."""
import socket
from openrgb.utils import OpenRGBDisconnected, SDKVersionError
import pytest
from homeassistant.components.openrgb.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_setup_entry", "mock_openrgb_client")
async def test_full_user_flow(hass: HomeAssistant) -> None:
"""Test the full user flow from start to finish."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_NAME: "Test Computer",
CONF_HOST: "127.0.0.1",
CONF_PORT: 6742,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Test Computer"
assert result["data"] == {
CONF_NAME: "Test Computer",
CONF_HOST: "127.0.0.1",
CONF_PORT: 6742,
}
@pytest.mark.parametrize(
("exception", "error_key"),
[
(ConnectionRefusedError, "cannot_connect"),
(OpenRGBDisconnected, "cannot_connect"),
(TimeoutError, "cannot_connect"),
(socket.gaierror, "cannot_connect"),
(SDKVersionError, "cannot_connect"),
(RuntimeError("Test error"), "unknown"),
],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_errors(
hass: HomeAssistant, exception: Exception, error_key: str, mock_openrgb_client
) -> None:
"""Test user flow with various errors."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
mock_openrgb_client.client_class_mock.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_NAME: "Test Server", CONF_HOST: "127.0.0.1", CONF_PORT: 6742},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": error_key}
# Test recovery from error
mock_openrgb_client.client_class_mock.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_NAME: "Test Server", CONF_HOST: "127.0.0.1", CONF_PORT: 6742},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Test Server"
assert result["data"] == {
CONF_NAME: "Test Server",
CONF_HOST: "127.0.0.1",
CONF_PORT: 6742,
}
@pytest.mark.usefixtures("mock_setup_entry", "mock_openrgb_client")
async def test_user_flow_already_configured(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test user flow when device is already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_NAME: "Test Server", CONF_HOST: "127.0.0.1", CONF_PORT: 6742},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@@ -0,0 +1,229 @@
"""Tests for the OpenRGB integration init."""
import socket
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
from openrgb.utils import ControllerParsingError, OpenRGBDisconnected, SDKVersionError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.openrgb.const import DOMAIN, SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_ON, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_entry_setup_unload(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_client: MagicMock,
) -> None:
"""Test entry setup and unload."""
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
assert mock_config_entry.runtime_data is not None
await hass.config_entries.async_unload(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
assert mock_openrgb_client.disconnect.called
@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
@pytest.mark.parametrize(
("exception", "expected_state"),
[
(ConnectionRefusedError, ConfigEntryState.SETUP_RETRY),
(OpenRGBDisconnected, ConfigEntryState.SETUP_RETRY),
(ControllerParsingError, ConfigEntryState.SETUP_RETRY),
(TimeoutError, ConfigEntryState.SETUP_RETRY),
(socket.gaierror, ConfigEntryState.SETUP_RETRY),
(SDKVersionError, ConfigEntryState.SETUP_RETRY),
(RuntimeError("Test error"), ConfigEntryState.SETUP_RETRY),
],
)
async def test_setup_entry_exceptions(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_client: MagicMock,
exception: Exception,
expected_state: ConfigEntryState,
) -> None:
"""Test setup entry with various exceptions."""
mock_config_entry.add_to_hass(hass)
mock_openrgb_client.client_class_mock.side_effect = exception
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is expected_state
async def test_reconnection_on_update_failure(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that coordinator reconnects when update fails."""
mock_config_entry.add_to_hass(hass)
# Set up the integration
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Verify initial state
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_ON
# Reset mock call counts after initial setup
mock_openrgb_client.update.reset_mock()
mock_openrgb_client.connect.reset_mock()
# Simulate the first update call failing, then second succeeding
mock_openrgb_client.update.side_effect = [
OpenRGBDisconnected(),
None, # Second call succeeds after reconnect
]
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
await hass.async_block_till_done()
# Verify that disconnect and connect were called (reconnection happened)
mock_openrgb_client.disconnect.assert_called_once()
mock_openrgb_client.connect.assert_called_once()
# Verify that update was called twice (once failed, once after reconnect)
assert mock_openrgb_client.update.call_count == 2
# Verify that the light is still available after successful reconnect
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_ON
async def test_reconnection_fails_second_attempt(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that coordinator fails when reconnection also fails."""
mock_config_entry.add_to_hass(hass)
# Set up the integration
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Verify initial state
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_ON
# Reset mock call counts after initial setup
mock_openrgb_client.update.reset_mock()
mock_openrgb_client.connect.reset_mock()
# Simulate the first update call failing, and reconnection also failing
mock_openrgb_client.update.side_effect = [
OpenRGBDisconnected(),
None, # Second call would succeed if reconnect worked
]
# Simulate connect raising an exception to mimic failed reconnection
mock_openrgb_client.connect.side_effect = ConnectionRefusedError()
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
await hass.async_block_till_done()
await hass.async_block_till_done()
# Verify that the light became unavailable after failed reconnection
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_UNAVAILABLE
# Verify that disconnect and connect were called (reconnection was attempted)
mock_openrgb_client.disconnect.assert_called_once()
mock_openrgb_client.connect.assert_called_once()
# Verify that update was only called in the first attempt
mock_openrgb_client.update.assert_called_once()
async def test_normal_update_without_errors(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that normal updates work without triggering reconnection."""
mock_config_entry.add_to_hass(hass)
# Set up the integration
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Verify initial state
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_ON
# Reset mock call counts after initial setup
mock_openrgb_client.update.reset_mock()
mock_openrgb_client.connect.reset_mock()
# Simulate successful update
mock_openrgb_client.update.side_effect = None
mock_openrgb_client.update.return_value = None
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
await hass.async_block_till_done()
# Verify that disconnect and connect were NOT called (no reconnection needed)
mock_openrgb_client.disconnect.assert_not_called()
mock_openrgb_client.connect.assert_not_called()
# Verify that update was called only once
mock_openrgb_client.update.assert_called_once()
# Verify that the light is still available
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_ON

View File

@@ -0,0 +1,857 @@
"""Tests for the OpenRGB light platform."""
from collections.abc import Generator
import copy
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from openrgb.utils import OpenRGBDisconnected, RGBColor
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_MODE,
ATTR_EFFECT,
ATTR_RGB_COLOR,
ATTR_SUPPORTED_COLOR_MODES,
DOMAIN as LIGHT_DOMAIN,
EFFECT_OFF,
ColorMode,
LightEntityFeature,
)
from homeassistant.components.openrgb.const import (
DEFAULT_COLOR,
DOMAIN,
OFF_COLOR,
SCAN_INTERVAL,
OpenRGBMode,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
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 light_only() -> Generator[None]:
"""Enable only the light platform."""
with patch(
"homeassistant.components.openrgb.PLATFORMS",
[Platform.LIGHT],
):
yield
# 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 light entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
# Ensure entities are correctly assigned to device
device_entry = device_registry.async_get_device(
identifiers={
(
DOMAIN,
f"{mock_config_entry.entry_id}||DRAM||ENE||ENE SMBus Device||none||I2C: PIIX4, address 0x70",
)
}
)
assert device_entry
entity_entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
# Filter out the server device
entity_entries = [e for e in entity_entries if e.device_id == device_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_light_with_black_leds(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_device: MagicMock,
) -> None:
"""Test light state when all LEDs are black (off by color)."""
# Set all LEDs to black
mock_openrgb_device.colors = [RGBColor(*OFF_COLOR), RGBColor(*OFF_COLOR)]
mock_openrgb_device.active_mode = 0 # Direct mode (supports colors)
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 light is off by color
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_OFF
assert state.attributes.get(ATTR_RGB_COLOR) is None
assert state.attributes.get(ATTR_BRIGHTNESS) is None
@pytest.mark.usefixtures("mock_openrgb_client")
async def test_light_with_one_non_black_led(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_device: MagicMock,
) -> None:
"""Test light state when one LED is non-black among black LEDs (on by color)."""
# Set one LED to red, others to black
mock_openrgb_device.colors = [RGBColor(*OFF_COLOR), RGBColor(255, 0, 0)]
mock_openrgb_device.active_mode = 0 # Direct mode (supports colors)
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 light is on with the non-black LED color
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_ON
assert state.attributes.get(ATTR_COLOR_MODE) == ColorMode.RGB
assert state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) == [ColorMode.RGB]
assert state.attributes.get(ATTR_RGB_COLOR) == (255, 0, 0)
assert state.attributes.get(ATTR_BRIGHTNESS) == 255
@pytest.mark.usefixtures("mock_openrgb_client")
async def test_light_with_non_color_mode(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_device: MagicMock,
) -> None:
"""Test light state with a mode that doesn't support colors."""
# Set to Rainbow mode (doesn't support colors)
mock_openrgb_device.active_mode = 6
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 light is on with ON/OFF mode
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_ON
assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == LightEntityFeature.EFFECT
assert state.attributes.get(ATTR_EFFECT) == "rainbow"
assert state.attributes.get(ATTR_COLOR_MODE) == ColorMode.ONOFF
assert state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) == [ColorMode.ONOFF]
assert state.attributes.get(ATTR_RGB_COLOR) is None
assert state.attributes.get(ATTR_BRIGHTNESS) is None
@pytest.mark.usefixtures("mock_openrgb_client")
async def test_light_with_no_effects(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_device: MagicMock,
) -> None:
"""Test light with a device that has no effects."""
# Keep only no-effect modes in the device
mock_openrgb_device.modes = [
mode
for mode in mock_openrgb_device.modes
if mode.name in {OpenRGBMode.OFF, OpenRGBMode.DIRECT, OpenRGBMode.STATIC}
]
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 light entity doesn't have EFFECT feature
state = hass.states.get("light.ene_dram")
assert state
assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0
assert state.attributes.get(ATTR_EFFECT) is None
# Verify the light is still functional (can be turned on/off)
assert state.state == STATE_ON
# Test basic turn on/off functionality
@pytest.mark.usefixtures("mock_openrgb_client")
async def test_turn_on_light(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_device: MagicMock,
) -> None:
"""Test turning on the light."""
# Initialize device in Off mode
mock_openrgb_device.active_mode = 1
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 light is initially off
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_OFF
# Turn on without parameters
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "light.ene_dram"},
blocking=True,
)
# Verify that set_mode was called to restore to Direct mode (preferred over Static)
mock_openrgb_device.set_mode.assert_called_once_with(OpenRGBMode.DIRECT)
# And set_color was called with default color
mock_openrgb_device.set_color.assert_called_once_with(
RGBColor(*DEFAULT_COLOR), True
)
@pytest.mark.usefixtures("mock_openrgb_client")
async def test_turn_on_light_with_color(
hass: HomeAssistant,
mock_openrgb_device: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test turning on the light with color."""
# Start with color Red at half brightness
mock_openrgb_device.colors = [RGBColor(128, 0, 0), RGBColor(128, 0, 0)]
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 state
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_ON
assert state.attributes.get(ATTR_RGB_COLOR) == (255, 0, 0) # Red
assert state.attributes.get(ATTR_BRIGHTNESS) == 128
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: "light.ene_dram",
ATTR_RGB_COLOR: (0, 255, 0), # Green
},
blocking=True,
)
# Check that set_color was called with Green color scaled with half brightness
mock_openrgb_device.set_color.assert_called_once_with(RGBColor(0, 128, 0), True)
@pytest.mark.usefixtures("mock_openrgb_client")
async def test_turn_on_light_with_brightness(
hass: HomeAssistant,
mock_openrgb_device: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test turning on the light with brightness."""
# Start with color Red at full brightness
mock_openrgb_device.colors = [RGBColor(255, 0, 0), RGBColor(255, 0, 0)]
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 state
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_ON
assert state.attributes.get(ATTR_RGB_COLOR) == (255, 0, 0) # Red
assert state.attributes.get(ATTR_BRIGHTNESS) == 255
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: "light.ene_dram",
ATTR_BRIGHTNESS: 128,
},
blocking=True,
)
# Check that set_color was called with Red color scaled with half brightness
mock_openrgb_device.set_color.assert_called_once_with(RGBColor(128, 0, 0), True)
@pytest.mark.usefixtures("init_integration")
async def test_turn_on_light_with_effect(
hass: HomeAssistant,
mock_openrgb_device: MagicMock,
) -> None:
"""Test turning on the light with effect."""
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: "light.ene_dram",
ATTR_EFFECT: "rainbow",
},
blocking=True,
)
mock_openrgb_device.set_mode.assert_called_once_with("Rainbow")
@pytest.mark.usefixtures("init_integration")
async def test_turn_on_light_with_effect_off(
hass: HomeAssistant,
mock_openrgb_device: MagicMock,
) -> None:
"""Test turning on the light with effect Off."""
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: "light.ene_dram",
ATTR_EFFECT: EFFECT_OFF,
},
blocking=True,
)
# Should switch to Direct mode (preferred over Static)
mock_openrgb_device.set_mode.assert_called_once_with(OpenRGBMode.DIRECT)
@pytest.mark.usefixtures("mock_openrgb_client")
async def test_turn_on_restores_previous_values(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_device: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test turning on after off restores previous brightness, color, and mode."""
# Start with device in Static mode with blue color
mock_openrgb_device.active_mode = 2
mock_openrgb_device.colors = [RGBColor(0, 0, 128), RGBColor(0, 0, 128)]
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 state
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_ON
# Now device is in Off mode
mock_openrgb_device.active_mode = 1
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
await hass.async_block_till_done()
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_OFF
# Turn on without parameters - should restore previous mode and values
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "light.ene_dram"},
blocking=True,
)
# Should restore to Static mode (previous mode) even though Direct is preferred
mock_openrgb_device.set_mode.assert_called_once_with(OpenRGBMode.STATIC)
@pytest.mark.usefixtures("mock_openrgb_client")
async def test_previous_values_updated_on_refresh(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_device: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that previous values are updated when device state changes externally."""
# Start with device in Direct mode with red color at full brightness
mock_openrgb_device.active_mode = 0
mock_openrgb_device.colors = [RGBColor(255, 0, 0), RGBColor(255, 0, 0)]
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 state
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_ON
assert state.attributes.get(ATTR_RGB_COLOR) == (255, 0, 0) # Red
assert state.attributes.get(ATTR_BRIGHTNESS) == 255
assert state.attributes.get(ATTR_EFFECT) == EFFECT_OFF # Direct mode
# Simulate external change to green at 50% brightness in Breathing mode
# (e.g., via the OpenRGB application)
mock_openrgb_device.active_mode = 3 # Breathing mode
mock_openrgb_device.colors = [RGBColor(0, 128, 0), RGBColor(0, 128, 0)]
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
await hass.async_block_till_done()
# Verify new state
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_ON
assert state.attributes.get(ATTR_RGB_COLOR) == (0, 255, 0) # Green
assert state.attributes.get(ATTR_BRIGHTNESS) == 128 # 50% brightness
assert state.attributes.get(ATTR_EFFECT) == "breathing"
# Simulate external change to Off mode
mock_openrgb_device.active_mode = 1
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
await hass.async_block_till_done()
# Verify light is off
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_OFF
# Turn on without parameters - should restore most recent state (green, 50%, Breathing)
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "light.ene_dram"},
blocking=True,
)
mock_openrgb_device.set_mode.assert_called_once_with("Breathing")
mock_openrgb_device.set_color.assert_called_once_with(RGBColor(0, 128, 0), True)
@pytest.mark.usefixtures("mock_openrgb_client")
async def test_turn_on_restores_rainbow_after_off(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_device: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test turning on after off restores Rainbow effect (non-color mode)."""
# Start with device in Rainbow mode (doesn't support colors)
mock_openrgb_device.active_mode = 6
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 state - Rainbow mode active
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_ON
assert state.attributes.get(ATTR_EFFECT) == "rainbow"
# Turn off the light by switching to Off mode
mock_openrgb_device.active_mode = 1
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
await hass.async_block_till_done()
# Verify light is off
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_OFF
# Turn on without parameters - should restore Rainbow mode
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "light.ene_dram"},
blocking=True,
)
# Should restore to Rainbow mode (previous mode)
mock_openrgb_device.set_mode.assert_called_once_with("Rainbow")
# set_color should NOT be called since Rainbow doesn't support colors
mock_openrgb_device.set_color.assert_not_called()
@pytest.mark.usefixtures("mock_openrgb_client")
async def test_turn_on_restores_rainbow_after_off_by_color(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_device: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test turning on after off by color restores Rainbow effect (non-color mode)."""
# Start with device in Rainbow mode (doesn't support colors)
mock_openrgb_device.active_mode = 6
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 state - Rainbow mode active
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_ON
assert state.attributes.get(ATTR_EFFECT) == "rainbow"
# Turn off the light by setting all LEDs to black in Direct mode
mock_openrgb_device.active_mode = 0 # Direct mode
mock_openrgb_device.colors = [RGBColor(*OFF_COLOR), RGBColor(*OFF_COLOR)]
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
await hass.async_block_till_done()
# Verify light is off
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_OFF
# Turn on without parameters - should restore Rainbow mode, not Direct
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "light.ene_dram"},
blocking=True,
)
# Should restore to Rainbow mode (previous mode), not Direct
mock_openrgb_device.set_mode.assert_called_once_with("Rainbow")
# set_color should NOT be called since Rainbow doesn't support colors
mock_openrgb_device.set_color.assert_not_called()
@pytest.mark.usefixtures("init_integration")
async def test_turn_off_light(
hass: HomeAssistant,
mock_openrgb_device: MagicMock,
) -> None:
"""Test turning off the light."""
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "light.ene_dram"},
blocking=True,
)
# Device supports "Off" mode
mock_openrgb_device.set_mode.assert_called_once_with(OpenRGBMode.OFF)
@pytest.mark.usefixtures("mock_openrgb_client")
async def test_turn_off_light_without_off_mode(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_device: MagicMock,
) -> None:
"""Test turning off a light that doesn't support Off mode."""
# Modify the device to not have Off mode
mock_openrgb_device.modes = [
mode_data
for mode_data in mock_openrgb_device.modes
if mode_data.name != OpenRGBMode.OFF
]
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 light is initially on
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_ON
# Turn off the light
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "light.ene_dram"},
blocking=True,
)
# Device should have set_color called with black/off color instead
mock_openrgb_device.set_color.assert_called_once_with(RGBColor(*OFF_COLOR), True)
# Test error handling
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
"exception",
[OpenRGBDisconnected(), ValueError("Invalid color")],
)
async def test_turn_on_light_with_color_exceptions(
hass: HomeAssistant,
mock_openrgb_device: MagicMock,
exception: Exception,
) -> None:
"""Test turning on the light with exceptions when setting color."""
mock_openrgb_device.set_color.side_effect = exception
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: "light.ene_dram",
ATTR_RGB_COLOR: (0, 255, 0),
},
blocking=True,
)
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
"exception",
[OpenRGBDisconnected(), ValueError("Invalid mode")],
)
async def test_turn_on_light_with_mode_exceptions(
hass: HomeAssistant,
mock_openrgb_device: MagicMock,
exception: Exception,
) -> None:
"""Test turning on the light with exceptions when setting mode."""
mock_openrgb_device.set_mode.side_effect = exception
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: "light.ene_dram",
ATTR_EFFECT: "rainbow",
},
blocking=True,
)
@pytest.mark.usefixtures("init_integration")
async def test_turn_on_light_with_unsupported_effect(
hass: HomeAssistant,
) -> None:
"""Test turning on the light with an invalid effect."""
with pytest.raises(
ServiceValidationError,
match="Effect `InvalidEffect` is not supported by ENE DRAM",
):
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: "light.ene_dram",
ATTR_EFFECT: "InvalidEffect",
},
blocking=True,
)
@pytest.mark.usefixtures("init_integration")
async def test_turn_on_light_with_color_and_non_color_effect(
hass: HomeAssistant,
) -> None:
"""Test turning on the light with color/brightness and a non-color effect."""
with pytest.raises(
ServiceValidationError,
match="Effect `rainbow` does not support color control on ENE DRAM",
):
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: "light.ene_dram",
ATTR_EFFECT: "rainbow",
ATTR_RGB_COLOR: (255, 0, 0),
},
blocking=True,
)
@pytest.mark.usefixtures("init_integration")
async def test_turn_on_light_with_brightness_and_non_color_effect(
hass: HomeAssistant,
) -> None:
"""Test turning on the light with brightness and a non-color effect."""
with pytest.raises(
ServiceValidationError,
match="Effect `rainbow` does not support color control on ENE DRAM",
):
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: "light.ene_dram",
ATTR_EFFECT: "rainbow",
ATTR_BRIGHTNESS: 128,
},
blocking=True,
)
# Test device management
async def test_dynamic_device_addition(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_client: MagicMock,
mock_openrgb_device: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that new devices are added dynamically."""
mock_config_entry.add_to_hass(hass)
# Start with one device
mock_openrgb_client.devices = [mock_openrgb_device]
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
# Check that one light entity exists
state = hass.states.get("light.ene_dram")
assert state
# Add a second device
new_device = MagicMock()
new_device.id = 1 # Different device ID
new_device.name = "New RGB Device"
new_device.type = MagicMock()
new_device.type.name = "KEYBOARD"
new_device.metadata = MagicMock()
new_device.metadata.vendor = "New Vendor"
new_device.metadata.description = "New Keyboard"
new_device.metadata.serial = "NEW123"
new_device.metadata.location = "New Location"
new_device.metadata.version = "2.0.0"
new_device.active_mode = 0
new_device.modes = mock_openrgb_device.modes
new_device.colors = [RGBColor(0, 255, 0)]
new_device.set_color = MagicMock()
new_device.set_mode = MagicMock()
mock_openrgb_client.devices = [mock_openrgb_device, new_device]
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
await hass.async_block_till_done()
# Check that second light entity was added
state = hass.states.get("light.new_rgb_device")
assert state
@pytest.mark.usefixtures("init_integration")
async def test_light_availability(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test light becomes unavailable when device is unplugged."""
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_ON
# Simulate device being momentarily unplugged
mock_openrgb_client.devices = []
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
await hass.async_block_till_done()
await hass.async_block_till_done()
state = hass.states.get("light.ene_dram")
assert state
assert state.state == STATE_UNAVAILABLE
async def test_duplicate_device_names(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_openrgb_client: MagicMock,
mock_openrgb_device: MagicMock,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test that devices with duplicate names get numeric suffixes."""
device1 = copy.deepcopy(mock_openrgb_device)
device1.id = 3 # Should get suffix "1"
device1.metadata.location = "I2C: PIIX4, address 0x71"
# Create a true copy of the first device for device2 to ensure they are separate instances
device2 = copy.deepcopy(mock_openrgb_device)
device2.id = 4 # Should get suffix "2"
device2.metadata.location = "I2C: PIIX4, address 0x72"
mock_openrgb_client.devices = [device1, device2]
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
# The device key format is: entry_id||type||vendor||description||serial||location
device1_key = f"{mock_config_entry.entry_id}||DRAM||ENE||ENE SMBus Device||none||I2C: PIIX4, address 0x71"
device2_key = f"{mock_config_entry.entry_id}||DRAM||ENE||ENE SMBus Device||none||I2C: PIIX4, address 0x72"
# Verify devices exist with correct names (suffix based on device.id position)
device1_entry = device_registry.async_get_device(
identifiers={(DOMAIN, device1_key)}
)
device2_entry = device_registry.async_get_device(
identifiers={(DOMAIN, device2_key)}
)
assert device1_entry
assert device2_entry
# device1 has lower device.id, so it gets suffix "1"
# device2 has higher device.id, so it gets suffix "2"
assert device1_entry.name == "ENE DRAM 1"
assert device2_entry.name == "ENE DRAM 2"