diff --git a/lock-cycle-transitions.patch b/lock-cycle-transitions.patch new file mode 100644 index 0000000..d9f037e --- /dev/null +++ b/lock-cycle-transitions.patch @@ -0,0 +1,253 @@ +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