test_strip_light.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. from unittest.mock import AsyncMock, MagicMock, patch
  2. import pytest
  3. from bleak.backends.device import BLEDevice
  4. from switchbot import SwitchBotAdvertisement, SwitchbotModel
  5. from switchbot.const.light import ColorMode
  6. from switchbot.devices import light_strip
  7. from switchbot.devices.base_light import SwitchbotBaseLight
  8. from switchbot.devices.device import SwitchbotEncryptedDevice, SwitchbotOperationError
  9. from . import (
  10. FLOOR_LAMP_INFO,
  11. RGBICWW_FLOOR_LAMP_INFO,
  12. RGBICWW_STRIP_LIGHT_INFO,
  13. STRIP_LIGHT_3_INFO,
  14. )
  15. from .test_adv_parser import AdvTestCase, generate_ble_device
  16. @pytest.fixture(
  17. params=[
  18. (STRIP_LIGHT_3_INFO, light_strip.SwitchbotStripLight3),
  19. (FLOOR_LAMP_INFO, light_strip.SwitchbotStripLight3),
  20. (RGBICWW_STRIP_LIGHT_INFO, light_strip.SwitchbotRgbicLight),
  21. (RGBICWW_FLOOR_LAMP_INFO, light_strip.SwitchbotRgbicLight),
  22. ]
  23. )
  24. def device_case(request):
  25. return request.param
  26. @pytest.fixture
  27. def expected_effects(device_case):
  28. adv_info, _dev_cls = device_case
  29. EXPECTED = {
  30. SwitchbotModel.STRIP_LIGHT_3: ("christmas", "halloween", "sunset"),
  31. SwitchbotModel.FLOOR_LAMP: ("christmas", "halloween", "sunset"),
  32. SwitchbotModel.RGBICWW_STRIP_LIGHT: ("romance", "energy", "heartbeat"),
  33. SwitchbotModel.RGBICWW_FLOOR_LAMP: ("romance", "energy", "heartbeat"),
  34. }
  35. return EXPECTED[adv_info.modelName]
  36. def create_device_for_command_testing(
  37. adv_info: AdvTestCase,
  38. dev_cls: type[SwitchbotBaseLight],
  39. init_data: dict | None = None,
  40. ):
  41. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  42. device = dev_cls(
  43. ble_device, "ff", "ffffffffffffffffffffffffffffffff", model=adv_info.modelName
  44. )
  45. device.update_from_advertisement(
  46. make_advertisement_data(ble_device, adv_info, init_data)
  47. )
  48. device._send_command = AsyncMock()
  49. device._check_command_result = MagicMock()
  50. device.update = AsyncMock()
  51. return device
  52. def make_advertisement_data(
  53. ble_device: BLEDevice, adv_info: AdvTestCase, init_data: dict | None = None
  54. ):
  55. """Set advertisement data with defaults."""
  56. if init_data is None:
  57. init_data = {}
  58. return SwitchBotAdvertisement(
  59. address="aa:bb:cc:dd:ee:ff",
  60. data={
  61. "rawAdvData": adv_info.service_data,
  62. "data": adv_info.data | init_data,
  63. "isEncrypted": False,
  64. "model": adv_info.model,
  65. "modelFriendlyName": adv_info.modelFriendlyName,
  66. "modelName": adv_info.modelName,
  67. },
  68. device=ble_device,
  69. rssi=-80,
  70. active=True,
  71. )
  72. @pytest.mark.asyncio
  73. async def test_default_info(device_case, expected_effects):
  74. """Test default initialization of the strip light."""
  75. adv_info, dev_cls = device_case
  76. device = create_device_for_command_testing(adv_info, dev_cls)
  77. assert device.rgb is None
  78. device._state = {"r": 30, "g": 0, "b": 0, "cw": 3200}
  79. assert device.is_on() is True
  80. assert device.on is True
  81. assert device.color_mode == ColorMode.RGB
  82. assert device.color_modes == {
  83. ColorMode.RGB,
  84. ColorMode.COLOR_TEMP,
  85. }
  86. assert device.rgb == (30, 0, 0)
  87. assert device.color_temp == 3200
  88. assert device.brightness == adv_info.data["brightness"]
  89. assert device.min_temp == 2700
  90. assert device.max_temp == 6500
  91. # Check that effect list contains expected lowercase effect names
  92. effect_list = device.get_effect_list
  93. assert effect_list is not None
  94. assert all(effect.islower() for effect in effect_list)
  95. # Verify some known effects are present
  96. for effect in expected_effects:
  97. assert effect in effect_list
  98. @pytest.mark.asyncio
  99. @pytest.mark.parametrize(
  100. ("basic_info", "version_info"), [(True, False), (False, True), (False, False)]
  101. )
  102. async def test_get_basic_info_returns_none(basic_info, version_info, device_case):
  103. """Test that get_basic_info returns None if no data is available."""
  104. adv_info, dev_cls = device_case
  105. device = create_device_for_command_testing(adv_info, dev_cls)
  106. device._send_command = AsyncMock(side_effect=[version_info, basic_info])
  107. device._check_command_result = MagicMock(
  108. side_effect=[bool(version_info), bool(basic_info)]
  109. )
  110. assert await device.get_basic_info() is None
  111. @pytest.mark.asyncio
  112. @pytest.mark.parametrize(
  113. ("info_data", "result"),
  114. [
  115. (
  116. {
  117. "basic_info": b"\x01\x00<\xff\x00\xd8\x00\x19d\x00\x03",
  118. "version_info": b"\x01\x01\n",
  119. },
  120. [False, 60, 255, 0, 216, 6500, 3, 1.0],
  121. ),
  122. (
  123. {
  124. "basic_info": b"\x01\x80NK\xff:\x00\x19d\xff\x02",
  125. "version_info": b"\x01\x01\n",
  126. },
  127. [True, 78, 75, 255, 58, 6500, 2, 1.0],
  128. ),
  129. (
  130. {
  131. "basic_info": b"\x01\x80$K\xff:\x00\x13\xf9\xff\x06",
  132. "version_info": b"\x01\x01\n",
  133. },
  134. [True, 36, 75, 255, 58, 5113, 6, 1.0],
  135. ),
  136. ],
  137. )
  138. async def test_strip_light_get_basic_info(info_data, result, device_case):
  139. """Test getting basic info from the strip light."""
  140. adv_info, dev_cls = device_case
  141. device = create_device_for_command_testing(adv_info, dev_cls)
  142. device._send_command = AsyncMock(
  143. side_effect=[info_data["version_info"], info_data["basic_info"]]
  144. )
  145. device._check_command_result = MagicMock(side_effect=[True, True])
  146. info = await device.get_basic_info()
  147. assert info["isOn"] is result[0]
  148. assert info["brightness"] == result[1]
  149. assert info["r"] == result[2]
  150. assert info["g"] == result[3]
  151. assert info["b"] == result[4]
  152. assert info["cw"] == result[5]
  153. assert info["color_mode"] == result[6]
  154. assert info["firmware"] == result[7]
  155. @pytest.mark.asyncio
  156. async def test_set_color_temp(device_case):
  157. """Test setting color temperature."""
  158. adv_info, dev_cls = device_case
  159. device = create_device_for_command_testing(adv_info, dev_cls)
  160. await device.set_color_temp(50, 3000)
  161. device._send_command.assert_called_with(
  162. device._set_color_temp_command.format("320BB8")
  163. )
  164. @pytest.mark.asyncio
  165. async def test_turn_on(device_case):
  166. """Test turning on the strip light."""
  167. init_data = {"isOn": True}
  168. adv_info, dev_cls = device_case
  169. device = create_device_for_command_testing(adv_info, dev_cls, init_data)
  170. await device.turn_on()
  171. device._send_command.assert_called_with(device._turn_on_command)
  172. assert device.is_on() is True
  173. @pytest.mark.asyncio
  174. async def test_turn_off(device_case):
  175. """Test turning off the strip light."""
  176. init_data = {"isOn": False}
  177. adv_info, dev_cls = device_case
  178. device = create_device_for_command_testing(adv_info, dev_cls, init_data)
  179. await device.turn_off()
  180. device._send_command.assert_called_with(device._turn_off_command)
  181. assert device.is_on() is False
  182. @pytest.mark.asyncio
  183. async def test_set_brightness(device_case):
  184. """Test setting brightness."""
  185. adv_info, dev_cls = device_case
  186. device = create_device_for_command_testing(adv_info, dev_cls)
  187. await device.set_brightness(75)
  188. device._send_command.assert_called_with(device._set_brightness_command.format("4B"))
  189. @pytest.mark.asyncio
  190. async def test_set_rgb(device_case):
  191. """Test setting RGB values."""
  192. adv_info, dev_cls = device_case
  193. device = create_device_for_command_testing(adv_info, dev_cls)
  194. await device.set_rgb(100, 255, 128, 64)
  195. device._send_command.assert_called_with(device._set_rgb_command.format("64FF8040"))
  196. @pytest.mark.asyncio
  197. async def test_set_effect_with_invalid_effect(device_case):
  198. """Test setting an invalid effect."""
  199. adv_info, dev_cls = device_case
  200. device = create_device_for_command_testing(adv_info, dev_cls)
  201. with pytest.raises(
  202. SwitchbotOperationError, match="Effect invalid_effect not supported"
  203. ):
  204. await device.set_effect("invalid_effect")
  205. @pytest.mark.asyncio
  206. async def test_set_effect_with_valid_effect(device_case):
  207. """Test setting a valid effect."""
  208. adv_info, dev_cls = device_case
  209. device = create_device_for_command_testing(adv_info, dev_cls)
  210. device._send_multiple_commands = AsyncMock()
  211. await device.set_effect("christmas")
  212. device._send_multiple_commands.assert_called_with(device._effect_dict["christmas"])
  213. assert device.get_effect() == "christmas"
  214. @pytest.mark.asyncio
  215. async def test_effect_list_contains_lowercase_names(device_case, expected_effects):
  216. """Test that all effect names in get_effect_list are lowercase."""
  217. adv_info, dev_cls = device_case
  218. device = create_device_for_command_testing(adv_info, dev_cls)
  219. effect_list = device.get_effect_list
  220. assert effect_list is not None, "Effect list should not be None"
  221. # All effect names should be lowercase
  222. for effect_name in effect_list:
  223. assert effect_name.islower(), f"Effect name '{effect_name}' is not lowercase"
  224. # Verify some known effects are present
  225. for expected_effect in expected_effects:
  226. assert expected_effect in effect_list, (
  227. f"Expected effect '{expected_effect}' not found"
  228. )
  229. @pytest.mark.asyncio
  230. async def test_set_effect_normalizes_case(device_case):
  231. """Test that set_effect normalizes effect names to lowercase."""
  232. adv_info, dev_cls = device_case
  233. device = create_device_for_command_testing(adv_info, dev_cls)
  234. device._send_multiple_commands = AsyncMock()
  235. # Test various case combinations
  236. test_cases = ["CHRISTMAS", "Christmas", "ChRiStMaS", "christmas"]
  237. for test_effect in test_cases:
  238. await device.set_effect(test_effect)
  239. # Should always work regardless of case
  240. device._send_multiple_commands.assert_called()
  241. assert device.get_effect() == test_effect # Stored as provided
  242. @pytest.mark.asyncio
  243. @patch.object(SwitchbotEncryptedDevice, "verify_encryption_key", new_callable=AsyncMock)
  244. async def test_verify_encryption_key(mock_parent_verify, device_case):
  245. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  246. key_id = "ff"
  247. encryption_key = "ffffffffffffffffffffffffffffffff"
  248. mock_parent_verify.return_value = True
  249. adv_info, dev_cls = device_case
  250. result = await dev_cls.verify_encryption_key(
  251. device=ble_device,
  252. key_id=key_id,
  253. encryption_key=encryption_key,
  254. model=adv_info.modelName,
  255. )
  256. mock_parent_verify.assert_awaited_once_with(
  257. ble_device,
  258. key_id,
  259. encryption_key,
  260. adv_info.modelName,
  261. )
  262. assert result is True
  263. def create_strip_light_device(init_data: dict | None = None):
  264. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  265. return light_strip.SwitchbotLightStrip(ble_device)
  266. @pytest.mark.asyncio
  267. async def test_strip_light_supported_color_modes():
  268. """Test that the strip light supports the expected color modes."""
  269. device = create_strip_light_device()
  270. assert device.color_modes == {
  271. ColorMode.RGB,
  272. }
  273. @pytest.mark.asyncio
  274. @pytest.mark.parametrize(
  275. ("commands", "results", "final_result"),
  276. [
  277. (("command1", "command2"), [(b"\x01", False), (None, False)], False),
  278. (("command1", "command2"), [(None, False), (b"\x01", True)], True),
  279. (("command1", "command2"), [(b"\x01", True), (b"\x01", False)], True),
  280. ],
  281. )
  282. async def test_send_multiple_commands(commands, results, final_result, device_case):
  283. """Test sending multiple commands."""
  284. adv_info, dev_cls = device_case
  285. device = create_device_for_command_testing(adv_info, dev_cls)
  286. device._send_command = AsyncMock(side_effect=[r[0] for r in results])
  287. device._check_command_result = MagicMock(side_effect=[r[1] for r in results])
  288. result = await device._send_multiple_commands(list(commands))
  289. assert result is final_result
  290. @pytest.mark.asyncio
  291. async def test_unimplemented_color_mode():
  292. class TestDevice(SwitchbotBaseLight):
  293. pass
  294. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  295. device = TestDevice(ble_device)
  296. with pytest.raises(NotImplementedError):
  297. _ = device.color_mode
  298. @pytest.mark.asyncio
  299. async def test_exception_with_wrong_model():
  300. class TestDevice(SwitchbotBaseLight):
  301. def __init__(self, device: BLEDevice, model: str = "unknown") -> None:
  302. super().__init__(device, model=model)
  303. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  304. device = TestDevice(ble_device)
  305. with pytest.raises(
  306. SwitchbotOperationError,
  307. match="Current device aa:bb:cc:dd:ee:ff does not support this functionality",
  308. ):
  309. await device.set_rgb(100, 255, 128, 64)