Преглед изворни кода

fix(remote): formatting issue from python post processing

Stray CR added for some unknown reason on every save.
Jason Rumney пре 1 дан
родитељ
комит
22abc4f4cb
1 измењених фајлова са 464 додато и 464 уклоњено
  1. 464 464
      custom_components/tuya_local/remote.py

+ 464 - 464
custom_components/tuya_local/remote.py

@@ -1,464 +1,464 @@
-"""
-Implementation of Tuya remote control devices
-Based on broadlink integration for code saving under HA storage
-"""
-
-import asyncio
-import json
-import logging
-from collections import defaultdict
-from collections.abc import Iterable
-from datetime import timedelta
-from itertools import product
-from typing import Any
-
-import voluptuous as vol
-from homeassistant.components import persistent_notification
-from homeassistant.components.remote import (
-    ATTR_ALTERNATIVE,
-    ATTR_COMMAND_TYPE,
-    ATTR_DELAY_SECS,
-    ATTR_DEVICE,
-    ATTR_NUM_REPEATS,
-    DEFAULT_DELAY_SECS,
-    SERVICE_DELETE_COMMAND,
-    SERVICE_LEARN_COMMAND,
-    SERVICE_SEND_COMMAND,
-    RemoteEntity,
-    RemoteEntityFeature,
-)
-from homeassistant.components.remote import DOMAIN as RM_DOMAIN
-from homeassistant.const import ATTR_COMMAND
-from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.storage import Store
-from homeassistant.util import dt as dt_util
-
-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__)
-
-CODE_STORAGE_VERSION = 1
-FLAG_STORAGE_VERSION = 1
-
-CODE_SAVE_DELAY = 15
-FLAG_SAVE_DELAY = 15
-
-LEARNING_TIMEOUT = timedelta(seconds=30)
-
-# These commands seem to be standard for all devices
-CMD_SEND = "send_ir"
-CMD_SEND_RF = "rfstudy_send"
-CMD_LEARN = "study"
-CMD_ENDLEARN = "study_exit"
-CMD_STUDYKEY = "study_key"
-CMD_STUDYRF = "rf_study"
-CMD_ENDSTUDYRF = "rfstudy_exit"
-
-COMMAND_SCHEMA = vol.Schema(
-    {
-        vol.Required(ATTR_COMMAND): vol.All(
-            cv.ensure_list, [vol.All(cv.string, vol.Length(min=1))], vol.Length(min=1)
-        ),
-    },
-    extra=vol.ALLOW_EXTRA,
-)
-
-SERVICE_SEND_SCHEMA = COMMAND_SCHEMA.extend(
-    {
-        vol.Optional(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1)),
-        vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): vol.Coerce(float),
-    }
-)
-SERVICE_LEARN_SCHEMA = COMMAND_SCHEMA.extend(
-    {
-        vol.Required(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1)),
-        vol.Optional(ATTR_ALTERNATIVE, default=False): cv.boolean,
-    }
-)
-SERVICE_DELETE_SCHEMA = COMMAND_SCHEMA.extend(
-    {
-        vol.Required(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1)),
-    }
-)
-
-
-async def async_setup_entry(hass, config_entry, async_add_entities):
-    config = {**config_entry.data, **config_entry.options}
-    await async_tuya_setup_platform(
-        hass,
-        async_add_entities,
-        config,
-        "remote",
-        TuyaLocalRemote,
-    )
-
-
-class TuyaLocalRemote(TuyaLocalEntity, RemoteEntity):
-    """Representation of a Tuya Remote entity."""
-
-    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
-        """
-        Initialise the remote device.
-        Args:
-           device (TuyaLocalDevice): The device API instance.
-           config (TuyaEntityConfig): The entity config.
-        """
-        super().__init__()
-        dps_map = self._init_begin(device, config)
-        self._send_dp = dps_map.pop("send", None)
-        self._receive_dp = dps_map.pop("receive", None)
-        # Some remotes split out the control (command) into its own dp and just send raw codes in send
-        self._control_dp = dps_map.pop("control", None)
-        self._delay_dp = dps_map.pop("delay", None)
-        self._type_dp = dps_map.pop("code_type", None)
-        self._init_end(dps_map)
-        if self._receive_dp:
-            self._attr_supported_features |= (
-                RemoteEntityFeature.LEARN_COMMAND | RemoteEntityFeature.DELETE_COMMAND
-            )
-        self._code_storage = Store(
-            device._hass,
-            CODE_STORAGE_VERSION,
-            f"tuya_local_remote_{device.unique_id}_codes",
-        )
-        self._flag_storage = Store(
-            device._hass,
-            FLAG_STORAGE_VERSION,
-            f"tuya_local_remote_{device.unique_id}_flags",
-        )
-        self._storage_loaded = False
-        self._codes = {}
-        self._flags = defaultdict(int)
-        self._lock = asyncio.Lock()
-        self._attr_is_on = True
-
-    async def _async_load_storage(self):
-        """Load stored codes and flags from disk."""
-        self._codes.update(await self._code_storage.async_load() or {})
-        self._flags.update(await self._flag_storage.async_load() or {})
-        self._storage_loaded = True
-
-    def _extract_codes(self, commands, subdevice=None):
-        """Extract a list of remote codes.
-        If the command starts with 'b64:', extract the IR code from it.
-        If the command starts with 'rf:', keep it as-is so that
-        _encode_send_code can apply the correct RF payload format.
-        Otherwise use the command and optionally subdevice as keys to extract the
-        actual command from storage.
-
-        The commands are returned in sublists. For toggle commands, the sublist
-        may contain two codes that must be sent alternately with each call."""
-        code_list = []
-        for cmd in commands:
-            if cmd.startswith("b64:"):
-                codes = [cmd[4:]]
-            elif cmd.startswith("rf:"):
-                codes = [cmd]
-            else:
-                if subdevice is None:
-                    raise ValueError("device must be specified")
-                try:
-                    codes = self._codes[subdevice][cmd]
-                except KeyError as err:
-                    raise ValueError(
-                        f"Command {repr(cmd)} not found for {subdevice}"
-                    ) from err
-                if isinstance(codes, list):
-                    codes = codes[:]
-                else:
-                    codes = [codes]
-
-            for idx, code in enumerate(codes):
-                try:
-                    codes[idx] = code
-                except ValueError as err:
-                    raise ValueError(f"Invalid code: {repr(code)}") from err
-
-            code_list.append(codes)
-        return code_list
-
-    def _encode_send_code(self, code, delay, is_rf=False):
-        """Encode a remote command into dps values to send.
-
-        Set is_rf=True to use the RF sub-GHz payload format.
-        The default (is_rf=False) uses the IR payload format.
-
-        Based on https://github.com/jasonacox/tinytuya/issues/74 and
-        the docs it references, there are two kinds of IR devices.
-        1. separate dps for control, code, study,...
-        2. single dp (201) for send_ir, which takes JSON input,
-           including control, code, delay, etc, and another for
-           study_ir (202) that receives the codes in study mode.
-        RF devices also use a single dp (201) but with a different
-        JSON payload using control 'rfstudy_send'.
-        """
-        dps = {}
-        if self._control_dp:
-            # control and code are sent in separate dps.
-            dps = dps | self._control_dp.get_values_to_set(self._device, CMD_SEND, dps)
-            dps = dps | self._send_dp.get_values_to_set(self._device, code, dps)
-            if self._delay_dp:
-                dps = dps | self._delay_dp.get_values_to_set(self._device, delay, dps)
-            if self._type_dp:
-                dps = dps | self._type_dp.get_values_to_set(self._device, 0, dps)
-        elif is_rf:
-            dps = dps | self._send_dp.get_values_to_set(
-                self._device,
-                json.dumps(
-                    {
-                        "control": CMD_SEND_RF,
-                        "rf_type": "sub_2g",
-                        "mode": 0,
-                        "key1": {
-                            "times": 6,
-                            "intervals": 0,
-                            "ver": "2",
-                            "delay": 0,
-                            "code": code,
-                        },
-                        "feq": 0,
-                        "rate": 0,
-                        "ver": "2",
-                    },
-                ),
-                dps,
-            )
-        else:
-            dps = dps | self._send_dp.get_values_to_set(
-                self._device,
-                json.dumps(
-                    {
-                        "control": CMD_SEND,
-                        "head": "",
-                        # leading zero means use head, any other leading character is discarded.
-                        "key1": "1" + code,
-                        "type": 0,
-                        "delay": int(delay),
-                    },
-                ),
-                dps,
-            )
-
-        return dps
-
-    async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
-        """Send remote commands"""
-        kwargs[ATTR_COMMAND] = command
-        kwargs = SERVICE_SEND_SCHEMA(kwargs)
-        subdevice = kwargs.get(ATTR_DEVICE)
-        repeat = kwargs.get(ATTR_NUM_REPEATS)
-        delay = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS)
-        service = f"{RM_DOMAIN}.{SERVICE_SEND_COMMAND}"
-        if not self._storage_loaded:
-            await self._async_load_storage()
-
-        try:
-            code_list = self._extract_codes(command, subdevice)
-        except ValueError as err:
-            _LOGGER.error("Failed to call %s: %s", service, err)
-            raise
-
-        at_least_one_sent = False
-        for _, codes in product(range(repeat), code_list):
-            if at_least_one_sent:
-                await asyncio.sleep(delay)
-
-            if len(codes) > 1:
-                code = codes[self._flags[subdevice]]
-            else:
-                code = codes[0]
-
-            async with self._device.set_lock:
-                # Tuya delay is in milliseconds
-                if code.startswith("rf:"):
-                    dps_to_set = self._encode_send_code(
-                        code[3:], delay * 1000, is_rf=True
-                    )
-                else:
-                    dps_to_set = self._encode_send_code(code, delay * 1000)
-                    _LOGGER.info(
-                        "%s sending command %s to %s",
-                        self._config.config_id,
-                        code,
-                        subdevice or "default device",
-                    )
-                    await self._device.async_set_properties(dps_to_set)
-
-            if len(codes) > 1:
-                self._flags[subdevice] ^= 1
-            at_least_one_sent = True
-
-        if at_least_one_sent:
-            self._flag_storage.async_delay_save(lambda: self._flags, FLAG_SAVE_DELAY)
-
-    async def async_learn_command(self, **kwargs: Any) -> None:
-        """Learn a list of commands from a remote."""
-        kwargs = SERVICE_LEARN_SCHEMA(kwargs)
-        commands = kwargs[ATTR_COMMAND]
-        subdevice = kwargs[ATTR_DEVICE]
-        toggle = kwargs[ATTR_ALTERNATIVE]
-        is_rf = kwargs.get(ATTR_COMMAND_TYPE) == "rf"
-
-        if not self._storage_loaded:
-            await self._async_load_storage()
-
-        async with self._lock:
-            should_store = False
-
-            for command in commands:
-                code = await self._async_learn_command(command, is_rf=is_rf)
-                _LOGGER.info("Learning %s for %s: %s", command, subdevice, code)
-                if toggle:
-                    code = [code, await self._async_learn_command(command, is_rf=is_rf)]
-                self._codes.setdefault(subdevice, {}).update({command: code})
-                should_store = True
-
-            if should_store:
-                await self._code_storage.async_save(self._codes)
-
-    async def _async_learn_command(self, command, is_rf=False):
-        """Learn a single command"""
-        service = f"{RM_DOMAIN}.{SERVICE_LEARN_COMMAND}"
-        if is_rf:
-            cmd_start = json.dumps(
-                {
-                    "control": CMD_STUDYRF,
-                    "rf_type": "sub_2g",
-                    "study_feq": "0",
-                    "ver": "2",
-                }
-            )
-            cmd_end = json.dumps(
-                {
-                    "control": CMD_ENDSTUDYRF,
-                    "rf_type": "sub_2g",
-                    "study_feq": "0",
-                    "ver": "2",
-                }
-            )
-        async with self._device.set_lock:
-            if self._control_dp:
-                _LOGGER.debug(
-                    "%s starting learning %s using multi dps method",
-                    self._config.config_id,
-                    command,
-                )
-                await self._control_dp.async_set_value(self._device, CMD_LEARN)
-            elif is_rf:
-                _LOGGER.debug(
-                    "%s starting learning %s using RF",
-                    self._config.config_id,
-                    command,
-                )
-                await self._send_dp.async_set_value(self._device, cmd_start)
-            else:
-                _LOGGER.debug(
-                    "%s starting learning %s using IR",
-                    self._config.config_id,
-                    command,
-                )
-                await self._send_dp.async_set_value(
-                    self._device,
-                    json.dumps({"control": CMD_LEARN}),
-                )
-
-        persistent_notification.async_create(
-            self._device._hass,
-            f"Press the '{command}' button.",
-            title="Learn command",
-            notification_id="learn_command",
-        )
-        try:
-            start_time = dt_util.utcnow()
-            while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT:
-                await asyncio.sleep(1)
-                code = self._receive_dp.get_value(self._device)
-                if code is not None:
-                    _LOGGER.info(
-                        "%s received code for %s: %s",
-                        self._config.config_id,
-                        command,
-                        code,
-                    )
-                    self._device.anticipate_property_value(self._receive_dp.id, None)
-                    return "rf:" + code if is_rf else code
-            _LOGGER.warning("Timed out without receiving code in %s", service)
-            raise TimeoutError(
-                f"No remote code received within {LEARNING_TIMEOUT.total_seconds()} seconds",
-            )
-
-        finally:
-            persistent_notification.async_dismiss(
-                self._device._hass, notification_id="learn_command"
-            )
-            _LOGGER.debug("%s ending learning mode", self._config.config_id)
-            async with self._device.set_lock:
-                if self._control_dp:
-                    await self._control_dp.async_set_value(
-                        self._device,
-                        CMD_ENDLEARN,
-                    )
-                elif is_rf:
-                    await self._send_dp.async_set_value(self._device, cmd_end)
-                else:
-                    await self._send_dp.async_set_value(
-                        self._device,
-                        json.dumps({"control": CMD_ENDLEARN}),
-                    )
-
-    async def async_delete_command(self, **kwargs: Any) -> None:
-        """Delete a list of commands from a remote."""
-        kwargs = SERVICE_DELETE_SCHEMA(kwargs)
-        commands = kwargs[ATTR_COMMAND]
-        subdevice = kwargs[ATTR_DEVICE]
-        service = f"{RM_DOMAIN}.{SERVICE_DELETE_COMMAND}"
-
-        if not self._storage_loaded:
-            await self._async_load_storage()
-
-        try:
-            codes = self._codes[subdevice]
-        except KeyError as err:
-            err_msg = f"Device not found {repr(subdevice)}"
-            _LOGGER.error("Failed to call %s. %s", service, err_msg)
-            raise ValueError(err_msg) from err
-
-        cmds_not_found = []
-        for command in commands:
-            try:
-                del codes[command]
-                _LOGGER.info(
-                    "%s deleted command %s for %s",
-                    self._config.config_id,
-                    command,
-                    subdevice or "default device",
-                )

