test_air_purifier.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. from unittest.mock import AsyncMock, MagicMock, call, patch
  2. import pytest
  3. import pytest_asyncio
  4. from bleak.backends.device import BLEDevice
  5. from switchbot import (
  6. SwitchBotAdvertisement,
  7. SwitchbotModel,
  8. SwitchbotOperationError,
  9. )
  10. from switchbot.const.air_purifier import AirPurifierMode
  11. from switchbot.devices import air_purifier
  12. from .test_adv_parser import generate_ble_device
  13. common_params = [
  14. (b"7\x00\x00\x95-\x00", "7", SwitchbotModel.AIR_PURIFIER_TABLE_US),
  15. (b"*\x00\x00\x15\x04\x00", "*", SwitchbotModel.AIR_PURIFIER_US),
  16. (b"+\x00\x00\x15\x04\x00", "+", SwitchbotModel.AIR_PURIFIER_JP),
  17. (b"8\x00\x00\x95-\x00", "8", SwitchbotModel.AIR_PURIFIER_TABLE_JP),
  18. ]
  19. @pytest_asyncio.fixture(params=common_params, ids=["table_us", "us", "jp", "table_jp"])
  20. async def filter_reset_device(
  21. request: pytest.FixtureRequest,
  22. ) -> air_purifier.SwitchbotAirPurifier:
  23. raw_adv, model, model_name = request.param
  24. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  25. device = air_purifier.SwitchbotAirPurifier(
  26. ble_device, "ff", "ffffffffffffffffffffffffffffffff", model=model_name
  27. )
  28. device.update = AsyncMock()
  29. device.update_from_advertisement(
  30. make_advertisement_data(ble_device, raw_adv, model, model_name)
  31. )
  32. return device
  33. @pytest.mark.asyncio
  34. async def test_reset_filter_usage(
  35. filter_reset_device: air_purifier.SwitchbotAirPurifier,
  36. ) -> None:
  37. device = filter_reset_device
  38. initial_data = device.parsed_data.copy()
  39. listener = MagicMock()
  40. unsubscribe = device.subscribe(listener)
  41. with patch.object(
  42. device, "_send_command", AsyncMock(side_effect=[b"\x01", b"\x01\x00\x00"])
  43. ) as send_command:
  44. assert await device.reset_filter_usage() is True
  45. assert send_command.await_args_list == [
  46. call("570f4c09", retry=0),
  47. call("570f4d09"),
  48. ]
  49. assert device.parsed_data == initial_data | {"filter element working time": 0}
  50. listener.assert_called_once_with()
  51. unsubscribe()
  52. advertisement = device._sb_adv_data
  53. assert advertisement is not None
  54. device.update_from_advertisement(
  55. make_advertisement_data(
  56. advertisement.device,
  57. advertisement.data["rawAdvData"],
  58. advertisement.data["model"],
  59. advertisement.data["modelName"],
  60. {"filter element working time": 1},
  61. )
  62. )
  63. assert device.parsed_data["filter element working time"] == 1
  64. @pytest.mark.asyncio
  65. @pytest.mark.parametrize(
  66. ("reset_response", "read_response", "message", "expected_calls"),
  67. [
  68. pytest.param(None, None, "Sending command failed", 1, id="no_reset_reply"),
  69. pytest.param(b"", None, "Sending command failed", 1, id="empty_reset_reply"),
  70. pytest.param(b"\x02", None, "reset was rejected", 1, id="reset_rejected"),
  71. pytest.param(b"\x01", None, "could not be verified", 2, id="no_read_reply"),
  72. pytest.param(b"\x01", b"", "could not be verified", 2, id="empty_read_reply"),
  73. pytest.param(
  74. b"\x01", b"\x01", "could not be verified", 2, id="short_read_reply"
  75. ),
  76. pytest.param(
  77. b"\x01", b"\x01\x00", "could not be verified", 2, id="partial_counter"
  78. ),
  79. pytest.param(
  80. b"\x01", b"\x02\x00\x00", "read was rejected", 2, id="read_rejected"
  81. ),
  82. pytest.param(
  83. b"\x01", b"\x01\x07\x68", "1896 used hours", 2, id="counter_not_reset"
  84. ),
  85. ],
  86. )
  87. async def test_reset_filter_usage_failure(
  88. filter_reset_device: air_purifier.SwitchbotAirPurifier,
  89. reset_response: bytes | None,
  90. read_response: bytes | None,
  91. message: str,
  92. expected_calls: int,
  93. ) -> None:
  94. device = filter_reset_device
  95. initial_data = device.parsed_data.copy()
  96. listener = MagicMock()
  97. device.subscribe(listener)
  98. with patch.object(
  99. device, "_send_command", AsyncMock(side_effect=[reset_response, read_response])
  100. ) as send_command:
  101. with pytest.raises(SwitchbotOperationError, match=message):
  102. await device.reset_filter_usage()
  103. assert send_command.await_count == expected_calls
  104. assert device.parsed_data == initial_data
  105. listener.assert_not_called()
  106. @pytest.mark.asyncio
  107. @pytest.mark.parametrize(
  108. ("responses", "expected_calls"),
  109. [
  110. pytest.param(
  111. [SwitchbotOperationError("Connection failed")], 1, id="reset_connection"
  112. ),
  113. pytest.param(
  114. [b"\x01", SwitchbotOperationError("Connection failed")],
  115. 2,
  116. id="read_connection",
  117. ),
  118. ],
  119. )
  120. async def test_reset_filter_usage_transport_failure(
  121. filter_reset_device: air_purifier.SwitchbotAirPurifier,
  122. responses: list[bytes | SwitchbotOperationError],
  123. expected_calls: int,
  124. ) -> None:
  125. device = filter_reset_device
  126. initial_data = device.parsed_data.copy()
  127. listener = MagicMock()
  128. device.subscribe(listener)
  129. with patch.object(
  130. device, "_send_command", AsyncMock(side_effect=responses)
  131. ) as send_command:
  132. with pytest.raises(SwitchbotOperationError, match="Connection failed"):
  133. await device.reset_filter_usage()
  134. assert send_command.await_count == expected_calls
  135. assert device.parsed_data == initial_data
  136. listener.assert_not_called()
  137. def create_device_for_command_testing(
  138. rawAdvData: bytes,
  139. model: str,
  140. model_name: SwitchbotModel,
  141. init_data: dict | None = None,
  142. ):
  143. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  144. device = air_purifier.SwitchbotAirPurifier(
  145. ble_device,
  146. "ff",
  147. "ffffffffffffffffffffffffffffffff",
  148. model=model_name,
  149. )
  150. device.update = AsyncMock()
  151. device.update_from_advertisement(
  152. make_advertisement_data(ble_device, rawAdvData, model, model_name, init_data)
  153. )
  154. device._send_command = AsyncMock()
  155. device._check_command_result = MagicMock()
  156. return device
  157. def make_advertisement_data(
  158. ble_device: BLEDevice,
  159. rawAdvData: bytes,
  160. model: str,
  161. model_name: SwitchbotModel,
  162. init_data: dict | None = None,
  163. ):
  164. """Set advertisement data with defaults."""
  165. if init_data is None:
  166. init_data = {}
  167. return SwitchBotAdvertisement(
  168. address="aa:bb:cc:dd:ee:ff",
  169. data={
  170. "rawAdvData": rawAdvData,
  171. "data": {
  172. "isOn": True,
  173. "mode": "level_3",
  174. "isAqiValid": False,
  175. "child_lock": False,
  176. "speed": 100,
  177. "aqi_level": "excellent",
  178. "filter element working time": 405,
  179. "err_code": 0,
  180. "sequence_number": 161,
  181. }
  182. | init_data,
  183. "isEncrypted": False,
  184. "model": model,
  185. "modelFriendlyName": "Air Purifier",
  186. "modelName": model_name,
  187. },
  188. device=ble_device,
  189. rssi=-80,
  190. active=True,
  191. )
  192. @pytest.mark.asyncio
  193. @pytest.mark.parametrize(
  194. ("rawAdvData", "model", "model_name"),
  195. common_params,
  196. )
  197. @pytest.mark.parametrize(
  198. "pm25",
  199. [150],
  200. )
  201. async def test_status_from_process_adv(rawAdvData, model, model_name, pm25):
  202. device = create_device_for_command_testing(
  203. rawAdvData, model, model_name, {"pm25": pm25}
  204. )
  205. assert device.get_current_percentage() == 100
  206. assert device.is_on() is True
  207. assert device.get_current_aqi_level() == "excellent"
  208. assert device.get_current_mode() == "level_3"
  209. assert device.get_current_pm25() == 150
  210. @pytest.mark.asyncio
  211. @pytest.mark.parametrize(
  212. ("rawAdvData", "model", "model_name"),
  213. common_params,
  214. )
  215. async def test_get_basic_info_returns_none_when_no_data(rawAdvData, model, model_name):
  216. device = create_device_for_command_testing(rawAdvData, model, model_name)
  217. device._get_basic_info_by_multi_commands = AsyncMock(return_value=None)
  218. assert await device.get_basic_info() is None
  219. @pytest.mark.asyncio
  220. @pytest.mark.parametrize(
  221. ("rawAdvData", "model", "model_name"),
  222. common_params,
  223. )
  224. @pytest.mark.parametrize(
  225. "mode", ["level_1", "level_2", "level_3", "auto", "pet", "sleep"]
  226. )
  227. async def test_set_preset_mode(rawAdvData, model, model_name, mode):
  228. device = create_device_for_command_testing(
  229. rawAdvData, model, model_name, {"mode": mode}
  230. )
  231. await device.set_preset_mode(mode)
  232. assert device.get_current_mode() == mode
  233. @pytest.mark.asyncio
  234. @pytest.mark.parametrize(
  235. ("rawAdvData", "model", "model_name"),
  236. common_params,
  237. )
  238. async def test_turn_on(rawAdvData, model, model_name):
  239. device = create_device_for_command_testing(
  240. rawAdvData, model, model_name, {"isOn": True}
  241. )
  242. await device.turn_on()
  243. assert device.is_on() is True
  244. @pytest.mark.asyncio
  245. @pytest.mark.parametrize(
  246. ("rawAdvData", "model", "model_name"),
  247. common_params,
  248. )
  249. async def test_turn_off(rawAdvData, model, model_name):
  250. device = create_device_for_command_testing(
  251. rawAdvData, model, model_name, {"isOn": False}
  252. )
  253. await device.turn_off()
  254. assert device.is_on() is False
  255. @pytest.mark.asyncio
  256. @pytest.mark.parametrize(
  257. ("rawAdvData", "model", "model_name"),
  258. common_params,
  259. )
  260. @pytest.mark.parametrize(
  261. ("response", "expected"),
  262. [
  263. (b"\x00", None),
  264. (b"\x07", None),
  265. (b"\x01\x02\x03", b"\x01\x02\x03"),
  266. ],
  267. )
  268. async def test__get_basic_info(rawAdvData, model, model_name, response, expected):
  269. device = create_device_for_command_testing(rawAdvData, model, model_name)
  270. device._send_command = AsyncMock(return_value=response)
  271. result = await device._get_basic_info()
  272. assert result == expected
  273. @pytest.mark.asyncio
  274. @pytest.mark.parametrize(
  275. "device_case",
  276. common_params,
  277. )
  278. @pytest.mark.parametrize(
  279. "info_case",
  280. [
  281. (
  282. bytearray(
  283. b"\x01\xa7\xe9\x8c\x08\x00\xb2\x01\x96\x00\x00\x00\x00\x01\x00\x17"
  284. ),
  285. bytearray(b"\x01\x01\x11\x22\x33\x44"),
  286. bytearray(b"\x01\x03"),
  287. [
  288. True,
  289. 2,
  290. "level_2",
  291. True,
  292. False,
  293. "excellent",
  294. 50,
  295. 1,
  296. 2.3,
  297. 0x44,
  298. True,
  299. ],
  300. ),
  301. (
  302. bytearray(
  303. b"\x01\xa8\xec\x8c\x08\x00\xb2\x01\x96\x00\x00\x00\x01\x00\x00\x17"
  304. ),
  305. bytearray(b"\x01\x01\xaa\xbb\xcc\x1e"),
  306. bytearray(b"\x01\x00"),
  307. [
  308. True,
  309. 2,
  310. "pet",
  311. True,
  312. False,
  313. "excellent",
  314. 50,
  315. 256,
  316. 2.3,
  317. 0x1E,
  318. False,
  319. ],
  320. ),
  321. ],
  322. )
  323. async def test_get_basic_info(device_case, info_case):
  324. rawAdvData, model, model_name = device_case
  325. basic_info, led_settings, led_status, result = info_case
  326. device = create_device_for_command_testing(rawAdvData, model, model_name)
  327. device._get_basic_info_by_multi_commands = AsyncMock(
  328. return_value=[basic_info, led_settings, led_status]
  329. )
  330. info = await device.get_basic_info()
  331. assert info["isOn"] == result[0]
  332. assert info["version_info"] == result[1]
  333. assert info["mode"] == result[2]
  334. assert info["isAqiValid"] == result[3]
  335. assert info["child_lock"] == result[4]
  336. assert info["aqi_level"] == result[5]
  337. assert info["speed"] == result[6]
  338. if model_name not in (
  339. SwitchbotModel.AIR_PURIFIER_JP,
  340. SwitchbotModel.AIR_PURIFIER_TABLE_JP,
  341. ):
  342. assert info["pm25"] == result[7]
  343. else:
  344. assert "pm25" not in info
  345. assert info["firmware"] == result[8]
  346. assert info["brightness"] == result[9]
  347. assert info["light_sensitive"] == result[10]
  348. def test_default_model_classvar():
  349. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  350. device = air_purifier.SwitchbotAirPurifier(
  351. ble_device, "ff", "ffffffffffffffffffffffffffffffff"
  352. )
  353. assert device._model == SwitchbotModel.AIR_PURIFIER_US
  354. def test_get_modes():
  355. assert AirPurifierMode.get_modes() == [
  356. "level_1",
  357. "level_2",
  358. "level_3",
  359. "auto",
  360. "sleep",
  361. "pet",
  362. ]
  363. @pytest.mark.asyncio
  364. async def test_air_purifier_color_and_led_properties():
  365. raw_adv, model, model_name = common_params[0]
  366. device = create_device_for_command_testing(
  367. raw_adv,
  368. model,
  369. model_name,
  370. )
  371. assert device.color_modes == {air_purifier.ColorMode.RGB}
  372. assert device.color_mode == air_purifier.ColorMode.RGB
  373. @pytest.mark.asyncio
  374. async def test_set_percentage_validation_and_command():
  375. raw_adv, model, model_name = common_params[0]
  376. device = create_device_for_command_testing(
  377. raw_adv,
  378. model,
  379. model_name,
  380. {"mode": "level_2"},
  381. )
  382. device._check_command_result = MagicMock(return_value=True)
  383. device._send_command = AsyncMock(return_value=b"\x01")
  384. assert await device.set_percentage(25) is True
  385. device._send_command.assert_called_with(
  386. air_purifier.COMMAND_SET_PERCENTAGE.format(percentage=25)
  387. )
  388. with pytest.raises(ValueError, match="Percentage must be between 0 and 100"):
  389. await device.set_percentage(-1)
  390. with pytest.raises(ValueError, match="Percentage must be between 0 and 100"):
  391. await device.set_percentage(101)
  392. invalid_mode_device = create_device_for_command_testing(
  393. raw_adv,
  394. model,
  395. model_name,
  396. {"mode": "auto"},
  397. )
  398. with pytest.raises(ValueError, match="Percentage can only be set in LEVEL modes"):
  399. await invalid_mode_device.set_percentage(10)
  400. @pytest.mark.asyncio
  401. async def test_set_brightness_validation_and_command():
  402. raw_adv, model, model_name = common_params[0]
  403. device = create_device_for_command_testing(raw_adv, model, model_name)
  404. device._state = {"r": 1, "g": 2, "b": 3}
  405. device._check_command_result = MagicMock(return_value=True)
  406. device._send_command = AsyncMock(return_value=b"\x01")
  407. assert await device.set_brightness(10) is True
  408. device._send_command.assert_called_with(
  409. device._set_brightness_command.format("0102030A")
  410. )
  411. with pytest.raises(ValueError, match="Brightness must be between 0 and 100"):
  412. await device.set_brightness(101)
  413. @pytest.mark.asyncio
  414. async def test_set_rgb_validation_and_command():
  415. raw_adv, model, model_name = common_params[0]
  416. device = create_device_for_command_testing(raw_adv, model, model_name)
  417. device._check_command_result = MagicMock(return_value=True)
  418. device._send_command = AsyncMock(return_value=b"\x01")
  419. assert await device.set_rgb(20, 1, 2, 3) is True
  420. device._send_command.assert_called_with(device._set_rgb_command.format("01020314"))
  421. with pytest.raises(ValueError, match="Brightness must be between 0 and 100"):
  422. await device.set_rgb(101, 1, 2, 3)
  423. with pytest.raises(ValueError, match="r must be between 0 and 255"):
  424. await device.set_rgb(10, 256, 2, 3)
  425. with pytest.raises(ValueError, match="g must be between 0 and 255"):
  426. await device.set_rgb(10, 1, 256, 3)
  427. with pytest.raises(ValueError, match="b must be between 0 and 255"):
  428. await device.set_rgb(10, 1, 2, 256)
  429. @pytest.mark.asyncio
  430. async def test_led_and_light_sensitive_commands():
  431. raw_adv, model, model_name = common_params[0]
  432. device = create_device_for_command_testing(
  433. raw_adv, model, model_name, {"led_status": True}
  434. )
  435. device._check_command_result = MagicMock(return_value=True)
  436. device._send_command = AsyncMock(return_value=b"\x01")
  437. assert await device.turn_led_on() is True
  438. device._send_command.assert_called_with(device._turn_led_on_command)
  439. assert await device.turn_led_off() is True
  440. device._send_command.assert_called_with(device._turn_led_off_command)
  441. assert await device.open_light_sensitive_switch() is True
  442. device._send_command.assert_called_with(device._open_light_sensitive_switch_command)
  443. assert await device.close_light_sensitive_switch() is True
  444. device._send_command.assert_called_with(device._turn_led_on_command)
  445. device_off = create_device_for_command_testing(
  446. raw_adv,
  447. model,
  448. model_name,
  449. )
  450. device_off._check_command_result = MagicMock(return_value=True)
  451. device_off._send_command = AsyncMock(return_value=b"\x01")
  452. assert await device_off.close_light_sensitive_switch() is True
  453. device_off._send_command.assert_called_with(device_off._turn_led_on_command)
  454. @pytest.mark.asyncio
  455. async def test_air_purifier_cache_getters():
  456. raw_adv, model, model_name = common_params[0]
  457. device = create_device_for_command_testing(
  458. raw_adv,
  459. model,
  460. model_name,
  461. {
  462. "child_lock": True,
  463. "wireless_charging": True,
  464. "light_sensitive": True,
  465. "speed": 88,
  466. },
  467. )
  468. assert device.get_current_percentage() == 88
  469. assert device.is_child_lock_on() is True
  470. assert device.is_wireless_charging_on() is True
  471. assert device.is_light_sensitive_on() is True
  472. @pytest.mark.asyncio
  473. @pytest.mark.parametrize(
  474. "operation_case",
  475. [
  476. ("open_child_lock", "_open_child_lock_command"),
  477. ("close_child_lock", "_close_child_lock_command"),
  478. ],
  479. )
  480. async def test_child_lock_operations(operation_case):
  481. """Child lock commands should always be forwarded correctly."""
  482. raw_adv, model, model_name = common_params[0]
  483. device = create_device_for_command_testing(raw_adv, model, model_name)
  484. operation_name, command_attr = operation_case
  485. command = getattr(device, command_attr)
  486. device._check_function_support = MagicMock()
  487. device._check_command_result = MagicMock(return_value=True)
  488. device._send_command = AsyncMock(return_value=b"\x01")
  489. operation = getattr(device, operation_name)
  490. assert await operation() is True
  491. device._check_function_support.assert_called_with(command)
  492. device._send_command.assert_called_with(command)
  493. device._check_command_result.assert_called_with(b"\x01", 0, {1})
  494. @pytest.mark.asyncio
  495. @pytest.mark.parametrize(
  496. ("raw_adv", "model", "model_name", "supported"),
  497. [
  498. (*common_params[0], True),
  499. (*common_params[3], True),
  500. (*common_params[1], False),
  501. (*common_params[2], False),
  502. ],
  503. )
  504. async def test_wireless_charging_model_support(raw_adv, model, model_name, supported):
  505. """Wireless charging operations should only succeed for table variants."""
  506. device = create_device_for_command_testing(raw_adv, model, model_name)
  507. if supported:
  508. device._check_command_result = MagicMock(return_value=True)
  509. device._send_command = AsyncMock(return_value=b"\x01")
  510. assert await device.open_wireless_charging() is True
  511. assert await device.close_wireless_charging() is True
  512. assert device._send_command.call_args_list == [
  513. ((device._open_wireless_charging_command,),),
  514. ((device._close_wireless_charging_command,),),
  515. ]
  516. else:
  517. with pytest.raises(SwitchbotOperationError):
  518. await device.open_wireless_charging()
  519. with pytest.raises(SwitchbotOperationError):
  520. await device.close_wireless_charging()