From 20b17b73196df07a8031f757a27c180acc3e47f5 Mon Sep 17 00:00:00 2001 From: PlusPlus-ua 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."""