Files
Tuya-BLE/lock-cycle-transitions.patch
T

254 lines
9.9 KiB
Diff

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