mirror of
https://github.com/home-assistant/core.git
synced 2025-11-09 02:49:40 +00:00
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:
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -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
|
||||
|
||||
50
homeassistant/components/openrgb/__init__.py
Normal file
50
homeassistant/components/openrgb/__init__.py
Normal 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)
|
||||
81
homeassistant/components/openrgb/config_flow.py
Normal file
81
homeassistant/components/openrgb/config_flow.py
Normal 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,
|
||||
)
|
||||
65
homeassistant/components/openrgb/const.py
Normal file
65
homeassistant/components/openrgb/const.py
Normal 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
|
||||
)
|
||||
150
homeassistant/components/openrgb/coordinator.py
Normal file
150
homeassistant/components/openrgb/coordinator.py
Normal 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
|
||||
31
homeassistant/components/openrgb/icons.json
Normal file
31
homeassistant/components/openrgb/icons.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
409
homeassistant/components/openrgb/light.py
Normal file
409
homeassistant/components/openrgb/light.py
Normal 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
|
||||
11
homeassistant/components/openrgb/manifest.json
Normal file
11
homeassistant/components/openrgb/manifest.json
Normal 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"]
|
||||
}
|
||||
80
homeassistant/components/openrgb/quality_scale.yaml
Normal file
80
homeassistant/components/openrgb/quality_scale.yaml
Normal 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
|
||||
70
homeassistant/components/openrgb/strings.json
Normal file
70
homeassistant/components/openrgb/strings.json
Normal 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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -471,6 +471,7 @@ FLOWS = {
|
||||
"openexchangerates",
|
||||
"opengarage",
|
||||
"openhome",
|
||||
"openrgb",
|
||||
"opensky",
|
||||
"opentherm_gw",
|
||||
"openuv",
|
||||
|
||||
@@ -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
3
requirements_all.txt
generated
@@ -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
|
||||
|
||||
|
||||
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
|
||||
1
tests/components/openrgb/__init__.py
Normal file
1
tests/components/openrgb/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the OpenRGB integration."""
|
||||
123
tests/components/openrgb/conftest.py
Normal file
123
tests/components/openrgb/conftest.py
Normal 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
|
||||
476
tests/components/openrgb/fixtures/device_ene_dram.json
Normal file
476
tests/components/openrgb/fixtures/device_ene_dram.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
32
tests/components/openrgb/snapshots/test_init.ambr
Normal file
32
tests/components/openrgb/snapshots/test_init.ambr
Normal 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,
|
||||
})
|
||||
# ---
|
||||
94
tests/components/openrgb/snapshots/test_light.ambr
Normal file
94
tests/components/openrgb/snapshots/test_light.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
114
tests/components/openrgb/test_config_flow.py
Normal file
114
tests/components/openrgb/test_config_flow.py
Normal 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"
|
||||
229
tests/components/openrgb/test_init.py
Normal file
229
tests/components/openrgb/test_init.py
Normal 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
|
||||
857
tests/components/openrgb/test_light.py
Normal file
857
tests/components/openrgb/test_light.py
Normal 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"
|
||||
Reference in New Issue
Block a user