test_strip_light.py 16 KB

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