From 20b17b73196df07a8031f757a27c180acc3e47f5 Mon Sep 17 00:00:00 2001
From: PlusPlus-ua <alexplas@gmail.com>
Date: Sun, 30 Apr 2023 14:35:18 +0300
Subject: [PATCH] Update to version 0.1.4

### Added

- Added support of CUBETOUCH 1s, thanks @damiano75
- Added new product_ids for Fingerbot.
- Added new product_ids for Fingerbot Plus.
- First attempt to support Smart Lock device.

### Fixed

- Fixed possible disconnect of BLE device.
---
 CHANGELOG.md                                  |  14 ++
 README.md                                     |   6 +-
 custom_components/tuya_ble/button.py          |   4 +-
 custom_components/tuya_ble/config_flow.py     |   8 +-
 custom_components/tuya_ble/devices.py         |  10 +-
 custom_components/tuya_ble/manifest.json      |   2 +-
 custom_components/tuya_ble/number.py          |  22 ++-
 custom_components/tuya_ble/select.py          |  42 ++--
 custom_components/tuya_ble/sensor.py          |  21 +-
 custom_components/tuya_ble/switch.py          |  46 ++---
 .../tuya_ble/tuya_ble/tuya_ble.py             | 182 ++++++++++--------
 11 files changed, 227 insertions(+), 130 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 293568be..4583396c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,3 +30,17 @@ and this project adheres to [Semantic Versioning].
 ### Changed
 
 - Changed a way to obtain device credentials from Tuya IOT cloud, possible fix to (#2)
+
+## [0.1.4] - 2023-04-30
+
+### Added
+
+- Added support of CUBETOUCH 1s, thanks @damiano75
+- Added new product_ids for Fingerbot.
+- Added new product_ids for Fingerbot Plus.
+- First attempt to support Smart Lock device.
+
+### Fixed
+
+- Fixed possible disconnect of BLE device.
+
diff --git a/README.md b/README.md
index 84b577da..ae8f1d8f 100644
--- a/README.md
+++ b/README.md
@@ -21,9 +21,10 @@ The integration works locally, but connection to Tuya BLE device requires device
 ## Supported devices list
 
 * Fingerbots (category_id 'szjqr')
-  + Fingerbot (product_id 'yrnk7mnn'), original device, first in category, powered by CR2 battery.
+  + Fingerbot (product_ids 'ltak7e1p', 'yrnk7mnn'), original device, first in category, powered by CR2 battery.
   + Adaprox Fingerbot (product_id 'y6kttvd6'), built-in battery with USB type C charging.
   + Fingerbot Plus (product_ids 'blliqpsj', 'yiihr7zh'), almost same as original, has sensor button for manual control.
+  + CubeTouch 1s (product_id '3yqdo5yt'), built-in battery with USB type C charging.
   + CubeTouch II (product_id 'xhf790if'), built-in battery with USB type C charging.
 
   All features available in Home Assistant, except programming (series of actions) - it's not documented and looks useless because it could be implemented by Home Assistant scripts or automations.
@@ -33,3 +34,6 @@ The integration works locally, but connection to Tuya BLE device requires device
 
 * CO2 sensors (category_id 'co2bj')
   + CO2 Detector (product_id '59s19z5m').
+
+* Smart Locks (category_id 'ms')
+  + Smart Lock (product_id 'ludzroix'), first attempt to support for now.
diff --git a/custom_components/tuya_ble/button.py b/custom_components/tuya_ble/button.py
index 9d5074f6..3a67e098 100644
--- a/custom_components/tuya_ble/button.py
+++ b/custom_components/tuya_ble/button.py
@@ -40,7 +40,7 @@ def is_fingerbot_in_push_mode(
     self: TuyaBLEButton,
     product: TuyaBLEProductInfo
 ) -> bool:
-    result: bool = False
+    result: bool = True
     if product.fingerbot:
         datapoint = self._device.datapoints[product.fingerbot.mode]
         if datapoint:
@@ -78,7 +78,7 @@ class TuyaBLECategoryButtonMapping:
                 ],
             ),
             **dict.fromkeys(
-                ["y6kttvd6", "yrnk7mnn"],  # Fingerbot
+                ["ltak7e1p", "y6kttvd6", "yrnk7mnn"],  # Fingerbot
                 [
                     TuyaBLEFingerbotModeMapping(dp_id=2),
                 ],
diff --git a/custom_components/tuya_ble/config_flow.py b/custom_components/tuya_ble/config_flow.py
index c77068aa..b609ef8a 100644
--- a/custom_components/tuya_ble/config_flow.py
+++ b/custom_components/tuya_ble/config_flow.py
@@ -171,16 +171,16 @@ async def async_step_login(
         self, user_input: dict[str, Any] | None = None
     ) -> FlowResult:
         """Handle the Tuya IOT platform login step."""
-        data: dict[str, Any] | None = None
         errors: dict[str, str] = {}
         placeholders: dict[str, Any] = {}
         credentials: TuyaBLEDeviceCredentials | None = None
         address: str | None = self.config_entry.data.get(CONF_ADDRESS)
 
         if user_input is not None:
-            entry: TuyaBLEData = self.hass.data[DOMAIN][
-                self.config_entry.entry_id
-            ]
+            entry: TuyaBLEData | None = None
+            domain_data = self.hass.data.get(DOMAIN)
+            if domain_data:
+                entry = domain_data.get(self.config_entry.entry_id)
             if entry:
                 login_data = await _try_login(
                     entry.manager,
diff --git a/custom_components/tuya_ble/devices.py b/custom_components/tuya_ble/devices.py
index 866d2df1..f20deade 100644
--- a/custom_components/tuya_ble/devices.py
+++ b/custom_components/tuya_ble/devices.py
@@ -161,6 +161,14 @@ class TuyaBLECategoryInfo:
             ),
         },
     ),
