air_purifier.py 10 KB

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