device.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  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, Callable
  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.exc import BleakDBusError
  13. from bleak_retry_connector import (
  14. BleakClientWithServiceCache,
  15. BleakNotFoundError,
  16. ble_device_has_changed,
  17. establish_connection,
  18. )
  19. from ..const import DEFAULT_RETRY_COUNT, DEFAULT_SCAN_TIMEOUT
  20. from ..discovery import GetSwitchbotDevices
  21. from ..models import SwitchBotAdvertisement
  22. _LOGGER = logging.getLogger(__name__)
  23. # Keys common to all device types
  24. DEVICE_GET_BASIC_SETTINGS_KEY = "5702"
  25. DEVICE_SET_MODE_KEY = "5703"
  26. DEVICE_SET_EXTENDED_KEY = "570f"
  27. # Base key when encryption is set
  28. KEY_PASSWORD_PREFIX = "571"
  29. BLEAK_EXCEPTIONS = (AttributeError, BleakError, asyncio.exceptions.TimeoutError)
  30. # How long to hold the connection
  31. # to wait for additional commands for
  32. # disconnecting the device.
  33. DISCONNECT_DELAY = 49
  34. class ColorMode(Enum):
  35. OFF = 0
  36. COLOR_TEMP = 1
  37. RGB = 2
  38. EFFECT = 3
  39. class CharacteristicMissingError(Exception):
  40. """Raised when a characteristic is missing."""
  41. def _sb_uuid(comms_type: str = "service") -> UUID | str:
  42. """Return Switchbot UUID."""
  43. _uuid = {"tx": "002", "rx": "003", "service": "d00"}
  44. if comms_type in _uuid:
  45. return UUID(f"cba20{_uuid[comms_type]}-224d-11e6-9fb8-0002a5d5c51b")
  46. return "Incorrect type, choose between: tx, rx or service"
  47. READ_CHAR_UUID = _sb_uuid(comms_type="rx")
  48. WRITE_CHAR_UUID = _sb_uuid(comms_type="tx")
  49. class SwitchbotDevice:
  50. """Base Representation of a Switchbot Device."""
  51. def __init__(
  52. self,
  53. device: BLEDevice,
  54. password: str | None = None,
  55. interface: int = 0,
  56. **kwargs: Any,
  57. ) -> None:
  58. """Switchbot base class constructor."""
  59. self._interface = f"hci{interface}"
  60. self._device = device
  61. self._sb_adv_data: SwitchBotAdvertisement | None = None
  62. self._scan_timeout: int = kwargs.pop("scan_timeout", DEFAULT_SCAN_TIMEOUT)
  63. self._retry_count: int = kwargs.pop("retry_count", DEFAULT_RETRY_COUNT)
  64. self._connect_lock = asyncio.Lock()
  65. self._operation_lock = asyncio.Lock()
  66. if password is None or password == "":
  67. self._password_encoded = None
  68. else:
  69. self._password_encoded = "%08x" % (
  70. binascii.crc32(password.encode("ascii")) & 0xFFFFFFFF
  71. )
  72. self._client: BleakClientWithServiceCache | None = None
  73. self._cached_services: BleakGATTServiceCollection | None = None
  74. self._read_char: BleakGATTCharacteristic | None = None
  75. self._write_char: BleakGATTCharacteristic | None = None
  76. self._disconnect_timer: asyncio.TimerHandle | None = None
  77. self._expected_disconnect = False
  78. self.loop = asyncio.get_event_loop()
  79. self._callbacks: list[Callable[[], None]] = []
  80. def _commandkey(self, key: str) -> str:
  81. """Add password to key if set."""
  82. if self._password_encoded is None:
  83. return key
  84. key_action = key[3]
  85. key_suffix = key[4:]
  86. return KEY_PASSWORD_PREFIX + key_action + self._password_encoded + key_suffix
  87. async def _sendcommand(self, key: str, retry: int | None = None) -> bytes:
  88. """Send command to device and read response."""
  89. if retry is None:
  90. retry = self._retry_count
  91. command = bytearray.fromhex(self._commandkey(key))
  92. _LOGGER.debug("%s: Sending command %s", self.name, command)
  93. if self._operation_lock.locked():
  94. _LOGGER.debug(
  95. "%s: Operation already in progress, waiting for it to complete; RSSI: %s",
  96. self.name,
  97. self.rssi,
  98. )
  99. max_attempts = retry + 1
  100. if self._operation_lock.locked():
  101. _LOGGER.debug(
  102. "%s: Operation already in progress, waiting for it to complete; RSSI: %s",
  103. self.name,
  104. self.rssi,
  105. )
  106. async with self._operation_lock:
  107. for attempt in range(max_attempts):
  108. try:
  109. return await self._send_command_locked(key, command)
  110. except BleakNotFoundError:
  111. _LOGGER.error(
  112. "%s: device not found, no longer in range, or poor RSSI: %s",
  113. self.name,
  114. self.rssi,
  115. exc_info=True,
  116. )
  117. return b"\x00"
  118. except CharacteristicMissingError as ex:
  119. if attempt == retry:
  120. _LOGGER.error(
  121. "%s: characteristic missing: %s; Stopping trying; RSSI: %s",
  122. self.name,
  123. ex,
  124. self.rssi,
  125. exc_info=True,
  126. )
  127. return b"\x00"
  128. _LOGGER.debug(
  129. "%s: characteristic missing: %s; RSSI: %s",
  130. self.name,
  131. ex,
  132. self.rssi,
  133. exc_info=True,
  134. )
  135. except BLEAK_EXCEPTIONS:
  136. if attempt == retry:
  137. _LOGGER.error(
  138. "%s: communication failed; Stopping trying; RSSI: %s",
  139. self.name,
  140. self.rssi,
  141. exc_info=True,
  142. )
  143. return b"\x00"
  144. _LOGGER.debug(
  145. "%s: communication failed with:", self.name, exc_info=True
  146. )
  147. raise RuntimeError("Unreachable")
  148. @property
  149. def name(self) -> str:
  150. """Return device name."""
  151. return f"{self._device.name} ({self._device.address})"
  152. @property
  153. def rssi(self) -> int:
  154. """Return RSSI of device."""
  155. return self._get_adv_value("rssi")
  156. async def _ensure_connected(self):
  157. """Ensure connection to device is established."""
  158. if self._connect_lock.locked():
  159. _LOGGER.debug(
  160. "%s: Connection already in progress, waiting for it to complete; RSSI: %s",
  161. self.name,
  162. self.rssi,
  163. )
  164. if self._client and self._client.is_connected:
  165. self._reset_disconnect_timer()
  166. return
  167. async with self._connect_lock:
  168. # Check again while holding the lock
  169. if self._client and self._client.is_connected:
  170. self._reset_disconnect_timer()
  171. return
  172. _LOGGER.debug("%s: Connecting; RSSI: %s", self.name, self.rssi)
  173. client = await establish_connection(
  174. BleakClientWithServiceCache,
  175. self._device,
  176. self.name,
  177. self._disconnected,
  178. cached_services=self._cached_services,
  179. ble_device_callback=lambda: self._device,
  180. )
  181. _LOGGER.debug("%s: Connected; RSSI: %s", self.name, self.rssi)
  182. resolved = self._resolve_characteristics(client.services)
  183. if not resolved:
  184. # Try to handle services failing to load
  185. resolved = self._resolve_characteristics(await client.get_services())
  186. self._cached_services = client.services if resolved else None
  187. self._client = client
  188. self._reset_disconnect_timer()
  189. def _resolve_characteristics(self, services: BleakGATTServiceCollection) -> bool:
  190. """Resolve characteristics."""
  191. self._read_char = services.get_characteristic(READ_CHAR_UUID)
  192. self._write_char = services.get_characteristic(WRITE_CHAR_UUID)
  193. return bool(self._read_char and self._write_char)
  194. def _reset_disconnect_timer(self):
  195. """Reset disconnect timer."""
  196. if self._disconnect_timer:
  197. self._disconnect_timer.cancel()
  198. self._expected_disconnect = False
  199. self._disconnect_timer = self.loop.call_later(
  200. DISCONNECT_DELAY, self._disconnect
  201. )
  202. def _disconnected(self, client: BleakClientWithServiceCache) -> None:
  203. """Disconnected callback."""
  204. if self._expected_disconnect:
  205. _LOGGER.debug(
  206. "%s: Disconnected from device; RSSI: %s", self.name, self.rssi
  207. )
  208. return
  209. _LOGGER.warning(
  210. "%s: Device unexpectedly disconnected; RSSI: %s",
  211. self.name,
  212. self.rssi,
  213. )
  214. def _disconnect(self):
  215. """Disconnect from device."""
  216. self._disconnect_timer = None
  217. asyncio.create_task(self._execute_timed_disconnect())
  218. async def _execute_timed_disconnect(self):
  219. """Execute timed disconnection."""
  220. _LOGGER.debug(
  221. "%s: Disconnecting after timeout of %s",
  222. self.name,
  223. DISCONNECT_DELAY,
  224. )
  225. await self._execute_disconnect()
  226. async def _execute_disconnect(self):
  227. """Execute disconnection."""
  228. async with self._connect_lock:
  229. client = self._client
  230. self._expected_disconnect = True
  231. self._client = None
  232. self._read_char = None
  233. self._write_char = None
  234. if client and client.is_connected:
  235. await client.disconnect()
  236. async def _send_command_locked(self, key: str, command: bytes) -> bytes:
  237. """Send command to device and read response."""
  238. await self._ensure_connected()
  239. try:
  240. return await self._execute_command_locked(key, command)
  241. except BleakDBusError as ex:
  242. # Disconnect so we can reset state and try again
  243. await asyncio.sleep(0.25)
  244. _LOGGER.debug(
  245. "%s: RSSI: %s; Backing off %ss; Disconnecting due to error: %s",
  246. self.name,
  247. self.rssi,
  248. 0.25,
  249. ex,
  250. )
  251. await self._execute_disconnect()
  252. raise
  253. except BleakError as ex:
  254. # Disconnect so we can reset state and try again
  255. _LOGGER.debug(
  256. "%s: RSSI: %s; Disconnecting due to error: %s", self.name, self.rssi, ex
  257. )
  258. await self._execute_disconnect()
  259. raise
  260. async def _execute_command_locked(self, key: str, command: bytes) -> bytes:
  261. """Execute command and read response."""
  262. assert self._client is not None
  263. if not self._read_char:
  264. raise CharacteristicMissingError(READ_CHAR_UUID)
  265. if not self._write_char:
  266. raise CharacteristicMissingError(WRITE_CHAR_UUID)
  267. future: asyncio.Future[bytearray] = asyncio.Future()
  268. client = self._client
  269. def _notification_handler(_sender: int, data: bytearray) -> None:
  270. """Handle notification responses."""
  271. if future.done():
  272. _LOGGER.debug("%s: Notification handler already done", self.name)
  273. return
  274. future.set_result(data)
  275. _LOGGER.debug("%s: Subscribe to notifications; RSSI: %s", self.name, self.rssi)
  276. await client.start_notify(self._read_char, _notification_handler)
  277. _LOGGER.debug("%s: Sending command: %s", self.name, key)
  278. await client.write_gatt_char(self._write_char, command, False)
  279. async with async_timeout.timeout(5):
  280. notify_msg = await future
  281. _LOGGER.debug("%s: Notification received: %s", self.name, notify_msg)
  282. _LOGGER.debug("%s: UnSubscribe to notifications", self.name)
  283. await client.stop_notify(self._read_char)
  284. if notify_msg == b"\x07":
  285. _LOGGER.error("Password required")
  286. elif notify_msg == b"\t":
  287. _LOGGER.error("Password incorrect")
  288. return notify_msg
  289. def get_address(self) -> str:
  290. """Return address of device."""
  291. return self._device.address
  292. def _get_adv_value(self, key: str) -> Any:
  293. """Return value from advertisement data."""
  294. if not self._sb_adv_data:
  295. return None
  296. return self._sb_adv_data.data["data"].get(key)
  297. def get_battery_percent(self) -> Any:
  298. """Return device battery level in percent."""
  299. return self._get_adv_value("battery")
  300. def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> None:
  301. """Update device data from advertisement."""
  302. self._sb_adv_data = advertisement
  303. if self._device and ble_device_has_changed(self._device, advertisement.device):
  304. self._cached_services = None
  305. self._device = advertisement.device
  306. async def get_device_data(
  307. self, retry: int | None = None, interface: int | None = None
  308. ) -> SwitchBotAdvertisement | None:
  309. """Find switchbot devices and their advertisement data."""
  310. if retry is None:
  311. retry = self._retry_count
  312. if interface:
  313. _interface: int = interface
  314. else:
  315. _interface = int(self._interface.replace("hci", ""))
  316. _data = await GetSwitchbotDevices(interface=_interface).discover(
  317. retry=retry, scan_timeout=self._scan_timeout
  318. )
  319. if self._device.address in _data:
  320. self._sb_adv_data = _data[self._device.address]
  321. return self._sb_adv_data
  322. async def _get_basic_info(self) -> bytes | None:
  323. """Return basic info of device."""
  324. _data = await self._sendcommand(
  325. key=DEVICE_GET_BASIC_SETTINGS_KEY, retry=self._retry_count
  326. )
  327. if _data in (b"\x07", b"\x00"):
  328. _LOGGER.error("Unsuccessful, please try again")
  329. return None
  330. return _data
  331. def _fire_callbacks(self) -> None:
  332. """Fire callbacks."""
  333. for callback in self._callbacks:
  334. callback()
  335. def subscribe(self, callback: Callable[[], None]) -> Callable[[], None]:
  336. """Subscribe to device notifications."""
  337. self._callbacks.append(callback)
  338. def _unsub() -> None:
  339. """Unsubscribe from device notifications."""
  340. self._callbacks.remove(callback)
  341. return _unsub
  342. async def update(self) -> None:
  343. """Update state of device."""