+    "ms": TuyaBLECategoryInfo(
+        products={
+            "ludzroix":  # device product_id
+            TuyaBLEProductInfo(
+                name="Smart Lock",
+            ),
+        },
+    ),
     "szjqr": TuyaBLECategoryInfo(
         products={
             "3yqdo5yt":  # device product_id
@@ -202,7 +210,7 @@ class TuyaBLECategoryInfo:
                 )
             ),
             **dict.fromkeys(
-                ["y6kttvd6", "yrnk7mnn"],  # device product_ids
+                ["ltak7e1p", "y6kttvd6", "yrnk7mnn"],  # device product_ids
                 TuyaBLEProductInfo(
                     name="Fingerbot",
                     fingerbot=TuyaBLEFingerbotInfo(
diff --git a/custom_components/tuya_ble/manifest.json b/custom_components/tuya_ble/manifest.json
index 2ec81237..d15b27ff 100644
--- a/custom_components/tuya_ble/manifest.json
+++ b/custom_components/tuya_ble/manifest.json
@@ -13,5 +13,5 @@
   "documentation": "https://www.home-assistant.io/integrations/tuya_ble",
   "requirements": ["tuya-iot-py-sdk==0.6.6", "pycountry"],
   "iot_class": "local_push",
-  "version": "0.1.2"
+  "version": "0.1.4"
 }
diff --git a/custom_components/tuya_ble/number.py b/custom_components/tuya_ble/number.py
index dd20339e..11aec3c1 100644
--- a/custom_components/tuya_ble/number.py
+++ b/custom_components/tuya_ble/number.py
@@ -79,7 +79,7 @@ def is_fingerbot_in_push_mode(
     self: TuyaBLENumber,
     product: TuyaBLEProductInfo
 ) -> bool:
-    result: bool = False
+    result: bool = True
     if product.fingerbot:
         datapoint = self._device.datapoints[product.fingerbot.mode]
         if datapoint:
@@ -143,6 +143,24 @@ class TuyaBLECategoryNumberMapping:
             ],
         },
     ),
