From e42bb890d638d949438a546b8e541a17a61ef235 Mon Sep 17 00:00:00 2001 From: Manu Date: Tue, 14 Jul 2026 00:05:25 +0200 Subject: [PATCH 1/2] Add LED Infrared integration (#175294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: AbĂ­lio Costa --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/led_infrared/__init__.py | 18 ++ .../components/led_infrared/config_flow.py | 94 ++++++ .../components/led_infrared/const.py | 14 + .../components/led_infrared/icons.json | 42 +++ .../components/led_infrared/light.py | 129 ++++++++ .../components/led_infrared/manifest.json | 11 + .../led_infrared/quality_scale.yaml | 120 ++++++++ .../components/led_infrared/strings.json | 73 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + tests/components/led_infrared/__init__.py | 1 + tests/components/led_infrared/conftest.py | 60 ++++ .../led_infrared/snapshots/test_light.ambr | 106 +++++++ .../led_infrared/test_config_flow.py | 99 +++++++ tests/components/led_infrared/test_init.py | 22 ++ tests/components/led_infrared/test_light.py | 280 ++++++++++++++++++ 19 files changed, 1089 insertions(+) create mode 100644 homeassistant/components/led_infrared/__init__.py create mode 100644 homeassistant/components/led_infrared/config_flow.py create mode 100644 homeassistant/components/led_infrared/const.py create mode 100644 homeassistant/components/led_infrared/icons.json create mode 100644 homeassistant/components/led_infrared/light.py create mode 100644 homeassistant/components/led_infrared/manifest.json create mode 100644 homeassistant/components/led_infrared/quality_scale.yaml create mode 100644 homeassistant/components/led_infrared/strings.json create mode 100644 tests/components/led_infrared/__init__.py create mode 100644 tests/components/led_infrared/conftest.py create mode 100644 tests/components/led_infrared/snapshots/test_light.ambr create mode 100644 tests/components/led_infrared/test_config_flow.py create mode 100644 tests/components/led_infrared/test_init.py create mode 100644 tests/components/led_infrared/test_light.py diff --git a/.strict-typing b/.strict-typing index f878a42c6b544d..e3629e702389ae 100644 --- a/.strict-typing +++ b/.strict-typing @@ -337,6 +337,7 @@ homeassistant.components.lawn_mower.* homeassistant.components.lcn.* homeassistant.components.ld2410_ble.* homeassistant.components.led_ble.* +homeassistant.components.led_infrared.* homeassistant.components.lektrico.* homeassistant.components.letpot.* homeassistant.components.lg_infrared.* diff --git a/CODEOWNERS b/CODEOWNERS index b5bcbe335b655d..8d02a119003da8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1001,6 +1001,8 @@ CLAUDE.md @home-assistant/core /tests/components/leaone/ @bdraco /homeassistant/components/led_ble/ @bdraco /tests/components/led_ble/ @bdraco +/homeassistant/components/led_infrared/ @tr4nt0r +/tests/components/led_infrared/ @tr4nt0r /homeassistant/components/lektrico/ @lektrico /tests/components/lektrico/ @lektrico /homeassistant/components/letpot/ @jpelgrom diff --git a/homeassistant/components/led_infrared/__init__.py b/homeassistant/components/led_infrared/__init__.py new file mode 100644 index 00000000000000..12d4096ea42632 --- /dev/null +++ b/homeassistant/components/led_infrared/__init__.py @@ -0,0 +1,18 @@ +"""The LED Infrared integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up LED Infrared from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/led_infrared/config_flow.py b/homeassistant/components/led_infrared/config_flow.py new file mode 100644 index 00000000000000..d15c734d4ea837 --- /dev/null +++ b/homeassistant/components/led_infrared/config_flow.py @@ -0,0 +1,94 @@ +"""Config flow for the LED Infrared integration.""" + +from typing import TYPE_CHECKING, Any, override + +import voluptuous as vol + +from homeassistant.components.infrared import ( + DOMAIN as INFRARED_DOMAIN, + async_get_emitters, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, DOMAIN, LEDIrDeviceType + +DEVICE_NAMES = { + LEDIrDeviceType.GENERIC_24_KEY: "24-key remote", + LEDIrDeviceType.GENERIC_13_KEY: "13-key remote", +} + + +class LEDIrConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for LED Infrared.""" + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + emitter_entity_ids = async_get_emitters(self.hass) + if not emitter_entity_ids: + return self.async_abort(reason="no_infrared_entities") + + if user_input is not None: + emitter_id = user_input.get(CONF_INFRARED_ENTITY_ID) + if emitter_id: + self._async_abort_entries_match( + { + CONF_DEVICE_TYPE: user_input[CONF_DEVICE_TYPE], + CONF_INFRARED_ENTITY_ID: emitter_id, + } + ) + + title_entity_id = emitter_id + if TYPE_CHECKING: + assert title_entity_id is not None + ent_reg = er.async_get(self.hass) + entry = ent_reg.async_get(title_entity_id) + title_entity_name = ( + entry.name or entry.original_name or title_entity_id + if entry + else title_entity_id + ) + return self.async_create_entry( + title=f"LED light with {DEVICE_NAMES[LEDIrDeviceType(user_input[CONF_DEVICE_TYPE])]} via {title_entity_name}", + data=user_input, + ) + + errors["base"] = "missing_infrared_entity" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_DEVICE_TYPE): SelectSelector( + SelectSelectorConfig( + options=[ + device_type.value for device_type in LEDIrDeviceType + ], + translation_key=CONF_DEVICE_TYPE, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_INFRARED_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entity_ids, + ) + ), + } + ), + errors=errors, + description_placeholders={ + "docs_url": "https://www.home-assistant.io/integrations/led_infrared" + }, + ) diff --git a/homeassistant/components/led_infrared/const.py b/homeassistant/components/led_infrared/const.py new file mode 100644 index 00000000000000..7c5295f2b586f4 --- /dev/null +++ b/homeassistant/components/led_infrared/const.py @@ -0,0 +1,14 @@ +"""Constants for the LED Infrared integration.""" + +from enum import StrEnum + +DOMAIN = "led_infrared" +CONF_INFRARED_ENTITY_ID = "infrared_entity_id" +CONF_DEVICE_TYPE = "device_type" + + +class LEDIrDeviceType(StrEnum): + """LED Infrared device types.""" + + GENERIC_24_KEY = "generic_24_key" + GENERIC_13_KEY = "generic_13_key" diff --git a/homeassistant/components/led_infrared/icons.json b/homeassistant/components/led_infrared/icons.json new file mode 100644 index 00000000000000..d1e1784e95ceff --- /dev/null +++ b/homeassistant/components/led_infrared/icons.json @@ -0,0 +1,42 @@ +{ + "entity": { + "light": { + "light": { + "state_attributes": { + "effect": { + "state": { + "blue": "mdi:palette", + "cyan": "mdi:palette", + "dark_cyan": "mdi:palette", + "fade": "mdi:gradient-horizontal", + "flash": "mdi:flash", + "green": "mdi:palette", + "light_green": "mdi:palette", + "mode_1": "mdi:numeric-1-box", + "mode_2": "mdi:numeric-2-box", + "mode_3": "mdi:numeric-3-box", + "mode_4": "mdi:numeric-4-box", + "mode_5": "mdi:numeric-5-box", + "mode_6": "mdi:numeric-6-box", + "mode_7": "mdi:numeric-7-box", + "mode_8": "mdi:numeric-8-box", + "orange": "mdi:palette", + "orange_red": "mdi:palette", + "plum": "mdi:palette", + "purple": "mdi:palette", + "rebecca_purple": "mdi:palette", + "red": "mdi:palette", + "sky_blue": "mdi:palette", + "smooth": "mdi:looks", + "strobe": "mdi:light-flood-down", + "tomato": "mdi:palette", + "turquoise": "mdi:palette", + "white": "mdi:palette", + "yellow": "mdi:palette" + } + } + } + } + } + } +} diff --git a/homeassistant/components/led_infrared/light.py b/homeassistant/components/led_infrared/light.py new file mode 100644 index 00000000000000..7f0d385218a1cd --- /dev/null +++ b/homeassistant/components/led_infrared/light.py @@ -0,0 +1,129 @@ +"""Light platform for LED Infrared integration.""" + +from typing import Any, override + +from infrared_protocols.codes.generic.led import Generic13KeyCode, Generic24KeyCode + +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.components.light import ( + ATTR_EFFECT, + ColorMode, + LightEntity, + LightEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, DOMAIN, LEDIrDeviceType + +PARALLEL_UPDATES = 1 + +CODES = { + LEDIrDeviceType.GENERIC_24_KEY: Generic24KeyCode, + LEDIrDeviceType.GENERIC_13_KEY: Generic13KeyCode, +} + + +SUPPORTED_EFFECTS = { + LEDIrDeviceType.GENERIC_24_KEY: ["flash", "strobe", "fade", "smooth"], + LEDIrDeviceType.GENERIC_13_KEY: [ + "mode_1", + "mode_2", + "mode_3", + "mode_4", + "mode_5", + "mode_6", + "mode_7", + "mode_8", + ], +} + + +SUPPORTED_COLORS = { + LEDIrDeviceType.GENERIC_24_KEY: [ + "red", + "green", + "blue", + "white", + "tomato", + "light_green", + "sky_blue", + "orange_red", + "cyan", + "rebecca_purple", + "orange", + "turquoise", + "purple", + "yellow", + "dark_cyan", + "plum", + ], +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up platform from config entry.""" + if not (infrared_entity_id := entry.data.get(CONF_INFRARED_ENTITY_ID)): + return + + async_add_entities( + [LEDIrLightEntity(entry, entry.data[CONF_DEVICE_TYPE], infrared_entity_id)] + ) + + +class LEDIrLightEntity(InfraredEmitterConsumerEntity, LightEntity): + """Represents a LED Infrared light entity.""" + + _attr_assumed_state = True + _attr_color_mode = ColorMode.ONOFF + _attr_effect_list: list[str] + _attr_has_entity_name = True + _attr_name = None + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_supported_features = LightEntityFeature.EFFECT + _attr_translation_key = "light" + + def __init__( + self, + entry: ConfigEntry, + device_type: LEDIrDeviceType, + infrared_entity_id: str, + ) -> None: + """Initialize the entity.""" + self._attr_unique_id = entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + ) + + self._infrared_emitter_entity_id = infrared_entity_id + + self._codes = CODES[device_type] + self._attr_effect_list = SUPPORTED_EFFECTS.get( + device_type, [] + ) + SUPPORTED_COLORS.get(device_type, []) + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn device on.""" + await self._send_command(self._codes.ON.to_command()) + self._attr_is_on = True + effect: str | None = kwargs.get(ATTR_EFFECT) + if effect and effect in self._attr_effect_list: + await self._send_command(self._codes[effect.upper()].to_command()) + self._attr_effect = effect + + self.async_write_ha_state() + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._send_command(self._codes.OFF.to_command()) + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/led_infrared/manifest.json b/homeassistant/components/led_infrared/manifest.json new file mode 100644 index 00000000000000..501f79a54b73e3 --- /dev/null +++ b/homeassistant/components/led_infrared/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "led_infrared", + "name": "LED Infrared", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "dependencies": ["infrared"], + "documentation": "https://www.home-assistant.io/integrations/led_infrared", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "bronze" +} diff --git a/homeassistant/components/led_infrared/quality_scale.yaml b/homeassistant/components/led_infrared/quality_scale.yaml new file mode 100644 index 00000000000000..b3909f093af278 --- /dev/null +++ b/homeassistant/components/led_infrared/quality_scale.yaml @@ -0,0 +1,120 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: + status: exempt + comment: This integration has only one platform + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: + status: exempt + comment: | + This integration does not store runtime data. + test-before-configure: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no separate connection to validate during config flow. + test-before-setup: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no separate connection to validate during setup. + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + This integration is configured manually via config flow. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Each config entry creates a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + No entities should be disabled by default + entity-translations: done + exception-translations: + status: exempt + comment: | + This integration does not raise exceptions. + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration has no repairs. + stale-devices: + status: exempt + comment: | + Each config entry manages exactly one device. + + # Platinum + async-dependency: + status: exempt + comment: | + This integration depends on infrared_protocols which provides only code + definitions with no I/O, so async dependency does not apply. + inject-websession: + status: exempt + comment: | + This integration does not do HTTP requests. + strict-typing: done diff --git a/homeassistant/components/led_infrared/strings.json b/homeassistant/components/led_infrared/strings.json new file mode 100644 index 00000000000000..5b13ae6caefa43 --- /dev/null +++ b/homeassistant/components/led_infrared/strings.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "This device has already been configured with this infrared entity.", + "no_infrared_entities": "[%key:common::config_flow::abort::no_infrared_entities%]" + }, + "error": { + "missing_infrared_entity": "Select an infrared emitter." + }, + "step": { + "user": { + "data": { + "device_type": "[%key:common::generic::device_type%]", + "infrared_entity_id": "[%key:common::config_flow::data::infrared_entity_id%]" + }, + "data_description": { + "device_type": "The type of remote control used for the LED light bulb, lamp, or controller.", + "infrared_entity_id": "[%key:common::config_flow::data_description::infrared_entity_id%]" + }, + "description": "Select the device type and an infrared emitter. You can identify the correct device based on the remote control used. Please refer to the [documentation]({docs_url}).", + "title": "Set up LED Infrared device" + } + } + }, + "entity": { + "light": { + "light": { + "state_attributes": { + "effect": { + "state": { + "blue": "Color: Blue", + "cyan": "Color: Cyan", + "dark_cyan": "Color: Dark cyan", + "fade": "Fade", + "flash": "Flash", + "green": "Color: Green", + "light_green": "Color: Light green", + "mode_1": "Mode 1", + "mode_2": "Mode 2", + "mode_3": "Mode 3", + "mode_4": "Mode 4", + "mode_5": "Mode 5", + "mode_6": "Mode 6", + "mode_7": "Mode 7", + "mode_8": "Mode 8", + "orange": "Color: Orange", + "orange_red": "Color: Orange red", + "plum": "Color: Plum", + "purple": "Color: Purple", + "rebecca_purple": "Color: Rebecca purple", + "red": "Color: Red", + "sky_blue": "Color: Sky blue", + "smooth": "Smooth", + "strobe": "Strobe", + "tomato": "Color: Tomato", + "turquoise": "Color: Turquoise", + "white": "Color: White", + "yellow": "Color: Yellow" + } + } + } + } + } + }, + "selector": { + "device_type": { + "options": { + "generic_13_key": "13-key remote control", + "generic_24_key": "24-key remote control" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 098e872eb0a643..83f559cf281189 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -413,6 +413,7 @@ "ld2410_ble", "leaone", "led_ble", + "led_infrared", "lektrico", "letpot", "lg_infrared", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 92c50b56e104bc..2387a9de906f04 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3722,6 +3722,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "led_infrared": { + "name": "LED Infrared", + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state" + }, "legrand": { "name": "Legrand", "integration_type": "virtual", diff --git a/mypy.ini b/mypy.ini index 70d09d0ae3f42a..73645a4a23603d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3127,6 +3127,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.led_infrared.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lektrico.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/led_infrared/__init__.py b/tests/components/led_infrared/__init__.py new file mode 100644 index 00000000000000..1d9171961d86b3 --- /dev/null +++ b/tests/components/led_infrared/__init__.py @@ -0,0 +1 @@ +"""Tests for the LED Infrared integration.""" diff --git a/tests/components/led_infrared/conftest.py b/tests/components/led_infrared/conftest.py new file mode 100644 index 00000000000000..b608b97a0007fe --- /dev/null +++ b/tests/components/led_infrared/conftest.py @@ -0,0 +1,60 @@ +"""Common fixtures for the LED Infrared tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.led_infrared.const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + DOMAIN, + LEDIrDeviceType, +) + +from tests.common import MockConfigEntry +from tests.components.infrared import EMITTER_ENTITY_ID + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.led_infrared.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="LED Infrared via Test IR emitter", + entry_id="1234567890", + data={ + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + }, + ) + + +@pytest.fixture(name="infrared_codes") +def mock_infrared_code_to_command() -> Generator[None]: + """Patch to_command to return the code directly. + + This allows tests to assert on the high-level code enum value + rather than the raw NEC timings. + """ + with ( + patch( + "infrared_protocols.codes.generic.led.Generic24KeyCode.to_command", + autospec=True, + side_effect=lambda self, **kwargs: self, + ) as mock_to_command, + patch( + "infrared_protocols.codes.generic.led.Generic13KeyCode.to_command", + new=mock_to_command, + ), + ): + yield diff --git a/tests/components/led_infrared/snapshots/test_light.ambr b/tests/components/led_infrared/snapshots/test_light.ambr new file mode 100644 index 00000000000000..ae797297dfa08b --- /dev/null +++ b/tests/components/led_infrared/snapshots/test_light.ambr @@ -0,0 +1,106 @@ +# serializer version: 1 +# name: test_setup[light.led_infrared_via_test_ir_emitter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'flash', + 'strobe', + 'fade', + 'smooth', + 'red', + 'green', + 'blue', + 'white', + 'tomato', + 'light_green', + 'sky_blue', + 'orange_red', + 'cyan', + 'rebecca_purple', + 'orange', + 'turquoise', + 'purple', + 'yellow', + 'dark_cyan', + 'plum', + ]), + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.led_infrared_via_test_ir_emitter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'light', + 'unique_id': '1234567890', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.led_infrared_via_test_ir_emitter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : None, + : None, + : list([ + 'flash', + 'strobe', + 'fade', + 'smooth', + 'red', + 'green', + 'blue', + 'white', + 'tomato', + 'light_green', + 'sky_blue', + 'orange_red', + 'cyan', + 'rebecca_purple', + 'orange', + 'turquoise', + 'purple', + 'yellow', + 'dark_cyan', + 'plum', + ]), + : 'LED Infrared via Test IR emitter', + : list([ + , + ]), + : , + }), + 'context': , + 'entity_id': 'light.led_infrared_via_test_ir_emitter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/led_infrared/test_config_flow.py b/tests/components/led_infrared/test_config_flow.py new file mode 100644 index 00000000000000..760b1c2b9c38bd --- /dev/null +++ b/tests/components/led_infrared/test_config_flow.py @@ -0,0 +1,99 @@ +"""Test the LED Infrared config flow.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.led_infrared.const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + DOMAIN, + LEDIrDeviceType, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry +from tests.components.infrared import EMITTER_ENTITY_ID + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "LED light with 24-key remote via Test IR emitter" + assert result["data"] == { + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_form_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock, config_entry: MockConfigEntry +) -> None: + """Test we abort when already configured.""" + config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_user_flow_requires_emitter( + hass: HomeAssistant, +) -> None: + """Test user flow requires an infrared emitter.""" + 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_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "missing_infrared_entity"} + + +@pytest.mark.usefixtures("init_infrared") +async def test_user_flow_no_emitters(hass: HomeAssistant) -> None: + """Test user flow aborts when no infrared emitters exist.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_infrared_entities" diff --git a/tests/components/led_infrared/test_init.py b/tests/components/led_infrared/test_init.py new file mode 100644 index 00000000000000..4e8cf794c27a37 --- /dev/null +++ b/tests/components/led_infrared/test_init.py @@ -0,0 +1,22 @@ +"""Tests for the LED Infrared integration setup.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test setting up and unloading a config entry.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/led_infrared/test_light.py b/tests/components/led_infrared/test_light.py new file mode 100644 index 00000000000000..1e2083265ac558 --- /dev/null +++ b/tests/components/led_infrared/test_light.py @@ -0,0 +1,280 @@ +"""Tests for the LED Infrared light platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +from infrared_protocols.codes.generic.led import Generic13KeyCode, Generic24KeyCode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.led_infrared.const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + DOMAIN, + LEDIrDeviceType, +) +from homeassistant.components.light import ( + ATTR_EFFECT, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.infrared import EMITTER_ENTITY_ID +from tests.components.infrared.common import MockInfraredEmitterEntity + + +@pytest.fixture(autouse=True) +def light_only() -> Generator[None]: + """Enable only the light platform.""" + with patch( + "homeassistant.components.led_infrared.PLATFORMS", + [Platform.LIGHT], + ): + yield + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of light platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("device_type", "service", "service_data", "expected_codes"), + [ + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {}, + [Generic24KeyCode.ON], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "flash"}, + [Generic24KeyCode.ON, Generic24KeyCode.FLASH], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "strobe"}, + [Generic24KeyCode.ON, Generic24KeyCode.STROBE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "fade"}, + [Generic24KeyCode.ON, Generic24KeyCode.FADE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "smooth"}, + [Generic24KeyCode.ON, Generic24KeyCode.SMOOTH], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "red"}, + [Generic24KeyCode.ON, Generic24KeyCode.RED], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "green"}, + [Generic24KeyCode.ON, Generic24KeyCode.GREEN], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "blue"}, + [Generic24KeyCode.ON, Generic24KeyCode.BLUE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "white"}, + [Generic24KeyCode.ON, Generic24KeyCode.WHITE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "orange_red"}, + [Generic24KeyCode.ON, Generic24KeyCode.ORANGE_RED], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "tomato"}, + [Generic24KeyCode.ON, Generic24KeyCode.TOMATO], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "light_green"}, + [Generic24KeyCode.ON, Generic24KeyCode.LIGHT_GREEN], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "sky_blue"}, + [Generic24KeyCode.ON, Generic24KeyCode.SKY_BLUE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "cyan"}, + [Generic24KeyCode.ON, Generic24KeyCode.CYAN], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "rebecca_purple"}, + [Generic24KeyCode.ON, Generic24KeyCode.REBECCA_PURPLE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "orange"}, + [Generic24KeyCode.ON, Generic24KeyCode.ORANGE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "turquoise"}, + [Generic24KeyCode.ON, Generic24KeyCode.TURQUOISE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "purple"}, + [Generic24KeyCode.ON, Generic24KeyCode.PURPLE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "yellow"}, + [Generic24KeyCode.ON, Generic24KeyCode.YELLOW], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "dark_cyan"}, + [Generic24KeyCode.ON, Generic24KeyCode.DARK_CYAN], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "plum"}, + [Generic24KeyCode.ON, Generic24KeyCode.PLUM], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_OFF, + {}, + [Generic24KeyCode.OFF], + ), + (LEDIrDeviceType.GENERIC_13_KEY, SERVICE_TURN_ON, {}, [Generic13KeyCode.ON]), + (LEDIrDeviceType.GENERIC_13_KEY, SERVICE_TURN_OFF, {}, [Generic13KeyCode.OFF]), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_1"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_1], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_2"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_2], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_3"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_3], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_4"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_4], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_5"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_5], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_6"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_6], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_7"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_7], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_8"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_8], + ), + ], +) +@pytest.mark.usefixtures("infrared_codes") +async def test_light_actions( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + device_type: LEDIrDeviceType, + service: str, + service_data: dict[str, str], + expected_codes: list[Generic24KeyCode | Generic13KeyCode], +) -> None: + """Test light actions.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="LED Infrared via Test IR emitter", + entry_id="1234567890", + data={ + CONF_DEVICE_TYPE: device_type, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + }, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: "light.led_infrared_via_test_ir_emitter", **service_data}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == len(expected_codes) + assert mock_infrared_emitter_entity.send_command_calls == expected_codes From 079ab97702b839f3f86a597d1cbe6d34f87c0138 Mon Sep 17 00:00:00 2001 From: Filipe Date: Tue, 14 Jul 2026 10:15:15 +1200 Subject: [PATCH 2/2] Add S3000 Pro support to Edifier Infrared (#175425) --- .../components/edifier_infrared/button.py | 43 +++++++++++++++++++ .../components/edifier_infrared/const.py | 2 + .../edifier_infrared/media_player.py | 14 ++++++ .../components/edifier_infrared/strings.json | 21 +++++++++ tests/components/edifier_infrared/conftest.py | 5 +++ .../edifier_infrared/test_config_flow.py | 1 + 6 files changed, 86 insertions(+) diff --git a/homeassistant/components/edifier_infrared/button.py b/homeassistant/components/edifier_infrared/button.py index 8240e357cdcb7d..10cd8d2e481e32 100644 --- a/homeassistant/components/edifier_infrared/button.py +++ b/homeassistant/components/edifier_infrared/button.py @@ -8,6 +8,7 @@ from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode +from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.components.infrared import InfraredEmitterConsumerEntity @@ -141,6 +142,48 @@ class EdifierIrButtonEntityDescription(ButtonEntityDescription): command_code=EdifierRC20GCode.COAX, ), ), + EdifierCommandSet.S3000PRO: ( + EdifierIrButtonEntityDescription( + key="usb", + translation_key="usb", + command_code=EdifierS3000ProCode.USB, + ), + EdifierIrButtonEntityDescription( + key="bluetooth", + translation_key="bluetooth", + command_code=EdifierS3000ProCode.BLUETOOTH, + ), + EdifierIrButtonEntityDescription( + key="line_bal", + translation_key="line_bal", + command_code=EdifierS3000ProCode.LINE_BAL, + ), + EdifierIrButtonEntityDescription( + key="opt_coax", + translation_key="opt_coax", + command_code=EdifierS3000ProCode.OPT_COAX, + ), + EdifierIrButtonEntityDescription( + key="eq_monitor", + translation_key="eq_monitor", + command_code=EdifierS3000ProCode.EQ_MONITOR, + ), + EdifierIrButtonEntityDescription( + key="eq_dynamic", + translation_key="eq_dynamic", + command_code=EdifierS3000ProCode.EQ_DYNAMIC, + ), + EdifierIrButtonEntityDescription( + key="eq_classic", + translation_key="eq_classic", + command_code=EdifierS3000ProCode.EQ_CLASSIC, + ), + EdifierIrButtonEntityDescription( + key="eq_vocal", + translation_key="eq_vocal", + command_code=EdifierS3000ProCode.EQ_VOCAL, + ), + ), } diff --git a/homeassistant/components/edifier_infrared/const.py b/homeassistant/components/edifier_infrared/const.py index 057f71a7c51046..4fd4b959d7b245 100644 --- a/homeassistant/components/edifier_infrared/const.py +++ b/homeassistant/components/edifier_infrared/const.py @@ -5,6 +5,7 @@ from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode +from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode DOMAIN = "edifier_infrared" CONF_INFRARED_ENTITY_ID = "infrared_entity_id" @@ -16,4 +17,5 @@ | EdifierR1280TCode | EdifierS360DBCode | EdifierRC20GCode + | EdifierS3000ProCode ) diff --git a/homeassistant/components/edifier_infrared/media_player.py b/homeassistant/components/edifier_infrared/media_player.py index 6944e05b91560d..8c9963e80e9d40 100644 --- a/homeassistant/components/edifier_infrared/media_player.py +++ b/homeassistant/components/edifier_infrared/media_player.py @@ -8,6 +8,7 @@ from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode +from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode from homeassistant.components.infrared import InfraredEmitterConsumerEntity from homeassistant.components.media_player import ( @@ -92,6 +93,19 @@ MediaPlayerEntityFeature.NEXT_TRACK: (EdifierRC20GCode.FORWARD,), MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierRC20GCode.PREVIOUS,), }, + EdifierCommandSet.S3000PRO: { + MediaPlayerEntityFeature.TURN_ON: (EdifierS3000ProCode.POWER,), + MediaPlayerEntityFeature.TURN_OFF: (EdifierS3000ProCode.POWER,), + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierS3000ProCode.VOLUME_UP,), + (EdifierS3000ProCode.VOLUME_DOWN,), + ), + MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierS3000ProCode.MUTE,), + MediaPlayerEntityFeature.PLAY: (EdifierS3000ProCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.PAUSE: (EdifierS3000ProCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.NEXT_TRACK: (EdifierS3000ProCode.NEXT,), + MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierS3000ProCode.PREVIOUS,), + }, } diff --git a/homeassistant/components/edifier_infrared/strings.json b/homeassistant/components/edifier_infrared/strings.json index 28235e17699bd6..0ed3c9e2c2a874 100644 --- a/homeassistant/components/edifier_infrared/strings.json +++ b/homeassistant/components/edifier_infrared/strings.json @@ -30,6 +30,18 @@ "coax": { "name": "Coaxial" }, + "eq_classic": { + "name": "Classic EQ" + }, + "eq_dynamic": { + "name": "Dynamic EQ" + }, + "eq_monitor": { + "name": "Monitor EQ" + }, + "eq_vocal": { + "name": "Vocal EQ" + }, "fx_off": { "name": "FX off" }, @@ -42,11 +54,20 @@ "line_2": { "name": "Line 2" }, + "line_bal": { + "name": "Line / Balanced" + }, + "opt_coax": { + "name": "Optical / Coaxial" + }, "optical": { "name": "Optical" }, "pc": { "name": "PC" + }, + "usb": { + "name": "USB" } } } diff --git a/tests/components/edifier_infrared/conftest.py b/tests/components/edifier_infrared/conftest.py index 25eec8b6654a05..7cc22ec7b2a299 100644 --- a/tests/components/edifier_infrared/conftest.py +++ b/tests/components/edifier_infrared/conftest.py @@ -77,6 +77,11 @@ def mock_edifier_code_to_command() -> Generator[None]: autospec=True, side_effect=lambda self: self, ), + patch( + "infrared_protocols.codes.edifier.s3000pro.EdifierS3000ProCode.to_command", + autospec=True, + side_effect=lambda self: self, + ), ): yield diff --git a/tests/components/edifier_infrared/test_config_flow.py b/tests/components/edifier_infrared/test_config_flow.py index f12b3b33899e39..119cfa8ad0c189 100644 --- a/tests/components/edifier_infrared/test_config_flow.py +++ b/tests/components/edifier_infrared/test_config_flow.py @@ -26,6 +26,7 @@ (EdifierModel.R1280T, EdifierCommandSet.R1280T), (EdifierModel.S360DB, EdifierCommandSet.S360DB), (EdifierModel.RC20G, EdifierCommandSet.RC20G), + (EdifierModel.S3000PRO, EdifierCommandSet.S3000PRO), ], ) @pytest.mark.usefixtures("mock_infrared_emitter_entity")