diff --git a/.strict-typing b/.strict-typing index ba072005a3415f..e9e8780169225f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -594,6 +594,7 @@ homeassistant.components.transmission.* homeassistant.components.trend.* homeassistant.components.trmnl.* homeassistant.components.tts.* +homeassistant.components.tween_light_ir.* homeassistant.components.twentemilieu.* homeassistant.components.unifi.* homeassistant.components.unifi_access.* diff --git a/CODEOWNERS b/CODEOWNERS index 0802144fb666ee..c990d45caeb87d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1864,6 +1864,8 @@ CLAUDE.md @home-assistant/core /tests/components/tts/ @home-assistant/core /homeassistant/components/tuya/ @Tuya @zlinoliver /tests/components/tuya/ @Tuya @zlinoliver +/homeassistant/components/tween_light_ir/ @tr4nt0r +/tests/components/tween_light_ir/ @tr4nt0r /homeassistant/components/twentemilieu/ @frenck /tests/components/twentemilieu/ @frenck /homeassistant/components/twinkly/ @dr1rrb @Robbie1221 @Olen diff --git a/homeassistant/components/tween_light_ir/__init__.py b/homeassistant/components/tween_light_ir/__init__.py new file mode 100644 index 00000000000000..c173fba8b9d647 --- /dev/null +++ b/homeassistant/components/tween_light_ir/__init__.py @@ -0,0 +1,18 @@ +"""The Tween Light 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 Tween Light 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/tween_light_ir/config_flow.py b/homeassistant/components/tween_light_ir/config_flow.py new file mode 100644 index 00000000000000..d83c9e051d951e --- /dev/null +++ b/homeassistant/components/tween_light_ir/config_flow.py @@ -0,0 +1,92 @@ +"""Config flow for the Tween Light Infrared integration.""" + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.infrared import ( + DOMAIN as INFRARED_DOMAIN, + async_get_emitters, + async_get_receivers, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + CONF_INFRARED_RECEIVER_ENTITY_ID, + DEVICE_TYPE_NAMES, + DOMAIN, + TweenLightIrDeviceType, +) + + +class TweenLightIrConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Tween Light Infrared.""" + + 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) + receiver_entity_ids = async_get_receivers(self.hass) + if not emitter_entity_ids and not receiver_entity_ids: + return self.async_abort(reason="no_infrared_entities") + + if user_input is not None: + if user_input.get(CONF_INFRARED_ENTITY_ID) or user_input.get( + CONF_INFRARED_RECEIVER_ENTITY_ID + ): + self._async_abort_entries_match( + { + CONF_DEVICE_TYPE: user_input[CONF_DEVICE_TYPE], + CONF_INFRARED_ENTITY_ID: user_input.get( + CONF_INFRARED_ENTITY_ID + ), + } + ) + return self.async_create_entry( + title=DEVICE_TYPE_NAMES[user_input[CONF_DEVICE_TYPE]], + 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 TweenLightIrDeviceType + ], + translation_key=CONF_DEVICE_TYPE, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_INFRARED_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entity_ids, + ) + ), + vol.Optional(CONF_INFRARED_RECEIVER_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=receiver_entity_ids, + ) + ), + } + ), + errors=errors, + ) diff --git a/homeassistant/components/tween_light_ir/const.py b/homeassistant/components/tween_light_ir/const.py new file mode 100644 index 00000000000000..dfb0ee5024f17a --- /dev/null +++ b/homeassistant/components/tween_light_ir/const.py @@ -0,0 +1,19 @@ +"""Constants for the Tween Light Infrared integration.""" + +from enum import StrEnum + +DOMAIN = "tween_light_ir" +CONF_INFRARED_ENTITY_ID = "infrared_entity_id" +CONF_INFRARED_RECEIVER_ENTITY_ID = "infrared_receiver_entity_id" +CONF_DEVICE_TYPE = "device_type" + + +class TweenLightIrDeviceType(StrEnum): + """Tween Light Infrared device types.""" + + LED_STRIP = "led_strip" + + +DEVICE_TYPE_NAMES: dict[TweenLightIrDeviceType, str] = { + TweenLightIrDeviceType.LED_STRIP: "LED Strip", +} diff --git a/homeassistant/components/tween_light_ir/entity.py b/homeassistant/components/tween_light_ir/entity.py new file mode 100644 index 00000000000000..d2128dae4b208c --- /dev/null +++ b/homeassistant/components/tween_light_ir/entity.py @@ -0,0 +1,26 @@ +"""Common entity for Tween Light Infrared integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import CONF_DEVICE_TYPE, DEVICE_TYPE_NAMES, DOMAIN + + +class TweenLightIrEntity(Entity): + """Tween Light Infrared base entity providing common device info.""" + + _attr_has_entity_name = True + + def __init__(self, entry: ConfigEntry, unique_id_suffix: str | None = None) -> None: + """Initialize entity.""" + self._attr_unique_id = ( + f"{entry.entry_id}_{unique_id_suffix}" + if unique_id_suffix is not None + else entry.entry_id + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + model=DEVICE_TYPE_NAMES[entry.data[CONF_DEVICE_TYPE]], + manufacturer="Tween Light", + ) diff --git a/homeassistant/components/tween_light_ir/icons.json b/homeassistant/components/tween_light_ir/icons.json new file mode 100644 index 00000000000000..6f34714c28ebd5 --- /dev/null +++ b/homeassistant/components/tween_light_ir/icons.json @@ -0,0 +1,38 @@ +{ + "entity": { + "light": { + "led_strip": { + "default": "mdi:led-strip-variant", + "state": { + "off": "mdi:led-strip-variant-off" + }, + "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", + "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/tween_light_ir/light.py b/homeassistant/components/tween_light_ir/light.py new file mode 100644 index 00000000000000..6a127421d4b71e --- /dev/null +++ b/homeassistant/components/tween_light_ir/light.py @@ -0,0 +1,92 @@ +"""Light platform for Tween Light Infrared integration.""" + +from typing import Any + +from infrared_protocols.codes.tween_light.led_strip import TweenLightLEDStripCode + +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.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, TweenLightIrDeviceType +from .entity import TweenLightIrEntity + +PARALLEL_UPDATES = 1 + + +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 + + device_type = entry.data[CONF_DEVICE_TYPE] + if device_type == TweenLightIrDeviceType.LED_STRIP: + async_add_entities([TweenLightIrLightEntity(entry, infrared_entity_id)]) + + +class TweenLightIrLightEntity( + TweenLightIrEntity, InfraredEmitterConsumerEntity, LightEntity +): + """Represents a Tween Light Infrared light entity.""" + + _attr_assumed_state = True + _attr_color_mode = ColorMode.ONOFF + _attr_effect_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", + ] + _attr_name = None + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_supported_features = LightEntityFeature.EFFECT + _attr_translation_key = "led_strip" + + def __init__(self, entry: ConfigEntry, infrared_entity_id: str) -> None: + """Initialize the entity.""" + super().__init__(entry) + self._infrared_emitter_entity_id = infrared_entity_id + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn device on.""" + command = TweenLightLEDStripCode.ON.to_command() + self._attr_is_on = True + if ATTR_EFFECT in kwargs and kwargs[ATTR_EFFECT] in self._attr_effect_list: + effect: str = kwargs[ATTR_EFFECT] + command = TweenLightLEDStripCode[effect.upper()].to_command() + + await self._send_command(command) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + self._attr_is_on = False + await self._send_command(TweenLightLEDStripCode.OFF.to_command()) + self.async_write_ha_state() diff --git a/homeassistant/components/tween_light_ir/manifest.json b/homeassistant/components/tween_light_ir/manifest.json new file mode 100644 index 00000000000000..f6d3d31cf8276a --- /dev/null +++ b/homeassistant/components/tween_light_ir/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tween_light_ir", + "name": "Tween Light Infrared", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tween_light_ir", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "bronze" +} diff --git a/homeassistant/components/tween_light_ir/quality_scale.yaml b/homeassistant/components/tween_light_ir/quality_scale.yaml new file mode 100644 index 00000000000000..dc0c777274d2a1 --- /dev/null +++ b/homeassistant/components/tween_light_ir/quality_scale.yaml @@ -0,0 +1,110 @@ +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: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: + status: exempt + comment: | + This integration has no additional dependencies. + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + 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: done + 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 has no additional dependencies. + inject-websession: + status: exempt + comment: | + This integration does not do HTTP requests. + strict-typing: done diff --git a/homeassistant/components/tween_light_ir/strings.json b/homeassistant/components/tween_light_ir/strings.json new file mode 100644 index 00000000000000..59f233b11d5bd4 --- /dev/null +++ b/homeassistant/components/tween_light_ir/strings.json @@ -0,0 +1,66 @@ +{ + "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 or receiver." + }, + "step": { + "user": { + "data": { + "device_type": "[%key:common::generic::device_type%]", + "infrared_entity_id": "[%key:common::config_flow::data::infrared_entity_id%]", + "infrared_receiver_entity_id": "[%key:common::config_flow::data::infrared_receiver_entity_id%]" + }, + "data_description": { + "device_type": "The type of Tween Light device to control.", + "infrared_entity_id": "[%key:common::config_flow::data_description::infrared_entity_id%]", + "infrared_receiver_entity_id": "The infrared receiver entity to use for receiving signals." + }, + "description": "Select the device type and at least one infrared emitter or receiver to use with your Tween Light device.", + "title": "Set up Tween Light IR Remote" + } + } + }, + "entity": { + "light": { + "led_strip": { + "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", + "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": { + "led_strip": "Tween Light LED Strip" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b5e0330d3ae172..28f9e212111664 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -783,6 +783,7 @@ "triggercmd", "trmnl", "tuya", + "tween_light_ir", "twentemilieu", "twilio", "twinkly", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a5f9fd5bcffe1f..ee72e85c8ffc76 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7449,6 +7449,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "tween_light_ir": { + "name": "Tween Light Infrared", + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state" + }, "twentemilieu": { "name": "Twente Milieu", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index 6871bd7c882417..f8e74880ecb9a6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5699,6 +5699,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tween_light_ir.*] +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.twentemilieu.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/tween_light_ir/__init__.py b/tests/components/tween_light_ir/__init__.py new file mode 100644 index 00000000000000..96463e802b01c1 --- /dev/null +++ b/tests/components/tween_light_ir/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tween Light Infrared integration.""" diff --git a/tests/components/tween_light_ir/conftest.py b/tests/components/tween_light_ir/conftest.py new file mode 100644 index 00000000000000..01b9a3d2b9e9ac --- /dev/null +++ b/tests/components/tween_light_ir/conftest.py @@ -0,0 +1,55 @@ +"""Common fixtures for the Tween Light Infrared tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.tween_light_ir.const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + CONF_INFRARED_RECEIVER_ENTITY_ID, + DOMAIN, +) + +from tests.common import MockConfigEntry +from tests.components.infrared import EMITTER_ENTITY_ID, RECEIVER_ENTITY_ID + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.tween_light_ir.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 Strip", + entry_id="1234567890", + data={ + CONF_DEVICE_TYPE: "led_strip", + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + CONF_INFRARED_RECEIVER_ENTITY_ID: RECEIVER_ENTITY_ID, + }, + ) + + +@pytest.fixture(name="led_strip_codes") +def mock_tween_light_led_strip_code_to_command() -> Generator[None]: + """Patch TweenLightLEDStripCode.to_command to return the TweenLightLEDStripCode directly. + + This allows tests to assert on the high-level code enum value + rather than the raw NEC timings. + """ + with patch( + "infrared_protocols.codes.tween_light.led_strip.TweenLightLEDStripCode.to_command", + autospec=True, + side_effect=lambda self, **kwargs: self, + ): + yield diff --git a/tests/components/tween_light_ir/snapshots/test_light.ambr b/tests/components/tween_light_ir/snapshots/test_light.ambr new file mode 100644 index 00000000000000..11d2db87bcfea5 --- /dev/null +++ b/tests/components/tween_light_ir/snapshots/test_light.ambr @@ -0,0 +1,106 @@ +# serializer version: 1 +# name: test_setup[light.led_strip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'flash', + 'strobe', + 'fade', + 'smooth', + 'red', + 'green', + 'blue', + 'white', + 'orange_red', + 'tomato', + 'light_green', + 'sky_blue', + 'cyan', + 'rebecca_purple', + 'orange', + 'turquoise', + 'purple', + 'yellow', + 'dark_cyan', + 'plum', + ]), + '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.led_strip', + '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': 'tween_light_ir', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'led_strip', + 'unique_id': '1234567890', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.led_strip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assumed_state': True, + 'color_mode': None, + 'effect': None, + 'effect_list': list([ + 'flash', + 'strobe', + 'fade', + 'smooth', + 'red', + 'green', + 'blue', + 'white', + 'orange_red', + 'tomato', + 'light_green', + 'sky_blue', + 'cyan', + 'rebecca_purple', + 'orange', + 'turquoise', + 'purple', + 'yellow', + 'dark_cyan', + 'plum', + ]), + 'friendly_name': 'LED Strip', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.led_strip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/tween_light_ir/test_config_flow.py b/tests/components/tween_light_ir/test_config_flow.py new file mode 100644 index 00000000000000..c412b0e7c021aa --- /dev/null +++ b/tests/components/tween_light_ir/test_config_flow.py @@ -0,0 +1,107 @@ +"""Test the Tween Light Infrared config flow.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.tween_light_ir.const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + CONF_INFRARED_RECEIVER_ENTITY_ID, + DOMAIN, + TweenLightIrDeviceType, +) +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, RECEIVER_ENTITY_ID + + +@pytest.mark.usefixtures( + "mock_infrared_emitter_entity", "mock_infrared_receiver_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: TweenLightIrDeviceType.LED_STRIP, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + CONF_INFRARED_RECEIVER_ENTITY_ID: RECEIVER_ENTITY_ID, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "LED Strip" + assert result["data"] == { + CONF_DEVICE_TYPE: TweenLightIrDeviceType.LED_STRIP, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + CONF_INFRARED_RECEIVER_ENTITY_ID: RECEIVER_ENTITY_ID, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures( + "mock_infrared_emitter_entity", "mock_infrared_receiver_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: TweenLightIrDeviceType.LED_STRIP, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + CONF_INFRARED_RECEIVER_ENTITY_ID: RECEIVER_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_or_receiver( + hass: HomeAssistant, +) -> None: + """Test user flow requires an infrared emitter or receiver.""" + 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: TweenLightIrDeviceType.LED_STRIP}, + ) + + 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_receivers(hass: HomeAssistant) -> None: + """Test user flow aborts when no infrared emitters or receivers 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/tween_light_ir/test_init.py b/tests/components/tween_light_ir/test_init.py new file mode 100644 index 00000000000000..b9d625f67c1fef --- /dev/null +++ b/tests/components/tween_light_ir/test_init.py @@ -0,0 +1,22 @@ +"""Tests for the Tween Light IR 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/tween_light_ir/test_light.py b/tests/components/tween_light_ir/test_light.py new file mode 100644 index 00000000000000..a4823ed071d516 --- /dev/null +++ b/tests/components/tween_light_ir/test_light.py @@ -0,0 +1,119 @@ +"""Tests for the Tween Light Infrared light platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +from infrared_protocols.codes.tween_light.led_strip import TweenLightLEDStripCode +import pytest +from syrupy.assertion import SnapshotAssertion + +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.common import MockInfraredEmitterEntity + + +@pytest.fixture(autouse=True) +def light_only() -> Generator[None]: + """Enable only the light platform.""" + with patch( + "homeassistant.components.tween_light_ir.PLATFORMS", + [Platform.LIGHT], + ): + yield + + +@pytest.mark.usefixtures( + "mock_infrared_emitter_entity", "mock_infrared_receiver_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( + ("service", "service_data", "expected_code"), + [ + (SERVICE_TURN_ON, {}, TweenLightLEDStripCode.ON), + (SERVICE_TURN_ON, {ATTR_EFFECT: "flash"}, TweenLightLEDStripCode.FLASH), + (SERVICE_TURN_ON, {ATTR_EFFECT: "strobe"}, TweenLightLEDStripCode.STROBE), + (SERVICE_TURN_ON, {ATTR_EFFECT: "fade"}, TweenLightLEDStripCode.FADE), + (SERVICE_TURN_ON, {ATTR_EFFECT: "smooth"}, TweenLightLEDStripCode.SMOOTH), + (SERVICE_TURN_ON, {ATTR_EFFECT: "red"}, TweenLightLEDStripCode.RED), + (SERVICE_TURN_ON, {ATTR_EFFECT: "green"}, TweenLightLEDStripCode.GREEN), + (SERVICE_TURN_ON, {ATTR_EFFECT: "blue"}, TweenLightLEDStripCode.BLUE), + (SERVICE_TURN_ON, {ATTR_EFFECT: "white"}, TweenLightLEDStripCode.WHITE), + ( + SERVICE_TURN_ON, + {ATTR_EFFECT: "orange_red"}, + TweenLightLEDStripCode.ORANGE_RED, + ), + (SERVICE_TURN_ON, {ATTR_EFFECT: "tomato"}, TweenLightLEDStripCode.TOMATO), + ( + SERVICE_TURN_ON, + {ATTR_EFFECT: "light_green"}, + TweenLightLEDStripCode.LIGHT_GREEN, + ), + (SERVICE_TURN_ON, {ATTR_EFFECT: "sky_blue"}, TweenLightLEDStripCode.SKY_BLUE), + (SERVICE_TURN_ON, {ATTR_EFFECT: "cyan"}, TweenLightLEDStripCode.CYAN), + ( + SERVICE_TURN_ON, + {ATTR_EFFECT: "rebecca_purple"}, + TweenLightLEDStripCode.REBECCA_PURPLE, + ), + (SERVICE_TURN_ON, {ATTR_EFFECT: "orange"}, TweenLightLEDStripCode.ORANGE), + (SERVICE_TURN_ON, {ATTR_EFFECT: "turquoise"}, TweenLightLEDStripCode.TURQUOISE), + (SERVICE_TURN_ON, {ATTR_EFFECT: "purple"}, TweenLightLEDStripCode.PURPLE), + (SERVICE_TURN_ON, {ATTR_EFFECT: "yellow"}, TweenLightLEDStripCode.YELLOW), + (SERVICE_TURN_ON, {ATTR_EFFECT: "dark_cyan"}, TweenLightLEDStripCode.DARK_CYAN), + (SERVICE_TURN_ON, {ATTR_EFFECT: "plum"}, TweenLightLEDStripCode.PLUM), + (SERVICE_TURN_OFF, {}, TweenLightLEDStripCode.OFF), + ], +) +@pytest.mark.usefixtures("mock_infrared_receiver_entity", "led_strip_codes") +async def test_light_actions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + service: str, + service_data: dict[str, str], + expected_code: TweenLightLEDStripCode, +) -> None: + """Test light actions.""" + + 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_strip", **service_data}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + assert mock_infrared_emitter_entity.send_command_calls[0] == expected_code