from unittest.mock import AsyncMock, MagicMock import pytest from bleak.backends.device import BLEDevice from switchbot import SwitchBotAdvertisement, SwitchbotModel from switchbot.const.light import ColorMode from switchbot.devices import light_strip from switchbot.devices.base_light import SwitchbotBaseLight from switchbot.devices.device import SwitchbotOperationError from . import ( CANDLE_WARMER_LAMP_INFO, FLOOR_LAMP_INFO, PERMANENT_OUTDOOR_LIGHT_INFO, RGBIC_NEON_LIGHT_INFO, RGBICWW_FLOOR_LAMP_INFO, RGBICWW_STRIP_LIGHT_INFO, STRIP_LIGHT_3_INFO, ) from .test_adv_parser import AdvTestCase, generate_ble_device ALL_LIGHT_CASES = [ (STRIP_LIGHT_3_INFO, light_strip.SwitchbotStripLight3), (FLOOR_LAMP_INFO, light_strip.SwitchbotStripLight3), (CANDLE_WARMER_LAMP_INFO, light_strip.SwitchbotCandleWarmerLamp), (RGBICWW_STRIP_LIGHT_INFO, light_strip.SwitchbotRgbicLight), (RGBICWW_FLOOR_LAMP_INFO, light_strip.SwitchbotRgbicLight), (PERMANENT_OUTDOOR_LIGHT_INFO, light_strip.SwitchbotPermanentOutdoorLight), (RGBIC_NEON_LIGHT_INFO, light_strip.SwitchbotRgbicNeonLight), ] # RGB + color-temp devices. Excludes brightness-only lights (CWL) and # RGB-only lights (RGBIC Neon) whose color_modes differ from the rest. RGB_LIGHT_CASES = [ case for case in ALL_LIGHT_CASES if case[1] not in (light_strip.SwitchbotCandleWarmerLamp, light_strip.SwitchbotRgbicNeonLight) ] @pytest.fixture(params=RGB_LIGHT_CASES) def device_case(request): return request.param @pytest.fixture def expected_effects(device_case): adv_info, _dev_cls = device_case EXPECTED = { SwitchbotModel.STRIP_LIGHT_3: ("christmas", "halloween", "sunset"), SwitchbotModel.FLOOR_LAMP: ("christmas", "halloween", "sunset"), SwitchbotModel.RGBICWW_STRIP_LIGHT: ("romance", "energy", "heartbeat"), SwitchbotModel.RGBICWW_FLOOR_LAMP: ("romance", "energy", "heartbeat"), SwitchbotModel.PERMANENT_OUTDOOR_LIGHT: ("romance", "energy", "heartbeat"), } return EXPECTED[adv_info.modelName] def create_device_for_command_testing( adv_info: AdvTestCase, dev_cls: type[SwitchbotBaseLight], init_data: dict | None = None, ): ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") device = dev_cls( ble_device, "ff", "ffffffffffffffffffffffffffffffff", model=adv_info.modelName ) device.update_from_advertisement( make_advertisement_data(ble_device, adv_info, init_data) ) device._send_command = AsyncMock() device._check_command_result = MagicMock() device.update = AsyncMock() return device def make_advertisement_data( ble_device: BLEDevice, adv_info: AdvTestCase, init_data: dict | None = None ): """Set advertisement data with defaults.""" if init_data is None: init_data = {} return SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": adv_info.service_data, "data": adv_info.data | init_data, "isEncrypted": False, "model": adv_info.model, "modelFriendlyName": adv_info.modelFriendlyName, "modelName": adv_info.modelName, }, device=ble_device, rssi=-80, active=True, ) @pytest.mark.asyncio async def test_default_info(device_case, expected_effects): """Test default initialization of the strip light.""" adv_info, dev_cls = device_case device = create_device_for_command_testing(adv_info, dev_cls) assert device.rgb is None device._state = {"r": 30, "g": 0, "b": 0, "cw": 3200} assert device.is_on() is True assert device.on is True assert device.color_mode == ColorMode.RGB assert device.color_modes == {ColorMode.RGB, ColorMode.COLOR_TEMP} assert device.rgb == (30, 0, 0) assert device.color_temp == 3200 assert device.brightness == adv_info.data["brightness"] assert device.min_temp == 2700 assert device.max_temp == 6500 # Check that effect list contains expected lowercase effect names effect_list = device.get_effect_list assert effect_list is not None assert all(effect.islower() for effect in effect_list) # Verify some known effects are present for effect in expected_effects: assert effect in effect_list @pytest.mark.asyncio async def test_rgbic_neon_light_info() -> None: """Test color_mode / color_modes on SwitchbotRgbicNeonLight (RGB only).""" adv_info, dev_cls = RGBIC_NEON_LIGHT_INFO, light_strip.SwitchbotRgbicNeonLight device = create_device_for_command_testing(adv_info, dev_cls) assert device.color_mode == ColorMode.RGB assert device.color_modes == {ColorMode.RGB} @pytest.mark.asyncio async def test_candle_warmer_lamp_info() -> None: """Test default initialization of the candle warmer lamp.""" adv_info, dev_cls = CANDLE_WARMER_LAMP_INFO, light_strip.SwitchbotCandleWarmerLamp device = create_device_for_command_testing(adv_info, dev_cls) assert device.rgb is None assert device.is_on() is True assert device.on is True assert device.color_mode == ColorMode.BRIGHTNESS assert device.color_modes == { ColorMode.BRIGHTNESS, } assert device.brightness == adv_info.data["brightness"] assert device.get_effect_list is None @pytest.mark.asyncio async def test_candle_warmer_lamp_unsupported_operations() -> None: """Test that RGB/color-temp/effect operations are not supported on CWL.""" device = create_device_for_command_testing( CANDLE_WARMER_LAMP_INFO, light_strip.SwitchbotCandleWarmerLamp ) with pytest.raises( SwitchbotOperationError, match="does not support this functionality", ): await device.set_rgb(100, 255, 128, 64) with pytest.raises( SwitchbotOperationError, match="does not support this functionality", ): await device.set_color_temp(100, 4000) with pytest.raises(SwitchbotOperationError, match="not supported"): await device.set_effect("sunset") @pytest.mark.asyncio @pytest.mark.parametrize( ("basic_info", "version_info"), [(True, False), (False, True), (False, False)] ) @pytest.mark.parametrize(("adv_info", "dev_cls"), ALL_LIGHT_CASES) async def test_get_basic_info_returns_none( basic_info, version_info, adv_info, dev_cls ) -> None: """Test that get_basic_info returns None if no data is available.""" device = create_device_for_command_testing(adv_info, dev_cls) device._send_command = AsyncMock(side_effect=[version_info, basic_info]) device._check_command_result = MagicMock( side_effect=[bool(version_info), bool(basic_info)] ) assert await device.get_basic_info() is None @pytest.mark.asyncio @pytest.mark.parametrize( ("info_data", "result"), [ ( { "basic_info": b"\x01\x00<\xff\x00\xd8\x00\x19d\x00\x03", "version_info": b"\x01\x01\n", }, [False, 60, 255, 0, 216, 6500, 3, 1.0], ), ( { "basic_info": b"\x01\x80NK\xff:\x00\x19d\xff\x02", "version_info": b"\x01\x01\n", }, [True, 78, 75, 255, 58, 6500, 2, 1.0], ), ( { "basic_info": b"\x01\x80$K\xff:\x00\x13\xf9\xff\x06", "version_info": b"\x01\x01\n", }, [True, 36, 75, 255, 58, 5113, 6, 1.0], ), ], ) async def test_strip_light_get_basic_info(info_data, result, device_case): """Test getting basic info from the strip light.""" adv_info, dev_cls = device_case device = create_device_for_command_testing(adv_info, dev_cls) device._send_command = AsyncMock( side_effect=[info_data["version_info"], info_data["basic_info"]] ) device._check_command_result = MagicMock(side_effect=[True, True]) info = await device.get_basic_info() assert info["isOn"] is result[0] assert info["brightness"] == result[1] assert info["r"] == result[2] assert info["g"] == result[3] assert info["b"] == result[4] assert info["cw"] == result[5] assert info["color_mode"] == result[6] assert info["firmware"] == result[7] @pytest.mark.asyncio async def test_set_color_temp(device_case): """Test setting color temperature.""" adv_info, dev_cls = device_case device = create_device_for_command_testing(adv_info, dev_cls) await device.set_color_temp(50, 3000) device._send_command.assert_called_with( device._set_color_temp_command.format("320BB8") ) @pytest.mark.asyncio async def test_turn_on(device_case): """Test turning on the strip light.""" init_data = {"isOn": True} adv_info, dev_cls = device_case device = create_device_for_command_testing(adv_info, dev_cls, init_data) await device.turn_on() device._send_command.assert_called_with(device._turn_on_command) assert device.is_on() is True @pytest.mark.asyncio async def test_turn_off(device_case): """Test turning off the strip light.""" init_data = {"isOn": False} adv_info, dev_cls = device_case device = create_device_for_command_testing(adv_info, dev_cls, init_data) await device.turn_off() device._send_command.assert_called_with(device._turn_off_command) assert device.is_on() is False @pytest.mark.asyncio async def test_set_brightness(device_case): """Test setting brightness.""" adv_info, dev_cls = device_case device = create_device_for_command_testing(adv_info, dev_cls) await device.set_brightness(75) device._send_command.assert_called_with(device._set_brightness_command.format("4B")) @pytest.mark.asyncio async def test_set_rgb(device_case): """Test setting RGB values.""" adv_info, dev_cls = device_case device = create_device_for_command_testing(adv_info, dev_cls) await device.set_rgb(100, 255, 128, 64) device._send_command.assert_called_with(device._set_rgb_command.format("64FF8040")) @pytest.mark.asyncio async def test_set_effect_with_invalid_effect(device_case): """Test setting an invalid effect.""" adv_info, dev_cls = device_case device = create_device_for_command_testing(adv_info, dev_cls) with pytest.raises( SwitchbotOperationError, match="Effect invalid_effect not supported" ): await device.set_effect("invalid_effect") @pytest.mark.asyncio async def test_set_effect_with_valid_effect(device_case): """Test setting a valid effect.""" adv_info, dev_cls = device_case device = create_device_for_command_testing(adv_info, dev_cls) device._send_multiple_commands = AsyncMock() await device.set_effect("christmas") device._send_multiple_commands.assert_called_with(device._effect_dict["christmas"]) assert device.get_effect() == "christmas" @pytest.mark.asyncio async def test_effect_list_contains_lowercase_names(device_case, expected_effects): """Test that all effect names in get_effect_list are lowercase.""" adv_info, dev_cls = device_case device = create_device_for_command_testing(adv_info, dev_cls) effect_list = device.get_effect_list assert effect_list is not None, "Effect list should not be None" # All effect names should be lowercase for effect_name in effect_list: assert effect_name.islower(), f"Effect name '{effect_name}' is not lowercase" # Verify some known effects are present for expected_effect in expected_effects: assert expected_effect in effect_list, ( f"Expected effect '{expected_effect}' not found" ) @pytest.mark.asyncio async def test_set_effect_normalizes_case(device_case): """Test that set_effect normalizes effect names to lowercase.""" adv_info, dev_cls = device_case device = create_device_for_command_testing(adv_info, dev_cls) device._send_multiple_commands = AsyncMock() # Test various case combinations test_cases = ["CHRISTMAS", "Christmas", "ChRiStMaS", "christmas"] for test_effect in test_cases: await device.set_effect(test_effect) # Should always work regardless of case device._send_multiple_commands.assert_called() assert device.get_effect() == test_effect # Stored as provided @pytest.mark.parametrize( ("dev_cls", "expected_model"), [ (light_strip.SwitchbotStripLight3, SwitchbotModel.STRIP_LIGHT_3), (light_strip.SwitchbotRgbicLight, SwitchbotModel.RGBICWW_STRIP_LIGHT), ( light_strip.SwitchbotPermanentOutdoorLight, SwitchbotModel.PERMANENT_OUTDOOR_LIGHT, ), (light_strip.SwitchbotRgbicNeonLight, SwitchbotModel.RGBIC_NEON_ROPE_LIGHT), (light_strip.SwitchbotCandleWarmerLamp, SwitchbotModel.CANDLE_WARMER_LAMP), ], ) def test_default_model_classvar(dev_cls, expected_model): ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") device = dev_cls(ble_device, "ff", "ffffffffffffffffffffffffffffffff") assert device._model == expected_model def create_strip_light_device(init_data: dict | None = None): ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") return light_strip.SwitchbotLightStrip(ble_device) @pytest.mark.asyncio async def test_strip_light_supported_color_modes(): """Test that the strip light supports the expected color modes.""" device = create_strip_light_device() assert device.color_modes == { ColorMode.RGB, } @pytest.mark.asyncio @pytest.mark.parametrize( ("commands", "results", "final_result"), [ (("command1", "command2"), [(b"\x01", False), (None, False)], False), (("command1", "command2"), [(None, False), (b"\x01", True)], True), (("command1", "command2"), [(b"\x01", True), (b"\x01", False)], True), ], ) async def test_send_multiple_commands(commands, results, final_result, device_case): """Test sending multiple commands.""" adv_info, dev_cls = device_case device = create_device_for_command_testing(adv_info, dev_cls) device._send_command = AsyncMock(side_effect=[r[0] for r in results]) device._check_command_result = MagicMock(side_effect=[r[1] for r in results]) result = await device._send_multiple_commands(list(commands)) assert result is final_result @pytest.mark.asyncio async def test_unimplemented_color_mode(): class TestDevice(SwitchbotBaseLight): pass ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") device = TestDevice(ble_device) with pytest.raises(NotImplementedError): _ = device.color_mode @pytest.mark.asyncio async def test_exception_with_wrong_model(): class TestDevice(SwitchbotBaseLight): def __init__(self, device: BLEDevice, model: str = "unknown") -> None: super().__init__(device, model=model) ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") device = TestDevice(ble_device) with pytest.raises( SwitchbotOperationError, match="Current device aa:bb:cc:dd:ee:ff does not support this functionality", ): await device.set_rgb(100, 255, 128, 64) @pytest.mark.asyncio @pytest.mark.parametrize( ("color_mode_value", "expected_color_mode"), [ (1, ColorMode.EFFECT), # SEGMENTED (2, ColorMode.RGB), (3, ColorMode.EFFECT), # SCENE (4, ColorMode.EFFECT), # MUSIC (5, ColorMode.EFFECT), # CONTROLLER (6, ColorMode.COLOR_TEMP), (7, ColorMode.EFFECT), # EFFECT (RGBIC-specific) (10, ColorMode.OFF), # UNKNOWN ], ) async def test_permanent_outdoor_light_color_mode( color_mode_value, expected_color_mode ): """Test that POL correctly handles all RGBICStripLightColorMode values including EFFECT (7).""" device = create_device_for_command_testing( PERMANENT_OUTDOOR_LIGHT_INFO, light_strip.SwitchbotPermanentOutdoorLight, init_data={"color_mode": color_mode_value}, ) assert device.color_mode == expected_color_mode