test_roller_shade.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. from unittest.mock import AsyncMock
  2. import pytest
  3. from bleak.backends.device import BLEDevice
  4. from switchbot import SwitchBotAdvertisement, SwitchbotModel
  5. from switchbot.devices import roller_shade
  6. from .test_adv_parser import generate_ble_device
  7. def create_device_for_command_testing(
  8. position=50, calibration=True, reverse_mode=False
  9. ):
  10. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  11. roller_shade_device = roller_shade.SwitchbotRollerShade(
  12. ble_device, reverse_mode=reverse_mode
  13. )
  14. roller_shade_device.update_from_advertisement(
  15. make_advertisement_data(ble_device, True, position, calibration)
  16. )
  17. roller_shade_device._send_multiple_commands = AsyncMock()
  18. roller_shade_device.update = AsyncMock()
  19. return roller_shade_device
  20. def make_advertisement_data(
  21. ble_device: BLEDevice, in_motion: bool, position: int, calibration: bool = True
  22. ):
  23. """Set advertisement data with defaults."""
  24. return SwitchBotAdvertisement(
  25. address="aa:bb:cc:dd:ee:ff",
  26. data={
  27. "rawAdvData": b",\x00'\x9f\x11\x04",
  28. "data": {
  29. "battery": 39,
  30. "calibration": calibration,
  31. "deviceChain": 1,
  32. "inMotion": in_motion,
  33. "lightLevel": 1,
  34. "position": position,
  35. },
  36. "isEncrypted": False,
  37. "model": ",",
  38. "modelFriendlyName": "Roller Shade",
  39. "modelName": SwitchbotModel.ROLLER_SHADE,
  40. },
  41. device=ble_device,
  42. rssi=-80,
  43. active=True,
  44. )
  45. @pytest.mark.asyncio
  46. async def test_open():
  47. roller_shade_device = create_device_for_command_testing()
  48. await roller_shade_device.open()
  49. assert roller_shade_device.is_opening() is True
  50. assert roller_shade_device.is_closing() is False
  51. roller_shade_device._send_multiple_commands.assert_awaited_once_with(
  52. [roller_shade.OPEN_KEYS[0], f"{roller_shade.OPEN_KEYS[1]}0000"]
  53. )
  54. @pytest.mark.asyncio
  55. async def test_open_quietdrift():
  56. roller_shade_device = create_device_for_command_testing()
  57. await roller_shade_device.open(mode=1)
  58. assert roller_shade_device.is_opening() is True
  59. assert roller_shade_device.is_closing() is False
  60. roller_shade_device._send_multiple_commands.assert_awaited_once_with(
  61. [roller_shade.OPEN_KEYS[0], f"{roller_shade.OPEN_KEYS[1]}0100"]
  62. )
  63. @pytest.mark.asyncio
  64. async def test_close():
  65. roller_shade_device = create_device_for_command_testing()
  66. await roller_shade_device.close()
  67. assert roller_shade_device.is_opening() is False
  68. assert roller_shade_device.is_closing() is True
  69. roller_shade_device._send_multiple_commands.assert_awaited_once_with(
  70. [roller_shade.CLOSE_KEYS[0], f"{roller_shade.CLOSE_KEYS[1]}0064"]
  71. )
  72. @pytest.mark.asyncio
  73. async def test_close_quietdrift():
  74. roller_shade_device = create_device_for_command_testing()
  75. await roller_shade_device.close(mode=1)
  76. assert roller_shade_device.is_opening() is False
  77. assert roller_shade_device.is_closing() is True
  78. roller_shade_device._send_multiple_commands.assert_awaited_once_with(
  79. [roller_shade.CLOSE_KEYS[0], f"{roller_shade.CLOSE_KEYS[1]}0164"]
  80. )
  81. @pytest.mark.asyncio
  82. async def test_get_basic_info_returns_none_when_no_data():
  83. roller_shade_device = create_device_for_command_testing()
  84. roller_shade_device._get_basic_info = AsyncMock(return_value=None)
  85. assert await roller_shade_device.get_basic_info() is None
  86. @pytest.mark.asyncio
  87. @pytest.mark.parametrize(
  88. ("reverse_mode", "data", "result"),
  89. [
  90. (
  91. True,
  92. bytes([0, 1, 10, 2, 0, 50, 4]),
  93. [1, 1, 2, "anticlockwise", False, False, False, False, False, 50, 4],
  94. ),
  95. (
  96. True,
  97. bytes([0, 1, 10, 2, 214, 50, 4]),
  98. [1, 1, 2, "clockwise", True, False, True, True, True, 50, 4],
  99. ),
  100. ],
  101. )
  102. async def test_get_basic_info(reverse_mode, data, result):
  103. blind_device = create_device_for_command_testing(reverse_mode=reverse_mode)
  104. blind_device._get_basic_info = AsyncMock(return_value=data)
  105. info = await blind_device.get_basic_info()
  106. assert info["battery"] == result[0]
  107. assert info["firmware"] == result[1]
  108. assert info["chainLength"] == result[2]
  109. assert info["openDirection"] == result[3]
  110. assert info["fault"] == result[4]
  111. assert info["solarPanel"] == result[5]
  112. assert info["calibration"] == result[6]
  113. assert info["calibrated"] == result[7]
  114. assert info["inMotion"] == result[8]
  115. assert info["position"] == result[9]
  116. assert info["timers"] == result[10]
  117. @pytest.mark.parametrize("reverse_mode", [(True), (False)])
  118. def test_device_passive_closing(reverse_mode):
  119. """Test passive closing advertisement."""
  120. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  121. curtain_device = roller_shade.SwitchbotRollerShade(
  122. ble_device, reverse_mode=reverse_mode
  123. )
  124. curtain_device.update_from_advertisement(
  125. make_advertisement_data(ble_device, True, 100)
  126. )
  127. curtain_device.update_from_advertisement(
  128. make_advertisement_data(ble_device, True, 90)
  129. )
  130. assert curtain_device.is_opening() is False
  131. assert curtain_device.is_closing() is True
  132. @pytest.mark.parametrize("reverse_mode", [(True), (False)])
  133. def test_device_passive_opening_then_stop(reverse_mode):
  134. """Test passive stopped after opening advertisement."""
  135. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  136. curtain_device = roller_shade.SwitchbotRollerShade(
  137. ble_device, reverse_mode=reverse_mode
  138. )
  139. curtain_device.update_from_advertisement(
  140. make_advertisement_data(ble_device, True, 0)
  141. )
  142. curtain_device.update_from_advertisement(
  143. make_advertisement_data(ble_device, True, 10)
  144. )
  145. curtain_device.update_from_advertisement(
  146. make_advertisement_data(ble_device, False, 10)
  147. )
  148. assert curtain_device.is_opening() is False
  149. assert curtain_device.is_closing() is False
  150. @pytest.mark.parametrize("reverse_mode", [(True), (False)])
  151. def test_device_passive_closing_then_stop(reverse_mode):
  152. """Test passive stopped after closing advertisement."""
  153. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  154. curtain_device = roller_shade.SwitchbotRollerShade(
  155. ble_device, reverse_mode=reverse_mode
  156. )
  157. curtain_device.update_from_advertisement(
  158. make_advertisement_data(ble_device, True, 100)
  159. )
  160. curtain_device.update_from_advertisement(
  161. make_advertisement_data(ble_device, True, 90)
  162. )
  163. curtain_device.update_from_advertisement(
  164. make_advertisement_data(ble_device, False, 90)
  165. )
  166. assert curtain_device.is_opening() is False
  167. assert curtain_device.is_closing() is False
  168. @pytest.mark.asyncio
  169. async def test_stop():
  170. curtain_device = create_device_for_command_testing()
  171. await curtain_device.stop()
  172. assert curtain_device.is_opening() is False
  173. assert curtain_device.is_closing() is False
  174. curtain_device._send_multiple_commands.assert_awaited_once_with(
  175. roller_shade.STOP_KEYS
  176. )
  177. @pytest.mark.asyncio
  178. async def test_set_position_opening():
  179. curtain_device = create_device_for_command_testing(reverse_mode=True)
  180. await curtain_device.set_position(0)
  181. assert curtain_device.is_opening() is True
  182. assert curtain_device.is_closing() is False
  183. curtain_device._send_multiple_commands.assert_awaited_once()
  184. @pytest.mark.asyncio
  185. async def test_set_position_closing():
  186. curtain_device = create_device_for_command_testing(reverse_mode=True)
  187. await curtain_device.set_position(100)
  188. assert curtain_device.is_opening() is False
  189. assert curtain_device.is_closing() is True
  190. curtain_device._send_multiple_commands.assert_awaited_once()
  191. @pytest.mark.asyncio
  192. async def test_set_position_default_mode_performance():
  193. """`mode=0` (default) must send the same wire bytes as before quiet mode."""
  194. curtain_device = create_device_for_command_testing()
  195. await curtain_device.set_position(50)
  196. curtain_device._send_multiple_commands.assert_awaited_once_with(
  197. [
  198. f"{roller_shade.POSITION_KEYS[0]}32",
  199. f"{roller_shade.POSITION_KEYS[1]}0032",
  200. ]
  201. )
  202. @pytest.mark.asyncio
  203. async def test_set_position_quietdrift():
  204. """`mode=1` flips the mode byte while leaving the position byte alone."""
  205. curtain_device = create_device_for_command_testing()
  206. await curtain_device.set_position(50, mode=1)
  207. curtain_device._send_multiple_commands.assert_awaited_once_with(
  208. [
  209. f"{roller_shade.POSITION_KEYS[0]}32",
  210. f"{roller_shade.POSITION_KEYS[1]}0132",
  211. ]
  212. )
  213. @pytest.mark.asyncio
  214. async def test_set_position_quietdrift_reversed():
  215. """Quiet mode and reverse mode are independent — both apply correctly."""
  216. curtain_device = create_device_for_command_testing(reverse_mode=True)
  217. # position=30 with reverse → device position 70 (0x46), mode byte 01
  218. await curtain_device.set_position(30, mode=1)
  219. curtain_device._send_multiple_commands.assert_awaited_once_with(
  220. [
  221. f"{roller_shade.POSITION_KEYS[0]}46",
  222. f"{roller_shade.POSITION_KEYS[1]}0146",
  223. ]
  224. )
  225. @pytest.mark.asyncio
  226. @pytest.mark.parametrize("invalid_mode", [-1, 2, 255])
  227. async def test_open_rejects_invalid_mode(invalid_mode):
  228. roller_shade_device = create_device_for_command_testing()
  229. with pytest.raises(ValueError, match="mode must be 0"):
  230. await roller_shade_device.open(mode=invalid_mode)
  231. @pytest.mark.asyncio
  232. @pytest.mark.parametrize("invalid_mode", [-1, 2, 255])
  233. async def test_close_rejects_invalid_mode(invalid_mode):
  234. roller_shade_device = create_device_for_command_testing()
  235. with pytest.raises(ValueError, match="mode must be 0"):
  236. await roller_shade_device.close(mode=invalid_mode)
  237. @pytest.mark.asyncio
  238. @pytest.mark.parametrize("invalid_mode", [-1, 2, 255])
  239. async def test_set_position_rejects_invalid_mode(invalid_mode):
  240. roller_shade_device = create_device_for_command_testing()
  241. with pytest.raises(ValueError, match="mode must be 0"):
  242. await roller_shade_device.set_position(50, mode=invalid_mode)
  243. @pytest.mark.asyncio
  244. async def test_open_does_not_set_motion_flag_on_failure():
  245. """If the open command fails, _is_opening must remain False."""
  246. roller_shade_device = create_device_for_command_testing()
  247. roller_shade_device._send_multiple_commands = AsyncMock(return_value=False)
  248. result = await roller_shade_device.open()
  249. assert result is False
  250. assert roller_shade_device.is_opening() is False
  251. assert roller_shade_device.is_closing() is False
  252. @pytest.mark.asyncio
  253. async def test_close_speed_kwarg_is_deprecated_alias_for_mode():
  254. """`close(speed=1)` continues to work but emits DeprecationWarning."""
  255. roller_shade_device = create_device_for_command_testing()
  256. with pytest.warns(DeprecationWarning, match="speed.*deprecated"):
  257. await roller_shade_device.close(speed=1)
  258. roller_shade_device._send_multiple_commands.assert_awaited_once_with(
  259. [roller_shade.CLOSE_KEYS[0], f"{roller_shade.CLOSE_KEYS[1]}0164"]
  260. )
  261. @pytest.mark.asyncio
  262. async def test_close_speed_kwarg_validates_mode():
  263. """A bad value via `speed=` is still rejected by `_validate_mode`."""
  264. roller_shade_device = create_device_for_command_testing()
  265. with (
  266. pytest.warns(DeprecationWarning, match="speed.*deprecated"),
  267. pytest.raises(ValueError, match="mode must be 0"),
  268. ):
  269. await roller_shade_device.close(speed=2)
  270. @pytest.mark.asyncio
  271. async def test_close_rejects_other_unexpected_kwargs():
  272. """Unknown kwargs (other than `speed`) should still raise TypeError."""
  273. roller_shade_device = create_device_for_command_testing()
  274. with pytest.raises(TypeError, match="unexpected keyword arguments"):
  275. await roller_shade_device.close(turbo=True)
  276. @pytest.mark.asyncio
  277. async def test_close_does_not_set_motion_flag_on_failure():
  278. """If the close command fails, _is_closing must remain False."""
  279. roller_shade_device = create_device_for_command_testing()
  280. roller_shade_device._send_multiple_commands = AsyncMock(return_value=False)
  281. result = await roller_shade_device.close()
  282. assert result is False
  283. assert roller_shade_device.is_opening() is False
  284. assert roller_shade_device.is_closing() is False
  285. @pytest.mark.asyncio
  286. async def test_stop_does_not_clear_motion_flags_on_failure():
  287. """If the stop command fails, prior motion flags persist."""
  288. roller_shade_device = create_device_for_command_testing()
  289. roller_shade_device._is_opening = True
  290. roller_shade_device._send_multiple_commands = AsyncMock(return_value=False)
  291. result = await roller_shade_device.stop()
  292. assert result is False
  293. assert roller_shade_device.is_opening() is True
  294. @pytest.mark.asyncio
  295. async def test_set_position_does_not_update_direction_on_failure():
  296. """If set_position fails, the motion direction must not be touched."""
  297. roller_shade_device = create_device_for_command_testing(position=50)
  298. roller_shade_device._send_multiple_commands = AsyncMock(return_value=False)
  299. result = await roller_shade_device.set_position(80)
  300. assert result is False
  301. assert roller_shade_device.is_opening() is False
  302. assert roller_shade_device.is_closing() is False
  303. def test_get_position():
  304. curtain_device = create_device_for_command_testing()
  305. assert curtain_device.get_position() == 50
  306. def test_update_motion_direction_with_no_previous_position():
  307. curtain_device = create_device_for_command_testing(reverse_mode=True)
  308. curtain_device._update_motion_direction(True, None, 100)
  309. assert curtain_device.is_opening() is False
  310. assert curtain_device.is_closing() is False
  311. def test_update_motion_direction_with_previous_position():
  312. curtain_device = create_device_for_command_testing(reverse_mode=True)
  313. curtain_device._update_motion_direction(True, 50, 100)
  314. assert curtain_device.is_opening() is True
  315. assert curtain_device.is_closing() is False