test_strip_light.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  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. async def mock_get_basic_info(arg):
  107. if arg == device._get_basic_info_command[1]:
  108. return basic_info
  109. if arg == device._get_basic_info_command[0]:
  110. return version_info
  111. return None
  112. device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
  113. assert await device.get_basic_info() is None
  114. @pytest.mark.asyncio
  115. @pytest.mark.parametrize(
  116. ("info_data", "result"),
  117. [
  118. (
  119. {
  120. "basic_info": b"\x01\x00<\xff\x00\xd8\x00\x19d\x00\x03",
  121. "version_info": b"\x01\x01\n",
  122. },
  123. [False, 60, 255, 0, 216, 6500, 3, 1.0],
  124. ),
  125. (
  126. {
  127. "basic_info": b"\x01\x80NK\xff:\x00\x19d\xff\x02",
  128. "version_info": b"\x01\x01\n",
  129. },
  130. [True, 78, 75, 255, 58, 6500, 2, 1.0],
  131. ),
  132. (
  133. {
  134. "basic_info": b"\x01\x80$K\xff:\x00\x13\xf9\xff\x06",
  135. "version_info": b"\x01\x01\n",
  136. },
  137. [True, 36, 75, 255, 58, 5113, 6, 1.0],
  138. ),
  139. ],
  140. )
  141. async def test_strip_light_get_basic_info(info_data, result, device_case):
  142. """Test getting basic info from the strip light."""
  143. adv_info, dev_cls = device_case
  144. device = create_device_for_command_testing(adv_info, dev_cls)
  145. async def mock_get_basic_info(args: str) -> list[int] | None:
  146. if args == device._get_basic_info_command[1]:
  147. return info_data["basic_info"]
  148. if args == device._get_basic_info_command[0]:
  149. return info_data["version_info"]
  150. return None
  151. device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
  152. info = await device.get_basic_info()
  153. assert info["isOn"] is result[0]
  154. assert info["brightness"] == result[1]
  155. assert info["r"] == result[2]
  156. assert info["g"] == result[3]
  157. assert info["b"] == result[4]
  158. assert info["cw"] == result[5]
  159. assert info["color_mode"] == result[6]
  160. assert info["firmware"] == result[7]
  161. @pytest.mark.asyncio
  162. async def test_set_color_temp(device_case):
  163. """Test setting color temperature."""
  164. adv_info, dev_cls = device_case
  165. device = create_device_for_command_testing(adv_info, dev_cls)
  166. await device.set_color_temp(50, 3000)
  167. device._send_command.assert_called_with(
  168. device._set_color_temp_command.format("320BB8")
  169. )
  170. @pytest.mark.asyncio
  171. async def test_turn_on(device_case):
  172. """Test turning on the strip light."""
  173. init_data = {"isOn": True}
  174. adv_info, dev_cls = device_case
  175. device = create_device_for_command_testing(adv_info, dev_cls, init_data)
  176. await device.turn_on()
  177. device._send_command.assert_called_with(device._turn_on_command)
  178. assert device.is_on() is True
  179. @pytest.mark.asyncio
  180. async def test_turn_off(device_case):
  181. """Test turning off the strip light."""
  182. init_data = {"isOn": False}
  183. adv_info, dev_cls = device_case
  184. device = create_device_for_command_testing(adv_info, dev_cls, init_data)
  185. await device.turn_off()
  186. device._send_command.assert_called_with(device._turn_off_command)
  187. assert device.is_on() is False
  188. @pytest.mark.asyncio
  189. async def test_set_brightness(device_case):
  190. """Test setting brightness."""
  191. adv_info, dev_cls = device_case
  192. device = create_device_for_command_testing(adv_info, dev_cls)
  193. await device.set_brightness(75)
  194. device._send_command.assert_called_with(device._set_brightness_command.format("4B"))
  195. @pytest.mark.asyncio
  196. async def test_set_rgb(device_case):
  197. """Test setting RGB values."""
  198. adv_info, dev_cls = device_case
  199. device = create_device_for_command_testing(adv_info, dev_cls)
  200. await device.set_rgb(100, 255, 128, 64)
  201. device._send_command.assert_called_with(device._set_rgb_command.format("64FF8040"))
  202. @pytest.mark.asyncio
  203. async def test_set_effect_with_invalid_effect(device_case):
  204. """Test setting an invalid effect."""
  205. adv_info, dev_cls = device_case
  206. device = create_device_for_command_testing(adv_info, dev_cls)
  207. with pytest.raises(
  208. SwitchbotOperationError, match="Effect invalid_effect not supported"
  209. ):
  210. await device.set_effect("invalid_effect")
  211. @pytest.mark.asyncio
  212. async def test_set_effect_with_valid_effect(device_case):
  213. """Test setting a valid effect."""
  214. adv_info, dev_cls = device_case
  215. device = create_device_for_command_testing(adv_info, dev_cls)
  216. device._send_multiple_commands = AsyncMock()
  217. await device.set_effect("christmas")
  218. device._send_multiple_commands.assert_called_with(device._effect_dict["christmas"])
  219. assert device.get_effect() == "christmas"
  220. @pytest.mark.asyncio
  221. async def test_effect_list_contains_lowercase_names(device_case, expected_effects):
  222. """Test that all effect names in get_effect_list are lowercase."""
  223. adv_info, dev_cls = device_case
  224. device = create_device_for_command_testing(adv_info, dev_cls)
  225. effect_list = device.get_effect_list
  226. assert effect_list is not None, "Effect list should not be None"
  227. # All effect names should be lowercase
  228. for effect_name in effect_list:
  229. assert effect_name.islower(), f"Effect name '{effect_name}' is not lowercase"
  230. # Verify some known effects are present
  231. for expected_effect in expected_effects:
  232. assert expected_effect in effect_list, (
  233. f"Expected effect '{expected_effect}' not found"
  234. )
  235. @pytest.mark.asyncio
  236. async def test_set_effect_normalizes_case(device_case):
  237. """Test that set_effect normalizes effect names to lowercase."""
  238. adv_info, dev_cls = device_case
  239. device = create_device_for_command_testing(adv_info, dev_cls)
  240. device._send_multiple_commands = AsyncMock()
  241. # Test various case combinations
  242. test_cases = ["CHRISTMAS", "Christmas", "ChRiStMaS", "christmas"]
  243. for test_effect in test_cases:
  244. await device.set_effect(test_effect)
  245. # Should always work regardless of case
  246. device._send_multiple_commands.assert_called()
  247. assert device.get_effect() == test_effect # Stored as provided
  248. @pytest.mark.asyncio
  249. @patch.object(SwitchbotEncryptedDevice, "verify_encryption_key", new_callable=AsyncMock)
  250. async def test_verify_encryption_key(mock_parent_verify, device_case):
  251. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  252. key_id = "ff"
  253. encryption_key = "ffffffffffffffffffffffffffffffff"
  254. mock_parent_verify.return_value = True
  255. adv_info, dev_cls = device_case
  256. result = await dev_cls.verify_encryption_key(
  257. device=ble_device,
  258. key_id=key_id,
  259. encryption_key=encryption_key,
  260. model=adv_info.modelName,
  261. )
  262. mock_parent_verify.assert_awaited_once_with(
  263. ble_device,
  264. key_id,
  265. encryption_key,
  266. adv_info.modelName,
  267. )
  268. assert result is True
  269. def create_strip_light_device(init_data: dict | None = None):
  270. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  271. return light_strip.SwitchbotLightStrip(ble_device)
  272. @pytest.mark.asyncio
  273. async def test_strip_light_supported_color_modes():
  274. """Test that the strip light supports the expected color modes."""
  275. device = create_strip_light_device()
  276. assert device.color_modes == {
  277. ColorMode.RGB,
  278. }
  279. @pytest.mark.asyncio
  280. @pytest.mark.parametrize(
  281. ("commands", "results", "final_result"),
  282. [
  283. (("command1", "command2"), [(b"\x01", False), (None, False)], False),
  284. (("command1", "command2"), [(None, False), (b"\x01", True)], True),
  285. (("command1", "command2"), [(b"\x01", True), (b"\x01", False)], True),
  286. ],
  287. )
  288. async def test_send_multiple_commands(commands, results, final_result, device_case):
  289. """Test sending multiple commands."""
  290. adv_info, dev_cls = device_case
  291. device = create_device_for_command_testing(adv_info, dev_cls)
  292. device._send_command = AsyncMock(side_effect=[r[0] for r in results])
  293. device._check_command_result = MagicMock(side_effect=[r[1] for r in results])
  294. result = await device._send_multiple_commands(list(commands))
  295. assert result is final_result
  296. @pytest.mark.asyncio
  297. async def test_unimplemented_color_mode():
  298. class TestDevice(SwitchbotBaseLight):
  299. pass
  300. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  301. device = TestDevice(ble_device)
  302. with pytest.raises(NotImplementedError):
  303. _ = device.color_mode
  304. @pytest.mark.asyncio
  305. async def test_exception_with_wrong_model():
  306. class TestDevice(SwitchbotBaseLight):
  307. def __init__(self, device: BLEDevice, model: str = "unknown") -> None:
  308. super().__init__(device, model=model)
  309. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  310. device = TestDevice(ble_device)
  311. with pytest.raises(
  312. SwitchbotOperationError,
  313. match="Current device aa:bb:cc:dd:ee:ff does not support this functionality",
  314. ):
  315. await device.set_rgb(100, 255, 128, 64)