__init__.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692
  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 bleak
  9. DEFAULT_RETRY_COUNT = 3
  10. DEFAULT_RETRY_TIMEOUT = 1
  11. DEFAULT_SCAN_TIMEOUT = 5
  12. # Keys common to all device types
  13. DEVICE_GET_BASIC_SETTINGS_KEY = "5702"
  14. DEVICE_SET_MODE_KEY = "5703"
  15. DEVICE_SET_EXTENDED_KEY = "570f"
  16. # Bot keys
  17. PRESS_KEY = "570100"
  18. ON_KEY = "570101"
  19. OFF_KEY = "570102"
  20. DOWN_KEY = "570103"
  21. UP_KEY = "570104"
  22. # Curtain keys
  23. OPEN_KEY = "570f450105ff00" # 570F4501010100
  24. CLOSE_KEY = "570f450105ff64" # 570F4501010164
  25. POSITION_KEY = "570F450105ff" # +actual_position ex: 570F450105ff32 for 50%
  26. STOP_KEY = "570F450100ff"
  27. CURTAIN_EXT_SUM_KEY = "570f460401"
  28. CURTAIN_EXT_ADV_KEY = "570f460402"
  29. CURTAIN_EXT_CHAIN_INFO_KEY = "570f468101"
  30. # Base key when encryption is set
  31. KEY_PASSWORD_PREFIX = "571"
  32. _LOGGER = logging.getLogger(__name__)
  33. CONNECT_LOCK = asyncio.Lock()
  34. def _sb_uuid(comms_type: str = "service") -> UUID | str:
  35. """Return Switchbot UUID."""
  36. _uuid = {"tx": "002", "rx": "003", "service": "d00"}
  37. if comms_type in _uuid:
  38. return UUID(f"cba20{_uuid[comms_type]}-224d-11e6-9fb8-0002a5d5c51b")
  39. return "Incorrect type, choose between: tx, rx or service"
  40. def _process_wohand(data: bytes) -> dict[str, bool | int]:
  41. """Process woHand/Bot services data."""
  42. _switch_mode = bool(data[1] & 0b10000000)
  43. _bot_data = {
  44. "switchMode": _switch_mode,
  45. "isOn": not bool(data[1] & 0b01000000) if _switch_mode else False,
  46. "battery": data[2] & 0b01111111,
  47. }
  48. return _bot_data
  49. def _process_wocurtain(data: bytes, reverse: bool = True) -> dict[str, bool | int]:
  50. """Process woCurtain/Curtain services data."""
  51. _position = max(min(data[3] & 0b01111111, 100), 0)
  52. _curtain_data = {
  53. "calibration": bool(data[1] & 0b01000000),
  54. "battery": data[2] & 0b01111111,
  55. "inMotion": bool(data[3] & 0b10000000),
  56. "position": (100 - _position) if reverse else _position,
  57. "lightLevel": (data[4] >> 4) & 0b00001111,
  58. "deviceChain": data[4] & 0b00000111,
  59. }
  60. return _curtain_data
  61. def _process_wosensorth(data: bytes) -> dict[str, object]:
  62. """Process woSensorTH/Temp sensor services data."""
  63. _temp_sign = 1 if data[4] & 0b10000000 else -1
  64. _temp_c = _temp_sign * ((data[4] & 0b01111111) + ((data[3] & 0b00001111) / 10))
  65. _temp_f = (_temp_c * 9 / 5) + 32
  66. _temp_f = (_temp_f * 10) / 10
  67. _wosensorth_data = {
  68. "temp": {"c": _temp_c, "f": _temp_f},
  69. "fahrenheit": bool(data[5] & 0b10000000),
  70. "humidity": data[5] & 0b01111111,
  71. "battery": data[2] & 0b01111111,
  72. }
  73. return _wosensorth_data
  74. class GetSwitchbotDevices:
  75. """Scan for all Switchbot devices and return by type."""
  76. def __init__(self, interface: int = 0) -> None:
  77. """Get switchbot devices class constructor."""
  78. self._interface = f"hci{interface}"
  79. self._adv_data: dict[str, Any] = {}
  80. def detection_callback(
  81. self,
  82. device: bleak.backends.device.BLEDevice,
  83. advertisement_data: bleak.backends.scanner.AdvertisementData,
  84. ) -> None:
  85. """BTLE adv scan callback."""
  86. _services = list(advertisement_data.service_data.values())
  87. if not _services:
  88. return
  89. _service_data = _services[0]
  90. _device = device.address.replace(":", "").lower()
  91. _model = chr(_service_data[0] & 0b01111111)
  92. supported_types: dict[str, dict[str, Any]] = {
  93. "H": {"modelName": "WoHand", "func": _process_wohand},
  94. "c": {"modelName": "WoCurtain", "func": _process_wocurtain},
  95. "T": {"modelName": "WoSensorTH", "func": _process_wosensorth},
  96. }
  97. self._adv_data[_device] = {
  98. "mac_address": device.address.lower(),
  99. "rawAdvData": list(advertisement_data.service_data.values())[0],
  100. "data": {
  101. "rssi": device.rssi,
  102. },
  103. }
  104. if _model in supported_types:
  105. self._adv_data[_device].update(
  106. {
  107. "isEncrypted": bool(_service_data[0] & 0b10000000),
  108. "model": _model,
  109. "modelName": supported_types[_model]["modelName"],
  110. "data": supported_types[_model]["func"](_service_data),
  111. }
  112. )
  113. self._adv_data[_device]["data"]["rssi"] = device.rssi
  114. async def discover(
  115. self, retry: int = DEFAULT_RETRY_COUNT, scan_timeout: int = DEFAULT_SCAN_TIMEOUT
  116. ) -> dict:
  117. """Find switchbot devices and their advertisement data."""
  118. devices = None
  119. devices = bleak.BleakScanner(
  120. # TODO: Find new UUIDs to filter on. For example, see
  121. # https://github.com/OpenWonderLabs/SwitchBotAPI-BLE/blob/4ad138bb09f0fbbfa41b152ca327a78c1d0b6ba9/devicetypes/meter.md
  122. adapter=self._interface,
  123. )
  124. devices.register_detection_callback(self.detection_callback)
  125. async with CONNECT_LOCK:
  126. await devices.start()
  127. await asyncio.sleep(scan_timeout)
  128. await devices.stop()
  129. if devices is None:
  130. if retry < 1:
  131. _LOGGER.error(
  132. "Scanning for Switchbot devices failed. Stop trying", exc_info=True
  133. )
  134. return self._adv_data
  135. _LOGGER.warning(
  136. "Error scanning for Switchbot devices. Retrying (remaining: %d)",
  137. retry,
  138. )
  139. await asyncio.sleep(DEFAULT_RETRY_TIMEOUT)
  140. return await self.discover(retry - 1, scan_timeout)
  141. return self._adv_data
  142. async def get_curtains(self) -> dict:
  143. """Return all WoCurtain/Curtains devices with services data."""
  144. if not self._adv_data:
  145. await self.discover()
  146. _curtain_devices = {
  147. device: data
  148. for device, data in self._adv_data.items()
  149. if data.get("model") == "c"
  150. }
  151. return _curtain_devices
  152. async def get_bots(self) -> dict[str, Any] | None:
  153. """Return all WoHand/Bot devices with services data."""
  154. if not self._adv_data:
  155. await self.discover()
  156. _bot_devices = {
  157. device: data
  158. for device, data in self._adv_data.items()
  159. if data.get("model") == "H"
  160. }
  161. return _bot_devices
  162. async def get_tempsensors(self) -> dict[str, Any] | None:
  163. """Return all WoSensorTH/Temp sensor devices with services data."""
  164. if not self._adv_data:
  165. await self.discover()
  166. _bot_temp = {
  167. device: data
  168. for device, data in self._adv_data.items()
  169. if data.get("model") == "T"
  170. }
  171. return _bot_temp
  172. async def get_device_data(self, mac: str) -> dict[str, Any] | None:
  173. """Return data for specific device."""
  174. if not self._adv_data:
  175. await self.discover()
  176. _switchbot_data = {
  177. device: data
  178. for device, data in self._adv_data.items()
  179. if data.get("mac_address") == mac
  180. }
  181. return _switchbot_data
  182. class SwitchbotDevice:
  183. """Base Representation of a Switchbot Device."""
  184. def __init__(
  185. self,
  186. mac: str,
  187. password: str | None = None,
  188. interface: int = 0,
  189. **kwargs: Any,
  190. ) -> None:
  191. """Switchbot base class constructor."""
  192. self._interface = f"hci{interface}"
  193. self._mac = mac.replace("-", ":").lower()
  194. self._sb_adv_data: dict[str, Any] = {}
  195. self._scan_timeout: int = kwargs.pop("scan_timeout", DEFAULT_SCAN_TIMEOUT)
  196. self._retry_count: int = kwargs.pop("retry_count", DEFAULT_RETRY_COUNT)
  197. if password is None or password == "":
  198. self._password_encoded = None
  199. else:
  200. self._password_encoded = "%x" % (
  201. binascii.crc32(password.encode("ascii")) & 0xFFFFFFFF
  202. )
  203. self._last_notification = bytearray()
  204. async def _notification_handler(self, sender: int, data: bytearray) -> None:
  205. """Handle notification responses."""
  206. self._last_notification = data
  207. def _commandkey(self, key: str) -> str:
  208. """Add password to key if set."""
  209. if self._password_encoded is None:
  210. return key
  211. key_action = key[3]
  212. key_suffix = key[4:]
  213. return KEY_PASSWORD_PREFIX + key_action + self._password_encoded + key_suffix
  214. async def _sendcommand(self, key: str, retry: int) -> bytes:
  215. """Send command to device and read response."""
  216. command = bytearray.fromhex(self._commandkey(key))
  217. notify_msg = b""
  218. _LOGGER.debug("Sending command to switchbot %s", command)
  219. if len(self._mac.split(":")) != 6:
  220. raise ValueError("Expected MAC address, got %s" % repr(self._mac))
  221. async with CONNECT_LOCK:
  222. try:
  223. async with bleak.BleakClient(
  224. address_or_ble_device=self._mac, timeout=float(self._scan_timeout)
  225. ) as client:
  226. _LOGGER.debug("Connnected to switchbot: %s", client.is_connected)
  227. _LOGGER.debug("Subscribe to notifications")
  228. await client.start_notify(
  229. _sb_uuid(comms_type="rx"), self._notification_handler
  230. )
  231. _LOGGER.debug("Sending command, %s", key)
  232. await client.write_gatt_char(
  233. _sb_uuid(comms_type="tx"), command, False
  234. )
  235. await asyncio.sleep(
  236. 1.0
  237. ) # Bot needs pause. Otherwise notification could be missed.
  238. notify_msg = self._last_notification
  239. _LOGGER.info("Notification received: %s", notify_msg)
  240. _LOGGER.debug("UnSubscribe to notifications")
  241. await client.stop_notify(_sb_uuid(comms_type="rx"))
  242. except (bleak.BleakError, asyncio.exceptions.TimeoutError):
  243. if retry < 1:
  244. _LOGGER.error(
  245. "Switchbot communication failed. Stopping trying", exc_info=True
  246. )
  247. return b"\x00"
  248. _LOGGER.debug("Switchbot communication failed with:", exc_info=True)
  249. if notify_msg:
  250. if notify_msg == b"\x07":
  251. _LOGGER.error("Password required")
  252. elif notify_msg == b"\t":
  253. _LOGGER.error("Password incorrect")
  254. return notify_msg
  255. _LOGGER.warning("Cannot connect to Switchbot. Retrying (remaining: %d)", retry)
  256. if retry < 1: # failsafe
  257. return b"\x00"
  258. await asyncio.sleep(DEFAULT_RETRY_TIMEOUT)
  259. return await self._sendcommand(key, retry - 1)
  260. def get_mac(self) -> str:
  261. """Return mac address of device."""
  262. return self._mac
  263. def get_battery_percent(self) -> Any:
  264. """Return device battery level in percent."""
  265. if not self._sb_adv_data:
  266. return None
  267. return self._sb_adv_data["data"]["battery"]
  268. async def get_device_data(
  269. self, retry: int = DEFAULT_RETRY_COUNT, interface: int | None = None
  270. ) -> dict | None:
  271. """Find switchbot devices and their advertisement data."""
  272. if interface:
  273. _interface: int = interface
  274. else:
  275. _interface = int(self._interface.replace("hci", ""))
  276. dev_id = self._mac.replace(":", "")
  277. _data = await GetSwitchbotDevices(interface=_interface).discover(
  278. retry=retry, scan_timeout=self._scan_timeout
  279. )
  280. if _data.get(dev_id):
  281. self._sb_adv_data = _data[dev_id]
  282. return self._sb_adv_data
  283. class Switchbot(SwitchbotDevice):
  284. """Representation of a Switchbot."""
  285. def __init__(self, *args: Any, **kwargs: Any) -> None:
  286. """Switchbot Bot/WoHand constructor."""
  287. super().__init__(*args, **kwargs)
  288. self._inverse: bool = kwargs.pop("inverse_mode", False)
  289. self._settings: dict[str, Any] = {}
  290. async def update(self, interface: int | None = None) -> None:
  291. """Update mode, battery percent and state of device."""
  292. await self.get_device_data(retry=self._retry_count, interface=interface)
  293. async def turn_on(self) -> bool:
  294. """Turn device on."""
  295. result = await self._sendcommand(ON_KEY, self._retry_count)
  296. if result[0] == 1:
  297. return True
  298. if result[0] == 5:
  299. _LOGGER.debug("Bot is in press mode and doesn't have on state")
  300. return True
  301. return False
  302. async def turn_off(self) -> bool:
  303. """Turn device off."""
  304. result = await self._sendcommand(OFF_KEY, self._retry_count)
  305. if result[0] == 1:
  306. return True
  307. if result[0] == 5:
  308. _LOGGER.debug("Bot is in press mode and doesn't have off state")
  309. return True
  310. return False
  311. async def hand_up(self) -> bool:
  312. """Raise device arm."""
  313. result = await self._sendcommand(UP_KEY, self._retry_count)
  314. if result[0] == 1:
  315. return True
  316. if result[0] == 5:
  317. _LOGGER.debug("Bot is in press mode")
  318. return True
  319. return False
  320. async def hand_down(self) -> bool:
  321. """Lower device arm."""
  322. result = await self._sendcommand(DOWN_KEY, self._retry_count)
  323. if result[0] == 1:
  324. return True
  325. if result[0] == 5:
  326. _LOGGER.debug("Bot is in press mode")
  327. return True
  328. return False
  329. async def press(self) -> bool:
  330. """Press command to device."""
  331. result = await self._sendcommand(PRESS_KEY, self._retry_count)
  332. if result[0] == 1:
  333. return True
  334. if result[0] == 5:
  335. _LOGGER.debug("Bot is in switch mode")
  336. return True
  337. return False
  338. async def set_switch_mode(
  339. self, switch_mode: bool = False, strength: int = 100, inverse: bool = False
  340. ) -> bool:
  341. """Change bot mode."""
  342. mode_key = format(switch_mode, "b") + format(inverse, "b")
  343. strength_key = f"{strength:0{2}x}" # to hex with padding to double digit
  344. result = await self._sendcommand(
  345. DEVICE_SET_MODE_KEY + strength_key + mode_key, self._retry_count
  346. )
  347. if result[0] == 1:
  348. return True
  349. return False
  350. async def set_long_press(self, duration: int = 0) -> bool:
  351. """Set bot long press duration."""
  352. duration_key = f"{duration:0{2}x}" # to hex with padding to double digit
  353. result = await self._sendcommand(
  354. DEVICE_SET_EXTENDED_KEY + "08" + duration_key, self._retry_count
  355. )
  356. if result[0] == 1:
  357. return True
  358. return False
  359. async def get_basic_info(self) -> dict[str, Any] | None:
  360. """Get device basic settings."""
  361. _data = await self._sendcommand(
  362. key=DEVICE_GET_BASIC_SETTINGS_KEY, retry=self._retry_count
  363. )
  364. if _data in (b"\x07", b"\x00"):
  365. _LOGGER.error("Unsuccessfull, please try again")
  366. return None
  367. self._settings = {
  368. "battery": _data[1],
  369. "firmware": _data[2] / 10.0,
  370. "strength": _data[3],
  371. "timers": _data[8],
  372. "switchMode": bool(_data[9] & 16),
  373. "inverseDirection": bool(_data[9] & 1),
  374. "holdSeconds": _data[10],
  375. }
  376. return self._settings
  377. def switch_mode(self) -> Any:
  378. """Return true or false from cache."""
  379. # To get actual position call update() first.
  380. if not self._sb_adv_data.get("data"):
  381. return None
  382. return self._sb_adv_data["data"].get("switchMode")
  383. def is_on(self) -> Any:
  384. """Return switch state from cache."""
  385. # To get actual position call update() first.
  386. if not self._sb_adv_data.get("data"):
  387. return None
  388. if self._inverse:
  389. return not self._sb_adv_data["data"].get("isOn")
  390. return self._sb_adv_data["data"].get("isOn")
  391. class SwitchbotCurtain(SwitchbotDevice):
  392. """Representation of a Switchbot Curtain."""
  393. def __init__(self, *args: Any, **kwargs: Any) -> None:
  394. """Switchbot Curtain/WoCurtain constructor."""
  395. # The position of the curtain is saved returned with 0 = open and 100 = closed.
  396. # This is independent of the calibration of the curtain bot (Open left to right/
  397. # Open right to left/Open from the middle).
  398. # The parameter 'reverse_mode' reverse these values,
  399. # if 'reverse_mode' = True, position = 0 equals close
  400. # and position = 100 equals open. The parameter is default set to True so that
  401. # the definition of position is the same as in Home Assistant.
  402. super().__init__(*args, **kwargs)
  403. self._reverse: bool = kwargs.pop("reverse_mode", True)
  404. self._settings: dict[str, Any] = {}
  405. self.ext_info_sum: dict[str, Any] = {}
  406. self.ext_info_adv: dict[str, Any] = {}
  407. async def open(self) -> bool:
  408. """Send open command."""
  409. result = await self._sendcommand(OPEN_KEY, self._retry_count)
  410. if result[0] == 1:
  411. return True
  412. return False
  413. async def close(self) -> bool:
  414. """Send close command."""
  415. result = await self._sendcommand(CLOSE_KEY, self._retry_count)
  416. if result[0] == 1:
  417. return True
  418. return False
  419. async def stop(self) -> bool:
  420. """Send stop command to device."""
  421. result = await self._sendcommand(STOP_KEY, self._retry_count)
  422. if result[0] == 1:
  423. return True
  424. return False
  425. async def set_position(self, position: int) -> bool:
  426. """Send position command (0-100) to device."""
  427. position = (100 - position) if self._reverse else position
  428. hex_position = "%0.2X" % position
  429. result = await self._sendcommand(POSITION_KEY + hex_position, self._retry_count)
  430. if result[0] == 1:
  431. return True
  432. return False
  433. async def update(self, interface: int | None = None) -> None:
  434. """Update position, battery percent and light level of device."""
  435. await self.get_device_data(retry=self._retry_count, interface=interface)
  436. def get_position(self) -> Any:
  437. """Return cached position (0-100) of Curtain."""
  438. # To get actual position call update() first.
  439. if not self._sb_adv_data.get("data"):
  440. return None
  441. return self._sb_adv_data["data"].get("position")
  442. async def get_basic_info(self) -> dict[str, Any] | None:
  443. """Get device basic settings."""
  444. _data = await self._sendcommand(
  445. key=DEVICE_GET_BASIC_SETTINGS_KEY, retry=self._retry_count
  446. )
  447. if _data in (b"\x07", b"\x00"):
  448. _LOGGER.error("Unsuccessfull, please try again")
  449. return None
  450. _position = max(min(_data[6], 100), 0)
  451. self._settings = {
  452. "battery": _data[1],
  453. "firmware": _data[2] / 10.0,
  454. "chainLength": _data[3],
  455. "openDirection": (
  456. "right_to_left" if _data[4] & 0b10000000 == 128 else "left_to_right"
  457. ),
  458. "touchToOpen": bool(_data[4] & 0b01000000),
  459. "light": bool(_data[4] & 0b00100000),
  460. "fault": bool(_data[4] & 0b00001000),
  461. "solarPanel": bool(_data[5] & 0b00001000),
  462. "calibrated": bool(_data[5] & 0b00000100),
  463. "inMotion": bool(_data[5] & 0b01000011),
  464. "position": (100 - _position) if self._reverse else _position,
  465. "timers": _data[7],
  466. }
  467. return self._settings
  468. async def get_extended_info_summary(self) -> dict[str, Any] | None:
  469. """Get basic info for all devices in chain."""
  470. _data = await self._sendcommand(
  471. key=CURTAIN_EXT_SUM_KEY, retry=self._retry_count
  472. )
  473. if _data in (b"\x07", b"\x00"):
  474. _LOGGER.error("Unsuccessfull, please try again")
  475. return None
  476. self.ext_info_sum["device0"] = {
  477. "openDirectionDefault": not bool(_data[1] & 0b10000000),
  478. "touchToOpen": bool(_data[1] & 0b01000000),
  479. "light": bool(_data[1] & 0b00100000),
  480. "openDirection": (
  481. "left_to_right" if _data[1] & 0b00010000 == 1 else "right_to_left"
  482. ),
  483. }
  484. # if grouped curtain device present.
  485. if _data[2] != 0:
  486. self.ext_info_sum["device1"] = {
  487. "openDirectionDefault": not bool(_data[1] & 0b10000000),
  488. "touchToOpen": bool(_data[1] & 0b01000000),
  489. "light": bool(_data[1] & 0b00100000),
  490. "openDirection": (
  491. "left_to_right" if _data[1] & 0b00010000 else "right_to_left"
  492. ),
  493. }
  494. return self.ext_info_sum
  495. async def get_extended_info_adv(self) -> dict[str, Any] | None:
  496. """Get advance page info for device chain."""
  497. _data = await self._sendcommand(
  498. key=CURTAIN_EXT_ADV_KEY, retry=self._retry_count
  499. )
  500. if _data in (b"\x07", b"\x00"):
  501. _LOGGER.error("Unsuccessfull, please try again")
  502. return None
  503. _state_of_charge = [
  504. "not_charging",
  505. "charging_by_adapter",
  506. "charging_by_solar",
  507. "fully_charged",
  508. "solar_not_charging",
  509. "charging_error",
  510. ]
  511. self.ext_info_adv["device0"] = {
  512. "battery": _data[1],
  513. "firmware": _data[2] / 10.0,
  514. "stateOfCharge": _state_of_charge[_data[3]],
  515. }
  516. # If grouped curtain device present.
  517. if _data[4]:
  518. self.ext_info_adv["device1"] = {
  519. "battery": _data[4],
  520. "firmware": _data[5] / 10.0,
  521. "stateOfCharge": _state_of_charge[_data[6]],
  522. }
  523. return self.ext_info_adv
  524. def get_light_level(self) -> Any:
  525. """Return cached light level."""
  526. # To get actual light level call update() first.
  527. if not self._sb_adv_data.get("data"):
  528. return None
  529. return self._sb_adv_data["data"].get("lightLevel")
  530. def is_reversed(self) -> bool:
  531. """Return True if curtain position is opposite from SB data."""
  532. return self._reverse
  533. def is_calibrated(self) -> Any:
  534. """Return True curtain is calibrated."""
  535. # To get actual light level call update() first.
  536. if not self._sb_adv_data.get("data"):
  537. return None
  538. return self._sb_adv_data["data"].get("calibration")