fan.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. """Library to handle connection with Switchbot."""
  2. from __future__ import annotations
  3. import logging
  4. from enum import Enum
  5. from typing import Any, ClassVar
  6. from ..const.fan import (
  7. FanMode,
  8. HorizontalOscillationAngle,
  9. NightLightState,
  10. StandingFanMode,
  11. VerticalOscillationAngle,
  12. )
  13. from .device import (
  14. DEVICE_GET_BASIC_SETTINGS_KEY,
  15. SwitchbotSequenceDevice,
  16. update_after_operation,
  17. )
  18. _LOGGER = logging.getLogger(__name__)
  19. COMMAND_HEAD = "570f41"
  20. # Circulator Fan (single-axis): start/stop oscillation with V kept unchanged.
  21. # These also serve as the explicit horizontal-only commands since the byte
  22. # layout is identical.
  23. COMMAND_START_OSCILLATION = f"{COMMAND_HEAD}020101ff"
  24. COMMAND_STOP_OSCILLATION = f"{COMMAND_HEAD}020102ff"
  25. COMMAND_START_HORIZONTAL_OSCILLATION = COMMAND_START_OSCILLATION
  26. COMMAND_STOP_HORIZONTAL_OSCILLATION = COMMAND_STOP_OSCILLATION
  27. COMMAND_START_VERTICAL_OSCILLATION = f"{COMMAND_HEAD}0201ff01" # H keep, V start
  28. COMMAND_STOP_VERTICAL_OSCILLATION = f"{COMMAND_HEAD}0201ff02" # H keep, V stop
  29. # Standing Fan (dual-axis): start/stop both axes at once.
  30. COMMAND_START_OSCILLATION_ALL_AXES = f"{COMMAND_HEAD}02010101"
  31. COMMAND_STOP_OSCILLATION_ALL_AXES = f"{COMMAND_HEAD}02010202"
  32. COMMAND_SET_OSCILLATION_PARAMS = f"{COMMAND_HEAD}0202" # +angles
  33. COMMAND_SET_NIGHT_LIGHT = f"{COMMAND_HEAD}0502" # +state
  34. # Standing Fan (FAN2) extra controls.
  35. COMMAND_SET_DISPLAY_LIGHT = f"{COMMAND_HEAD}0501" # +state + FFFF (front LED display)
  36. COMMAND_SET_SOUND = f"{COMMAND_HEAD}0601" # +level (64 on / 00 off)
  37. COMMAND_SET_AUTO_RECENTER = f"{COMMAND_HEAD}0205" # +both axes (0101 on / 0202 off)
  38. COMMAND_SET_CHILD_LOCK = f"{COMMAND_HEAD}07" # +state (01 on / 02 off)
  39. COMMAND_SET_MODE = {
  40. FanMode.NORMAL.name.lower(): f"{COMMAND_HEAD}030101ff",
  41. FanMode.NATURAL.name.lower(): f"{COMMAND_HEAD}030102ff",
  42. FanMode.SLEEP.name.lower(): f"{COMMAND_HEAD}030103",
  43. FanMode.BABY.name.lower(): f"{COMMAND_HEAD}030104",
  44. }
  45. COMMAND_SET_STANDING_FAN_MODE = {
  46. **COMMAND_SET_MODE,
  47. StandingFanMode.CUSTOM_NATURAL.name.lower(): f"{COMMAND_HEAD}030105",
  48. }
  49. COMMAND_SET_PERCENTAGE = f"{COMMAND_HEAD}0302" # +speed
  50. COMMAND_GET_BASIC_INFO = "570f428102"
  51. class SwitchbotFan(SwitchbotSequenceDevice):
  52. """Representation of a Switchbot Circulator Fan."""
  53. _turn_on_command = f"{COMMAND_HEAD}0101"
  54. _turn_off_command = f"{COMMAND_HEAD}0102"
  55. _mode_enum: ClassVar[type[Enum]] = FanMode
  56. _command_set_mode: ClassVar[dict[str, str]] = COMMAND_SET_MODE
  57. _command_start_oscillation: ClassVar[str] = COMMAND_START_OSCILLATION
  58. _command_stop_oscillation: ClassVar[str] = COMMAND_STOP_OSCILLATION
  59. async def get_basic_info(self) -> dict[str, Any] | None:
  60. """Get device basic settings."""
  61. if not (_data := await self._get_basic_info(COMMAND_GET_BASIC_INFO)):
  62. return None
  63. if not (_data1 := await self._get_basic_info(DEVICE_GET_BASIC_SETTINGS_KEY)):
  64. return None
  65. _LOGGER.debug("data: %s", _data)
  66. return self._parse_basic_info(_data, _data1)
  67. def _parse_basic_info(self, _data: bytes, _data1: bytes) -> dict[str, Any]:
  68. """Decode the basic-info connection response into a state dict."""
  69. battery = _data[2] & 0b01111111
  70. isOn = bool(_data[3] & 0b10000000)
  71. oscillating_horizontal = bool(_data[3] & 0b01000000)
  72. oscillating_vertical = bool(_data[3] & 0b00100000)
  73. oscillating = oscillating_horizontal or oscillating_vertical
  74. _mode = _data[8] & 0b00000111
  75. mode_enum = self._mode_enum
  76. max_mode = max(m.value for m in mode_enum)
  77. mode = mode_enum(_mode).name.lower() if 1 <= _mode <= max_mode else None
  78. speed = _data[9]
  79. firmware = _data1[2] / 10.0
  80. info: dict[str, Any] = {
  81. "battery": battery,
  82. "isOn": isOn,
  83. "oscillating": oscillating,
  84. "oscillating_horizontal": oscillating_horizontal,
  85. "oscillating_vertical": oscillating_vertical,
  86. "mode": mode,
  87. "speed": speed,
  88. "firmware": firmware,
  89. }
  90. # Night light is only meaningful for models that expose it. Copy from
  91. # the latest advertisement parse if the parser put it there.
  92. night_light = self._get_adv_value("nightLight")
  93. if night_light is not None:
  94. info["nightLight"] = night_light
  95. return info
  96. async def _get_basic_info(self, cmd: str) -> bytes | None:
  97. """Return basic info of device."""
  98. _data = await self._send_command(key=cmd, retry=self._retry_count)
  99. if _data in (b"\x07", b"\x00"):
  100. _LOGGER.error("Unsuccessful, please try again")
  101. return None
  102. return _data
  103. @update_after_operation
  104. async def set_preset_mode(self, preset_mode: str) -> bool:
  105. """Send command to set fan preset_mode."""
  106. result = await self._send_command(self._command_set_mode[preset_mode])
  107. return self._check_command_result(result, 0, {1})
  108. @update_after_operation
  109. async def set_percentage(self, percentage: int) -> bool:
  110. """Send command to set fan percentage."""
  111. result = await self._send_command(f"{COMMAND_SET_PERCENTAGE}{percentage:02X}")
  112. return self._check_command_result(result, 0, {1})
  113. @update_after_operation
  114. async def set_oscillation(self, oscillating: bool) -> bool:
  115. """Send command to set fan oscillation"""
  116. cmd = (
  117. self._command_start_oscillation
  118. if oscillating
  119. else self._command_stop_oscillation
  120. )
  121. result = await self._send_command(cmd)
  122. return self._check_command_result(result, 0, {1})
  123. @update_after_operation
  124. async def set_horizontal_oscillation(self, oscillating: bool) -> bool:
  125. """Send command to set fan horizontal (left-right) oscillation only."""
  126. cmd = (
  127. COMMAND_START_HORIZONTAL_OSCILLATION
  128. if oscillating
  129. else COMMAND_STOP_HORIZONTAL_OSCILLATION
  130. )
  131. result = await self._send_command(cmd)
  132. return self._check_command_result(result, 0, {1})
  133. @update_after_operation
  134. async def set_vertical_oscillation(self, oscillating: bool) -> bool:
  135. """Send command to set fan vertical (up-down) oscillation only."""
  136. cmd = (
  137. COMMAND_START_VERTICAL_OSCILLATION
  138. if oscillating
  139. else COMMAND_STOP_VERTICAL_OSCILLATION
  140. )
  141. result = await self._send_command(cmd)
  142. return self._check_command_result(result, 0, {1})
  143. def get_current_percentage(self) -> Any:
  144. """Return cached percentage."""
  145. return self._get_adv_value("speed")
  146. def is_on(self) -> bool | None:
  147. """Return fan state from cache."""
  148. return self._get_adv_value("isOn")
  149. def get_oscillating_state(self) -> Any:
  150. """Return cached oscillating."""
  151. return self._get_adv_value("oscillating")
  152. def get_horizontal_oscillating_state(self) -> Any:
  153. """Return cached horizontal (left-right) oscillating state."""
  154. return self._get_adv_value("oscillating_horizontal")
  155. def get_vertical_oscillating_state(self) -> Any:
  156. """Return cached vertical (up-down) oscillating state."""
  157. return self._get_adv_value("oscillating_vertical")
  158. def get_current_mode(self) -> Any:
  159. """Return cached mode."""
  160. return self._get_adv_value("mode")
  161. class SwitchbotStandingFan(SwitchbotFan):
  162. """Representation of a Switchbot Standing Fan (FAN2)."""
  163. _mode_enum: ClassVar[type[Enum]] = StandingFanMode
  164. _command_set_mode: ClassVar[dict[str, str]] = COMMAND_SET_STANDING_FAN_MODE
  165. _command_start_oscillation: ClassVar[str] = COMMAND_START_OSCILLATION_ALL_AXES
  166. _command_stop_oscillation: ClassVar[str] = COMMAND_STOP_OSCILLATION_ALL_AXES
  167. def _parse_basic_info(self, _data: bytes, _data1: bytes) -> dict[str, Any]:
  168. """Add the Standing-Fan-only fields to the basic-info response."""
  169. info = super()._parse_basic_info(_data, _data1)
  170. # Sweep angle as the raw device byte: horizontal is the angle in degrees
  171. # (30/60/90); vertical encodes 90 as 95 (see VerticalOscillationAngle).
  172. info["oscillating_horizontal_angle"] = _data[4]
  173. info["oscillating_vertical_angle"] = _data[6]
  174. info["charging"] = bool(_data[2] & 0b10000000)
  175. info["child_lock"] = bool(_data[3] & 0b00000001)
  176. info["display"] = bool(_data[3] & 0b00000010)
  177. # bit 4 = horizontal axis, bit 3 = vertical; the app toggles both at once.
  178. info["auto_recenter"] = bool(_data[3] & 0b00011000)
  179. if len(_data) > 10:
  180. info["sound"] = bool(_data[10] & 0b01111111)
  181. return info
  182. @update_after_operation
  183. async def set_horizontal_oscillation_angle(
  184. self, angle: HorizontalOscillationAngle | int
  185. ) -> bool:
  186. """Set horizontal oscillation angle (30 / 60 / 90 degrees)."""
  187. value = HorizontalOscillationAngle(angle).value
  188. cmd = f"{COMMAND_SET_OSCILLATION_PARAMS}{value:02X}FFFFFF"
  189. result = await self._send_command(cmd)
  190. return self._check_command_result(result, 0, {1})
  191. @update_after_operation
  192. async def set_vertical_oscillation_angle(
  193. self, angle: VerticalOscillationAngle | int
  194. ) -> bool:
  195. """
  196. Set vertical oscillation angle (30 / 60 / 90 degrees).
  197. The device uses a different byte encoding on the vertical axis than
  198. on the horizontal one: 90° maps to byte 0x5F (95), not 0x5A (90),
  199. which the firmware interprets as an axis halt. Use
  200. `VerticalOscillationAngle` (or the raw byte values 30 / 60 / 95).
  201. """
  202. value = VerticalOscillationAngle(angle).value
  203. cmd = f"{COMMAND_SET_OSCILLATION_PARAMS}FFFF{value:02X}FF"
  204. result = await self._send_command(cmd)
  205. return self._check_command_result(result, 0, {1})
  206. @update_after_operation
  207. async def set_night_light(self, state: NightLightState | int) -> bool:
  208. """Set night-light state (LEVEL_1, LEVEL_2, OFF)."""
  209. value = NightLightState(state).value
  210. cmd = f"{COMMAND_SET_NIGHT_LIGHT}{value:02X}FFFF"
  211. result = await self._send_command(cmd)
  212. return self._check_command_result(result, 0, {1})
  213. @update_after_operation
  214. async def set_child_lock(self, enabled: bool) -> bool:
  215. """Enable or disable the child lock."""
  216. cmd = f"{COMMAND_SET_CHILD_LOCK}{'01' if enabled else '02'}"
  217. result = await self._send_command(cmd)
  218. return self._check_command_result(result, 0, {1})
  219. @update_after_operation
  220. async def set_display(self, enabled: bool) -> bool:
  221. """Turn the front display (LED) on or off."""
  222. cmd = f"{COMMAND_SET_DISPLAY_LIGHT}{'01' if enabled else '02'}FFFF"
  223. result = await self._send_command(cmd)
  224. return self._check_command_result(result, 0, {1})
  225. @update_after_operation
  226. async def set_sound(self, enabled: bool) -> bool:
  227. """Turn the key tone (buzzer) on or off."""
  228. cmd = f"{COMMAND_SET_SOUND}{'64' if enabled else '00'}"
  229. result = await self._send_command(cmd)
  230. return self._check_command_result(result, 0, {1})
  231. @update_after_operation
  232. async def set_auto_recenter(self, enabled: bool) -> bool:
  233. """Enable or disable auto return-to-center on both axes."""
  234. cmd = f"{COMMAND_SET_AUTO_RECENTER}{'0101' if enabled else '0202'}"
  235. result = await self._send_command(cmd)
  236. return self._check_command_result(result, 0, {1})
  237. def get_horizontal_oscillation_angle(self) -> int | None:
  238. """Return cached horizontal oscillation angle (raw device byte)."""
  239. return self._get_adv_value("oscillating_horizontal_angle")
  240. def get_vertical_oscillation_angle(self) -> int | None:
  241. """Return cached vertical oscillation angle (raw device byte; 90° = 95)."""
  242. return self._get_adv_value("oscillating_vertical_angle")
  243. def get_night_light_state(self) -> int | None:
  244. """Return cached night light state."""
  245. return self._get_adv_value("nightLight")
  246. def is_charging(self) -> bool | None:
  247. """Return cached charging state."""
  248. return self._get_adv_value("charging")
  249. def get_child_lock(self) -> bool | None:
  250. """Return cached child-lock state."""
  251. return self._get_adv_value("child_lock")
  252. def get_display(self) -> bool | None:
  253. """Return cached front-display (LED) state."""
  254. return self._get_adv_value("display")
  255. def get_sound(self) -> bool | None:
  256. """Return cached key-tone (buzzer) state."""
  257. return self._get_adv_value("sound")
  258. def get_auto_recenter(self) -> bool | None:
  259. """Return cached auto-recenter (return-to-center) state."""
  260. return self._get_adv_value("auto_recenter")