mirror of
https://github.com/python-kasa/python-kasa.git
synced 2025-10-20 06:18:01 +00:00
Merge remote-tracking branch 'upstream/master' into feat/smartcam_passthrough
This commit is contained in:
@@ -473,8 +473,12 @@ def get_nearest_fixture_to_ip(dev):
|
||||
assert protocol_fixtures, "Unknown device type"
|
||||
|
||||
# This will get the best fixture with a match on model region
|
||||
if model_region_fixtures := filter_fixtures(
|
||||
"", model_filter={dev._model_region}, fixture_list=protocol_fixtures
|
||||
if (di := dev.device_info) and (
|
||||
model_region_fixtures := filter_fixtures(
|
||||
"",
|
||||
model_filter={di.long_name + (f"({di.region})" if di.region else "")},
|
||||
fixture_list=protocol_fixtures,
|
||||
)
|
||||
):
|
||||
return next(iter(model_region_fixtures))
|
||||
|
||||
|
@@ -114,6 +114,15 @@ class FakeSmartTransport(BaseTransport):
|
||||
"type": 0,
|
||||
},
|
||||
),
|
||||
"get_homekit_info": (
|
||||
"homekit",
|
||||
{
|
||||
"mfi_setup_code": "000-00-000",
|
||||
"mfi_setup_id": "0000",
|
||||
"mfi_token_token": "000000000000000000000000000000000",
|
||||
"mfi_token_uuid": "00000000-0000-0000-0000-000000000000",
|
||||
},
|
||||
),
|
||||
"get_auto_update_info": (
|
||||
"firmware",
|
||||
{"enable": True, "random_range": 120, "time": 180},
|
||||
@@ -151,6 +160,13 @@ class FakeSmartTransport(BaseTransport):
|
||||
"energy_monitoring",
|
||||
{"igain": 10861, "vgain": 118657},
|
||||
),
|
||||
"get_matter_setup_info": (
|
||||
"matter",
|
||||
{
|
||||
"setup_code": "00000000000",
|
||||
"setup_payload": "00:0000000-0000.00.000",
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
async def send(self, request: str):
|
||||
|
@@ -34,6 +34,7 @@ class FakeSmartCamTransport(BaseTransport):
|
||||
list_return_size=10,
|
||||
is_child=False,
|
||||
verbatim=False,
|
||||
components_not_included=False,
|
||||
):
|
||||
super().__init__(
|
||||
config=DeviceConfig(
|
||||
@@ -44,6 +45,7 @@ class FakeSmartCamTransport(BaseTransport):
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
self.fixture_name = fixture_name
|
||||
# When True verbatim will bypass any extra processing of missing
|
||||
# methods and is used to test the fixture creation itself.
|
||||
@@ -58,6 +60,17 @@ class FakeSmartCamTransport(BaseTransport):
|
||||
# self.child_protocols = self._get_child_protocols()
|
||||
self.list_return_size = list_return_size
|
||||
|
||||
# Setting this flag allows tests to create dummy transports without
|
||||
# full fixture info for testing specific cases like list handling etc
|
||||
self.components_not_included = (components_not_included,)
|
||||
if not components_not_included:
|
||||
self.components = {
|
||||
comp["name"]: comp["version"]
|
||||
for comp in self.info["getAppComponentList"]["app_component"][
|
||||
"app_component_list"
|
||||
]
|
||||
}
|
||||
|
||||
@property
|
||||
def default_port(self):
|
||||
"""Default port for the transport."""
|
||||
@@ -112,6 +125,15 @@ class FakeSmartCamTransport(BaseTransport):
|
||||
info = info[key]
|
||||
info[set_keys[-1]] = value
|
||||
|
||||
FIXTURE_MISSING_MAP = {
|
||||
"getMatterSetupInfo": (
|
||||
"matter",
|
||||
{
|
||||
"setup_code": "00000000000",
|
||||
"setup_payload": "00:0000000-0000.00.000",
|
||||
},
|
||||
)
|
||||
}
|
||||
# Setters for when there's not a simple mapping of setters to getters
|
||||
SETTERS = {
|
||||
("system", "sys", "dev_alias"): [
|
||||
@@ -217,8 +239,17 @@ class FakeSmartCamTransport(BaseTransport):
|
||||
start_index : start_index + self.list_return_size
|
||||
]
|
||||
return {"result": result, "error_code": 0}
|
||||
else:
|
||||
return {"error_code": -1}
|
||||
if (
|
||||
# FIXTURE_MISSING is for service calls not in place when
|
||||
# SMART fixtures started to be generated
|
||||
missing_result := self.FIXTURE_MISSING_MAP.get(method)
|
||||
) and missing_result[0] in self.components:
|
||||
# Copy to info so it will work with update methods
|
||||
info[method] = copy.deepcopy(missing_result[1])
|
||||
result = copy.deepcopy(info[method])
|
||||
return {"result": result, "error_code": 0}
|
||||
|
||||
return {"error_code": -1}
|
||||
return {"error_code": -1}
|
||||
|
||||
async def close(self) -> None:
|
||||
|
@@ -145,12 +145,21 @@ def filter_fixtures(
|
||||
def _component_match(
|
||||
fixture_data: FixtureInfo, component_filter: str | ComponentFilter
|
||||
):
|
||||
if (component_nego := fixture_data.data.get("component_nego")) is None:
|
||||
components = {}
|
||||
if component_nego := fixture_data.data.get("component_nego"):
|
||||
components = {
|
||||
component["id"]: component["ver_code"]
|
||||
for component in component_nego["component_list"]
|
||||
}
|
||||
if get_app_component_list := fixture_data.data.get("getAppComponentList"):
|
||||
components = {
|
||||
component["name"]: component["version"]
|
||||
for component in get_app_component_list["app_component"][
|
||||
"app_component_list"
|
||||
]
|
||||
}
|
||||
if not components:
|
||||
return False
|
||||
components = {
|
||||
component["id"]: component["ver_code"]
|
||||
for component in component_nego["component_list"]
|
||||
}
|
||||
if isinstance(component_filter, str):
|
||||
return component_filter in components
|
||||
else:
|
||||
|
@@ -91,7 +91,9 @@ 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)
|
||||
color_temp_feat = light.get_feature("color_temp")
|
||||
assert color_temp_feat
|
||||
assert color_temp_feat.range == (2700, 5000)
|
||||
assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text
|
||||
|
||||
|
||||
|
@@ -99,7 +99,7 @@ async def test_invalid_connection(mocker, dev):
|
||||
@has_emeter_iot
|
||||
async def test_initial_update_emeter(dev, mocker):
|
||||
"""Test that the initial update performs second query if emeter is available."""
|
||||
dev._last_update = None
|
||||
dev._last_update = {}
|
||||
dev._legacy_features = set()
|
||||
spy = mocker.spy(dev.protocol, "query")
|
||||
await dev.update()
|
||||
@@ -111,7 +111,7 @@ async def test_initial_update_emeter(dev, mocker):
|
||||
@no_emeter_iot
|
||||
async def test_initial_update_no_emeter(dev, mocker):
|
||||
"""Test that the initial update performs second query if emeter is available."""
|
||||
dev._last_update = None
|
||||
dev._last_update = {}
|
||||
dev._legacy_features = set()
|
||||
spy = mocker.spy(dev.protocol, "query")
|
||||
await dev.update()
|
||||
|
@@ -2,6 +2,7 @@ import logging
|
||||
|
||||
import pytest
|
||||
import pytest_mock
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from kasa.exceptions import (
|
||||
SMART_RETRYABLE_ERRORS,
|
||||
@@ -14,6 +15,7 @@ from kasa.smart import SmartDevice
|
||||
|
||||
from ..conftest import device_smart
|
||||
from ..fakeprotocol_smart import FakeSmartTransport
|
||||
from ..fakeprotocol_smartcam import FakeSmartCamTransport
|
||||
|
||||
DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}}
|
||||
DUMMY_MULTIPLE_QUERY = {
|
||||
@@ -448,3 +450,81 @@ async def test_smart_queries_redaction(
|
||||
await dev.update()
|
||||
assert device_id not in caplog.text
|
||||
assert "REDACTED_" + device_id[9::] in caplog.text
|
||||
|
||||
|
||||
async def test_no_method_returned_multiple(
|
||||
mocker: MockerFixture, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""Test protocol handles multiple requests that don't return the method."""
|
||||
req = {
|
||||
"getDeviceInfo": {"device_info": {"name": ["basic_info", "info"]}},
|
||||
"getAppComponentList": {"app_component": {"name": "app_component_list"}},
|
||||
}
|
||||
res = {
|
||||
"result": {
|
||||
"responses": [
|
||||
{
|
||||
"method": "getDeviceInfo",
|
||||
"result": {
|
||||
"device_info": {
|
||||
"basic_info": {
|
||||
"device_model": "C210",
|
||||
},
|
||||
}
|
||||
},
|
||||
"error_code": 0,
|
||||
},
|
||||
{
|
||||
"result": {"app_component": {"app_component_list": []}},
|
||||
"error_code": 0,
|
||||
},
|
||||
]
|
||||
},
|
||||
"error_code": 0,
|
||||
}
|
||||
|
||||
transport = FakeSmartCamTransport(
|
||||
{},
|
||||
"dummy-name",
|
||||
components_not_included=True,
|
||||
)
|
||||
protocol = SmartProtocol(transport=transport)
|
||||
mocker.patch.object(protocol._transport, "send", return_value=res)
|
||||
await protocol.query(req)
|
||||
assert "No method key in response" in caplog.text
|
||||
caplog.clear()
|
||||
await protocol.query(req)
|
||||
assert "No method key in response" not in caplog.text
|
||||
|
||||
|
||||
async def test_no_multiple_methods(
|
||||
mocker: MockerFixture, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""Test protocol sends NO_MULTI methods as single call."""
|
||||
req = {
|
||||
"getDeviceInfo": {"device_info": {"name": ["basic_info", "info"]}},
|
||||
"getConnectStatus": {"onboarding": {"get_connect_status": {}}},
|
||||
}
|
||||
info = {
|
||||
"getDeviceInfo": {
|
||||
"device_info": {
|
||||
"basic_info": {
|
||||
"avatar": "Home",
|
||||
}
|
||||
}
|
||||
},
|
||||
"getConnectStatus": {
|
||||
"onboarding": {
|
||||
"get_connect_status": {"current_ssid": "", "err_code": 0, "status": 0}
|
||||
}
|
||||
},
|
||||
}
|
||||
transport = FakeSmartCamTransport(
|
||||
info,
|
||||
"dummy-name",
|
||||
components_not_included=True,
|
||||
)
|
||||
protocol = SmartProtocol(transport=transport)
|
||||
send_spy = mocker.spy(protocol._transport, "send")
|
||||
await protocol.query(req)
|
||||
assert send_spy.call_count == 2
|
||||
|
16
tests/smart/modules/test_homekit.py
Normal file
16
tests/smart/modules/test_homekit.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from kasa import Module
|
||||
from kasa.smart import SmartDevice
|
||||
|
||||
from ...device_fixtures import parametrize
|
||||
|
||||
homekit = parametrize(
|
||||
"has homekit", component_filter="homekit", protocol_filter={"SMART"}
|
||||
)
|
||||
|
||||
|
||||
@homekit
|
||||
async def test_info(dev: SmartDevice):
|
||||
"""Test homekit info."""
|
||||
homekit = dev.modules.get(Module.HomeKit)
|
||||
assert homekit
|
||||
assert homekit.info
|
20
tests/smart/modules/test_matter.py
Normal file
20
tests/smart/modules/test_matter.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from kasa import Module
|
||||
from kasa.smart import SmartDevice
|
||||
|
||||
from ...device_fixtures import parametrize
|
||||
|
||||
matter = parametrize(
|
||||
"has matter", component_filter="matter", protocol_filter={"SMART", "SMARTCAM"}
|
||||
)
|
||||
|
||||
|
||||
@matter
|
||||
async def test_info(dev: SmartDevice):
|
||||
"""Test matter info."""
|
||||
matter = dev.modules.get(Module.Matter)
|
||||
assert matter
|
||||
assert matter.info
|
||||
setup_code = dev.features.get("matter_setup_code")
|
||||
assert setup_code
|
||||
setup_payload = dev.features.get("matter_setup_payload")
|
||||
assert setup_payload
|
@@ -62,11 +62,14 @@ async def test_device_type_no_update(discovery_mock, caplog: pytest.LogCaptureFi
|
||||
assert repr(dev) == f"<DeviceType.Unknown at {DISCOVERY_MOCK_IP} - update() needed>"
|
||||
|
||||
discovery_result = copy.deepcopy(discovery_mock.discovery_data["result"])
|
||||
|
||||
disco_model = discovery_result["device_model"]
|
||||
short_model, _, _ = disco_model.partition("(")
|
||||
dev.update_from_discover_info(discovery_result)
|
||||
assert dev.device_type is DeviceType.Unknown
|
||||
assert (
|
||||
repr(dev)
|
||||
== f"<DeviceType.Unknown at {DISCOVERY_MOCK_IP} - None (None) - update() needed>"
|
||||
== f"<DeviceType.Unknown at {DISCOVERY_MOCK_IP} - None ({short_model}) - update() needed>"
|
||||
)
|
||||
discovery_result["device_type"] = "SMART.FOOBAR"
|
||||
dev.update_from_discover_info(discovery_result)
|
||||
@@ -74,7 +77,7 @@ async def test_device_type_no_update(discovery_mock, caplog: pytest.LogCaptureFi
|
||||
assert dev.device_type is DeviceType.Plug
|
||||
assert (
|
||||
repr(dev)
|
||||
== f"<DeviceType.Plug at {DISCOVERY_MOCK_IP} - None (None) - update() needed>"
|
||||
== f"<DeviceType.Plug at {DISCOVERY_MOCK_IP} - None ({short_model}) - update() needed>"
|
||||
)
|
||||
assert "Unknown device type, falling back to plug" in caplog.text
|
||||
|
||||
@@ -469,7 +472,9 @@ async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixt
|
||||
async def test_smart_temp_range(dev: Device):
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
assert light.valid_temperature_range
|
||||
color_temp_feat = light.get_feature("color_temp")
|
||||
assert color_temp_feat
|
||||
assert color_temp_feat.range
|
||||
|
||||
|
||||
@device_smart
|
||||
@@ -528,3 +533,16 @@ async def test_initialize_modules_required_component(
|
||||
|
||||
assert "AvailableComponent" in dev.modules
|
||||
assert "NonExistingComponent" not in dev.modules
|
||||
|
||||
|
||||
async def test_smartmodule_query():
|
||||
"""Test that a module that doesn't set QUERY_GETTER_NAME has empty query."""
|
||||
|
||||
class DummyModule(SmartModule):
|
||||
pass
|
||||
|
||||
dummy_device = await get_device_for_fixture_protocol(
|
||||
"KS240(US)_1.0_1.0.5.json", "SMART"
|
||||
)
|
||||
mod = DummyModule(dummy_device, "dummy")
|
||||
assert mod.query() == {}
|
||||
|
@@ -25,7 +25,7 @@ async def test_hsv(dev: Device, turn_on):
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
await handle_turn_on(dev, turn_on)
|
||||
assert light.is_color
|
||||
assert light.has_feature("hsv")
|
||||
|
||||
hue, saturation, brightness = light.hsv
|
||||
assert 0 <= hue <= 360
|
||||
@@ -106,7 +106,7 @@ async def test_invalid_hsv(
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
await handle_turn_on(dev, turn_on)
|
||||
assert light.is_color
|
||||
assert light.has_feature("hsv")
|
||||
with pytest.raises(exception_cls, match=error):
|
||||
await light.set_hsv(hue, sat, brightness)
|
||||
|
||||
@@ -124,7 +124,7 @@ async def test_color_state_information(dev: Device):
|
||||
async def test_hsv_on_non_color(dev: Device):
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
assert not light.is_color
|
||||
assert not light.has_feature("hsv")
|
||||
|
||||
with pytest.raises(KasaException):
|
||||
await light.set_hsv(0, 0, 0)
|
||||
@@ -173,9 +173,6 @@ async def test_non_variable_temp(dev: Device):
|
||||
with pytest.raises(KasaException):
|
||||
await light.set_color_temp(2700)
|
||||
|
||||
with pytest.raises(KasaException):
|
||||
print(light.valid_temperature_range)
|
||||
|
||||
with pytest.raises(KasaException):
|
||||
print(light.color_temp)
|
||||
|
||||
|
@@ -12,6 +12,7 @@ from pytest_mock import MockerFixture
|
||||
|
||||
from kasa import (
|
||||
AuthenticationError,
|
||||
ColorTempRange,
|
||||
Credentials,
|
||||
Device,
|
||||
DeviceError,
|
||||
@@ -523,7 +524,9 @@ async def test_emeter(dev: Device, mocker, runner):
|
||||
|
||||
async def test_brightness(dev: Device, runner):
|
||||
res = await runner.invoke(brightness, obj=dev)
|
||||
if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable:
|
||||
if not (light := dev.modules.get(Module.Light)) or not light.has_feature(
|
||||
"brightness"
|
||||
):
|
||||
assert "This device does not support brightness." in res.output
|
||||
return
|
||||
|
||||
@@ -540,13 +543,16 @@ async def test_brightness(dev: Device, runner):
|
||||
|
||||
async def test_color_temperature(dev: Device, runner):
|
||||
res = await runner.invoke(temperature, obj=dev)
|
||||
if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp:
|
||||
if not (light := dev.modules.get(Module.Light)) or not (
|
||||
color_temp_feat := light.get_feature("color_temp")
|
||||
):
|
||||
assert "Device does not support color temperature" in res.output
|
||||
return
|
||||
|
||||
res = await runner.invoke(temperature, obj=dev)
|
||||
assert f"Color temperature: {light.color_temp}" in res.output
|
||||
valid_range = light.valid_temperature_range
|
||||
valid_range = color_temp_feat.range
|
||||
assert isinstance(valid_range, ColorTempRange)
|
||||
assert f"(min: {valid_range.min}, max: {valid_range.max})" in res.output
|
||||
|
||||
val = int((valid_range.min + valid_range.max) / 2)
|
||||
@@ -572,7 +578,7 @@ async def test_color_temperature(dev: Device, runner):
|
||||
|
||||
async def test_color_hsv(dev: Device, runner: CliRunner):
|
||||
res = await runner.invoke(hsv, obj=dev)
|
||||
if not (light := dev.modules.get(Module.Light)) or not light.is_color:
|
||||
if not (light := dev.modules.get(Module.Light)) or not light.has_feature("hsv"):
|
||||
assert "Device does not support colors" in res.output
|
||||
return
|
||||
|
||||
|
@@ -198,7 +198,7 @@ async def test_light_color_temp(dev: Device):
|
||||
|
||||
light = next(get_parent_and_child_modules(dev, Module.Light))
|
||||
assert light
|
||||
if not light.is_variable_color_temp:
|
||||
if not light.has_feature("color_temp"):
|
||||
pytest.skip(
|
||||
"Some smart light strips have color_temperature"
|
||||
" component but min and max are the same"
|
||||
|
@@ -280,19 +280,19 @@ async def test_deprecated_light_attributes(dev: Device):
|
||||
await _test_attribute(dev, "is_color", bool(light), "Light")
|
||||
await _test_attribute(dev, "is_variable_color_temp", bool(light), "Light")
|
||||
|
||||
exc = KasaException if light and not light.is_dimmable else None
|
||||
exc = KasaException if light and not light.has_feature("brightness") else None
|
||||
await _test_attribute(dev, "brightness", bool(light), "Light", will_raise=exc)
|
||||
await _test_attribute(
|
||||
dev, "set_brightness", bool(light), "Light", 50, will_raise=exc
|
||||
)
|
||||
|
||||
exc = KasaException if light and not light.is_color else None
|
||||
exc = KasaException if light and not light.has_feature("hsv") else None
|
||||
await _test_attribute(dev, "hsv", bool(light), "Light", will_raise=exc)
|
||||
await _test_attribute(
|
||||
dev, "set_hsv", bool(light), "Light", 50, 50, 50, will_raise=exc
|
||||
)
|
||||
|
||||
exc = KasaException if light and not light.is_variable_color_temp else None
|
||||
exc = KasaException if light and not light.has_feature("color_temp") else None
|
||||
await _test_attribute(dev, "color_temp", bool(light), "Light", will_raise=exc)
|
||||
await _test_attribute(
|
||||
dev, "set_color_temp", bool(light), "Light", 2700, will_raise=exc
|
||||
|
@@ -390,13 +390,12 @@ async def test_device_update_from_new_discovery_info(discovery_mock):
|
||||
device_class = Discover._get_device_class(discovery_data)
|
||||
device = device_class("127.0.0.1")
|
||||
discover_info = DiscoveryResult.from_dict(discovery_data["result"])
|
||||
discover_dump = discover_info.to_dict()
|
||||
model, _, _ = discover_dump["device_model"].partition("(")
|
||||
discover_dump["model"] = model
|
||||
device.update_from_discover_info(discover_dump)
|
||||
|
||||
assert device.mac == discover_dump["mac"].replace("-", ":")
|
||||
assert device.model == model
|
||||
device.update_from_discover_info(discovery_data["result"])
|
||||
|
||||
assert device.mac == discover_info.mac.replace("-", ":")
|
||||
no_region_model, _, _ = discover_info.device_model.partition("(")
|
||||
assert device.model == no_region_model
|
||||
|
||||
# TODO implement requires_update for SmartDevice
|
||||
if isinstance(device, IotDevice):
|
||||
|
@@ -19,7 +19,7 @@ def test_bulb_examples(mocker):
|
||||
assert not res["failed"]
|
||||
|
||||
|
||||
def test_smartdevice_examples(mocker):
|
||||
def test_iotdevice_examples(mocker):
|
||||
"""Use HS110 for emeter examples."""
|
||||
p = asyncio.run(get_device_for_fixture_protocol("HS110(EU)_1.0_1.2.5.json", "IOT"))
|
||||
asyncio.run(p.set_alias("Bedroom Lamp Plug"))
|
||||
|
Reference in New Issue
Block a user