Răsfoiți Sursa

fix: add locking similar to PR #4986 to other entity types

Potentially there are race conditions when using scenes to set
multiple entities of any type from the same device, given a complex
enough config.
Jason Rumney 2 zile în urmă
părinte
comite
403c83023d

+ 9 - 7
custom_components/tuya_local/alarm_control_panel.py

@@ -77,11 +77,12 @@ class TuyaLocalAlarmControlPanel(TuyaLocalEntity, AlarmControlPanelEntity):
         )
         )
 
 
     async def _alarm_send_command(self, cmd: str):
     async def _alarm_send_command(self, cmd: str):
-        _LOGGER.info("%s setting alarm state to %s", self._config.config_id, cmd)
-        if cmd in self._alarm_state_dp.values(self._device):
-            await self._alarm_state_dp.async_set_value(self._device, cmd)
-        else:
-            raise NotImplementedError()
+        async with self._device.set_lock:
+            _LOGGER.info("%s setting alarm state to %s", self._config.config_id, cmd)
+            if cmd in self._alarm_state_dp.values(self._device):
+                await self._alarm_state_dp.async_set_value(self._device, cmd)
+            else:
+                raise NotImplementedError()
 
 
     async def async_alarm_disarm(self, code=None):
     async def async_alarm_disarm(self, code=None):
         """Send disarm command"""
         """Send disarm command"""
@@ -107,7 +108,8 @@ class TuyaLocalAlarmControlPanel(TuyaLocalEntity, AlarmControlPanelEntity):
 
 
     async def async_alarm_trigger(self, code=None):
     async def async_alarm_trigger(self, code=None):
         if self._trigger_dp:
         if self._trigger_dp:
-            _LOGGER.info("%s triggering alarm", self._config.config_id)
-            await self._trigger_dp.async_set_value(self._device, True)
+            async with self._device.set_lock:
+                _LOGGER.info("%s triggering alarm", self._config.config_id)
+                await self._trigger_dp.async_set_value(self._device, True)
         else:
         else:
             await self._alarm_send_command(AlarmControlPanelState.TRIGGERED)
             await self._alarm_send_command(AlarmControlPanelState.TRIGGERED)

+ 3 - 2
custom_components/tuya_local/button.py

@@ -57,5 +57,6 @@ class TuyaLocalButton(TuyaLocalEntity, ButtonEntity):
 
 
     async def async_press(self):
     async def async_press(self):
         """Press the button"""
         """Press the button"""
-        _LOGGER.info("%s pressing button", self._config._device.config)
-        await self._button_dp.async_set_value(self._device, True)
+        async with self._device.set_lock:
+            _LOGGER.info("%s pressing button", self._config._device.config)
+            await self._button_dp.async_set_value(self._device, True)

+ 15 - 10
custom_components/tuya_local/camera.py

@@ -61,8 +61,9 @@ class TuyaLocalCamera(TuyaLocalEntity, CameraEntity):
 
 
     async def async_camera_image(self, width=None, height=None):
     async def async_camera_image(self, width=None, height=None):
         if self._snapshot_dp:
         if self._snapshot_dp:
-            _LOGGER.info("%s fetching snapshot", self._config.config_id)
-            return self._snapshot_dp.decoded_value(self._device)
+            async with self._device.set_lock:
+                _LOGGER.info("%s fetching snapshot", self._config.config_id)
+                return self._snapshot_dp.decoded_value(self._device)
 
 
     @property
     @property
     def is_on(self):
     def is_on(self):
@@ -74,26 +75,30 @@ class TuyaLocalCamera(TuyaLocalEntity, CameraEntity):
         """Turn off the camera"""
         """Turn off the camera"""
         if not self._switch_dp:
         if not self._switch_dp:
             raise NotImplementedError()
             raise NotImplementedError()
-        _LOGGER.info("%s turning off camera", self._config.config_id)
-        await self._switch_dp.async_set_value(self._device, False)
+        async with self._device.set_lock:
+            _LOGGER.info("%s turning off camera", self._config.config_id)
+            await self._switch_dp.async_set_value(self._device, False)
 
 
     async def async_turn_on(self):
     async def async_turn_on(self):
         """Turn on the camera"""
         """Turn on the camera"""
         if not self._switch_dp:
         if not self._switch_dp:
             raise NotImplementedError()
             raise NotImplementedError()
-        _LOGGER.info("%s turning on camera", self._config.config_id)
-        await self._switch_dp.async_set_value(self._device, True)
+        async with self._device.set_lock:
+            _LOGGER.info("%s turning on camera", self._config.config_id)
+            await self._switch_dp.async_set_value(self._device, True)
 
 
     async def async_enable_motion_detection(self):
     async def async_enable_motion_detection(self):
         """Enable motion detection on the camera"""
         """Enable motion detection on the camera"""
         if not self._motion_enable_dp:
         if not self._motion_enable_dp:
             raise NotImplementedError()
             raise NotImplementedError()
-        _LOGGER.info("%s enabling motion detection", self._config.config_id)
-        await self._motion_enable_dp.async_set_value(self._device, True)
+        async with self._device.set_lock:
+            _LOGGER.info("%s enabling motion detection", self._config.config_id)
+            await self._motion_enable_dp.async_set_value(self._device, True)
 
 
     async def async_disable_motion_detection(self):
     async def async_disable_motion_detection(self):
         """Disable motion detection on the camera"""
         """Disable motion detection on the camera"""
         if not self._motion_enable_dp:
         if not self._motion_enable_dp:
             raise NotImplementedError()
             raise NotImplementedError()
-        _LOGGER.info("%s disabling motion detection", self._config.config_id)
-        await self._motion_enable_dp.async_set_value(self._device, False)
+        async with self._device.set_lock:
+            _LOGGER.info("%s disabling motion detection", self._config.config_id)
+            await self._motion_enable_dp.async_set_value(self._device, False)

+ 98 - 87
custom_components/tuya_local/climate.py

@@ -244,55 +244,58 @@ class TuyaLocalClimate(TuyaLocalEntity, ClimateEntity):
 
 
     async def async_set_temperature(self, **kwargs):
     async def async_set_temperature(self, **kwargs):
         """Set new target temperature."""
         """Set new target temperature."""
-        if kwargs.get(ATTR_PRESET_MODE) is not None:
-            _LOGGER.info(
-                "%s setting temperature: setting preset mode to %s",
-                self._config.config_id,
-                kwargs.get(ATTR_PRESET_MODE),
-            )
-            await self.async_set_preset_mode(kwargs.get(ATTR_PRESET_MODE))
-        if kwargs.get(ATTR_TEMPERATURE) is not None:
-            _LOGGER.info(
-                "%s setting temperature to %s",
-                self._config.config_id,
-                kwargs.get(ATTR_TEMPERATURE),
-            )
-            await self.async_set_target_temperature(
-                kwargs.get(ATTR_TEMPERATURE),
-            )
-        high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
-        low = kwargs.get(ATTR_TARGET_TEMP_LOW)
-        if high is not None or low is not None:
-            _LOGGER.info(
-                "%s setting temperature range to %s - %s",
-                self._config.config_id,
-                low,
-                high,
-            )
-            await self.async_set_target_temperature_range(low, high)
+        async with self._device.set_lock:
+            if kwargs.get(ATTR_PRESET_MODE) is not None:
+                _LOGGER.info(
+                    "%s setting temperature: setting preset mode to %s",
+                    self._config.config_id,
+                    kwargs.get(ATTR_PRESET_MODE),
+                )
+                await self.async_set_preset_mode(kwargs.get(ATTR_PRESET_MODE))
+            if kwargs.get(ATTR_TEMPERATURE) is not None:
+                _LOGGER.info(
+                    "%s setting temperature to %s",
+                    self._config.config_id,
+                    kwargs.get(ATTR_TEMPERATURE),
+                )
+                await self.async_set_target_temperature(
+                    kwargs.get(ATTR_TEMPERATURE),
+                )
+            high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
+            low = kwargs.get(ATTR_TARGET_TEMP_LOW)
+            if high is not None or low is not None:
+                _LOGGER.info(
+                    "%s setting temperature range to %s - %s",
+                    self._config.config_id,
+                    low,
+                    high,
+                )
+                await self.async_set_target_temperature_range(low, high)
 
 
     async def async_set_target_temperature(self, target_temperature):
     async def async_set_target_temperature(self, target_temperature):
         if self._temperature_dps is None:
         if self._temperature_dps is None:
             raise NotImplementedError()
             raise NotImplementedError()
 
 
