test_air_purifier.py 15 KB

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