diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 2af0ca06..d206b714 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -217,6 +217,9 @@ no_emeter = parametrize( model_filter=ALL_DEVICES - WITH_EMETER, protocol_filter={"SMART", "IOT"}, ) +has_emeter_smart = parametrize( + "has emeter smart", model_filter=WITH_EMETER_SMART, protocol_filter={"SMART"} +) has_emeter_iot = parametrize( "has emeter iot", model_filter=WITH_EMETER_IOT, protocol_filter={"IOT"} ) diff --git a/tests/test_emeter.py b/tests/iot/modules/test_emeter.py similarity index 67% rename from tests/test_emeter.py rename to tests/iot/modules/test_emeter.py index e796ffee..54fd02b2 100644 --- a/tests/test_emeter.py +++ b/tests/iot/modules/test_emeter.py @@ -12,13 +12,9 @@ from voluptuous import ( from kasa import Device, DeviceType, EmeterStatus, Module from kasa.interfaces.energy import Energy -from kasa.iot import IotDevice, IotStrip +from kasa.iot import IotStrip from kasa.iot.modules.emeter import Emeter -from kasa.smart import SmartDevice -from kasa.smart.modules import Energy as SmartEnergyModule -from kasa.smart.smartmodule import SmartModule - -from .conftest import has_emeter, has_emeter_iot, no_emeter +from tests.conftest import has_emeter_iot, no_emeter_iot CURRENT_CONSUMPTION_SCHEMA = Schema( Any( @@ -40,30 +36,23 @@ CURRENT_CONSUMPTION_SCHEMA = Schema( ) -@no_emeter +@no_emeter_iot async def test_no_emeter(dev): assert not dev.has_emeter with pytest.raises(AttributeError): await dev.get_emeter_realtime() - # Only iot devices support the historical stats so other - # devices will not implement the methods below - if isinstance(dev, IotDevice): - with pytest.raises(AttributeError): - await dev.get_emeter_daily() - with pytest.raises(AttributeError): - await dev.get_emeter_monthly() - with pytest.raises(AttributeError): - await dev.erase_emeter_stats() + + with pytest.raises(AttributeError): + await dev.get_emeter_daily() + with pytest.raises(AttributeError): + await dev.get_emeter_monthly() + with pytest.raises(AttributeError): + await dev.erase_emeter_stats() -@has_emeter +@has_emeter_iot async def test_get_emeter_realtime(dev): - if isinstance(dev, SmartDevice): - mod = SmartEnergyModule(dev, str(Module.Energy)) - if not await mod._check_supported(): - pytest.skip(f"Energy module not supported for {dev}.") - emeter = dev.modules[Module.Energy] current_emeter = await emeter.get_status() @@ -136,7 +125,7 @@ async def test_emeter_status(dev): @pytest.mark.skip("not clearing your stats..") -@has_emeter +@has_emeter_iot async def test_erase_emeter_stats(dev): emeter = dev.modules[Module.Energy] @@ -191,37 +180,22 @@ async def test_emeter_daily(): assert emeter.consumption_today == 0.500 -@has_emeter +@has_emeter_iot async def test_supported(dev: Device): - if isinstance(dev, SmartDevice): - mod = SmartEnergyModule(dev, str(Module.Energy)) - if not await mod._check_supported(): - pytest.skip(f"Energy module not supported for {dev}.") energy_module = dev.modules.get(Module.Energy) assert energy_module - if isinstance(dev, IotDevice): - info = ( - dev._last_update - if not isinstance(dev, IotStrip) - else dev.children[0].internal_state - ) - emeter = info[energy_module._module]["get_realtime"] - has_total = "total" in emeter or "total_wh" in emeter - has_voltage_current = "voltage" in emeter or "voltage_mv" in emeter - assert ( - energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is has_total - ) - assert ( - energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) - is has_voltage_current - ) - assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is True - else: - assert isinstance(energy_module, SmartModule) - assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is False - assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is False - if energy_module.supported_version < 2: - assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is False - else: - assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is True + info = ( + dev._last_update + if not isinstance(dev, IotStrip) + else dev.children[0].internal_state + ) + emeter = info[energy_module._module]["get_realtime"] + has_total = "total" in emeter or "total_wh" in emeter + has_voltage_current = "voltage" in emeter or "voltage_mv" in emeter + assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is has_total + assert ( + energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) + is has_voltage_current + ) + assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is True diff --git a/tests/test_usage.py b/tests/iot/modules/test_usage.py similarity index 100% rename from tests/test_usage.py rename to tests/iot/modules/test_usage.py diff --git a/tests/iot/test_iotbulb.py b/tests/iot/test_iotbulb.py new file mode 100644 index 00000000..b573a545 --- /dev/null +++ b/tests/iot/test_iotbulb.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import re + +import pytest +from voluptuous import ( + All, + Boolean, + Optional, + Range, + Schema, +) + +from kasa import Device, IotLightPreset, KasaException, LightState, Module +from kasa.iot import IotBulb, IotDimmer +from kasa.iot.modules import LightPreset as IotLightPresetModule +from tests.conftest import ( + bulb_iot, + color_bulb_iot, + dimmable_iot, + handle_turn_on, + non_dimmable_iot, + turn_on, + variable_temp_iot, +) +from tests.iot.test_iotdevice import SYSINFO_SCHEMA + + +@bulb_iot +async def test_bulb_sysinfo(dev: Device): + assert dev.sys_info is not None + SYSINFO_SCHEMA_BULB(dev.sys_info) + + assert dev.model is not None + + +@bulb_iot +async def test_light_state_without_update(dev: IotBulb, monkeypatch): + monkeypatch.setitem(dev._last_update["system"]["get_sysinfo"], "light_state", None) + with pytest.raises(KasaException): + print(dev.light_state) + + +@bulb_iot +async def test_get_light_state(dev: IotBulb): + LIGHT_STATE_SCHEMA(await dev.get_light_state()) + + +@color_bulb_iot +async def test_set_hsv_transition(dev: IotBulb, mocker): + set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") + light = dev.modules.get(Module.Light) + assert light + await light.set_hsv(10, 10, 100, transition=1000) + + set_light_state.assert_called_with( + {"hue": 10, "saturation": 10, "brightness": 100, "color_temp": 0}, + transition=1000, + ) + + +@bulb_iot +async def test_light_set_state(dev: IotBulb, mocker): + """Testing setting LightState on the light module.""" + light = dev.modules.get(Module.Light) + assert light + set_light_state = mocker.spy(dev, "_set_light_state") + state = LightState(light_on=True) + await light.set_state(state) + + set_light_state.assert_called_with({"on_off": 1}, transition=None) + state = LightState(light_on=False) + await light.set_state(state) + + set_light_state.assert_called_with({"on_off": 0}, transition=None) + + +@variable_temp_iot +async def test_set_color_temp_transition(dev: IotBulb, mocker): + set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") + light = dev.modules.get(Module.Light) + assert light + await light.set_color_temp(2700, transition=100) + + set_light_state.assert_called_with({"color_temp": 2700}, transition=100) + + +@variable_temp_iot +@pytest.mark.xdist_group(name="caplog") +async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog): + monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") + light = dev.modules.get(Module.Light) + assert light + assert light.valid_temperature_range == (2700, 5000) + assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text + + +@dimmable_iot +@turn_on +async def test_dimmable_brightness(dev: IotBulb, turn_on): + assert isinstance(dev, IotBulb | IotDimmer) + light = dev.modules.get(Module.Light) + assert light + await handle_turn_on(dev, turn_on) + assert dev._is_dimmable + + await light.set_brightness(50) + await dev.update() + assert light.brightness == 50 + + await light.set_brightness(10) + await dev.update() + assert light.brightness == 10 + + with pytest.raises(TypeError, match="Brightness must be an integer"): + await light.set_brightness("foo") # type: ignore[arg-type] + + +@bulb_iot +async def test_turn_on_transition(dev: IotBulb, mocker): + set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") + await dev.turn_on(transition=1000) + + set_light_state.assert_called_with({"on_off": 1}, transition=1000) + + await dev.turn_off(transition=100) + + set_light_state.assert_called_with({"on_off": 0}, transition=100) + + +@bulb_iot +async def test_dimmable_brightness_transition(dev: IotBulb, mocker): + set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") + light = dev.modules.get(Module.Light) + assert light + await light.set_brightness(10, transition=1000) + + set_light_state.assert_called_with({"brightness": 10, "on_off": 1}, transition=1000) + + +@dimmable_iot +async def test_invalid_brightness(dev: IotBulb): + assert dev._is_dimmable + light = dev.modules.get(Module.Light) + assert light + with pytest.raises( + ValueError, + match=re.escape("Invalid brightness value: 110 (valid range: 0-100%)"), + ): + await light.set_brightness(110) + + with pytest.raises( + ValueError, + match=re.escape("Invalid brightness value: -100 (valid range: 0-100%)"), + ): + await light.set_brightness(-100) + + +@non_dimmable_iot +async def test_non_dimmable(dev: IotBulb): + assert not dev._is_dimmable + light = dev.modules.get(Module.Light) + assert light + with pytest.raises(KasaException): + assert light.brightness == 0 + with pytest.raises(KasaException): + await light.set_brightness(100) + + +@bulb_iot +async def test_ignore_default_not_set_without_color_mode_change_turn_on( + dev: IotBulb, mocker +): + query_helper = mocker.patch("kasa.iot.IotBulb._query_helper") + # When turning back without settings, ignore default to restore the state + await dev.turn_on() + args, kwargs = query_helper.call_args_list[0] + assert args[2] == {"on_off": 1, "ignore_default": 0} + + await dev.turn_off() + args, kwargs = query_helper.call_args_list[1] + assert args[2] == {"on_off": 0, "ignore_default": 1} + + +@bulb_iot +async def test_list_presets(dev: IotBulb): + light_preset = dev.modules.get(Module.LightPreset) + assert light_preset + assert isinstance(light_preset, IotLightPresetModule) + presets = light_preset._deprecated_presets + # Light strip devices may list some light effects along with normal presets but these + # are handled by the LightEffect module so exclude preferred states with id + raw_presets = [ + pstate for pstate in dev.sys_info["preferred_state"] if "id" not in pstate + ] + assert len(presets) == len(raw_presets) + + for preset, raw in zip(presets, raw_presets, strict=False): + assert preset.index == raw["index"] + assert preset.brightness == raw["brightness"] + assert preset.hue == raw["hue"] + assert preset.saturation == raw["saturation"] + assert preset.color_temp == raw["color_temp"] + + +@bulb_iot +async def test_modify_preset(dev: IotBulb, mocker): + """Verify that modifying preset calls the and exceptions are raised properly.""" + if ( + not (light_preset := dev.modules.get(Module.LightPreset)) + or not light_preset._deprecated_presets + ): + pytest.skip("Some strips do not support presets") + + assert isinstance(light_preset, IotLightPresetModule) + data: dict[str, int | None] = { + "index": 0, + "brightness": 10, + "hue": 0, + "saturation": 0, + "color_temp": 0, + } + preset = IotLightPreset(**data) # type: ignore[call-arg, arg-type] + + assert preset.index == 0 + assert preset.brightness == 10 + assert preset.hue == 0 + assert preset.saturation == 0 + assert preset.color_temp == 0 + + await light_preset._deprecated_save_preset(preset) + await dev.update() + assert light_preset._deprecated_presets[0].brightness == 10 + + with pytest.raises(KasaException): + await light_preset._deprecated_save_preset( + IotLightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) # type: ignore[call-arg] + ) + + +@bulb_iot +@pytest.mark.parametrize( + ("preset", "payload"), + [ + ( + IotLightPreset(index=0, hue=0, brightness=1, saturation=0), # type: ignore[call-arg] + {"index": 0, "hue": 0, "brightness": 1, "saturation": 0}, + ), + ( + IotLightPreset(index=0, brightness=1, id="testid", mode=2, custom=0), # type: ignore[call-arg] + {"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0}, + ), + ], +) +async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker): + """Test that modify preset payloads ignore none values.""" + if ( + not (light_preset := dev.modules.get(Module.LightPreset)) + or not light_preset._deprecated_presets + ): + pytest.skip("Some strips do not support presets") + + query_helper = mocker.patch("kasa.iot.IotBulb._query_helper") + await light_preset._deprecated_save_preset(preset) + query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload) + + +LIGHT_STATE_SCHEMA = Schema( + { + "brightness": All(int, Range(min=0, max=100)), + "color_temp": int, + "hue": All(int, Range(min=0, max=360)), + "mode": str, + "on_off": Boolean, + "saturation": All(int, Range(min=0, max=100)), + "length": Optional(int), + "transition": Optional(int), + "dft_on_state": Optional( + { + "brightness": All(int, Range(min=0, max=100)), + "color_temp": All(int, Range(min=0, max=9000)), + "hue": All(int, Range(min=0, max=360)), + "mode": str, + "saturation": All(int, Range(min=0, max=100)), + "groups": Optional(list[int]), + } + ), + "err_code": int, + } +) + +SYSINFO_SCHEMA_BULB = SYSINFO_SCHEMA.extend( + { + "ctrl_protocols": Optional(dict), + "description": Optional(str), # Seen on LBxxx, similar to dev_name + "dev_state": str, + "disco_ver": str, + "heapsize": int, + "is_color": Boolean, + "is_dimmable": Boolean, + "is_factory": Boolean, + "is_variable_color_temp": Boolean, + "light_state": LIGHT_STATE_SCHEMA, + "preferred_state": [ + { + "brightness": All(int, Range(min=0, max=100)), + "color_temp": int, + "hue": All(int, Range(min=0, max=360)), + "index": int, + "saturation": All(int, Range(min=0, max=100)), + } + ], + } +) + + +@bulb_iot +async def test_turn_on_behaviours(dev: IotBulb): + behavior = await dev.get_turn_on_behavior() + assert behavior diff --git a/tests/test_iotdevice.py b/tests/iot/test_iotdevice.py similarity index 97% rename from tests/test_iotdevice.py rename to tests/iot/test_iotdevice.py index 68ee7a51..124910b7 100644 --- a/tests/test_iotdevice.py +++ b/tests/iot/test_iotdevice.py @@ -19,10 +19,9 @@ from voluptuous import ( from kasa import DeviceType, KasaException, Module from kasa.iot import IotDevice from kasa.iot.iotmodule import _merge_dict - -from .conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on -from .device_fixtures import device_iot, has_emeter_iot, no_emeter_iot -from .fakeprotocol_iot import FakeIotProtocol +from tests.conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on +from tests.device_fixtures import device_iot, has_emeter_iot, no_emeter_iot +from tests.fakeprotocol_iot import FakeIotProtocol TZ_SCHEMA = Schema( {"zone_str": str, "dst_offset": int, "index": All(int, Range(min=0)), "tz_str": str} diff --git a/tests/test_dimmer.py b/tests/iot/test_iotdimmer.py similarity index 98% rename from tests/test_dimmer.py rename to tests/iot/test_iotdimmer.py index 3505a7c1..38f440e7 100644 --- a/tests/test_dimmer.py +++ b/tests/iot/test_iotdimmer.py @@ -2,8 +2,7 @@ import pytest from kasa import DeviceType, Module from kasa.iot import IotDimmer - -from .conftest import dimmer_iot, handle_turn_on, turn_on +from tests.conftest import dimmer_iot, handle_turn_on, turn_on @dimmer_iot diff --git a/tests/test_lightstrip.py b/tests/iot/test_iotlightstrip.py similarity index 98% rename from tests/test_lightstrip.py rename to tests/iot/test_iotlightstrip.py index 365d0163..23eb61dc 100644 --- a/tests/test_lightstrip.py +++ b/tests/iot/test_iotlightstrip.py @@ -3,8 +3,7 @@ import pytest from kasa import DeviceType, Module from kasa.iot import IotLightStrip from kasa.iot.modules import LightEffect - -from .conftest import lightstrip_iot +from tests.conftest import lightstrip_iot @lightstrip_iot diff --git a/tests/protocols/__init__.py b/tests/protocols/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_protocol.py b/tests/protocols/test_iotprotocol.py similarity index 99% rename from tests/test_protocol.py rename to tests/protocols/test_iotprotocol.py index 09134e85..a2feaae3 100644 --- a/tests/test_protocol.py +++ b/tests/protocols/test_iotprotocol.py @@ -29,8 +29,8 @@ from kasa.transports.basetransport import BaseTransport from kasa.transports.klaptransport import KlapTransport, KlapTransportV2 from kasa.transports.xortransport import XorEncryption, XorTransport -from .conftest import device_iot -from .fakeprotocol_iot import FakeIotTransport +from ..conftest import device_iot +from ..fakeprotocol_iot import FakeIotTransport @pytest.mark.parametrize( diff --git a/tests/test_smartprotocol.py b/tests/protocols/test_smartprotocol.py similarity index 99% rename from tests/test_smartprotocol.py rename to tests/protocols/test_smartprotocol.py index fce6cd07..988c95eb 100644 --- a/tests/test_smartprotocol.py +++ b/tests/protocols/test_smartprotocol.py @@ -12,8 +12,8 @@ from kasa.exceptions import ( from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from kasa.smart import SmartDevice -from .conftest import device_smart -from .fakeprotocol_smart import FakeSmartTransport +from ..conftest import device_smart +from ..fakeprotocol_smart import FakeSmartTransport DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} DUMMY_MULTIPLE_QUERY = { diff --git a/tests/smart/modules/test_energy.py b/tests/smart/modules/test_energy.py new file mode 100644 index 00000000..fdbea88b --- /dev/null +++ b/tests/smart/modules/test_energy.py @@ -0,0 +1,21 @@ +import pytest + +from kasa import Module, SmartDevice +from kasa.interfaces.energy import Energy +from kasa.smart.modules import Energy as SmartEnergyModule +from tests.conftest import has_emeter_smart + + +@has_emeter_smart +async def test_supported(dev: SmartDevice): + energy_module = dev.modules.get(Module.Energy) + if not energy_module: + pytest.skip(f"Energy module not supported for {dev}.") + + assert isinstance(energy_module, SmartEnergyModule) + assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is False + assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is False + if energy_module.supported_version < 2: + assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is False + else: + assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is True diff --git a/tests/test_smartdevice.py b/tests/smart/test_smartdevice.py similarity index 98% rename from tests/test_smartdevice.py rename to tests/smart/test_smartdevice.py index a89b1098..c53193a3 100644 --- a/tests/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -17,12 +17,12 @@ from kasa.protocols.smartprotocol import _ChildProtocolWrapper from kasa.smart import SmartDevice from kasa.smart.modules.energy import Energy from kasa.smart.smartmodule import SmartModule - -from .conftest import ( +from tests.conftest import ( device_smart, get_device_for_fixture_protocol, get_parent_and_child_modules, ) +from tests.device_fixtures import variable_temp_smart @device_smart @@ -435,3 +435,10 @@ async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixt ): await new_dev.update() assert new_dev.is_cloud_connected is False + + +@variable_temp_smart +async def test_smart_temp_range(dev: Device): + light = dev.modules.get(Module.Light) + assert light + assert light.valid_temperature_range diff --git a/tests/test_bulb.py b/tests/test_bulb.py index 3ae1328f..6956c4e8 100644 --- a/tests/test_bulb.py +++ b/tests/test_bulb.py @@ -1,44 +1,16 @@ from __future__ import annotations -import re - import pytest -from voluptuous import ( - All, - Boolean, - Optional, - Range, - Schema, -) -from kasa import Device, DeviceType, IotLightPreset, KasaException, LightState, Module -from kasa.iot import IotBulb, IotDimmer -from kasa.iot.modules import LightPreset as IotLightPresetModule - -from .conftest import ( +from kasa import Device, DeviceType, KasaException, Module +from tests.conftest import handle_turn_on, turn_on +from tests.device_fixtures import ( bulb, - bulb_iot, color_bulb, - color_bulb_iot, - dimmable_iot, - handle_turn_on, non_color_bulb, - non_dimmable_iot, non_variable_temp, - turn_on, variable_temp, - variable_temp_iot, - variable_temp_smart, ) -from .test_iotdevice import SYSINFO_SCHEMA - - -@bulb_iot -async def test_bulb_sysinfo(dev: Device): - assert dev.sys_info is not None - SYSINFO_SCHEMA_BULB(dev.sys_info) - - assert dev.model is not None @bulb @@ -47,18 +19,6 @@ async def test_state_attributes(dev: Device): assert isinstance(dev.state_information["Cloud connection"], bool) -@bulb_iot -async def test_light_state_without_update(dev: IotBulb, monkeypatch): - monkeypatch.setitem(dev._last_update["system"]["get_sysinfo"], "light_state", None) - with pytest.raises(KasaException): - print(dev.light_state) - - -@bulb_iot -async def test_get_light_state(dev: IotBulb): - LIGHT_STATE_SCHEMA(await dev.get_light_state()) - - @color_bulb @turn_on async def test_hsv(dev: Device, turn_on): @@ -81,35 +41,6 @@ async def test_hsv(dev: Device, turn_on): assert brightness == 1 -@color_bulb_iot -async def test_set_hsv_transition(dev: IotBulb, mocker): - set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") - light = dev.modules.get(Module.Light) - assert light - await light.set_hsv(10, 10, 100, transition=1000) - - set_light_state.assert_called_with( - {"hue": 10, "saturation": 10, "brightness": 100, "color_temp": 0}, - transition=1000, - ) - - -@bulb_iot -async def test_light_set_state(dev: IotBulb, mocker): - """Testing setting LightState on the light module.""" - light = dev.modules.get(Module.Light) - assert light - set_light_state = mocker.spy(dev, "_set_light_state") - state = LightState(light_on=True) - await light.set_state(state) - - set_light_state.assert_called_with({"on_off": 1}, transition=None) - state = LightState(light_on=False) - await light.set_state(state) - - set_light_state.assert_called_with({"on_off": 0}, transition=None) - - @color_bulb @turn_on @pytest.mark.parametrize( @@ -221,33 +152,6 @@ async def test_try_set_colortemp(dev: Device, turn_on): assert light.color_temp == 2700 -@variable_temp_iot -async def test_set_color_temp_transition(dev: IotBulb, mocker): - set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") - light = dev.modules.get(Module.Light) - assert light - await light.set_color_temp(2700, transition=100) - - set_light_state.assert_called_with({"color_temp": 2700}, transition=100) - - -@variable_temp_iot -@pytest.mark.xdist_group(name="caplog") -async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog): - monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") - light = dev.modules.get(Module.Light) - assert light - assert light.valid_temperature_range == (2700, 5000) - assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text - - -@variable_temp_smart -async def test_smart_temp_range(dev: Device): - light = dev.modules.get(Module.Light) - assert light - assert light.valid_temperature_range - - @variable_temp async def test_out_of_range_temperature(dev: Device): light = dev.modules.get(Module.Light) @@ -276,231 +180,6 @@ async def test_non_variable_temp(dev: Device): print(light.color_temp) -@dimmable_iot -@turn_on -async def test_dimmable_brightness(dev: IotBulb, turn_on): - assert isinstance(dev, IotBulb | IotDimmer) - light = dev.modules.get(Module.Light) - assert light - await handle_turn_on(dev, turn_on) - assert dev._is_dimmable - - await light.set_brightness(50) - await dev.update() - assert light.brightness == 50 - - await light.set_brightness(10) - await dev.update() - assert light.brightness == 10 - - with pytest.raises(TypeError, match="Brightness must be an integer"): - await light.set_brightness("foo") # type: ignore[arg-type] - - -@bulb_iot -async def test_turn_on_transition(dev: IotBulb, mocker): - set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") - await dev.turn_on(transition=1000) - - set_light_state.assert_called_with({"on_off": 1}, transition=1000) - - await dev.turn_off(transition=100) - - set_light_state.assert_called_with({"on_off": 0}, transition=100) - - -@bulb_iot -async def test_dimmable_brightness_transition(dev: IotBulb, mocker): - set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") - light = dev.modules.get(Module.Light) - assert light - await light.set_brightness(10, transition=1000) - - set_light_state.assert_called_with({"brightness": 10, "on_off": 1}, transition=1000) - - -@dimmable_iot -async def test_invalid_brightness(dev: IotBulb): - assert dev._is_dimmable - light = dev.modules.get(Module.Light) - assert light - with pytest.raises( - ValueError, - match=re.escape("Invalid brightness value: 110 (valid range: 0-100%)"), - ): - await light.set_brightness(110) - - with pytest.raises( - ValueError, - match=re.escape("Invalid brightness value: -100 (valid range: 0-100%)"), - ): - await light.set_brightness(-100) - - -@non_dimmable_iot -async def test_non_dimmable(dev: IotBulb): - assert not dev._is_dimmable - light = dev.modules.get(Module.Light) - assert light - with pytest.raises(KasaException): - assert light.brightness == 0 - with pytest.raises(KasaException): - await light.set_brightness(100) - - -@bulb_iot -async def test_ignore_default_not_set_without_color_mode_change_turn_on( - dev: IotBulb, mocker -): - query_helper = mocker.patch("kasa.iot.IotBulb._query_helper") - # When turning back without settings, ignore default to restore the state - await dev.turn_on() - args, kwargs = query_helper.call_args_list[0] - assert args[2] == {"on_off": 1, "ignore_default": 0} - - await dev.turn_off() - args, kwargs = query_helper.call_args_list[1] - assert args[2] == {"on_off": 0, "ignore_default": 1} - - -@bulb_iot -async def test_list_presets(dev: IotBulb): - light_preset = dev.modules.get(Module.LightPreset) - assert light_preset - assert isinstance(light_preset, IotLightPresetModule) - presets = light_preset._deprecated_presets - # Light strip devices may list some light effects along with normal presets but these - # are handled by the LightEffect module so exclude preferred states with id - raw_presets = [ - pstate for pstate in dev.sys_info["preferred_state"] if "id" not in pstate - ] - assert len(presets) == len(raw_presets) - - for preset, raw in zip(presets, raw_presets, strict=False): - assert preset.index == raw["index"] - assert preset.brightness == raw["brightness"] - assert preset.hue == raw["hue"] - assert preset.saturation == raw["saturation"] - assert preset.color_temp == raw["color_temp"] - - -@bulb_iot -async def test_modify_preset(dev: IotBulb, mocker): - """Verify that modifying preset calls the and exceptions are raised properly.""" - if ( - not (light_preset := dev.modules.get(Module.LightPreset)) - or not light_preset._deprecated_presets - ): - pytest.skip("Some strips do not support presets") - - assert isinstance(light_preset, IotLightPresetModule) - data: dict[str, int | None] = { - "index": 0, - "brightness": 10, - "hue": 0, - "saturation": 0, - "color_temp": 0, - } - preset = IotLightPreset(**data) # type: ignore[call-arg, arg-type] - - assert preset.index == 0 - assert preset.brightness == 10 - assert preset.hue == 0 - assert preset.saturation == 0 - assert preset.color_temp == 0 - - await light_preset._deprecated_save_preset(preset) - await dev.update() - assert light_preset._deprecated_presets[0].brightness == 10 - - with pytest.raises(KasaException): - await light_preset._deprecated_save_preset( - IotLightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) # type: ignore[call-arg] - ) - - -@bulb_iot -@pytest.mark.parametrize( - ("preset", "payload"), - [ - ( - IotLightPreset(index=0, hue=0, brightness=1, saturation=0), # type: ignore[call-arg] - {"index": 0, "hue": 0, "brightness": 1, "saturation": 0}, - ), - ( - IotLightPreset(index=0, brightness=1, id="testid", mode=2, custom=0), # type: ignore[call-arg] - {"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0}, - ), - ], -) -async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker): - """Test that modify preset payloads ignore none values.""" - if ( - not (light_preset := dev.modules.get(Module.LightPreset)) - or not light_preset._deprecated_presets - ): - pytest.skip("Some strips do not support presets") - - query_helper = mocker.patch("kasa.iot.IotBulb._query_helper") - await light_preset._deprecated_save_preset(preset) - query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload) - - -LIGHT_STATE_SCHEMA = Schema( - { - "brightness": All(int, Range(min=0, max=100)), - "color_temp": int, - "hue": All(int, Range(min=0, max=360)), - "mode": str, - "on_off": Boolean, - "saturation": All(int, Range(min=0, max=100)), - "length": Optional(int), - "transition": Optional(int), - "dft_on_state": Optional( - { - "brightness": All(int, Range(min=0, max=100)), - "color_temp": All(int, Range(min=0, max=9000)), - "hue": All(int, Range(min=0, max=360)), - "mode": str, - "saturation": All(int, Range(min=0, max=100)), - "groups": Optional(list[int]), - } - ), - "err_code": int, - } -) - -SYSINFO_SCHEMA_BULB = SYSINFO_SCHEMA.extend( - { - "ctrl_protocols": Optional(dict), - "description": Optional(str), # Seen on LBxxx, similar to dev_name - "dev_state": str, - "disco_ver": str, - "heapsize": int, - "is_color": Boolean, - "is_dimmable": Boolean, - "is_factory": Boolean, - "is_variable_color_temp": Boolean, - "light_state": LIGHT_STATE_SCHEMA, - "preferred_state": [ - { - "brightness": All(int, Range(min=0, max=100)), - "color_temp": int, - "hue": All(int, Range(min=0, max=360)), - "index": int, - "saturation": All(int, Range(min=0, max=100)), - } - ], - } -) - - @bulb def test_device_type_bulb(dev: Device): assert dev.device_type in {DeviceType.Bulb, DeviceType.LightStrip} - - -@bulb_iot -async def test_turn_on_behaviours(dev: IotBulb): - behavior = await dev.get_turn_on_behavior() - assert behavior diff --git a/tests/test_plug.py b/tests/test_plug.py index 795ebe55..25be910b 100644 --- a/tests/test_plug.py +++ b/tests/test_plug.py @@ -1,9 +1,9 @@ import pytest from kasa import DeviceType +from tests.iot.test_iotdevice import SYSINFO_SCHEMA from .conftest import plug, plug_iot, plug_smart, switch_smart, wallswitch_iot -from .test_iotdevice import SYSINFO_SCHEMA # these schemas should go to the mainlib as # they can be useful when adding support for new features/devices diff --git a/tests/transports/__init__.py b/tests/transports/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_aestransport.py b/tests/transports/test_aestransport.py similarity index 100% rename from tests/test_aestransport.py rename to tests/transports/test_aestransport.py diff --git a/tests/test_klapprotocol.py b/tests/transports/test_klaptransport.py similarity index 100% rename from tests/test_klapprotocol.py rename to tests/transports/test_klaptransport.py diff --git a/tests/test_sslaestransport.py b/tests/transports/test_sslaestransport.py similarity index 100% rename from tests/test_sslaestransport.py rename to tests/transports/test_sslaestransport.py