Просмотр исходного кода

feat (remote): add RF sub-GHz support and fix IR/RF blaster connectivity (#4578)

* feat (remote): add RF sub-GHz support and fix IR/RF blaster connectivity

* fix linting errors

* remove IR/RF device from wrong config file

* clear cache after learning

* fix linting error

* remove scenes from S16Pro remote

* remove error 900 treatment from _refresh_cached_state

* Remove wrong timestamp for updated_at

---------

Co-authored-by: Jason Rumney <make-all@users.noreply.github.com>
kongo09 1 день назад
Родитель
Сommit
5864a2e7c2

+ 36 - 4
README.md

@@ -64,10 +64,18 @@ or [Zigbee2MQTT](https://www.zigbee2mqtt.io/guide/adapters/).
 Some Tuya Bluetooth devices can be supported directly by the
 [tuya_ble](https://github.com/PlusPlus-ua/ha_tuya_ble/) integration.
 
+Tuya IR/RF blasters are supported through the remote entity platform.
+They can learn and store IR and RF commands, and replay them via the
+`remote.learn_command` and `remote.send_command` services.
+
 Tuya IR hubs that expose general IR remotes as sub devices usually
-expose them as cloud only devices, or sometimes read-only local
-devices with an expectation that the app will invoke commands via
-remote commands.
+expose them as one way devices (send only).  Due to the way this
+integration does device detection based on the dps returned by the
+device, it is not currently able to detect such devices at all.  Some
+specialised IR hubs for air conditioner remote controls do work, as
+they try to emulate a fully smart air conditioner using internal memory
+of what settings are currently set, and internal temperature and humidity
+sensors.
 
 Some Tuya hubs now support Matter over WiFi, and this can be used as an
 alternative to this integration for connecting the hub and sub-devices
@@ -255,11 +263,35 @@ Although this is documented in the BLE lock documentation from Tuya, Zigbee
 and WiFi locks often use the same naming for datapoints, which may be
 compatible with this scheme.
 
+## Using IR/RF blasters
+
+Tuya IR and RF blasters are exposed as remote entities and support learning and
+sending commands via the standard Home Assistant remote services.
+
+### Learning commands
+
+Use the `remote.learn_command` service with:
+- `command`: the name to store the command under (e.g. `power`)
+- `device`: a name for the appliance being controlled (e.g. `TV`)
+- `command_type`: set to `rf` for RF remotes, omit or leave blank for IR
+
+The integration will put the blaster into learning mode and wait up to 30 seconds
+for you to press a button on the original remote. The learned code is stored
+persistently and survives restarts.
+
+### Sending commands
+
+Use the `remote.send_command` service with the same `command` and `device` values
+used when learning. You can also send codes directly without learning first:
+
+- **IR inline code**: prefix with `b64:` followed by the base64-encoded IR code
+- **RF inline code**: prefix with `rf:` followed by the base64-encoded RF code
+
 ## Contributing
 
 Beyond contributing device configs, here are some areas that could benefit from more hands:
 
 1. Unit tests. This integration is mostly unit-tested thanks to the upstream project, but there are a few more to complete. Focus on unit tests is on python code, the current coverage is summarised in reports on github, but to get full coverage details you can run the tests yourself.
-2. Once unit tests are complete, the next task is to properly evaluate against the Home Assistant quality scale. 
+2. Once unit tests are complete, the next task is to properly evaluate against the Home Assistant quality scale.
 3. Discovery. Local discovery is currently limited to finding the IP address in the cloud assisted config. Performing discovery in background would allow notifications to be raised when new devices are noticed on the network, and would provide a productKey for the manual config method to use when matching device configs.
 

+ 25 - 17
custom_components/tuya_local/device.py

@@ -504,22 +504,17 @@ class TuyaLocalDevice(object):
 
     def _refresh_cached_state(self):
         new_state = self._api.status()
-        if new_state and "Err" not in new_state:
-            self._cached_state = self._cached_state | new_state.get("dps", {})
-            self._cached_state["updated_at"] = time()
-            for entity in self._children:
-                for dp in entity._config.dps():
-                    # Clear non-persistant dps that were not in the poll
-                    if not dp.persist and dp.id not in new_state.get("dps", {}):
-                        self._cached_state.pop(dp.id, None)
-                entity.schedule_update_ha_state()
-        _LOGGER.debug(
-            "%s refreshed device state: %s",
-            self.name,
-            log_json(new_state),
-        )
-        if "Err" in new_state:
-            if self._api_working_protocol_failures == 1:
+        if new_state:
+            if "Err" not in new_state:
+                self._cached_state = self._cached_state | new_state.get("dps", {})
+                self._cached_state["updated_at"] = time()
+                for entity in self._children:
+                    for dp in entity._config.dps():
+                        # Clear non-persistant dps that were not in the poll
+                        if not dp.persist and dp.id not in new_state.get("dps", {}):
+                            self._cached_state.pop(dp.id, None)
+                    entity.schedule_update_ha_state()
+            elif self._api_working_protocol_failures == 1:
                 _LOGGER.warning(
                     "%s protocol error %s: %s",
                     self.name,
@@ -533,6 +528,11 @@ class TuyaLocalDevice(object):
                     new_state.get("Err"),
                     new_state.get("Error", "message not provided"),
                 )
+        _LOGGER.debug(
+            "%s refreshed device state: %s",
+            self.name,
+            log_json(new_state),
+        )
         _LOGGER.debug(
             "new state (incl pending): %s",
             log_json(self._get_cached_state()),
@@ -629,7 +629,15 @@ class TuyaLocalDevice(object):
                 if not self._hass.is_stopping:
                     retval = await self._hass.async_add_executor_job(func)
                     if isinstance(retval, dict) and "Error" in retval:
-                        raise AttributeError(retval["Error"])
+                        if retval.get("Err") == "900":
+                            # Some devices (e.g. IR/RF remotes) never return
+                            # status data; error 900 is their normal response
+                            # to a status query. Treat as reachable with no
+                            # data so commands can still be sent.
+                            self._cached_state["updated_at"] = time()
+                            retval = None
+                        else:
+                            raise AttributeError(retval["Error"])
                     self._api_protocol_working = True
                     self._api_working_protocol_failures = 0
                     return retval

+ 2 - 1
custom_components/tuya_local/devices/basic_ir_remote.yaml

@@ -11,7 +11,8 @@ products:
     model: S16
   - id: x0lyfgjuguuh1vof
     manufacturer: Avatto
-    model: S16Pro  # also includes rf, but that is not supported yet
+    name: Smart IR+RF Remote Control
+    model: S16Pro
 entities:
   - entity: remote
     dps:

+ 84 - 19
custom_components/tuya_local/remote.py

@@ -16,6 +16,7 @@ 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,
@@ -56,9 +57,12 @@ 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(
     {
@@ -146,7 +150,9 @@ class TuyaLocalRemote(TuyaLocalEntity, RemoteEntity):
 
     def _extract_codes(self, commands, subdevice=None):
         """Extract a list of remote codes.
-        If the command starts with 'b64:', extract the code from it.
+        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.
 
@@ -156,6 +162,8 @@ class TuyaLocalRemote(TuyaLocalEntity, RemoteEntity):
         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")
@@ -179,23 +187,52 @@ class TuyaLocalRemote(TuyaLocalEntity, RemoteEntity):
             code_list.append(codes)
         return code_list
 
-    def _encode_send_code(self, code, delay):
-        """Encode a remote command into dps values to send."""
-        # 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.
+    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 seperate dps.
+            # 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,
@@ -203,7 +240,7 @@ class TuyaLocalRemote(TuyaLocalEntity, RemoteEntity):
                     {
                         "control": CMD_SEND,
                         "head": "",
-                        # leading zero means use head, any other leeading character is discarded.
+                        # leading zero means use head, any other leading character is discarded.
                         "key1": "1" + code,
                         "type": 0,
                         "delay": int(delay),
@@ -241,7 +278,10 @@ class TuyaLocalRemote(TuyaLocalEntity, RemoteEntity):
             else:
                 code = codes[0]
 
-            dps_to_set = self._encode_send_code(code, delay)
+            if code.startswith("rf:"):
+                dps_to_set = self._encode_send_code(code[3:], delay, is_rf=True)
+            else:
+                dps_to_set = self._encode_send_code(code, delay)
             await self._device.async_set_properties(dps_to_set)
 
             if len(codes) > 1:
@@ -249,7 +289,7 @@ class TuyaLocalRemote(TuyaLocalEntity, RemoteEntity):
             at_least_one_sent = True
 
         if at_least_one_sent:
-            self._flag_storage.async_delay_save(self._flags, FLAG_SAVE_DELAY)
+            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."""
@@ -257,6 +297,7 @@ class TuyaLocalRemote(TuyaLocalEntity, RemoteEntity):
         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()
@@ -265,24 +306,43 @@ class TuyaLocalRemote(TuyaLocalEntity, RemoteEntity):
             should_store = False
 
             for command in commands:
-                code = await self._async_learn_command(command)
+                code = await self._async_learn_command(command, is_rf=is_rf)
                 _LOGGER.info("Learning %s for %s: %s", command, subdevice, code)
                 # pulses = base64_to_pulses(code)
                 # _LOGGER.debug("= pronto code: %s", pulses_to_pronto(pulses))
                 # _LOGGER.debug("= width encoded: %s", pulses_to_width_encoded(pulses))
                 if toggle:
-                    code = [code, await self._async_learn_command(command)]
+                    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):
+    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",
+                }
+            )
         if self._control_dp:
             await self._control_dp.async_set_value(self._device, CMD_LEARN)
+        elif is_rf:
+            await self._send_dp.async_set_value(self._device, cmd_start)
         else:
             await self._send_dp.async_set_value(
                 self._device,
@@ -301,7 +361,8 @@ class TuyaLocalRemote(TuyaLocalEntity, RemoteEntity):
                 await asyncio.sleep(1)
                 code = self._receive_dp.get_value(self._device)
                 if code is not None:
-                    return 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",
@@ -316,6 +377,8 @@ class TuyaLocalRemote(TuyaLocalEntity, RemoteEntity):
                     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,
@@ -362,5 +425,7 @@ class TuyaLocalRemote(TuyaLocalEntity, RemoteEntity):
         if not codes:
             del self._codes[subdevice]
             if self._flags.pop(subdevice, None) is not None:
-                self._flag_storage.async_delay_save(self._flags, FLAG_SAVE_DELAY)
-        self._code_storage.async_delay_save(self._codes, CODE_SAVE_DELAY)
+                self._flag_storage.async_delay_save(
+                    lambda: self._flags, FLAG_SAVE_DELAY
+                )
+        self._code_storage.async_delay_save(lambda: self._codes, CODE_SAVE_DELAY)