Compare commits

...

7 Commits

Author SHA1 Message Date
Franck Nijhof
4709913a7f Merge branch 'dev' into infrared 2026-02-13 21:42:52 +01:00
abmantis
cf24011690 Cleanup 2026-02-12 18:48:06 +00:00
abmantis
f95f731a3f Add kitchen sink IR fan subentry 2026-02-12 18:44:19 +00:00
abmantis
775e5aca7b Merge branch 'dev' of github.com:home-assistant/core into infrared 2026-02-12 18:08:47 +00:00
abmantis
8b52e16b0a Add infrared to kitchen sink 2026-02-12 17:29:36 +00:00
abmantis
6a2fbecad3 Add infrared to .core_files.yaml 2026-02-11 12:51:45 +00:00
abmantis
90bacbb98e Add infrared entity integration 2026-02-05 20:06:33 +00:00
25 changed files with 1039 additions and 5 deletions

View File

@@ -34,6 +34,7 @@ base_platforms: &base_platforms
- homeassistant/components/humidifier/**
- homeassistant/components/image/**
- homeassistant/components/image_processing/**
- homeassistant/components/infrared/**
- homeassistant/components/lawn_mower/**
- homeassistant/components/light/**
- homeassistant/components/lock/**

View File

@@ -283,6 +283,7 @@ homeassistant.components.imgw_pib.*
homeassistant.components.immich.*
homeassistant.components.incomfort.*
homeassistant.components.inels.*
homeassistant.components.infrared.*
homeassistant.components.input_button.*
homeassistant.components.input_select.*
homeassistant.components.input_text.*

2
CODEOWNERS generated
View File

@@ -790,6 +790,8 @@ build.json @home-assistant/supervisor
/tests/components/inels/ @epdevlab
/homeassistant/components/influxdb/ @mdegat01
/tests/components/influxdb/ @mdegat01
/homeassistant/components/infrared/ @home-assistant/core
/tests/components/infrared/ @home-assistant/core
/homeassistant/components/inkbird/ @bdraco
/tests/components/inkbird/ @bdraco
/homeassistant/components/input_boolean/ @home-assistant/core

View File

@@ -0,0 +1,156 @@
"""Provides functionality to interact with infrared devices."""
from __future__ import annotations
from abc import abstractmethod
from datetime import datetime, timedelta
import logging
from typing import final
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
from .protocols import InfraredCommand, NECInfraredCommand, Timing
__all__ = [
"DOMAIN",
"InfraredCommand",
"InfraredEntity",
"InfraredEntityDescription",
"NECInfraredCommand",
"Timing",
"async_get_emitters",
"async_send_command",
]
_LOGGER = logging.getLogger(__name__)
DATA_COMPONENT: HassKey[EntityComponent[InfraredEntity]] = HassKey(DOMAIN)
ENTITY_ID_FORMAT = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
SCAN_INTERVAL = timedelta(seconds=30)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the infrared domain."""
component = hass.data[DATA_COMPONENT] = EntityComponent[InfraredEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
)
await component.async_setup(config)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
@callback
def async_get_emitters(hass: HomeAssistant) -> list[InfraredEntity]:
"""Get all infrared emitters."""
component = hass.data.get(DATA_COMPONENT)
if component is None:
return []
return list(component.entities)
async def async_send_command(
hass: HomeAssistant,
entity_uuid: str,
command: InfraredCommand,
context: Context | None = None,
) -> None:
"""Send an IR command to the specified infrared entity.
Raises:
HomeAssistantError: If the infrared entity is not found.
"""
component = hass.data.get(DATA_COMPONENT)
if component is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="component_not_loaded",
)
ent_reg = er.async_get(hass)
entity_id = er.async_validate_entity_id(ent_reg, entity_uuid)
entity = component.get_entity(entity_id)
if entity is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="entity_not_found",
translation_placeholders={"entity_id": entity_id},
)
if context is not None:
entity.async_set_context(context)
await entity.async_send_command_internal(command)
class InfraredEntityDescription(EntityDescription, frozen_or_thawed=True):
"""Describes infrared entities."""
class InfraredEntity(RestoreEntity):
"""Base class for infrared transmitter entities."""
entity_description: InfraredEntityDescription
_attr_should_poll = False
_attr_state: None
__last_command_sent: datetime | None = None
@property
@final
def state(self) -> str | None:
"""Return the entity state."""
if (last_command := self.__last_command_sent) is None:
return None
return last_command.isoformat(timespec="milliseconds")
@final
async def async_send_command_internal(self, command: InfraredCommand) -> None:
"""Send an IR command and update state.
Should not be overridden, handles setting last sent timestamp.
"""
await self.async_send_command(command)
self.__last_command_sent = dt_util.utcnow()
self.async_write_ha_state()
@final
async def async_internal_added_to_hass(self) -> None:
"""Call when the infrared entity is added to hass."""
await super().async_internal_added_to_hass()
state = await self.async_get_last_state()
if state is not None and state.state is not None:
self.__last_command_sent = dt_util.parse_datetime(state.state)
@abstractmethod
async def async_send_command(self, command: InfraredCommand) -> None:
"""Send an IR command.
Args:
command: The IR command to send.
Raises:
HomeAssistantError: If transmission fails.
"""

View File

@@ -0,0 +1,5 @@
"""Constants for the Infrared integration."""
from typing import Final
DOMAIN: Final = "infrared"

View File

@@ -0,0 +1,7 @@
{
"entity_component": {
"_": {
"default": "mdi:led-on"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"domain": "infrared",
"name": "Infrared",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity",
"quality_scale": "internal"
}

View File

@@ -0,0 +1,119 @@
"""IR protocol definitions for the Infrared integration."""
import abc
from dataclasses import dataclass
from typing import override
@dataclass(frozen=True, slots=True)
class Timing:
"""High/low signal timing."""
high_us: int
low_us: int
class InfraredCommand(abc.ABC):
"""Base class for IR commands."""
repeat_count: int
modulation: int
def __init__(self, *, modulation: int, repeat_count: int = 0) -> None:
"""Initialize the IR command."""
self.modulation = modulation
self.repeat_count = repeat_count
@abc.abstractmethod
def get_raw_timings(self) -> list[Timing]:
"""Get raw timings for the command."""
class NECInfraredCommand(InfraredCommand):
"""NEC IR command."""
address: int
command: int
def __init__(
self, *, address: int, command: int, modulation: int, repeat_count: int = 0
) -> None:
"""Initialize the NEC IR command."""
super().__init__(modulation=modulation, repeat_count=repeat_count)
self.address = address
self.command = command
@override
def get_raw_timings(self) -> list[Timing]:
"""Get raw timings for the NEC command.
NEC protocol timing (in microseconds):
- Leader pulse: 9000µs high, 4500µs low
- Logical '0': 562µs high, 562µs low
- Logical '1': 562µs high, 1687µs low
- End pulse: 562µs high
- Repeat code: 9000µs high, 2250µs low, 562µs end pulse
- Frame gap: ~96ms between end pulse and next frame (total frame ~108ms)
Data format (32 bits, LSB first):
- Standard NEC: address (8-bit) + ~address (8-bit) + command (8-bit) + ~command (8-bit)
- Extended NEC: address_low (8-bit) + address_high (8-bit) + command (8-bit) + ~command (8-bit)
"""
# NEC timing constants (microseconds)
leader_high = 9000
leader_low = 4500
bit_high = 562
zero_low = 562
one_low = 1687
repeat_low = 2250
frame_gap = 96000 # Gap to make total frame ~108ms
timings: list[Timing] = [Timing(high_us=leader_high, low_us=leader_low)]
# Determine if standard (8-bit) or extended (16-bit) address
if self.address <= 0xFF:
# Standard NEC: address + inverted address
address_low = self.address & 0xFF
address_high = (~self.address) & 0xFF
else:
# Extended NEC: 16-bit address (no inversion)
address_low = self.address & 0xFF
address_high = (self.address >> 8) & 0xFF
command_byte = self.command & 0xFF
command_inverted = (~self.command) & 0xFF
# Build 32-bit command data (LSB first in transmission)
data = (
address_low
| (address_high << 8)
| (command_byte << 16)
| (command_inverted << 24)
)
for _ in range(32):
bit = data & 1
if bit:
timings.append(Timing(high_us=bit_high, low_us=one_low))
else:
timings.append(Timing(high_us=bit_high, low_us=zero_low))
data >>= 1
# End pulse
timings.append(Timing(high_us=bit_high, low_us=0))
# Add repeat codes if requested
for _ in range(self.repeat_count):
# Replace the last timing's low_us with the frame gap
last_timing = timings[-1]
timings[-1] = Timing(high_us=last_timing.high_us, low_us=frame_gap)
# Repeat code: leader burst + shorter space + end pulse
timings.extend(
[
Timing(high_us=leader_high, low_us=repeat_low),
Timing(high_us=bit_high, low_us=0),
]
)
return timings

View File

@@ -0,0 +1,10 @@
{
"exceptions": {
"component_not_loaded": {
"message": "Infrared component not loaded"
},
"entity_not_found": {
"message": "Infrared entity `{entity_id}` not found"
}
}
}

View File

@@ -51,7 +51,9 @@ from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.BUTTON,
Platform.FAN,
Platform.IMAGE,
Platform.INFRARED,
Platform.LAWN_MOWER,
Platform.LOCK,
Platform.NOTIFY,
@@ -126,6 +128,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Notify backup listeners
hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)
# Reload config entry when subentries are added/removed/updated
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
# Subscribe to labs feature updates for kitchen_sink preview repair
entry.async_on_unload(
async_listen(
@@ -142,6 +147,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload config entry on update (e.g. subentry added/removed)."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload config entry."""
# Notify backup listeners

View File

@@ -8,18 +8,23 @@ from typing import Any
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.infrared import (
DOMAIN as INFRARED_DOMAIN,
async_get_emitters,
)
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
OptionsFlowWithReload,
OptionsFlow,
SubentryFlowResult,
)
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig
from . import DOMAIN
from .const import CONF_INFRARED_ENTITY_ID, DOMAIN
CONF_BOOLEAN = "bool"
CONF_INT = "int"
@@ -44,7 +49,10 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN):
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {"entity": SubentryFlowHandler}
return {
"entity": SubentryFlowHandler,
"infrared_fan": InfraredFanSubentryFlowHandler,
}
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Set the config entry up from yaml."""
@@ -65,7 +73,7 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="reauth_successful")
class OptionsFlowHandler(OptionsFlowWithReload):
class OptionsFlowHandler(OptionsFlow):
"""Handle options."""
async def async_step_init(
@@ -146,7 +154,7 @@ class SubentryFlowHandler(ConfigSubentryFlow):
"""Reconfigure a sensor."""
if user_input is not None:
title = user_input.pop("name")
return self.async_update_reload_and_abort(
return self.async_update_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=user_input,
@@ -162,3 +170,35 @@ class SubentryFlowHandler(ConfigSubentryFlow):
}
),
)
class InfraredFanSubentryFlowHandler(ConfigSubentryFlow):
"""Handle infrared fan subentry flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""User flow to add an infrared fan."""
entities = async_get_emitters(self.hass)
if not entities:
return self.async_abort(reason="no_emitters")
if user_input is not None:
title = user_input.pop("name")
return self.async_create_entry(data=user_input, title=title)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required("name"): str,
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=[entity.entity_id for entity in entities],
)
),
}
),
)

View File

@@ -7,6 +7,7 @@ from collections.abc import Callable
from homeassistant.util.hass_dict import HassKey
DOMAIN = "kitchen_sink"
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
f"{DOMAIN}.backup_agent_listeners"
)

View File

@@ -0,0 +1,148 @@
"""Demo platform that offers a fake infrared fan entity."""
from __future__ import annotations
from typing import Any
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.components.infrared import NECInfraredCommand, async_send_command
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from .const import CONF_INFRARED_ENTITY_ID, DOMAIN
PARALLEL_UPDATES = 0
DUMMY_FAN_ADDRESS = 0x1234
DUMMY_CMD_POWER_ON = 0x01
DUMMY_CMD_POWER_OFF = 0x02
DUMMY_CMD_SPEED_LOW = 0x03
DUMMY_CMD_SPEED_MEDIUM = 0x04
DUMMY_CMD_SPEED_HIGH = 0x05
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo infrared fan platform."""
for subentry_id, subentry in config_entry.subentries.items():
if subentry.subentry_type != "infrared_fan":
continue
async_add_entities(
[
DemoInfraredFan(
subentry_id=subentry_id,
device_name=subentry.title,
infrared_entity_id=subentry.data[CONF_INFRARED_ENTITY_ID],
)
],
config_subentry_id=subentry_id,
)
class DemoInfraredFan(FanEntity):
"""Representation of a demo infrared fan entity."""
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
_attr_assumed_state = True
_attr_speed_count = 3
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
def __init__(
self,
subentry_id: str,
device_name: str,
infrared_entity_id: str,
) -> None:
"""Initialize the demo infrared fan entity."""
self._infrared_entity_id = infrared_entity_id
self._attr_unique_id = subentry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, subentry_id)},
name=device_name,
)
self._attr_percentage = 0
async def async_added_to_hass(self) -> None:
"""Subscribe to infrared entity state changes."""
await super().async_added_to_hass()
@callback
def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None:
"""Handle infrared entity state changes."""
new_state = event.data["new_state"]
self._attr_available = (
new_state is not None and new_state.state != STATE_UNAVAILABLE
)
self.async_write_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, [self._infrared_entity_id], _async_ir_state_changed
)
)
# Set initial availability based on current infrared entity state
ir_state = self.hass.states.get(self._infrared_entity_id)
self._attr_available = (
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
)
async def _send_command(self, command_code: int) -> None:
"""Send an IR command using the NEC protocol."""
command = NECInfraredCommand(
address=DUMMY_FAN_ADDRESS,
command=command_code,
modulation=38000,
)
await async_send_command(
self.hass, self._infrared_entity_id, command, context=self._context
)
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn on the fan."""
if percentage is not None:
await self.async_set_percentage(percentage)
return
await self._send_command(DUMMY_CMD_POWER_ON)
self._attr_percentage = 33
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan."""
await self._send_command(DUMMY_CMD_POWER_OFF)
self._attr_percentage = 0
self.async_write_ha_state()
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
if percentage == 0:
await self.async_turn_off()
return
if percentage <= 33:
await self._send_command(DUMMY_CMD_SPEED_LOW)
elif percentage <= 66:
await self._send_command(DUMMY_CMD_SPEED_MEDIUM)
else:
await self._send_command(DUMMY_CMD_SPEED_HIGH)
self._attr_percentage = percentage
self.async_write_ha_state()

View File

@@ -0,0 +1,63 @@
"""Demo platform that offers a fake infrared entity."""
from __future__ import annotations
from homeassistant.components import persistent_notification
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo infrared platform."""
async_add_entities(
[
DemoInfrared(
unique_id="ir_transmitter",
device_name="IR Blaster",
entity_name="Infrared Transmitter",
),
]
)
class DemoInfrared(InfraredEntity):
"""Representation of a demo infrared entity."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
unique_id: str,
device_name: str,
entity_name: str,
) -> None:
"""Initialize the demo infrared entity."""
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=device_name,
)
self._attr_name = entity_name
async def async_send_command(self, command: InfraredCommand) -> None:
"""Send an IR command."""
timings = [
interval
for timing in command.get_raw_timings()
for interval in (timing.high_us, -timing.low_us)
]
persistent_notification.async_create(
self.hass, str(timings), title="Infrared Command"
)

View File

@@ -3,6 +3,7 @@
"name": "Everything but the Kitchen Sink",
"after_dependencies": ["recorder"],
"codeowners": ["@home-assistant/core"],
"dependencies": ["infrared"],
"documentation": "https://www.home-assistant.io/integrations/kitchen_sink",
"iot_class": "calculated",
"preview_features": {

View File

@@ -101,6 +101,8 @@ async def async_setup_entry(
)
for subentry_id, subentry in config_entry.subentries.items():
if subentry.subentry_type != "entity":
continue
async_add_entities(
[
DemoSensor(

View File

@@ -32,6 +32,24 @@
"description": "Reconfigure the sensor"
}
}
},
"infrared_fan": {
"abort": {
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
},
"entry_type": "Infrared fan",
"initiate_flow": {
"user": "Add infrared fan"
},
"step": {
"user": {
"data": {
"infrared_entity_id": "Infrared transmitter",
"name": "[%key:common::config_flow::data::name%]"
},
"description": "Select an infrared transmitter to control the fan."
}
}
}
},
"device": {

View File

@@ -29,6 +29,7 @@ class EntityPlatforms(StrEnum):
HUMIDIFIER = "humidifier"
IMAGE = "image"
IMAGE_PROCESSING = "image_processing"
INFRARED = "infrared"
LAWN_MOWER = "lawn_mower"
LIGHT = "light"
LOCK = "lock"

10
mypy.ini generated
View File

@@ -2586,6 +2586,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.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.input_button.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@@ -0,0 +1 @@
"""Tests for the Infrared integration."""

View File

@@ -0,0 +1,37 @@
"""Common fixtures for the Infrared tests."""
import pytest
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.components.infrared.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@pytest.fixture
async def init_integration(hass: HomeAssistant) -> None:
"""Set up the Infrared integration for testing."""
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
class MockInfraredEntity(InfraredEntity):
"""Mock infrared entity for testing."""
_attr_has_entity_name = True
_attr_name = "Test IR transmitter"
def __init__(self, unique_id: str) -> None:
"""Initialize mock entity."""
self._attr_unique_id = unique_id
self.send_command_calls: list[InfraredCommand] = []
async def async_send_command(self, command: InfraredCommand) -> None:
"""Mock send command."""
self.send_command_calls.append(command)
@pytest.fixture
def mock_infrared_entity() -> MockInfraredEntity:
"""Return a mock infrared entity."""
return MockInfraredEntity("test_ir_transmitter")

View File

@@ -0,0 +1,146 @@
"""Tests for the Infrared integration setup."""
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.infrared import (
DATA_COMPONENT,
DOMAIN,
NECInfraredCommand,
async_get_emitters,
async_send_command,
)
from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from .conftest import MockInfraredEntity
from tests.common import mock_restore_cache
async def test_get_entities_integration_setup(hass: HomeAssistant) -> None:
"""Test getting entities when the integration is not setup."""
assert async_get_emitters(hass) == []
@pytest.mark.usefixtures("init_integration")
async def test_get_entities_empty(hass: HomeAssistant) -> None:
"""Test getting entities when none are registered."""
assert async_get_emitters(hass) == []
@pytest.mark.usefixtures("init_integration")
async def test_infrared_entity_initial_state(
hass: HomeAssistant, mock_infrared_entity: MockInfraredEntity
) -> None:
"""Test infrared entity has no state before any command is sent."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_infrared_entity])
state = hass.states.get("infrared.test_ir_transmitter")
assert state is not None
assert state.state == STATE_UNKNOWN
@pytest.mark.usefixtures("init_integration")
async def test_async_send_command_success(
hass: HomeAssistant,
mock_infrared_entity: MockInfraredEntity,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test sending command via async_send_command helper."""
# Add the mock entity to the component
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_infrared_entity])
# Freeze time so we can verify the state update
now = dt_util.utcnow()
freezer.move_to(now)
command = NECInfraredCommand(address=0x04FB, command=0x08F7, modulation=38000)
await async_send_command(hass, mock_infrared_entity.entity_id, command)
assert len(mock_infrared_entity.send_command_calls) == 1
assert mock_infrared_entity.send_command_calls[0] is command
state = hass.states.get("infrared.test_ir_transmitter")
assert state is not None
assert state.state == now.isoformat(timespec="milliseconds")
@pytest.mark.usefixtures("init_integration")
async def test_async_send_command_error_does_not_update_state(
hass: HomeAssistant,
mock_infrared_entity: MockInfraredEntity,
) -> None:
"""Test that state is not updated when async_send_command raises an error."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_infrared_entity])
state = hass.states.get("infrared.test_ir_transmitter")
assert state is not None
assert state.state == STATE_UNKNOWN
command = NECInfraredCommand(address=0x04FB, command=0x08F7, modulation=38000)
mock_infrared_entity.async_send_command = AsyncMock(
side_effect=HomeAssistantError("Transmission failed")
)
with pytest.raises(HomeAssistantError, match="Transmission failed"):
await async_send_command(hass, mock_infrared_entity.entity_id, command)
# Verify state was not updated after the error
state = hass.states.get("infrared.test_ir_transmitter")
assert state is not None
assert state.state == STATE_UNKNOWN
@pytest.mark.usefixtures("init_integration")
async def test_async_send_command_entity_not_found(hass: HomeAssistant) -> None:
"""Test async_send_command raises error when entity not found."""
command = NECInfraredCommand(
address=0x04FB, command=0x08F7, modulation=38000, repeat_count=1
)
with pytest.raises(
HomeAssistantError,
match="Infrared entity `infrared.nonexistent_entity` not found",
):
await async_send_command(hass, "infrared.nonexistent_entity", command)
async def test_async_send_command_component_not_loaded(hass: HomeAssistant) -> None:
"""Test async_send_command raises error when component not loaded."""
command = NECInfraredCommand(
address=0x04FB, command=0x08F7, modulation=38000, repeat_count=1
)
with pytest.raises(HomeAssistantError, match="component_not_loaded"):
await async_send_command(hass, "infrared.some_entity", command)
async def test_infrared_entity_state_restore(
hass: HomeAssistant,
mock_infrared_entity: MockInfraredEntity,
) -> None:
"""Test infrared entity restores state from previous session."""
previous_timestamp = "2026-01-01T12:00:00.000+00:00"
mock_restore_cache(
hass, [State("infrared.test_ir_transmitter", previous_timestamp)]
)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_infrared_entity])
state = hass.states.get("infrared.test_ir_transmitter")
assert state is not None
assert state.state == previous_timestamp

View File

@@ -0,0 +1,128 @@
"""Tests for the Infrared protocol definitions."""
from homeassistant.components.infrared import NECInfraredCommand, Timing
def test_nec_command_get_raw_timings_standard() -> None:
"""Test NEC command raw timings generation for standard 8-bit address."""
expected_raw_timings = [
Timing(high_us=9000, low_us=4500),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=0),
]
command = NECInfraredCommand(
address=0x04, command=0x08, modulation=38000, repeat_count=0
)
timings = command.get_raw_timings()
assert timings == expected_raw_timings
# Same command now with 2 repeats
command_with_repeats = NECInfraredCommand(
address=command.address,
command=command.command,
modulation=command.modulation,
repeat_count=2,
)
timings_with_repeats = command_with_repeats.get_raw_timings()
assert timings_with_repeats == [
*expected_raw_timings[:-1],
Timing(high_us=562, low_us=96000),
Timing(high_us=9000, low_us=2250),
Timing(high_us=562, low_us=96000),
Timing(high_us=9000, low_us=2250),
Timing(high_us=562, low_us=0),
]
def test_nec_command_get_raw_timings_extended() -> None:
"""Test NEC command raw timings generation for extended 16-bit address."""
expected_raw_timings = [
Timing(high_us=9000, low_us=4500),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=562),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=1687),
Timing(high_us=562, low_us=0),
]
command = NECInfraredCommand(
address=0x04FB, command=0x08, modulation=38000, repeat_count=0
)
timings = command.get_raw_timings()
assert timings == expected_raw_timings
# Same command now with 2 repeats
command_with_repeats = NECInfraredCommand(
address=command.address,
command=command.command,
modulation=command.modulation,
repeat_count=2,
)
timings_with_repeats = command_with_repeats.get_raw_timings()
assert timings_with_repeats == [
*expected_raw_timings[:-1],
Timing(high_us=562, low_us=96000),
Timing(high_us=9000, low_us=2250),
Timing(high_us=562, low_us=96000),
Timing(high_us=9000, low_us=2250),
Timing(high_us=562, low_us=0),
]

View File

@@ -7,12 +7,15 @@ import pytest
from homeassistant import config_entries, setup
from homeassistant.components.kitchen_sink import DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
ENTITY_IR_TRANSMITTER = "infrared.ir_blaster_infrared_transmitter"
@pytest.fixture
def no_platforms() -> Generator[None]:
@@ -24,6 +27,16 @@ def no_platforms() -> Generator[None]:
yield
@pytest.fixture
def infrared_only() -> Generator[None]:
"""Enable only the infrared platform."""
with patch(
"homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM",
[Platform.INFRARED],
):
yield
async def test_import(hass: HomeAssistant) -> None:
"""Test that we can import a config entry."""
with patch("homeassistant.components.kitchen_sink.async_setup_entry"):
@@ -193,3 +206,57 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant) -> None:
}
await hass.async_block_till_done()
@pytest.mark.usefixtures("infrared_only")
async def test_infrared_fan_subentry_flow(hass: HomeAssistant) -> None:
"""Test infrared fan subentry flow creates an entry."""
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
result = await hass.config_entries.subentries.async_init(
(config_entry.entry_id, "infrared_fan"),
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
user_input={
"name": "Living Room Fan",
"infrared_entity_id": ENTITY_IR_TRANSMITTER,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
subentry_id = [
sid
for sid, s in config_entry.subentries.items()
if s.subentry_type == "infrared_fan"
][0]
assert config_entry.subentries[subentry_id] == config_entries.ConfigSubentry(
data={"infrared_entity_id": ENTITY_IR_TRANSMITTER},
subentry_id=subentry_id,
subentry_type="infrared_fan",
title="Living Room Fan",
unique_id=None,
)
@pytest.mark.usefixtures("no_platforms")
async def test_infrared_fan_subentry_flow_no_emitters(hass: HomeAssistant) -> None:
"""Test infrared fan subentry flow aborts when no emitters are available."""
config_entry = MockConfigEntry(domain=DOMAIN)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.subentries.async_init(
(config_entry.entry_id, "infrared_fan"),
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_emitters"

View File

@@ -0,0 +1,52 @@
"""The tests for the kitchen_sink infrared platform."""
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.infrared import NECInfraredCommand, async_send_command
from homeassistant.components.kitchen_sink import DOMAIN
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
ENTITY_IR_TRANSMITTER = "infrared.ir_blaster_infrared_transmitter"
@pytest.fixture
async def infrared_only() -> None:
"""Enable only the infrared platform."""
with patch(
"homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM",
[Platform.INFRARED],
):
yield
@pytest.fixture(autouse=True)
async def setup_comp(hass: HomeAssistant, infrared_only: None) -> None:
"""Set up demo component."""
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
async def test_send_command(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test sending an infrared command."""
state = hass.states.get(ENTITY_IR_TRANSMITTER)
assert state
assert state.state == STATE_UNKNOWN
now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00")
assert now is not None
freezer.move_to(now)
command = NECInfraredCommand(address=0x04, command=0x08, modulation=38000)
await async_send_command(hass, ENTITY_IR_TRANSMITTER, command)
state = hass.states.get(ENTITY_IR_TRANSMITTER)
assert state
assert state.state == now.isoformat(timespec="milliseconds")