-        await self._temperature_dps.async_set_value(
-            self._device,
-            target_temperature,
-        )
+        async with self._device.set_lock:
+            await self._temperature_dps.async_set_value(
+                self._device,
+                target_temperature,
+            )
 
 
     async def async_set_target_temperature_range(self, low, high):
     async def async_set_target_temperature_range(self, low, high):
         """Set the target temperature range."""
         """Set the target temperature range."""
-        dps_map = {}
-        if low is not None and self._temp_low_dps is not None:
-            dps_map.update(
-                self._temp_low_dps.get_values_to_set(self._device, low, dps_map),
-            )
-        if high is not None and self._temp_high_dps is not None:
-            dps_map.update(
-                self._temp_high_dps.get_values_to_set(self._device, high, dps_map),
-            )
-        if dps_map:
-            await self._device.async_set_properties(dps_map)
+        async with self._device.set_lock:
+            dps_map = {}
+            if low is not None and self._temp_low_dps is not None:
+                dps_map.update(
+                    self._temp_low_dps.get_values_to_set(self._device, low, dps_map),
+                )
+            if high is not None and self._temp_high_dps is not None:
+                dps_map.update(
+                    self._temp_high_dps.get_values_to_set(self._device, high, dps_map),
+                )
+            if dps_map:
+                await self._device.async_set_properties(dps_map)
 
 
     @property
     @property
     def current_temperature(self):
     def current_temperature(self):
@@ -333,12 +336,13 @@ class TuyaLocalClimate(TuyaLocalEntity, ClimateEntity):
         if self._humidity_dps is None:
         if self._humidity_dps is None:
             raise NotImplementedError()
             raise NotImplementedError()
 
 
-        _LOGGER.info(
-            "%s setting humidity to %s",
-            self._config.config_id,
-            humidity,
-        )
-        await self._humidity_dps.async_set_value(self._device, humidity)
+        async with self._device.set_lock:
+            _LOGGER.info(
+                "%s setting humidity to %s",
+                self._config.config_id,
+                humidity,
+            )
+            await self._humidity_dps.async_set_value(self._device, humidity)
 
 
     @property
     @property
     def current_humidity(self):
     def current_humidity(self):
@@ -393,20 +397,22 @@ class TuyaLocalClimate(TuyaLocalEntity, ClimateEntity):
         """Set new HVAC mode."""
         """Set new HVAC mode."""
         if self._hvac_mode_dps is None:
         if self._hvac_mode_dps is None:
             raise NotImplementedError()
             raise NotImplementedError()
-        _LOGGER.info(
-            "%s setting HVAC mode to %s",
-            self._config.config_id,
-            hvac_mode,
-        )
-        await self._hvac_mode_dps.async_set_value(self._device, hvac_mode)
+        async with self._device.set_lock:
+            _LOGGER.info(
+                "%s setting HVAC mode to %s",
+                self._config.config_id,
+                hvac_mode,
+            )
+            await self._hvac_mode_dps.async_set_value(self._device, hvac_mode)
 
 
     async def async_turn_on(self):
     async def async_turn_on(self):
         """Turn on the climate device."""
         """Turn on the climate device."""
         # Bypass the usual dps mapping to switch the power dp directly
         # Bypass the usual dps mapping to switch the power dp directly
         # this way the hvac_mode will be kept when toggling off and on.
         # this way the hvac_mode will be kept when toggling off and on.
         if self._hvac_mode_dps and self._hvac_mode_dps.type is bool:
         if self._hvac_mode_dps and self._hvac_mode_dps.type is bool:
-            _LOGGER.info("%s turning on", self._config.config_id)
-            await self._device.async_set_property(self._hvac_mode_dps.id, True)
+            async with self._device.set_lock:
+                _LOGGER.info("%s turning on", self._config.config_id)
+                await self._device.async_set_property(self._hvac_mode_dps.id, True)
         else:
         else:
             await super().async_turn_on()
             await super().async_turn_on()
 
 
@@ -415,11 +421,12 @@ class TuyaLocalClimate(TuyaLocalEntity, ClimateEntity):
         # Bypass the usual dps mapping to switch the power dp directly
         # Bypass the usual dps mapping to switch the power dp directly
         # this way the hvac_mode will be kept when toggling off and on.
         # this way the hvac_mode will be kept when toggling off and on.
         if self._hvac_mode_dps and self._hvac_mode_dps.type is bool:
         if self._hvac_mode_dps and self._hvac_mode_dps.type is bool:
-            _LOGGER.info("%s turning off", self._config.config_id)
-            await self._device.async_set_property(
-                self._hvac_mode_dps.id,
-                False,
-            )
+            async with self._device.set_lock:
+                _LOGGER.info("%s turning off", self._config.config_id)
+                await self._device.async_set_property(
+                    self._hvac_mode_dps.id,
+                    False,
+                )
         else:
         else:
             await super().async_turn_off()
             await super().async_turn_off()
 
 
@@ -440,12 +447,13 @@ class TuyaLocalClimate(TuyaLocalEntity, ClimateEntity):
         """Set the preset mode."""
         """Set the preset mode."""
         if self._preset_mode_dps is None:
         if self._preset_mode_dps is None:
             raise NotImplementedError()
             raise NotImplementedError()
-        _LOGGER.info(
-            "%s setting preset mode to %s",
-            self._config.config_id,
-            preset_mode,
-        )
-        await self._preset_mode_dps.async_set_value(self._device, preset_mode)
+        async with self._device.set_lock:
+            _LOGGER.info(
+                "%s setting preset mode to %s",
+                self._config.config_id,
+                preset_mode,
+            )
+            await self._preset_mode_dps.async_set_value(self._device, preset_mode)
 
 
     @property
     @property
     def swing_mode(self):
     def swing_mode(self):
@@ -464,12 +472,13 @@ class TuyaLocalClimate(TuyaLocalEntity, ClimateEntity):
         """Set the preset mode."""
         """Set the preset mode."""
         if self._swing_mode_dps is None:
         if self._swing_mode_dps is None:
             raise NotImplementedError()
             raise NotImplementedError()
-        _LOGGER.info(
-            "%s setting swing mode to %s",
-            self._config.config_id,
-            swing_mode,
-        )
-        await self._swing_mode_dps.async_set_value(self._device, swing_mode)
+        async with self._device.set_lock:
+            _LOGGER.info(
+                "%s setting swing mode to %s",
+                self._config.config_id,
+                swing_mode,
+            )
+            await self._swing_mode_dps.async_set_value(self._device, swing_mode)
 
 
     @property
     @property
     def swing_horizontal_mode(self):
     def swing_horizontal_mode(self):
@@ -488,15 +497,16 @@ class TuyaLocalClimate(TuyaLocalEntity, ClimateEntity):
         """Set the preset mode."""
         """Set the preset mode."""
         if self._swing_horizontal_mode_dps is None:
         if self._swing_horizontal_mode_dps is None:
             raise NotImplementedError()
             raise NotImplementedError()
