test_air_purifier.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. from unittest.mock import AsyncMock, MagicMock
  2. import pytest
  3. from bleak.backends.device import BLEDevice
  4. from switchbot import (
  5. SwitchBotAdvertisement,
  6. SwitchbotModel,
  7. SwitchbotOperationError,
  8. )
  9. from switchbot.const.air_purifier import AirPurifierMode
  10. from switchbot.devices import air_purifier
  11. from .test_adv_parser import generate_ble_device
  12. common_params = [
  13. (b"7\x00\x00\x95-\x00", "7", SwitchbotModel.AIR_PURIFIER_TABLE_US),
  14. (b"*\x00\x00\x15\x04\x00", "*", SwitchbotModel.AIR_PURIFIER_US),
  15. (b"+\x00\x00\x15\x04\x00", "+", SwitchbotModel.AIR_PURIFIER_JP),
  16. (b"8\x00\x00\x95-\x00", "8", SwitchbotModel.AIR_PURIFIER_TABLE_JP),
  17. ]
  18. def create_device_for_command_testing(
  19. rawAdvData: bytes,
  20. model: str,
  21. model_name: SwitchbotModel,
  22. init_data: dict | None = None,
  23. ):
  24. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  25. device = air_purifier.SwitchbotAirPurifier(
  26. ble_device,
  27. "ff",
  28. "ffffffffffffffffffffffffffffffff",
  29. model=model_name,
  30. )
  31. device.update = AsyncMock()
  32. device.update_from_advertisement(
  33. make_advertisement_data(ble_device, rawAdvData, model, model_name, init_data)
  34. )
  35. device._send_command = AsyncMock()
  36. device._check_command_result = MagicMock()
  37. return device
  38. def make_advertisement_data(
  39. ble_device: BLEDevice,
  40. rawAdvData: bytes,
  41. model: str,
  42. model_name: SwitchbotModel,
  43. init_data: dict | None = None,
  44. ):
  45. """Set advertisement data with defaults."""
  46. if init_data is None:
  47. init_data = {}
  48. return SwitchBotAdvertisement(
  49. address="aa:bb:cc:dd:ee:ff",
  50. data={
  51. "rawAdvData": rawAdvData,
  52. "data": {
  53. "isOn": True,
  54. "mode": "level_3",
  55. "isAqiValid": False,
  56. "child_lock": False,
  57. "speed": 100,
  58. "aqi_level": "excellent",
  59. "filter element working time": 405,
  60. "err_code": 0,
  61. "sequence_number": 161,
  62. }
  63. | init_data,
  64. "isEncrypted": False,
  65. "model": model,
  66. "modelFriendlyName": "Air Purifier",
  67. "modelName": model_name,
  68. },
  69. device=ble_device,
  70. rssi=-80,
  71. active=True,
  72. )
  73. @pytest.mark.asyncio
  74. @pytest.mark.parametrize(
  75. ("rawAdvData", "model", "model_name"),
  76. common_params,
  77. )
  78. @pytest.mark.parametrize(
  79. "pm25",
  80. [150],
  81. )
  82. async def test_status_from_process_adv(rawAdvData, model, model_name, pm25):
  83. device = create_device_for_command_testing(
  84. rawAdvData, model, model_name, {"pm25": pm25}
  85. )
  86. assert device.get_current_percentage() == 100
  87. assert device.is_on() is True
  88. assert device.get_current_aqi_level() == "excellent"
  89. assert device.get_current_mode() == "level_3"
  90. assert device.get_current_pm25() == 150
  91. @pytest.mark.asyncio
  92. @pytest.mark.parametrize(
  93. ("rawAdvData", "model", "model_name"),
  94. common_params,
  95. )
  96. async def test_get_basic_info_returns_none_when_no_data(rawAdvData, model, model_name):
  97. device = create_device_for_command_testing(rawAdvData, model, model_name)
  98. device._get_basic_info_by_multi_commands = AsyncMock(return_value=None)
  99. assert await device.get_basic_info() is None
  100. @pytest.mark.asyncio
  101. @pytest.mark.parametrize(
  102. ("rawAdvData", "model", "model_name"),
  103. common_params,
  104. )
  105. @pytest.mark.parametrize(
  106. "mode", ["level_1", "level_2", "level_3", "auto", "pet", "sleep"]
  107. )
  108. async def test_set_preset_mode(rawAdvData, model, model_name, mode):
  109. device = create_device_for_command_testing(
  110. rawAdvData, model, model_name, {"mode": mode}
  111. )
  112. await device.set_preset_mode(mode)
  113. assert device.get_current_mode() == mode
  114. @pytest.mark.asyncio
  115. @pytest.mark.parametrize(
  116. ("rawAdvData", "model", "model_name"),
  117. common_params,
  118. )
  119. async def test_turn_on(rawAdvData, model, model_name):
  120. device = create_device_for_command_testing(
  121. rawAdvData, model, model_name, {"isOn": True}
  122. )
  123. await device.turn_on()
  124. assert device.is_on() is True
  125. @pytest.mark.asyncio
  126. @pytest.mark.parametrize(
  127. ("rawAdvData", "model", "model_name"),
  128. common_params,
  129. )
  130. async def test_turn_off(rawAdvData, model, model_name):
  131. device = create_device_for_command_testing(
  132. rawAdvData, model, model_name, {"isOn": False}
  133. )
  134. await device.turn_off()
  135. assert device.is_on() is False
  136. @pytest.mark.asyncio
  137. @pytest.mark.parametrize(
  138. ("rawAdvData", "model", "model_name"),
  139. common_params,
  140. )
  141. @pytest.mark.parametrize(
  142. ("response", "expected"),
  143. [
  144. (b"\x00", None),
  145. (b"\x07", None),
  146. (b"\x01\x02\x03", b"\x01\x02\x03"),
  147. ],
  148. )
  149. async def test__get_basic_info(rawAdvData, model, model_name, response, expected):
  150. device = create_device_for_command_testing(rawAdvData, model, model_name)
  151. device._send_command = AsyncMock(return_value=response)
  152. result = await device._get_basic_info()
  153. assert result == expected
  154. @pytest.mark.asyncio
  155. @pytest.mark.parametrize(
  156. "device_case",
  157. common_params,
  158. )
  159. @pytest.mark.parametrize(
  160. "info_case",
  161. [
  162. (
  163. bytearray(
  164. b"\x01\xa7\xe9\x8c\x08\x00\xb2\x01\x96\x00\x00\x00\x00\x01\x00\x17"
  165. ),
  166. bytearray(b"\x01\x01\x11\x22\x33\x44"),
  167. bytearray(b"\x01\x03"),
  168. [
  169. True,
  170. 2,
  171. "level_2",
  172. True,
  173. False,
  174. "excellent",
  175. 50,
  176. 1,
  177. 2.3,
  178. 0x44,
  179. True,
  180. ],
  181. ),
  182. (
  183. bytearray(
  184. b"\x01\xa8\xec\x8c\x08\x00\xb2\x01\x96\x00\x00\x00\x01\x00\x00\x17"
  185. ),
  186. bytearray(b"\x01\x01\xaa\xbb\xcc\x1e"),
  187. bytearray(b"\x01\x00"),
  188. [
  189. True,
  190. 2,
  191. "pet",
  192. True,
  193. False,
  194. "excellent",
  195. 50,
  196. 256,
  197. 2.3,
  198. 0x1E,
  199. False,
  200. ],
  201. ),
  202. ],
  203. )
  204. async def test_get_basic_info(device_case, info_case):
  205. rawAdvData, model, model_name = device_case
  206. basic_info, led_settings, led_status, result = info_case
  207. device = create_device_for_command_testing(rawAdvData, model, model_name)
  208. device._get_basic_info_by_multi_commands = AsyncMock(
  209. return_value=[basic_info, led_settings, led_status]
  210. )
  211. info = await device.get_basic_info()
  212. assert info["isOn"] == result[0]
  213. assert info["version_info"] == result[1]
  214. assert info["mode"] == result[2]
  215. assert info["isAqiValid"] == result[3]
  216. assert info["child_lock"] == result[4]
  217. assert info["aqi_level"] == result[5]
  218. assert info["speed"] == result[6]
  219. if model_name not in (
  220. SwitchbotModel.AIR_PURIFIER_JP,
  221. SwitchbotModel.AIR_PURIFIER_TABLE_JP,
  222. ):
  223. assert info["pm25"] == result[7]
  224. else:
  225. assert "pm25" not in info
  226. assert info["firmware"] == result[8]
  227. assert info["brightness"] == result[9]
  228. assert info["light_sensitive"] == result[10]
  229. def test_default_model_classvar():
  230. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  231. device = air_purifier.SwitchbotAirPurifier(
  232. ble_device, "ff", "ffffffffffffffffffffffffffffffff"
  233. )
  234. assert device._model == SwitchbotModel.AIR_PURIFIER_US
  235. def test_get_modes():
  236. assert AirPurifierMode.get_modes() == [
  237. "level_1",
  238. "level_2",
  239. "level_3",
  240. "auto",
  241. "sleep",
  242. "pet",
  243. ]
  244. @pytest.mark.asyncio
  245. async def test_air_purifier_color_and_led_properties():
  246. raw_adv, model, model_name = common_params[0]
  247. device = create_device_for_command_testing(
  248. raw_adv,
  249. model,
  250. model_name,
  251. )
  252. assert device.color_modes == {air_purifier.ColorMode.RGB}
  253. assert device.color_mode == air_purifier.ColorMode.RGB
  254. @pytest.mark.asyncio
  255. async def test_set_percentage_validation_and_command():
  256. raw_adv, model, model_name = common_params[0]
  257. device = create_device_for_command_testing(
  258. raw_adv,
  259. model,
  260. model_name,
  261. {"mode": "level_2"},
  262. )
  263. device._check_command_result = MagicMock(return_value=True)
  264. device._send_command = AsyncMock(return_value=b"\x01")
  265. assert await device.set_percentage(25) is True
  266. device._send_command.assert_called_with(
  267. air_purifier.COMMAND_SET_PERCENTAGE.format(percentage=25)
  268. )
  269. with pytest.raises(ValueError, match="Percentage must be between 0 and 100"):
  270. await device.set_percentage(-1)
  271. with pytest.raises(ValueError, match="Percentage must be between 0 and 100"):
  272. await device.set_percentage(101)
  273. invalid_mode_device = create_device_for_command_testing(
  274. raw_adv,
  275. model,
  276. model_name,
  277. {"mode": "auto"},
  278. )
  279. with pytest.raises(ValueError, match="Percentage can only be set in LEVEL modes"):
  280. await invalid_mode_device.set_percentage(10)
  281. @pytest.mark.asyncio
  282. async def test_set_brightness_validation_and_command():
  283. raw_adv, model, model_name = common_params[0]
  284. device = create_device_for_command_testing(raw_adv, model, model_name)
  285. device._state = {"r": 1, "g": 2, "b": 3}
  286. device._check_command_result = MagicMock(return_value=True)
  287. device._send_command = AsyncMock(return_value=b"\x01")
  288. assert await device.set_brightness(10) is True
  289. device._send_command.assert_called_with(
  290. device._set_brightness_command.format("0102030A")
  291. )
  292. with pytest.raises(ValueError, match="Brightness must be between 0 and 100"):
  293. await device.set_brightness(101)
  294. @pytest.mark.asyncio
  295. async def test_set_rgb_validation_and_command():
  296. raw_adv, model, model_name = common_params[0]
  297. device = create_device_for_command_testing(raw_adv, model, model_name)
  298. device._check_command_result = MagicMock(return_value=True)
  299. device._send_command = AsyncMock(return_value=b"\x01")
  300. assert await device.set_rgb(20, 1, 2, 3) is True
  301. device._send_command.assert_called_with(device._set_rgb_command.format("01020314"))
  302. with pytest.raises(ValueError, match="Brightness must be between 0 and 100"):
  303. await device.set_rgb(101, 1, 2, 3)
  304. with pytest.raises(ValueError, match="r must be between 0 and 255"):
  305. await device.set_rgb(10, 256, 2, 3)
  306. with pytest.raises(ValueError, match="g must be between 0 and 255"):
  307. await device.set_rgb(10, 1, 256, 3)
  308. with pytest.raises(ValueError, match="b must be between 0 and 255"):
  309. await device.set_rgb(10, 1, 2, 256)
  310. @pytest.mark.asyncio
  311. async def test_led_and_light_sensitive_commands():
  312. raw_adv, model, model_name = common_params[0]
  313. device = create_device_for_command_testing(
  314. raw_adv, model, model_name, {"led_status": True}
  315. )
  316. device._check_command_result = MagicMock(return_value=True)
  317. device._send_command = AsyncMock(return_value=b"\x01")
  318. assert await device.turn_led_on() is True
  319. device._send_command.assert_called_with(device._turn_led_on_command)
  320. assert await device.turn_led_off() is True
  321. device._send_command.assert_called_with(device._turn_led_off_command)
  322. assert await device.open_light_sensitive_switch() is True
  323. device._send_command.assert_called_with(device._open_light_sensitive_switch_command)
  324. assert await device.close_light_sensitive_switch() is True
  325. device._send_command.assert_called_with(device._turn_led_on_command)
  326. device_off = create_device_for_command_testing(
  327. raw_adv,
  328. model,
  329. model_name,
  330. )
  331. device_off._check_command_result = MagicMock(return_value=True)
  332. device_off._send_command = AsyncMock(return_value=b"\x01")
  333. assert await device_off.close_light_sensitive_switch() is True
  334. device_off._send_command.assert_called_with(device_off._turn_led_on_command)
  335. @pytest.mark.asyncio
  336. async def test_air_purifier_cache_getters():
  337. raw_adv, model, model_name = common_params[0]
  338. device = create_device_for_command_testing(
  339. raw_adv,
  340. model,
  341. model_name,
  342. {
  343. "child_lock": True,
  344. "wireless_charging": True,
  345. "light_sensitive": True,
  346. "speed": 88,
  347. },
  348. )
  349. assert device.get_current_percentage() == 88
  350. assert device.is_child_lock_on() is True
  351. assert device.is_wireless_charging_on() is True
  352. assert device.is_light_sensitive_on() is True
  353. @pytest.mark.asyncio
  354. @pytest.mark.parametrize(
  355. "operation_case",
  356. [
  357. ("open_child_lock", "_open_child_lock_command"),
  358. ("close_child_lock", "_close_child_lock_command"),
  359. ],
  360. )
  361. async def test_child_lock_operations(operation_case):
  362. """Child lock commands should always be forwarded correctly."""
  363. raw_adv, model, model_name = common_params[0]
  364. device = create_device_for_command_testing(raw_adv, model, model_name)
  365. operation_name, command_attr = operation_case
  366. command = getattr(device, command_attr)
  367. device._check_function_support = MagicMock()
  368. device._check_command_result = MagicMock(return_value=True)
  369. device._send_command = AsyncMock(return_value=b"\x01")
  370. operation = getattr(device, operation_name)
  371. assert await operation() is True
  372. device._check_function_support.assert_called_with(command)
  373. device._send_command.assert_called_with(command)
  374. device._check_command_result.assert_called_with(b"\x01", 0, {1})
  375. @pytest.mark.asyncio
  376. @pytest.mark.parametrize(
  377. ("raw_adv", "model", "model_name", "supported"),
  378. [
  379. (*common_params[0], True),
  380. (*common_params[3], True),
  381. (*common_params[1], False),
  382. (*common_params[2], False),
  383. ],
  384. )
  385. async def test_wireless_charging_model_support(raw_adv, model, model_name, supported):
  386. """Wireless charging operations should only succeed for table variants."""
  387. device = create_device_for_command_testing(raw_adv, model, model_name)
  388. if supported:
  389. device._check_command_result = MagicMock(return_value=True)
  390. device._send_command = AsyncMock(return_value=b"\x01")
  391. assert await device.open_wireless_charging() is True
  392. assert await device.close_wireless_charging() is True
  393. assert device._send_command.call_args_list == [
  394. ((device._open_wireless_charging_command,),),
  395. ((device._close_wireless_charging_command,),),
  396. ]
  397. else:
  398. with pytest.raises(SwitchbotOperationError):
  399. await device.open_wireless_charging()
  400. with pytest.raises(SwitchbotOperationError):
  401. await device.close_wireless_charging()