lock.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. """
  2. Setup for different kinds of Tuya lock devices
  3. """
  4. import logging
  5. from base64 import b64encode
  6. from secrets import randbelow
  7. from time import time
  8. from homeassistant.components.lock import LockEntity, LockEntityFeature
  9. from .device import TuyaLocalDevice
  10. from .entity import TuyaLocalEntity
  11. from .helpers.config import async_tuya_setup_platform
  12. from .helpers.device_config import TuyaEntityConfig
  13. _LOGGER = logging.getLogger(__name__)
  14. # Remote Code unlocking protocol: 8 digit code set when paired by
  15. # remote_no_pd_setkey (typically dp 60), user can obtain by
  16. # eavesdropping cloud unlock messages.
  17. # Format in case this command can be supported outside of pairing process:
  18. # Request: Validity (1 byte 0 or 1), Member ID (2 bytes),
  19. # Start time (4 byte unixtime), End time (4 byte unixtime),
  20. # Usable times (2 bytes, 0=infinite), Key (8 bytes ASCII)
  21. # Same 8 digit ASCII code used along with binary member ID to generate
  22. # unlock command with remote_no_dp_key (typically dp 61)
  23. # Locking usually possible without code, but remote_no_dp_key seems
  24. # to also support locking with code.
  25. # Request: action (1 byte), Member (2 bytes 0-100), code (8 bytes ASCII),
  26. # source (2 bytes)
  27. CODE_LOCK = 0x00
  28. CODE_UNLOCK = 0x01
  29. CODE_SRC_UNKNOWN = 0x0000
  30. CODE_SRC_APP = 0x0001
  31. CODE_SRC_VOICE = 0x0002
  32. # Reply: status (1 byte), Member ID (2 bytes 1 - 100)
  33. CODE_REPLY_SUCCESS = 0x00
  34. CODE_REPLY_FAIL = 0x01
  35. CODE_REPLY_PWD_ERROR = 0x02
  36. CODE_REPLY_TIMEOUT = 0x03
  37. CODE_REPLY_OUTOFHOURS = 0x04
  38. CODE_REPLY_WRONGCODE = 0x05
  39. CODE_REPLY_DOUBLELOCKED = 0x06
  40. async def async_setup_entry(hass, config_entry, async_add_entities):
  41. config = {**config_entry.data, **config_entry.options}
  42. await async_tuya_setup_platform(
  43. hass,
  44. async_add_entities,
  45. config,
  46. "lock",
  47. TuyaLocalLock,
  48. )
  49. class TuyaLocalLock(TuyaLocalEntity, LockEntity):
  50. """Representation of a Tuya Wi-Fi connected lock."""
  51. def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
  52. """
  53. Initialise the lock.
  54. Args:
  55. device (TuyaLocalDevice): The device API instance.
  56. config (TuyaEntityConfig): The configuration for this entity.
  57. """
  58. super().__init__()
  59. dps_map = self._init_begin(device, config)
  60. self._lock_dp = dps_map.pop("lock", None)
  61. self._lock_state_dp = dps_map.pop("lock_state", None)
  62. self._open_dp = dps_map.pop("open", None)
  63. self._unlock_fp_dp = dps_map.pop("unlock_fingerprint", None)
  64. self._unlock_pw_dp = dps_map.pop("unlock_password", None)
  65. self._unlock_tmppw_dp = dps_map.pop("unlock_temp_pwd", None)
  66. self._unlock_dynpw_dp = dps_map.pop("unlock_dynamic_pwd", None)
  67. self._unlock_offlinepw_dp = dps_map.pop("unlock_offline_pwd", None)
  68. self._unlock_card_dp = dps_map.pop("unlock_card", None)
  69. self._unlock_app_dp = dps_map.pop("unlock_app", None)
  70. self._unlock_key_dp = dps_map.pop("unlock_key", None)
  71. self._unlock_ble_dp = dps_map.pop("unlock_ble", None)
  72. self._unlock_voice_dp = dps_map.pop("unlock_voice", None)
  73. self._unlock_face_dp = dps_map.pop("unlock_face", None)
  74. self._unlock_multi_dp = dps_map.pop("unlock_multi", None)
  75. self._unlock_ibeacon_dp = dps_map.pop("unlock_ibeacon", None)
  76. self._req_unlock_dp = dps_map.pop("request_unlock", None)
  77. self._approve_unlock_dp = dps_map.pop("approve_unlock", None)
  78. self._code_unlock_dp = dps_map.pop("code_unlock", None)
  79. self._set_code_dp = dps_map.pop("set_unlock_code", None)
  80. self._req_intercom_dp = dps_map.pop("request_intercom", None)
  81. self._approve_intercom_dp = dps_map.pop("approve_intercom", None)
  82. self._jam_dp = dps_map.pop("jammed", None)
  83. self._init_end(dps_map)
  84. if self._open_dp and not self._open_dp.readonly:
  85. self._attr_supported_features = LockEntityFeature.OPEN
  86. @property
  87. def is_locked(self):
  88. """Return the a boolean representing whether the lock is locked."""
  89. lock = None
  90. if self._lock_state_dp:
  91. lock = self._lock_state_dp.get_value(self._device)
  92. if lock is None and self._lock_dp:
  93. lock = self._lock_dp.get_value(self._device)
  94. if lock is None:
  95. for d in (
  96. self._unlock_card_dp,
  97. self._unlock_dynpw_dp,
  98. self._unlock_fp_dp,
  99. self._unlock_offlinepw_dp,
  100. self._unlock_pw_dp,
  101. self._unlock_tmppw_dp,
  102. self._unlock_app_dp,
  103. self._unlock_key_dp,
  104. self._unlock_ble_dp,
  105. self._unlock_voice_dp,
  106. self._unlock_face_dp,
  107. self._unlock_multi_dp,
  108. self._unlock_ibeacon_dp,
  109. ):
  110. if d:
  111. if d.get_value(self._device):
  112. lock = False
  113. elif lock is None:
  114. lock = True
  115. return lock
  116. @property
  117. def is_open(self):
  118. if self._open_dp:
  119. return self._open_dp.get_value(self._device)
  120. @property
  121. def is_jammed(self):
  122. if self._jam_dp:
  123. return self._jam_dp.get_value(self._device)
  124. @property
  125. def code_format(self):
  126. """Return the code format of the lock."""
  127. if self._code_unlock_dp and not self._set_code_dp:
  128. return r".{8}"
  129. return None
  130. def unlocker_id(self, dp, how):
  131. if dp:
  132. unlock = dp.get_value(self._device)
  133. if unlock:
  134. if unlock is True:
  135. return f"{how}"
  136. else:
  137. return f"{how} #{unlock}"
  138. @property
  139. def changed_by(self):
  140. for dp, desc in {
  141. self._unlock_app_dp: "App",
  142. self._unlock_ble_dp: "Bluetooth",
  143. self._unlock_card_dp: "Card",
  144. self._unlock_dynpw_dp: "Dynamic Password",
  145. self._unlock_fp_dp: "Finger",
  146. self._unlock_key_dp: "Key",
  147. self._unlock_offlinepw_dp: "Offline Password",
  148. self._unlock_pw_dp: "Password",
  149. self._unlock_tmppw_dp: "Temporary Password",
  150. self._unlock_voice_dp: "Voice",
  151. self._unlock_face_dp: "Face",
  152. self._unlock_multi_dp: "Multifactor",
  153. self._unlock_ibeacon_dp: "iBeacon",
  154. }.items():
  155. by = self.unlocker_id(dp, desc)
  156. if by:
  157. # clear non-persistent dps immediately on reporting, instead
  158. # of waiting for the next poll, to make the lock more responsive
  159. # to multiple attempts
  160. if not dp.persist:
  161. self._device._cached_state.pop(dp.id, None)
  162. return by
  163. async def async_lock(self, **kwargs):
  164. """Lock the lock."""
  165. if self._lock_dp and not self._lock_dp.readonly:
  166. _LOGGER.info("%s locking", self._config.config_id)
  167. await self._lock_dp.async_set_value(self._device, True)
  168. elif self._code_unlock_dp and self._set_code_dp:
  169. code = sprintf("%08d", randbelow(100000000))
  170. setting = self.build_code_set_msg(code)
  171. msg = self.build_code_unlock_msg(
  172. CODE_LOCK, member_id=7, code=code, source=CODE_SRC_UNKNOWN
  173. )
  174. _LOGGER.info("%s locking with random code", self._config.config_id)
  175. await self._device.async_set_properties(
  176. {
  177. self._set_code_dp.id: setting,
  178. self._code_unlock_dp.id: msg,
  179. }
  180. )
  181. elif self._code_unlock_dp:
  182. code = kwargs.get("code")
  183. if not code:
  184. raise ValueError("Code required to lock")
  185. msg = self.build_code_unlock_msg(
  186. CODE_LOCK, member_id=1, code=code, source=CODE_SRC_UNKNOWN
  187. )
  188. _LOGGER.info("%s locking with code", self._config.config_id)
  189. await self._code_unlock_dp.async_set_value(self._device, msg)
  190. else:
  191. raise NotImplementedError()
  192. async def async_unlock(self, **kwargs):
  193. """Unlock the lock."""
  194. if self._lock_dp and not self._lock_dp.readonly:
  195. _LOGGER.info("%s unlocking", self._config.config_id)
  196. await self._lock_dp.async_set_value(self._device, False)
  197. elif self._code_unlock_dp and self._set_code_dp:
  198. code = sprintf("%08d", randbelow(100000000))
  199. setting = self.build_code_set_msg(code)
  200. msg = self.build_code_unlock_msg(
  201. CODE_UNLOCK, member_id=7, code=code, source=CODE_SRC_UNKNOWN
  202. )
  203. _LOGGER.info("%s locking with random code", self._config.config_id)
  204. await self._device.async_set_properties(
  205. {
  206. self._set_code_dp.id: setting,
  207. self._code_unlock_dp.id: msg,
  208. }
  209. )
  210. elif self._code_unlock_dp:
  211. code = kwargs.get("code")
  212. if not code:
  213. raise ValueError("Code required to unlock")
  214. msg = self.build_code_unlock_msg(
  215. CODE_UNLOCK, member_id=1, code=code, source=CODE_SRC_UNKNOWN
  216. )
  217. _LOGGER.info("%s unlocking with code", self._config.config_id)
  218. await self._code_unlock_dp.async_set_value(self._device, msg)
  219. elif self._approve_unlock_dp:
  220. if self._req_unlock_dp and not self._req_unlock_dp.get_value(self._device):
  221. raise TimeoutError()
  222. _LOGGER.info("%s approving unlock", self._config.config_id)
  223. await self._approve_unlock_dp.async_set_value(self._device, True)
  224. elif self._approve_intercom_dp:
  225. if self._req_intercom_dp and not self._req_intercom_dp.get_value(
  226. self._device
  227. ):
  228. raise TimeoutError()
  229. _LOGGER.info("%s approving intercom unlock", self._config.config_id)
  230. await self._approve_intercom_dp.async_set_value(self._device, True)
  231. else:
  232. raise NotImplementedError()
  233. async def async_open(self, **kwargs):
  234. """Open the door latch."""
  235. if self._open_dp:
  236. _LOGGER.info("%s opening", self._config.config_id)
  237. await self._open_dp.async_set_value(self._device, True)
  238. def build_code_unlock_msg(self, action, member_id, code, source=CODE_SRC_UNKNOWN):
  239. """Generate the unlock code message."""
  240. if len(code) != 8 or not code.isascii():
  241. raise ValueError("Code must be 8 ASCII characters")
  242. msg = bytearray()
  243. msg.append(action)
  244. msg += member_id.to_bytes(2, "big")
  245. msg += code.encode("ascii")
  246. msg += source.to_bytes(2, "big")
  247. # msg += b"\x00" # ordinary user (0x01 is admin)
  248. return b64encode(msg).decode("utf-8")
  249. def build_code_set_msg(self, code):
  250. """Generate the set code message."""
  251. if len(code) != 8 or not code.isascii():
  252. raise ValueError("Code must be 8 ASCII characters")
  253. validity = int(time())
  254. msg = bytearray()
  255. msg += (7).to_bytes(3, "big") # valid + member ID
  256. # start and end times. 5 minute allowance each way for clock drift
  257. msg += (validity - 300).to_bytes(4, "big")
  258. msg += (validity + 300).to_bytes(4, "big")
  259. msg += (1).to_bytes(2, "big") # usable times
  260. msg += code.encode("ascii")
  261. return b64encode(msg).decode("utf-8")