-        _LOGGER.info(
-            "%s setting horizontal swing mode to %s",
-            self._config.config_id,
-            swing_mode,
-        )
-        await self._swing_horizontal_mode_dps.async_set_value(
-            self._device,
-            swing_mode,
-        )
+        async with self._device.set_lock:
+            _LOGGER.info(
+                "%s setting horizontal swing mode to %s",
+                self._config.config_id,
+                swing_mode,
+            )
+            await self._swing_horizontal_mode_dps.async_set_value(
+                self._device,
+                swing_mode,
+            )
 
 
     @property
     @property
     def fan_mode(self):
     def fan_mode(self):
@@ -515,9 +525,10 @@ class TuyaLocalClimate(TuyaLocalEntity, ClimateEntity):
         """Set the fan mode."""
         """Set the fan mode."""
         if self._fan_mode_dps is None:
         if self._fan_mode_dps is None:
             raise NotImplementedError()
             raise NotImplementedError()
-        _LOGGER.info(
-            "%s setting fan mode to %s",
-            self._config.config_id,
-            fan_mode,
-        )
-        await self._fan_mode_dps.async_set_value(self._device, fan_mode)
+        async with self._device.set_lock:
+            _LOGGER.info(
+                "%s setting fan mode to %s",
+                self._config.config_id,
+                fan_mode,
+            )
+            await self._fan_mode_dps.async_set_value(self._device, fan_mode)

+ 44 - 39
custom_components/tuya_local/cover.py

@@ -201,66 +201,71 @@ class TuyaLocalCover(TuyaLocalEntity, CoverEntity):
 
 
     async def async_open_cover(self, **kwargs):
     async def async_open_cover(self, **kwargs):
         """Open the cover."""
         """Open the cover."""
-        if self._control_dp and "open" in self._control_dp.values(self._device):
-            _LOGGER.info("%s opening", self._config.config_id)
-            await self._control_dp.async_set_value(self._device, "open")
-        elif self._position_dp:
-            pos = 100
-            _LOGGER.info("%s opening to 100%%", self._config.config_id)
-            await self._position_dp.async_set_value(self._device, pos)
-        else:
-            raise NotImplementedError()
+        async with self._device.set_lock:
+            if self._control_dp and "open" in self._control_dp.values(self._device):
+                _LOGGER.info("%s opening", self._config.config_id)
+                await self._control_dp.async_set_value(self._device, "open")
+            elif self._position_dp:
+                pos = 100
+                _LOGGER.info("%s opening to 100%%", self._config.config_id)
+                await self._position_dp.async_set_value(self._device, pos)
+            else:
+                raise NotImplementedError()
 
 
     async def async_close_cover(self, **kwargs):
     async def async_close_cover(self, **kwargs):
         """Close the cover."""
         """Close the cover."""
-        if self._control_dp and "close" in self._control_dp.values(self._device):
-            _LOGGER.info("%s closing", self._config.config_id)
-            await self._control_dp.async_set_value(self._device, "close")
-        elif self._position_dp:
-            pos = 0
-            _LOGGER.info("%s closing to 0%%", self._config.config_id)
-            await self._position_dp.async_set_value(self._device, pos)
-        else:
-            raise NotImplementedError()
+        async with self._device.set_lock:
+            if self._control_dp and "close" in self._control_dp.values(self._device):
+                _LOGGER.info("%s closing", self._config.config_id)
+                await self._control_dp.async_set_value(self._device, "close")
+            elif self._position_dp:
+                pos = 0
+                _LOGGER.info("%s closing to 0%%", self._config.config_id)
+                await self._position_dp.async_set_value(self._device, pos)
+            else:
+                raise NotImplementedError()
 
 
     async def async_set_cover_position(self, position, **kwargs):
     async def async_set_cover_position(self, position, **kwargs):
         """Set the cover to a specific position."""
         """Set the cover to a specific position."""
         if position is None:
         if position is None:
             raise AttributeError()
             raise AttributeError()
         if self._position_dp:
         if self._position_dp:
-            _LOGGER.info(
-                "%s setting position to %d%%", self._config.config_id, position
-            )
-            await self._position_dp.async_set_value(self._device, position)
+            async with self._device.set_lock:
+                _LOGGER.info(
+                    "%s setting position to %d%%", self._config.config_id, position
+                )
+                await self._position_dp.async_set_value(self._device, position)
         else:
         else:
             raise NotImplementedError()
             raise NotImplementedError()
 
 
     async def async_set_cover_tilt_position(self, tilt_position, **kwargs):
     async def async_set_cover_tilt_position(self, tilt_position, **kwargs):
         """Set the cover tilt position."""
         """Set the cover tilt position."""
         if self._tiltpos_dp:
         if self._tiltpos_dp:
-            # If there is a fixed list of values, snap to the closest one
-            if self._tiltpos_dp.values(self._device):
-                tilt_position = min(
-                    self._tiltpos_dp.values(self._device),
-                    key=lambda x: abs(x - tilt_position),
+            async with self._device.set_lock:
+                # If there is a fixed list of values, snap to the closest one
+                if self._tiltpos_dp.values(self._device):
+                    tilt_position = min(
+                        self._tiltpos_dp.values(self._device),
+                        key=lambda x: abs(x - tilt_position),
+                    )
+                elif self._tiltpos_dp.range(self._device):
+                    r = self._tiltpos_dp.range(self._device)
+                    tilt_position = percentage_to_ranged_value(r, tilt_position)
+
+                _LOGGER.info(
+                    "%s setting tilt position to %d%%",
+                    self._config.config_id,
+                    tilt_position,
                 )
                 )
-            elif self._tiltpos_dp.range(self._device):
-                r = self._tiltpos_dp.range(self._device)
-                tilt_position = percentage_to_ranged_value(r, tilt_position)
-
-            _LOGGER.info(
-                "%s setting tilt position to %d%%",
-                self._config.config_id,
-                tilt_position,
-            )
-            await self._tiltpos_dp.async_set_value(self._device, tilt_position)
+                await self._tiltpos_dp.async_set_value(self._device, tilt_position)
         else:
         else:
             raise NotImplementedError
             raise NotImplementedError
 
 
     async def async_stop_cover(self, **kwargs):
     async def async_stop_cover(self, **kwargs):
         """Stop the cover."""
         """Stop the cover."""
         if self._control_dp and "stop" in self._control_dp.values(self._device):
         if self._control_dp and "stop" in self._control_dp.values(self._device):
-            _LOGGER.info("%s stopping", self._config.config_id)
-            await self._control_dp.async_set_value(self._device, "stop")
+            async with self._device.set_lock:
+                _LOGGER.info("%s stopping", self._config.config_id)
+                await self._control_dp.async_set_value(self._device, "stop")
         else:
         else:
             raise NotImplementedError()
             raise NotImplementedError()

+ 4 - 0
custom_components/tuya_local/datetime.py

@@ -103,6 +103,10 @@ class TuyaLocalDateTime(TuyaLocalEntity, DateTimeEntity):
 
 
     async def async_set_value(self, value: datetime):
     async def async_set_value(self, value: datetime):
         """Set the datetime."""
         """Set the datetime."""
+        async with self._device.set_lock:
+            return await self._async_set_value_locked(value)
+
+    async def _async_set_value_locked(self, value: datetime):
         settings = {}
         settings = {}
         # Use Local time if split into components
         # Use Local time if split into components
         if self._year_dps:
         if self._year_dps:

+ 1 - 1
custom_components/tuya_local/device.py

@@ -162,7 +162,7 @@ class TuyaLocalDevice(object):
         # different masks). Without this, two concurrent async_turn_on calls
         # different masks). Without this, two concurrent async_turn_on calls
         # both read the same baseline before either updates pending, then the
         # both read the same baseline before either updates pending, then the
         # second's pending update clobbers the first.
         # second's pending update clobbers the first.
