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
|
/tests/components/opengarage/ @danielhiversen
|
||||||
/homeassistant/components/openhome/ @bazwilliams
|
/homeassistant/components/openhome/ @bazwilliams
|
||||||
/tests/components/openhome/ @bazwilliams
|
/tests/components/openhome/ @bazwilliams
|
||||||
|
/homeassistant/components/openrgb/ @felipecrs
|
||||||
|
/tests/components/openrgb/ @felipecrs
|
||||||
/homeassistant/components/opensky/ @joostlek
|
/homeassistant/components/opensky/ @joostlek
|
||||||
/tests/components/opensky/ @joostlek
|
/tests/components/opensky/ @joostlek
|
||||||
/homeassistant/components/opentherm_gw/ @mvn23
|
/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",
|
"openexchangerates",
|
||||||
"opengarage",
|
"opengarage",
|
||||||
"openhome",
|
"openhome",
|
||||||
|
"openrgb",
|
||||||
"opensky",
|
"opensky",
|
||||||
"opentherm_gw",
|
"opentherm_gw",
|
||||||
"openuv",
|
"openuv",
|
||||||
|
|||||||
@@ -4757,6 +4757,12 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
},
|
},
|
||||||
|
"openrgb": {
|
||||||
|
"name": "OpenRGB",
|
||||||
|
"integration_type": "hub",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_polling"
|
||||||
|
},
|
||||||
"opensensemap": {
|
"opensensemap": {
|
||||||
"name": "openSenseMap",
|
"name": "openSenseMap",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
|||||||
3
requirements_all.txt
generated
3
requirements_all.txt
generated
@@ -1645,6 +1645,9 @@ openevsewifi==1.1.2
|
|||||||
# homeassistant.components.openhome
|
# homeassistant.components.openhome
|
||||||
openhomedevice==2.2.0
|
openhomedevice==2.2.0
|
||||||
|
|
||||||
|
# homeassistant.components.openrgb
|
||||||
|
openrgb-python==0.3.5
|
||||||
|
|
||||||
# homeassistant.components.opensensemap
|
# homeassistant.components.opensensemap
|
||||||
opensensemap-api==0.2.0
|
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
|
# homeassistant.components.openhome
|
||||||
openhomedevice==2.2.0
|
openhomedevice==2.2.0
|
||||||
|
|
||||||
|
# homeassistant.components.openrgb
|
||||||
|
openrgb-python==0.3.5
|
||||||
|
|
||||||
# homeassistant.components.enigma2
|
# homeassistant.components.enigma2
|
||||||
openwebifpy==4.3.1
|
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