+    "ms": TuyaBLECategoryNumberMapping(
+        products={
+            "ludzroix":  # Smart Lock
+            [
+                TuyaBLENumberMapping(
+                    dp_id=8,
+                    description=NumberEntityDescription(
+                        key="residual_electricity",
+                        native_max_value=100,
+                        native_min_value=-1,
+                        native_unit_of_measurement=PERCENTAGE,
+                        native_step=1,
+                        entity_category=EntityCategory.CONFIG,
+                    ),
+                ),
+            ]
+        }
+    ),
     "szjqr": TuyaBLECategoryNumberMapping(
         products={
             **dict.fromkeys(
@@ -178,7 +196,7 @@ class TuyaBLECategoryNumberMapping:
                 ],
             ),
             **dict.fromkeys(
-                ["y6kttvd6", "yrnk7mnn"],  # Fingerbot
+                ["ltak7e1p", "y6kttvd6", "yrnk7mnn"],  # Fingerbot
                 [
                     TuyaBLENumberMapping(
                         dp_id=9,
diff --git a/custom_components/tuya_ble/select.py b/custom_components/tuya_ble/select.py
index c66ec528..fc2bc2b5 100644
--- a/custom_components/tuya_ble/select.py
+++ b/custom_components/tuya_ble/select.py
@@ -74,22 +74,25 @@ class TuyaBLECategorySelectMapping:
             ],
         },
     ),
-    "wsdcg": TuyaBLECategorySelectMapping(
+    "ms": TuyaBLECategorySelectMapping(
         products={
-            "ojzlzzsw":  # Soil moisture sensor
+            "ludzroix":  # Smart Lock
             [
                 TuyaBLESelectMapping(
-                    dp_id=9,
-                    description=TemperatureUnitDescription(
+                    dp_id=31,
+                    description=SelectEntityDescription(
+                        key="beep_volume",
                         options=[
-                            UnitOfTemperature.CELSIUS,
-                            UnitOfTemperature.FAHRENHEIT,
+                            "mute",
+                            "low",
+                            "normal",
+                            "high",
                         ],
-                        entity_registry_enabled_default=False,
-                    )
+                        entity_category=EntityCategory.CONFIG,
+                    ),
                 ),
-            ],
-        },
+            ]
+        }
     ),
     "szjqr": TuyaBLECategorySelectMapping(
         products={
@@ -106,13 +109,30 @@ class TuyaBLECategorySelectMapping:
                 ],
             ),
             **dict.fromkeys(
-                ["y6kttvd6", "yrnk7mnn"],  # Fingerbot
+                ["ltak7e1p", "y6kttvd6", "yrnk7mnn"],  # Fingerbot
                 [
                     TuyaBLEFingerbotModeMapping(dp_id=8),
                 ],
             ),
         },
     ),
+    "wsdcg": TuyaBLECategorySelectMapping(
+        products={
+            "ojzlzzsw":  # Soil moisture sensor
+            [
+                TuyaBLESelectMapping(
+                    dp_id=9,
+                    description=TemperatureUnitDescription(
+                        options=[
+                            UnitOfTemperature.CELSIUS,
+                            UnitOfTemperature.FAHRENHEIT,
+                        ],
+                        entity_registry_enabled_default=False,
+                    )
+                ),
+            ],
+        },
+    ),
 }
 
 
diff --git a/custom_components/tuya_ble/sensor.py b/custom_components/tuya_ble/sensor.py
index 28d0aa6c..9a07df80 100644
--- a/custom_components/tuya_ble/sensor.py
+++ b/custom_components/tuya_ble/sensor.py
@@ -140,6 +140,25 @@ class TuyaBLECategorySensorMapping:
             ]
         }
     ),
+    "ms": TuyaBLECategorySensorMapping(
+        products={
+            "ludzroix":  # Smart Lock
+            [
+                TuyaBLESensorMapping(
+                    dp_id=21,
+                    description=SensorEntityDescription(
+                        key="alarm_lock",
+                        device_class=SensorDeviceClass.ENUM,
+                        options=[
+                            "wrong_finger",
+                            "wrong_password",
+                            "low_battery",
+                        ],
+                    ),
+                ),
+            ]
+        }
+    ),
     "szjqr": TuyaBLECategorySensorMapping(
         products={
             **dict.fromkeys(
@@ -173,7 +192,7 @@ class TuyaBLECategorySensorMapping:
                 ],
             ),
             **dict.fromkeys(
-                ["y6kttvd6", "yrnk7mnn"],  # Fingerbot
+                ["ltak7e1p", "y6kttvd6", "yrnk7mnn"],  # Fingerbot
                 [
                     TuyaBLEBatteryMapping(dp_id=12),
                 ],
diff --git a/custom_components/tuya_ble/switch.py b/custom_components/tuya_ble/switch.py
index 25eaf62b..08aa926c 100644
--- a/custom_components/tuya_ble/switch.py
+++ b/custom_components/tuya_ble/switch.py
@@ -42,7 +42,7 @@ def is_fingerbot_in_switch_mode(
     self: TuyaBLESwitch,
     product: TuyaBLEProductInfo
 ) -> bool:
-    result: bool = False
+    result: bool = True
     if product.fingerbot:
         datapoint = self._device.datapoints[product.fingerbot.mode]
         if datapoint:
@@ -110,32 +110,28 @@ class TuyaBLECategorySwitchMapping:
             ],
         },
     ),