-        self._set_lock = asyncio.Lock()
+        self.set_lock = asyncio.Lock()
 
 
     @property
     @property
     def name(self):
     def name(self):

+ 43 - 20
custom_components/tuya_local/fan.py

@@ -88,6 +88,15 @@ class TuyaLocalFan(TuyaLocalEntity, FanEntity):
         **kwargs: Any,
         **kwargs: Any,
     ):
     ):
         """Turn the fan on, setting any other parameters given"""
         """Turn the fan on, setting any other parameters given"""
+        async with self._device.set_lock:
+            return await self._async_turn_on_locked(percentage, preset_mode, **kwargs)
+
+    async def _async_turn_on_locked(
+        self,
+        percentage: int | None,
+        preset_mode: str | None,
+        **kwargs: Any,
+    ):
         settings = {}
         settings = {}
         if self._switch_dps:
         if self._switch_dps:
             _LOGGER.info("%s turning on", self._config.config_id)
             _LOGGER.info("%s turning on", self._config.config_id)
@@ -125,18 +134,21 @@ class TuyaLocalFan(TuyaLocalEntity, FanEntity):
 
 
     async def async_turn_off(self, **kwargs):
     async def async_turn_off(self, **kwargs):
         """Turn the switch off"""
         """Turn the switch off"""
-        if self._switch_dps:
-            _LOGGER.info("%s turning off", self._config.config_id)
-            await self._switch_dps.async_set_value(self._device, False)
-        elif (
-            self._speed_dps
-            and self._speed_dps.range(self._device)
-            and self._speed_dps.range(self._device)[0] == 0
-        ):
-            _LOGGER.info("%s turning off by setting speed to 0", self._config.config_id)
-            await self._speed_dps.async_set_value(self._device, 0)
-        else:
-            raise NotImplementedError
+        async with self._device.set_lock:
+            if self._switch_dps:
+                _LOGGER.info("%s turning off", self._config.config_id)
+                await self._switch_dps.async_set_value(self._device, False)
+            elif (
+                self._speed_dps
+                and self._speed_dps.range(self._device)
+                and self._speed_dps.range(self._device)[0] == 0
+            ):
+                _LOGGER.info(
+                    "%s turning off by setting speed to 0", self._config.config_id
+                )
+                await self._speed_dps.async_set_value(self._device, 0)
+            else:
+                raise NotImplementedError
 
 
     @property
     @property
     def percentage(self):
     def percentage(self):
@@ -173,6 +185,10 @@ class TuyaLocalFan(TuyaLocalEntity, FanEntity):
 
 
     async def async_set_percentage(self, percentage):
     async def async_set_percentage(self, percentage):
         """Set the fan speed as a percentage."""
         """Set the fan speed as a percentage."""
+        async with self._device.set_lock:
+            return await self._async_set_percentage_locked(percentage)
+
+    async def _async_set_percentage_locked(self, percentage):
         # If speed is 0, turn the fan off
         # If speed is 0, turn the fan off
         if percentage == 0 and self._switch_dps:
         if percentage == 0 and self._switch_dps:
             return await self.async_turn_off()
             return await self.async_turn_off()
@@ -217,10 +233,11 @@ class TuyaLocalFan(TuyaLocalEntity, FanEntity):
         """Set the preset mode."""
         """Set the preset mode."""
         if self._preset_dps is None:
         if self._preset_dps is None:
             raise NotImplementedError()
             raise NotImplementedError()
-        _LOGGER.info(
-            "%s setting preset mode to %s", self._config.config_id, preset_mode
-        )
-        await self._preset_dps.async_set_value(self._device, preset_mode)
+        async with self._device.set_lock:
+            _LOGGER.info(
+                "%s setting preset mode to %s", self._config.config_id, preset_mode
+            )
+            await self._preset_dps.async_set_value(self._device, preset_mode)
 
 
     @property
     @property
     def current_direction(self):
     def current_direction(self):
@@ -232,8 +249,11 @@ class TuyaLocalFan(TuyaLocalEntity, FanEntity):
         """Set the direction of the fan."""
         """Set the direction of the fan."""
         if self._direction_dps is None:
         if self._direction_dps is None:
             raise NotImplementedError()
             raise NotImplementedError()
-        _LOGGER.info("%s setting direction to %s", self._config.config_id, direction)
-        await self._direction_dps.async_set_value(self._device, direction)
+        async with self._device.set_lock:
+            _LOGGER.info(
+                "%s setting direction to %s", self._config.config_id, direction
+            )
+            await self._direction_dps.async_set_value(self._device, direction)
 
 
     @property
     @property
     def oscillating(self):
     def oscillating(self):
@@ -245,5 +265,8 @@ class TuyaLocalFan(TuyaLocalEntity, FanEntity):
         """Oscillate the fan."""
         """Oscillate the fan."""
         if self._oscillate_dps is None:
         if self._oscillate_dps is None:
             raise NotImplementedError()
             raise NotImplementedError()
-        _LOGGER.info("%s setting oscillate to %s", self._config.config_id, oscillating)
-        await self._oscillate_dps.async_set_value(self._device, oscillating)
+        async with self._device.set_lock:
+            _LOGGER.info(
+                "%s setting oscillate to %s", self._config.config_id, oscillating
+            )
+            await self._oscillate_dps.async_set_value(self._device, oscillating)

+ 12 - 8
custom_components/tuya_local/humidifier.py

@@ -99,13 +99,15 @@ class TuyaLocalHumidifier(TuyaLocalEntity, HumidifierEntity):
 
 
     async def async_turn_on(self, **kwargs):
     async def async_turn_on(self, **kwargs):
         """Turn the switch on"""
         """Turn the switch on"""
-        _LOGGER.info("%s turning on", self._config.config_id)
-        await self._switch_dp.async_set_value(self._device, True)
+        async with self._device.set_lock:
+            _LOGGER.info("%s turning on", self._config.config_id)
+            await self._switch_dp.async_set_value(self._device, True)
 
 
     async def async_turn_off(self, **kwargs):
     async def async_turn_off(self, **kwargs):
         """Turn the switch off"""
         """Turn the switch off"""
-        _LOGGER.info("%s turning off", self._config.config_id)
-        await self._switch_dp.async_set_value(self._device, False)
+        async with self._device.set_lock:
+            _LOGGER.info("%s turning off", self._config.config_id)
+            await self._switch_dp.async_set_value(self._device, False)
 
 
     @property
     @property
     def current_humidity(self):
     def current_humidity(self):
@@ -139,8 +141,9 @@ class TuyaLocalHumidifier(TuyaLocalEntity, HumidifierEntity):
     async def async_set_humidity(self, humidity):
     async def async_set_humidity(self, humidity):
         if self._humidity_dp is None:
         if self._humidity_dp is None:
             raise NotImplementedError()
             raise NotImplementedError()
-        _LOGGER.info("%s setting humidity to %s", self._config.config_id, humidity)
-        await self._humidity_dp.async_set_value(self._device, humidity)
+        async with self._device.set_lock:
+            _LOGGER.info("%s setting humidity to %s", self._config.config_id, humidity)
+            await self._humidity_dp.async_set_value(self._device, humidity)
 
 
     @property
     @property
     def mode(self):
     def mode(self):
@@ -159,5 +162,6 @@ class TuyaLocalHumidifier(TuyaLocalEntity, HumidifierEntity):
         """Set the preset mode."""
         """Set the preset mode."""
         if self._mode_dp is None:
         if self._mode_dp is None:
             raise NotImplementedError()
             raise NotImplementedError()
-        _LOGGER.info("%s setting mode to %s", self._config.config_id, mode)
-        await self._mode_dp.async_set_value(self._device, mode)
+        async with self._device.set_lock:
+            _LOGGER.info("%s setting mode to %s", self._config.config_id, mode)
+            await self._mode_dp.async_set_value(self._device, mode)

