fan.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  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. COMMAND_SET_MODE = {
  35. FanMode.NORMAL.name.lower(): f"{COMMAND_HEAD}030101ff",
  36. FanMode.NATURAL.name.lower(): f"{COMMAND_HEAD}030102ff",
  37. FanMode.SLEEP.name.lower(): f"{COMMAND_HEAD}030103",
  38. FanMode.BABY.name.lower(): f"{COMMAND_HEAD}030104",
  39. }
  40. COMMAND_SET_STANDING_FAN_MODE = {
  41. **COMMAND_SET_MODE,
  42. StandingFanMode.CUSTOM_NATURAL.name.lower(): f"{COMMAND_HEAD}030105",
  43. }
  44. COMMAND_SET_PERCENTAGE = f"{COMMAND_HEAD}0302" # +speed
  45. COMMAND_GET_BASIC_INFO = "570f428102"
  46. class SwitchbotFan(SwitchbotSequenceDevice):
  47. """Representation of a Switchbot Circulator Fan."""
  48. _turn_on_command = f"{COMMAND_HEAD}0101"
  49. _turn_off_command = f"{COMMAND_HEAD}0102"
  50. _mode_enum: ClassVar[type[Enum]] = FanMode
  51. _command_set_mode: ClassVar[dict[str, str]] = COMMAND_SET_MODE
  52. _command_start_oscillation: ClassVar[str] = COMMAND_START_OSCILLATION
  53. _command_stop_oscillation: ClassVar[str] = COMMAND_STOP_OSCILLATION
  54. async def get_basic_info(self) -> dict[str, Any] | None:
  55. """Get device basic settings."""
  56. if not (_data := await self._get_basic_info(COMMAND_GET_BASIC_INFO)):
  57. return None
  58. if not (_data1 := await self._get_basic_info(DEVICE_GET_BASIC_SETTINGS_KEY)):
  59. return None
  60. _LOGGER.debug("data: %s", _data)
  61. battery = _data[2] & 0b01111111
  62. isOn = bool(_data[3] & 0b10000000)
  63. oscillating_horizontal = bool(_data[3] & 0b01000000)
  64. oscillating_vertical = bool(_data[3] & 0b00100000)
  65. oscillating = oscillating_horizontal or oscillating_vertical
  66. _mode = _data[8] & 0b00000111
  67. mode_enum = self._mode_enum
  68. max_mode = max(m.value for m in mode_enum)
  69. mode = mode_enum(_mode).name.lower() if 1 <= _mode <= max_mode else None
  70. speed = _data[9]
  71. firmware = _data1[2] / 10.0
  72. info: dict[str, Any] = {
  73. "battery": battery,
  74. "isOn": isOn,
  75. "oscillating": oscillating,
  76. "oscillating_horizontal": oscillating_horizontal,
  77. "oscillating_vertical": oscillating_vertical,
  78. "mode": mode,
  79. "speed": speed,
  80. "firmware": firmware,
  81. }
  82. # Night light is only meaningful for models that expose it. Copy from
  83. # the latest advertisement parse if the parser put it there.
  84. night_light = self._get_adv_value("nightLight")
  85. if night_light is not None:
  86. info["nightLight"] = night_light
  87. return info
  88. async def _get_basic_info(self, cmd: str) -> bytes | None:
  89. """Return basic info of device."""
  90. _data = await self._send_command(key=cmd, retry=self._retry_count)
  91. if _data in (b"\x07", b"\x00"):
  92. _LOGGER.error("Unsuccessful, please try again")
  93. return None
  94. return _data
  95. @update_after_operation
  96. async def set_preset_mode(self, preset_mode: str) -> bool:
  97. """Send command to set fan preset_mode."""
  98. result = await self._send_command(self._command_set_mode[preset_mode])
  99. return self._check_command_result(result, 0, {1})
  100. @update_after_operation
  101. async def set_percentage(self, percentage: int) -> bool:
  102. """Send command to set fan percentage."""
  103. result = await self._send_command(f"{COMMAND_SET_PERCENTAGE}{percentage:02X}")
  104. return self._check_command_result(result, 0, {1})
  105. @update_after_operation
  106. async def set_oscillation(self, oscillating: bool) -> bool:
  107. """Send command to set fan oscillation"""
  108. cmd = (
  109. self._command_start_oscillation
  110. if oscillating
  111. else self._command_stop_oscillation
  112. )
  113. result = await self._send_command(cmd)
  114. return self._check_command_result(result, 0, {1})
  115. @update_after_operation
  116. async def set_horizontal_oscillation(self, oscillating: bool) -> bool:
  117. """Send command to set fan horizontal (left-right) oscillation only."""
  118. cmd = (
  119. COMMAND_START_HORIZONTAL_OSCILLATION
  120. if oscillating
  121. else COMMAND_STOP_HORIZONTAL_OSCILLATION
  122. )
  123. result = await self._send_command(cmd)
  124. return self._check_command_result(result, 0, {1})
  125. @update_after_operation
  126. async def set_vertical_oscillation(self, oscillating: bool) -> bool:
  127. """Send command to set fan vertical (up-down) oscillation only."""
  128. cmd = (
  129. COMMAND_START_VERTICAL_OSCILLATION
  130. if oscillating
  131. else COMMAND_STOP_VERTICAL_OSCILLATION
  132. )
  133. result = await self._send_command(cmd)
  134. return self._check_command_result(result, 0, {1})
  135. def get_current_percentage(self) -> Any:
  136. """Return cached percentage."""
  137. return self._get_adv_value("speed")
  138. def is_on(self) -> bool | None:
  139. """Return fan state from cache."""
  140. return self._get_adv_value("isOn")
  141. def get_oscillating_state(self) -> Any:
  142. """Return cached oscillating."""
  143. return self._get_adv_value("oscillating")
  144. def get_horizontal_oscillating_state(self) -> Any:
  145. """Return cached horizontal (left-right) oscillating state."""
  146. return self._get_adv_value("oscillating_horizontal")
  147. def get_vertical_oscillating_state(self) -> Any:
  148. """Return cached vertical (up-down) oscillating state."""
  149. return self._get_adv_value("oscillating_vertical")
  150. def get_current_mode(self) -> Any:
  151. """Return cached mode."""
  152. return self._get_adv_value("mode")
  153. class SwitchbotStandingFan(SwitchbotFan):
  154. """Representation of a Switchbot Standing Fan (FAN2)."""
  155. _mode_enum: ClassVar[type[Enum]] = StandingFanMode
  156. _command_set_mode: ClassVar[dict[str, str]] = COMMAND_SET_STANDING_FAN_MODE
  157. _command_start_oscillation: ClassVar[str] = COMMAND_START_OSCILLATION_ALL_AXES
  158. _command_stop_oscillation: ClassVar[str] = COMMAND_STOP_OSCILLATION_ALL_AXES
  159. @update_after_operation
  160. async def set_horizontal_oscillation_angle(
  161. self, angle: HorizontalOscillationAngle | int
  162. ) -> bool:
  163. """Set horizontal oscillation angle (30 / 60 / 90 degrees)."""
  164. value = HorizontalOscillationAngle(angle).value
  165. cmd = f"{COMMAND_SET_OSCILLATION_PARAMS}{value:02X}FFFFFF"
  166. result = await self._send_command(cmd)
  167. return self._check_command_result(result, 0, {1})
  168. @update_after_operation
  169. async def set_vertical_oscillation_angle(
  170. self, angle: VerticalOscillationAngle | int
  171. ) -> bool:
  172. """
  173. Set vertical oscillation angle (30 / 60 / 90 degrees).
  174. The device uses a different byte encoding on the vertical axis than
  175. on the horizontal one — 90° maps to byte 0x5F (95), not 0x5A (90),
  176. which the firmware interprets as an axis halt. Use
  177. `VerticalOscillationAngle` (or the raw byte values 30 / 60 / 95).
  178. """
  179. value = VerticalOscillationAngle(angle).value
  180. cmd = f"{COMMAND_SET_OSCILLATION_PARAMS}FFFF{value:02X}FF"
  181. result = await self._send_command(cmd)
  182. return self._check_command_result(result, 0, {1})
  183. @update_after_operation
  184. async def set_night_light(self, state: NightLightState | int) -> bool:
  185. """Set night-light state (LEVEL_1, LEVEL_2, OFF)."""
  186. value = NightLightState(state).value
  187. cmd = f"{COMMAND_SET_NIGHT_LIGHT}{value:02X}FFFF"
  188. result = await self._send_command(cmd)
  189. return self._check_command_result(result, 0, {1})
  190. def get_night_light_state(self) -> int | None:
  191. """Return cached night light state."""
  192. return self._get_adv_value("nightLight")