device.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. """Library to handle connection with Switchbot."""
  2. from __future__ import annotations
  3. import asyncio
  4. import binascii
  5. import logging
  6. from typing import Any
  7. from uuid import UUID
  8. import async_timeout
  9. from bleak import BleakError
  10. from bleak.backends.device import BLEDevice
  11. from bleak.backends.service import BleakGATTCharacteristic, BleakGATTServiceCollection
  12. from bleak_retry_connector import (
  13. BleakClientWithServiceCache,
  14. BleakNotFoundError,
  15. ble_device_has_changed,
  16. establish_connection,
  17. )
  18. from ..const import DEFAULT_RETRY_COUNT, DEFAULT_SCAN_TIMEOUT
  19. from ..discovery import GetSwitchbotDevices
  20. from ..models import SwitchBotAdvertisement
  21. _LOGGER = logging.getLogger(__name__)
  22. # Keys common to all device types
  23. DEVICE_GET_BASIC_SETTINGS_KEY = "5702"
  24. DEVICE_SET_MODE_KEY = "5703"
  25. DEVICE_SET_EXTENDED_KEY = "570f"
  26. # Base key when encryption is set
  27. KEY_PASSWORD_PREFIX = "571"
  28. BLEAK_EXCEPTIONS = (AttributeError, BleakError, asyncio.exceptions.TimeoutError)
  29. # How long to hold the connection
  30. # to wait for additional commands for
  31. # disconnecting the device.
  32. DISCONNECT_DELAY = 49
  33. def _sb_uuid(comms_type: str = "service") -> UUID | str:
  34. """Return Switchbot UUID."""
  35. _uuid = {"tx": "002", "rx": "003", "service": "d00"}
  36. if comms_type in _uuid:
  37. return UUID(f"cba20{_uuid[comms_type]}-224d-11e6-9fb8-0002a5d5c51b")
  38. return "Incorrect type, choose between: tx, rx or service"
  39. class SwitchbotDevice:
  40. """Base Representation of a Switchbot Device."""
  41. def __init__(
  42. self,
  43. device: BLEDevice,
  44. password: str | None = None,
  45. interface: int = 0,
  46. **kwargs: Any,
  47. ) -> None:
  48. """Switchbot base class constructor."""
  49. self._interface = f"hci{interface}"
  50. self._device = device
  51. self._sb_adv_data: SwitchBotAdvertisement | None = None
  52. self._scan_timeout: int = kwargs.pop("scan_timeout", DEFAULT_SCAN_TIMEOUT)
  53. self._retry_count: int = kwargs.pop("retry_count", DEFAULT_RETRY_COUNT)
  54. self._connect_lock = asyncio.Lock()
  55. self._operation_lock = asyncio.Lock()
  56. if password is None or password == "":
  57. self._password_encoded = None
  58. else:
  59. self._password_encoded = "%08x" % (
  60. binascii.crc32(password.encode("ascii")) & 0xFFFFFFFF
  61. )
  62. self._client: BleakClientWithServiceCache | None = None
  63. self._cached_services: BleakGATTServiceCollection | None = None
  64. self._read_char: BleakGATTCharacteristic | None = None
  65. self._write_char: BleakGATTCharacteristic | None = None
  66. self._disconnect_timer: asyncio.TimerHandle | None = None
  67. self._expected_disconnect = False
  68. self.loop = asyncio.get_event_loop()
  69. def _commandkey(self, key: str) -> str:
  70. """Add password to key if set."""
  71. if self._password_encoded is None:
  72. return key
  73. key_action = key[3]
  74. key_suffix = key[4:]
  75. return KEY_PASSWORD_PREFIX + key_action + self._password_encoded + key_suffix
  76. async def _sendcommand(self, key: str, retry: int) -> bytes:
  77. """Send command to device and read response."""
  78. command = bytearray.fromhex(self._commandkey(key))
  79. _LOGGER.debug("%s: Sending command %s", self.name, command)
  80. if self._operation_lock.locked():
  81. _LOGGER.debug(
  82. "%s: Operation already in progress, waiting for it to complete; RSSI: %s",
  83. self.name,
  84. self.rssi,
  85. )
  86. max_attempts = retry + 1
  87. async with self._operation_lock:
  88. for attempt in range(max_attempts):
  89. try:
  90. return await self._send_command_locked(key, command)
  91. except BleakNotFoundError:
  92. _LOGGER.error(
  93. "%s: device not found or no longer in range; Try restarting Bluetooth",
  94. self.name,
  95. exc_info=True,
  96. )
  97. return b"\x00"
  98. except BLEAK_EXCEPTIONS:
  99. if attempt == retry:
  100. _LOGGER.error(
  101. "%s: communication failed; Stopping trying; RSSI: %s",
  102. self.name,
  103. self.rssi,
  104. exc_info=True,
  105. )
  106. return b"\x00"
  107. _LOGGER.debug(
  108. "%s: communication failed with:", self.name, exc_info=True
  109. )
  110. raise RuntimeError("Unreachable")
  111. @property
  112. def name(self) -> str:
  113. """Return device name."""
  114. return f"{self._device.name} ({self._device.address})"
  115. @property
  116. def rssi(self) -> int:
  117. """Return RSSI of device."""
  118. return self._get_adv_value("rssi")
  119. async def _ensure_connected(self):
  120. """Ensure connection to device is established."""
  121. if self._connect_lock.locked():
  122. _LOGGER.debug(
  123. "%s: Connection already in progress, waiting for it to complete; RSSI: %s",
  124. self.name,
  125. self.rssi,
  126. )
  127. if self._client and self._client.is_connected:
  128. self._reset_disconnect_timer()
  129. return
  130. async with self._connect_lock:
  131. # Check again while holding the lock
  132. if self._client and self._client.is_connected:
  133. self._reset_disconnect_timer()
  134. return
  135. _LOGGER.debug("%s: Connecting; RSSI: %s", self.name, self.rssi)
  136. client = await establish_connection(
  137. BleakClientWithServiceCache,
  138. self._device,
  139. self.name,
  140. self._disconnected,
  141. cached_services=self._cached_services,
  142. ble_device_callback=lambda: self._device
  143. )
  144. self._cached_services = client.services
  145. _LOGGER.debug("%s: Connected; RSSI: %s", self.name, self.rssi)
  146. services = client.services
  147. self._read_char = services.get_characteristic(_sb_uuid(comms_type="rx"))
  148. self._write_char = services.get_characteristic(_sb_uuid(comms_type="tx"))
  149. self._client = client
  150. self._reset_disconnect_timer()
  151. def _reset_disconnect_timer(self):
  152. """Reset disconnect timer."""
  153. if self._disconnect_timer:
  154. self._disconnect_timer.cancel()
  155. self._expected_disconnect = False
  156. self._disconnect_timer = self.loop.call_later(
  157. DISCONNECT_DELAY, self._disconnect
  158. )
  159. def _disconnected(self, client: BleakClientWithServiceCache) -> None:
  160. """Disconnected callback."""
  161. if self._expected_disconnect:
  162. _LOGGER.debug(
  163. "%s: Disconnected from device; RSSI: %s", self.name, self.rssi
  164. )
  165. return
  166. _LOGGER.warning(
  167. "%s: Device unexpectedly disconnected; RSSI: %s",
  168. self.name,
  169. self.rssi,
  170. )
  171. def _disconnect(self):
  172. """Disconnect from device."""
  173. self._disconnect_timer = None
  174. asyncio.create_task(self._execute_disconnect())
  175. async def _execute_disconnect(self):
  176. """Execute disconnection."""
  177. _LOGGER.debug(
  178. "%s: Disconnecting after timeout of %s",
  179. self.name,
  180. DISCONNECT_DELAY,
  181. )
  182. async with self._connect_lock:
  183. if not self._client or not self._client.is_connected:
  184. return
  185. self._expected_disconnect = True
  186. await self._client.disconnect()
  187. self._client = None
  188. self._read_char = None
  189. self._write_char = None
  190. async def _send_command_locked(self, key: str, command: bytes) -> bytes:
  191. """Send command to device and read response."""
  192. await self._ensure_connected()
  193. assert self._client is not None
  194. assert self._read_char is not None
  195. assert self._write_char is not None
  196. future: asyncio.Future[bytearray] = asyncio.Future()
  197. client = self._client
  198. def _notification_handler(_sender: int, data: bytearray) -> None:
  199. """Handle notification responses."""
  200. if future.done():
  201. _LOGGER.debug("%s: Notification handler already done", self.name)
  202. return
  203. future.set_result(data)
  204. _LOGGER.debug("%s: Subscribe to notifications; RSSI: %s", self.name, self.rssi)
  205. await client.start_notify(self._read_char, _notification_handler)
  206. _LOGGER.debug("%s: Sending command: %s", self.name, key)
  207. await client.write_gatt_char(self._write_char, command, False)
  208. async with async_timeout.timeout(5):
  209. notify_msg = await future
  210. _LOGGER.debug("%s: Notification received: %s", self.name, notify_msg)
  211. _LOGGER.debug("%s: UnSubscribe to notifications", self.name)
  212. await client.stop_notify(self._read_char)
  213. if notify_msg == b"\x07":
  214. _LOGGER.error("Password required")
  215. elif notify_msg == b"\t":
  216. _LOGGER.error("Password incorrect")
  217. return notify_msg
  218. def get_address(self) -> str:
  219. """Return address of device."""
  220. return self._device.address
  221. def _get_adv_value(self, key: str) -> Any:
  222. """Return value from advertisement data."""
  223. if not self._sb_adv_data:
  224. return None
  225. return self._sb_adv_data.data["data"][key]
  226. def get_battery_percent(self) -> Any:
  227. """Return device battery level in percent."""
  228. return self._get_adv_value("battery")
  229. def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> None:
  230. """Update device data from advertisement."""
  231. self._sb_adv_data = advertisement
  232. if self._device and ble_device_has_changed(self._device, advertisement.device):
  233. self._cached_services = None
  234. self._device = advertisement.device
  235. async def get_device_data(
  236. self, retry: int = DEFAULT_RETRY_COUNT, interface: int | None = None
  237. ) -> SwitchBotAdvertisement | None:
  238. """Find switchbot devices and their advertisement data."""
  239. if interface:
  240. _interface: int = interface
  241. else:
  242. _interface = int(self._interface.replace("hci", ""))
  243. _data = await GetSwitchbotDevices(interface=_interface).discover(
  244. retry=retry, scan_timeout=self._scan_timeout
  245. )
  246. if self._device.address in _data:
  247. self._sb_adv_data = _data[self._device.address]
  248. return self._sb_adv_data
  249. async def _get_basic_info(self) -> bytes | None:
  250. """Return basic info of device."""
  251. _data = await self._sendcommand(
  252. key=DEVICE_GET_BASIC_SETTINGS_KEY, retry=self._retry_count
  253. )
  254. if _data in (b"\x07", b"\x00"):
  255. _LOGGER.error("Unsuccessful, please try again")
  256. return None
  257. return _data