+ 10 - 9
custom_components/tuya_local/infrared.py

@@ -83,15 +83,16 @@ class TuyaLocalInfrared(TuyaLocalEntity, InfraredEntity):
     async def _ir_send(self, tuya_command: str):
     async def _ir_send(self, tuya_command: str):
         """Send the infrared command to the device."""
         """Send the infrared command to the device."""
         if self._send_dp:
         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),
-                )
+            async with self._device.set_lock:
+                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:
     def _package_single_dp_send(self, command: str) -> str:
         """Package the command for a single DP (usually dp id 201) send."""
         """Package the command for a single DP (usually dp id 201) send."""

+ 11 - 6
custom_components/tuya_local/lawn_mower.py

@@ -65,17 +65,22 @@ class TuyaLocalLawnMower(TuyaLocalEntity, LawnMowerEntity):
     async def async_start_mowing(self) -> None:
     async def async_start_mowing(self) -> None:
         """Start mowing the lawn."""
         """Start mowing the lawn."""
         if self._command_dp:
         if self._command_dp:
-            _LOGGER.info("%s starting lawn mowing", self._config.config_id)
-            await self._command_dp.async_set_value(self._device, SERVICE_START_MOWING)
+            async with self._device.set_lock:
+                _LOGGER.info("%s starting lawn mowing", self._config.config_id)
+                await self._command_dp.async_set_value(
+                    self._device, SERVICE_START_MOWING
+                )
 
 
     async def async_pause(self):
     async def async_pause(self):
         """Pause lawn mowing."""
         """Pause lawn mowing."""
         if self._command_dp:
         if self._command_dp:
-            _LOGGER.info("%s pausing lawn mowing", self._config.config_id)
-            await self._command_dp.async_set_value(self._device, SERVICE_PAUSE)
+            async with self._device.set_lock:
+                _LOGGER.info("%s pausing lawn mowing", self._config.config_id)
+                await self._command_dp.async_set_value(self._device, SERVICE_PAUSE)
 
 
     async def async_dock(self):
     async def async_dock(self):
         """Stop mowing and return to dock."""
         """Stop mowing and return to dock."""
         if self._command_dp:
         if self._command_dp:
-            _LOGGER.info("%s returning to dock", self._config.config_id)
-            await self._command_dp.async_set_value(self._device, SERVICE_DOCK)
+            async with self._device.set_lock:
+                _LOGGER.info("%s returning to dock", self._config.config_id)
+                await self._command_dp.async_set_value(self._device, SERVICE_DOCK)

+ 2 - 2
custom_components/tuya_local/light.py

@@ -285,7 +285,7 @@ class TuyaLocalLight(TuyaLocalEntity, LightEntity):
             return best_match
             return best_match
 
 
     async def async_turn_on(self, **params):
     async def async_turn_on(self, **params):
-        async with self._device._set_lock:
+        async with self._device.set_lock:
             await self._async_turn_on_locked(**params)
             await self._async_turn_on_locked(**params)
 
 
     async def _async_turn_on_locked(self, **params):
     async def _async_turn_on_locked(self, **params):
@@ -568,7 +568,7 @@ class TuyaLocalLight(TuyaLocalEntity, LightEntity):
             await self._device.async_set_properties(settings)
             await self._device.async_set_properties(settings)
 
 
     async def async_turn_off(self):
     async def async_turn_off(self):
-        async with self._device._set_lock:
+        async with self._device.set_lock:
             await self._async_turn_off_locked()
             await self._async_turn_off_locked()
 
 
     async def _async_turn_off_locked(self):
     async def _async_turn_off_locked(self):

+ 40 - 34
custom_components/tuya_local/lock.py

@@ -182,53 +182,59 @@ class TuyaLocalLock(TuyaLocalEntity, LockEntity):
         """Lock the lock."""
         """Lock the lock."""
         if self._lock_dp and not self._lock_dp.readonly:
         if self._lock_dp and not self._lock_dp.readonly:
             _LOGGER.info("%s locking", self._config.config_id)
             _LOGGER.info("%s locking", self._config.config_id)
-            await self._lock_dp.async_set_value(self._device, True)
+            async with self._device.set_lock:
+                await self._lock_dp.async_set_value(self._device, True)
         elif self._code_unlock_dp:
         elif self._code_unlock_dp:
             code = kwargs.get("code")
             code = kwargs.get("code")
             if not code:
             if not code:
                 raise ValueError("Code required to lock")
                 raise ValueError("Code required to lock")
-            msg = self.build_code_unlock_msg(
-                CODE_LOCK, member_id=1, code=code, source=CODE_SRC_UNKNOWN
-            )
-            _LOGGER.info("%s locking with code", self._config.config_id)
-            await self._code_unlock_dp.async_set_value(self._device, msg)
+            async with self._device.set_lock:
+                msg = self.build_code_unlock_msg(
+                    CODE_LOCK, member_id=1, code=code, source=CODE_SRC_UNKNOWN
+                )
+                _LOGGER.info("%s locking with code", self._config.config_id)
+                await self._code_unlock_dp.async_set_value(self._device, msg)
         else:
         else:
             raise NotImplementedError()
             raise NotImplementedError()
 
 
     async def async_unlock(self, **kwargs):
     async def async_unlock(self, **kwargs):
         """Unlock the lock."""
         """Unlock the lock."""
-        if self._code_unlock_dp:
-            code = kwargs.get("code")
-            if not code:
-                raise ValueError("Code required to unlock")
-            msg = self.build_code_unlock_msg(
-                CODE_UNLOCK, member_id=1, code=code, source=CODE_SRC_UNKNOWN
-            )
-            _LOGGER.info("%s unlocking with code", self._config.config_id)
-            await self._code_unlock_dp.async_set_value(self._device, msg)
-        elif self._lock_dp and not self._lock_dp.readonly:
-            _LOGGER.info("%s unlocking", self._config.config_id)
-            await self._lock_dp.async_set_value(self._device, False)
-        elif self._approve_unlock_dp:
-            if self._req_unlock_dp and not self._req_unlock_dp.get_value(self._device):
-                raise TimeoutError()
-            _LOGGER.info("%s approving unlock", self._config.config_id)
-            await self._approve_unlock_dp.async_set_value(self._device, True)
-        elif self._approve_intercom_dp:
-            if self._req_intercom_dp and not self._req_intercom_dp.get_value(
-                self._device
-            ):
-                raise TimeoutError()
-            _LOGGER.info("%s approving intercom unlock", self._config.config_id)
-            await self._approve_intercom_dp.async_set_value(self._device, True)
-        else:
-            raise NotImplementedError()
+        async with self._device.set_lock:
+            if self._code_unlock_dp:
+                code = kwargs.get("code")
+                if not code:
+                    raise ValueError("Code required to unlock")
+                msg = self.build_code_unlock_msg(
+                    CODE_UNLOCK, member_id=1, code=code, source=CODE_SRC_UNKNOWN
+                )
+                _LOGGER.info("%s unlocking with code", self._config.config_id)
+                await self._code_unlock_dp.async_set_value(self._device, msg)
+            elif self._lock_dp and not self._lock_dp.readonly:
+                _LOGGER.info("%s unlocking", self._config.config_id)
+                await self._lock_dp.async_set_value(self._device, False)
+            elif self._approve_unlock_dp:
+                if self._req_unlock_dp and not self._req_unlock_dp.get_value(
+                    self._device
+                ):
+                    raise TimeoutError()
+                _LOGGER.info("%s approving unlock", self._config.config_id)
+                await self._approve_unlock_dp.async_set_value(self._device, True)
+            elif self._approve_intercom_dp:
+                if self._req_intercom_dp and not self._req_intercom_dp.get_value(
+                    self._device
+                ):
+                    raise TimeoutError()
+                _LOGGER.info("%s approving intercom unlock", self._config.config_id)
+                await self._approve_intercom_dp.async_set_value(self._device, True)
+            else:
+                raise NotImplementedError()
 
 
     async def async_open(self, **kwargs):
     async def async_open(self, **kwargs):
         """Open the door latch."""
         """Open the door latch."""
         if self._open_dp:
         if self._open_dp:
