test_strip_light.py 15 KB

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