test_strip_light.py 14 KB

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