-            _LOGGER.info("%s opening", self._config.config_id)
-            await self._open_dp.async_set_value(self._device, True)
+            async with self._device.set_lock:
+                _LOGGER.info("%s opening", self._config.config_id)
+                await self._open_dp.async_set_value(self._device, True)
 
 
     def build_code_unlock_msg(self, action, member_id, code, source=CODE_SRC_UNKNOWN):
     def build_code_unlock_msg(self, action, member_id, code, source=CODE_SRC_UNKNOWN):
         """Generate the unlock code message."""
         """Generate the unlock code message."""

+ 14 - 13
custom_components/tuya_local/number.py

@@ -120,16 +120,17 @@ class TuyaLocalNumber(TuyaLocalEntity, NumberEntity):
 
 
     async def async_set_native_value(self, value):
     async def async_set_native_value(self, value):
         """Set the number."""
         """Set the number."""
-        _LOGGER.info("%s setting value to %s", self._config.config_id, value)
-        settings = {}
-        if self._decimal_dps is not None:
-            whole = int(value)
-            decimal = value - whole
-            settings = self._decimal_dps.get_values_to_set(self._device, decimal)
-            value = whole
-
-        settings = settings | self._value_dps.get_values_to_set(
-            self._device, value, settings
-        )
-
-        await self._device.async_set_properties(settings)
+        async with self._device.set_lock:
+            _LOGGER.info("%s setting value to %s", self._config.config_id, value)
+            settings = {}
+            if self._decimal_dps is not None:
+                whole = int(value)
+                decimal = value - whole
+                settings = self._decimal_dps.get_values_to_set(self._device, decimal)
+                value = whole
+
+            settings = settings | self._value_dps.get_values_to_set(
+                self._device, value, settings
+            )
+
+            await self._device.async_set_properties(settings)

+ 464 - 459
custom_components/tuya_local/remote.py

@@ -1,459 +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]
-
-            # 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",
-                }
-            )
-        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)
-            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)

+ 3 - 2
custom_components/tuya_local/select.py

@@ -58,5 +58,6 @@ class TuyaLocalSelect(TuyaLocalEntity, SelectEntity):
 
 
     async def async_select_option(self, option):
     async def async_select_option(self, option):
         "Set the option"
         "Set the option"
-        _LOGGER.info("%s selecting option %s", self._config.config_id, option)
-        await self._option_dps.async_set_value(self._device, option)
+        async with self._device.set_lock:
+            _LOGGER.info("%s selecting option %s", self._config.config_id, option)
+            await self._option_dps.async_set_value(self._device, option)

+ 11 - 6
custom_components/tuya_local/siren.py

@@ -79,6 +79,10 @@ class TuyaLocalSiren(TuyaLocalEntity, SirenEntity):
             return self._tone_dp.get_value(self._device) != "off"
             return self._tone_dp.get_value(self._device) != "off"
 
 
     async def async_turn_on(self, **kwargs) -> None:
     async def async_turn_on(self, **kwargs) -> None:
+        async with self._device.set_lock:
+            await self._async_turn_on_locked(**kwargs)
+
+    async def _async_turn_on_locked(self, **kwargs) -> None:
         tone = kwargs.get(ATTR_TONE, None)
         tone = kwargs.get(ATTR_TONE, None)
         duration = kwargs.get(ATTR_DURATION, None)
         duration = kwargs.get(ATTR_DURATION, None)
         volume = kwargs.get(ATTR_VOLUME_LEVEL, None)
         volume = kwargs.get(ATTR_VOLUME_LEVEL, None)
@@ -133,9 +137,10 @@ class TuyaLocalSiren(TuyaLocalEntity, SirenEntity):
 
 
     async def async_turn_off(self) -> None:
     async def async_turn_off(self) -> None:
         """Turn off the siren"""
         """Turn off the siren"""
-        if self._switch_dp:
-            _LOGGER.info("%s turning off siren", self._config.config_id)
-            await self._switch_dp.async_set_value(self._device, False)
-        elif self._tone_dp:
-            _LOGGER.info("%s setting siren tone to off", self._config.config_id)
-            await self._tone_dp.async_set_value(self._device, "off")
+        async with self._device.set_lock:
+            if self._switch_dp:
+                _LOGGER.info("%s turning off siren", self._config.config_id)
+                await self._switch_dp.async_set_value(self._device, False)
+            elif self._tone_dp:
+                _LOGGER.info("%s setting siren tone to off", self._config.config_id)
+                await self._tone_dp.async_set_value(self._device, "off")

+ 6 - 4
custom_components/tuya_local/switch.py

@@ -64,10 +64,12 @@ class TuyaLocalSwitch(TuyaLocalEntity, SwitchEntity):
 
 
     async def async_turn_on(self, **kwargs):
     async def async_turn_on(self, **kwargs):
         """Turn the switch on"""
         """Turn the switch on"""
-        _LOGGER.info("%s turning on", self._config.config_id)
-        await self._switch_dps.async_set_value(self._device, True)
+        async with self._device.set_lock:
+            _LOGGER.info("%s turning on", self._config.config_id)
+            await self._switch_dps.async_set_value(self._device, True)
 
 
     async def async_turn_off(self, **kwargs):
     async def async_turn_off(self, **kwargs):
         """Turn the switch off"""
         """Turn the switch off"""
-        _LOGGER.info("%s turning off", self._config.config_id)
-        await self._switch_dps.async_set_value(self._device, False)
+        async with self._device.set_lock:
+            _LOGGER.info("%s turning off", self._config.config_id)
+            await self._switch_dps.async_set_value(self._device, False)

+ 3 - 2
custom_components/tuya_local/text.py

@@ -75,8 +75,9 @@ class TuyaLocalText(TuyaLocalEntity, TextEntity):
 
 
     async def async_set_value(self, value: str) -> None:
     async def async_set_value(self, value: str) -> None:
         """Set the value"""
         """Set the value"""
-        _LOGGER.info("%s setting value to %s", self._config.config_id, value)
-        await self._value_dp.async_set_value(self._device, value)
+        async with self._device.set_lock:
+            _LOGGER.info("%s setting value to %s", self._config.config_id, value)
+            await self._value_dp.async_set_value(self._device, value)
 
 
     @property
     @property
     def extra_state_attributes(self) -> dict[str, any]:
     def extra_state_attributes(self) -> dict[str, any]:

+ 4 - 0
custom_components/tuya_local/time.py

@@ -99,6 +99,10 @@ class TuyaLocalTime(TuyaLocalEntity, TimeEntity):
 
 
     async def async_set_value(self, value: time):
     async def async_set_value(self, value: time):
         """Set the number."""
         """Set the number."""
+        async with self._device.set_lock:
+            return await self._async_set_value_locked(value)
+
+    async def _async_set_value_locked(self, value: time):
         settings = {}
         settings = {}
         hours = value.hour
         hours = value.hour
         minutes = value.minute
         minutes = value.minute

+ 55 - 44
custom_components/tuya_local/vacuum.py

@@ -120,68 +120,77 @@ class TuyaLocalVacuum(TuyaLocalEntity, StateVacuumEntity):
     async def async_turn_on(self, **kwargs):
     async def async_turn_on(self, **kwargs):
         """Turn on the vacuum cleaner."""
         """Turn on the vacuum cleaner."""
         if self._power_dps:
         if self._power_dps:
