mirror of
https://github.com/home-assistant/core.git
synced 2026-01-10 17:47:16 +01:00
Compare commits
20 Commits
tibber_bin
...
20251206-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b135d03203 | ||
|
|
5c35f0acbf | ||
|
|
544edf83e9 | ||
|
|
b117d13ae8 | ||
|
|
1ce38fc400 | ||
|
|
a38f4ea273 | ||
|
|
d59f83e0f2 | ||
|
|
83e4c7cd82 | ||
|
|
d02e2d1005 | ||
|
|
966dfdcef5 | ||
|
|
b50f6fb97e | ||
|
|
2d5e0d0d03 | ||
|
|
127a05ca97 | ||
|
|
dd3af8872f | ||
|
|
a793afa059 | ||
|
|
9b820436a8 | ||
|
|
f63d693ae2 | ||
|
|
343c4183f7 | ||
|
|
c37ca31bec | ||
|
|
d47d013219 |
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -772,6 +772,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
|
||||
|
||||
@@ -29,6 +29,7 @@ from aioesphomeapi import (
|
||||
Event,
|
||||
EventInfo,
|
||||
FanInfo,
|
||||
InfraredProxyInfo,
|
||||
LightInfo,
|
||||
LockInfo,
|
||||
MediaPlayerInfo,
|
||||
@@ -84,6 +85,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
|
||||
DateTimeInfo: Platform.DATETIME,
|
||||
EventInfo: Platform.EVENT,
|
||||
FanInfo: Platform.FAN,
|
||||
InfraredProxyInfo: Platform.INFRARED,
|
||||
LightInfo: Platform.LIGHT,
|
||||
LockInfo: Platform.LOCK,
|
||||
MediaPlayerInfo: Platform.MEDIA_PLAYER,
|
||||
@@ -187,6 +189,7 @@ class RuntimeEntryData:
|
||||
entity_removal_callbacks: dict[EntityInfoKey, list[CALLBACK_TYPE]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
infrared_proxy_receive_callbacks: list[CALLBACK_TYPE] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -518,6 +521,27 @@ class RuntimeEntryData:
|
||||
),
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_on_infrared_proxy_receive(
|
||||
self, hass: HomeAssistant, receive_event: Any
|
||||
) -> None:
|
||||
"""Handle an infrared proxy receive event."""
|
||||
# Fire a Home Assistant event with the infrared data
|
||||
device_info = self.device_info
|
||||
if not device_info:
|
||||
return
|
||||
|
||||
hass.bus.async_fire(
|
||||
f"{DOMAIN}_infrared_proxy_received",
|
||||
{
|
||||
"device_name": device_info.name,
|
||||
"device_mac": device_info.mac_address,
|
||||
"entry_id": self.entry_id,
|
||||
"key": receive_event.key,
|
||||
"timings": receive_event.timings,
|
||||
},
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_register_assist_satellite_config_updated_callback(
|
||||
self,
|
||||
|
||||
112
homeassistant/components/esphome/infrared.py
Normal file
112
homeassistant/components/esphome/infrared.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Infrared platform for ESPHome."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
|
||||
from aioesphomeapi import (
|
||||
EntityInfo,
|
||||
EntityState,
|
||||
InfraredProxyCapability,
|
||||
InfraredProxyInfo,
|
||||
)
|
||||
|
||||
from homeassistant.components.infrared import (
|
||||
InfraredCommand,
|
||||
InfraredEntity,
|
||||
InfraredEntityFeature,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import EsphomeEntity, platform_async_setup_entry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class EsphomeInfraredEntity(
|
||||
EsphomeEntity[InfraredProxyInfo, EntityState], InfraredEntity
|
||||
):
|
||||
"""ESPHome infrared entity using native API."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
@callback
|
||||
def _on_static_info_update(self, static_info: EntityInfo) -> None:
|
||||
"""Set attrs from static info."""
|
||||
super()._on_static_info_update(static_info)
|
||||
static_info = self._static_info
|
||||
capabilities = static_info.capabilities
|
||||
|
||||
features = InfraredEntityFeature(0)
|
||||
if capabilities & InfraredProxyCapability.TRANSMITTER:
|
||||
features |= InfraredEntityFeature.TRANSMIT
|
||||
if capabilities & InfraredProxyCapability.RECEIVER:
|
||||
features |= InfraredEntityFeature.RECEIVE
|
||||
self._attr_supported_features = features
|
||||
|
||||
@callback
|
||||
def _on_device_update(self) -> None:
|
||||
"""Call when device updates or entry data changes."""
|
||||
super()._on_device_update()
|
||||
if self._entry_data.available:
|
||||
# Infrared entities should go available as soon as the device comes online
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the infrared entity."""
|
||||
has_transmit = bool(self.supported_features & InfraredEntityFeature.TRANSMIT)
|
||||
has_receive = bool(self.supported_features & InfraredEntityFeature.RECEIVE)
|
||||
if has_transmit and has_receive:
|
||||
return "IR Transceiver"
|
||||
if has_transmit:
|
||||
return "IR Transmitter"
|
||||
if has_receive:
|
||||
return "IR Receiver"
|
||||
raise HomeAssistantError("Invalid infrared entity with no supported features.")
|
||||
|
||||
async def async_send_command(self, command: InfraredCommand) -> None:
|
||||
"""Send an IR command.
|
||||
|
||||
Raises:
|
||||
HomeAssistantError: If transmission fails or not supported.
|
||||
"""
|
||||
if not self._static_info.capabilities & InfraredProxyCapability.TRANSMITTER:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="infrared_proxy_transmitter_not_supported",
|
||||
)
|
||||
|
||||
timings = [
|
||||
interval
|
||||
for timing in command.get_raw_timings()
|
||||
for interval in (timing.high_us, -timing.low_us)
|
||||
]
|
||||
_LOGGER.debug("Sending command: %s", timings)
|
||||
|
||||
try:
|
||||
self._client.infrared_proxy_transmit_raw_timings(
|
||||
self._static_info.key, carrier_frequency=38000, timings=timings
|
||||
)
|
||||
except Exception as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_sending_ir_command",
|
||||
translation_placeholders={
|
||||
"device_name": self._device_info.name,
|
||||
"error": str(err),
|
||||
},
|
||||
) from err
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
platform_async_setup_entry,
|
||||
info_type=InfraredProxyInfo,
|
||||
entity_type=EsphomeInfraredEntity,
|
||||
state_type=EntityState,
|
||||
)
|
||||
@@ -692,6 +692,11 @@ class ESPHomeManager:
|
||||
cli.subscribe_zwave_proxy_request(self._async_zwave_proxy_request)
|
||||
)
|
||||
|
||||
if device_info.infrared_proxy_feature_flags:
|
||||
entry_data.disconnect_callbacks.add(
|
||||
cli.subscribe_infrared_proxy_receive(self._async_infrared_proxy_receive)
|
||||
)
|
||||
|
||||
cli.subscribe_home_assistant_states_and_services(
|
||||
on_state=entry_data.async_update_state,
|
||||
on_service_call=self.async_on_service_call,
|
||||
@@ -722,6 +727,10 @@ class ESPHomeManager:
|
||||
self.hass, self.entry_data.device_info, zwave_home_id
|
||||
)
|
||||
|
||||
def _async_infrared_proxy_receive(self, receive_event: Any) -> None:
|
||||
"""Handle an infrared proxy receive event."""
|
||||
self.entry_data.async_on_infrared_proxy_receive(self.hass, receive_event)
|
||||
|
||||
async def on_disconnect(self, expected_disconnect: bool) -> None:
|
||||
"""Run disconnect callbacks on API disconnect."""
|
||||
entry_data = self.entry_data
|
||||
|
||||
@@ -137,9 +137,15 @@
|
||||
"error_compiling": {
|
||||
"message": "Error compiling {configuration}. Try again in ESPHome dashboard for more information."
|
||||
},
|
||||
"error_sending_ir_command": {
|
||||
"message": "Error sending IR command to {device_name}: {error}"
|
||||
},
|
||||
"error_uploading": {
|
||||
"message": "Error during OTA (Over-The-Air) update of {configuration}. Try again in ESPHome dashboard for more information."
|
||||
},
|
||||
"infrared_proxy_transmitter_not_supported": {
|
||||
"message": "Device does not support infrared transmission"
|
||||
},
|
||||
"ota_in_progress": {
|
||||
"message": "An OTA (Over-The-Air) update is already in progress for {configuration}."
|
||||
}
|
||||
|
||||
170
homeassistant/components/infrared/__init__.py
Normal file
170
homeassistant/components/infrared/__init__.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Support for infrared transmitter entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import final
|
||||
|
||||
from propcache.api import cached_property
|
||||
|
||||
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
|
||||
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, InfraredEntityFeature
|
||||
from .protocols import InfraredCommand, InfraredProtocol, NECInfraredCommand, Timing
|
||||
|
||||
__all__ = [
|
||||
"DOMAIN",
|
||||
"InfraredCommand",
|
||||
"InfraredEntity",
|
||||
"InfraredEntityDescription",
|
||||
"InfraredEntityFeature",
|
||||
"InfraredProtocol",
|
||||
"NECInfraredCommand",
|
||||
"Timing",
|
||||
"async_get_entities",
|
||||
"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_entities(
|
||||
hass: HomeAssistant, supported_features: InfraredEntityFeature
|
||||
) -> list[InfraredEntity]:
|
||||
"""Get all infrared entities that support the given features."""
|
||||
component = hass.data.get(DATA_COMPONENT)
|
||||
if component is None:
|
||||
return []
|
||||
|
||||
return [
|
||||
entity
|
||||
for entity in component.entities
|
||||
if entity.supported_features & supported_features
|
||||
]
|
||||
|
||||
|
||||
async def async_send_command(
|
||||
hass: HomeAssistant,
|
||||
entity_id: 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("Infrared component not loaded.")
|
||||
|
||||
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."""
|
||||
|
||||
|
||||
CACHED_PROPERTIES_WITH_ATTR_ = {"supported_features"}
|
||||
|
||||
|
||||
class InfraredEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Base class for infrared transmitter entities."""
|
||||
|
||||
entity_description: InfraredEntityDescription
|
||||
_attr_supported_features: InfraredEntityFeature = InfraredEntityFeature(0)
|
||||
_attr_should_poll = False
|
||||
_attr_state: None
|
||||
|
||||
__last_command_sent: datetime | None = None
|
||||
|
||||
@cached_property
|
||||
def supported_features(self) -> InfraredEntityFeature:
|
||||
"""Flag supported features."""
|
||||
return self._attr_supported_features
|
||||
|
||||
@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.
|
||||
"""
|
||||
self.__last_command_sent = dt_util.utcnow()
|
||||
self.async_write_ha_state()
|
||||
await self.async_send_command(command)
|
||||
|
||||
@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.
|
||||
"""
|
||||
16
homeassistant/components/infrared/const.py
Normal file
16
homeassistant/components/infrared/const.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Constants for the Infrared integration."""
|
||||
|
||||
from enum import IntFlag
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "infrared"
|
||||
|
||||
|
||||
class InfraredEntityFeature(IntFlag):
|
||||
"""Supported features of infrared entities."""
|
||||
|
||||
TRANSMIT = 1
|
||||
"""Entity can transmit IR signals."""
|
||||
|
||||
RECEIVE = 2
|
||||
"""Entity can receive/learn IR signals."""
|
||||
8
homeassistant/components/infrared/manifest.json
Normal file
8
homeassistant/components/infrared/manifest.json
Normal 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"
|
||||
}
|
||||
121
homeassistant/components/infrared/protocols.py
Normal file
121
homeassistant/components/infrared/protocols.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""IR protocol definitions for the Infrared integration."""
|
||||
|
||||
import abc
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import override
|
||||
|
||||
|
||||
class InfraredProtocol(StrEnum):
|
||||
"""IR protocol type identifiers."""
|
||||
|
||||
NEC = "nec"
|
||||
SAMSUNG = "samsung"
|
||||
|
||||
|
||||
@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."""
|
||||
|
||||
protocol: InfraredProtocol
|
||||
repeat_count: int
|
||||
|
||||
def __init__(self, *, repeat_count: int = 0) -> None:
|
||||
"""Initialize the IR command."""
|
||||
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."""
|
||||
|
||||
protocol = InfraredProtocol.NEC
|
||||
address: int
|
||||
command: int
|
||||
|
||||
def __init__(self, *, address: int, command: int, repeat_count: int = 0) -> None:
|
||||
"""Initialize the NEC IR command."""
|
||||
super().__init__(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.append(Timing(high_us=leader_high, low_us=repeat_low))
|
||||
timings.append(Timing(high_us=bit_high, low_us=0))
|
||||
|
||||
return timings
|
||||
10
homeassistant/components/infrared/strings.json
Normal file
10
homeassistant/components/infrared/strings.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"entity_not_found": {
|
||||
"message": "Infrared entity `{entity_id}` not found"
|
||||
},
|
||||
"send_command_failed": {
|
||||
"message": "Failed to send IR command: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
homeassistant/components/lg_infrared/__init__.py
Normal file
20
homeassistant/components/lg_infrared/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""LG IR Remote integration for Home Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up LG 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 a LG IR config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
256
homeassistant/components/lg_infrared/button.py
Normal file
256
homeassistant/components/lg_infrared/button.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""Button platform for LG IR integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
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_DEVICE_TYPE,
|
||||
CONF_INFRARED_ENTITY_ID,
|
||||
DOMAIN,
|
||||
LG_ADDRESS,
|
||||
LGDeviceType,
|
||||
LGTVCommand,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class LgIrButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes LG IR button entity."""
|
||||
|
||||
command_code: int
|
||||
|
||||
|
||||
TV_BUTTON_DESCRIPTIONS: tuple[LgIrButtonEntityDescription, ...] = (
|
||||
LgIrButtonEntityDescription(
|
||||
key="power_on",
|
||||
translation_key="power_on",
|
||||
command_code=LGTVCommand.POWER_ON,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="power_off",
|
||||
translation_key="power_off",
|
||||
command_code=LGTVCommand.POWER_OFF,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="hdmi_1",
|
||||
translation_key="hdmi_1",
|
||||
command_code=LGTVCommand.HDMI_1,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="hdmi_2",
|
||||
translation_key="hdmi_2",
|
||||
command_code=LGTVCommand.HDMI_2,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="hdmi_3",
|
||||
translation_key="hdmi_3",
|
||||
command_code=LGTVCommand.HDMI_3,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="hdmi_4",
|
||||
translation_key="hdmi_4",
|
||||
command_code=LGTVCommand.HDMI_4,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="exit",
|
||||
translation_key="exit",
|
||||
command_code=LGTVCommand.EXIT,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="info",
|
||||
translation_key="info",
|
||||
command_code=LGTVCommand.INFO,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="guide",
|
||||
translation_key="guide",
|
||||
command_code=LGTVCommand.GUIDE,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="up",
|
||||
translation_key="up",
|
||||
command_code=LGTVCommand.NAV_UP,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="down",
|
||||
translation_key="down",
|
||||
command_code=LGTVCommand.NAV_DOWN,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="left",
|
||||
translation_key="left",
|
||||
command_code=LGTVCommand.NAV_LEFT,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="right",
|
||||
translation_key="right",
|
||||
command_code=LGTVCommand.NAV_RIGHT,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="ok",
|
||||
translation_key="ok",
|
||||
command_code=LGTVCommand.OK,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="back",
|
||||
translation_key="back",
|
||||
command_code=LGTVCommand.BACK,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="home",
|
||||
translation_key="home",
|
||||
command_code=LGTVCommand.HOME,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="menu",
|
||||
translation_key="menu",
|
||||
command_code=LGTVCommand.MENU,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="input",
|
||||
translation_key="input",
|
||||
command_code=LGTVCommand.INPUT,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_0",
|
||||
translation_key="num_0",
|
||||
command_code=LGTVCommand.NUM_0,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_1",
|
||||
translation_key="num_1",
|
||||
command_code=LGTVCommand.NUM_1,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_2",
|
||||
translation_key="num_2",
|
||||
command_code=LGTVCommand.NUM_2,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_3",
|
||||
translation_key="num_3",
|
||||
command_code=LGTVCommand.NUM_3,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_4",
|
||||
translation_key="num_4",
|
||||
command_code=LGTVCommand.NUM_4,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_5",
|
||||
translation_key="num_5",
|
||||
command_code=LGTVCommand.NUM_5,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_6",
|
||||
translation_key="num_6",
|
||||
command_code=LGTVCommand.NUM_6,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_7",
|
||||
translation_key="num_7",
|
||||
command_code=LGTVCommand.NUM_7,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_8",
|
||||
translation_key="num_8",
|
||||
command_code=LGTVCommand.NUM_8,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_9",
|
||||
translation_key="num_9",
|
||||
command_code=LGTVCommand.NUM_9,
|
||||
),
|
||||
)
|
||||
|
||||
HIFI_BUTTON_DESCRIPTIONS: tuple[LgIrButtonEntityDescription, ...] = (
|
||||
LgIrButtonEntityDescription(
|
||||
key="power_on",
|
||||
translation_key="power_on",
|
||||
command_code=LGTVCommand.POWER_ON,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up LG IR buttons from config entry."""
|
||||
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
|
||||
device_type = entry.data.get(CONF_DEVICE_TYPE, LGDeviceType.TV)
|
||||
if device_type == LGDeviceType.TV:
|
||||
async_add_entities(
|
||||
LgIrButton(entry, infrared_entity_id, description)
|
||||
for description in TV_BUTTON_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
class LgIrButton(ButtonEntity):
|
||||
"""LG IR button entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_description: LgIrButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: ConfigEntry,
|
||||
infrared_entity_id: str,
|
||||
description: LgIrButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize LG IR button."""
|
||||
self._entry = entry
|
||||
self._infrared_entity_id = infrared_entity_id
|
||||
self._description = description
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{entry.entry_id}_{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)}, name="LG TV", manufacturer="LG"
|
||||
)
|
||||
|
||||
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 async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
command = NECInfraredCommand(
|
||||
address=LG_ADDRESS,
|
||||
command=self._description.command_code,
|
||||
repeat_count=1,
|
||||
)
|
||||
await async_send_command(
|
||||
self.hass, self._infrared_entity_id, command, context=self._context
|
||||
)
|
||||
85
homeassistant/components/lg_infrared/config_flow.py
Normal file
85
homeassistant/components/lg_infrared/config_flow.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Config flow for LG IR integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.infrared import (
|
||||
DOMAIN as INFRARED_DOMAIN,
|
||||
InfraredEntityFeature,
|
||||
async_get_entities,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers.selector import (
|
||||
EntitySelector,
|
||||
EntitySelectorConfig,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, DOMAIN, LGDeviceType
|
||||
|
||||
DEVICE_TYPE_NAMES: dict[LGDeviceType, str] = {
|
||||
LGDeviceType.TV: "TV",
|
||||
}
|
||||
|
||||
|
||||
class LgIrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle config flow for LG IR."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
entities = async_get_entities(
|
||||
self.hass, supported_features=InfraredEntityFeature.TRANSMIT
|
||||
)
|
||||
if not entities:
|
||||
return self.async_abort(reason="no_emitters")
|
||||
|
||||
valid_entity_ids = [entity.entity_id for entity in entities]
|
||||
|
||||
if user_input is not None:
|
||||
entity_id = user_input[CONF_INFRARED_ENTITY_ID]
|
||||
device_type = user_input[CONF_DEVICE_TYPE]
|
||||
|
||||
await self.async_set_unique_id(f"lg_ir_{device_type}_{entity_id}")
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Get entity name for the title
|
||||
entity_name = next(
|
||||
(
|
||||
entity.name or entity.entity_id
|
||||
for entity in entities
|
||||
if entity.entity_id == entity_id
|
||||
),
|
||||
entity_id,
|
||||
)
|
||||
device_type_name = DEVICE_TYPE_NAMES[LGDeviceType(device_type)]
|
||||
title = f"LG {device_type_name} via {entity_name}"
|
||||
|
||||
return self.async_create_entry(title=title, data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_DEVICE_TYPE): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[device_type.value for device_type in LGDeviceType],
|
||||
translation_key=CONF_DEVICE_TYPE,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
|
||||
EntitySelectorConfig(
|
||||
domain=INFRARED_DOMAIN,
|
||||
include_entities=valid_entity_ids,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
59
homeassistant/components/lg_infrared/const.py
Normal file
59
homeassistant/components/lg_infrared/const.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Constants for the LG IR integration."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
DOMAIN = "lg_infrared"
|
||||
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
|
||||
CONF_DEVICE_TYPE = "device_type"
|
||||
|
||||
LG_ADDRESS = 0xFB04
|
||||
|
||||
|
||||
class LGDeviceType(StrEnum):
|
||||
"""LG device types."""
|
||||
|
||||
TV = "tv"
|
||||
|
||||
|
||||
class LGTVCommand:
|
||||
"""LG TV IR command codes."""
|
||||
|
||||
BACK = 0xD728
|
||||
CHANNEL_DOWN = 0xFE01
|
||||
CHANNEL_UP = 0xFF00
|
||||
EXIT = 0xA45B
|
||||
FAST_FORWARD = 0x718E
|
||||
GUIDE = 0x56A9
|
||||
HDMI_1 = 0x31CE
|
||||
HDMI_2 = 0x33CC
|
||||
HDMI_3 = 0x16E9
|
||||
HDMI_4 = 0x25DA
|
||||
HOME = 0x837C
|
||||
INFO = 0x55AA
|
||||
INPUT = 0xF40B
|
||||
MENU = 0xBC43
|
||||
MUTE = 0xF609
|
||||
NAV_DOWN = 0xBE41
|
||||
NAV_LEFT = 0xF807
|
||||
NAV_RIGHT = 0xF906
|
||||
NAV_UP = 0xBF40
|
||||
NUM_0 = 0xEF10
|
||||
NUM_1 = 0xEE11
|
||||
NUM_2 = 0xED12
|
||||
NUM_3 = 0xEC13
|
||||
NUM_4 = 0xEB14
|
||||
NUM_5 = 0xEA15
|
||||
NUM_6 = 0xE916
|
||||
NUM_7 = 0xE817
|
||||
NUM_8 = 0xE718
|
||||
NUM_9 = 0xE619
|
||||
OK = 0xBB44
|
||||
PAUSE = 0x45BA
|
||||
PLAY = 0x4FB0
|
||||
POWER = 0xF708
|
||||
POWER_ON = 0x3BC4
|
||||
POWER_OFF = 0x3AC5
|
||||
REWIND = 0x708F
|
||||
STOP = 0x4EB1
|
||||
VOLUME_DOWN = 0xFC03
|
||||
VOLUME_UP = 0xFD02
|
||||
11
homeassistant/components/lg_infrared/manifest.json
Normal file
11
homeassistant/components/lg_infrared/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "lg_infrared",
|
||||
"name": "LG Infrared",
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"dependencies": ["infrared"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/lg_infrared",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze"
|
||||
}
|
||||
144
homeassistant/components/lg_infrared/media_player.py
Normal file
144
homeassistant/components/lg_infrared/media_player.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Media player platform for LG IR integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.infrared import NECInfraredCommand, async_send_command
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
)
|
||||
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_DEVICE_TYPE,
|
||||
CONF_INFRARED_ENTITY_ID,
|
||||
DOMAIN,
|
||||
LG_ADDRESS,
|
||||
LGDeviceType,
|
||||
LGTVCommand,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up LG IR media player from config entry."""
|
||||
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
|
||||
device_type = entry.data.get(CONF_DEVICE_TYPE, LGDeviceType.TV)
|
||||
if device_type == LGDeviceType.TV:
|
||||
async_add_entities([LgIrTvMediaPlayer(entry, infrared_entity_id)])
|
||||
|
||||
|
||||
class LgIrTvMediaPlayer(MediaPlayerEntity):
|
||||
"""LG IR media player entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_assumed_state = True
|
||||
_attr_device_class = MediaPlayerDeviceClass.TV
|
||||
_attr_supported_features = (
|
||||
MediaPlayerEntityFeature.TURN_ON
|
||||
| MediaPlayerEntityFeature.TURN_OFF
|
||||
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.NEXT_TRACK
|
||||
| MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||
| MediaPlayerEntityFeature.PLAY
|
||||
| MediaPlayerEntityFeature.PAUSE
|
||||
| MediaPlayerEntityFeature.STOP
|
||||
)
|
||||
|
||||
def __init__(self, entry: ConfigEntry, infrared_entity_id: str) -> None:
|
||||
"""Initialize LG IR media player."""
|
||||
self._entry = entry
|
||||
self._infrared_entity_id = infrared_entity_id
|
||||
self._attr_unique_id = f"{entry.entry_id}_media_player"
|
||||
self._attr_state = MediaPlayerState.ON
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)}, name="LG TV", manufacturer="LG"
|
||||
)
|
||||
|
||||
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, repeat_count: int = 1) -> None:
|
||||
"""Send an IR command using the LG protocol."""
|
||||
command = NECInfraredCommand(
|
||||
address=LG_ADDRESS,
|
||||
command=command_code,
|
||||
repeat_count=repeat_count,
|
||||
)
|
||||
await async_send_command(
|
||||
self.hass, self._infrared_entity_id, command, context=self._context
|
||||
)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn on the TV."""
|
||||
await self._send_command(LGTVCommand.POWER)
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn off the TV."""
|
||||
await self._send_command(LGTVCommand.POWER)
|
||||
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Send volume up command."""
|
||||
await self._send_command(LGTVCommand.VOLUME_UP)
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Send volume down command."""
|
||||
await self._send_command(LGTVCommand.VOLUME_DOWN)
|
||||
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Send mute command."""
|
||||
await self._send_command(LGTVCommand.MUTE)
|
||||
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Send channel up command."""
|
||||
await self._send_command(LGTVCommand.CHANNEL_UP)
|
||||
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send channel down command."""
|
||||
await self._send_command(LGTVCommand.CHANNEL_DOWN)
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
await self._send_command(LGTVCommand.PLAY)
|
||||
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Send pause command."""
|
||||
await self._send_command(LGTVCommand.PAUSE)
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Send stop command."""
|
||||
await self._send_command(LGTVCommand.STOP)
|
||||
127
homeassistant/components/lg_infrared/quality_scale.yaml
Normal file
127
homeassistant/components/lg_infrared/quality_scale.yaml
Normal file
@@ -0,0 +1,127 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not poll.
|
||||
brands:
|
||||
status: exempt
|
||||
comment: |
|
||||
This is a proof of concept integration, brand assets will be added later.
|
||||
common-modules:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration is simple and does not share patterns with others.
|
||||
config-flow-test-coverage:
|
||||
status: exempt
|
||||
comment: |
|
||||
This is a proof of concept integration, config flow tests will be added later.
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description:
|
||||
status: exempt
|
||||
comment: |
|
||||
This is a proof of concept integration, documentation will be added later.
|
||||
docs-installation-instructions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This is a proof of concept integration, documentation will be added later.
|
||||
docs-removal-instructions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This is a proof of concept integration, documentation will be added later.
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not subscribe to events.
|
||||
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: |
|
||||
Setup validation is handled by checking emitter existence in remote.py.
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: todo
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not require authentication.
|
||||
test-coverage: todo
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration is configured manually via config flow.
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Each config entry creates a single device.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: |
|
||||
The remote entity is the primary entity and does not need a category.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: |
|
||||
Remote entities do not have a device class.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
The remote entity is the primary entity and should be enabled by default.
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have repairable issues.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Each config entry manages exactly one device.
|
||||
|
||||
# Platinum
|
||||
async-dependency:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration only depends on ir_proxy which is part of Home Assistant.
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not make HTTP requests.
|
||||
strict-typing: todo
|
||||
114
homeassistant/components/lg_infrared/strings.json
Normal file
114
homeassistant/components/lg_infrared/strings.json
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "This LG device has already been configured with this transmitter.",
|
||||
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"device_type": "Device type",
|
||||
"infrared_entity_id": "Infrared transmitter"
|
||||
},
|
||||
"description": "Select the device type and the infrared transmitter entity to use for controlling your LG device.",
|
||||
"title": "Set up LG IR Remote"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"back": {
|
||||
"name": "Back"
|
||||
},
|
||||
"down": {
|
||||
"name": "Down"
|
||||
},
|
||||
"exit": {
|
||||
"name": "Exit"
|
||||
},
|
||||
"guide": {
|
||||
"name": "Guide"
|
||||
},
|
||||
"hdmi_1": {
|
||||
"name": "HDMI 1"
|
||||
},
|
||||
"hdmi_2": {
|
||||
"name": "HDMI 2"
|
||||
},
|
||||
"hdmi_3": {
|
||||
"name": "HDMI 3"
|
||||
},
|
||||
"hdmi_4": {
|
||||
"name": "HDMI 4"
|
||||
},
|
||||
"home": {
|
||||
"name": "Home"
|
||||
},
|
||||
"info": {
|
||||
"name": "Info"
|
||||
},
|
||||
"input": {
|
||||
"name": "Input"
|
||||
},
|
||||
"left": {
|
||||
"name": "Left"
|
||||
},
|
||||
"menu": {
|
||||
"name": "Menu"
|
||||
},
|
||||
"num_0": {
|
||||
"name": "0"
|
||||
},
|
||||
"num_1": {
|
||||
"name": "1"
|
||||
},
|
||||
"num_2": {
|
||||
"name": "2"
|
||||
},
|
||||
"num_3": {
|
||||
"name": "3"
|
||||
},
|
||||
"num_4": {
|
||||
"name": "4"
|
||||
},
|
||||
"num_5": {
|
||||
"name": "5"
|
||||
},
|
||||
"num_6": {
|
||||
"name": "6"
|
||||
},
|
||||
"num_7": {
|
||||
"name": "7"
|
||||
},
|
||||
"num_8": {
|
||||
"name": "8"
|
||||
},
|
||||
"num_9": {
|
||||
"name": "9"
|
||||
},
|
||||
"ok": {
|
||||
"name": "OK"
|
||||
},
|
||||
"power_off": {
|
||||
"name": "Power off"
|
||||
},
|
||||
"power_on": {
|
||||
"name": "Power on"
|
||||
},
|
||||
"right": {
|
||||
"name": "Right"
|
||||
},
|
||||
"up": {
|
||||
"name": "Up"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"device_type": {
|
||||
"options": {
|
||||
"hifi": "Hi-Fi",
|
||||
"tv": "TV"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -369,6 +369,7 @@ FLOWS = {
|
||||
"led_ble",
|
||||
"lektrico",
|
||||
"letpot",
|
||||
"lg_infrared",
|
||||
"lg_netcast",
|
||||
"lg_soundbar",
|
||||
"lg_thinq",
|
||||
|
||||
1
homeassistant/generated/entity_platforms.py
generated
1
homeassistant/generated/entity_platforms.py
generated
@@ -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"
|
||||
|
||||
@@ -3555,6 +3555,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"lg_infrared": {
|
||||
"name": "LG Infrared",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
"libre_hardware_monitor": {
|
||||
"name": "Libre Hardware Monitor",
|
||||
"integration_type": "hub",
|
||||
|
||||
@@ -2171,6 +2171,7 @@ NO_QUALITY_SCALE = [
|
||||
"input_text",
|
||||
"intent_script",
|
||||
"intent",
|
||||
"infrared",
|
||||
"labs",
|
||||
"logbook",
|
||||
"logger",
|
||||
|
||||
236
tests/components/esphome/test_infrared.py
Normal file
236
tests/components/esphome/test_infrared.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""Test ESPHome infrared platform."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
InfraredProxyCapability,
|
||||
InfraredProxyInfo,
|
||||
InfraredProxyReceiveEvent,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.infrared import (
|
||||
InfraredEntityFeature,
|
||||
NECInfraredCommand,
|
||||
async_get_entities,
|
||||
)
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from .conftest import MockESPHomeDeviceType
|
||||
|
||||
|
||||
def _create_infrared_proxy_info(
|
||||
object_id: str = "myremote",
|
||||
key: int = 1,
|
||||
name: str = "my remote",
|
||||
capabilities: InfraredProxyCapability = InfraredProxyCapability.TRANSMITTER,
|
||||
) -> InfraredProxyInfo:
|
||||
"""Create mock InfraredProxyInfo."""
|
||||
return InfraredProxyInfo(
|
||||
object_id=object_id, key=key, name=name, capabilities=capabilities
|
||||
)
|
||||
|
||||
|
||||
def _get_expected_entity_id(capabilities: InfraredProxyCapability) -> str:
|
||||
"""Get expected entity ID based on capabilities.
|
||||
|
||||
The entity name is dynamically determined in the entity based on capabilities:
|
||||
- TRANSMITTER only -> "IR Transmitter" -> infrared.test_ir_transmitter
|
||||
- RECEIVER only -> "IR Receiver" -> infrared.test_ir_receiver
|
||||
- Both -> "IR Transceiver" -> infrared.test_ir_transceiver
|
||||
"""
|
||||
if capabilities == InfraredProxyCapability.TRANSMITTER:
|
||||
return "infrared.test_ir_transmitter"
|
||||
if capabilities == InfraredProxyCapability.RECEIVER:
|
||||
return "infrared.test_ir_receiver"
|
||||
return "infrared.test_ir_transceiver"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("capabilities", "expected_features"),
|
||||
[
|
||||
(InfraredProxyCapability.TRANSMITTER, InfraredEntityFeature.TRANSMIT),
|
||||
(
|
||||
InfraredProxyCapability.RECEIVER,
|
||||
InfraredEntityFeature.RECEIVE,
|
||||
),
|
||||
(
|
||||
InfraredProxyCapability.TRANSMITTER | InfraredProxyCapability.RECEIVER,
|
||||
InfraredEntityFeature.TRANSMIT | InfraredEntityFeature.RECEIVE,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_capabilities(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
capabilities: InfraredProxyCapability,
|
||||
expected_features: InfraredEntityFeature,
|
||||
) -> None:
|
||||
"""Test infrared entity capabilities."""
|
||||
entity_info = [_create_infrared_proxy_info(capabilities=capabilities)]
|
||||
await mock_esphome_device(mock_client=mock_client, entity_info=entity_info)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_id = _get_expected_entity_id(capabilities)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.attributes.get("supported_features") == expected_features
|
||||
|
||||
|
||||
async def test_unavailability(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test infrared entity availability."""
|
||||
entity_info = [_create_infrared_proxy_info()]
|
||||
device = await mock_esphome_device(mock_client=mock_client, entity_info=entity_info)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_id = "infrared.test_ir_transmitter"
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
await device.mock_disconnect(True)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
await device.mock_connect()
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_receive_event(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test infrared receive event firing."""
|
||||
entity_info = [
|
||||
_create_infrared_proxy_info(capabilities=InfraredProxyCapability.RECEIVER)
|
||||
]
|
||||
device = await mock_esphome_device(mock_client=mock_client, entity_info=entity_info)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
events = []
|
||||
|
||||
def event_listener(event):
|
||||
events.append(event)
|
||||
|
||||
hass.bus.async_listen("esphome_infrared_proxy_received", event_listener)
|
||||
|
||||
# Simulate receiving an infrared signal
|
||||
receive_event = InfraredProxyReceiveEvent(
|
||||
key=1,
|
||||
timings=[1000, 500, 1000, 500, 500, 1000],
|
||||
)
|
||||
entry_data = device.entry.runtime_data
|
||||
entry_data.async_on_infrared_proxy_receive(hass, receive_event)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify event was fired
|
||||
assert len(events) == 1
|
||||
event_data = events[0].data
|
||||
assert event_data["key"] == 1
|
||||
assert event_data["timings"] == [1000, 500, 1000, 500, 500, 1000]
|
||||
assert event_data["device_name"] == "test"
|
||||
assert "entry_id" in event_data
|
||||
|
||||
|
||||
async def test_send_nec_command(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test sending NEC command via native API using raw timings."""
|
||||
entity_info = [_create_infrared_proxy_info()]
|
||||
await mock_esphome_device(mock_client=mock_client, entity_info=entity_info)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entities = async_get_entities(
|
||||
hass, supported_features=InfraredEntityFeature.TRANSMIT
|
||||
)
|
||||
assert len(entities) == 1
|
||||
entity = entities[0]
|
||||
|
||||
command = NECInfraredCommand(
|
||||
address=0x10,
|
||||
command=0x20,
|
||||
repeat_count=1,
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
mock_client, "infrared_proxy_transmit_raw_timings"
|
||||
) as mock_transmit:
|
||||
await entity.async_send_command(command)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_transmit.assert_called_once()
|
||||
call_args = mock_transmit.call_args
|
||||
assert call_args[0][0] == 1 # key
|
||||
|
||||
# Verify carrier frequency
|
||||
assert call_args.kwargs.get("carrier_frequency") == 38000
|
||||
|
||||
# Verify timings is a list of integers (alternating high/low)
|
||||
timings = call_args.kwargs.get("timings")
|
||||
assert timings is not None
|
||||
assert isinstance(timings, list)
|
||||
assert len(timings) > 0
|
||||
# Should have alternating positive (high) and negative (low) values
|
||||
# First should be positive (leader high)
|
||||
assert timings[0] > 0
|
||||
|
||||
|
||||
async def test_send_command_no_transmitter(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test sending command to receiver-only device raises error."""
|
||||
entity_info = [
|
||||
_create_infrared_proxy_info(capabilities=InfraredProxyCapability.RECEIVER)
|
||||
]
|
||||
await mock_esphome_device(mock_client=mock_client, entity_info=entity_info)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entities = async_get_entities(
|
||||
hass, supported_features=InfraredEntityFeature.RECEIVE
|
||||
)
|
||||
assert len(entities) == 1
|
||||
entity = entities[0]
|
||||
|
||||
command = NECInfraredCommand(address=0x04, command=0x08)
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await entity.async_send_command(command)
|
||||
|
||||
|
||||
async def test_device_association(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test infrared entity is associated with ESPHome device."""
|
||||
entity_info = [_create_infrared_proxy_info()]
|
||||
await mock_esphome_device(mock_client=mock_client, entity_info=entity_info)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device = device_registry.async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, "11:22:33:44:55:aa")}
|
||||
)
|
||||
assert device is not None
|
||||
|
||||
entry = entity_registry.async_get("infrared.test_ir_transmitter")
|
||||
assert entry is not None
|
||||
assert entry.device_id == device.id
|
||||
1
tests/components/infrared/__init__.py
Normal file
1
tests/components/infrared/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the Infrared integration."""
|
||||
44
tests/components/infrared/conftest.py
Normal file
44
tests/components/infrared/conftest.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Common fixtures for the Infrared tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.infrared import (
|
||||
InfraredCommand,
|
||||
InfraredEntity,
|
||||
InfraredEntityFeature,
|
||||
)
|
||||
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._attr_supported_features = InfraredEntityFeature.TRANSMIT
|
||||
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")
|
||||
187
tests/components/infrared/test_init.py
Normal file
187
tests/components/infrared/test_init.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Tests for the Infrared integration setup."""
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.infrared import (
|
||||
DATA_COMPONENT,
|
||||
DOMAIN,
|
||||
InfraredEntityFeature,
|
||||
NECInfraredCommand,
|
||||
async_get_entities,
|
||||
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_setup(hass: HomeAssistant) -> None:
|
||||
"""Test Infrared integration setup."""
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify the component is loaded
|
||||
assert DATA_COMPONENT in hass.data
|
||||
|
||||
|
||||
async def test_get_entities_empty(hass: HomeAssistant) -> None:
|
||||
"""Test getting entities when none are registered."""
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entities = async_get_entities(
|
||||
hass, supported_features=InfraredEntityFeature.TRANSMIT
|
||||
)
|
||||
assert entities == []
|
||||
|
||||
|
||||
async def test_get_entities_filter_by_feature(
|
||||
hass: HomeAssistant,
|
||||
init_integration: None,
|
||||
mock_infrared_entity: MockInfraredEntity,
|
||||
) -> None:
|
||||
"""Test filtering entities by feature support."""
|
||||
# Add the mock entity to the component
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
await component.async_add_entities([mock_infrared_entity])
|
||||
|
||||
# Get entities with TRANSMIT feature (should match)
|
||||
transmit_entities = async_get_entities(
|
||||
hass, supported_features=InfraredEntityFeature.TRANSMIT
|
||||
)
|
||||
assert len(transmit_entities) == 1
|
||||
assert transmit_entities[0] is mock_infrared_entity
|
||||
|
||||
# Get entities with RECEIVE feature (should not match since mock only supports TRANSMIT)
|
||||
receive_entities = async_get_entities(
|
||||
hass, supported_features=InfraredEntityFeature.RECEIVE
|
||||
)
|
||||
assert len(receive_entities) == 0
|
||||
|
||||
|
||||
async def test_infrared_entity_initial_state(
|
||||
hass: HomeAssistant,
|
||||
init_integration: None,
|
||||
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
|
||||
|
||||
|
||||
async def test_infrared_entity_send_command(
|
||||
hass: HomeAssistant,
|
||||
init_integration: None,
|
||||
mock_infrared_entity: MockInfraredEntity,
|
||||
) -> None:
|
||||
"""Test sending command via infrared entity."""
|
||||
# Add the mock entity to the component
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
await component.async_add_entities([mock_infrared_entity])
|
||||
|
||||
# Create a test command
|
||||
command = NECInfraredCommand(
|
||||
address=0x04FB,
|
||||
command=0x08F7,
|
||||
repeat_count=1,
|
||||
)
|
||||
|
||||
# Send command
|
||||
await mock_infrared_entity.async_send_command(command)
|
||||
|
||||
# Verify command was recorded
|
||||
assert len(mock_infrared_entity.send_command_calls) == 1
|
||||
assert mock_infrared_entity.send_command_calls[0] is command
|
||||
|
||||
|
||||
async def test_infrared_entity_features(
|
||||
hass: HomeAssistant,
|
||||
init_integration: None,
|
||||
mock_infrared_entity: MockInfraredEntity,
|
||||
) -> None:
|
||||
"""Test infrared entity features property."""
|
||||
assert mock_infrared_entity.supported_features == InfraredEntityFeature.TRANSMIT
|
||||
|
||||
|
||||
async def test_async_send_command_success(
|
||||
hass: HomeAssistant,
|
||||
init_integration: None,
|
||||
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
|
||||
now = dt_util.utcnow()
|
||||
freezer.move_to(now)
|
||||
|
||||
command = NECInfraredCommand(address=0x04FB, command=0x08F7, repeat_count=1)
|
||||
await async_send_command(hass, "infrared.test_ir_transmitter", 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")
|
||||
|
||||
|
||||
async def test_async_send_command_entity_not_found(
|
||||
hass: HomeAssistant, init_integration: None
|
||||
) -> None:
|
||||
"""Test async_send_command raises error when entity not found."""
|
||||
command = NECInfraredCommand(address=0x04FB, command=0x08F7, 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, repeat_count=1)
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="Infrared 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."""
|
||||
# Set up restore cache with a previous state (milliseconds format)
|
||||
previous_timestamp = "2026-01-01T12:00:00.000+00:00"
|
||||
mock_restore_cache(
|
||||
hass,
|
||||
[State("infrared.test_ir_transmitter", previous_timestamp)],
|
||||
)
|
||||
|
||||
# Set up integration
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Add entity
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
await component.async_add_entities([mock_infrared_entity])
|
||||
|
||||
# Verify state was restored
|
||||
state = hass.states.get("infrared.test_ir_transmitter")
|
||||
assert state is not None
|
||||
assert state.state == previous_timestamp
|
||||
91
tests/components/infrared/test_protocols.py
Normal file
91
tests/components/infrared/test_protocols.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Tests for the Infrared protocol definitions."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.infrared import (
|
||||
InfraredProtocol,
|
||||
NECInfraredCommand,
|
||||
Timing,
|
||||
)
|
||||
|
||||
|
||||
def test_nec_command_get_raw_timings_standard() -> None:
|
||||
"""Test NEC command raw timings generation for standard 8-bit address."""
|
||||
command = NECInfraredCommand(
|
||||
address=0x04,
|
||||
command=0x08,
|
||||
repeat_count=0,
|
||||
)
|
||||
|
||||
timings = command.get_raw_timings()
|
||||
|
||||
# Leader pulse
|
||||
assert timings[0] == Timing(high_us=9000, low_us=4500)
|
||||
|
||||
# 32 data bits + end pulse = 33 more timings after leader
|
||||
assert len(timings) == 34 # 1 leader + 32 data bits + 1 end pulse
|
||||
|
||||
# End pulse (no repeat, so low_us = 0)
|
||||
assert timings[-1].high_us == 562
|
||||
assert timings[-1].low_us == 0
|
||||
|
||||
|
||||
def test_nec_command_get_raw_timings_extended() -> None:
|
||||
"""Test NEC command raw timings generation for extended 16-bit address."""
|
||||
command = NECInfraredCommand(
|
||||
address=0x04FB, # 16-bit address
|
||||
command=0x08,
|
||||
repeat_count=0,
|
||||
)
|
||||
|
||||
timings = command.get_raw_timings()
|
||||
|
||||
# Leader pulse
|
||||
assert timings[0] == Timing(high_us=9000, low_us=4500)
|
||||
|
||||
# 32 data bits + end pulse = 33 more timings after leader
|
||||
assert len(timings) == 34
|
||||
|
||||
|
||||
def test_nec_command_get_raw_timings_with_repeat() -> None:
|
||||
"""Test NEC command raw timings generation with repeat codes."""
|
||||
command = NECInfraredCommand(
|
||||
address=0x04,
|
||||
command=0x08,
|
||||
repeat_count=2,
|
||||
)
|
||||
|
||||
timings = command.get_raw_timings()
|
||||
|
||||
# Base: 1 leader + 32 data bits + 1 end pulse = 34
|
||||
# Each repeat: replaces last low_us with gap, adds leader + end pulse = +2
|
||||
# With 2 repeats: 34 + 2*2 = 38
|
||||
assert len(timings) == 38
|
||||
|
||||
# Last timing should be end pulse with low_us=0
|
||||
assert timings[-1].high_us == 562
|
||||
assert timings[-1].low_us == 0
|
||||
|
||||
|
||||
def test_nec_command_protocol_attribute() -> None:
|
||||
"""Test NEC command has correct protocol attribute."""
|
||||
command = NECInfraredCommand(
|
||||
address=0x04,
|
||||
command=0x08,
|
||||
)
|
||||
|
||||
assert command.protocol == InfraredProtocol.NEC
|
||||
|
||||
|
||||
def test_timing_frozen() -> None:
|
||||
"""Test that Timing is immutable."""
|
||||
timing = Timing(high_us=9000, low_us=4500)
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
timing.high_us = 1000 # type: ignore[misc]
|
||||
|
||||
|
||||
def test_protocol_types() -> None:
|
||||
"""Test protocol type enum values."""
|
||||
assert InfraredProtocol.NEC == "nec"
|
||||
assert InfraredProtocol.SAMSUNG == "samsung"
|
||||
Reference in New Issue
Block a user