-    "szjqr": TuyaBLECategorySwitchMapping(
+    "ms": TuyaBLECategorySwitchMapping(
         products={
-            "3yqdo5yt":  # CubeTouch 1s
+            "ludzroix":  # Smart Lock
             [
-                TuyaBLEFingerbotSwitchMapping(dp_id=1),
-                TuyaBLEReversePositionsMapping(dp_id=4),
-                    TuyaBLESwitchMapping(
-                        dp_id=9,
-                        description=SwitchEntityDescription(
-                            key="power_saving",
-                            entity_category=EntityCategory.CONFIG,
-                        ),
-                    ),
-                    TuyaBLESwitchMapping(
-                        dp_id=10,
-                        description=SwitchEntityDescription(
-                            key="tap_enable",
-                            entity_category=EntityCategory.CONFIG,
-                        ),
+                TuyaBLESwitchMapping(
+                    dp_id=47,
+                    description=SwitchEntityDescription(
+                        key="lock_motor_state",
                     ),
-            ],
-            "xhf790if":  # CubeTouch II
-            [
-                TuyaBLEFingerbotSwitchMapping(dp_id=1),
-                TuyaBLEReversePositionsMapping(dp_id=4),
-            ],
+                ),
+            ]
+        }
+    ),
+    "szjqr": TuyaBLECategorySwitchMapping(
+        products={
+            **dict.fromkeys(
+                ["3yqdo5yt", "xhf790if"],  # CubeTouch 1s and II
+                [
+                    TuyaBLEFingerbotSwitchMapping(dp_id=1),
+                    TuyaBLEReversePositionsMapping(dp_id=4),
+                ],
+            ),
             **dict.fromkeys(
                 ["blliqpsj", "yiihr7zh"],  # Fingerbot Plus
                 [
@@ -152,7 +148,7 @@ class TuyaBLECategorySwitchMapping:
                 ],
             ),
             **dict.fromkeys(
-                ["y6kttvd6", "yrnk7mnn"],  # Fingerbot
+                ["ltak7e1p", "y6kttvd6", "yrnk7mnn"],  # Fingerbot
                 [
                     TuyaBLEFingerbotSwitchMapping(dp_id=2),
                     TuyaBLEReversePositionsMapping(dp_id=11),
diff --git a/custom_components/tuya_ble/tuya_ble/tuya_ble.py b/custom_components/tuya_ble/tuya_ble/tuya_ble.py
index 0df48794..e389d8a9 100644
--- a/custom_components/tuya_ble/tuya_ble/tuya_ble.py
+++ b/custom_components/tuya_ble/tuya_ble/tuya_ble.py
@@ -14,12 +14,10 @@
 from bleak_retry_connector import BLEAK_BACKOFF_TIME
 from bleak_retry_connector import BLEAK_RETRY_EXCEPTIONS
 from bleak_retry_connector import (
-    DEFAULT_ATTEMPTS,
     BleakClientWithServiceCache,
     BleakError,
     BleakNotFoundError,
-    establish_connection,
-    retry_bluetooth_connection_error,
+    establish_connection
 )
 from Crypto.Cipher import AES
 
@@ -209,6 +207,8 @@ async def _update_from_user(self, dp_id: int) -> None:
             await self._owner._send_datapoints([dp_id])
 
 
+global_connect_lock = asyncio.Lock()
+
 class TuyaBLEDevice:
     def __init__(
         self,
@@ -561,6 +561,7 @@ async def _execute_disconnect(self) -> None:
 
     async def _ensure_connected(self) -> None:
         """Ensure connection to device is established."""
+        global global_connect_lock
         if self._connect_lock.locked():
             _LOGGER.debug(
                 "%s: Connection already in progress,"
@@ -585,17 +586,18 @@ async def _ensure_connected(self) -> None:
                         self.rssi
                     )
                     raise BleakNotFoundError()
-                _LOGGER.debug("%s: Connecting; RSSI: %s",
-                              self.address, self.rssi)
                 try:
-                    client = await establish_connection(
-                        BleakClientWithServiceCache,
-                        self._ble_device,
-                        self.address,
-                        self._disconnected,
-                        use_services_cache=True,
-                        ble_device_callback=lambda: self._ble_device,
-                    )
+                    async with global_connect_lock:
+                        _LOGGER.debug("%s: Connecting; RSSI: %s",
+                                    self.address, self.rssi)
+                        client = await establish_connection(
+                            BleakClientWithServiceCache,
+                            self._ble_device,
+                            self.address,
+                            self._disconnected,
+                            use_services_cache=True,
+                            ble_device_callback=lambda: self._ble_device,
+                        )
                 except BleakNotFoundError:
                     _LOGGER.error(
                         "%s: device not found, not in range, or poor RSSI: %s",
@@ -711,7 +713,7 @@ async def _ensure_connected(self) -> None:
                 self.address
             )
 
-    async def _reconnect(self, use_delay: bool = True) -> None:
+    async def _reconnect(self) -> None:
         """Attempt a reconnect"""
         _LOGGER.debug("%s: Reconnect, ensuring connection", self.address)
         async with self._seq_num_lock:
@@ -727,7 +729,7 @@ async def _reconnect(self, use_delay: bool = True) -> None:
             )
             await asyncio.sleep(BLEAK_BACKOFF_TIME)
             _LOGGER.debug("%s: Reconnecting again", self.address)
-            asyncio.create_task(self._reconnect(False))
+            asyncio.create_task(self._reconnect())
 
     @staticmethod
     def _calc_crc16(data: bytes) -> int:
@@ -824,34 +826,6 @@ def _build_packets(
 
         return command
 
-    # @retry_bluetooth_connection_error(DEFAULT_ATTEMPTS)
-    async def _send_packets_locked(self, packets: list[bytes]) -> None:
-        """Send command to device and read response."""
-        try:
-            await self._int_send_packets_locked(packets)
-        except BleakDBusError as ex:
-            # Disconnect so we can reset state and try again
-            await asyncio.sleep(BLEAK_BACKOFF_TIME)
-            _LOGGER.debug(
-                "%s: RSSI: %s; Backing off %ss; " "Disconnecting due to error: %s",
-                self.address,
-                self.rssi,
-                BLEAK_BACKOFF_TIME,
-                ex,
-            )
-            await self._execute_disconnect()
-            raise BleakError from ex
-        except BleakError as ex:
-            # Disconnect so we can reset state and try again
-            _LOGGER.debug(
-                "%s: RSSI: %s; Disconnecting due to error: %s",
-                self.address,
-                self.rssi,
-                ex,
-            )
-            await self._execute_disconnect()
-            raise
-
     async def _get_seq_num(self) -> int:
         async with self._seq_num_lock:
             result = self._current_seq_num
@@ -876,8 +850,8 @@ async def _send_response(
         response_to: int,
     ) -> None:
         """Send response to received packet."""
-        await self._ensure_connected()
-        await self._send_packet_while_connected(code, data, response_to, False)
+        if self._client and self._client.is_connected:
+            await self._send_packet_while_connected(code, data, response_to, False)
 
     async def _send_packet_while_connected(
         self,
@@ -889,6 +863,48 @@ async def _send_packet_while_connected(
     ) -> bool:
         """Send packet to device and optional read response."""
         result = True
+        future: asyncio.Future | None = None
+        seq_num = await self._get_seq_num()
+        if wait_for_response:
+            future = asyncio.Future()
+            self._input_expected_responses[seq_num] = future
+
+        if response_to > 0:
+            _LOGGER.debug(
+                "%s: Sending packet: #%s %s in response to #%s",
+                self.address,
+                seq_num,
+                code.name,
+                response_to,
+            )
+        else:
+            _LOGGER.debug(
+                "%s: Sending packet: #%s %s",
+                self.address,
+                seq_num,
+                code.name,
+            )
+        packets: list[bytes] = self._build_packets(
+            seq_num, code, data, response_to
+        )
+        await self._int_send_packet_while_connected(packets)
+        if future:
+            try:
+                await asyncio.wait_for(future, RESPONSE_WAIT_TIMEOUT)
+            except asyncio.TimeoutError:
+                _LOGGER.error(
+                    "%s: timeout receiving response, RSSI: %s",
+                    self.address,
+                    self.rssi,
+                )
+                result = False
+            self._input_expected_responses.pop(seq_num, None)
+
+        return result
+
+    async def _int_send_packet_while_connected(self,
+        packets: list[bytes],
+    ) -> None:
         if self._operation_lock.locked():
             _LOGGER.debug(
                 "%s: Operation already in progress, "
@@ -898,45 +914,10 @@ async def _send_packet_while_connected(
             )
         async with self._operation_lock:
             try:
-                future: asyncio.Future | None = None
-                seq_num = await self._get_seq_num()
-                if wait_for_response:
-                    future = asyncio.Future()
-                    self._input_expected_responses[seq_num] = future
-
-                if response_to > 0:
-                    _LOGGER.debug(
-                        "%s: Sending packet: #%s %s in response to #%s",
-                        self.address,
-                        seq_num,
-                        code.name,
-                        response_to,
-                    )
-                else:
-                    _LOGGER.debug(
-                        "%s: Sending packet: #%s %s",
-                        self.address,
-                        seq_num,
-                        code.name,
-                    )
-                packets: list[bytes] = self._build_packets(
-                    seq_num, code, data, response_to
-                )
                 await self._send_packets_locked(packets)
-                if future:
-                    try:
-                        await asyncio.wait_for(future, RESPONSE_WAIT_TIMEOUT)
-                    except asyncio.TimeoutError:
-                        _LOGGER.error(
-                            "%s: timeout receiving response, RSSI: %s",
-                            self.address,
-                            self.rssi,
-                        )
-                        result = False
-                    self._input_expected_responses.pop(seq_num, None)
             except BleakNotFoundError:
                 _LOGGER.error(
-                    "%s: device not found, no longer in range, " "or poor RSSI: %s",
+                    "%s: device not found, no longer in range, or poor RSSI: %s",
                     self.address,
                     self.rssi,
                     exc_info=True,
@@ -950,7 +931,44 @@ async def _send_packet_while_connected(
                 )
                 raise
 
-            return result
+    async def _resend_packets(self, packets: list[bytes]) -> None:
+        await self._ensure_connected()
+        await self._int_send_packet_while_connected(packets)
+
+    async def _send_packets_locked(self, packets: list[bytes]) -> None:
+        """Send command to device and read response."""
+        try:
+            await self._int_send_packets_locked(packets)
+        except BleakDBusError as ex:
+            # Disconnect so we can reset state and try again
+            await asyncio.sleep(BLEAK_BACKOFF_TIME)
+            _LOGGER.debug(
+                "%s: RSSI: %s; Backing off %ss; Disconnecting due to error: %s",
+                self.address,
+                self.rssi,
+                BLEAK_BACKOFF_TIME,
+                ex,
+            )
+            if self._is_paired:
+                asyncio.create_task(self._resend_packets(packets))
+            else:
+                asyncio.create_task(self._reconnect())
+            #await self._execute_disconnect()
+            raise BleakError from ex
+        except BleakError as ex:
+            # Disconnect so we can reset state and try again
+            _LOGGER.debug(
+                "%s: RSSI: %s; Disconnecting due to error: %s",
+                self.address,
+                self.rssi,
+                ex,
+            )
+            if self._is_paired:
+                asyncio.create_task(self._resend_packets(packets))
+            else:
+                asyncio.create_task(self._reconnect())
+            #await self._execute_disconnect()
+            raise
 
     async def _int_send_packets_locked(self, packets: list[bytes]) -> None:
         """Execute command and read response."""