test_fan.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960
  1. from unittest.mock import AsyncMock, MagicMock
  2. import pytest
  3. from bleak.backends.device import BLEDevice
  4. from switchbot import SwitchBotAdvertisement, SwitchbotModel
  5. from switchbot.adv_parsers.fan import process_standing_fan
  6. from switchbot.const.fan import (
  7. FanMode,
  8. HorizontalOscillationAngle,
  9. NightLightState,
  10. StandingFanMode,
  11. VerticalOscillationAngle,
  12. )
  13. from switchbot.devices import fan
  14. from switchbot.devices.device import SwitchbotEncryptedDevice, SwitchbotOperationError
  15. from switchbot.devices.fan import SwitchbotCirculatorFanPro, SwitchbotStandingFan
  16. from .test_adv_parser import generate_ble_device
  17. def create_device_for_command_testing(
  18. init_data: dict | None = None, model: SwitchbotModel = SwitchbotModel.CIRCULATOR_FAN
  19. ):
  20. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  21. fan_device = fan.SwitchbotFan(ble_device, model=model)
  22. fan_device.update_from_advertisement(make_advertisement_data(ble_device, init_data))
  23. fan_device._send_command = AsyncMock()
  24. fan_device._check_command_result = MagicMock()
  25. fan_device.update = AsyncMock()
  26. return fan_device
  27. def make_advertisement_data(
  28. ble_device: BLEDevice,
  29. init_data: dict | None = None,
  30. model: SwitchbotModel = SwitchbotModel.CIRCULATOR_FAN,
  31. ):
  32. """Set advertisement data with defaults."""
  33. if init_data is None:
  34. init_data = {}
  35. return SwitchBotAdvertisement(
  36. address="aa:bb:cc:dd:ee:ff",
  37. data={
  38. "rawAdvData": b"~\x00R",
  39. "data": {
  40. "isOn": True,
  41. "mode": "NORMAL",
  42. "nightLight": 3,
  43. "oscillating": False,
  44. "battery": 60,
  45. "speed": 50,
  46. }
  47. | init_data,
  48. "isEncrypted": False,
  49. "model": ",",
  50. "modelFriendlyName": model.value,
  51. "modelName": model,
  52. },
  53. device=ble_device,
  54. rssi=-80,
  55. active=True,
  56. )
  57. @pytest.mark.asyncio
  58. @pytest.mark.parametrize(
  59. ("response", "expected"),
  60. [
  61. (b"\x00", None),
  62. (b"\x07", None),
  63. (b"\x01\x02\x03", b"\x01\x02\x03"),
  64. ],
  65. )
  66. async def test__get_basic_info(response, expected):
  67. fan_device = create_device_for_command_testing()
  68. fan_device._send_command = AsyncMock(return_value=response)
  69. result = await fan_device._get_basic_info(cmd="TEST_CMD")
  70. assert result == expected
  71. @pytest.mark.asyncio
  72. @pytest.mark.parametrize(
  73. ("basic_info", "firmware_info"), [(True, False), (False, True), (False, False)]
  74. )
  75. async def test_get_basic_info_returns_none(basic_info, firmware_info):
  76. fan_device = create_device_for_command_testing()
  77. async def mock_get_basic_info(arg):
  78. if arg == fan.COMMAND_GET_BASIC_INFO:
  79. return basic_info
  80. if arg == fan.DEVICE_GET_BASIC_SETTINGS_KEY:
  81. return firmware_info
  82. return None
  83. fan_device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
  84. assert await fan_device.get_basic_info() is None
  85. @pytest.mark.asyncio
  86. @pytest.mark.parametrize(
  87. ("basic_info", "firmware_info", "result"),
  88. [
  89. (
  90. bytearray(b"\x01\x02W\x82g\xf5\xde4\x01=dPP\x03\x14P\x00\x00\x00\x00"),
  91. bytearray(b"\x01W\x0b\x17\x01"),
  92. [87, True, False, "normal", 61, 1.1],
  93. ),
  94. (
  95. bytearray(b"\x01\x02U\xc2g\xf5\xde4\x04+dPP\x03\x14P\x00\x00\x00\x00"),
  96. bytearray(b"\x01U\x0b\x17\x01"),
  97. [85, True, True, "baby", 43, 1.1],
  98. ),
  99. ],
  100. )
  101. async def test_get_basic_info(basic_info, firmware_info, result):
  102. fan_device = create_device_for_command_testing()
  103. async def mock_get_basic_info(arg):
  104. if arg == fan.COMMAND_GET_BASIC_INFO:
  105. return basic_info
  106. if arg == fan.DEVICE_GET_BASIC_SETTINGS_KEY:
  107. return firmware_info
  108. return None
  109. fan_device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
  110. info = await fan_device.get_basic_info()
  111. assert info["battery"] == result[0]
  112. assert info["isOn"] == result[1]
  113. assert info["oscillating"] == result[2]
  114. assert info["mode"] == result[3]
  115. assert info["speed"] == result[4]
  116. assert info["firmware"] == result[5]
  117. @pytest.mark.asyncio
  118. async def test_set_preset_mode():
  119. fan_device = create_device_for_command_testing({"mode": "baby"})
  120. await fan_device.set_preset_mode("baby")
  121. assert fan_device.get_current_mode() == "baby"
  122. @pytest.mark.asyncio
  123. async def test_set_percentage_with_speed_is_0():
  124. fan_device = create_device_for_command_testing({"speed": 0, "isOn": False})
  125. await fan_device.turn_off()
  126. assert fan_device.get_current_percentage() == 0
  127. assert fan_device.is_on() is False
  128. @pytest.mark.asyncio
  129. async def test_set_percentage():
  130. fan_device = create_device_for_command_testing({"speed": 80})
  131. await fan_device.set_percentage(80)
  132. assert fan_device.get_current_percentage() == 80
  133. @pytest.mark.asyncio
  134. async def test_set_not_oscillation():
  135. fan_device = create_device_for_command_testing({"oscillating": False})
  136. await fan_device.set_oscillation(False)
  137. assert fan_device.get_oscillating_state() is False
  138. @pytest.mark.asyncio
  139. async def test_set_oscillation():
  140. fan_device = create_device_for_command_testing({"oscillating": True})
  141. await fan_device.set_oscillation(True)
  142. assert fan_device.get_oscillating_state() is True
  143. @pytest.mark.asyncio
  144. @pytest.mark.parametrize(
  145. ("oscillating", "expected_cmd"),
  146. [
  147. (True, fan.COMMAND_START_OSCILLATION),
  148. (False, fan.COMMAND_STOP_OSCILLATION),
  149. ],
  150. )
  151. async def test_circulator_fan_set_oscillation_command(oscillating, expected_cmd):
  152. """Circulator Fan keeps the original single-axis (V kept) payload."""
  153. fan_device = create_device_for_command_testing({"oscillating": oscillating})
  154. await fan_device.set_oscillation(oscillating)
  155. fan_device._send_command.assert_called_once()
  156. cmd = fan_device._send_command.call_args[0][0]
  157. assert cmd == expected_cmd
  158. def test_circulator_fan_oscillation_command_constants():
  159. """Lock the bytes for the Circulator Fan oscillation commands."""
  160. # These are master-version bytes preserved for backward compatibility.
  161. assert fan.COMMAND_START_OSCILLATION == "570f41020101ff"
  162. assert fan.COMMAND_STOP_OSCILLATION == "570f41020102ff"
  163. @pytest.mark.asyncio
  164. @pytest.mark.parametrize(
  165. ("oscillating", "expected_cmd"),
  166. [
  167. (True, fan.COMMAND_START_OSCILLATION_ALL_AXES),
  168. (False, fan.COMMAND_STOP_OSCILLATION_ALL_AXES),
  169. ],
  170. )
  171. async def test_standing_fan_set_oscillation_command(oscillating, expected_cmd):
  172. """Standing Fan oscillation toggles both axes at once."""
  173. standing_fan = create_standing_fan_for_testing({"oscillating": oscillating})
  174. await standing_fan.set_oscillation(oscillating)
  175. standing_fan._send_command.assert_called_once()
  176. cmd = standing_fan._send_command.call_args[0][0]
  177. assert cmd == expected_cmd
  178. def test_standing_fan_oscillation_command_constants():
  179. """Lock the bytes for the Standing Fan dual-axis oscillation commands."""
  180. assert fan.COMMAND_START_OSCILLATION_ALL_AXES == "570f4102010101"
  181. assert fan.COMMAND_STOP_OSCILLATION_ALL_AXES == "570f4102010202"
  182. def _fan_with_real_result_check(init_data: dict | None = None):
  183. """
  184. Command-test fixture that uses the real _check_command_result.
  185. Unlike `create_device_for_command_testing`, this keeps the real
  186. `_check_command_result` so setter methods exercise the success-byte
  187. validation path.
  188. """
  189. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  190. fan_device = fan.SwitchbotFan(ble_device, model=SwitchbotModel.CIRCULATOR_FAN)
  191. fan_device.update_from_advertisement(make_advertisement_data(ble_device, init_data))
  192. fan_device._send_command = AsyncMock()
  193. fan_device.update = AsyncMock()
  194. return fan_device
  195. def _standing_fan_with_real_result_check(init_data: dict | None = None):
  196. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  197. standing_fan = SwitchbotStandingFan(ble_device, model=SwitchbotModel.STANDING_FAN)
  198. standing_fan.update_from_advertisement(
  199. make_advertisement_data(ble_device, init_data)
  200. )
  201. standing_fan._send_command = AsyncMock()
  202. standing_fan.update = AsyncMock()
  203. return standing_fan
  204. @pytest.mark.asyncio
  205. @pytest.mark.parametrize(
  206. ("response", "expected"),
  207. [
  208. # Success byte is 1.
  209. (b"\x01", True),
  210. (b"\x01\xff", True),
  211. # Known fan error payloads.
  212. (b"\x00", False),
  213. (b"\x07", False),
  214. ],
  215. )
  216. @pytest.mark.parametrize(
  217. "invoke",
  218. [
  219. lambda d: d.set_preset_mode("baby"),
  220. lambda d: d.set_percentage(80),
  221. lambda d: d.set_oscillation(True),
  222. lambda d: d.set_oscillation(False),
  223. lambda d: d.set_horizontal_oscillation(True),
  224. lambda d: d.set_vertical_oscillation(True),
  225. ],
  226. )
  227. async def test_circulator_fan_setters_validate_success_byte(response, expected, invoke):
  228. """Every Circulator Fan setter returns True only on success-byte 1."""
  229. device = _fan_with_real_result_check()
  230. device._send_command.return_value = response
  231. assert await invoke(device) is expected
  232. @pytest.mark.asyncio
  233. @pytest.mark.parametrize(
  234. ("response", "expected"),
  235. [
  236. (b"\x01", True),
  237. (b"\x01\xff", True),
  238. (b"\x00", False),
  239. (b"\x07", False),
  240. ],
  241. )
  242. @pytest.mark.parametrize(
  243. "invoke",
  244. [
  245. lambda d: d.set_horizontal_oscillation_angle(
  246. HorizontalOscillationAngle.ANGLE_60
  247. ),
  248. lambda d: d.set_vertical_oscillation_angle(VerticalOscillationAngle.ANGLE_90),
  249. lambda d: d.set_night_light(NightLightState.LEVEL_1),
  250. lambda d: d.set_night_light(NightLightState.OFF),
  251. lambda d: d.set_child_lock(True),
  252. lambda d: d.set_display(False),
  253. lambda d: d.set_sound(True),
  254. lambda d: d.set_auto_recenter(False),
  255. ],
  256. )
  257. async def test_standing_fan_setters_validate_success_byte(response, expected, invoke):
  258. """Every Standing Fan setter returns True only on success-byte 1."""
  259. device = _standing_fan_with_real_result_check()
  260. device._send_command.return_value = response
  261. assert await invoke(device) is expected
  262. @pytest.mark.asyncio
  263. async def test_fan_setter_raises_on_none_response():
  264. """None responses raise SwitchbotOperationError via _check_command_result."""
  265. device = _fan_with_real_result_check()
  266. device._send_command.return_value = None
  267. with pytest.raises(SwitchbotOperationError):
  268. await device.set_oscillation(True)
  269. @pytest.mark.asyncio
  270. async def test_turn_on():
  271. fan_device = create_device_for_command_testing({"isOn": True})
  272. await fan_device.turn_on()
  273. assert fan_device.is_on() is True
  274. @pytest.mark.asyncio
  275. async def test_turn_off():
  276. fan_device = create_device_for_command_testing({"isOn": False})
  277. await fan_device.turn_off()
  278. assert fan_device.is_on() is False
  279. def test_get_modes():
  280. assert FanMode.get_modes() == ["normal", "natural", "sleep", "baby"]
  281. def create_standing_fan_for_testing(init_data: dict | None = None):
  282. """Create a SwitchbotStandingFan instance for command testing."""
  283. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  284. standing_fan = SwitchbotStandingFan(ble_device, model=SwitchbotModel.STANDING_FAN)
  285. standing_fan.update_from_advertisement(
  286. make_advertisement_data(ble_device, init_data)
  287. )
  288. standing_fan._send_command = AsyncMock()
  289. standing_fan._check_command_result = MagicMock()
  290. standing_fan.update = AsyncMock()
  291. return standing_fan
  292. def test_standing_fan_inherits_from_switchbot_fan():
  293. assert issubclass(SwitchbotStandingFan, fan.SwitchbotFan)
  294. def test_standing_fan_instantiation():
  295. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  296. standing_fan = SwitchbotStandingFan(ble_device, model=SwitchbotModel.STANDING_FAN)
  297. assert standing_fan is not None
  298. def test_standing_fan_get_modes():
  299. assert StandingFanMode.get_modes() == [
  300. "normal",
  301. "natural",
  302. "sleep",
  303. "baby",
  304. "custom_natural",
  305. ]
  306. @pytest.mark.asyncio
  307. async def test_standing_fan_turn_on():
  308. standing_fan = create_standing_fan_for_testing({"isOn": True})
  309. await standing_fan.turn_on()
  310. assert standing_fan.is_on() is True
  311. @pytest.mark.asyncio
  312. async def test_standing_fan_turn_off():
  313. standing_fan = create_standing_fan_for_testing({"isOn": False})
  314. await standing_fan.turn_off()
  315. assert standing_fan.is_on() is False
  316. @pytest.mark.asyncio
  317. @pytest.mark.parametrize(
  318. "mode",
  319. ["normal", "natural", "sleep", "baby", "custom_natural"],
  320. )
  321. async def test_standing_fan_set_preset_mode(mode):
  322. standing_fan = create_standing_fan_for_testing({"mode": mode})
  323. await standing_fan.set_preset_mode(mode)
  324. assert standing_fan.get_current_mode() == mode
  325. def create_circulator_fan_pro_for_testing(init_data: dict | None = None):
  326. """Create an encrypted SwitchbotCirculatorFanPro instance for testing."""
  327. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  328. fan_device = SwitchbotCirculatorFanPro(
  329. ble_device,
  330. "ff",
  331. "ffffffffffffffffffffffffffffffff",
  332. model=SwitchbotModel.CIRCULATOR_FAN_PRO,
  333. )
  334. fan_device.update_from_advertisement(
  335. make_advertisement_data(
  336. ble_device, init_data, model=SwitchbotModel.CIRCULATOR_FAN_PRO
  337. )
  338. )
  339. fan_device._send_command = AsyncMock()
  340. fan_device._check_command_result = MagicMock()
  341. fan_device.update = AsyncMock()
  342. return fan_device
  343. def test_circulator_fan_pro_inherits_from_switchbot_fan():
  344. assert issubclass(SwitchbotCirculatorFanPro, fan.SwitchbotFan)
  345. def test_circulator_fan_pro_is_encrypted_device():
  346. assert issubclass(SwitchbotCirculatorFanPro, SwitchbotEncryptedDevice)
  347. def test_circulator_fan_pro_instantiation():
  348. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  349. fan_device = SwitchbotCirculatorFanPro(
  350. ble_device, "ff", "ffffffffffffffffffffffffffffffff"
  351. )
  352. assert fan_device is not None
  353. assert fan_device._model == SwitchbotModel.CIRCULATOR_FAN_PRO
  354. @pytest.mark.asyncio
  355. async def test_circulator_fan_pro_turn_on():
  356. fan_device = create_circulator_fan_pro_for_testing({"isOn": True})
  357. await fan_device.turn_on()
  358. assert fan_device.is_on() is True
  359. @pytest.mark.asyncio
  360. async def test_circulator_fan_pro_turn_off():
  361. fan_device = create_circulator_fan_pro_for_testing({"isOn": False})
  362. await fan_device.turn_off()
  363. assert fan_device.is_on() is False
  364. @pytest.mark.asyncio
  365. async def test_circulator_fan_pro_set_percentage():
  366. fan_device = create_circulator_fan_pro_for_testing({"speed": 80})
  367. await fan_device.set_percentage(80)
  368. fan_device._send_command.assert_awaited_once_with("570f411129010150")
  369. @pytest.mark.asyncio
  370. @pytest.mark.parametrize(
  371. ("percentage", "expected_cmd"),
  372. [
  373. (0, "570f411129010101"), # clamped up to 1
  374. (1, "570f411129010101"),
  375. (100, "570f411129010164"),
  376. (150, "570f411129010164"), # clamped down to 100
  377. ],
  378. )
  379. async def test_circulator_fan_pro_set_percentage_clamped(percentage, expected_cmd):
  380. fan_device = create_circulator_fan_pro_for_testing()
  381. await fan_device.set_percentage(percentage)
  382. fan_device._send_command.assert_awaited_once_with(expected_cmd)
  383. @pytest.mark.asyncio
  384. @pytest.mark.parametrize(
  385. ("oscillating", "expected_cmd"),
  386. [
  387. (True, "570f4102290101"), # start both axes
  388. (False, "570f4102290202"), # stop both axes
  389. ],
  390. )
  391. async def test_circulator_fan_pro_set_oscillation(oscillating, expected_cmd):
  392. fan_device = create_circulator_fan_pro_for_testing()
  393. await fan_device.set_oscillation(oscillating)
  394. fan_device._send_command.assert_awaited_once_with(expected_cmd)
  395. @pytest.mark.asyncio
  396. @pytest.mark.parametrize(
  397. ("oscillating", "expected_cmd"),
  398. [
  399. (True, "570f41022901ff"), # start horizontal, keep vertical
  400. (False, "570f41022902ff"), # stop horizontal, keep vertical
  401. ],
  402. )
  403. async def test_circulator_fan_pro_set_horizontal_oscillation(oscillating, expected_cmd):
  404. fan_device = create_circulator_fan_pro_for_testing()
  405. await fan_device.set_horizontal_oscillation(oscillating)
  406. fan_device._send_command.assert_awaited_once_with(expected_cmd)
  407. @pytest.mark.asyncio
  408. @pytest.mark.parametrize(
  409. ("oscillating", "expected_cmd"),
  410. [
  411. (True, "570f410229ff01"), # keep horizontal, start vertical
  412. (False, "570f410229ff02"), # keep horizontal, stop vertical
  413. ],
  414. )
  415. async def test_circulator_fan_pro_set_vertical_oscillation(oscillating, expected_cmd):
  416. fan_device = create_circulator_fan_pro_for_testing()
  417. await fan_device.set_vertical_oscillation(oscillating)
  418. fan_device._send_command.assert_awaited_once_with(expected_cmd)
  419. @pytest.mark.asyncio
  420. @pytest.mark.parametrize(
  421. ("mode", "expected_cmd"),
  422. [
  423. ("normal", "570f4111290101"),
  424. ("natural", "570f4111290102"),
  425. ("sleep", "570f4111290103"),
  426. ("hurricane", "570f4111290104"),
  427. ],
  428. )
  429. async def test_circulator_fan_pro_set_preset_mode(mode, expected_cmd):
  430. fan_device = create_circulator_fan_pro_for_testing({"mode": mode})
  431. await fan_device.set_preset_mode(mode)
  432. fan_device._send_command.assert_awaited_once_with(expected_cmd)
  433. @pytest.mark.asyncio
  434. async def test_circulator_fan_pro_turn_on_sends_extended_frame():
  435. fan_device = create_circulator_fan_pro_for_testing()
  436. await fan_device.turn_on()
  437. fan_device._send_command.assert_awaited_once_with("570f41112901")
  438. @pytest.mark.asyncio
  439. async def test_circulator_fan_pro_turn_off_sends_extended_frame():
  440. fan_device = create_circulator_fan_pro_for_testing()
  441. await fan_device.turn_off()
  442. fan_device._send_command.assert_awaited_once_with("570f41112900")
  443. @pytest.mark.asyncio
  444. async def test_circulator_fan_pro_turn_on_light():
  445. fan_device = create_circulator_fan_pro_for_testing()
  446. await fan_device.turn_on_light()
  447. fan_device._send_command.assert_awaited_once_with("570f960a0201")
  448. @pytest.mark.asyncio
  449. async def test_circulator_fan_pro_turn_on_light_low():
  450. fan_device = create_circulator_fan_pro_for_testing()
  451. await fan_device.turn_on_light(low=True)
  452. fan_device._send_command.assert_awaited_once_with("570f960a0203")
  453. @pytest.mark.asyncio
  454. async def test_circulator_fan_pro_turn_off_light():
  455. fan_device = create_circulator_fan_pro_for_testing()
  456. await fan_device.turn_off_light()
  457. fan_device._send_command.assert_awaited_once_with("570f960a0200")
  458. @pytest.mark.parametrize(
  459. ("key", "method", "expected"),
  460. [
  461. ("night_light_is_on", "is_night_light_on", True),
  462. ("night_light_level", "get_night_light_level", 2),
  463. ],
  464. )
  465. def test_circulator_fan_pro_light_state_getters(key, method, expected):
  466. fan_device = create_circulator_fan_pro_for_testing({key: expected})
  467. assert getattr(fan_device, method)() == expected
  468. def test_circulator_fan_pro_fan_modes():
  469. fan_device = create_circulator_fan_pro_for_testing()
  470. assert fan_device.fan_modes == ["normal", "natural", "sleep", "hurricane"]
  471. @pytest.mark.asyncio
  472. async def test_circulator_fan_pro_get_basic_info():
  473. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  474. fan_device = SwitchbotCirculatorFanPro(
  475. ble_device,
  476. "ff",
  477. "ffffffffffffffffffffffffffffffff",
  478. model=SwitchbotModel.CIRCULATOR_FAN_PRO,
  479. )
  480. fan_device._send_command = AsyncMock(return_value=b"\x01\x02\x37\x04")
  481. info = await fan_device.get_basic_info()
  482. assert info == {"firmware": 5.5}
  483. fan_device._send_command.assert_called_once()
  484. @pytest.mark.asyncio
  485. @pytest.mark.parametrize(
  486. "response",
  487. [b"\x00", b"\x07", b"\x01\x02"],
  488. )
  489. async def test_circulator_fan_pro_get_basic_info_returns_none(response):
  490. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  491. fan_device = SwitchbotCirculatorFanPro(
  492. ble_device,
  493. "ff",
  494. "ffffffffffffffffffffffffffffffff",
  495. model=SwitchbotModel.CIRCULATOR_FAN_PRO,
  496. )
  497. fan_device._send_command = AsyncMock(return_value=response)
  498. assert await fan_device.get_basic_info() is None
  499. @pytest.mark.asyncio
  500. @pytest.mark.parametrize(
  501. ("basic_info", "firmware_info", "result"),
  502. [
  503. (
  504. bytearray(b"\x01\x02W\x82g\xf5\xde4\x01=dPP\x03\x14P\x00\x00\x00\x00"),
  505. bytearray(b"\x01W\x0b\x17\x01"),
  506. {
  507. "battery": 87,
  508. "isOn": True,
  509. "oscillating": False,
  510. "oscillating_horizontal": False,
  511. "oscillating_vertical": False,
  512. "mode": "normal",
  513. "speed": 61,
  514. "firmware": 1.1,
  515. },
  516. ),
  517. (
  518. bytearray(b"\x01\x02U\xc2g\xf5\xde4\x04+dPP\x03\x14P\x00\x00\x00\x00"),
  519. bytearray(b"\x01U\x0b\x17\x01"),
  520. {
  521. "battery": 85,
  522. "isOn": True,
  523. "oscillating": True,
  524. "oscillating_horizontal": True,
  525. "oscillating_vertical": False,
  526. "mode": "baby",
  527. "speed": 43,
  528. "firmware": 1.1,
  529. },
  530. ),
  531. (
  532. bytearray(b"\x01\x02U\xe2g\xf5\xde4\x05+dPP\x03\x14P\x00\x00\x00\x00"),
  533. bytearray(b"\x01U\x0b\x17\x01"),
  534. {
  535. "battery": 85,
  536. "isOn": True,
  537. "oscillating": True,
  538. "oscillating_horizontal": True,
  539. "oscillating_vertical": True,
  540. "mode": "custom_natural",
  541. "speed": 43,
  542. "firmware": 1.1,
  543. },
  544. ),
  545. ],
  546. )
  547. async def test_standing_fan_get_basic_info(basic_info, firmware_info, result):
  548. # Preload nightLight via the fixture adv data so get_basic_info can surface it.
  549. standing_fan = create_standing_fan_for_testing({"nightLight": 3})
  550. async def mock_get_basic_info(arg):
  551. if arg == fan.COMMAND_GET_BASIC_INFO:
  552. return basic_info
  553. if arg == fan.DEVICE_GET_BASIC_SETTINGS_KEY:
  554. return firmware_info
  555. return None
  556. standing_fan._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
  557. info = await standing_fan.get_basic_info()
  558. # Standing Fan adds extra keys (charging, angles, child_lock, ...); assert the
  559. # core fields are a subset rather than requiring exact equality.
  560. expected = result | {"nightLight": 3}
  561. assert expected.items() <= info.items()
  562. @pytest.mark.asyncio
  563. @pytest.mark.parametrize(
  564. ("basic_info", "firmware_info"),
  565. [(True, False), (False, True), (False, False)],
  566. )
  567. async def test_standing_fan_get_basic_info_returns_none(basic_info, firmware_info):
  568. standing_fan = create_standing_fan_for_testing()
  569. async def mock_get_basic_info(arg):
  570. if arg == fan.COMMAND_GET_BASIC_INFO:
  571. return basic_info
  572. if arg == fan.DEVICE_GET_BASIC_SETTINGS_KEY:
  573. return firmware_info
  574. return None
  575. standing_fan._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
  576. assert await standing_fan.get_basic_info() is None
  577. @pytest.mark.asyncio
  578. @pytest.mark.parametrize(
  579. "angle",
  580. [
  581. HorizontalOscillationAngle.ANGLE_30,
  582. HorizontalOscillationAngle.ANGLE_60,
  583. HorizontalOscillationAngle.ANGLE_90,
  584. ],
  585. )
  586. async def test_standing_fan_set_horizontal_oscillation_angle(angle):
  587. standing_fan = create_standing_fan_for_testing()
  588. await standing_fan.set_horizontal_oscillation_angle(angle)
  589. standing_fan._send_command.assert_called_once()
  590. cmd = standing_fan._send_command.call_args[0][0]
  591. assert cmd == f"{fan.COMMAND_SET_OSCILLATION_PARAMS}{angle.value:02X}FFFFFF"
  592. @pytest.mark.asyncio
  593. @pytest.mark.parametrize("angle", [30, 60, 90])
  594. async def test_standing_fan_set_horizontal_oscillation_angle_int(angle):
  595. """Raw int inputs are coerced through HorizontalOscillationAngle(angle)."""
  596. standing_fan = create_standing_fan_for_testing()
  597. await standing_fan.set_horizontal_oscillation_angle(angle)
  598. cmd = standing_fan._send_command.call_args[0][0]
  599. assert cmd == f"{fan.COMMAND_SET_OSCILLATION_PARAMS}{angle:02X}FFFFFF"
  600. @pytest.mark.asyncio
  601. @pytest.mark.parametrize("angle", [0, 45, 120, -1])
  602. async def test_standing_fan_set_horizontal_oscillation_angle_invalid(angle):
  603. standing_fan = create_standing_fan_for_testing()
  604. with pytest.raises(ValueError, match="is not a valid"):
  605. await standing_fan.set_horizontal_oscillation_angle(angle)
  606. standing_fan._send_command.assert_not_called()
  607. @pytest.mark.asyncio
  608. @pytest.mark.parametrize(
  609. "angle",
  610. [
  611. VerticalOscillationAngle.ANGLE_30,
  612. VerticalOscillationAngle.ANGLE_60,
  613. # Vertical 90° maps to byte 0x5F (95); byte 0x5A (90) halts the axis.
  614. VerticalOscillationAngle.ANGLE_90,
  615. ],
  616. )
  617. async def test_standing_fan_set_vertical_oscillation_angle(angle):
  618. standing_fan = create_standing_fan_for_testing()
  619. await standing_fan.set_vertical_oscillation_angle(angle)
  620. standing_fan._send_command.assert_called_once()
  621. cmd = standing_fan._send_command.call_args[0][0]
  622. assert cmd == f"{fan.COMMAND_SET_OSCILLATION_PARAMS}FFFF{angle.value:02X}FF"
  623. @pytest.mark.asyncio
  624. @pytest.mark.parametrize("byte_value", [30, 60, 95])
  625. async def test_standing_fan_set_vertical_oscillation_angle_int(byte_value):
  626. """Raw-int callers pass the device byte value (30 / 60 / 95)."""
  627. standing_fan = create_standing_fan_for_testing()
  628. await standing_fan.set_vertical_oscillation_angle(byte_value)
  629. cmd = standing_fan._send_command.call_args[0][0]
  630. assert cmd == f"{fan.COMMAND_SET_OSCILLATION_PARAMS}FFFF{byte_value:02X}FF"
  631. @pytest.mark.asyncio
  632. async def test_standing_fan_set_vertical_oscillation_angle_90():
  633. """Raw-int callers may also use 90 degrees, which maps to byte 0x5F (95)."""
  634. standing_fan = create_standing_fan_for_testing()
  635. await standing_fan.set_vertical_oscillation_angle(90)
  636. cmd = standing_fan._send_command.call_args[0][0]
  637. byte_value = VerticalOscillationAngle.ANGLE_90.value
  638. assert cmd == f"{fan.COMMAND_SET_OSCILLATION_PARAMS}FFFF{byte_value:02X}FF"
  639. @pytest.mark.asyncio
  640. @pytest.mark.parametrize("angle", [0, 45, 120, -1])
  641. async def test_standing_fan_set_vertical_oscillation_angle_invalid(angle):
  642. standing_fan = create_standing_fan_for_testing()
  643. with pytest.raises(ValueError, match="is not a valid"):
  644. await standing_fan.set_vertical_oscillation_angle(angle)
  645. standing_fan._send_command.assert_not_called()
  646. @pytest.mark.asyncio
  647. @pytest.mark.parametrize(
  648. "state",
  649. [NightLightState.LEVEL_1, NightLightState.LEVEL_2, NightLightState.OFF],
  650. )
  651. async def test_standing_fan_set_night_light(state):
  652. standing_fan = create_standing_fan_for_testing()
  653. await standing_fan.set_night_light(state)
  654. standing_fan._send_command.assert_called_once()
  655. cmd = standing_fan._send_command.call_args[0][0]
  656. # OFF is sent as 0x00 (firmware ignores NightLightState.OFF's 0x03).
  657. expected = 0 if state is NightLightState.OFF else state.value
  658. assert cmd == f"{fan.COMMAND_SET_NIGHT_LIGHT}{expected:02X}FFFF"
  659. @pytest.mark.asyncio
  660. @pytest.mark.parametrize("state", [1, 2, 3])
  661. async def test_standing_fan_set_night_light_int(state):
  662. """Raw int inputs are coerced through NightLightState(state)."""
  663. standing_fan = create_standing_fan_for_testing()
  664. await standing_fan.set_night_light(state)
  665. cmd = standing_fan._send_command.call_args[0][0]
  666. # OFF (3) is sent as 0x00 (firmware ignores NightLightState.OFF's 0x03).
  667. expected = 0 if state == NightLightState.OFF.value else state
  668. assert cmd == f"{fan.COMMAND_SET_NIGHT_LIGHT}{expected:02X}FFFF"
  669. @pytest.mark.asyncio
  670. @pytest.mark.parametrize("state", [0, 4, 99, -1])
  671. async def test_standing_fan_set_night_light_invalid(state):
  672. standing_fan = create_standing_fan_for_testing()
  673. with pytest.raises(ValueError, match="is not a valid"):
  674. await standing_fan.set_night_light(state)
  675. standing_fan._send_command.assert_not_called()
  676. def test_standing_fan_get_night_light_state():
  677. standing_fan = create_standing_fan_for_testing({"nightLight": 1})
  678. assert standing_fan.get_night_light_state() == 1
  679. @pytest.mark.asyncio
  680. @pytest.mark.parametrize(
  681. ("oscillating", "expected_cmd"),
  682. [
  683. (True, fan.COMMAND_START_HORIZONTAL_OSCILLATION),
  684. (False, fan.COMMAND_STOP_HORIZONTAL_OSCILLATION),
  685. ],
  686. )
  687. async def test_standing_fan_set_horizontal_oscillation(oscillating, expected_cmd):
  688. standing_fan = create_standing_fan_for_testing({"oscillating": oscillating})
  689. await standing_fan.set_horizontal_oscillation(oscillating)
  690. standing_fan._send_command.assert_called_once()
  691. cmd = standing_fan._send_command.call_args[0][0]
  692. assert cmd == expected_cmd
  693. @pytest.mark.asyncio
  694. @pytest.mark.parametrize(
  695. ("oscillating", "expected_cmd"),
  696. [
  697. (True, fan.COMMAND_START_VERTICAL_OSCILLATION),
  698. (False, fan.COMMAND_STOP_VERTICAL_OSCILLATION),
  699. ],
  700. )
  701. async def test_standing_fan_set_vertical_oscillation(oscillating, expected_cmd):
  702. standing_fan = create_standing_fan_for_testing({"oscillating": oscillating})
  703. await standing_fan.set_vertical_oscillation(oscillating)
  704. standing_fan._send_command.assert_called_once()
  705. cmd = standing_fan._send_command.call_args[0][0]
  706. assert cmd == expected_cmd
  707. def test_standing_fan_get_horizontal_oscillating_state():
  708. standing_fan = create_standing_fan_for_testing({"oscillating_horizontal": True})
  709. assert standing_fan.get_horizontal_oscillating_state() is True
  710. def test_standing_fan_get_vertical_oscillating_state():
  711. standing_fan = create_standing_fan_for_testing({"oscillating_vertical": True})
  712. assert standing_fan.get_vertical_oscillating_state() is True
  713. @pytest.mark.asyncio
  714. async def test_standing_fan_get_basic_info_extended():
  715. """The Standing Fan decodes angles, charging, child lock, etc. from status."""
  716. standing_fan = create_standing_fan_for_testing({"nightLight": 2})
  717. # byte: 2=battery|charge, 3=status bits, 4=h angle, 6=v angle (95=90deg),
  718. # 8=mode (low nibble), 9=speed, 10=sound.
  719. basic_info = bytearray(b"\x01\x02\xd5\xd3\x3c\x00\x5f\x00\x32\x32\x64")
  720. firmware_info = bytearray(b"\x01W\x0b\x17\x01")
  721. async def mock_get_basic_info(arg):
  722. if arg == fan.COMMAND_GET_BASIC_INFO:
  723. return basic_info
  724. if arg == fan.DEVICE_GET_BASIC_SETTINGS_KEY:
  725. return firmware_info
  726. return None
  727. standing_fan._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
  728. info = await standing_fan.get_basic_info()
  729. assert info["battery"] == 85
  730. assert info["charging"] is True
  731. assert info["isOn"] is True
  732. assert info["oscillating_horizontal"] is True
  733. assert info["oscillating_vertical"] is False
  734. assert info["oscillating_horizontal_angle"] == 60
  735. assert info["oscillating_vertical_angle"] == 95
  736. assert info["child_lock"] is True
  737. assert info["display"] is True
  738. assert info["auto_recenter"] is True
  739. assert info["sound"] is True
  740. assert info["mode"] == "natural"
  741. assert info["speed"] == 50
  742. assert info["firmware"] == 1.1
  743. @pytest.mark.asyncio
  744. @pytest.mark.parametrize(
  745. ("invoke", "expected_cmd"),
  746. [
  747. (lambda d: d.set_child_lock(True), f"{fan.COMMAND_SET_CHILD_LOCK}01"),
  748. (lambda d: d.set_child_lock(False), f"{fan.COMMAND_SET_CHILD_LOCK}02"),
  749. (lambda d: d.set_display(True), f"{fan.COMMAND_SET_DISPLAY_LIGHT}01FFFF"),
  750. (lambda d: d.set_display(False), f"{fan.COMMAND_SET_DISPLAY_LIGHT}02FFFF"),
  751. (lambda d: d.set_sound(True), f"{fan.COMMAND_SET_SOUND}64"),
  752. (lambda d: d.set_sound(False), f"{fan.COMMAND_SET_SOUND}00"),
  753. (lambda d: d.set_auto_recenter(True), f"{fan.COMMAND_SET_AUTO_RECENTER}0101"),
  754. (lambda d: d.set_auto_recenter(False), f"{fan.COMMAND_SET_AUTO_RECENTER}0202"),
  755. ],
  756. )
  757. async def test_standing_fan_extra_setter_commands(invoke, expected_cmd):
  758. standing_fan = create_standing_fan_for_testing()
  759. await invoke(standing_fan)
  760. standing_fan._send_command.assert_called_once()
  761. assert standing_fan._send_command.call_args[0][0] == expected_cmd
  762. @pytest.mark.parametrize(
  763. ("getter", "key", "value"),
  764. [
  765. (
  766. lambda d: d.get_horizontal_oscillation_angle(),
  767. "oscillating_horizontal_angle",
  768. 60,
  769. ),
  770. (
  771. lambda d: d.get_vertical_oscillation_angle(),
  772. "oscillating_vertical_angle",
  773. 95,
  774. ),
  775. (lambda d: d.is_charging(), "charging", True),
  776. (lambda d: d.get_child_lock(), "child_lock", True),
  777. (lambda d: d.get_display(), "display", False),
  778. (lambda d: d.get_sound(), "sound", True),
  779. (lambda d: d.get_auto_recenter(), "auto_recenter", True),
  780. ],
  781. )
  782. def test_standing_fan_cached_getters(getter, key, value):
  783. standing_fan = create_standing_fan_for_testing({key: value})
  784. assert getter(standing_fan) == value
  785. @pytest.mark.parametrize(
  786. ("battery_byte", "charging", "battery"),
  787. [(0xD5, True, 85), (0x55, False, 85)],
  788. )
  789. def test_process_standing_fan_charging(battery_byte, charging, battery):
  790. mfr_data = bytes([0, 1, 2, 3, 4, 5, 0x01, 0x80, battery_byte, 0x32])
  791. result = process_standing_fan(None, mfr_data)
  792. assert result["charging"] is charging
  793. assert result["battery"] == battery
  794. def test_process_standing_fan_charging_short_payload():
  795. assert process_standing_fan(None, None) == {}
  796. assert process_standing_fan(None, b"\x00") == {}