fan.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  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 import SwitchbotModel
  7. from ..const.fan import (
  8. CirculatorFanProMode,
  9. FanMode,
  10. HorizontalOscillationAngle,
  11. NightLightState,
  12. StandingFanMode,
  13. VerticalOscillationAngle,
  14. )
  15. from .device import (
  16. DEVICE_GET_BASIC_SETTINGS_KEY,
  17. SwitchbotEncryptedDevice,
  18. SwitchbotSequenceDevice,
  19. update_after_operation,
  20. )
  21. _LOGGER = logging.getLogger(__name__)
  22. COMMAND_HEAD = "570f41"
  23. # Circulator Fan (single-axis): start/stop oscillation with V kept unchanged.
  24. # These also serve as the explicit horizontal-only commands since the byte
  25. # layout is identical.
  26. COMMAND_START_OSCILLATION = f"{COMMAND_HEAD}020101ff"
  27. COMMAND_STOP_OSCILLATION = f"{COMMAND_HEAD}020102ff"
  28. COMMAND_START_HORIZONTAL_OSCILLATION = COMMAND_START_OSCILLATION
  29. COMMAND_STOP_HORIZONTAL_OSCILLATION = COMMAND_STOP_OSCILLATION
  30. COMMAND_START_VERTICAL_OSCILLATION = f"{COMMAND_HEAD}0201ff01" # H keep, V start
  31. COMMAND_STOP_VERTICAL_OSCILLATION = f"{COMMAND_HEAD}0201ff02" # H keep, V stop
  32. # Standing Fan (dual-axis): start/stop both axes at once.
  33. COMMAND_START_OSCILLATION_ALL_AXES = f"{COMMAND_HEAD}02010101"
  34. COMMAND_STOP_OSCILLATION_ALL_AXES = f"{COMMAND_HEAD}02010202"
  35. COMMAND_SET_OSCILLATION_PARAMS = f"{COMMAND_HEAD}0202" # +angles
  36. COMMAND_SET_NIGHT_LIGHT = f"{COMMAND_HEAD}0502" # +state
  37. # Standing Fan (FAN2) extra controls.
  38. COMMAND_SET_DISPLAY_LIGHT = f"{COMMAND_HEAD}0501" # +state + FFFF (front LED display)
  39. COMMAND_SET_SOUND = f"{COMMAND_HEAD}0601" # +level (64 on / 00 off)
  40. COMMAND_SET_AUTO_RECENTER = f"{COMMAND_HEAD}0205" # +both axes (0101 on / 0202 off)
  41. COMMAND_SET_CHILD_LOCK = f"{COMMAND_HEAD}07" # +state (01 on / 02 off)
  42. COMMAND_SET_MODE = {
  43. FanMode.NORMAL.name.lower(): f"{COMMAND_HEAD}030101ff",
  44. FanMode.NATURAL.name.lower(): f"{COMMAND_HEAD}030102ff",
  45. FanMode.SLEEP.name.lower(): f"{COMMAND_HEAD}030103",
  46. FanMode.BABY.name.lower(): f"{COMMAND_HEAD}030104",
  47. }
  48. COMMAND_SET_STANDING_FAN_MODE = {
  49. **COMMAND_SET_MODE,
  50. StandingFanMode.CUSTOM_NATURAL.name.lower(): f"{COMMAND_HEAD}030105",
  51. }
  52. COMMAND_SET_PERCENTAGE = f"{COMMAND_HEAD}0302" # +speed
  53. COMMAND_GET_BASIC_INFO = "570f428102"
  54. class SwitchbotFan(SwitchbotSequenceDevice):
  55. """Representation of a Switchbot Circulator Fan."""
  56. _turn_on_command = f"{COMMAND_HEAD}0101"
  57. _turn_off_command = f"{COMMAND_HEAD}0102"
  58. _mode_enum: ClassVar[type[Enum]] = FanMode
  59. _command_set_mode: ClassVar[dict[str, str]] = COMMAND_SET_MODE
  60. _command_start_oscillation: ClassVar[str] = COMMAND_START_OSCILLATION
  61. _command_stop_oscillation: ClassVar[str] = COMMAND_STOP_OSCILLATION
  62. async def get_basic_info(self) -> dict[str, Any] | None:
  63. """Get device basic settings."""
  64. if not (_data := await self._get_basic_info(COMMAND_GET_BASIC_INFO)):
  65. return None
  66. if not (_data1 := await self._get_basic_info(DEVICE_GET_BASIC_SETTINGS_KEY)):
  67. return None
  68. _LOGGER.debug("data: %s", _data)
  69. return self._parse_basic_info(_data, _data1)
  70. def _parse_basic_info(self, _data: bytes, _data1: bytes) -> dict[str, Any]:
  71. """Decode the basic-info connection response into a state dict."""
  72. battery = _data[2] & 0b01111111
  73. isOn = bool(_data[3] & 0b10000000)
  74. oscillating_horizontal = bool(_data[3] & 0b01000000)
  75. oscillating_vertical = bool(_data[3] & 0b00100000)
  76. oscillating = oscillating_horizontal or oscillating_vertical
  77. _mode = _data[8] & 0b00000111
  78. mode_enum = self._mode_enum
  79. max_mode = max(m.value for m in mode_enum)
  80. mode = mode_enum(_mode).name.lower() if 1 <= _mode <= max_mode else None
  81. speed = _data[9]
  82. firmware = _data1[2] / 10.0
  83. info: dict[str, Any] = {
  84. "battery": battery,
  85. "isOn": isOn,
  86. "oscillating": oscillating,
  87. "oscillating_horizontal": oscillating_horizontal,
  88. "oscillating_vertical": oscillating_vertical,
  89. "mode": mode,
  90. "speed": speed,
  91. "firmware": firmware,
  92. }
  93. # Night light is only meaningful for models that expose it. Copy from
  94. # the latest advertisement parse if the parser put it there.
  95. night_light = self._get_adv_value("nightLight")
  96. if night_light is not None:
  97. info["nightLight"] = night_light
  98. return info
  99. async def _get_basic_info(self, cmd: str) -> bytes | None:
  100. """Return basic info of device."""
  101. _data = await self._send_command(key=cmd, retry=self._retry_count)
  102. if _data in (b"\x07", b"\x00"):
  103. _LOGGER.error("Unsuccessful, please try again")
  104. return None
  105. return _data
  106. @update_after_operation
  107. async def set_preset_mode(self, preset_mode: str) -> bool:
  108. """Send command to set fan preset_mode."""
  109. result = await self._send_command(self._command_set_mode[preset_mode])
  110. return self._check_command_result(result, 0, {1})
  111. @update_after_operation
  112. async def set_percentage(self, percentage: int) -> bool:
  113. """Send command to set fan percentage."""
  114. result = await self._send_command(f"{COMMAND_SET_PERCENTAGE}{percentage:02X}")
  115. return self._check_command_result(result, 0, {1})
  116. @update_after_operation
  117. async def set_oscillation(self, oscillating: bool) -> bool:
  118. """Send command to set fan oscillation"""
  119. cmd = (
  120. self._command_start_oscillation
  121. if oscillating
  122. else self._command_stop_oscillation
  123. )
  124. result = await self._send_command(cmd)
  125. return self._check_command_result(result, 0, {1})
  126. @update_after_operation
  127. async def set_horizontal_oscillation(self, oscillating: bool) -> bool:
  128. """Send command to set fan horizontal (left-right) oscillation only."""
  129. cmd = (
  130. COMMAND_START_HORIZONTAL_OSCILLATION
  131. if oscillating
  132. else COMMAND_STOP_HORIZONTAL_OSCILLATION
  133. )
  134. result = await self._send_command(cmd)
  135. return self._check_command_result(result, 0, {1})
  136. @update_after_operation
  137. async def set_vertical_oscillation(self, oscillating: bool) -> bool:
  138. """Send command to set fan vertical (up-down) oscillation only."""
  139. cmd = (
  140. COMMAND_START_VERTICAL_OSCILLATION
  141. if oscillating
  142. else COMMAND_STOP_VERTICAL_OSCILLATION
  143. )
  144. result = await self._send_command(cmd)
  145. return self._check_command_result(result, 0, {1})
  146. def get_current_percentage(self) -> Any:
  147. """Return cached percentage."""
  148. return self._get_adv_value("speed")
  149. def is_on(self) -> bool | None:
  150. """Return fan state from cache."""
  151. return self._get_adv_value("isOn")
  152. def get_oscillating_state(self) -> Any:
  153. """Return cached oscillating."""
  154. return self._get_adv_value("oscillating")
  155. def get_horizontal_oscillating_state(self) -> Any:
  156. """Return cached horizontal (left-right) oscillating state."""
  157. return self._get_adv_value("oscillating_horizontal")
  158. def get_vertical_oscillating_state(self) -> Any:
  159. """Return cached vertical (up-down) oscillating state."""
  160. return self._get_adv_value("oscillating_vertical")
  161. def get_current_mode(self) -> Any:
  162. """Return cached mode."""
  163. return self._get_adv_value("mode")
  164. @property
  165. def fan_modes(self) -> list[str]:
  166. """Return the supported preset (wind) modes for this device."""
  167. return self._mode_enum.get_modes()
  168. class SwitchbotStandingFan(SwitchbotFan):
  169. """Representation of a Switchbot Standing Fan (FAN2)."""
  170. _mode_enum: ClassVar[type[Enum]] = StandingFanMode
  171. _command_set_mode: ClassVar[dict[str, str]] = COMMAND_SET_STANDING_FAN_MODE
  172. _command_start_oscillation: ClassVar[str] = COMMAND_START_OSCILLATION_ALL_AXES
  173. _command_stop_oscillation: ClassVar[str] = COMMAND_STOP_OSCILLATION_ALL_AXES
  174. def _parse_basic_info(self, _data: bytes, _data1: bytes) -> dict[str, Any]:
  175. """Add the Standing-Fan-only fields to the basic-info response."""
  176. info = super()._parse_basic_info(_data, _data1)
  177. # Sweep angle as the raw device byte: horizontal is the angle in degrees
  178. # (30/60/90); vertical encodes 90 as 95 (see VerticalOscillationAngle).
  179. info["oscillating_horizontal_angle"] = _data[4]
  180. info["oscillating_vertical_angle"] = _data[6]
  181. info["charging"] = bool(_data[2] & 0b10000000)
  182. info["child_lock"] = bool(_data[3] & 0b00000001)
  183. info["display"] = bool(_data[3] & 0b00000010)
  184. # bit 4 = horizontal axis, bit 3 = vertical; the app toggles both at once.
  185. info["auto_recenter"] = bool(_data[3] & 0b00011000)
  186. if len(_data) > 10:
  187. info["sound"] = bool(_data[10] & 0b01111111)
  188. return info
  189. @update_after_operation
  190. async def set_horizontal_oscillation_angle(
  191. self, angle: HorizontalOscillationAngle | int
  192. ) -> bool:
  193. """Set horizontal oscillation angle (30 / 60 / 90 degrees)."""
  194. value = HorizontalOscillationAngle(angle).value
  195. cmd = f"{COMMAND_SET_OSCILLATION_PARAMS}{value:02X}FFFFFF"
  196. result = await self._send_command(cmd)
  197. return self._check_command_result(result, 0, {1})
  198. @update_after_operation
  199. async def set_vertical_oscillation_angle(
  200. self, angle: VerticalOscillationAngle | int
  201. ) -> bool:
  202. """
  203. Set vertical oscillation angle (30 / 60 / 90 degrees).
  204. The device uses a different byte encoding on the vertical axis than
  205. on the horizontal one: 90° maps to byte 0x5F (95), not 0x5A (90),
  206. which the firmware interprets as an axis halt. Use
  207. `VerticalOscillationAngle` (or the raw byte values 30 / 60 / 95).
  208. """
  209. value = VerticalOscillationAngle(angle).value
  210. cmd = f"{COMMAND_SET_OSCILLATION_PARAMS}FFFF{value:02X}FF"
  211. result = await self._send_command(cmd)
  212. return self._check_command_result(result, 0, {1})
  213. @update_after_operation
  214. async def set_night_light(self, state: NightLightState | int) -> bool:
  215. """Set night-light state (LEVEL_1, LEVEL_2, OFF)."""
  216. state = NightLightState(state)
  217. # The Standing Fan firmware ignores the OFF byte defined by
  218. # NightLightState (0x03) and only turns the night light off when it
  219. # receives 0x00. Map OFF -> 0x00 here; LEVEL_1/LEVEL_2 are unchanged.
  220. value = 0 if state is NightLightState.OFF else state.value
  221. cmd = f"{COMMAND_SET_NIGHT_LIGHT}{value:02X}FFFF"
  222. result = await self._send_command(cmd)
  223. return self._check_command_result(result, 0, {1})
  224. @update_after_operation
  225. async def set_child_lock(self, enabled: bool) -> bool:
  226. """Enable or disable the child lock."""
  227. cmd = f"{COMMAND_SET_CHILD_LOCK}{'01' if enabled else '02'}"
  228. result = await self._send_command(cmd)
  229. return self._check_command_result(result, 0, {1})
  230. @update_after_operation
  231. async def set_display(self, enabled: bool) -> bool:
  232. """Turn the front display (LED) on or off."""
  233. cmd = f"{COMMAND_SET_DISPLAY_LIGHT}{'01' if enabled else '02'}FFFF"
  234. result = await self._send_command(cmd)
  235. return self._check_command_result(result, 0, {1})
  236. @update_after_operation
  237. async def set_sound(self, enabled: bool) -> bool:
  238. """Turn the key tone (buzzer) on or off."""
  239. cmd = f"{COMMAND_SET_SOUND}{'64' if enabled else '00'}"
  240. result = await self._send_command(cmd)
  241. return self._check_command_result(result, 0, {1})
  242. @update_after_operation
  243. async def set_auto_recenter(self, enabled: bool) -> bool:
  244. """Enable or disable auto return-to-center on both axes."""
  245. cmd = f"{COMMAND_SET_AUTO_RECENTER}{'0101' if enabled else '0202'}"
  246. result = await self._send_command(cmd)
  247. return self._check_command_result(result, 0, {1})
  248. def get_horizontal_oscillation_angle(self) -> int | None:
  249. """Return cached horizontal oscillation angle (raw device byte)."""
  250. return self._get_adv_value("oscillating_horizontal_angle")
  251. def get_vertical_oscillation_angle(self) -> int | None:
  252. """Return cached vertical oscillation angle (raw device byte; 90° = 95)."""
  253. return self._get_adv_value("oscillating_vertical_angle")
  254. def get_night_light_state(self) -> int | None:
  255. """Return cached night light state."""
  256. return self._get_adv_value("nightLight")
  257. def is_charging(self) -> bool | None:
  258. """Return cached charging state."""
  259. return self._get_adv_value("charging")
  260. def get_child_lock(self) -> bool | None:
  261. """Return cached child-lock state."""
  262. return self._get_adv_value("child_lock")
  263. def get_display(self) -> bool | None:
  264. """Return cached front-display (LED) state."""
  265. return self._get_adv_value("display")
  266. def get_sound(self) -> bool | None:
  267. """Return cached key-tone (buzzer) state."""
  268. return self._get_adv_value("sound")
  269. def get_auto_recenter(self) -> bool | None:
  270. """Return cached auto-recenter (return-to-center) state."""
  271. return self._get_adv_value("auto_recenter")
  272. class SwitchbotCirculatorFanPro(SwitchbotEncryptedDevice, SwitchbotFan):
  273. """
  274. Representation of a Switchbot Circulator Fan Pro (W1160).
  275. The Pro uses extended commands (``57 0F <subcmd> …``) with a control-source
  276. byte (0x29 = Home Assistant), wrapped in the encrypted command shell, so it
  277. extends SwitchbotEncryptedDevice. Fan power uses subcommand 0x41 (open/close
  278. sub-op 0x11); the night light uses subcommand 0x96 and supports on/off plus
  279. a choice between two brightness levels via ``turn_on_light(low=...)``
  280. (level 1 / bright or level 2 / dim).
  281. """
  282. _model = SwitchbotModel.CIRCULATOR_FAN_PRO
  283. # Fan power: ext 0x0F, subcmd 0x41, 0x11 = power, 0x29 = control source
  284. # (Home Assistant), byte5 0x01 = on / 0x00 = off / 0x02 = toggle.
  285. _turn_on_command = "570f41112901"
  286. _turn_off_command = "570f41112900"
  287. # Preset mode: the 0x11 power command also carries the running mode in byte6
  288. # (turning the fan on). The Pro's mode 0x04 is hurricane, not the legacy baby.
  289. _mode_enum: ClassVar[type[Enum]] = CirculatorFanProMode
  290. _command_set_mode: ClassVar[dict[str, str]] = {
  291. mode.name.lower(): f"570f41112901{mode.value:02X}"
  292. for mode in CirculatorFanProMode
  293. }
  294. # Night light: ext 0x0F, subcmd 0x96, byte3 0x0A, byte4 0x02, then a state
  295. # byte: bit0 = on/off (0 off / 1 on), bit1 = level (0 high / 1 low).
  296. # off = 0x00, on high = 0x01, on low = 0x03.
  297. _night_light_command = "570f960a02{}"
  298. # Oscillation: ext 0x0F, subcmd 0x02, 0x29 = control source (Home Assistant),
  299. # then per-axis action bytes [horizontal, vertical] where 0x01 = start,
  300. # 0x02 = stop, 0xFF = keep current. The Pro is dual-axis, so the all-axes
  301. # start/stop variants toggle both axes at once.
  302. _command_start_oscillation: ClassVar[str] = "570f4102290101"
  303. _command_stop_oscillation: ClassVar[str] = "570f4102290202"
  304. _command_start_horizontal_oscillation: ClassVar[str] = "570f41022901ff"
  305. _command_stop_horizontal_oscillation: ClassVar[str] = "570f41022902ff"
  306. _command_start_vertical_oscillation: ClassVar[str] = "570f410229ff01"
  307. _command_stop_vertical_oscillation: ClassVar[str] = "570f410229ff02"
  308. async def get_basic_info(self) -> dict[str, Any] | None:
  309. """
  310. Get device basic info.
  311. The Pro carries all runtime state (fan + night light) in its
  312. advertisement, so only the firmware is read here.
  313. """
  314. if not (_data1 := await self._get_basic_info(DEVICE_GET_BASIC_SETTINGS_KEY)):
  315. return None
  316. if len(_data1) <= 2:
  317. return None
  318. return {"firmware": _data1[2] / 10.0}
  319. @update_after_operation
  320. async def set_percentage(self, percentage: int) -> bool:
  321. """
  322. Set the fan speed (1-100).
  323. Speed lives in byte7 of the 0x11 power command and only applies in
  324. direct mode, so this sends "on + direct mode + speed".
  325. """
  326. percentage = max(1, min(100, percentage))
  327. result = await self._send_command(f"570f4111290101{percentage:02X}")
  328. return self._check_command_result(result, 0, {1})
  329. @update_after_operation
  330. async def set_horizontal_oscillation(self, oscillating: bool) -> bool:
  331. """Send command to set fan horizontal (left-right) oscillation only."""
  332. cmd = (
  333. self._command_start_horizontal_oscillation
  334. if oscillating
  335. else self._command_stop_horizontal_oscillation
  336. )
  337. result = await self._send_command(cmd)
  338. return self._check_command_result(result, 0, {1})
  339. @update_after_operation
  340. async def set_vertical_oscillation(self, oscillating: bool) -> bool:
  341. """Send command to set fan vertical (up-down) oscillation only."""
  342. cmd = (
  343. self._command_start_vertical_oscillation
  344. if oscillating
  345. else self._command_stop_vertical_oscillation
  346. )
  347. result = await self._send_command(cmd)
  348. return self._check_command_result(result, 0, {1})
  349. @update_after_operation
  350. async def turn_on_light(self, low: bool = False) -> bool:
  351. """Turn the night light on (low selects level 2 / dim, else level 1 / bright)."""
  352. state = 0x03 if low else 0x01
  353. result = await self._send_command(
  354. self._night_light_command.format(f"{state:02X}")
  355. )
  356. return self._check_command_result(result, 0, {1})
  357. @update_after_operation
  358. async def turn_off_light(self) -> bool:
  359. """Turn the night light off."""
  360. result = await self._send_command(self._night_light_command.format("00"))
  361. return self._check_command_result(result, 0, {1})
  362. def is_night_light_on(self) -> bool | None:
  363. """Return the cached night-light power state."""
  364. return self._get_adv_value("night_light_is_on")
  365. def get_night_light_level(self) -> int | None:
  366. """Return the cached night-light level (1 high, 2 low, 0 off)."""
  367. return self._get_adv_value("night_light_level")