diff --git a/custom_components/tuya_local_ble/const.py b/custom_components/tuya_local_ble/const.py index 1bebccd..2b13c6e 100644 --- a/custom_components/tuya_local_ble/const.py +++ b/custom_components/tuya_local_ble/const.py @@ -18,6 +18,8 @@ CONF_DEVICE_NAME: Final = "device_name" CONF_PRODUCT_MODEL: Final = "product_model" CONF_PRODUCT_NAME: Final = "product_name" CONF_BLE_UNLOCK_CHECK: Final = "ble_unlock_check" +CONF_LOCK_CYCLE_SECONDS: Final = "lock_cycle_seconds" +CONF_UNLOCK_CYCLE_SECONDS: Final = "unlock_cycle_seconds" CONF_CRED_FILE = DOMAIN + "/devices.json" diff --git a/custom_components/tuya_local_ble/keyman.py b/custom_components/tuya_local_ble/keyman.py index e80d7e6..ea2d4ef 100644 --- a/custom_components/tuya_local_ble/keyman.py +++ b/custom_components/tuya_local_ble/keyman.py @@ -26,6 +26,8 @@ from .const import ( CONF_DEVICE_NAME, CONF_PRODUCT_NAME, CONF_BLE_UNLOCK_CHECK, + CONF_LOCK_CYCLE_SECONDS, + CONF_UNLOCK_CYCLE_SECONDS, DOMAIN, ) @@ -41,6 +43,8 @@ CONF_TUYA_DEVICE_KEYS = [ CONF_PRODUCT_NAME, CONF_PRODUCT_MODEL, CONF_BLE_UNLOCK_CHECK, + CONF_LOCK_CYCLE_SECONDS, + CONF_UNLOCK_CYCLE_SECONDS, ] class HASSTuyaBLEDeviceManager(AbstaractTuyaBLEDeviceManager): @@ -90,6 +94,8 @@ class HASSTuyaBLEDeviceManager(AbstaractTuyaBLEDeviceManager): credentials.get(CONF_PRODUCT_MODEL, ""), credentials.get(CONF_PRODUCT_NAME, ""), credentials.get(CONF_BLE_UNLOCK_CHECK, ""), + credentials.get(CONF_LOCK_CYCLE_SECONDS), + credentials.get(CONF_UNLOCK_CYCLE_SECONDS), ) _LOGGER.debug("Retrieved: %s", result) diff --git a/custom_components/tuya_local_ble/lock.py b/custom_components/tuya_local_ble/lock.py index 1f91275..89ab339 100644 --- a/custom_components/tuya_local_ble/lock.py +++ b/custom_components/tuya_local_ble/lock.py @@ -17,6 +17,7 @@ from homeassistant.components.lock import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.const import ( @@ -45,6 +46,12 @@ class TuyaBLELockMapping: keep_connect: bool = False dp_type: TuyaBLEDataPointType | None = None is_available: TuyaBLELockIsAvailable = None + # How long the motor takes to finish a full lock/unlock cycle. Commands + # sent mid-cycle are acknowledged (beep) but not executed by the lock, so + # the entity rejects them until the cycle completes. Can be overridden + # per device in devices.json (lock_cycle_seconds / unlock_cycle_seconds). + lock_cycle_seconds: float = 8.0 + unlock_cycle_seconds: float = 8.0 @dataclass class TuyaBLELockMapping(TuyaBLELockMapping): @@ -135,6 +142,18 @@ class TuyaBLELock(TuyaBLEEntity, LockEntity): self._commanded_timer = None self._datapoint_nop = None self._isjammed = False + self._cycle_active = False + self._cycle_cancel = None + device_lock_cycle = device.lock_cycle_seconds + device_unlock_cycle = device.unlock_cycle_seconds + self._lock_cycle_seconds = ( + float(device_lock_cycle) if device_lock_cycle + else mapping.lock_cycle_seconds + ) + self._unlock_cycle_seconds = ( + float(device_unlock_cycle) if device_unlock_cycle + else mapping.unlock_cycle_seconds + ) self._update_attrs() if mapping.keep_connect: self._thread = Timer(self._mapping.keep_connect_timer, self.send_nop_request) @@ -162,18 +181,18 @@ class TuyaBLELock(TuyaBLEEntity, LockEntity): def is_locking(self) -> bool: """Return true if device is locking.""" return ( - self._current_state == LockState.UNLOCKED + self._commanded and self._target_state == LockState.LOCKED - and self._commanded + and self._current_state != LockState.LOCKED ) @property def is_unlocking(self) -> bool: """Return true if device is unlocking.""" return ( - self._current_state == LockState.LOCKED + self._commanded and self._target_state == LockState.UNLOCKED - and self._commanded + and self._current_state != LockState.UNLOCKED ) @property @@ -202,6 +221,16 @@ class TuyaBLELock(TuyaBLEEntity, LockEntity): await self._set_lock_state(LockState.UNLOCKED) async def _set_lock_state(self, state: str) -> None: + if self._cycle_active: + # The lock beeps on commands received mid-cycle but does not + # execute them, while HA would flip its state — reject instead. + _LOGGER.debug( + "%s: lock cycle in progress, ignoring %s command", + self._device.address, + state, + ) + return + self._target_state = state self._update_attrs() self.async_write_ha_state() @@ -217,22 +246,30 @@ class TuyaBLELock(TuyaBLEEntity, LockEntity): False, ) - if self._device.product_id == "hc7n0urm" and self._target_state == LockState.UNLOCKED: - await datapoint.set_value(True) - self._current_state = LockState.UNLOCKED - self._commanded = False - self._isjammed = False + if self._device.product_id == "hc7n0urm": + if self._target_state == LockState.UNLOCKED: + cycle_seconds = self._unlock_cycle_seconds + else: + cycle_seconds = self._lock_cycle_seconds + self._cycle_active = True + self._commanded = True + self._commanded_timer = datetime.now() self._update_attrs() self.async_write_ha_state() - return - - if self._device.product_id == "hc7n0urm" and self._target_state == LockState.LOCKED: - await datapoint.set_value(True) - self._current_state = LockState.LOCKED - self._commanded = False + try: + await datapoint.set_value(True) + except Exception: + self._cycle_active = False + self._commanded = False + self._update_attrs() + self.async_write_ha_state() + raise self._isjammed = False - self._update_attrs() - self.async_write_ha_state() + # The device ACK only confirms the command was accepted; the motor + # is still turning, so commit the final state after the cycle time. + self._cycle_cancel = async_call_later( + self._hass, cycle_seconds, self._finish_cycle + ) return #Gimdow need true to activate lock/unlock commands @@ -240,6 +277,24 @@ class TuyaBLELock(TuyaBLEEntity, LockEntity): self._commanded = True self._commanded_timer = datetime.now() + @callback + def _finish_cycle(self, _now) -> None: + """Commit the target state once the motor cycle is over.""" + self._cycle_cancel = None + self._cycle_active = False + if self._commanded and self._target_state is not None: + self._current_state = self._target_state + self._commanded = False + self._update_attrs() + self.async_write_ha_state() + + async def async_will_remove_from_hass(self) -> None: + """Cancel a pending cycle timer on removal.""" + if self._cycle_cancel is not None: + self._cycle_cancel() + self._cycle_cancel = None + await super().async_will_remove_from_hass() + def update_device_state(self): datapoint = self._device.datapoints[self._mapping.dp_id] diff --git a/custom_components/tuya_local_ble/tuya_ble/manager.py b/custom_components/tuya_local_ble/tuya_ble/manager.py index f789a53..ba4b12b 100644 --- a/custom_components/tuya_local_ble/tuya_ble/manager.py +++ b/custom_components/tuya_local_ble/tuya_ble/manager.py @@ -15,6 +15,8 @@ class TuyaBLEDeviceCredentials: product_model: str | None product_name: str | None ble_unlock_check: str | None = None + lock_cycle_seconds: float | None = None + unlock_cycle_seconds: float | None = None def __str__(self): return ( @@ -26,7 +28,9 @@ class TuyaBLEDeviceCredentials: "device_name: %s, " "product_model: %s, " "product_name: %s, " - "ble_unlock_check: %s" + "ble_unlock_check: %s, " + "lock_cycle_seconds: %s, " + "unlock_cycle_seconds: %s" ) % ( self.category, self.product_id, @@ -34,6 +38,8 @@ class TuyaBLEDeviceCredentials: self.product_model, self.product_name, "set" if self.ble_unlock_check else "not set", + self.lock_cycle_seconds, + self.unlock_cycle_seconds, ) class AbstaractTuyaBLEDeviceManager(ABC): diff --git a/custom_components/tuya_local_ble/tuya_ble/tuya_ble.py b/custom_components/tuya_local_ble/tuya_ble/tuya_ble.py index 73049eb..55bd644 100644 --- a/custom_components/tuya_local_ble/tuya_ble/tuya_ble.py +++ b/custom_components/tuya_local_ble/tuya_ble/tuya_ble.py @@ -416,6 +416,18 @@ class TuyaBLEDevice: else: return "" + @property + def lock_cycle_seconds(self) -> float | None: + if self._device_info is not None: + return self._device_info.lock_cycle_seconds + return None + + @property + def unlock_cycle_seconds(self) -> float | None: + if self._device_info is not None: + return self._device_info.unlock_cycle_seconds + return None + @property def device_version(self) -> str: return self._device_version