air_purifier.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. """Library to handle connection with Switchbot."""
  2. from __future__ import annotations
  3. import logging
  4. from typing import Any, ClassVar
  5. from bleak.backends.device import BLEDevice
  6. from ..adv_parsers.air_purifier import get_air_purifier_mode
  7. from ..const import SwitchbotModel
  8. from ..const.air_purifier import AirPurifierMode, AirQualityLevel
  9. from ..const.light import ColorMode
  10. from ..helpers import _UNPACK_UINT16_BE
  11. from .base_light import SwitchbotSequenceBaseLight
  12. from .device import (
  13. SwitchbotEncryptedDevice,
  14. SwitchbotOperationError,
  15. update_after_operation,
  16. )
  17. _LOGGER = logging.getLogger(__name__)
  18. COMMAND_HEAD = "570f4c"
  19. COMMAND_SET_MODE = {
  20. AirPurifierMode.LEVEL_1.name.lower(): f"{COMMAND_HEAD}01010100",
  21. AirPurifierMode.LEVEL_2.name.lower(): f"{COMMAND_HEAD}01010132",
  22. AirPurifierMode.LEVEL_3.name.lower(): f"{COMMAND_HEAD}01010164",
  23. AirPurifierMode.AUTO.name.lower(): f"{COMMAND_HEAD}01010200",
  24. AirPurifierMode.SLEEP.name.lower(): f"{COMMAND_HEAD}01010300",
  25. AirPurifierMode.PET.name.lower(): f"{COMMAND_HEAD}01010400",
  26. }
  27. DEVICE_GET_BASIC_SETTINGS_KEY = "570f4d81"
  28. COMMAND_SET_PERCENTAGE = f"{COMMAND_HEAD}02{{percentage:02x}}"
  29. READ_LED_SETTINGS_COMMAND = "570f4d05"
  30. READ_LED_STATUS_COMMAND = "570f4d07"
  31. class SwitchbotAirPurifier(SwitchbotSequenceBaseLight, SwitchbotEncryptedDevice):
  32. """Representation of a Switchbot Air Purifier."""
  33. _turn_on_command = f"{COMMAND_HEAD}010100"
  34. _turn_off_command = f"{COMMAND_HEAD}010000"
  35. _open_child_lock_command = f"{COMMAND_HEAD}0301"
  36. _close_child_lock_command = f"{COMMAND_HEAD}0300"
  37. _open_wireless_charging_command = f"{COMMAND_HEAD}0d01"
  38. _close_wireless_charging_command = f"{COMMAND_HEAD}0d00"
  39. _open_light_sensitive_switch_command = f"{COMMAND_HEAD}0702"
  40. _turn_led_on_command = f"{COMMAND_HEAD}0701"
  41. _turn_led_off_command = f"{COMMAND_HEAD}0700"
  42. _set_rgb_command = _set_brightness_command = f"{COMMAND_HEAD}0501{{}}"
  43. _get_basic_info_command = [
  44. DEVICE_GET_BASIC_SETTINGS_KEY,
  45. READ_LED_SETTINGS_COMMAND,
  46. READ_LED_STATUS_COMMAND,
  47. ]
  48. _PM25_MODELS: ClassVar[frozenset[SwitchbotModel]] = frozenset(
  49. {
  50. SwitchbotModel.AIR_PURIFIER_US,
  51. SwitchbotModel.AIR_PURIFIER_TABLE_US,
  52. }
  53. )
  54. _LEVEL_MODES: ClassVar[frozenset[str]] = frozenset(
  55. {
  56. AirPurifierMode.LEVEL_1.name.lower(),
  57. AirPurifierMode.LEVEL_2.name.lower(),
  58. AirPurifierMode.LEVEL_3.name.lower(),
  59. }
  60. )
  61. _WIRELESS_MODELS: ClassVar[frozenset[SwitchbotModel]] = frozenset(
  62. {
  63. SwitchbotModel.AIR_PURIFIER_TABLE_US,
  64. SwitchbotModel.AIR_PURIFIER_TABLE_JP,
  65. }
  66. )
  67. def __init__(
  68. self,
  69. device: BLEDevice,
  70. key_id: str,
  71. encryption_key: str,
  72. interface: int = 0,
  73. model: SwitchbotModel = SwitchbotModel.AIR_PURIFIER_US,
  74. **kwargs: Any,
  75. ) -> None:
  76. super().__init__(device, key_id, encryption_key, model, interface, **kwargs)
  77. @classmethod
  78. async def verify_encryption_key(
  79. cls,
  80. device: BLEDevice,
  81. key_id: str,
  82. encryption_key: str,
  83. model: SwitchbotModel = SwitchbotModel.AIR_PURIFIER_US,
  84. **kwargs: Any,
  85. ) -> bool:
  86. return await super().verify_encryption_key(
  87. device, key_id, encryption_key, model, **kwargs
  88. )
  89. @property
  90. def color_modes(self) -> set[ColorMode]:
  91. """Return the supported color modes."""
  92. return {ColorMode.RGB}
  93. @property
  94. def color_mode(self) -> ColorMode:
  95. """Return the current color mode."""
  96. return ColorMode.RGB
  97. async def get_basic_info(self) -> dict[str, Any] | None:
  98. """Get device basic settings."""
  99. if not (
  100. res := await self._get_basic_info_by_multi_commands(
  101. self._get_basic_info_command
  102. )
  103. ):
  104. return None
  105. _data, led_settings, led_status = res[0], res[1], res[2]
  106. _LOGGER.debug(
  107. "%s %s basic info %s", self._model, self._device.address, _data.hex()
  108. )
  109. _LOGGER.debug(
  110. "%s %s led settings %s",
  111. self._model,
  112. self._device.address,
  113. led_settings.hex(),
  114. )
  115. _LOGGER.debug(
  116. "%s %s led_status %s", self._model, self._device.address, led_status.hex()
  117. )
  118. isOn = bool(_data[2] & 0b10000000)
  119. wireless_charging = bool(_data[2] & 0b01000000)
  120. version_info = (_data[2] & 0b00110000) >> 4
  121. _mode = _data[2] & 0b00000111
  122. isAqiValid = bool(_data[3] & 0b00000100)
  123. child_lock = bool(_data[3] & 0b00000010)
  124. _aqi_level = (_data[4] & 0b00000110) >> 1
  125. aqi_level = AirQualityLevel(_aqi_level).name.lower()
  126. speed = _data[6] & 0b01111111
  127. pm25 = _UNPACK_UINT16_BE(_data, 12)[0] & 0xFFF
  128. firmware = _data[15] / 10.0
  129. mode = get_air_purifier_mode(_mode, speed)
  130. self._state["r"] = led_settings[2]
  131. self._state["g"] = led_settings[3]
  132. self._state["b"] = led_settings[4]
  133. brightness = led_settings[5]
  134. light_sensitive = bool(led_status[1] & 0x02)
  135. data = {
  136. "isOn": isOn,
  137. "version_info": version_info,
  138. "mode": mode,
  139. "isAqiValid": isAqiValid,
  140. "child_lock": child_lock,
  141. "aqi_level": aqi_level,
  142. "speed": speed,
  143. "firmware": firmware,
  144. "brightness": brightness,
  145. "light_sensitive": light_sensitive,
  146. }
  147. if self._model in self._WIRELESS_MODELS:
  148. data["wireless_charging"] = wireless_charging
  149. if self._model in self._PM25_MODELS:
  150. return data | {"pm25": pm25}
  151. return data
  152. @update_after_operation
  153. async def set_preset_mode(self, preset_mode: str) -> bool:
  154. """Send command to set air purifier preset_mode."""
  155. result = await self._send_command(COMMAND_SET_MODE[preset_mode])
  156. return self._check_command_result(result, 0, {1})
  157. @update_after_operation
  158. async def set_percentage(self, percentage: int) -> bool:
  159. """Set percentage."""
  160. if not 0 <= percentage <= 100:
  161. raise ValueError("Percentage must be between 0 and 100")
  162. self._validate_current_mode()
  163. result = await self._send_command(
  164. COMMAND_SET_PERCENTAGE.format(percentage=percentage)
  165. )
  166. return self._check_command_result(result, 0, {1})
  167. def _validate_current_mode(self) -> None:
  168. """Validate current mode for setting percentage."""
  169. current_mode = self.get_current_mode()
  170. if current_mode not in self._LEVEL_MODES:
  171. raise ValueError("Percentage can only be set in LEVEL modes.")
  172. @update_after_operation
  173. async def set_brightness(self, brightness: int) -> bool:
  174. """Set brightness."""
  175. self._validate_brightness(brightness)
  176. r, g, b = (
  177. self._state.get("r", 0),
  178. self._state.get("g", 0),
  179. self._state.get("b", 0),
  180. )
  181. hex_data = f"{r:02X}{g:02X}{b:02X}{brightness:02X}"
  182. result = await self._send_command(self._set_brightness_command.format(hex_data))
  183. return self._check_command_result(result, 0, {1})
  184. @update_after_operation
  185. async def set_rgb(self, brightness: int, r: int, g: int, b: int) -> bool:
  186. """
  187. Set rgb.
  188. Note: byte order is reversed from base class (RGB+brightness
  189. instead of brightness+RGB).
  190. """
  191. self._validate_brightness(brightness)
  192. self._validate_rgb(r, g, b)
  193. hex_data = f"{r:02X}{g:02X}{b:02X}{brightness:02X}"
  194. result = await self._send_command(self._set_rgb_command.format(hex_data))
  195. return self._check_command_result(result, 0, {1})
  196. @update_after_operation
  197. async def turn_led_on(self) -> bool:
  198. """Turn on LED."""
  199. result = await self._send_command(self._turn_led_on_command)
  200. return self._check_command_result(result, 0, {1})
  201. @update_after_operation
  202. async def turn_led_off(self) -> bool:
  203. """Turn off LED."""
  204. result = await self._send_command(self._turn_led_off_command)
  205. return self._check_command_result(result, 0, {1})
  206. @update_after_operation
  207. async def open_light_sensitive_switch(self) -> bool:
  208. """
  209. Open the light sensitive switch.
  210. This will allow the LED to automatically adjust brightness based on ambient light.
  211. The LED will turn on in dark environments and turn off in bright environments.
  212. """
  213. result = await self._send_command(self._open_light_sensitive_switch_command)
  214. return self._check_command_result(result, 0, {1})
  215. @update_after_operation
  216. async def close_light_sensitive_switch(self) -> bool:
  217. """
  218. Close the light sensitive switch.
  219. Since the current protocol does not support obtaining the LED status,
  220. sending an on or off command will turn off the light sensitive switch.
  221. """
  222. result = await self._send_command(self._turn_led_on_command)
  223. return self._check_command_result(result, 0, {1})
  224. def _check_wireless_charging_supported(self) -> None:
  225. if self._model not in self._WIRELESS_MODELS:
  226. raise SwitchbotOperationError(
  227. "Wireless charging is only available on table versions"
  228. f" (current model={self._model})"
  229. )
  230. @update_after_operation
  231. async def open_wireless_charging(self) -> bool:
  232. """Enable the wireless charging pad (table models only)."""
  233. self._check_wireless_charging_supported()
  234. result = await self._send_command(self._open_wireless_charging_command)
  235. return self._check_command_result(result, 0, {1})
  236. @update_after_operation
  237. async def close_wireless_charging(self) -> bool:
  238. """Disable the wireless charging pad (table models only)."""
  239. self._check_wireless_charging_supported()
  240. result = await self._send_command(self._close_wireless_charging_command)
  241. return self._check_command_result(result, 0, {1})
  242. def is_on(self) -> bool | None:
  243. """Return air purifier state from cache."""
  244. return self._get_adv_value("isOn")
  245. def get_current_aqi_level(self) -> Any:
  246. """Return cached aqi level."""
  247. return self._get_adv_value("aqi_level")
  248. def get_current_pm25(self) -> Any:
  249. """Return cached pm25."""
  250. return self._get_adv_value("pm25")
  251. def get_current_mode(self) -> Any:
  252. """Return cached mode."""
  253. return self._get_adv_value("mode")
  254. def is_child_lock_on(self) -> bool | None:
  255. """Return child lock state from cache."""
  256. return self._get_adv_value("child_lock")
  257. def is_wireless_charging_on(self) -> bool | None:
  258. """Return wireless charging state from cache."""
  259. return self._get_adv_value("wireless_charging")
  260. def get_current_percentage(self) -> int | None:
  261. """Return cached percentage."""
  262. return self._get_adv_value("speed")
  263. def is_light_sensitive_on(self) -> bool | None:
  264. """Return light sensitive state from cache."""
  265. return self._get_adv_value("light_sensitive")