test_fan.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730
  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. # OFF is sent as 0x00 (firmware ignores NightLightState.OFF's 0x03).
  471. expected = 0 if state is NightLightState.OFF else state.value
  472. assert cmd == f"{fan.COMMAND_SET_NIGHT_LIGHT}{expected:02X}FFFF"
  473. @pytest.mark.asyncio
  474. @pytest.mark.parametrize("state", [1, 2, 3])
  475. async def test_standing_fan_set_night_light_int(state):
  476. """Raw int inputs are coerced through NightLightState(state)."""
  477. standing_fan = create_standing_fan_for_testing()
  478. await standing_fan.set_night_light(state)
  479. cmd = standing_fan._send_command.call_args[0][0]
  480. # OFF (3) is sent as 0x00 (firmware ignores NightLightState.OFF's 0x03).
  481. expected = 0 if state == NightLightState.OFF.value else state
  482. assert cmd == f"{fan.COMMAND_SET_NIGHT_LIGHT}{expected:02X}FFFF"
  483. @pytest.mark.asyncio
  484. @pytest.mark.parametrize("state", [0, 4, 99, -1])
  485. async def test_standing_fan_set_night_light_invalid(state):
  486. standing_fan = create_standing_fan_for_testing()
  487. with pytest.raises(ValueError, match="is not a valid"):
  488. await standing_fan.set_night_light(state)
  489. standing_fan._send_command.assert_not_called()
  490. def test_standing_fan_get_night_light_state():
  491. standing_fan = create_standing_fan_for_testing({"nightLight": 1})
  492. assert standing_fan.get_night_light_state() == 1
  493. @pytest.mark.asyncio
  494. @pytest.mark.parametrize(
  495. ("oscillating", "expected_cmd"),
  496. [
  497. (True, fan.COMMAND_START_HORIZONTAL_OSCILLATION),
  498. (False, fan.COMMAND_STOP_HORIZONTAL_OSCILLATION),
  499. ],
  500. )
  501. async def test_standing_fan_set_horizontal_oscillation(oscillating, expected_cmd):
  502. standing_fan = create_standing_fan_for_testing({"oscillating": oscillating})
  503. await standing_fan.set_horizontal_oscillation(oscillating)
  504. standing_fan._send_command.assert_called_once()
  505. cmd = standing_fan._send_command.call_args[0][0]
  506. assert cmd == expected_cmd
  507. @pytest.mark.asyncio
  508. @pytest.mark.parametrize(
  509. ("oscillating", "expected_cmd"),
  510. [
  511. (True, fan.COMMAND_START_VERTICAL_OSCILLATION),
  512. (False, fan.COMMAND_STOP_VERTICAL_OSCILLATION),
  513. ],
  514. )
  515. async def test_standing_fan_set_vertical_oscillation(oscillating, expected_cmd):
  516. standing_fan = create_standing_fan_for_testing({"oscillating": oscillating})
  517. await standing_fan.set_vertical_oscillation(oscillating)
  518. standing_fan._send_command.assert_called_once()
  519. cmd = standing_fan._send_command.call_args[0][0]
  520. assert cmd == expected_cmd
  521. def test_standing_fan_get_horizontal_oscillating_state():
  522. standing_fan = create_standing_fan_for_testing({"oscillating_horizontal": True})
  523. assert standing_fan.get_horizontal_oscillating_state() is True
  524. def test_standing_fan_get_vertical_oscillating_state():
  525. standing_fan = create_standing_fan_for_testing({"oscillating_vertical": True})
  526. assert standing_fan.get_vertical_oscillating_state() is True
  527. @pytest.mark.asyncio
  528. async def test_standing_fan_get_basic_info_extended():
  529. """The Standing Fan decodes angles, charging, child lock, etc. from status."""
  530. standing_fan = create_standing_fan_for_testing({"nightLight": 2})
  531. # byte: 2=battery|charge, 3=status bits, 4=h angle, 6=v angle (95=90deg),
  532. # 8=mode (low nibble), 9=speed, 10=sound.
  533. basic_info = bytearray(b"\x01\x02\xd5\xd3\x3c\x00\x5f\x00\x32\x32\x64")
  534. firmware_info = bytearray(b"\x01W\x0b\x17\x01")
  535. async def mock_get_basic_info(arg):
  536. if arg == fan.COMMAND_GET_BASIC_INFO:
  537. return basic_info
  538. if arg == fan.DEVICE_GET_BASIC_SETTINGS_KEY:
  539. return firmware_info
  540. return None
  541. standing_fan._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
  542. info = await standing_fan.get_basic_info()
  543. assert info["battery"] == 85
  544. assert info["charging"] is True
  545. assert info["isOn"] is True
  546. assert info["oscillating_horizontal"] is True
  547. assert info["oscillating_vertical"] is False
  548. assert info["oscillating_horizontal_angle"] == 60
  549. assert info["oscillating_vertical_angle"] == 95
  550. assert info["child_lock"] is True
  551. assert info["display"] is True
  552. assert info["auto_recenter"] is True
  553. assert info["sound"] is True
  554. assert info["mode"] == "natural"
  555. assert info["speed"] == 50
  556. assert info["firmware"] == 1.1
  557. @pytest.mark.asyncio
  558. @pytest.mark.parametrize(
  559. ("invoke", "expected_cmd"),
  560. [
  561. (lambda d: d.set_child_lock(True), f"{fan.COMMAND_SET_CHILD_LOCK}01"),
  562. (lambda d: d.set_child_lock(False), f"{fan.COMMAND_SET_CHILD_LOCK}02"),
  563. (lambda d: d.set_display(True), f"{fan.COMMAND_SET_DISPLAY_LIGHT}01FFFF"),
  564. (lambda d: d.set_display(False), f"{fan.COMMAND_SET_DISPLAY_LIGHT}02FFFF"),
  565. (lambda d: d.set_sound(True), f"{fan.COMMAND_SET_SOUND}64"),
  566. (lambda d: d.set_sound(False), f"{fan.COMMAND_SET_SOUND}00"),
  567. (lambda d: d.set_auto_recenter(True), f"{fan.COMMAND_SET_AUTO_RECENTER}0101"),
  568. (lambda d: d.set_auto_recenter(False), f"{fan.COMMAND_SET_AUTO_RECENTER}0202"),
  569. ],
  570. )
  571. async def test_standing_fan_extra_setter_commands(invoke, expected_cmd):
  572. standing_fan = create_standing_fan_for_testing()
  573. await invoke(standing_fan)
  574. standing_fan._send_command.assert_called_once()
  575. assert standing_fan._send_command.call_args[0][0] == expected_cmd
  576. @pytest.mark.parametrize(
  577. ("getter", "key", "value"),
  578. [
  579. (
  580. lambda d: d.get_horizontal_oscillation_angle(),
  581. "oscillating_horizontal_angle",
  582. 60,
  583. ),
  584. (
  585. lambda d: d.get_vertical_oscillation_angle(),
  586. "oscillating_vertical_angle",
  587. 95,
  588. ),
  589. (lambda d: d.is_charging(), "charging", True),
  590. (lambda d: d.get_child_lock(), "child_lock", True),
  591. (lambda d: d.get_display(), "display", False),
  592. (lambda d: d.get_sound(), "sound", True),
  593. (lambda d: d.get_auto_recenter(), "auto_recenter", True),
  594. ],
  595. )
  596. def test_standing_fan_cached_getters(getter, key, value):
  597. standing_fan = create_standing_fan_for_testing({key: value})
  598. assert getter(standing_fan) == value
  599. @pytest.mark.parametrize(
  600. ("battery_byte", "charging", "battery"),
  601. [(0xD5, True, 85), (0x55, False, 85)],
  602. )
  603. def test_process_standing_fan_charging(battery_byte, charging, battery):
  604. mfr_data = bytes([0, 1, 2, 3, 4, 5, 0x01, 0x80, battery_byte, 0x32])
  605. result = process_standing_fan(None, mfr_data)
  606. assert result["charging"] is charging
  607. assert result["battery"] == battery
  608. def test_process_standing_fan_charging_short_payload():
  609. assert process_standing_fan(None, None) == {}
  610. assert process_standing_fan(None, b"\x00") == {}