Browse Source

feat!: add support for new infrared platform (#4805)

* feat: add infrared platform

- new platform in HA 2026.4.0 to send generic IR signals provided by a
device specific integration via any supported IR emitter.

This change allows supported Tuya remotes to be used as emitters via
this integration.

Issue #4793
Jason Rumney 1 week ago
parent
commit
3747ada91b

+ 7 - 1
custom_components/tuya_local/devices/README.md

@@ -684,6 +684,11 @@ Humidifer can also cover dehumidifiers (use class to specify which).
 - **current_humidity** (optional, number): a dp to report the current humidity measured by the device
 - **action** (optional, string): a dp to report the current action the device is performing. Valid actions are `humidifying`, `drying`, `idle` and `off`
 
+### `infrared`
+- **send** (required, accepts a string): a dp to send remote codes.
+- **control** (optional, accepts strings `"send_ir"`): a dp to send commands seperately from ir codes. If not supplied, commands will be JSON formatted and sent through the **send** dp.
+- **code_type** (optional, accepts integers): a dp to set the type of code being sent. The current implementation only supports type `0`. This is only used when a separate **control** dp is also supplied, otherwise the parameter is included in the JSON sent to the **send** dp.
+
 ### `lawn_mower`
 - **activity** (required, string): a dp to report the current activity of the mower. Valid activities are `mowing`, `paused`, `docked`, `error`, `returning` (from LawnMowerActivities in https://github.com/home-assistant/core/blob/dev/homeassistant/components/lawn_mower/const.py). Any additional activities should be mapped to one of those, and exposed through an extra attribute or sensor entity that shows all the statuses that the mower is reporting.
 
@@ -743,9 +748,10 @@ no information will be available about which specific credential was used to unl
 ### `remote`
 - **send** (required, accepts a string): a dp to send remote codes.
 - **receive** (optional, returns strings): a dp to receive learned commands on. If not supplied, the `remote.learn_command` service call will not be available. 
-- **control** (optional, accepts strings `"send_ir"`, `"study"`, `"study_exit"`): a dp to send commands seperately from ir codes. If not supplied, commands will be JSON formatted and sent through the **send** dp.
+- **control** (optional, accepts strings `"send_ir"`, `"study"`, `"study_exit"`, `rfstudy_send`, `rf_study`, `rfstudy_exit`): a dp to send commands seperately from ir codes. If not supplied, commands will be JSON formatted and sent through the **send** dp.
 - **delay** (optional, accepts numbers): a dp to set the delay in ms between buttons when there are multiple in the send string. This is only used when a separate **control** dp is also supplied, otherwise the parameter is included in the JSON sent to the **send** dp.
 - **code_type** (optional, accepts integers): a dp to set the type of code being sent. The current implementation only supports type `0`. This is only used when a separate **control** dp is also supplied, otherwise the parameter is included in the JSON sent to the **send** dp.
+
 ### `select`
 - **option** (required, mapping of strings): a dp to control the option that is selected.
 

+ 6 - 0
custom_components/tuya_local/devices/avatto_whs20s_irremote.yaml

@@ -114,3 +114,9 @@ entities:
         type: string
         optional: true
         persist: false
+  - entity: infrared
+    dps:
+      - id: 201
+        name: send
+        type: string
+        optional: true

+ 6 - 0
custom_components/tuya_local/devices/basic_ir_remote.yaml

@@ -25,3 +25,9 @@ entities:
         type: string
         optional: true
         persist: false
+  - entity: infrared
+    dps:
+      - id: 201
+        type: string
+        optional: true
+        name: send

+ 1 - 1
custom_components/tuya_local/devices/device_config_schema.json

@@ -49,7 +49,7 @@
                 "properties": {
                     "entity": {
                         "type": "string",
-                        "enum": ["alarm_control_panel", "binary_sensor", "button", "camera", "climate", "cover", "datetime", "event", "fan", "humidifier", "lawn_mower", "light", "lock", "number", "remote", "select", "sensor", "siren", "switch", "text", "time", "vacuum", "valve", "water_heater"],
+                        "enum": ["alarm_control_panel", "binary_sensor", "button", "camera", "climate", "cover", "datetime", "event", "fan", "humidifier", "infrared", "lawn_mower", "light", "lock", "number", "remote", "select", "sensor", "siren", "switch", "text", "time", "vacuum", "valve", "water_heater"],
                         "description": "The type of entity (e.g., light, switch, sensor)."
                     },
                     "name": {

+ 16 - 0
custom_components/tuya_local/devices/hircr_remote_control.yaml

@@ -68,3 +68,19 @@ entities:
         range:
           min: 0
           max: 255
+  - entity: infrared
+    dps:
+      - id: 1
+        type: string
+        name: control
+      - id: 3
+        type: string
+        optional: true
+        name: send
+      - id: 13
+        type: integer
+        optional: true
+        name: code_type
+        range:
+          min: 0
+          max: 255

+ 6 - 0
custom_components/tuya_local/devices/ir_moes_heatpump.yaml

@@ -187,3 +187,9 @@ entities:
         type: string
         optional: true
         persist: false
+  - entity: infrared
+    dps:
+      - id: 201
+        name: send
+        type: string
+        optional: true

+ 6 - 0
custom_components/tuya_local/devices/ir_remote_sensors.yaml

@@ -13,6 +13,12 @@ entities:
         type: string
         optional: true
         persist: false
+  - entity: infrared
+    dps:
+      - id: 201
+        name: send
+        type: string
+        optional: true
   - entity: sensor
     class: temperature
     dps:

+ 6 - 0
custom_components/tuya_local/devices/moes_controlpanel.yaml

@@ -131,6 +131,12 @@ entities:
         type: string
         optional: true
         name: receive
+  - entity: infrared
+    dps:
+      - id: 201
+        type: string
+        optional: true
+        name: send
   - entity: number
     name: Voice volume
     category: config

+ 6 - 0
custom_components/tuya_local/devices/s11_rfir_remote.yaml

@@ -36,3 +36,9 @@ entities:
         type: string
         optional: true
         name: scene_4
+  - entity: infrared
+    dps:
+      - id: 201
+        name: send
+        type: string
+        optional: true

+ 6 - 0
custom_components/tuya_local/devices/woox_r7246_ir_remote_with_th_sensor.yaml

@@ -64,3 +64,9 @@ entities:
         type: string
         optional: true
         persist: false
+  - entity: infrared
+    dps:
+      - id: 201
+        name: send
+        type: string
+        optional: true

+ 79 - 0
custom_components/tuya_local/infrared.py

@@ -0,0 +1,79 @@
+"""
+Implementation of Tuya infrared control devices
+"""
+
+import logging
+
+from homeassistant.components.infrared import InfraredCommand, InfraredEntity
+from tinytuya.Contrib.IRRemoteControlDevice import IRRemoteControlDevice as IR
+
+from .device import TuyaLocalDevice
+from .entity import TuyaLocalEntity
+from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+    """Set up the Tuya Local infrared control platform."""
+    config = {**entry.data, **entry.options}
+    await async_tuya_setup_platform(
+        hass,
+        async_add_entities,
+        config,
+        "infrared",
+        TuyaLocalInfrared,
+    )
+
+
+class TuyaLocalInfrared(TuyaLocalEntity, InfraredEntity):
+    """Representation of a Tuya Local infrared control device."""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """Initialize the infrared control device."""
+        super().__init__()
+        dps_map = self._init_begin(device, config)
+        self._send_dp = dps_map.pop("send", None)
+        self._command_dp = dps_map.pop("control", None)
+        self._type_dp = dps_map.pop("code_type", None)
+        self._init_end(dps_map)
+
+    async def async_send_command(self, command: InfraredCommand) -> None:
+        """Handle sending an infrared command."""
+        timings = command.get_raw_timings()
+        raw = [
+            interval
+            for timing in timings
+            for interval in (timing.high_us, timing.low_us)
+        ]
+        tuya_command = IR.pulses_to_base64(raw)
+        _LOGGER.debug("Sending infrared command: %s", tuya_command)
+        if self._send_dp:
+            if self._command_dp:
+                await self._device.async_set_properties(
+                    self.package_multi_dp_send(tuya_command)
+                )
+            else:
+                await self._send_dp.async_set_value(
+                    self._device,
+                    self.package_single_dp_send(tuya_command),
+                )
+
+    def package_single_dp_send(self, command: str) -> str:
+        """Package the command for a single DP (usually dp id 201) send."""
+        json_command = {
+            "control": "send_ir",
+            "type": 0,
+            "head": "",
+            "key1": "1" + command,
+        }
+        return json_command
+
+    def package_multi_dp_send(self, command: str) -> dict:
+        """Package the command for a multi DP send"""
+        return {
+            f"{self._command_dp.id}": "send_ir",
+            f"{self._type_dp.id}": 0,
+            f"{self._send_dp.id}": command,
+        }

+ 1 - 1
hacs.json

@@ -1,5 +1,5 @@
 {
     "name": "Tuya Local",
-    "homeassistant": "2025.11.0",
+    "homeassistant": "2026.4.0",
     "hacs": "2.0.0"
 }

+ 1 - 1
requirements-dev.txt

@@ -2,7 +2,7 @@ fuzzywuzzy
 infrared-protocols~=1.1.0
 levenshtein
 PyTurboJPEG~=1.8.0
-pytest-homeassistant-custom-component==0.13.320
+pytest-homeassistant-custom-component==0.13.321
 pytest
 pytest-asyncio
 pytest-cov

+ 2 - 0
tests/devices/base_device_tests.py

@@ -18,6 +18,7 @@ from custom_components.tuya_local.helpers.device_config import (
     possible_matches,
 )
 from custom_components.tuya_local.humidifier import TuyaLocalHumidifier
+from custom_components.tuya_local.infrared import TuyaLocalInfrared
 from custom_components.tuya_local.lawn_mower import TuyaLocalLawnMower
 from custom_components.tuya_local.light import TuyaLocalLight
 from custom_components.tuya_local.lock import TuyaLocalLock
@@ -44,6 +45,7 @@ DEVICE_TYPES = {
     "event": TuyaLocalEvent,
     "fan": TuyaLocalFan,
     "humidifier": TuyaLocalHumidifier,
+    "infrared": TuyaLocalInfrared,
     "lawn_mower": TuyaLocalLawnMower,
     "light": TuyaLocalLight,
     "lock": TuyaLocalLock,

+ 10 - 0
tests/helpers.py

@@ -77,3 +77,13 @@ async def assert_device_properties_set_optional(
 
         for result in results:
             result.assert_awaited()
+
+
+def mock_device(dps, mocker):
+    """Helper function to create a mock device with specified dps."""
+    device = mocker.MagicMock()
+    device.get_property.side_effect = lambda id: dps.get(id)
+    device.has_returned_state = True
+    device.unique_id = "test_device_id"
+    device.name = "Test Device"
+    return device

+ 7 - 12
tests/test_device_config.py

@@ -18,7 +18,7 @@ from custom_components.tuya_local.helpers.device_config import (
 from custom_components.tuya_local.sensor import TuyaLocalSensor
 
 from .const import GPPH_HEATER_PAYLOAD, KOGAN_HEATER_PAYLOAD
-from .helpers import assert_device_properties_set
+from .helpers import assert_device_properties_set, mock_device
 
 PRODUCT_SCHEMA = vol.Schema(
     {
@@ -136,6 +136,7 @@ ENTITY_SCHEMA = vol.Schema(
                 "event",
                 "fan",
                 "humidifier",
+                "infrared",
                 "lawn_mower",
                 "light",
                 "lock",
@@ -229,6 +230,10 @@ KNOWN_DPS = {
         "required": ["humidity"],
         "optional": ["switch", "mode", "current_humidity"],
     },
+    "infrared": {
+        "required": ["send"],
+        "optional": ["control", "code_type", "delay"],
+    },
     "lawn_mower": {"required": ["activity", "command"], "optional": []},
     "light": {
         "required": [{"or": ["switch", "brightness", "effect"]}],
@@ -260,7 +265,7 @@ KNOWN_DPS = {
     },
     "remote": {
         "required": ["send"],
-        "optional": ["receive"],
+        "optional": ["receive", "command", "type", "head"],
     },
     "select": {"required": ["option"], "optional": []},
     "sensor": {"required": ["sensor"], "optional": ["unit"]},
@@ -303,16 +308,6 @@ KNOWN_DPS = {
 }
 
 
-def mock_device(dps, mocker):
-    """Helper function to create a mock device with specified dps."""
-    device = mocker.MagicMock()
-    device.get_property.side_effect = lambda id: dps.get(id)
-    device.has_returned_state = True
-    device.unique_id = "test_device_id"
-    device.name = "Test Device"
-    return device
-
-
 def test_can_find_config_files():
     """Test that the config files can be found by the parser."""
     found = False

+ 135 - 0
tests/test_infrared.py

@@ -0,0 +1,135 @@
+"""Tests for the infrared entity."""
+
+import pytest
+from infrared_protocols.commands import NECCommand
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+from custom_components.tuya_local.const import (
+    CONF_DEVICE_ID,
+    CONF_PROTOCOL_VERSION,
+    CONF_TYPE,
+    DOMAIN,
+)
+from custom_components.tuya_local.helpers.device_config import TuyaEntityConfig
+from custom_components.tuya_local.infrared import TuyaLocalInfrared, async_setup_entry
+
+from .helpers import assert_device_properties_set, mock_device
+
+
+@pytest.mark.asyncio
+async def test_init_entry(hass, mocker):
+    """Test the initialisation."""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "ir_remote_sensors",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    # although async, the async_add_entities function passed to
+    # async_setup_entry is called truly asynchronously. If we use
+    # AsyncMock, it expects us to await the result.
+    m_add_entities = mocker.Mock()
+    m_device = mocker.AsyncMock()
+
+    hass.data[DOMAIN] = {}
+    hass.data[DOMAIN]["dummy"] = {}
+    hass.data[DOMAIN]["dummy"]["device"] = m_device
+
+    await async_setup_entry(hass, entry, m_add_entities)
+    assert type(hass.data[DOMAIN]["dummy"]["infrared"]) is TuyaLocalInfrared
+    m_add_entities.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_init_entry_fails_if_device_has_no_infrared(hass, mocker):
+    """Test initialisation when device has no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "smartplugv1",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    # although async, the async_add_entities function passed to
+    # async_setup_entry is called truly asynchronously. If we use
+    # AsyncMock, it expects us to await the result.
+    m_add_entities = mocker.Mock()
+    m_device = mocker.AsyncMock()
+
+    hass.data[DOMAIN] = {}
+    hass.data[DOMAIN]["dummy"] = {}
+    hass.data[DOMAIN]["dummy"]["device"] = m_device
+    try:
+        await async_setup_entry(hass, entry, m_add_entities)
+        assert False
+    except ValueError:
+        pass
+    m_add_entities.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_init_entry_fails_if_config_is_missing(hass, mocker):
+    """Test initialisation when device has no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "non_existing",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    # although async, the async_add_entities function passed to
+    # async_setup_entry is called truly asynchronously. If we use
+    # AsyncMock, it expects us to await the result.
+    m_add_entities = mocker.Mock()
+    m_device = mocker.AsyncMock()
+
+    hass.data[DOMAIN] = {}
+    hass.data[DOMAIN]["dummy"] = {}
+    hass.data[DOMAIN]["dummy"]["device"] = m_device
+    try:
+        await async_setup_entry(hass, entry, m_add_entities)
+        assert False
+    except ValueError:
+        pass
+    m_add_entities.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_async_send_command(mocker):
+    """Test that infrared encodes commands as expected."""
+    config = {
+        "entity": "infrared",
+        "dps": [
+            {
+                "id": "201",
+                "name": "send",
+                "type": "base64",
+            }
+        ],
+    }
+    tuyadevice = mocker.MagicMock()
+    dps = {"201": ""}
+    device = mock_device(dps, mocker)
+    infrared = TuyaLocalInfrared(
+        device,
+        TuyaEntityConfig(tuyadevice, config),
+    )
+
+    async with assert_device_properties_set(
+        device,
+        {
+            "201": (
+                "{'control': 'send_ir', 'type': 0, 'head': '', 'key1': '1KCOUETICMgIyAjI"
+                "CMgKXBjICMgIyAjICMgIyAjICMgIyApcGMgIyAjIClwYyAjICMgIyAjIClwYyAjICMgKXBj"
+                "ICMgIyAjICMgKXBjICMgIyAjICMgKXBjIClwYyAjICMgIyAjIClwYyAjICMgKXBjIClwYyA"
+                "jICMgIyAjIClwYyApcGMgIAAA=='}"
+            )
+        },
+    ):
+        await infrared.async_send_command(
+            NECCommand(address=0x5284, command=0x32, modulation=38000)
+        )