Add Osram Infrared integration (#173250)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
This commit is contained in:
Dominik
2026-09-28 16:03:34 +01:00
committed by GitHub
co-authored by Copilot Autofix powered by AI Abílio Costa
parent e71ec7228a
commit c8ec9151ad
19 changed files with 1711 additions and 0 deletions
+1
View File
@@ -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.*
Generated
+2
View File
@@ -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
@@ -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)
@@ -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,
)
@@ -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"
@@ -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())
@@ -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
@@ -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"
}
@@ -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
@@ -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"
}
}
}
}
+1
View File
@@ -591,6 +591,7 @@ FLOWS = {
"oralb",
"orvibo",
"osoenergy",
"osram_infrared",
"otbr",
"ouman_eh_800",
"ourgroceries",
@@ -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",
Generated
+10
View File
@@ -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
@@ -0,0 +1 @@
"""Tests for the OSRAM infrared integration."""
@@ -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
@@ -0,0 +1,78 @@
# serializer version: 1
# name: test_entities[light.osram_light-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<LightEntityCapabilityAttribute.EFFECT_LIST: 'effect_list'>: list([
'off',
'flash',
'strobe',
'smooth',
]),
<LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES: 'supported_color_modes'>: list([
<ColorMode.RGB: 'rgb'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': None,
'entity_id': 'light.osram_light',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <LightEntityFeature: 4>,
'translation_key': None,
'unique_id': '01JTEST0000000000000000000',
'unit_of_measurement': None,
})
# ---
# name: test_entities[light.osram_light-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.ASSUMED_STATE: 'assumed_state'>: True,
<LightEntityStateAttribute.BRIGHTNESS: 'brightness'>: None,
<LightEntityStateAttribute.COLOR_MODE: 'color_mode'>: None,
<LightEntityStateAttribute.EFFECT: 'effect'>: None,
<LightEntityCapabilityAttribute.EFFECT_LIST: 'effect_list'>: list([
'off',
'flash',
'strobe',
'smooth',
]),
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'OSRAM light',
<LightEntityStateAttribute.HS_COLOR: 'hs_color'>: None,
<LightEntityStateAttribute.RGB_COLOR: 'rgb_color'>: None,
<LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES: 'supported_color_modes'>: list([
<ColorMode.RGB: 'rgb'>,
]),
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <LightEntityFeature: 4>,
<LightEntityStateAttribute.XY_COLOR: 'xy_color'>: None,
}),
'context': <ANY>,
'entity_id': 'light.osram_light',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
@@ -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,
}
@@ -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
@@ -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