-            _LOGGER.info("%s turning on", self._config.config_id)
-            await self._power_dps.async_set_value(self._device, True)
+            async with self._device.set_lock:
+                _LOGGER.info("%s turning on", self._config.config_id)
+                await self._power_dps.async_set_value(self._device, True)
 
 
     async def async_turn_off(self, **kwargs):
     async def async_turn_off(self, **kwargs):
         """Turn off the vacuum cleaner."""
         """Turn off the vacuum cleaner."""
         if self._power_dps:
         if self._power_dps:
-            _LOGGER.info("%s turning off", self._config.config_id)
-            await self._power_dps.async_set_value(self._device, False)
+            async with self._device.set_lock:
+                _LOGGER.info("%s turning off", self._config.config_id)
+                await self._power_dps.async_set_value(self._device, False)
 
 
     async def async_toggle(self, **kwargs):
     async def async_toggle(self, **kwargs):
         """Toggle the vacuum cleaner."""
         """Toggle the vacuum cleaner."""
         dps = self._power_dps or self._activate_dps
         dps = self._power_dps or self._activate_dps
         if dps:
         if dps:
-            switch_to = not dps.get_value(self._device)
-            _LOGGER.info("%s toggling to %s", self._config.config_id, switch_to)
-            await dps.async_set_value(self._device, switch_to)
+            async with self._device.set_lock:
+                switch_to = not dps.get_value(self._device)
+                _LOGGER.info("%s toggling to %s", self._config.config_id, switch_to)
+                await dps.async_set_value(self._device, switch_to)
 
 
     async def async_start(self):
     async def async_start(self):
         dps = self._command_dps or self._status_dps
         dps = self._command_dps or self._status_dps
-        if dps and "start" in dps.values(self._device):
-            _LOGGER.info("%s starting by command", self._config.config_id)
-            await dps.async_set_value(self._device, "start")
-        elif self._activate_dps:
-            _LOGGER.info("%s activating", self._config.config_id)
-            await self._activate_dps.async_set_value(self._device, True)
+        async with self._device.set_lock:
+            if dps and "start" in dps.values(self._device):
+                _LOGGER.info("%s starting by command", self._config.config_id)
+                await dps.async_set_value(self._device, "start")
+            elif self._activate_dps:
+                _LOGGER.info("%s activating", self._config.config_id)
+                await self._activate_dps.async_set_value(self._device, True)
 
 
     async def async_pause(self):
     async def async_pause(self):
         """Pause the vacuum cleaner."""
         """Pause the vacuum cleaner."""
         dps = self._command_dps or self._status_dps
         dps = self._command_dps or self._status_dps
-        if dps and "pause" in dps.values(self._device):
-            _LOGGER.info("%s pausing by command", self._config.config_id)
-            await dps.async_set_value(self._device, "pause")
-        elif self._activate_dps:
-            _LOGGER.info("%s deactivating", self._config.config_id)
-            await self._activate_dps.async_set_value(self._device, False)
+        async with self._device.set_lock:
+            if dps and "pause" in dps.values(self._device):
+                _LOGGER.info("%s pausing by command", self._config.config_id)
+                await dps.async_set_value(self._device, "pause")
+            elif self._activate_dps:
+                _LOGGER.info("%s deactivating", self._config.config_id)
+                await self._activate_dps.async_set_value(self._device, False)
 
 
     async def async_return_to_base(self, **kwargs):
     async def async_return_to_base(self, **kwargs):
         """Tell the vacuum cleaner to return to its base."""
         """Tell the vacuum cleaner to return to its base."""
         dps = self._command_dps or self._status_dps
         dps = self._command_dps or self._status_dps
         if dps and SERVICE_RETURN_TO_BASE in dps.values(self._device):
         if dps and SERVICE_RETURN_TO_BASE in dps.values(self._device):
-            _LOGGER.info("%s returning to base", self._config.config_id)
-            await dps.async_set_value(self._device, SERVICE_RETURN_TO_BASE)
+            async with self._device.set_lock:
+                _LOGGER.info("%s returning to base", self._config.config_id)
+                await dps.async_set_value(self._device, SERVICE_RETURN_TO_BASE)
 
 
     async def async_clean_spot(self, **kwargs):
     async def async_clean_spot(self, **kwargs):
         """Tell the vacuum cleaner do a spot clean."""
         """Tell the vacuum cleaner do a spot clean."""
         dps = self._command_dps or self._status_dps
         dps = self._command_dps or self._status_dps
         if dps and SERVICE_CLEAN_SPOT in dps.values(self._device):
         if dps and SERVICE_CLEAN_SPOT in dps.values(self._device):
-            _LOGGER.info("%s doing spot clean", self._config.config_id)
-            await dps.async_set_value(self._device, SERVICE_CLEAN_SPOT)
+            async with self._device.set_lock:
+                _LOGGER.info("%s doing spot clean", self._config.config_id)
+                await dps.async_set_value(self._device, SERVICE_CLEAN_SPOT)
 
 
     async def async_stop(self, **kwargs):
     async def async_stop(self, **kwargs):
         """Tell the vacuum cleaner to stop."""
         """Tell the vacuum cleaner to stop."""
         dps = self._command_dps or self._status_dps
         dps = self._command_dps or self._status_dps
         if dps and SERVICE_STOP in dps.values(self._device):
         if dps and SERVICE_STOP in dps.values(self._device):
-            _LOGGER.info("%s stopping", self._config.config_id)
-            await dps.async_set_value(self._device, SERVICE_STOP)
+            async with self._device.set_lock:
+                _LOGGER.info("%s stopping", self._config.config_id)
+                await dps.async_set_value(self._device, SERVICE_STOP)
 
 
     async def async_locate(self, **kwargs):
     async def async_locate(self, **kwargs):
         """Locate the vacuum cleaner."""
         """Locate the vacuum cleaner."""
         if self._locate_dps:
         if self._locate_dps:
-            _LOGGER.info("%s locating", self._config.config_id)
-            await self._locate_dps.async_set_value(self._device, True)
+            async with self._device.set_lock:
+                _LOGGER.info("%s locating", self._config.config_id)
+                await self._locate_dps.async_set_value(self._device, True)
 
 
     async def async_send_command(self, command, params=None, **kwargs):
     async def async_send_command(self, command, params=None, **kwargs):
         """Send a command to the vacuum cleaner."""
         """Send a command to the vacuum cleaner."""
@@ -196,19 +205,20 @@ class TuyaLocalVacuum(TuyaLocalEntity, StateVacuumEntity):
         ):
         ):
             dps = self._direction_dps
             dps = self._direction_dps
 
 
-        if command in dps.values(self._device):
-            _LOGGER.info(
-                "%s sending %s %s",
-                self._config.config_id,
-                "direction" if dps is self._direction_dps else "command",
-                command,
-            )
-            await dps.async_set_value(self._device, command)
-        elif self._direction_dps and command in self._direction_dps.values(
-            self._device
-        ):
-            _LOGGER.info("%s sending direction %s", self._config.config_id, command)
-            await self._direction_dps.async_set_value(self._device, command)
+        async with self._device.set_lock:
+            if command in dps.values(self._device):
+                _LOGGER.info(
+                    "%s sending %s %s",
+                    self._config.config_id,
+                    "direction" if dps is self._direction_dps else "command",
+                    command,
+                )
+                await dps.async_set_value(self._device, command)
+            elif self._direction_dps and command in self._direction_dps.values(
+                self._device
+            ):
+                _LOGGER.info("%s sending direction %s", self._config.config_id, command)
+                await self._direction_dps.async_set_value(self._device, command)
 
 
     @property
     @property
     def fan_speed_list(self):
     def fan_speed_list(self):
