diff --git a/CODEOWNERS b/CODEOWNERS index 6e3c0928390..6ffc83f6bf3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1137,6 +1137,8 @@ build.json @home-assistant/supervisor /tests/components/opengarage/ @danielhiversen /homeassistant/components/openhome/ @bazwilliams /tests/components/openhome/ @bazwilliams +/homeassistant/components/openrgb/ @felipecrs +/tests/components/openrgb/ @felipecrs /homeassistant/components/opensky/ @joostlek /tests/components/opensky/ @joostlek /homeassistant/components/opentherm_gw/ @mvn23 diff --git a/homeassistant/components/openrgb/__init__.py b/homeassistant/components/openrgb/__init__.py new file mode 100644 index 00000000000..320a5aeebc5 --- /dev/null +++ b/homeassistant/components/openrgb/__init__.py @@ -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) diff --git a/homeassistant/components/openrgb/config_flow.py b/homeassistant/components/openrgb/config_flow.py new file mode 100644 index 00000000000..fd82fdca726 --- /dev/null +++ b/homeassistant/components/openrgb/config_flow.py @@ -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, + ) diff --git a/homeassistant/components/openrgb/const.py b/homeassistant/components/openrgb/const.py new file mode 100644 index 00000000000..eb136006037 --- /dev/null +++ b/homeassistant/components/openrgb/const.py @@ -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 +) diff --git a/homeassistant/components/openrgb/coordinator.py b/homeassistant/components/openrgb/coordinator.py new file mode 100644 index 00000000000..4a24fcb529e --- /dev/null +++ b/homeassistant/components/openrgb/coordinator.py @@ -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 diff --git a/homeassistant/components/openrgb/icons.json b/homeassistant/components/openrgb/icons.json new file mode 100644 index 00000000000..4eaa56b2427 --- /dev/null +++ b/homeassistant/components/openrgb/icons.json @@ -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" + } + } + } + } + } + } +} diff --git a/homeassistant/components/openrgb/light.py b/homeassistant/components/openrgb/light.py new file mode 100644 index 00000000000..76d80cd8faf --- /dev/null +++ b/homeassistant/components/openrgb/light.py @@ -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 diff --git a/homeassistant/components/openrgb/manifest.json b/homeassistant/components/openrgb/manifest.json new file mode 100644 index 00000000000..853b4ba357c --- /dev/null +++ b/homeassistant/components/openrgb/manifest.json @@ -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"] +} diff --git a/homeassistant/components/openrgb/quality_scale.yaml b/homeassistant/components/openrgb/quality_scale.yaml new file mode 100644 index 00000000000..b969915020c --- /dev/null +++ b/homeassistant/components/openrgb/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/openrgb/strings.json b/homeassistant/components/openrgb/strings.json new file mode 100644 index 00000000000..908443cbef8 --- /dev/null +++ b/homeassistant/components/openrgb/strings.json @@ -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}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2a2658c535b..7ef474bce63 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -471,6 +471,7 @@ FLOWS = { "openexchangerates", "opengarage", "openhome", + "openrgb", "opensky", "opentherm_gw", "openuv", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7f11b65001a..bd49c9d1e04 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4757,6 +4757,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "openrgb": { + "name": "OpenRGB", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "opensensemap": { "name": "openSenseMap", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index bd526904c8e..1458322946b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1645,6 +1645,9 @@ openevsewifi==1.1.2 # homeassistant.components.openhome openhomedevice==2.2.0 +# homeassistant.components.openrgb +openrgb-python==0.3.5 + # homeassistant.components.opensensemap opensensemap-api==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bba0afb793b..de15be9da80 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1413,6 +1413,9 @@ openerz-api==0.3.0 # homeassistant.components.openhome openhomedevice==2.2.0 +# homeassistant.components.openrgb +openrgb-python==0.3.5 + # homeassistant.components.enigma2 openwebifpy==4.3.1 diff --git a/tests/components/openrgb/__init__.py b/tests/components/openrgb/__init__.py new file mode 100644 index 00000000000..9d9cd6d5f4b --- /dev/null +++ b/tests/components/openrgb/__init__.py @@ -0,0 +1 @@ +"""Tests for the OpenRGB integration.""" diff --git a/tests/components/openrgb/conftest.py b/tests/components/openrgb/conftest.py new file mode 100644 index 00000000000..71208e3cc6e --- /dev/null +++ b/tests/components/openrgb/conftest.py @@ -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 diff --git a/tests/components/openrgb/fixtures/device_ene_dram.json b/tests/components/openrgb/fixtures/device_ene_dram.json new file mode 100644 index 00000000000..c711d2834db --- /dev/null +++ b/tests/components/openrgb/fixtures/device_ene_dram.json @@ -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 + } + } + ] +} diff --git a/tests/components/openrgb/snapshots/test_init.ambr b/tests/components/openrgb/snapshots/test_init.ambr new file mode 100644 index 00000000000..6c1d760c9cd --- /dev/null +++ b/tests/components/openrgb/snapshots/test_init.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_server_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + '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': , + 'serial_number': None, + 'sw_version': '4 (Protocol)', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/openrgb/snapshots/test_light.ambr b/tests/components/openrgb/snapshots/test_light.ambr new file mode 100644 index 00000000000..bb195cbebaf --- /dev/null +++ b/tests/components/openrgb/snapshots/test_light.ambr @@ -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([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.ene_dram', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + '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': , + '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([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.701, + 0.299, + ), + }), + 'context': , + 'entity_id': 'light.ene_dram', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/openrgb/test_config_flow.py b/tests/components/openrgb/test_config_flow.py new file mode 100644 index 00000000000..8160f36fb11 --- /dev/null +++ b/tests/components/openrgb/test_config_flow.py @@ -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" diff --git a/tests/components/openrgb/test_init.py b/tests/components/openrgb/test_init.py new file mode 100644 index 00000000000..55e2c8c616f --- /dev/null +++ b/tests/components/openrgb/test_init.py @@ -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 diff --git a/tests/components/openrgb/test_light.py b/tests/components/openrgb/test_light.py new file mode 100644 index 00000000000..7e1be565049 --- /dev/null +++ b/tests/components/openrgb/test_light.py @@ -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"