254 lines
9.9 KiB
Diff
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
|