@@ -225,7 +235,8 @@ class TuyaLocalVacuum(TuyaLocalEntity, StateVacuumEntity):
     async def async_set_fan_speed(self, fan_speed, **kwargs):
     async def async_set_fan_speed(self, fan_speed, **kwargs):
         """Set the fan speed of the vacuum."""
         """Set the fan speed of the vacuum."""
         if self._fan_dps:
         if self._fan_dps:
-            _LOGGER.info(
-                "%s setting fan speed to %s", self._config.config_id, fan_speed
-            )
-            await self._fan_dps.async_set_value(self._device, fan_speed)
+            async with self._device.set_lock:
+                _LOGGER.info(
+                    "%s setting fan speed to %s", self._config.config_id, fan_speed
+                )
+                await self._fan_dps.async_set_value(self._device, fan_speed)

+ 16 - 14
custom_components/tuya_local/valve.py

@@ -101,16 +101,17 @@ class TuyaLocalValve(TuyaLocalEntity, ValveEntity):
 
 
     async def async_open_valve(self):
     async def async_open_valve(self):
         """Open the valve."""
         """Open the valve."""
-        if self._switch_dp:
-            _LOGGER.info("%s opening valve", self._config.config_id)
-            await self._switch_dp.async_set_value(self._device, True)
-            if self._valve_dp.get_value(self._device):
-                return
-        _LOGGER.info("%s fully opening valve", self._config.config_id)
-        await self._valve_dp.async_set_value(
-            self._device,
-            100 if self.reports_position else True,
-        )
+        async with self._device.set_lock:
+            if self._switch_dp:
+                _LOGGER.info("%s opening valve", self._config.config_id)
+                await self._switch_dp.async_set_value(self._device, True)
+                if self._valve_dp.get_value(self._device):
+                    return
+            _LOGGER.info("%s fully opening valve", self._config.config_id)
+            await self._valve_dp.async_set_value(
+                self._device,
+                100 if self.reports_position else True,
+            )
 
 
     async def async_close_valve(self):
     async def async_close_valve(self):
         """Close the valve"""
         """Close the valve"""
@@ -128,7 +129,8 @@ class TuyaLocalValve(TuyaLocalEntity, ValveEntity):
         """Set the position of the valve"""
         """Set the position of the valve"""
         if not self.reports_position:
         if not self.reports_position:
             raise NotImplementedError()
             raise NotImplementedError()
-        _LOGGER.info(
-            "%s setting valve position to %s%%", self._config.config_id, position
-        )
-        await self._valve_dp.async_set_value(self._device, position)
+        async with self._device.set_lock:
+            _LOGGER.info(
+                "%s setting valve position to %s%%", self._config.config_id, position
+            )
+            await self._valve_dp.async_set_value(self._device, position)

+ 37 - 29
custom_components/tuya_local/water_heater.py

@@ -182,32 +182,37 @@ class TuyaLocalWaterHeater(TuyaLocalEntity, WaterHeaterEntity):
         if kwargs.get(ATTR_TEMPERATURE) is not None:
         if kwargs.get(ATTR_TEMPERATURE) is not None:
             if self._temperature_dps is None:
             if self._temperature_dps is None:
                 raise NotImplementedError()
                 raise NotImplementedError()
-            _LOGGER.info(
-                "%s setting temperature to %s",
-                self._config.config_id,
-                kwargs.get(ATTR_TEMPERATURE),
-            )
-            await self._temperature_dps.async_set_value(
-                self._device, kwargs.get(ATTR_TEMPERATURE)
-            )
+            async with self._device.set_lock:
+                _LOGGER.info(
+                    "%s setting temperature to %s",
+                    self._config.config_id,
+                    kwargs.get(ATTR_TEMPERATURE),
+                )
+                await self._temperature_dps.async_set_value(
+                    self._device, kwargs.get(ATTR_TEMPERATURE)
+                )
 
 
     async def async_set_operation_mode(self, operation_mode):
     async def async_set_operation_mode(self, operation_mode):
         """Set new target operation mode."""
         """Set new target operation mode."""
         if self._operation_mode_dps is None:
         if self._operation_mode_dps is None:
             raise NotImplementedError()
             raise NotImplementedError()
-        _LOGGER.info(
-            "%s setting operation mode to %s", self._config.config_id, operation_mode
-        )
-        await self._operation_mode_dps.async_set_value(
-            self._device,
-            operation_mode,
-        )
+        async with self._device.set_lock:
+            _LOGGER.info(
+                "%s setting operation mode to %s",
+                self._config.config_id,
+                operation_mode,
+            )
+            await self._operation_mode_dps.async_set_value(
+                self._device,
+                operation_mode,
+            )
 
 
     async def async_turn_away_mode_on(self):
     async def async_turn_away_mode_on(self):
         """Turn away mode on"""
         """Turn away mode on"""
         if self._away_mode_dps:
         if self._away_mode_dps:
-            _LOGGER.info("%s turning away mode on", self._config.config_id)
-            await self._away_mode_dps.async_set_value(self._device, True)
+            async with self._device.set_lock:
+                _LOGGER.info("%s turning away mode on", self._config.config_id)
+                await self._away_mode_dps.async_set_value(self._device, True)
         elif self._operation_mode_dps and (
         elif self._operation_mode_dps and (
             "away" in self._operation_mode_dps.values(self._device)
             "away" in self._operation_mode_dps.values(self._device)
         ):
         ):
@@ -222,8 +227,9 @@ class TuyaLocalWaterHeater(TuyaLocalEntity, WaterHeaterEntity):
     async def async_turn_away_mode_off(self):
     async def async_turn_away_mode_off(self):
         """Turn away mode off"""
         """Turn away mode off"""
         if self._away_mode_dps:
         if self._away_mode_dps:
-            _LOGGER.info("%s turning away mode off", self._config.config_id)
-            await self._away_mode_dps.async_set_value(self._device, False)
+            async with self._device.set_lock:
+                _LOGGER.info("%s turning away mode off", self._config.config_id)
+                await self._away_mode_dps.async_set_value(self._device, False)
         elif self._operation_mode_dps and (
         elif self._operation_mode_dps and (
             "away" in self._operation_mode_dps.values(self._device)
             "away" in self._operation_mode_dps.values(self._device)
         ):
         ):
@@ -263,11 +269,12 @@ class TuyaLocalWaterHeater(TuyaLocalEntity, WaterHeaterEntity):
         boolean dp.
         boolean dp.
         """
         """
         if self._operation_mode_dps and self._operation_mode_dps.type is bool:
         if self._operation_mode_dps and self._operation_mode_dps.type is bool:
-            _LOGGER.info("%s turning on", self._config.config_id)
-            await self._device.async_set_property(
-                self._operation_mode_dps.id,
-                True,
-            )
+            async with self._device.set_lock:
+                _LOGGER.info("%s turning on", self._config.config_id)
+                await self._device.async_set_property(
+                    self._operation_mode_dps.id,
+                    True,
+                )
 
 
     async def async_turn_off(self):
     async def async_turn_off(self):
         """
         """
@@ -275,8 +282,9 @@ class TuyaLocalWaterHeater(TuyaLocalEntity, WaterHeaterEntity):
         boolean dp.
         boolean dp.
         """
         """
         if self._operation_mode_dps and self._operation_mode_dps.type is bool:
         if self._operation_mode_dps and self._operation_mode_dps.type is bool:
-            _LOGGER.info("%s turning off", self._config.config_id)
-            await self._device.async_set_property(
-                self._operation_mode_dps.id,
-                False,
-            )
+            async with self._device.set_lock:
+                _LOGGER.info("%s turning off", self._config.config_id)
+                await self._device.async_set_property(
+                    self._operation_mode_dps.id,
+                    False,
+                )