-            except KeyError:
-                cmds_not_found.append(command)
-
-        if cmds_not_found:
-            if len(cmds_not_found) == 1:
-                err_msg = f"Command not found: {repr(cmds_not_found[0])}"
-            else:
-                err_msg = f"Commands not found: {repr(cmds_not_found)}"
-
-            if len(cmds_not_found) == len(commands):
-                _LOGGER.error("Failed to call %s. %s", service, err_msg)
-                raise ValueError(err_msg)
-
-            _LOGGER.error("Error during %s. %s", service, err_msg)
-
-        # Clean up
-        if not codes:
-            _LOGGER.info(
-                "%s removing unused device %s", self._config.config_id, subdevice
-            )
-            del self._codes[subdevice]
-            if self._flags.pop(subdevice, None) is not None:
-                self._flag_storage.async_delay_save(
-                    lambda: self._flags, FLAG_SAVE_DELAY
-                )
-        self._code_storage.async_delay_save(lambda: self._codes, CODE_SAVE_DELAY)
+"""
+Implementation of Tuya remote control devices
+Based on broadlink integration for code saving under HA storage
+"""
+
+import asyncio
+import json
+import logging
+from collections import defaultdict
+from collections.abc import Iterable
+from datetime import timedelta
+from itertools import product
+from typing import Any
+
+import voluptuous as vol
+from homeassistant.components import persistent_notification
+from homeassistant.components.remote import (
+    ATTR_ALTERNATIVE,
+    ATTR_COMMAND_TYPE,
+    ATTR_DELAY_SECS,
+    ATTR_DEVICE,
+    ATTR_NUM_REPEATS,
+    DEFAULT_DELAY_SECS,
+    SERVICE_DELETE_COMMAND,
+    SERVICE_LEARN_COMMAND,
+    SERVICE_SEND_COMMAND,
+    RemoteEntity,
+    RemoteEntityFeature,
+)
+from homeassistant.components.remote import DOMAIN as RM_DOMAIN
+from homeassistant.const import ATTR_COMMAND
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.storage import Store
+from homeassistant.util import dt as dt_util
+
+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__)
+
+CODE_STORAGE_VERSION = 1
+FLAG_STORAGE_VERSION = 1
+
+CODE_SAVE_DELAY = 15
+FLAG_SAVE_DELAY = 15
+
+LEARNING_TIMEOUT = timedelta(seconds=30)
+
+# These commands seem to be standard for all devices
+CMD_SEND = "send_ir"
+CMD_SEND_RF = "rfstudy_send"
+CMD_LEARN = "study"
+CMD_ENDLEARN = "study_exit"
+CMD_STUDYKEY = "study_key"
+CMD_STUDYRF = "rf_study"
+CMD_ENDSTUDYRF = "rfstudy_exit"
+
+COMMAND_SCHEMA = vol.Schema(
+    {
+        vol.Required(ATTR_COMMAND): vol.All(
+            cv.ensure_list, [vol.All(cv.string, vol.Length(min=1))], vol.Length(min=1)
+        ),
+    },
+    extra=vol.ALLOW_EXTRA,
+)
+
+SERVICE_SEND_SCHEMA = COMMAND_SCHEMA.extend(
+    {
+        vol.Optional(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1)),
+        vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): vol.Coerce(float),
+    }
+)
+SERVICE_LEARN_SCHEMA = COMMAND_SCHEMA.extend(
+    {
+        vol.Required(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1)),
+        vol.Optional(ATTR_ALTERNATIVE, default=False): cv.boolean,
+    }
+)
+SERVICE_DELETE_SCHEMA = COMMAND_SCHEMA.extend(
+    {
+        vol.Required(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1)),
+    }
+)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    config = {**config_entry.data, **config_entry.options}
+    await async_tuya_setup_platform(
+        hass,
+        async_add_entities,
+        config,
+        "remote",
+        TuyaLocalRemote,
+    )
+
+
+class TuyaLocalRemote(TuyaLocalEntity, RemoteEntity):
+    """Representation of a Tuya Remote entity."""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the remote device.
+        Args:
+           device (TuyaLocalDevice): The device API instance.
+           config (TuyaEntityConfig): The entity config.
+        """
+        super().__init__()
+        dps_map = self._init_begin(device, config)
+        self._send_dp = dps_map.pop("send", None)
+        self._receive_dp = dps_map.pop("receive", None)
+        # Some remotes split out the control (command) into its own dp and just send raw codes in send
+        self._control_dp = dps_map.pop("control", None)
+        self._delay_dp = dps_map.pop("delay", None)
+        self._type_dp = dps_map.pop("code_type", None)
+        self._init_end(dps_map)
+        if self._receive_dp:
+            self._attr_supported_features |= (
+                RemoteEntityFeature.LEARN_COMMAND | RemoteEntityFeature.DELETE_COMMAND
+            )
+        self._code_storage = Store(
+            device._hass,
+            CODE_STORAGE_VERSION,
+            f"tuya_local_remote_{device.unique_id}_codes",
+        )
+        self._flag_storage = Store(
+            device._hass,
+            FLAG_STORAGE_VERSION,
+            f"tuya_local_remote_{device.unique_id}_flags",
+        )
+        self._storage_loaded = False
+        self._codes = {}
+        self._flags = defaultdict(int)
+        self._lock = asyncio.Lock()
+        self._attr_is_on = True
+
+    async def _async_load_storage(self):
+        """Load stored codes and flags from disk."""
+        self._codes.update(await self._code_storage.async_load() or {})
+        self._flags.update(await self._flag_storage.async_load() or {})
+        self._storage_loaded = True
+
+    def _extract_codes(self, commands, subdevice=None):
+        """Extract a list of remote codes.
+        If the command starts with 'b64:', extract the IR code from it.
+        If the command starts with 'rf:', keep it as-is so that
+        _encode_send_code can apply the correct RF payload format.
+        Otherwise use the command and optionally subdevice as keys to extract the
+        actual command from storage.
+
+        The commands are returned in sublists. For toggle commands, the sublist
+        may contain two codes that must be sent alternately with each call."""
+        code_list = []
+        for cmd in commands:
+            if cmd.startswith("b64:"):
+                codes = [cmd[4:]]
+            elif cmd.startswith("rf:"):
+                codes = [cmd]
+            else:
+                if subdevice is None:
+                    raise ValueError("device must be specified")
+                try:
+                    codes = self._codes[subdevice][cmd]
+                except KeyError as err:
+                    raise ValueError(
+                        f"Command {repr(cmd)} not found for {subdevice}"
+                    ) from err
+                if isinstance(codes, list):
+                    codes = codes[:]
+                else:
+                    codes = [codes]
+
+            for idx, code in enumerate(codes):
+                try:
+                    codes[idx] = code
+                except ValueError as err:
+                    raise ValueError(f"Invalid code: {repr(code)}") from err
+
+            code_list.append(codes)
+        return code_list
+
+    def _encode_send_code(self, code, delay, is_rf=False):
+        """Encode a remote command into dps values to send.
+
+        Set is_rf=True to use the RF sub-GHz payload format.
+        The default (is_rf=False) uses the IR payload format.
+
+        Based on https://github.com/jasonacox/tinytuya/issues/74 and
+        the docs it references, there are two kinds of IR devices.
+        1. separate dps for control, code, study,...
+        2. single dp (201) for send_ir, which takes JSON input,
+           including control, code, delay, etc, and another for
+           study_ir (202) that receives the codes in study mode.
+        RF devices also use a single dp (201) but with a different
+        JSON payload using control 'rfstudy_send'.
+        """
+        dps = {}
+        if self._control_dp:
+            # control and code are sent in separate dps.
+            dps = dps | self._control_dp.get_values_to_set(self._device, CMD_SEND, dps)
+            dps = dps | self._send_dp.get_values_to_set(self._device, code, dps)
+            if self._delay_dp:
+                dps = dps | self._delay_dp.get_values_to_set(self._device, delay, dps)
+            if self._type_dp:
+                dps = dps | self._type_dp.get_values_to_set(self._device, 0, dps)
+        elif is_rf:
+            dps = dps | self._send_dp.get_values_to_set(
+                self._device,
+                json.dumps(
+                    {
+                        "control": CMD_SEND_RF,
+                        "rf_type": "sub_2g",
+                        "mode": 0,
+                        "key1": {
+                            "times": 6,
+                            "intervals": 0,
+                            "ver": "2",
+                            "delay": 0,
+                            "code": code,
+                        },
+                        "feq": 0,
+                        "rate": 0,
+                        "ver": "2",
+                    },
+                ),
+                dps,
+            )
+        else:
+            dps = dps | self._send_dp.get_values_to_set(
+                self._device,
+                json.dumps(
+                    {
+                        "control": CMD_SEND,
+                        "head": "",
+                        # leading zero means use head, any other leading character is discarded.
+                        "key1": "1" + code,
+                        "type": 0,
+                        "delay": int(delay),
+                    },
+                ),
+                dps,
+            )
+
+        return dps
+
+    async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
+        """Send remote commands"""
+        kwargs[ATTR_COMMAND] = command
+        kwargs = SERVICE_SEND_SCHEMA(kwargs)
+        subdevice = kwargs.get(ATTR_DEVICE)
+        repeat = kwargs.get(ATTR_NUM_REPEATS)
+        delay = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS)
+        service = f"{RM_DOMAIN}.{SERVICE_SEND_COMMAND}"
+        if not self._storage_loaded:
+            await self._async_load_storage()
+
+        try:
+            code_list = self._extract_codes(command, subdevice)
+        except ValueError as err:
+            _LOGGER.error("Failed to call %s: %s", service, err)
+            raise
+
+        at_least_one_sent = False
+        for _, codes in product(range(repeat), code_list):
+            if at_least_one_sent:
+                await asyncio.sleep(delay)
+
+            if len(codes) > 1:
+                code = codes[self._flags[subdevice]]
+            else:
+                code = codes[0]
+
+            async with self._device.set_lock:
+                # Tuya delay is in milliseconds
+                if code.startswith("rf:"):
+                    dps_to_set = self._encode_send_code(
+                        code[3:], delay * 1000, is_rf=True
+                    )
+                else:
+                    dps_to_set = self._encode_send_code(code, delay * 1000)
+                    _LOGGER.info(
+                        "%s sending command %s to %s",
+                        self._config.config_id,
+                        code,
+                        subdevice or "default device",
+                    )
+                    await self._device.async_set_properties(dps_to_set)
+
+            if len(codes) > 1:
+                self._flags[subdevice] ^= 1
+            at_least_one_sent = True
+
+        if at_least_one_sent:
+            self._flag_storage.async_delay_save(lambda: self._flags, FLAG_SAVE_DELAY)
+
+    async def async_learn_command(self, **kwargs: Any) -> None:
+        """Learn a list of commands from a remote."""
+        kwargs = SERVICE_LEARN_SCHEMA(kwargs)
+        commands = kwargs[ATTR_COMMAND]
+        subdevice = kwargs[ATTR_DEVICE]
+        toggle = kwargs[ATTR_ALTERNATIVE]
+        is_rf = kwargs.get(ATTR_COMMAND_TYPE) == "rf"
+
+        if not self._storage_loaded:
+            await self._async_load_storage()
+
+        async with self._lock:
+            should_store = False
+
+            for command in commands:
+                code = await self._async_learn_command(command, is_rf=is_rf)
+                _LOGGER.info("Learning %s for %s: %s", command, subdevice, code)
+                if toggle:
+                    code = [code, await self._async_learn_command(command, is_rf=is_rf)]
+                self._codes.setdefault(subdevice, {}).update({command: code})
+                should_store = True
+
+            if should_store:
+                await self._code_storage.async_save(self._codes)
+
+    async def _async_learn_command(self, command, is_rf=False):
+        """Learn a single command"""
+        service = f"{RM_DOMAIN}.{SERVICE_LEARN_COMMAND}"
+        if is_rf:
+            cmd_start = json.dumps(
+                {
+                    "control": CMD_STUDYRF,
+                    "rf_type": "sub_2g",
+                    "study_feq": "0",
+                    "ver": "2",
+                }
+            )
+            cmd_end = json.dumps(
+                {
+                    "control": CMD_ENDSTUDYRF,
+                    "rf_type": "sub_2g",
+                    "study_feq": "0",
+                    "ver": "2",
+                }
+            )
+        async with self._device.set_lock:
+            if self._control_dp:
+                _LOGGER.debug(
+                    "%s starting learning %s using multi dps method",
+                    self._config.config_id,
+                    command,
+                )
+                await self._control_dp.async_set_value(self._device, CMD_LEARN)
+            elif is_rf:
+                _LOGGER.debug(
+                    "%s starting learning %s using RF",
+                    self._config.config_id,
+                    command,
+                )
+                await self._send_dp.async_set_value(self._device, cmd_start)
+            else:
+                _LOGGER.debug(
+                    "%s starting learning %s using IR",
+                    self._config.config_id,
+                    command,
+                )
+                await self._send_dp.async_set_value(
+                    self._device,
+                    json.dumps({"control": CMD_LEARN}),
+                )
+
+        persistent_notification.async_create(
+            self._device._hass,
+            f"Press the '{command}' button.",
+            title="Learn command",
+            notification_id="learn_command",
+        )
+        try:
+            start_time = dt_util.utcnow()
+            while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT:
+                await asyncio.sleep(1)
+                code = self._receive_dp.get_value(self._device)
+                if code is not None:
+                    _LOGGER.info(
+                        "%s received code for %s: %s",
+                        self._config.config_id,
+                        command,
+                        code,
+                    )
+                    self._device.anticipate_property_value(self._receive_dp.id, None)
+                    return "rf:" + code if is_rf else code
+            _LOGGER.warning("Timed out without receiving code in %s", service)
+            raise TimeoutError(
+                f"No remote code received within {LEARNING_TIMEOUT.total_seconds()} seconds",
+            )
+
+        finally:
+            persistent_notification.async_dismiss(
+                self._device._hass, notification_id="learn_command"
+            )
+            _LOGGER.debug("%s ending learning mode", self._config.config_id)
+            async with self._device.set_lock:
+                if self._control_dp:
+                    await self._control_dp.async_set_value(
+                        self._device,
+                        CMD_ENDLEARN,
+                    )
+                elif is_rf:
+                    await self._send_dp.async_set_value(self._device, cmd_end)
+                else:
+                    await self._send_dp.async_set_value(
+                        self._device,
+                        json.dumps({"control": CMD_ENDLEARN}),
+                    )
+
+    async def async_delete_command(self, **kwargs: Any) -> None:
+        """Delete a list of commands from a remote."""
+        kwargs = SERVICE_DELETE_SCHEMA(kwargs)
+        commands = kwargs[ATTR_COMMAND]
+        subdevice = kwargs[ATTR_DEVICE]
+        service = f"{RM_DOMAIN}.{SERVICE_DELETE_COMMAND}"
+
+        if not self._storage_loaded:
+            await self._async_load_storage()
+
+        try:
+            codes = self._codes[subdevice]
+        except KeyError as err:
+            err_msg = f"Device not found {repr(subdevice)}"
+            _LOGGER.error("Failed to call %s. %s", service, err_msg)
+            raise ValueError(err_msg) from err
+
+        cmds_not_found = []
+        for command in commands:
+            try:
+                del codes[command]
+                _LOGGER.info(
+                    "%s deleted command %s for %s",
+                    self._config.config_id,
+                    command,
+                    subdevice or "default device",
+                )
+            except KeyError:
+                cmds_not_found.append(command)
+
+        if cmds_not_found:
+            if len(cmds_not_found) == 1:
+                err_msg = f"Command not found: {repr(cmds_not_found[0])}"
+            else:
+                err_msg = f"Commands not found: {repr(cmds_not_found)}"
+
+            if len(cmds_not_found) == len(commands):
+                _LOGGER.error("Failed to call %s. %s", service, err_msg)
+                raise ValueError(err_msg)
+
+            _LOGGER.error("Error during %s. %s", service, err_msg)
+
+        # Clean up
+        if not codes:
+            _LOGGER.info(
+                "%s removing unused device %s", self._config.config_id, subdevice
+            )
+            del self._codes[subdevice]
+            if self._flags.pop(subdevice, None) is not None:
+                self._flag_storage.async_delay_save(
+                    lambda: self._flags, FLAG_SAVE_DELAY
+                )
+        self._code_storage.async_delay_save(lambda: self._codes, CODE_SAVE_DELAY)