From c8ec9151ad30c6bf2f0c55cf786ff3386bdbe097 Mon Sep 17 00:00:00 2001 From: Dominik Date: Mon, 28 Sep 2026 17:03:34 +0200 Subject: [PATCH] Add Osram Infrared integration (#173250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Abílio Costa --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/osram_infrared/__init__.py | 20 + .../components/osram_infrared/config_flow.py | 113 ++++ .../components/osram_infrared/const.py | 6 + .../components/osram_infrared/entity.py | 55 ++ .../components/osram_infrared/light.py | 329 ++++++++++++ .../components/osram_infrared/manifest.json | 11 + .../osram_infrared/quality_scale.yaml | 164 ++++++ .../components/osram_infrared/strings.json | 25 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + tests/components/osram_infrared/__init__.py | 1 + tests/components/osram_infrared/conftest.py | 89 ++++ .../osram_infrared/snapshots/test_light.ambr | 78 +++ .../osram_infrared/test_config_flow.py | 284 ++++++++++ tests/components/osram_infrared/test_init.py | 21 + tests/components/osram_infrared/test_light.py | 495 ++++++++++++++++++ 19 files changed, 1711 insertions(+) create mode 100644 homeassistant/components/osram_infrared/__init__.py create mode 100644 homeassistant/components/osram_infrared/config_flow.py create mode 100644 homeassistant/components/osram_infrared/const.py create mode 100644 homeassistant/components/osram_infrared/entity.py create mode 100644 homeassistant/components/osram_infrared/light.py create mode 100644 homeassistant/components/osram_infrared/manifest.json create mode 100644 homeassistant/components/osram_infrared/quality_scale.yaml create mode 100644 homeassistant/components/osram_infrared/strings.json create mode 100644 tests/components/osram_infrared/__init__.py create mode 100644 tests/components/osram_infrared/conftest.py create mode 100644 tests/components/osram_infrared/snapshots/test_light.ambr create mode 100644 tests/components/osram_infrared/test_config_flow.py create mode 100644 tests/components/osram_infrared/test_init.py create mode 100644 tests/components/osram_infrared/test_light.py diff --git a/.strict-typing b/.strict-typing index dfa00bad8522..fa035070a120 100644 --- a/.strict-typing +++ b/.strict-typing @@ -451,6 +451,7 @@ homeassistant.components.openuv.* homeassistant.components.opnsense.* homeassistant.components.opower.* homeassistant.components.oralb.* +homeassistant.components.osram_infrared.* homeassistant.components.otbr.* homeassistant.components.otp.* homeassistant.components.ouman_eh_800.* diff --git a/CODEOWNERS b/CODEOWNERS index 32300f66829b..b60dad20232d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1396,6 +1396,8 @@ CLAUDE.md @home-assistant/core /tests/components/oralb/ @bdraco @Lash-L /homeassistant/components/osoenergy/ @osohotwateriot /tests/components/osoenergy/ @osohotwateriot +/homeassistant/components/osram_infrared/ @freeDom- +/tests/components/osram_infrared/ @freeDom- /homeassistant/components/otbr/ @home-assistant/core /tests/components/otbr/ @home-assistant/core /homeassistant/components/ouman_eh_800/ @Markus98 diff --git a/homeassistant/components/osram_infrared/__init__.py b/homeassistant/components/osram_infrared/__init__.py new file mode 100644 index 000000000000..c62ab91e46e6 --- /dev/null +++ b/homeassistant/components/osram_infrared/__init__.py @@ -0,0 +1,20 @@ +"""The OSRAM 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 OSRAM IR 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 an OSRAM IR config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/osram_infrared/config_flow.py b/homeassistant/components/osram_infrared/config_flow.py new file mode 100644 index 000000000000..b1ed4c4ce085 --- /dev/null +++ b/homeassistant/components/osram_infrared/config_flow.py @@ -0,0 +1,113 @@ +"""Config flow for OSRAM Infrared.""" + +from collections.abc import Collection +from typing import Any, override + +import probatio + +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 import entity_registry as er +from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig + +from .const import CONF_IR_EMITTER_ENTITY_ID, CONF_IR_RECEIVER_ENTITY_ID, DOMAIN + + +class OsramIrConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for OSRAM Infrared.""" + + VERSION = 1 + + def _async_get_entry_title(self, emitter_entity_id: str) -> str: + """Return config entry title for the selected emitter.""" + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get(emitter_entity_id) + + if entity_entry is None: + emitter_name = emitter_entity_id + else: + emitter_name = ( + entity_entry.name + or entity_entry.original_name + or entity_entry.entity_id + ) + + return f"OSRAM light via {emitter_name}" + + def _async_validate_input( + self, + user_input: dict[str, Any], + emitter_entity_ids: Collection[str], + receiver_entity_ids: Collection[str], + ) -> dict[str, str]: + """Validate the user input.""" + errors: dict[str, str] = {} + + emitter_entity_id = user_input[CONF_IR_EMITTER_ENTITY_ID] + if emitter_entity_id not in emitter_entity_ids: + errors[CONF_IR_EMITTER_ENTITY_ID] = "entity_not_available" + + if ( + receiver_entity_id := user_input.get(CONF_IR_RECEIVER_ENTITY_ID) + ) and receiver_entity_id not in receiver_entity_ids: + errors[CONF_IR_RECEIVER_ENTITY_ID] = "entity_not_available" + + return errors + + @override + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle the initial step.""" + emitter_entity_ids = async_get_emitters(self.hass) + receiver_entity_ids = async_get_receivers(self.hass) + + if not emitter_entity_ids: + return self.async_abort(reason="no_infrared_emitters") + + errors: dict[str, str] = {} + + if user_input is not None: + emitter_entity_id = user_input[CONF_IR_EMITTER_ENTITY_ID] + + self._async_abort_entries_match( + {CONF_IR_EMITTER_ENTITY_ID: emitter_entity_id} + ) + + errors = self._async_validate_input( + user_input, + emitter_entity_ids, + receiver_entity_ids, + ) + + if not errors: + return self.async_create_entry( + title=self._async_get_entry_title(emitter_entity_id), + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=probatio.Schema( + { + probatio.Required(CONF_IR_EMITTER_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entity_ids, + ) + ), + probatio.Optional(CONF_IR_RECEIVER_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=receiver_entity_ids, + ) + ), + } + ), + errors=errors, + ) diff --git a/homeassistant/components/osram_infrared/const.py b/homeassistant/components/osram_infrared/const.py new file mode 100644 index 000000000000..721c76312eeb --- /dev/null +++ b/homeassistant/components/osram_infrared/const.py @@ -0,0 +1,6 @@ +"""Constants for the OSRAM infrared integration.""" + +DOMAIN = "osram_infrared" + +CONF_IR_EMITTER_ENTITY_ID = "infrared_emitter_entity_id" +CONF_IR_RECEIVER_ENTITY_ID = "infrared_receiver_entity_id" diff --git a/homeassistant/components/osram_infrared/entity.py b/homeassistant/components/osram_infrared/entity.py new file mode 100644 index 000000000000..9fbef9f22e4d --- /dev/null +++ b/homeassistant/components/osram_infrared/entity.py @@ -0,0 +1,55 @@ +"""Common entities for the OSRAM IR integration.""" + +from asyncio import sleep + +from infrared_protocols.codes.osram.light import OsramLightCode + +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + +FULL_FRAME_REPEAT_DELAY = 0.01 + + +class OsramIrEntity(Entity): + """OSRAM IR base entity providing common device info.""" + + _attr_has_entity_name = True + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize an OSRAM IR entity.""" + self._attr_unique_id = entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name="OSRAM light", + manufacturer="OSRAM", + ) + + +class OsramIrEmitterEntity(OsramIrEntity, InfraredEmitterConsumerEntity): + """Base entity that sends commands through an infrared emitter.""" + + def __init__( + self, + entry: ConfigEntry, + emitter_entity_id: str, + ) -> None: + """Initialize an OSRAM IR emitter consumer entity.""" + super().__init__(entry) + self._infrared_emitter_entity_id = emitter_entity_id + + async def _async_send_code( + self, + code: OsramLightCode, + *, + repeat_count: int = 1, + ) -> None: + """Send an OSRAM command one or more times as complete NEC frames.""" + for frame_index in range(repeat_count): + if frame_index: + await sleep(FULL_FRAME_REPEAT_DELAY) + + await self._send_command(code.to_command()) diff --git a/homeassistant/components/osram_infrared/light.py b/homeassistant/components/osram_infrared/light.py new file mode 100644 index 000000000000..2baeac56526c --- /dev/null +++ b/homeassistant/components/osram_infrared/light.py @@ -0,0 +1,329 @@ +"""Light platform for the OSRAM infrared integration.""" + +import logging +from typing import Any, Final, override + +from infrared_protocols.codes.osram.light import OSRAM_ADDRESS, OsramLightCode +from infrared_protocols.commands.nec import NECCommand + +from homeassistant.components.infrared import ( + InfraredReceivedSignal, + InfraredReceiverConsumerEntity, +) +from homeassistant.components.light import ( + ATTR_EFFECT, + ATTR_RGB_COLOR, + EFFECT_OFF, + ColorMode, + LightEntity, + LightEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import color as color_util + +from .const import CONF_IR_EMITTER_ENTITY_ID, CONF_IR_RECEIVER_ENTITY_ID +from .entity import OsramIrEmitterEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + +# Use the dedicated white command for colors with low saturation. +WHITE_SATURATION_THRESHOLD = 45.0 + +RGB_WHITE: Final[tuple[int, int, int]] = (255, 255, 255) + +# The physical remote exposes 15 discrete color presets. +HUE_TO_CODE: Final[dict[int, OsramLightCode]] = { + 0: OsramLightCode.HUE_000, + 15: OsramLightCode.HUE_015, + 30: OsramLightCode.HUE_030, + 45: OsramLightCode.HUE_045, + 60: OsramLightCode.HUE_060, + 120: OsramLightCode.HUE_120, + 135: OsramLightCode.HUE_135, + 150: OsramLightCode.HUE_150, + 165: OsramLightCode.HUE_165, + 180: OsramLightCode.HUE_180, + 240: OsramLightCode.HUE_240, + 255: OsramLightCode.HUE_255, + 270: OsramLightCode.HUE_270, + 285: OsramLightCode.HUE_285, + 300: OsramLightCode.HUE_300, +} + +SUPPORTED_HUES: Final[tuple[int, ...]] = (*HUE_TO_CODE, 360) + +CODE_TO_HUE: Final[dict[OsramLightCode, int]] = { + code: hue for hue, code in HUE_TO_CODE.items() +} + +CODE_TO_RGB: Final[dict[OsramLightCode, tuple[int, int, int]]] = { + OsramLightCode.WHITE: RGB_WHITE, + **{ + code: color_util.color_hs_to_RGB(hue, 100.0) + for code, hue in CODE_TO_HUE.items() + }, +} + +EFFECT_TO_CODE: Final[dict[str, OsramLightCode]] = { + "flash": OsramLightCode.FLASH, + "strobe": OsramLightCode.STROBE, + "smooth": OsramLightCode.SMOOTH, +} + +CODE_TO_EFFECT: Final[dict[OsramLightCode, str]] = { + code: effect for effect, code in EFFECT_TO_CODE.items() +} + +EFFECT_LIST: Final[list[str]] = [ + EFFECT_OFF, + *EFFECT_TO_CODE, +] + +CMD_REPEAT_COUNT = 3 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up an OSRAM infrared light from a config entry.""" + emitter_entity_id = entry.data[CONF_IR_EMITTER_ENTITY_ID] + if receiver_entity_id := entry.data.get(CONF_IR_RECEIVER_ENTITY_ID): + async_add_entities( + [ + OsramIrLightWithReceiver( + entry, + emitter_entity_id, + receiver_entity_id, + ) + ] + ) + else: + async_add_entities( + [ + OsramIrLight( + entry, + emitter_entity_id, + ) + ] + ) + + +def _snap_hue(hue: float) -> int: + """Snap an arbitrary hue to the nearest physical remote preset.""" + normalized_hue = hue % 360 + + # 360° is included as an alias for 0° to handle the wrap-around at red. + return ( + min( + SUPPORTED_HUES, + key=lambda supported_hue: abs(normalized_hue - supported_hue), + ) + % 360 + ) + + +def _rgb_color_to_code_and_reported_rgb( + rgb_color: tuple[int, int, int], +) -> tuple[OsramLightCode, tuple[int, int, int]]: + """Return nearest OSRAM command and reported RGB color for an RGB color.""" + hue, saturation = color_util.color_RGB_to_hs(*rgb_color) + + if saturation <= WHITE_SATURATION_THRESHOLD: + return OsramLightCode.WHITE, RGB_WHITE + + snapped_hue = _snap_hue(hue) + code = HUE_TO_CODE[snapped_hue] + + return code, CODE_TO_RGB[code] + + +class OsramIrLight(OsramIrEmitterEntity, LightEntity): + """Representation of an OSRAM infrared light.""" + + _attr_assumed_state = True + _attr_color_mode = ColorMode.RGB + _attr_effect_list = EFFECT_LIST + _attr_name = None + _attr_rgb_color = RGB_WHITE + _attr_supported_color_modes = {ColorMode.RGB} + _attr_supported_features = LightEntityFeature.EFFECT + + def __init__(self, entry: ConfigEntry, emitter_entity_id: str) -> None: + """Initialize an OSRAM infrared light.""" + super().__init__(entry, emitter_entity_id) + + self._attr_is_on = None + self._attr_effect = EFFECT_OFF + self._last_static_color_code = OsramLightCode.WHITE + self._last_static_rgb_color = RGB_WHITE + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light and apply optional effect and color.""" + effect = kwargs.get(ATTR_EFFECT) + + if effect is not None and effect not in EFFECT_LIST: + raise ServiceValidationError(f"Unsupported OSRAM infrared effect: {effect}") + + await self._async_send_code( + OsramLightCode.ON, + repeat_count=CMD_REPEAT_COUNT, + ) + + if effect is not None: + await self._async_set_effect(effect) + elif (rgb_color := kwargs.get(ATTR_RGB_COLOR)) is not None: + await self._async_set_rgb_color(rgb_color) + + self._attr_is_on = True + self.async_write_ha_state() + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + await self._async_send_code( + OsramLightCode.OFF, + repeat_count=CMD_REPEAT_COUNT, + ) + + self._update_off_state() + self.async_write_ha_state() + + async def _async_set_rgb_color( + self, + rgb_color: tuple[int, int, int], + ) -> None: + """Set the nearest supported static color preset.""" + code, reported_rgb_color = _rgb_color_to_code_and_reported_rgb(rgb_color) + + await self._async_send_code( + code, + repeat_count=CMD_REPEAT_COUNT, + ) + + self._update_static_color_state(code, reported_rgb_color) + + async def _async_set_effect(self, effect: str) -> None: + """Start or stop a native OSRAM effect.""" + if effect not in EFFECT_LIST: + raise ServiceValidationError(f"Unsupported OSRAM infrared effect: {effect}") + + if effect == EFFECT_OFF: + await self._async_send_code( + self._last_static_color_code, + repeat_count=CMD_REPEAT_COUNT, + ) + + self._update_static_color_state( + self._last_static_color_code, + self._last_static_rgb_color, + ) + return + + await self._async_send_code( + EFFECT_TO_CODE[effect], + repeat_count=CMD_REPEAT_COUNT, + ) + self._update_effect_state(effect) + + @callback + def _update_off_state(self) -> None: + """Update the local state after an off command.""" + self._attr_is_on = False + self._attr_effect = EFFECT_OFF + + @callback + def _update_static_color_state( + self, + code: OsramLightCode, + rgb_color: tuple[int, int, int], + ) -> None: + """Update the local state after selecting a static color.""" + self._attr_is_on = True + self._attr_effect = EFFECT_OFF + self._attr_color_mode = ColorMode.RGB + self._attr_rgb_color = rgb_color + self._last_static_color_code = code + self._last_static_rgb_color = rgb_color + + @callback + def _update_effect_state(self, effect: str) -> None: + """Update the local state after selecting an effect.""" + self._attr_is_on = True + self._attr_effect = effect + self._attr_color_mode = ColorMode.RGB + + +class OsramIrLightWithReceiver(OsramIrLight, InfraredReceiverConsumerEntity): + """Representation of an OSRAM infrared light with a configured receiver.""" + + def __init__( + self, + entry: ConfigEntry, + emitter_entity_id: str, + receiver_entity_id: str, + ) -> None: + """Initialize an OSRAM infrared light.""" + super().__init__(entry, emitter_entity_id) + + self._infrared_receiver_entity_id = receiver_entity_id + + @override + @callback + def _handle_signal(self, signal: InfraredReceivedSignal) -> None: + """Update the assumed light state after receiving an OSRAM command.""" + nec_command = NECCommand.from_raw_timings(signal.timings) + + if nec_command is None or nec_command.address != OSRAM_ADDRESS: + return + + try: + code = OsramLightCode(nec_command.command) + except ValueError: + _LOGGER.debug( + "Ignoring unknown OSRAM infrared command: 0x%02X", + nec_command.command, + ) + return + + _LOGGER.debug( + "Received OSRAM infrared command: %s (0x%02X)", + code.name, + nec_command.command, + ) + + self._apply_received_code(code) + self.async_write_ha_state() + + @callback + def _apply_received_code(self, code: OsramLightCode) -> None: + """Apply a received infrared command without transmitting anything.""" + if code is OsramLightCode.OFF: + self._update_off_state() + return + + # Every other recognized command requires the bulb to be powered on. + self._attr_is_on = True + + if code is OsramLightCode.ON: + return + + if (rgb_color := CODE_TO_RGB.get(code)) is not None: + self._update_static_color_state(code, rgb_color) + return + + if (effect := CODE_TO_EFFECT.get(code)) is not None: + self._update_effect_state(effect) + return + + if code is OsramLightCode.MODE: + # MODE changes color of the bulb in a predefined order but + # does not map reliably to a Home Assistant light effect. + return diff --git a/homeassistant/components/osram_infrared/manifest.json b/homeassistant/components/osram_infrared/manifest.json new file mode 100644 index 000000000000..18abbaeaabea --- /dev/null +++ b/homeassistant/components/osram_infrared/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "osram_infrared", + "name": "OSRAM Infrared", + "codeowners": ["@freeDom-"], + "config_flow": true, + "dependencies": ["infrared"], + "documentation": "https://www.home-assistant.io/integrations/osram_infrared", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "silver" +} diff --git a/homeassistant/components/osram_infrared/quality_scale.yaml b/homeassistant/components/osram_infrared/quality_scale.yaml new file mode 100644 index 000000000000..d747a6a34cc4 --- /dev/null +++ b/homeassistant/components/osram_infrared/quality_scale.yaml @@ -0,0 +1,164 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional service actions. + Light functionality is exposed through the standard light entity 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: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-conditions: + status: exempt + comment: The integration does not provide custom automation conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: The integration does not provide custom automation 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: 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 provide additional service actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not authenticate with a device or service. + test-coverage: done + # Gold + devices: done + diagnostics: + status: todo + comment: | + Evaluate whether diagnostics add useful information for this + assumed-state infrared integration. + discovery-update-info: + status: exempt + comment: | + This integration does not use discovery. + discovery: + status: exempt + comment: | + The integration is configured manually by selecting an existing infrared + emitter entity. + docs-data-update: + status: exempt + comment: | + This integration does not fetch data from devices. + docs-examples: + status: todo + comment: | + Add example automations for setting a color, brightness, and an effect. + docs-known-limitations: + status: todo + comment: | + Document assumed state, possible state drift when using the physical + remote, discrete hue presets, and approximate relative brightness + tracking. + docs-supported-devices: + status: todo + comment: | + Document the tested OSRAM LEDVANCE bulb models and any known unsupported + variants. + docs-supported-functions: + status: todo + comment: | + Document on/off control, discrete HS colors, white mode, relative + brightness, and native effects. + docs-troubleshooting: + status: todo + comment: | + Document emitter availability checks and common infrared transmission + issues. + docs-use-cases: + status: todo + comment: | + Describe replacing the original OSRAM infrared remote with a Home + Assistant-controlled infrared emitter. + dynamic-devices: + status: exempt + comment: | + Each config entry represents one manually configured infrared-controlled + light. Devices are not discovered dynamically. + entity-category: + status: exempt + comment: | + The integration currently exposes only the light entity, which is not a + configuration or diagnostic entity, so no entity category is required. + entity-device-class: + status: exempt + comment: | + Light entities do not require a device class. + entity-disabled-by-default: + status: exempt + comment: | + The integration currently exposes only the light entity, which should be + enabled by default. + entity-translations: done + exception-translations: + status: todo + comment: | + Ensure any user-facing HomeAssistantError exceptions use translation + keys instead of hard-coded messages. + icon-translations: + status: exempt + comment: | + The integration does not define custom entity icons. + reconfiguration-flow: + status: todo + comment: | + Add a reconfiguration flow so that the infrared emitter entity can be + changed without removing and recreating the integration. + repair-issues: + status: exempt + comment: | + No conditions currently require repair issues. + stale-devices: + status: exempt + comment: | + Each config entry represents exactly one manually configured light + device. There are no dynamically managed devices. + + # Platinum + async-dependency: + status: exempt + comment: | + This integration has no external dependencies. + inject-websession: + status: exempt + comment: | + This integration does not make HTTP requests. + strict-typing: done diff --git a/homeassistant/components/osram_infrared/strings.json b/homeassistant/components/osram_infrared/strings.json new file mode 100644 index 000000000000..e1aaa737a8db --- /dev/null +++ b/homeassistant/components/osram_infrared/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "This OSRAM light has already been configured with this infrared entity.", + "no_infrared_emitters": "[%key:common::config_flow::abort::no_infrared_emitters%]" + }, + "error": { + "entity_not_available": "The selected infrared entity is no longer available. Select another entity." + }, + "step": { + "user": { + "data": { + "infrared_emitter_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": { + "infrared_emitter_entity_id": "[%key:common::config_flow::data_description::infrared_entity_id%]", + "infrared_receiver_entity_id": "The optional infrared receiver entity used to detect commands sent by the physical remote." + }, + "description": "Select an infrared emitter to use with your OSRAM light. Optionally select a receiver to track commands sent by the physical remote.", + "title": "Set up OSRAM infrared light" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 615013b29fec..b7ad239864c3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -591,6 +591,7 @@ FLOWS = { "oralb", "orvibo", "osoenergy", + "osram_infrared", "otbr", "ouman_eh_800", "ourgroceries", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 35a544a87d7c..0da9efa5690d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5396,6 +5396,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "osram_infrared": { + "name": "OSRAM Infrared", + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state" + }, "osramlightify": { "name": "Osramlightify", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index c7660967c1d5..e5db6c52e19d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4268,6 +4268,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.osram_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.otbr.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/osram_infrared/__init__.py b/tests/components/osram_infrared/__init__.py new file mode 100644 index 000000000000..019aeb04dc1a --- /dev/null +++ b/tests/components/osram_infrared/__init__.py @@ -0,0 +1 @@ +"""Tests for the OSRAM infrared integration.""" diff --git a/tests/components/osram_infrared/conftest.py b/tests/components/osram_infrared/conftest.py new file mode 100644 index 000000000000..0d14246cb8a8 --- /dev/null +++ b/tests/components/osram_infrared/conftest.py @@ -0,0 +1,89 @@ +"""Common fixtures for the OSRAM infrared tests.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.components.osram_infrared import PLATFORMS +from homeassistant.components.osram_infrared.const import ( + CONF_IR_EMITTER_ENTITY_ID, + CONF_IR_RECEIVER_ENTITY_ID, + DOMAIN, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.infrared import ( + EMITTER_ENTITY_ID as MOCK_INFRARED_EMITTER_ENTITY_ID, + RECEIVER_ENTITY_ID as MOCK_INFRARED_RECEIVER_ENTITY_ID, +) +from tests.components.infrared.common import ( + MockInfraredEmitterEntity, + MockInfraredReceiverEntity, +) + + +@pytest.fixture +def has_receiver_entity() -> bool: + """Return whether the mock config entry has a receiver entity.""" + return False + + +@pytest.fixture +def mock_config_entry(has_receiver_entity: bool) -> MockConfigEntry: + """Return a mock OSRAM infrared config entry.""" + data = { + CONF_IR_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID, + } + + if has_receiver_entity: + data[CONF_IR_RECEIVER_ENTITY_ID] = MOCK_INFRARED_RECEIVER_ENTITY_ID + + return MockConfigEntry( + domain=DOMAIN, + entry_id="01JTEST0000000000000000000", + title="OSRAM light via Test IR emitter", + data=data, + ) + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return platforms to set up.""" + return PLATFORMS + + +@pytest.fixture +def mock_osram_light_code_to_command() -> Generator[None]: + """Patch OsramLightCode.to_command to return the OsramLightCode directly. + + This allows tests to assert on the high-level code enum value rather than the + raw NEC command timings. + """ + with patch( + "infrared_protocols.codes.osram.light.OsramLightCode.to_command", + autospec=True, + side_effect=lambda self, **kwargs: self, + ): + yield + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, + mock_osram_light_code_to_command: None, + platforms: list[Platform], +) -> MockConfigEntry: + """Set up the OSRAM infrared integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.osram_infrared.PLATFORMS", platforms): + 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/osram_infrared/snapshots/test_light.ambr b/tests/components/osram_infrared/snapshots/test_light.ambr new file mode 100644 index 000000000000..b29b52b0d6fc --- /dev/null +++ b/tests/components/osram_infrared/snapshots/test_light.ambr @@ -0,0 +1,78 @@ +# serializer version: 1 +# name: test_entities[light.osram_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'flash', + 'strobe', + 'smooth', + ]), + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.osram_light', + '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': 'osram_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01JTEST0000000000000000000', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.osram_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : None, + : None, + : None, + : list([ + 'off', + 'flash', + 'strobe', + 'smooth', + ]), + : 'OSRAM light', + : None, + : None, + : list([ + , + ]), + : , + : None, + }), + 'context': , + 'entity_id': 'light.osram_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/osram_infrared/test_config_flow.py b/tests/components/osram_infrared/test_config_flow.py new file mode 100644 index 000000000000..33d17824d1b7 --- /dev/null +++ b/tests/components/osram_infrared/test_config_flow.py @@ -0,0 +1,284 @@ +"""Tests for the OSRAM infrared config flow.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.osram_infrared.const import ( + CONF_IR_EMITTER_ENTITY_ID, + CONF_IR_RECEIVER_ENTITY_ID, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.infrared import ( + EMITTER_ENTITY_ID as MOCK_INFRARED_EMITTER_ENTITY_ID, + RECEIVER_ENTITY_ID as MOCK_INFRARED_RECEIVER_ENTITY_ID, +) + + +@pytest.mark.usefixtures( + "mock_infrared_emitter_entity", + "mock_infrared_receiver_entity", +) +async def test_user_flow_success(hass: HomeAssistant) -> None: + """Test successful user config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_IR_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID, + CONF_IR_RECEIVER_ENTITY_ID: MOCK_INFRARED_RECEIVER_ENTITY_ID, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "OSRAM light via Test IR emitter" + assert result["data"] == { + CONF_IR_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID, + CONF_IR_RECEIVER_ENTITY_ID: MOCK_INFRARED_RECEIVER_ENTITY_ID, + } + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_user_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test user flow aborts when the emitter is already configured.""" + mock_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 + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_IR_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("init_infrared") +async def test_user_flow_no_emitters(hass: HomeAssistant) -> None: + """Test user flow aborts when no infrared emitter exists.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_infrared_emitters" + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +@pytest.mark.parametrize( + ("entity_name", "expected_title"), + [ + (None, "OSRAM light via Test IR emitter"), + ("Living room IR emitter", "OSRAM light via Living room IR emitter"), + ], +) +async def test_user_flow_title_from_entity_name( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entity_name: str | None, + expected_title: str, +) -> None: + """Test config-entry title uses the infrared emitter name.""" + entity_registry.async_update_entity( + MOCK_INFRARED_EMITTER_ENTITY_ID, + name=entity_name, + ) + + 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_IR_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == expected_title + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_user_flow_without_receiver(hass: HomeAssistant) -> None: + """Test successful setup without an infrared receiver.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_IR_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_IR_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID, + } + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_user_flow_stale_emitter_selection( + hass: HomeAssistant, +) -> None: + """Test user flow recovers when an emitter disappears before submit.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + replacement_emitter = "infrared.replacement_emitter" + + with patch( + "homeassistant.components.osram_infrared.config_flow.async_get_emitters", + return_value=[replacement_emitter], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_IR_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == { + CONF_IR_EMITTER_ENTITY_ID: "entity_not_available", + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_IR_EMITTER_ENTITY_ID: replacement_emitter, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"OSRAM light via {replacement_emitter}" + assert result["data"] == { + CONF_IR_EMITTER_ENTITY_ID: replacement_emitter, + } + + +@pytest.mark.usefixtures( + "mock_infrared_emitter_entity", + "mock_infrared_receiver_entity", +) +async def test_user_flow_stale_receiver_selection( + hass: HomeAssistant, +) -> None: + """Test user flow recovers when a receiver disappears before submit.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + replacement_receiver = "infrared.replacement_receiver" + + with patch( + "homeassistant.components.osram_infrared.config_flow.async_get_receivers", + return_value=[replacement_receiver], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_IR_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID, + CONF_IR_RECEIVER_ENTITY_ID: MOCK_INFRARED_RECEIVER_ENTITY_ID, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == { + CONF_IR_RECEIVER_ENTITY_ID: "entity_not_available", + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_IR_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID, + CONF_IR_RECEIVER_ENTITY_ID: replacement_receiver, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_IR_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID, + CONF_IR_RECEIVER_ENTITY_ID: replacement_receiver, + } + + +async def test_user_flow_uses_entity_id_as_title_fallback( + hass: HomeAssistant, +) -> None: + """Test user flow uses the emitter entity ID when it is missing from registry.""" + emitter_entity_id = "infrared.missing_registry_emitter" + + with ( + patch( + "homeassistant.components.osram_infrared.config_flow.async_get_emitters", + return_value=[emitter_entity_id], + ), + patch( + "homeassistant.components.osram_infrared.config_flow.er.async_get", + ) as mock_async_get_entity_registry, + ): + mock_async_get_entity_registry.return_value.async_get.return_value = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_IR_EMITTER_ENTITY_ID: emitter_entity_id, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"OSRAM light via {emitter_entity_id}" + assert result["data"] == { + CONF_IR_EMITTER_ENTITY_ID: emitter_entity_id, + } diff --git a/tests/components/osram_infrared/test_init.py b/tests/components/osram_infrared/test_init.py new file mode 100644 index 000000000000..2e23a832b17d --- /dev/null +++ b/tests/components/osram_infrared/test_init.py @@ -0,0 +1,21 @@ +"""Tests for the OSRAM 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, + init_integration: MockConfigEntry, +) -> None: + """Test setting up and unloading a config entry.""" + entry = init_integration + + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/osram_infrared/test_light.py b/tests/components/osram_infrared/test_light.py new file mode 100644 index 000000000000..81952f72d872 --- /dev/null +++ b/tests/components/osram_infrared/test_light.py @@ -0,0 +1,495 @@ +"""Tests for the OSRAM Infrared light platform.""" + +from infrared_protocols.codes.osram.light import OSRAM_ADDRESS, OsramLightCode +from infrared_protocols.commands.nec import NECCommand +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.infrared import InfraredReceivedSignal +from homeassistant.components.light import ( + ATTR_EFFECT, + ATTR_RGB_COLOR, + DOMAIN as LIGHT_DOMAIN, + EFFECT_OFF, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.osram_infrared.const import DOMAIN +from homeassistant.components.osram_infrared.light import CMD_REPEAT_COUNT +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.common import assert_availability_follows_source_entity +from tests.components.infrared import EMITTER_ENTITY_ID, RECEIVER_ENTITY_ID +from tests.components.infrared.common import ( + MockInfraredEmitterEntity, + MockInfraredReceiverEntity, +) + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return platforms to set up.""" + return [Platform.LIGHT] + + +@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 all light entities are created with correct attributes.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + device_entries = dr.async_entries_for_config_entry( + device_registry, + mock_config_entry.entry_id, + ) + assert len(device_entries) == 1 + + device_entry = next(iter(device_entries)) + assert device_entry.identifiers == {(DOMAIN, mock_config_entry.entry_id)} + + entity_entries = er.async_entries_for_config_entry( + entity_registry, + mock_config_entry.entry_id, + ) + for entity_entry in entity_entries: + assert entity_entry.device_id == device_entry.id + + +@pytest.mark.usefixtures("init_integration") +async def test_turn_on_sends_on_code( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, +) -> None: + """Test turning on the light sends the OSRAM on code.""" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.osram_light"}, + blocking=True, + ) + + assert ( + mock_infrared_emitter_entity.send_command_calls + == [ + OsramLightCode.ON, + ] + * CMD_REPEAT_COUNT + ) + + state = hass.states.get("light.osram_light") + assert state + assert state.state == STATE_ON + + +@pytest.mark.usefixtures("init_integration") +async def test_turn_off_sends_off_code( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, +) -> None: + """Test turning off the light sends the OSRAM off code.""" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.osram_light"}, + blocking=True, + ) + + assert ( + mock_infrared_emitter_entity.send_command_calls + == [ + OsramLightCode.OFF, + ] + * CMD_REPEAT_COUNT + ) + + state = hass.states.get("light.osram_light") + assert state + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ("rgb_color", "expected_code", "expected_rgb_color"), + [ + ((180, 180, 180), OsramLightCode.WHITE, (255, 255, 255)), + ((0, 255, 0), OsramLightCode.HUE_120, (0, 255, 0)), + ((255, 0, 40), OsramLightCode.HUE_000, (255, 0, 0)), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_turn_on_with_rgb_color_sends_nearest_color_code( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + rgb_color: tuple[int, int, int], + expected_code: OsramLightCode, + expected_rgb_color: tuple[int, int, int], +) -> None: + """Test setting RGB color sends the nearest supported OSRAM color code.""" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.osram_light", + ATTR_RGB_COLOR: rgb_color, + }, + blocking=True, + ) + + assert mock_infrared_emitter_entity.send_command_calls == [ + *([OsramLightCode.ON] * CMD_REPEAT_COUNT), + *([expected_code] * CMD_REPEAT_COUNT), + ] + + state = hass.states.get("light.osram_light") + assert state + assert tuple(state.attributes[ATTR_RGB_COLOR]) == expected_rgb_color + + +@pytest.mark.parametrize( + ("effect", "expected_code"), + [ + ("flash", OsramLightCode.FLASH), + ("strobe", OsramLightCode.STROBE), + ("smooth", OsramLightCode.SMOOTH), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_turn_on_with_effect_sends_effect_code( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + effect: str, + expected_code: OsramLightCode, +) -> None: + """Test setting an OSRAM effect sends the matching effect code.""" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.osram_light", + ATTR_EFFECT: effect, + }, + blocking=True, + ) + + assert mock_infrared_emitter_entity.send_command_calls == [ + *([OsramLightCode.ON] * CMD_REPEAT_COUNT), + *([expected_code] * CMD_REPEAT_COUNT), + ] + + state = hass.states.get("light.osram_light") + assert state + assert state.attributes[ATTR_EFFECT] == effect + + +@pytest.mark.usefixtures("init_integration") +async def test_turn_on_with_effect_off_restores_last_static_color( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, +) -> None: + """Test turning an effect off restores the last static color.""" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.osram_light", + ATTR_RGB_COLOR: (0, 255, 0), + }, + blocking=True, + ) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.osram_light", + ATTR_EFFECT: "flash", + }, + blocking=True, + ) + + mock_infrared_emitter_entity.send_command_calls.clear() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.osram_light", + ATTR_EFFECT: EFFECT_OFF, + }, + blocking=True, + ) + + assert mock_infrared_emitter_entity.send_command_calls == [ + *([OsramLightCode.ON] * CMD_REPEAT_COUNT), + *([OsramLightCode.HUE_120] * CMD_REPEAT_COUNT), + ] + + state = hass.states.get("light.osram_light") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_EFFECT] == EFFECT_OFF + assert tuple(state.attributes[ATTR_RGB_COLOR]) == (0, 255, 0) + + +@pytest.mark.usefixtures("init_integration") +async def test_turn_on_with_unsupported_effect_raises( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, +) -> None: + """Test unsupported effect is rejected before sending infrared commands.""" + with pytest.raises( + ServiceValidationError, + match="Unsupported OSRAM infrared effect: invalid_effect", + ): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.osram_light", + ATTR_EFFECT: "invalid_effect", + }, + blocking=True, + ) + + assert mock_infrared_emitter_entity.send_command_calls == [] + + +@pytest.mark.usefixtures("init_integration") +async def test_light_availability_follows_ir_entity( + hass: HomeAssistant, +) -> None: + """Test light becomes unavailable when IR entity is unavailable.""" + await assert_availability_follows_source_entity( + hass, + "light.osram_light", + EMITTER_ENTITY_ID, + ) + + +@pytest.mark.parametrize("has_receiver_entity", [True]) +@pytest.mark.usefixtures("init_integration") +async def test_receiver_off_code_updates_light_state( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, +) -> None: + """Test receiving the OSRAM off command updates the assumed light state.""" + command = NECCommand( + address=OSRAM_ADDRESS, + command=OsramLightCode.OFF, + ) + + mock_infrared_receiver_entity._handle_received_signal( + InfraredReceivedSignal(timings=command.get_raw_timings()) + ) + await hass.async_block_till_done() + + state = hass.states.get("light.osram_light") + + assert state is not None + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ("received_code", "expected_rgb_color"), + [ + (OsramLightCode.WHITE, (255, 255, 255)), + (OsramLightCode.HUE_120, (0, 255, 0)), + (OsramLightCode.HUE_300, (255, 0, 255)), + ], +) +@pytest.mark.parametrize("has_receiver_entity", [True]) +@pytest.mark.usefixtures("init_integration") +async def test_receiver_static_color_code_updates_light_state( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, + received_code: OsramLightCode, + expected_rgb_color: tuple[int, int, int], +) -> None: + """Test receiving static color commands updates the assumed color state.""" + command = NECCommand( + address=OSRAM_ADDRESS, + command=received_code, + ) + + mock_infrared_receiver_entity._handle_received_signal( + InfraredReceivedSignal(timings=command.get_raw_timings()) + ) + await hass.async_block_till_done() + + state = hass.states.get("light.osram_light") + + assert state is not None + assert state.state == STATE_ON + assert tuple(state.attributes[ATTR_RGB_COLOR]) == expected_rgb_color + assert state.attributes[ATTR_EFFECT] == EFFECT_OFF + + +@pytest.mark.parametrize( + ("received_code", "expected_effect"), + [ + (OsramLightCode.FLASH, "flash"), + (OsramLightCode.STROBE, "strobe"), + (OsramLightCode.SMOOTH, "smooth"), + ], +) +@pytest.mark.parametrize("has_receiver_entity", [True]) +@pytest.mark.usefixtures("init_integration") +async def test_receiver_effect_code_updates_light_state( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, + received_code: OsramLightCode, + expected_effect: str, +) -> None: + """Test receiving effect commands updates the assumed effect state.""" + command = NECCommand( + address=OSRAM_ADDRESS, + command=received_code, + ) + + mock_infrared_receiver_entity._handle_received_signal( + InfraredReceivedSignal(timings=command.get_raw_timings()) + ) + await hass.async_block_till_done() + + state = hass.states.get("light.osram_light") + + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_EFFECT] == expected_effect + + +@pytest.mark.parametrize("has_receiver_entity", [True]) +@pytest.mark.usefixtures("init_integration") +async def test_receiver_ignores_other_nec_addresses( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, +) -> None: + """Test receiver ignores NEC commands from other addresses.""" + command = NECCommand( + address=0x1234, + command=OsramLightCode.ON, + ) + + mock_infrared_receiver_entity._handle_received_signal( + InfraredReceivedSignal(timings=command.get_raw_timings()) + ) + await hass.async_block_till_done() + + state = hass.states.get("light.osram_light") + + assert state is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize("has_receiver_entity", [True]) +@pytest.mark.usefixtures("init_integration") +async def test_receiver_ignores_non_nec_signals( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, +) -> None: + """Test receiver ignores signals that cannot be decoded as NEC.""" + mock_infrared_receiver_entity._handle_received_signal( + InfraredReceivedSignal(timings=[1, 2, 3, 4]) + ) + await hass.async_block_till_done() + + state = hass.states.get("light.osram_light") + + assert state is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize("has_receiver_entity", [True]) +@pytest.mark.usefixtures("init_integration") +async def test_receiver_resubscribes_after_receiver_unavailable( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, +) -> None: + """Test light resubscribes when the configured receiver becomes available again.""" + hass.states.async_set(RECEIVER_ENTITY_ID, STATE_UNAVAILABLE) + await hass.async_block_till_done() + + hass.states.async_set(RECEIVER_ENTITY_ID, STATE_UNKNOWN) + await hass.async_block_till_done() + + command = NECCommand( + address=OSRAM_ADDRESS, + command=OsramLightCode.ON, + ) + + mock_infrared_receiver_entity._handle_received_signal( + InfraredReceivedSignal(timings=command.get_raw_timings()) + ) + await hass.async_block_till_done() + + state = hass.states.get("light.osram_light") + + assert state is not None + assert state.state == STATE_ON + + +@pytest.mark.parametrize( + "received_code", + [ + OsramLightCode.ON, + OsramLightCode.MODE, + ], +) +@pytest.mark.parametrize("has_receiver_entity", [True]) +@pytest.mark.usefixtures("init_integration") +async def test_receiver_on_like_codes_turn_light_on( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, + received_code: OsramLightCode, +) -> None: + """Test receiving ON-like commands marks the assumed light state as on.""" + command = NECCommand( + address=OSRAM_ADDRESS, + command=received_code, + ) + + mock_infrared_receiver_entity._handle_received_signal( + InfraredReceivedSignal(timings=command.get_raw_timings()) + ) + await hass.async_block_till_done() + + state = hass.states.get("light.osram_light") + assert state is not None + assert state.state == STATE_ON + + +@pytest.mark.parametrize("has_receiver_entity", [True]) +@pytest.mark.usefixtures("init_integration") +async def test_receiver_ignores_unknown_osram_command( + hass: HomeAssistant, + mock_infrared_receiver_entity: MockInfraredReceiverEntity, +) -> None: + """Test receiver ignores unknown OSRAM NEC commands.""" + command = NECCommand( + address=OSRAM_ADDRESS, + command=0xFF, + ) + + mock_infrared_receiver_entity._handle_received_signal( + InfraredReceivedSignal(timings=command.get_raw_timings()) + ) + await hass.async_block_till_done() + + state = hass.states.get("light.osram_light") + assert state is not None + assert state.state == STATE_UNKNOWN