test_ceiling_light.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  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 ceiling_light
  7. from .test_adv_parser import generate_ble_device
  8. def create_device_for_command_testing(
  9. init_data: dict | None = None, model: SwitchbotModel = SwitchbotModel.CEILING_LIGHT
  10. ):
  11. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
  12. device = ceiling_light.SwitchbotCeilingLight(ble_device, model=model)
  13. device.update_from_advertisement(make_advertisement_data(ble_device, init_data))
  14. device._send_command = AsyncMock()
  15. device._check_command_result = MagicMock()
  16. device.update = AsyncMock()
  17. return device
  18. def make_advertisement_data(ble_device: BLEDevice, init_data: dict | None = None):
  19. """Set advertisement data with defaults."""
  20. if init_data is None:
  21. init_data = {}
  22. return SwitchBotAdvertisement(
  23. address="aa:bb:cc:dd:ee:ff",
  24. data={
  25. "rawAdvData": b"q\x00",
  26. "data": {
  27. "brightness": 1,
  28. "color_mode": 1,
  29. "cw": 6387,
  30. "isOn": False,
  31. "sequence_number": 10,
  32. }
  33. | init_data,
  34. "isEncrypted": False,
  35. "model": b"q\x00",
  36. "modelFriendlyName": "Ceiling Light",
  37. "modelName": SwitchbotModel.CEILING_LIGHT,
  38. },
  39. device=ble_device,
  40. rssi=-80,
  41. active=True,
  42. )
  43. @pytest.mark.asyncio
  44. async def test_default_info():
  45. """Test default initialization of the ceiling light."""
  46. device = create_device_for_command_testing()
  47. assert device.rgb is None
  48. device._state = {"cw": 3200}
  49. assert device.is_on() is False
  50. assert device.on is False
  51. assert device.color_mode == ColorMode.COLOR_TEMP
  52. assert device.color_modes == {ColorMode.COLOR_TEMP}
  53. assert device.color_temp == 3200
  54. assert device.brightness == 1
  55. assert device.min_temp == 2700
  56. assert device.max_temp == 6500
  57. assert device.get_effect_list is None
  58. @pytest.mark.asyncio
  59. @pytest.mark.parametrize(
  60. ("basic_info", "version_info"), [(True, False), (False, True), (False, False)]
  61. )
  62. async def test_get_basic_info_returns_none(basic_info, version_info):
  63. device = create_device_for_command_testing()
  64. device._send_command = AsyncMock(side_effect=[version_info, basic_info])
  65. device._check_command_result = MagicMock(
  66. side_effect=[bool(version_info), bool(basic_info)]
  67. )
  68. assert await device.get_basic_info() is None
  69. @pytest.mark.asyncio
  70. @pytest.mark.parametrize(
  71. ("info_data", "result"),
  72. [
  73. (
  74. {
  75. "basic_info": b"\x01\x80=\x0f\xa1\x00\x01",
  76. "version_info": b"\x01d\x15\x0f\x00\x00\x00\x00\x00\x00\x00\n\x00",
  77. },
  78. [True, 61, 4001, 0, 2.1],
  79. ),
  80. (
  81. {
  82. "basic_info": b"\x01\x80\x0e\x12B\x00\x01",
  83. "version_info": b"\x01d\x15\x0f\x00\x00\x00\x00\x00\x00\x00\n\x00",
  84. },
  85. [True, 14, 4674, 0, 2.1],
  86. ),
  87. (
  88. {
  89. "basic_info": b"\x01\x00\x0e\x10\x96\x00\x01",
  90. "version_info": b"\x01d\x15\x0f\x00\x00\x00\x00\x00\x00\x00\n\x00",
  91. },
  92. [False, 14, 4246, 0, 2.1],
  93. ),
  94. pytest.param(
  95. {
  96. "basic_info": b"\x01\xc0\x0e\x10\x96\x00\x01",
  97. "version_info": b"\x01d\x15\x0f\x00\x00\x00\x00\x00\x00\x00\n\x00",
  98. },
  99. [True, 14, 4246, 1, 2.1],
  100. id="night-color-mode",
  101. ),
  102. ],
  103. )
  104. async def test_get_basic_info(info_data, result):
  105. """Test getting basic info from the ceiling light."""
  106. device = create_device_for_command_testing()
  107. device._send_command = AsyncMock(
  108. side_effect=[info_data["version_info"], info_data["basic_info"]]
  109. )
  110. device._check_command_result = MagicMock(side_effect=[True, True])
  111. info = await device.get_basic_info()
  112. assert info["isOn"] is result[0]
  113. assert info["brightness"] == result[1]
  114. assert info["cw"] == result[2]
  115. assert info["color_mode"] == result[3]
  116. assert info["firmware"] == result[4]
  117. assert device.is_night_light_on() is bool(result[3])
  118. @pytest.mark.asyncio
  119. async def test_get_basic_info_ignores_invalid_color_temp() -> None:
  120. """Test retaining the last color temp when basic info reports a placeholder."""
  121. device = create_device_for_command_testing()
  122. device._state["cw"] = 3625
  123. device._send_command = AsyncMock(
  124. side_effect=[
  125. b"\x01d\x18\x0e\x00\x00\x00\x00\x00\x00\x00\x0c\x01",
  126. b"\x01\x80F\xff\x00\x01\x00",
  127. ]
  128. )
  129. device._check_command_result = MagicMock(side_effect=[True, True])
  130. info = await device.get_basic_info()
  131. assert info is not None
  132. assert info["cw"] == 3625
  133. assert device.color_temp == 3625
  134. @pytest.mark.asyncio
  135. async def test_get_basic_info_uses_default_for_initial_invalid_color_temp() -> None:
  136. """Test using the default color temp for an initial placeholder."""
  137. device = create_device_for_command_testing()
  138. device._send_command = AsyncMock(
  139. side_effect=[
  140. b"\x01d\x18\x0e\x00\x00\x00\x00\x00\x00\x00\x0c\x01",
  141. b"\x01\x80F\xff\x00\x01\x00",
  142. ]
  143. )
  144. device._check_command_result = MagicMock(side_effect=[True, True])
  145. info = await device.get_basic_info()
  146. assert info is not None
  147. assert info["cw"] == 4001
  148. assert device.color_temp == 4001
  149. @pytest.mark.asyncio
  150. async def test_set_color_temp():
  151. """Test setting color temperature."""
  152. device = create_device_for_command_testing()
  153. await device.set_color_temp(50, 3000)
  154. device._send_command.assert_called_with(
  155. device._set_color_temp_command.format("320BB8")
  156. )
  157. @pytest.mark.asyncio
  158. async def test_turn_on():
  159. """Test turning on the ceiling light."""
  160. device = create_device_for_command_testing({"isOn": True})
  161. await device.turn_on()
  162. device._send_command.assert_called_with(device._turn_on_command)
  163. assert device.is_on() is True
  164. @pytest.mark.asyncio
  165. async def test_turn_off():
  166. """Test turning off the ceiling light."""
  167. device = create_device_for_command_testing({"isOn": False})
  168. await device.turn_off()
  169. device._send_command.assert_called_with(device._turn_off_command)
  170. assert device.is_on() is False
  171. @pytest.mark.asyncio
  172. async def test_set_night_light_on():
  173. """Test turning night light mode on."""
  174. device = create_device_for_command_testing()
  175. device._state = {"cw": 2700}
  176. await device.set_night_light(True)
  177. device._send_command.assert_called_with(
  178. device._set_night_light_command.format("01", "140A8C")
  179. )
  180. @pytest.mark.asyncio
  181. async def test_set_night_light_off():
  182. """Test turning night light mode off."""
  183. device = create_device_for_command_testing()
  184. device._state = {"cw": 2700}
  185. await device.set_night_light(False)
  186. device._send_command.assert_called_with(
  187. device._set_night_light_command.format("00", "640A8C")
  188. )
  189. @pytest.mark.asyncio
  190. async def test_set_night_light_custom_brightness():
  191. """Test that an explicit brightness overrides the night-light default."""
  192. device = create_device_for_command_testing()
  193. device._state = {"cw": 2700}
  194. await device.set_night_light(True, brightness=30)
  195. device._send_command.assert_called_with(
  196. device._set_night_light_command.format("01", "1E0A8C")
  197. )
  198. @pytest.mark.asyncio
  199. @pytest.mark.parametrize("brightness", [-1, 101, 300])
  200. async def test_set_night_light_invalid_brightness(brightness):
  201. """Test that an out-of-range explicit brightness is rejected."""
  202. device = create_device_for_command_testing()
  203. device._state = {"cw": 2700}
  204. with pytest.raises(ValueError, match="Brightness must be between 0 and 100"):
  205. await device.set_night_light(True, brightness=brightness)
  206. device._send_command.assert_not_called()
  207. @pytest.mark.asyncio
  208. @pytest.mark.parametrize(
  209. ("color_mode", "expected"),
  210. [
  211. (0, False),
  212. (1, True),
  213. (None, None),
  214. ],
  215. )
  216. async def test_is_night_light_on(color_mode, expected):
  217. """Test reading the cached night light state."""
  218. device = create_device_for_command_testing()
  219. if color_mode is not None:
  220. device._state = {"color_mode": color_mode}
  221. assert device.is_night_light_on() is expected
  222. @pytest.mark.asyncio
  223. async def test_set_brightness():
  224. """Test setting brightness."""
  225. device = create_device_for_command_testing()
  226. await device.set_brightness(75)
  227. device._send_command.assert_called_with(
  228. device._set_brightness_command.format("4B0FA1")
  229. )
  230. @pytest.mark.asyncio
  231. @pytest.mark.parametrize(
  232. ("adv_value", "expected_color_mode"),
  233. [
  234. (0, ColorMode.COLOR_TEMP),
  235. (1, ColorMode.COLOR_TEMP),
  236. (4, ColorMode.EFFECT),
  237. (10, ColorMode.OFF),
  238. (None, ColorMode.OFF),
  239. ],
  240. )
  241. async def test_get_color_mode(adv_value, expected_color_mode):
  242. """Test getting color mode."""
  243. device = create_device_for_command_testing()
  244. with patch.object(device, "_get_adv_value", return_value=adv_value):
  245. assert device.color_mode == expected_color_mode
  246. @pytest.mark.asyncio
  247. async def test_get_color_mode_prefers_cached_state():
  248. """Test that color_mode prefers the _state cache over the adv value."""
  249. device = create_device_for_command_testing()
  250. device._state = {"color_mode": 4} # MUSIC -> EFFECT
  251. with patch.object(device, "_get_adv_value", return_value=0): # COLOR_TEMP
  252. assert device.color_mode == ColorMode.EFFECT