diff --git a/CODEOWNERS b/CODEOWNERS index 6e3c0928390576..6ffc83f6bf300e 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/airos/manifest.json b/homeassistant/components/airos/manifest.json index 5113aaf939e5df..ea73af579595a2 100644 --- a/homeassistant/components/airos/manifest.json +++ b/homeassistant/components/airos/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/airos", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["airos==0.5.6"] } diff --git a/homeassistant/components/airos/quality_scale.yaml b/homeassistant/components/airos/quality_scale.yaml index e8a5ce8ed894f9..b234afdc485a19 100644 --- a/homeassistant/components/airos/quality_scale.yaml +++ b/homeassistant/components/airos/quality_scale.yaml @@ -32,11 +32,11 @@ rules: config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done - entity-unavailable: todo + entity-unavailable: done integration-owner: done - log-when-unavailable: todo - parallel-updates: todo - reauthentication-flow: todo + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done test-coverage: done # Gold @@ -48,9 +48,9 @@ rules: docs-examples: todo docs-known-limitations: done docs-supported-devices: done - docs-supported-functions: todo + docs-supported-functions: done docs-troubleshooting: done - docs-use-cases: todo + docs-use-cases: done dynamic-devices: todo entity-category: done entity-device-class: done @@ -60,7 +60,7 @@ rules: icon-translations: status: exempt comment: no (custom) icons used or envisioned - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: todo diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index 64baba6d1e8684..e258c662bc7cd7 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -282,6 +282,7 @@ def async_update_group_state(self) -> None: self._attr_is_closed = True self._attr_is_closing = False self._attr_is_opening = False + self._update_assumed_state_from_members() for entity_id in self._entity_ids: if not (state := self.hass.states.get(entity_id)): continue diff --git a/homeassistant/components/group/entity.py b/homeassistant/components/group/entity.py index 40db70a2eb35a4..f9d9a62a0ac7a1 100644 --- a/homeassistant/components/group/entity.py +++ b/homeassistant/components/group/entity.py @@ -115,6 +115,17 @@ def async_defer_or_update_ha_state(self) -> None: def async_update_group_state(self) -> None: """Abstract method to update the entity.""" + @callback + def _update_assumed_state_from_members(self) -> None: + """Update assumed_state based on member entities.""" + self._attr_assumed_state = False + for entity_id in self._entity_ids: + if (state := self.hass.states.get(entity_id)) is None: + continue + if state.attributes.get(ATTR_ASSUMED_STATE): + self._attr_assumed_state = True + return + @callback def async_update_supported_features( self, diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 78745cb74c608c..621c00bb156740 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -252,6 +252,7 @@ def _set_attr_most_frequent(self, attr: str, flag: int, entity_attr: str) -> Non @callback def async_update_group_state(self) -> None: """Update state and attributes.""" + self._update_assumed_state_from_members() states = [ state diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 259832d6152d60..564a9f12ed9920 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -205,6 +205,8 @@ async def async_turn_off(self, **kwargs: Any) -> None: @callback def async_update_group_state(self) -> None: """Query all members and determine the light group state.""" + self._update_assumed_state_from_members() + states = [ state for entity_id in self._entity_ids diff --git a/homeassistant/components/group/switch.py b/homeassistant/components/group/switch.py index 29e625ca8e367c..0a13e2cf2051eb 100644 --- a/homeassistant/components/group/switch.py +++ b/homeassistant/components/group/switch.py @@ -156,6 +156,8 @@ async def async_turn_off(self, **kwargs: Any) -> None: @callback def async_update_group_state(self) -> None: """Query all members and determine the switch group state.""" + self._update_assumed_state_from_members() + states = [ state.state for entity_id in self._entity_ids diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 4f0217fd0c654f..77ca0c851ec567 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -177,6 +177,9 @@ async def async_step_user( step_id="user", data_schema=_data_schema(self.discovered_conf), errors=errors, + description_placeholders={ + "sample_ip": "http://192.168.10.100:80", + }, ) async def _async_set_unique_id_or_update( @@ -302,7 +305,10 @@ async def async_step_reauth_confirm( CONF_HOST: existing_data[CONF_HOST], } return self.async_show_form( - description_placeholders={CONF_HOST: existing_data[CONF_HOST]}, + description_placeholders={ + CONF_HOST: existing_data[CONF_HOST], + "sample_ip": "http://192.168.10.100:80", + }, step_id="reauth_confirm", data_schema=vol.Schema( { @@ -347,7 +353,13 @@ async def async_step_init( } ) - return self.async_show_form(step_id="init", data_schema=options_schema) + return self.async_show_form( + step_id="init", + data_schema=options_schema, + description_placeholders={ + "sample_ip": "http://192.168.10.100:80", + }, + ) class InvalidHost(HomeAssistantError): diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index 73f6cc98b12c0f..230ad5fca4e85c 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -9,7 +9,7 @@ "password": "[%key:common::config_flow::data::password%]", "tls": "The TLS version of the ISY controller." }, - "description": "The host entry must be in full URL format, e.g., http://192.168.10.100:80", + "description": "The host entry must be in full URL format, e.g., {sample_ip}", "title": "Connect to your ISY" }, "reauth_confirm": { @@ -26,7 +26,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_host": "The host entry was not in full URL format, e.g., http://192.168.10.100:80" + "invalid_host": "The host entry was not in full URL format, e.g., {sample_ip}" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 7fb4455d546d61..47d2908f5cfbb0 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -109,6 +109,7 @@ STATE_OPEN, STATE_OPENING, EntityCategory, + Platform, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, async_get_hass, callback @@ -417,7 +418,6 @@ TRANSPORT_TCP, TRANSPORT_WEBSOCKETS, VALUES_ON_COMMAND_TYPE, - Platform, ) from .models import MqttAvailabilityData, MqttDeviceData, MqttSubentryData from .util import ( @@ -821,10 +821,7 @@ @callback def configured_target_temperature_feature(config: dict[str, Any]) -> str: """Calculate current target temperature feature from config.""" - if ( - config == {CONF_PLATFORM: Platform.CLIMATE.value} - or CONF_TEMP_COMMAND_TOPIC in config - ): + if config == {CONF_PLATFORM: Platform.CLIMATE} or CONF_TEMP_COMMAND_TOPIC in config: # default to single on initial set return "single" if CONF_TEMP_HIGH_COMMAND_TOPIC in config: @@ -1156,20 +1153,20 @@ def validate_sensor_platform_config( Callable[[dict[str, Any]], dict[str, str]] | None, ] = { Platform.ALARM_CONTROL_PANEL: None, - Platform.BINARY_SENSOR.value: None, - Platform.BUTTON.value: None, - Platform.CLIMATE.value: validate_climate_platform_config, - Platform.COVER.value: validate_cover_platform_config, - Platform.FAN.value: validate_fan_platform_config, - Platform.IMAGE.value: None, - Platform.LIGHT.value: validate_light_platform_config, - Platform.LOCK.value: None, - Platform.NOTIFY.value: None, - Platform.NUMBER.value: validate_number_platform_config, + Platform.BINARY_SENSOR: None, + Platform.BUTTON: None, + Platform.CLIMATE: validate_climate_platform_config, + Platform.COVER: validate_cover_platform_config, + Platform.FAN: validate_fan_platform_config, + Platform.IMAGE: None, + Platform.LIGHT: validate_light_platform_config, + Platform.LOCK: None, + Platform.NOTIFY: None, + Platform.NUMBER: validate_number_platform_config, Platform.SELECT: None, + Platform.SENSOR: validate_sensor_platform_config, Platform.SIREN: None, - Platform.SENSOR.value: validate_sensor_platform_config, - Platform.SWITCH.value: None, + Platform.SWITCH: None, } @@ -1216,8 +1213,8 @@ class PlatformField: default=None, ), } -PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = { - Platform.ALARM_CONTROL_PANEL.value: { +PLATFORM_ENTITY_FIELDS: dict[Platform, dict[str, PlatformField]] = { + Platform.ALARM_CONTROL_PANEL: { CONF_SUPPORTED_FEATURES: PlatformField( selector=ALARM_CONTROL_PANEL_FEATURES_SELECTOR, required=True, @@ -1234,7 +1231,7 @@ class PlatformField: else "local_code", ), }, - Platform.BINARY_SENSOR.value: { + Platform.BINARY_SENSOR: { CONF_DEVICE_CLASS: PlatformField( selector=BINARY_SENSOR_DEVICE_CLASS_SELECTOR, required=False, @@ -1245,13 +1242,13 @@ class PlatformField: default=None, ), }, - Platform.BUTTON.value: { + Platform.BUTTON: { CONF_DEVICE_CLASS: PlatformField( selector=BUTTON_DEVICE_CLASS_SELECTOR, required=False, ), }, - Platform.CLIMATE.value: { + Platform.CLIMATE: { CONF_TEMPERATURE_UNIT: PlatformField( selector=TEMPERATURE_UNIT_SELECTOR, validator=validate(cv.temperature_unit), @@ -1323,13 +1320,13 @@ class PlatformField: default=lambda config: bool(config.get(CONF_POWER_COMMAND_TOPIC)), ), }, - Platform.COVER.value: { + Platform.COVER: { CONF_DEVICE_CLASS: PlatformField( selector=COVER_DEVICE_CLASS_SELECTOR, required=False, ), }, - Platform.FAN.value: { + Platform.FAN: { "fan_feature_speed": PlatformField( selector=BOOLEAN_SELECTOR, required=False, @@ -1355,7 +1352,7 @@ class PlatformField: default=lambda config: bool(config.get(CONF_DIRECTION_COMMAND_TOPIC)), ), }, - Platform.IMAGE.value: { + Platform.IMAGE: { "image_processing_mode": PlatformField( selector=IMAGE_PROCESSING_MODE_SELECTOR, required=True, @@ -1367,7 +1364,7 @@ class PlatformField: ), ) }, - Platform.LIGHT.value: { + Platform.LIGHT: { CONF_SCHEMA: PlatformField( selector=LIGHT_SCHEMA_SELECTOR, required=True, @@ -1381,8 +1378,8 @@ class PlatformField: is_schema_default=True, ), }, - Platform.LOCK.value: {}, - Platform.NOTIFY.value: {}, + Platform.LOCK: {}, + Platform.NOTIFY: {}, Platform.NUMBER: { CONF_DEVICE_CLASS: PlatformField( selector=NUMBER_DEVICE_CLASS_SELECTOR, @@ -1394,8 +1391,8 @@ class PlatformField: custom_filtering=True, ), }, - Platform.SELECT.value: {}, - Platform.SENSOR.value: { + Platform.SELECT: {}, + Platform.SENSOR: { CONF_DEVICE_CLASS: PlatformField( selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False ), @@ -1425,13 +1422,13 @@ class PlatformField: ), }, Platform.SIREN: {}, - Platform.SWITCH.value: { + Platform.SWITCH: { CONF_DEVICE_CLASS: PlatformField( selector=SWITCH_DEVICE_CLASS_SELECTOR, required=False ), }, } -PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = { +PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = { Platform.ALARM_CONTROL_PANEL: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, @@ -1518,7 +1515,7 @@ class PlatformField: section="alarm_control_panel_payload_settings", ), }, - Platform.BINARY_SENSOR.value: { + Platform.BINARY_SENSOR: { CONF_STATE_TOPIC: PlatformField( selector=TEXT_SELECTOR, required=True, @@ -1554,7 +1551,7 @@ class PlatformField: section="advanced_settings", ), }, - Platform.BUTTON.value: { + Platform.BUTTON: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, required=True, @@ -1574,7 +1571,7 @@ class PlatformField: ), CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), }, - Platform.CLIMATE.value: { + Platform.CLIMATE: { # operation mode settings CONF_MODE_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, @@ -2064,7 +2061,7 @@ class PlatformField: conditions=({"climate_feature_swing_horizontal_modes": True},), ), }, - Platform.COVER.value: { + Platform.COVER: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, required=False, @@ -2243,7 +2240,7 @@ class PlatformField: section="cover_tilt_settings", ), }, - Platform.FAN.value: { + Platform.FAN: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, required=True, @@ -2463,7 +2460,7 @@ class PlatformField: conditions=({"fan_feature_direction": True},), ), }, - Platform.IMAGE.value: { + Platform.IMAGE: { CONF_IMAGE_TOPIC: PlatformField( selector=TEXT_SELECTOR, required=True, @@ -2497,7 +2494,7 @@ class PlatformField: error="invalid_template", ), }, - Platform.LIGHT.value: { + Platform.LIGHT: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, required=True, @@ -2978,7 +2975,7 @@ class PlatformField: section="advanced_settings", ), }, - Platform.LOCK.value: { + Platform.LOCK: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, required=True, @@ -3065,7 +3062,7 @@ class PlatformField: CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), CONF_OPTIMISTIC: PlatformField(selector=BOOLEAN_SELECTOR, required=False), }, - Platform.NOTIFY.value: { + Platform.NOTIFY: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, required=True, @@ -3080,7 +3077,7 @@ class PlatformField: ), CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), }, - Platform.NUMBER.value: { + Platform.NUMBER: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, required=True, @@ -3132,7 +3129,7 @@ class PlatformField: ), CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), }, - Platform.SELECT.value: { + Platform.SELECT: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, required=True, @@ -3160,7 +3157,7 @@ class PlatformField: CONF_OPTIONS: PlatformField(selector=OPTIONS_SELECTOR, required=True), CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), }, - Platform.SENSOR.value: { + Platform.SENSOR: { CONF_STATE_TOPIC: PlatformField( selector=TEXT_SELECTOR, required=True, @@ -3252,7 +3249,7 @@ class PlatformField: section="siren_advanced_settings", ), }, - Platform.SWITCH.value: { + Platform.SWITCH: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, required=True, diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index a9fdf5fd771520..24426d7543f8a6 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -428,7 +428,9 @@ async def async_step_model( if user_input is not None: if user_input.get(CONF_WEB_SEARCH): - if user_input.get(CONF_WEB_SEARCH_USER_LOCATION): + if user_input.get(CONF_REASONING_EFFORT) == "minimal": + errors[CONF_WEB_SEARCH] = "web_search_minimal_reasoning" + if user_input.get(CONF_WEB_SEARCH_USER_LOCATION) and not errors: user_input.update(await self._get_location_data()) else: options.pop(CONF_WEB_SEARCH_CITY, None) @@ -437,16 +439,17 @@ async def async_step_model( options.pop(CONF_WEB_SEARCH_TIMEZONE, None) options.update(user_input) - if self._is_new: - return self.async_create_entry( - title=options.pop(CONF_NAME), + if not errors: + if self._is_new: + return self.async_create_entry( + title=options.pop(CONF_NAME), + data=options, + ) + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), data=options, ) - return self.async_update_and_abort( - self._get_entry(), - self._get_reconfigure_subentry(), - data=options, - ) return self.async_show_form( step_id="model", diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 9d936e03348acd..5118c5b8f9839c 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -55,6 +55,7 @@ ] UNSUPPORTED_WEB_SEARCH_MODELS: list[str] = [ + "gpt-5-nano", "gpt-3.5", "gpt-4-turbo", "gpt-4.1-nano", @@ -63,7 +64,7 @@ ] UNSUPPORTED_IMAGE_MODELS: list[str] = [ - "gpt-5", + "gpt-5-mini", "o3-mini", "o4", "o1", diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index e5b3cb30646574..acfe1475f5d23a 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -70,7 +70,8 @@ "entry_not_loaded": "Cannot add things while the configuration is disabled." }, "error": { - "model_not_supported": "This model is not supported, please select a different model" + "model_not_supported": "This model is not supported, please select a different model", + "web_search_minimal_reasoning": "Web search is currently not supported with minimal reasoning effort" } }, "ai_task_data": { @@ -98,6 +99,7 @@ "model": { "title": "[%key:component::openai_conversation::config_subentries::conversation::step::model::title%]", "data": { + "code_interpreter": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::code_interpreter%]", "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_effort%]", "image_model": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::image_model%]", "web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::web_search%]", @@ -105,6 +107,7 @@ "user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::user_location%]" }, "data_description": { + "code_interpreter": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::code_interpreter%]", "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_effort%]", "image_model": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::image_model%]", "web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::web_search%]", @@ -118,7 +121,8 @@ "entry_not_loaded": "[%key:component::openai_conversation::config_subentries::conversation::abort::entry_not_loaded%]" }, "error": { - "model_not_supported": "[%key:component::openai_conversation::config_subentries::conversation::error::model_not_supported%]" + "model_not_supported": "[%key:component::openai_conversation::config_subentries::conversation::error::model_not_supported%]", + "web_search_minimal_reasoning": "[%key:component::openai_conversation::config_subentries::conversation::error::web_search_minimal_reasoning%]" } } }, diff --git a/homeassistant/components/openrgb/__init__.py b/homeassistant/components/openrgb/__init__.py new file mode 100644 index 00000000000000..320a5aeebc5871 --- /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 00000000000000..fd82fdca726c9a --- /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 00000000000000..eb1360060370ca --- /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 00000000000000..4a24fcb529e5b3 --- /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 00000000000000..4eaa56b242795a --- /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 00000000000000..76d80cd8faf875 --- /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 00000000000000..853b4ba357c1fa --- /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 00000000000000..b969915020c6e9 --- /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 00000000000000..908443cbef8e7f --- /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/components/pushsafer/notify.py b/homeassistant/components/pushsafer/notify.py index faca654b4207eb..4d6f7898a5a27c 100644 --- a/homeassistant/components/pushsafer/notify.py +++ b/homeassistant/components/pushsafer/notify.py @@ -88,7 +88,7 @@ def send_message(self, message="", **kwargs): _LOGGER.debug("%s target(s) specified", len(targets)) title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - data = kwargs.get(ATTR_DATA, {}) + data = kwargs.get(ATTR_DATA) or {} # Converting the specified image to base64 picture1 = data.get(ATTR_PICTURE1) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2a2658c535b44d..7ef474bce63724 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -471,6 +471,7 @@ "openexchangerates", "opengarage", "openhome", + "openrgb", "opensky", "opentherm_gw", "openuv", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7f11b65001aa19..bd49c9d1e04ed8 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/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5227a58b8e63d9..36e60447720a08 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -130,7 +130,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.12.1 +pydantic==2.12.2 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index bd526904c8e231..1458322946b6db 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.txt b/requirements_test.txt index a7bd4b6b2a9a8b..ddcb51ef5f532e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -17,7 +17,7 @@ license-expression==30.4.3 mock-open==1.4.0 mypy-dev==1.19.0a4 pre-commit==4.2.0 -pydantic==2.12.1 +pydantic==2.12.2 pylint==4.0.0 pylint-per-file-ignores==1.4.0 pipdeptree==2.26.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bba0afb793b5dc..de15be9da8092b 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/script/gen_requirements_all.py b/script/gen_requirements_all.py index 62982b1a11c595..749cbe99783bb3 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -155,7 +155,7 @@ backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.12.1 +pydantic==2.12.2 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index ab92b18cc91f82..a8fe0f8d96e0ce 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -421,13 +421,6 @@ async def test_attributes( assert ATTR_CURRENT_POSITION not in state.attributes assert ATTR_CURRENT_TILT_POSITION not in state.attributes - # Group member has set assumed_state - hass.states.async_set(DEMO_TILT, CoverState.CLOSED, {ATTR_ASSUMED_STATE: True}) - await hass.async_block_till_done() - - state = hass.states.get(COVER_GROUP) - assert ATTR_ASSUMED_STATE not in state.attributes - # Test entity registry integration entry = entity_registry.async_get(COVER_GROUP) assert entry @@ -859,6 +852,61 @@ async def test_is_opening_closing(hass: HomeAssistant) -> None: assert hass.states.get(COVER_GROUP).state == CoverState.OPENING +@pytest.mark.parametrize("config_count", [(CONFIG_ATTRIBUTES, 1)]) +@pytest.mark.usefixtures("setup_comp") +async def test_assumed_state(hass: HomeAssistant) -> None: + """Test assumed_state attribute behavior.""" + # No members with assumed_state -> group doesn't have assumed_state in attributes + hass.states.async_set(DEMO_COVER, CoverState.OPEN, {}) + hass.states.async_set(DEMO_COVER_POS, CoverState.OPEN, {}) + hass.states.async_set(DEMO_COVER_TILT, CoverState.CLOSED, {}) + hass.states.async_set(DEMO_TILT, CoverState.CLOSED, {}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert ATTR_ASSUMED_STATE not in state.attributes + + # One member with assumed_state=True -> group has assumed_state=True + hass.states.async_set(DEMO_COVER, CoverState.OPEN, {ATTR_ASSUMED_STATE: True}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # Multiple members with assumed_state=True -> group has assumed_state=True + hass.states.async_set( + DEMO_COVER_TILT, CoverState.CLOSED, {ATTR_ASSUMED_STATE: True} + ) + hass.states.async_set(DEMO_TILT, CoverState.CLOSED, {ATTR_ASSUMED_STATE: True}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # Unavailable member with assumed_state=True -> group has assumed_state=True + hass.states.async_set(DEMO_COVER, CoverState.OPEN, {}) + hass.states.async_set(DEMO_COVER_TILT, CoverState.CLOSED, {}) + hass.states.async_set(DEMO_TILT, STATE_UNAVAILABLE, {ATTR_ASSUMED_STATE: True}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # Unknown member with assumed_state=True -> group has assumed_state=True + hass.states.async_set(DEMO_TILT, STATE_UNKNOWN, {ATTR_ASSUMED_STATE: True}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # All members without assumed_state -> group doesn't have assumed_state in attributes + hass.states.async_set(DEMO_TILT, CoverState.CLOSED, {}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert ATTR_ASSUMED_STATE not in state.attributes + + async def test_nested_group(hass: HomeAssistant) -> None: """Test nested cover group.""" await async_setup_component( diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py index 93509b5a651e0b..96c2cb33f6a131 100644 --- a/tests/components/group/test_fan.py +++ b/tests/components/group/test_fan.py @@ -587,3 +587,47 @@ async def test_nested_group(hass: HomeAssistant) -> None: assert hass.states.get(PERCENTAGE_FULL_FAN_ENTITY_ID).state == STATE_ON assert hass.states.get("fan.bedroom_group").state == STATE_ON assert hass.states.get("fan.nested_group").state == STATE_ON + + +async def test_assumed_state(hass: HomeAssistant) -> None: + """Test assumed_state attribute behavior.""" + await async_setup_component( + hass, + FAN_DOMAIN, + { + FAN_DOMAIN: [ + {"platform": "demo"}, + { + "platform": "group", + CONF_ENTITIES: [LIVING_ROOM_FAN_ENTITY_ID, CEILING_FAN_ENTITY_ID], + }, + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # No members with assumed_state -> group doesn't have assumed_state in attributes + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_ON, {}) + hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_OFF, {}) + await hass.async_block_till_done() + + state = hass.states.get(FAN_GROUP) + assert ATTR_ASSUMED_STATE not in state.attributes + + # One member with assumed_state=True -> group has assumed_state=True + hass.states.async_set( + LIVING_ROOM_FAN_ENTITY_ID, STATE_ON, {ATTR_ASSUMED_STATE: True} + ) + await hass.async_block_till_done() + + state = hass.states.get(FAN_GROUP) + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # All members without assumed_state -> group doesn't have assumed_state in attributes + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_ON, {}) + await hass.async_block_till_done() + + state = hass.states.get(FAN_GROUP) + assert ATTR_ASSUMED_STATE not in state.attributes diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index dbd74e9578066a..a8fbe50970ced4 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -30,6 +30,7 @@ ColorMode, ) from homeassistant.const import ( + ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, EVENT_CALL_SERVICE, @@ -1647,3 +1648,72 @@ async def test_nested_group(hass: HomeAssistant) -> None: assert hass.states.get("light.kitchen_lights").state == STATE_OFF assert hass.states.get("light.bedroom_group").state == STATE_OFF assert hass.states.get("light.nested_group").state == STATE_OFF + + +async def test_assumed_state(hass: HomeAssistant) -> None: + """Test assumed_state attribute behavior.""" + await async_setup_component( + hass, + LIGHT_DOMAIN, + { + LIGHT_DOMAIN: { + "platform": DOMAIN, + "entities": ["light.kitchen", "light.bedroom", "light.living_room"], + "name": "Light Group", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # No members with assumed_state -> group doesn't have assumed_state in attributes + hass.states.async_set("light.kitchen", STATE_ON, {}) + hass.states.async_set("light.bedroom", STATE_ON, {}) + hass.states.async_set("light.living_room", STATE_OFF, {}) + await hass.async_block_till_done() + + state = hass.states.get("light.light_group") + assert ATTR_ASSUMED_STATE not in state.attributes + + # One member with assumed_state=True -> group has assumed_state=True + hass.states.async_set("light.kitchen", STATE_ON, {ATTR_ASSUMED_STATE: True}) + await hass.async_block_till_done() + + state = hass.states.get("light.light_group") + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # Multiple members with assumed_state=True -> group has assumed_state=True + hass.states.async_set("light.bedroom", STATE_OFF, {ATTR_ASSUMED_STATE: True}) + hass.states.async_set("light.living_room", STATE_OFF, {ATTR_ASSUMED_STATE: True}) + await hass.async_block_till_done() + + state = hass.states.get("light.light_group") + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # Unavailable member with assumed_state=True -> group has assumed_state=True + hass.states.async_set("light.kitchen", STATE_ON, {}) + hass.states.async_set("light.bedroom", STATE_OFF, {}) + hass.states.async_set( + "light.living_room", STATE_UNAVAILABLE, {ATTR_ASSUMED_STATE: True} + ) + await hass.async_block_till_done() + + state = hass.states.get("light.light_group") + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # Unknown member with assumed_state=True -> group has assumed_state=True + hass.states.async_set( + "light.living_room", STATE_UNKNOWN, {ATTR_ASSUMED_STATE: True} + ) + await hass.async_block_till_done() + + state = hass.states.get("light.light_group") + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # All members without assumed_state -> group doesn't have assumed_state in attributes + hass.states.async_set("light.living_room", STATE_OFF, {}) + await hass.async_block_till_done() + + state = hass.states.get("light.light_group") + assert ATTR_ASSUMED_STATE not in state.attributes diff --git a/tests/components/group/test_switch.py b/tests/components/group/test_switch.py index 4230a6ee86fbed..b577d2a6e2b298 100644 --- a/tests/components/group/test_switch.py +++ b/tests/components/group/test_switch.py @@ -14,6 +14,7 @@ SERVICE_TURN_ON, ) from homeassistant.const import ( + ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_OFF, STATE_ON, @@ -458,3 +459,43 @@ async def test_nested_group(hass: HomeAssistant) -> None: assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("switch.some_group").state == STATE_OFF assert hass.states.get("switch.nested_group").state == STATE_OFF + + +async def test_assumed_state(hass: HomeAssistant) -> None: + """Test assumed_state attribute behavior.""" + await async_setup_component( + hass, + SWITCH_DOMAIN, + { + SWITCH_DOMAIN: { + "platform": DOMAIN, + "entities": ["switch.tv", "switch.soundbar"], + "name": "Media Group", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # No members with assumed_state -> group doesn't have assumed_state in attributes + hass.states.async_set("switch.tv", STATE_ON, {}) + hass.states.async_set("switch.soundbar", STATE_OFF, {}) + await hass.async_block_till_done() + + state = hass.states.get("switch.media_group") + assert ATTR_ASSUMED_STATE not in state.attributes + + # One member with assumed_state=True -> group has assumed_state=True + hass.states.async_set("switch.tv", STATE_ON, {ATTR_ASSUMED_STATE: True}) + await hass.async_block_till_done() + + state = hass.states.get("switch.media_group") + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # All members without assumed_state -> group doesn't have assumed_state in attributes + hass.states.async_set("switch.tv", STATE_ON, {}) + await hass.async_block_till_done() + + state = hass.states.get("switch.media_group") + assert ATTR_ASSUMED_STATE not in state.attributes diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index eaaf022d0ac4b7..b378779278f3c4 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -133,6 +133,7 @@ async def integration_fixture( "window_covering_pa_tilt", "window_covering_tilt", "yandex_smart_socket", + "zemismart_mt25b", ] ) async def matter_devices( diff --git a/tests/components/matter/fixtures/nodes/zemismart_mt25b.json b/tests/components/matter/fixtures/nodes/zemismart_mt25b.json new file mode 100644 index 00000000000000..f55348e22e4a8f --- /dev/null +++ b/tests/components/matter/fixtures/nodes/zemismart_mt25b.json @@ -0,0 +1,374 @@ +{ + "node_id": 122, + "date_commissioned": "2025-09-07T18:10:07.533041", + "last_interview": "2025-10-11T07:25:37.335849", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 43, 44, 45, 46, 48, 49, 51, 53, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 4 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Zemismart Technology Limited", + "0/40/2": 5020, + "0/40/3": "Zemismart MT25B Roller Motor", + "0/40/4": 65376, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "1.0.0", + "0/40/9": 1, + "0/40/10": "1.0.0", + "0/40/16": false, + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 16, 19, 65528, 65529, 65531, 65532, + 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/45/65532": 0, + "0/45/65533": 1, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [65528, 65529, 65531, 65532, 65533], + "0/46/0": [1], + "0/46/65532": 0, + "0/46/65533": 1, + "0/46/65528": [], + "0/46/65529": [], + "0/46/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "p0jbsOzJRNw=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "p0jbsOzJRNw=", + "0/49/7": null, + "0/49/65532": 2, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "ieee802154", + "1": true, + "2": null, + "3": null, + "4": "tlnt+DEI/w0=", + "5": [], + "6": [ + "/oAAAAAAAAC0We34MQj/DQ==", + "/QANuACgAACqDe1Q7Wb3kg==", + "/VX8YmMnAAEkxrhBP3vKaQ==" + ], + "7": 4 + } + ], + "0/51/1": 17, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 8, 65528, 65529, 65531, 65532, 65533], + "0/53/0": 25, + "0/53/1": 2, + "0/53/2": "MyHome36", + "0/53/3": 4660, + "0/53/4": 12054125955590472924, + "0/53/5": "QP0ADbgAoAAA", + "0/53/6": 0, + "0/53/7": [ + { + "0": 12864791528929066571, + "1": 63, + "2": 11264, + "3": 827766, + "4": 15637, + "5": 3, + "6": -24, + "7": -24, + "8": 38, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 12864791528929066571, + "1": 11264, + "2": 11, + "3": 0, + "4": 0, + "5": 3, + "6": 3, + "7": 63, + "8": true, + "9": true + } + ], + "0/53/9": 1005735192, + "0/53/10": 68, + "0/53/11": 29, + "0/53/12": 193, + "0/53/13": 20, + "0/53/58": 0, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//4A==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [0, 0, 0, 0], + "0/53/65532": 2, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 58, 59, 60, 61, 62, 65528, + 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRehgkBwEkCAEwCUEE5rRZ6+IxZloF9YT/mIh0IGremAoUa7fBsa+FuYjHzHahJKGE/+b87kM4qB3jGdVlMHWgaqgta3U0FJ3sVXVQBDcKNQEoARgkAgE2AwQCBAEYMAQUduRzb8HrF6Rv90/11Gb8oS49Oe0wBRS5+zzv8ZPGnI9mC3wH9vq10JnwlhgwC0CzVIy/JR6k8kQxgRZea7QhSmlhnliZaLlcsh5BxB5OcjF/R4QFB4+WFxmnk2ebNWhRsm2jW6ADlWWCo2KcwMNmGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE/DujEcdTsX19xbxX+KuKKWiMaA5D9u99P/pVxIOmscd2BA2PadEMNnjvtPOpf+WE2Zxar4rby1IfAClGUUuQrTcKNQEpARgkAmAwBBS5+zzv8ZPGnI9mC3wH9vq10JnwljAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQGkPpvsbkAFEbfPN6H3Kf23R0zzmW/gpAA3kgaL6wKB2Ofm+Tmylw22qM536Kj8mOMwaV0EL1dCCGcuxF98aL6gY", + "254": 4 + } + ], + "0/62/1": [ + { + "1": "BBmX+KwLR5HGlVNbvlC+dO8Jv9fPthHiTfGpUzi2JJADX5az6GxBAFn02QKHwLcZHyh+lh9faf6rf38/nPYF7/M=", + "2": 4939, + "3": 2, + "4": 122, + "5": "*******", + "254": 4 + } + ], + "0/62/2": 5, + "0/62/3": 4, + "0/62/4": [ + "FTABAQAkAgE3AyYUyakYCSYVj6gLsxgmBLJE8S8kBQA3BiYUyakYCSYVj6gLsxgkBwEkCAEwCUEEgYwxrTB+tyiEGfrRwjlXTG34MiQtJXbg5Qqd0ohdRW7MfwYY7vZiX/0h9hI8MqUralFaVPcnghAP0MSJm1YrqTcKNQEpARgkAmAwBBS3BS9aJzt+p6i28Nj+trB2Uu+vdzAFFLcFL1onO36nqLbw2P62sHZS7693GDALQNm5w34GyVhdicoksiHLKa0oErny3ZSs+8P9GxB2W6+WWUxShI77mPXZXYybBF5upTg/FG2lLVldrXcPu7dI/6oY", + "FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEiuu42juvSBfqPqWrV0OLnN4rePFxNq+O3ajhp0IJIJi1vE5qR9vsLcZeqBXgvO6UVKKdt7CZiR2oUEeqbmnG9TcKNQEpARgkAmAwBBTjAjvCZO2QpJyarhRj7T8yYjarAzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQE7hTxTRg92QOxwA1hK3xv8DaxvxL71r6ZHcNRzug9wNnonJ+NC84SFKvKDxwcBxHYqFdIyDiDgwJNTQIBgasmIY", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEGZf4rAtHkcaVU1u+UL507wm/18+2EeJN8alTOLYkkANflrPobEEAWfTZAofAtxkfKH6WH19p/qt/fz+c9gXv8zcKNQEpARgkAmAwBBT0+qfdyShnG+4Pq01pwOnrxdhHRjAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQPVrsFnfFplsQGV5m5EUua+rmo9hAr+OP1bvaifdLqiEIn3uXLTLoKmVUkPImRL2Fb+xcMEAqR2p7RM6ZlFCR20Y" + ], + "0/62/5": 4, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 514, + "1": 2 + } + ], + "1/29/1": [3, 4, 29, 47, 64, 65, 258], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/47/0": 1, + "1/47/1": 1, + "1/47/2": "Rechargeable Battery", + "1/47/11": 0, + "1/47/12": 0, + "1/47/14": 0, + "1/47/15": false, + "1/47/16": 0, + "1/47/26": 3, + "1/47/28": true, + "1/47/65532": 6, + "1/47/65533": 1, + "1/47/65528": [], + "1/47/65529": [], + "1/47/65531": [ + 0, 1, 2, 11, 12, 14, 15, 16, 26, 28, 65528, 65529, 65531, 65532, 65533 + ], + "1/64/0": [ + { + "0": "room", + "1": "bedroom 2" + }, + { + "0": "orientation", + "1": "North" + }, + { + "0": "floor", + "1": "2" + }, + { + "0": "direction", + "1": "up" + } + ], + "1/64/65532": 0, + "1/64/65533": 1, + "1/64/65528": [], + "1/64/65529": [], + "1/64/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/65/0": [], + "1/65/65532": 0, + "1/65/65533": 1, + "1/65/65528": [], + "1/65/65529": [], + "1/65/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/258/0": 0, + "1/258/7": 41, + "1/258/10": 0, + "1/258/11": null, + "1/258/13": 0, + "1/258/14": 10000, + "1/258/16": 0, + "1/258/17": 65535, + "1/258/23": 0, + "1/258/26": 0, + "1/258/65532": 13, + "1/258/65533": 5, + "1/258/65528": [], + "1/258/65529": [0, 1, 2, 5], + "1/258/65531": [ + 0, 7, 10, 11, 13, 14, 16, 17, 23, 26, 65528, 65529, 65531, 65532, 65533 + ] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_cover.ambr b/tests/components/matter/snapshots/test_cover.ambr index c0b38a58456042..9efe31ed428282 100644 --- a/tests/components/matter/snapshots/test_cover.ambr +++ b/tests/components/matter/snapshots/test_cover.ambr @@ -253,3 +253,54 @@ 'state': 'unknown', }) # --- +# name: test_covers[zemismart_mt25b][cover.zemismart_mt25b_roller_motor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.zemismart_mt25b_roller_motor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000007A-MatterNodeDevice-1-MatterCoverPositionAwareLift-258-10', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[zemismart_mt25b][cover.zemismart_mt25b_roller_motor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'shade', + 'friendly_name': 'Zemismart MT25B Roller Motor', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.zemismart_mt25b_roller_motor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 317969735fc238..9f01def19f2fe1 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -7451,3 +7451,175 @@ 'state': '217.0', }) # --- +# name: test_sensors[zemismart_mt25b][sensor.zemismart_mt25b_roller_motor_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zemismart_mt25b_roller_motor_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000007A-MatterNodeDevice-1-PowerSource-47-12', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[zemismart_mt25b][sensor.zemismart_mt25b_roller_motor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zemismart MT25B Roller Motor Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.zemismart_mt25b_roller_motor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[zemismart_mt25b][sensor.zemismart_mt25b_roller_motor_battery_charge_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_charging', + 'charging', + 'full_charge', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zemismart_mt25b_roller_motor_battery_charge_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery charge state', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_charge_state', + 'unique_id': '00000000000004D2-000000000000007A-MatterNodeDevice-1-PowerSourceBatChargeState-47-26', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[zemismart_mt25b][sensor.zemismart_mt25b_roller_motor_battery_charge_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Zemismart MT25B Roller Motor Battery charge state', + 'options': list([ + 'not_charging', + 'charging', + 'full_charge', + ]), + }), + 'context': , + 'entity_id': 'sensor.zemismart_mt25b_roller_motor_battery_charge_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_charging', + }) +# --- +# name: test_sensors[zemismart_mt25b][sensor.zemismart_mt25b_roller_motor_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zemismart_mt25b_roller_motor_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '00000000000004D2-000000000000007A-MatterNodeDevice-1-PowerSourceBatVoltage-47-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zemismart_mt25b][sensor.zemismart_mt25b_roller_motor_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Zemismart MT25B Roller Motor Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zemismart_mt25b_roller_motor_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index ce0a64ea718f55..154a90ae210f49 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -234,6 +234,62 @@ async def test_subentry_unsupported_model( assert subentry_flow["errors"] == {"chat_model": "model_not_supported"} +async def test_subentry_websearch_unsupported_reasoning_effort( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test the subentry form giving error about unsupported minimal reasoning effort.""" + subentry = next(iter(mock_config_entry.subentries.values())) + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "init" + + # Configure initial step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + }, + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "advanced" + + # Configure advanced step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_CHAT_MODEL: "gpt-5", + }, + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["step_id"] == "model" + + # Configure model step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_REASONING_EFFORT: "minimal", + CONF_WEB_SEARCH: True, + }, + ) + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["errors"] == {"web_search": "web_search_minimal_reasoning"} + + # Reconfigure model step + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], + { + CONF_REASONING_EFFORT: "low", + CONF_WEB_SEARCH: True, + }, + ) + assert subentry_flow["type"] is FlowResultType.ABORT + assert subentry_flow["reason"] == "reconfigure_successful" + + @pytest.mark.parametrize( ("side_effect", "error"), [ diff --git a/tests/components/openrgb/__init__.py b/tests/components/openrgb/__init__.py new file mode 100644 index 00000000000000..9d9cd6d5f4b14a --- /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 00000000000000..71208e3cc6efd2 --- /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 00000000000000..c711d2834db29e --- /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 00000000000000..6c1d760c9cda77 --- /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 00000000000000..bb195cbebaf998 --- /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 00000000000000..8160f36fb11975 --- /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 00000000000000..55e2c8c616f655 --- /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 00000000000000..7e1be565049a82 --- /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"