test_fan.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726
  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 SwitchbotOperationError
  15. from switchbot.devices.fan import 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(ble_device: BLEDevice, init_data: dict | None = None):
  28. """Set advertisement data with defaults."""
  29. if init_data is None:
  30. init_data = {}
  31. return SwitchBotAdvertisement(
  32. address="aa:bb:cc:dd:ee:ff",
  33. data={
  34. "rawAdvData": b"~\x00R",
  35. "data": {
  36. "isOn": True,
  37. "mode": "NORMAL",
  38. "nightLight": 3,
  39. "oscillating": False,
  40. "battery": 60,
  41. "speed": 50,
  42. }
  43. | init_data,
  44. "isEncrypted": False,
  45. "model": ",",
  46. "modelFriendlyName": "Circulator Fan",
  47. "modelName": SwitchbotModel.CIRCULATOR_FAN,
  48. },
  49. device=ble_device,
  50. rssi=-80,
  51. active=True,
  52. )
  53. @pytest.mark.asyncio
  54. @pytest.mark.parametrize(
  55. ("response", "expected"),
  56. [
  57. (b"\x00", None),
  58. (b"\x07", None),
  59. (b"\x01\x02\x03", b"\x01\x02\x03"),
  60. ],
  61. )
  62. async def test__get_basic_info(response, expected):
  63. fan_device = create_device_for_command_testing()
  64. fan_device._send_command = AsyncMock(return_value=response)
  65. result = await fan_device._get_basic_info(cmd="TEST_CMD")
  66. assert result == expected
  67. @pytest.mark.asyncio
  68. @pytest.mark.parametrize(
  69. ("basic_info", "firmware_info"), [(True, False), (False, True), (False, False)]
  70. )
  71. async def test_get_basic_info_returns_none(basic_info, firmware_info):
  72. fan_device = create_device_for_command_testing()
  73. async def mock_get_basic_info(arg):
  74. if arg == fan.COMMAND_GET_BASIC_INFO:
  75. return basic_info
  76. if arg == fan.DEVICE_GET_BASIC_SETTINGS_KEY:
  77. return firmware_info
  78. return None
  79. fan_device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
  80. assert await fan_device.get_basic_info() is None
  81. @pytest.mark.asyncio
  82. @pytest.mark.parametrize(
  83. ("basic_info", "firmware_info", "result"),
  84. [
  85. (
  86. bytearray(b"\x01\x02W\x82g\xf5\xde4\x01=dPP\x03\x14P\x00\x00\x00\x00"),
  87. bytearray(b"\x01W\x0b\x17\x01"),
  88. [87, True, False, "normal", 61, 1.1],
  89. ),
  90. (
  91. bytearray(b"\x01\x02U\xc2g\xf5\xde4\x04+dPP\x03\x14P\x00\x00\x00\x00"),
  92. bytearray(b"\x01U\x0b\x17\x01"),
  93. [85, True, True, "baby", 43, 1.1],
  94. ),
  95. ],
  96. )
  97. async def test_get_basic_info(basic_info, firmware_info, result):
  98. fan_device = create_device_for_command_testing()
  99. async def mock_get_basic_info(arg):
  100. if arg == fan.COMMAND_GET_BASIC_INFO:
  101. return basic_info
  102. if arg == fan.DEVICE_GET_BASIC_SETTINGS_KEY:
  103. return firmware_info
  104. return None
  105. fan_device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
  106. info = await fan_device.get_basic_info()
  107. assert info["battery"] == result[0]
  108. assert info["isOn"] == result[1]
  109. assert info["oscillating"] == result[2]
  110. assert info["mode"] == result[3]
  111. assert info["speed"] == result[4]
  112. assert info["firmware"] == result[5]
  113. @pytest.mark.asyncio
  114. async def test_set_preset_mode():
  115. fan_device = create_device_for_command_testing({"mode": "baby"})
  116. await fan_device.set_preset_mode("baby")
  117. assert fan_device.get_current_mode() == "baby"
  118. @pytest.mark.asyncio
  119. async def test_set_percentage_with_speed_is_0():
  120. fan_device = create_device_for_command_testing({"speed": 0, "isOn": False})
  121. await fan_device.turn_off()
  122. assert fan_device.get_current_percentage() == 0
  123. assert fan_device.is_on() is False
  124. @pytest.mark.asyncio
  125. async def test_set_percentage():
  126. fan_device = create_device_for_command_testing({"speed": 80})
  127. await fan_device.set_percentage(80)
  128. assert fan_device.get_current_percentage() == 80
  129. @pytest.mark.asyncio
  130. async def test_set_not_oscillation():
  131. fan_device = create_device_for_command_testing({"oscillating": False})
  132. await fan_device.set_oscillation(False)
  133. assert fan_device.get_oscillating_state() is False
  134. @pytest.mark.asyncio
  135. async def test_set_oscillation():
  136. fan_device = create_device_for_command_testing({"oscillating": True})
  137. await fan_device.set_oscillation(True)
  138. assert fan_device.get_oscillating_state() is True
  139. @pytest.mark.asyncio
  140. @pytest.mark.parametrize(
  141. ("oscillating", "expected_cmd"),
  142. [
  143. (True, fan.COMMAND_START_OSCILLATION),
  144. (False, fan.COMMAND_STOP_OSCILLATION),
  145. ],
  146. )
  147. async def test_circulator_fan_set_oscillation_command(oscillating, expected_cmd):
  148. """Circulator Fan keeps the original single-axis (V kept) payload."""
  149. fan_device = create_device_for_command_testing({"oscillating": oscillating})
  150. await fan_device.set_oscillation(oscillating)
  151. fan_device._send_command.assert_called_once()
  152. cmd = fan_device._send_command.call_args[0][0]
  153. assert cmd == expected_cmd
  154. def test_circulator_fan_oscillation_command_constants():
  155. """Lock the bytes for the Circulator Fan oscillation commands."""
  156. # These are master-version bytes preserved for backward compatibility.
  157. assert fan.COMMAND_START_OSCILLATION == "570f41020101ff"
  158. assert fan.COMMAND_STOP_OSCILLATION == "570f41020102ff"
  159. @pytest.mark.asyncio
  160. @pytest.mark.parametrize(
  161. ("oscillating", "expected_cmd"),
  162. [
  163. (True, fan.COMMAND_START_OSCILLATION_ALL_AXES),
  164. (False, fan.COMMAND_STOP_OSCILLATION_ALL_AXES),
  165. ],
  166. )
  167. async def test_standing_fan_set_oscillation_command(oscillating, expected_cmd):
  168. """Standing Fan oscillation toggles both axes at once."""
  169. standing_fan = create_standing_fan_for_testing({"oscillating": oscillating})
  170. await standing_fan.set_oscillation(oscillating)
  171. standing_fan._send_command.assert_called_once()
  172. cmd = standing_fan._send_command.call_args[0][0]
  173. assert cmd == expected_cmd
  174. def test_standing_fan_oscillation_command_constants():
  175. """Lock the bytes for the Standing Fan dual-axis oscillation commands."""
  176. assert fan.COMMAND_START_OSCILLATION_ALL_AXES == "570f4102010101"
  177. assert fan.COMMAND_STOP_OSCILLATION_ALL_AXES == "570f4102010202"
  178. def _fan_with_real_result_check(init_data: dict | None = None):
  179. """
  180. Command-test fixture that uses the real _check_command_result.
  181. Unlike `create_device_for_command_testing`, this keeps the real
  182. `_check_command_result` so setter methods exercise the success-byte
  183. validation path.
  184. """
  185. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  186. fan_device = fan.SwitchbotFan(ble_device, model=SwitchbotModel.CIRCULATOR_FAN)
  187. fan_device.update_from_advertisement(make_advertisement_data(ble_device, init_data))
  188. fan_device._send_command = AsyncMock()
  189. fan_device.update = AsyncMock()
  190. return fan_device
  191. def _standing_fan_with_real_result_check(init_data: dict | None = None):
  192. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  193. standing_fan = SwitchbotStandingFan(ble_device, model=SwitchbotModel.STANDING_FAN)
  194. standing_fan.update_from_advertisement(
  195. make_advertisement_data(ble_device, init_data)
  196. )
  197. standing_fan._send_command = AsyncMock()
  198. standing_fan.update = AsyncMock()
  199. return standing_fan
  200. @pytest.mark.asyncio
  201. @pytest.mark.parametrize(
  202. ("response", "expected"),
  203. [
  204. # Success byte is 1.
  205. (b"\x01", True),
  206. (b"\x01\xff", True),
  207. # Known fan error payloads.
  208. (b"\x00", False),
  209. (b"\x07", False),
  210. ],
  211. )
  212. @pytest.mark.parametrize(
  213. "invoke",
  214. [
  215. lambda d: d.set_preset_mode("baby"),
  216. lambda d: d.set_percentage(80),
  217. lambda d: d.set_oscillation(True),
  218. lambda d: d.set_oscillation(False),
  219. lambda d: d.set_horizontal_oscillation(True),
  220. lambda d: d.set_vertical_oscillation(True),
  221. ],
  222. )
  223. async def test_circulator_fan_setters_validate_success_byte(response, expected, invoke):
  224. """Every Circulator Fan setter returns True only on success-byte 1."""
  225. device = _fan_with_real_result_check()
  226. device._send_command.return_value = response
  227. assert await invoke(device) is expected
  228. @pytest.mark.asyncio
  229. @pytest.mark.parametrize(
  230. ("response", "expected"),
  231. [
  232. (b"\x01", True),
  233. (b"\x01\xff", True),
  234. (b"\x00", False),
  235. (b"\x07", False),
  236. ],
  237. )
  238. @pytest.mark.parametrize(
  239. "invoke",
  240. [
  241. lambda d: d.set_horizontal_oscillation_angle(
  242. HorizontalOscillationAngle.ANGLE_60
  243. ),
  244. lambda d: d.set_vertical_oscillation_angle(VerticalOscillationAngle.ANGLE_90),
  245. lambda d: d.set_night_light(NightLightState.LEVEL_1),
  246. lambda d: d.set_night_light(NightLightState.OFF),
  247. lambda d: d.set_child_lock(True),
  248. lambda d: d.set_display(False),
  249. lambda d: d.set_sound(True),
  250. lambda d: d.set_auto_recenter(False),
  251. ],
  252. )
  253. async def test_standing_fan_setters_validate_success_byte(response, expected, invoke):
  254. """Every Standing Fan setter returns True only on success-byte 1."""
  255. device = _standing_fan_with_real_result_check()
  256. device._send_command.return_value = response
  257. assert await invoke(device) is expected
  258. @pytest.mark.asyncio
  259. async def test_fan_setter_raises_on_none_response():
  260. """None responses raise SwitchbotOperationError via _check_command_result."""
  261. device = _fan_with_real_result_check()
  262. device._send_command.return_value = None
  263. with pytest.raises(SwitchbotOperationError):
  264. await device.set_oscillation(True)
  265. @pytest.mark.asyncio
  266. async def test_turn_on():
  267. fan_device = create_device_for_command_testing({"isOn": True})
  268. await fan_device.turn_on()
  269. assert fan_device.is_on() is True
  270. @pytest.mark.asyncio
  271. async def test_turn_off():
  272. fan_device = create_device_for_command_testing({"isOn": False})
  273. await fan_device.turn_off()
  274. assert fan_device.is_on() is False
  275. def test_get_modes():
  276. assert FanMode.get_modes() == ["normal", "natural", "sleep", "baby"]
  277. def create_standing_fan_for_testing(init_data: dict | None = None):
  278. """Create a SwitchbotStandingFan instance for command testing."""
  279. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  280. standing_fan = SwitchbotStandingFan(ble_device, model=SwitchbotModel.STANDING_FAN)
  281. standing_fan.update_from_advertisement(
  282. make_advertisement_data(ble_device, init_data)
  283. )
  284. standing_fan._send_command = AsyncMock()
  285. standing_fan._check_command_result = MagicMock()
  286. standing_fan.update = AsyncMock()
  287. return standing_fan
  288. def test_standing_fan_inherits_from_switchbot_fan():
  289. assert issubclass(SwitchbotStandingFan, fan.SwitchbotFan)
  290. def test_standing_fan_instantiation():
  291. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  292. standing_fan = SwitchbotStandingFan(ble_device, model=SwitchbotModel.STANDING_FAN)
  293. assert standing_fan is not None
  294. def test_standing_fan_get_modes():
  295. assert StandingFanMode.get_modes() == [
  296. "normal",
  297. "natural",
  298. "sleep",
  299. "baby",
  300. "custom_natural",
  301. ]
  302. @pytest.mark.asyncio
  303. async def test_standing_fan_turn_on():
  304. standing_fan = create_standing_fan_for_testing({"isOn": True})
  305. await standing_fan.turn_on()
  306. assert standing_fan.is_on() is True
  307. @pytest.mark.asyncio
  308. async def test_standing_fan_turn_off():
  309. standing_fan = create_standing_fan_for_testing({"isOn": False})
  310. await standing_fan.turn_off()
  311. assert standing_fan.is_on() is False
  312. @pytest.mark.asyncio
  313. @pytest.mark.parametrize(
  314. "mode",
  315. ["normal", "natural", "sleep", "baby", "custom_natural"],
  316. )
  317. async def test_standing_fan_set_preset_mode(mode):
  318. standing_fan = create_standing_fan_for_testing({"mode": mode})
  319. await standing_fan.set_preset_mode(mode)
  320. assert standing_fan.get_current_mode() == mode
  321. @pytest.mark.asyncio
  322. @pytest.mark.parametrize(
  323. ("basic_info", "firmware_info", "result"),
  324. [
  325. (
  326. bytearray(b"\x01\x02W\x82g\xf5\xde4\x01=dPP\x03\x14P\x00\x00\x00\x00"),
  327. bytearray(b"\x01W\x0b\x17\x01"),
  328. {
  329. "battery": 87,
  330. "isOn": True,
  331. "oscillating": False,
  332. "oscillating_horizontal": False,
  333. "oscillating_vertical": False,
  334. "mode": "normal",
  335. "speed": 61,
  336. "firmware": 1.1,
  337. },
  338. ),
  339. (
  340. bytearray(b"\x01\x02U\xc2g\xf5\xde4\x04+dPP\x03\x14P\x00\x00\x00\x00"),
  341. bytearray(b"\x01U\x0b\x17\x01"),
  342. {
  343. "battery": 85,
  344. "isOn": True,
  345. "oscillating": True,
  346. "oscillating_horizontal": True,
  347. "oscillating_vertical": False,
  348. "mode": "baby",
  349. "speed": 43,
  350. "firmware": 1.1,
  351. },
  352. ),
  353. (
  354. bytearray(b"\x01\x02U\xe2g\xf5\xde4\x05+dPP\x03\x14P\x00\x00\x00\x00"),
  355. bytearray(b"\x01U\x0b\x17\x01"),
  356. {
  357. "battery": 85,
  358. "isOn": True,
  359. "oscillating": True,
  360. "oscillating_horizontal": True,
  361. "oscillating_vertical": True,
  362. "mode": "custom_natural",
  363. "speed": 43,
  364. "firmware": 1.1,
  365. },
  366. ),
  367. ],
  368. )
  369. async def test_standing_fan_get_basic_info(basic_info, firmware_info, result):
  370. # Preload nightLight via the fixture adv data so get_basic_info can surface it.
  371. standing_fan = create_standing_fan_for_testing({"nightLight": 3})
  372. async def mock_get_basic_info(arg):
  373. if arg == fan.COMMAND_GET_BASIC_INFO:
  374. return basic_info
  375. if arg == fan.DEVICE_GET_BASIC_SETTINGS_KEY:
  376. return firmware_info
  377. return None
  378. standing_fan._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
  379. info = await standing_fan.get_basic_info()
  380. # Standing Fan adds extra keys (charging, angles, child_lock, ...); assert the
  381. # core fields are a subset rather than requiring exact equality.
  382. expected = result | {"nightLight": 3}
  383. assert expected.items() <= info.items()
  384. @pytest.mark.asyncio
  385. @pytest.mark.parametrize(
  386. ("basic_info", "firmware_info"),
  387. [(True, False), (False, True), (False, False)],
  388. )
  389. async def test_standing_fan_get_basic_info_returns_none(basic_info, firmware_info):
  390. standing_fan = create_standing_fan_for_testing()
  391. async def mock_get_basic_info(arg):
  392. if arg == fan.COMMAND_GET_BASIC_INFO:
  393. return basic_info
  394. if arg == fan.DEVICE_GET_BASIC_SETTINGS_KEY:
  395. return firmware_info
  396. return None
  397. standing_fan._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
  398. assert await standing_fan.get_basic_info() is None
  399. @pytest.mark.asyncio
  400. @pytest.mark.parametrize(
  401. "angle",
  402. [
  403. HorizontalOscillationAngle.ANGLE_30,
  404. HorizontalOscillationAngle.ANGLE_60,
  405. HorizontalOscillationAngle.ANGLE_90,
  406. ],
  407. )
  408. async def test_standing_fan_set_horizontal_oscillation_angle(angle):
  409. standing_fan = create_standing_fan_for_testing()
  410. await standing_fan.set_horizontal_oscillation_angle(angle)
  411. standing_fan._send_command.assert_called_once()
  412. cmd = standing_fan._send_command.call_args[0][0]
  413. assert cmd == f"{fan.COMMAND_SET_OSCILLATION_PARAMS}{angle.value:02X}FFFFFF"
  414. @pytest.mark.asyncio
  415. @pytest.mark.parametrize("angle", [30, 60, 90])
  416. async def test_standing_fan_set_horizontal_oscillation_angle_int(angle):
  417. """Raw int inputs are coerced through HorizontalOscillationAngle(angle)."""
  418. standing_fan = create_standing_fan_for_testing()
  419. await standing_fan.set_horizontal_oscillation_angle(angle)
  420. cmd = standing_fan._send_command.call_args[0][0]
  421. assert cmd == f"{fan.COMMAND_SET_OSCILLATION_PARAMS}{angle:02X}FFFFFF"
  422. @pytest.mark.asyncio
  423. @pytest.mark.parametrize("angle", [0, 45, 120, -1])
  424. async def test_standing_fan_set_horizontal_oscillation_angle_invalid(angle):
  425. standing_fan = create_standing_fan_for_testing()
  426. with pytest.raises(ValueError, match="is not a valid"):
  427. await standing_fan.set_horizontal_oscillation_angle(angle)
  428. standing_fan._send_command.assert_not_called()
  429. @pytest.mark.asyncio
  430. @pytest.mark.parametrize(
  431. "angle",
  432. [
  433. VerticalOscillationAngle.ANGLE_30,
  434. VerticalOscillationAngle.ANGLE_60,
  435. # Vertical 90° maps to byte 0x5F (95); byte 0x5A (90) halts the axis.
  436. VerticalOscillationAngle.ANGLE_90,
  437. ],
  438. )
  439. async def test_standing_fan_set_vertical_oscillation_angle(angle):
  440. standing_fan = create_standing_fan_for_testing()
  441. await standing_fan.set_vertical_oscillation_angle(angle)
  442. standing_fan._send_command.assert_called_once()
  443. cmd = standing_fan._send_command.call_args[0][0]
  444. assert cmd == f"{fan.COMMAND_SET_OSCILLATION_PARAMS}FFFF{angle.value:02X}FF"
  445. @pytest.mark.asyncio
  446. @pytest.mark.parametrize("byte_value", [30, 60, 95])
  447. async def test_standing_fan_set_vertical_oscillation_angle_int(byte_value):
  448. """Raw-int callers pass the device byte value (30 / 60 / 95)."""
  449. standing_fan = create_standing_fan_for_testing()
  450. await standing_fan.set_vertical_oscillation_angle(byte_value)
  451. cmd = standing_fan._send_command.call_args[0][0]
  452. assert cmd == f"{fan.COMMAND_SET_OSCILLATION_PARAMS}FFFF{byte_value:02X}FF"
  453. @pytest.mark.asyncio
  454. @pytest.mark.parametrize("angle", [0, 45, 120, -1])
  455. async def test_standing_fan_set_vertical_oscillation_angle_invalid(angle):
  456. standing_fan = create_standing_fan_for_testing()
  457. with pytest.raises(ValueError, match="is not a valid"):
  458. await standing_fan.set_vertical_oscillation_angle(angle)
  459. standing_fan._send_command.assert_not_called()
  460. @pytest.mark.asyncio
  461. @pytest.mark.parametrize(
  462. "state",
  463. [NightLightState.LEVEL_1, NightLightState.LEVEL_2, NightLightState.OFF],
  464. )
  465. async def test_standing_fan_set_night_light(state):
  466. standing_fan = create_standing_fan_for_testing()
  467. await standing_fan.set_night_light(state)
  468. standing_fan._send_command.assert_called_once()
  469. cmd = standing_fan._send_command.call_args[0][0]
  470. assert cmd == f"{fan.COMMAND_SET_NIGHT_LIGHT}{state.value:02X}FFFF"
  471. @pytest.mark.asyncio
  472. @pytest.mark.parametrize("state", [1, 2, 3])
  473. async def test_standing_fan_set_night_light_int(state):
  474. """Raw int inputs are coerced through NightLightState(state)."""
  475. standing_fan = create_standing_fan_for_testing()
  476. await standing_fan.set_night_light(state)
  477. cmd = standing_fan._send_command.call_args[0][0]
  478. assert cmd == f"{fan.COMMAND_SET_NIGHT_LIGHT}{state:02X}FFFF"
  479. @pytest.mark.asyncio
  480. @pytest.mark.parametrize("state", [0, 4, 99, -1])
  481. async def test_standing_fan_set_night_light_invalid(state):
  482. standing_fan = create_standing_fan_for_testing()
  483. with pytest.raises(ValueError, match="is not a valid"):
  484. await standing_fan.set_night_light(state)
  485. standing_fan._send_command.assert_not_called()
  486. def test_standing_fan_get_night_light_state():
  487. standing_fan = create_standing_fan_for_testing({"nightLight": 1})
  488. assert standing_fan.get_night_light_state() == 1
  489. @pytest.mark.asyncio
  490. @pytest.mark.parametrize(
  491. ("oscillating", "expected_cmd"),
  492. [
  493. (True, fan.COMMAND_START_HORIZONTAL_OSCILLATION),
  494. (False, fan.COMMAND_STOP_HORIZONTAL_OSCILLATION),
  495. ],
  496. )
  497. async def test_standing_fan_set_horizontal_oscillation(oscillating, expected_cmd):
  498. standing_fan = create_standing_fan_for_testing({"oscillating": oscillating})
  499. await standing_fan.set_horizontal_oscillation(oscillating)
  500. standing_fan._send_command.assert_called_once()
  501. cmd = standing_fan._send_command.call_args[0][0]
  502. assert cmd == expected_cmd
  503. @pytest.mark.asyncio
  504. @pytest.mark.parametrize(
  505. ("oscillating", "expected_cmd"),
  506. [
  507. (True, fan.COMMAND_START_VERTICAL_OSCILLATION),
  508. (False, fan.COMMAND_STOP_VERTICAL_OSCILLATION),
  509. ],
  510. )
  511. async def test_standing_fan_set_vertical_oscillation(oscillating, expected_cmd):
  512. standing_fan = create_standing_fan_for_testing({"oscillating": oscillating})
  513. await standing_fan.set_vertical_oscillation(oscillating)
  514. standing_fan._send_command.assert_called_once()
  515. cmd = standing_fan._send_command.call_args[0][0]
  516. assert cmd == expected_cmd
  517. def test_standing_fan_get_horizontal_oscillating_state():
  518. standing_fan = create_standing_fan_for_testing({"oscillating_horizontal": True})
  519. assert standing_fan.get_horizontal_oscillating_state() is True
  520. def test_standing_fan_get_vertical_oscillating_state():
  521. standing_fan = create_standing_fan_for_testing({"oscillating_vertical": True})
  522. assert standing_fan.get_vertical_oscillating_state() is True
  523. @pytest.mark.asyncio
  524. async def test_standing_fan_get_basic_info_extended():
  525. """The Standing Fan decodes angles, charging, child lock, etc. from status."""
  526. standing_fan = create_standing_fan_for_testing({"nightLight": 2})
  527. # byte: 2=battery|charge, 3=status bits, 4=h angle, 6=v angle (95=90deg),
  528. # 8=mode (low nibble), 9=speed, 10=sound.
  529. basic_info = bytearray(b"\x01\x02\xd5\xd3\x3c\x00\x5f\x00\x32\x32\x64")
  530. firmware_info = bytearray(b"\x01W\x0b\x17\x01")
  531. async def mock_get_basic_info(arg):
  532. if arg == fan.COMMAND_GET_BASIC_INFO:
  533. return basic_info
  534. if arg == fan.DEVICE_GET_BASIC_SETTINGS_KEY:
  535. return firmware_info
  536. return None
  537. standing_fan._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
  538. info = await standing_fan.get_basic_info()
  539. assert info["battery"] == 85
  540. assert info["charging"] is True
  541. assert info["isOn"] is True
  542. assert info["oscillating_horizontal"] is True
  543. assert info["oscillating_vertical"] is False
  544. assert info["oscillating_horizontal_angle"] == 60
  545. assert info["oscillating_vertical_angle"] == 95
  546. assert info["child_lock"] is True
  547. assert info["display"] is True
  548. assert info["auto_recenter"] is True
  549. assert info["sound"] is True
  550. assert info["mode"] == "natural"
  551. assert info["speed"] == 50
  552. assert info["firmware"] == 1.1
  553. @pytest.mark.asyncio
  554. @pytest.mark.parametrize(
  555. ("invoke", "expected_cmd"),
  556. [
  557. (lambda d: d.set_child_lock(True), f"{fan.COMMAND_SET_CHILD_LOCK}01"),
  558. (lambda d: d.set_child_lock(False), f"{fan.COMMAND_SET_CHILD_LOCK}02"),
  559. (lambda d: d.set_display(True), f"{fan.COMMAND_SET_DISPLAY_LIGHT}01FFFF"),
  560. (lambda d: d.set_display(False), f"{fan.COMMAND_SET_DISPLAY_LIGHT}02FFFF"),
  561. (lambda d: d.set_sound(True), f"{fan.COMMAND_SET_SOUND}64"),
  562. (lambda d: d.set_sound(False), f"{fan.COMMAND_SET_SOUND}00"),
  563. (lambda d: d.set_auto_recenter(True), f"{fan.COMMAND_SET_AUTO_RECENTER}0101"),
  564. (lambda d: d.set_auto_recenter(False), f"{fan.COMMAND_SET_AUTO_RECENTER}0202"),
  565. ],
  566. )
  567. async def test_standing_fan_extra_setter_commands(invoke, expected_cmd):
  568. standing_fan = create_standing_fan_for_testing()
  569. await invoke(standing_fan)
  570. standing_fan._send_command.assert_called_once()
  571. assert standing_fan._send_command.call_args[0][0] == expected_cmd
  572. @pytest.mark.parametrize(
  573. ("getter", "key", "value"),
  574. [
  575. (
  576. lambda d: d.get_horizontal_oscillation_angle(),
  577. "oscillating_horizontal_angle",
  578. 60,
  579. ),
  580. (
  581. lambda d: d.get_vertical_oscillation_angle(),
  582. "oscillating_vertical_angle",
  583. 95,
  584. ),
  585. (lambda d: d.is_charging(), "charging", True),
  586. (lambda d: d.get_child_lock(), "child_lock", True),
  587. (lambda d: d.get_display(), "display", False),
  588. (lambda d: d.get_sound(), "sound", True),
  589. (lambda d: d.get_auto_recenter(), "auto_recenter", True),
  590. ],
  591. )
  592. def test_standing_fan_cached_getters(getter, key, value):
  593. standing_fan = create_standing_fan_for_testing({key: value})
  594. assert getter(standing_fan) == value
  595. @pytest.mark.parametrize(
  596. ("battery_byte", "charging", "battery"),
  597. [(0xD5, True, 85), (0x55, False, 85)],
  598. )
  599. def test_process_standing_fan_charging(battery_byte, charging, battery):
  600. mfr_data = bytes([0, 1, 2, 3, 4, 5, 0x01, 0x80, battery_byte, 0x32])
  601. result = process_standing_fan(None, mfr_data)
  602. assert result["charging"] is charging
  603. assert result["battery"] == battery
  604. def test_process_standing_fan_charging_short_payload():
  605. assert process_standing_fan(None, None) == {}
  606. assert process_standing_fan(None, b"\x00") == {}