mirror of
https://github.com/python-kasa/python-kasa.git
synced 2025-04-27 00:56:23 +00:00
Support smart child modules queries (#967)
Required for the P300 firmware update with `auto_off` module on child devices. Will query child modules for parent devices that are not hubs. Coverage will be fixed when the P300 fixture is added https://github.com/python-kasa/python-kasa/pull/915
This commit is contained in:
parent
927fe648ac
commit
db6276d3fd
@ -99,3 +99,11 @@ class AutoOff(SmartModule):
|
|||||||
sysinfo = self._device.sys_info
|
sysinfo = self._device.sys_info
|
||||||
|
|
||||||
return self._device.time + timedelta(seconds=sysinfo["auto_off_remain_time"])
|
return self._device.time + timedelta(seconds=sysinfo["auto_off_remain_time"])
|
||||||
|
|
||||||
|
async def _check_supported(self):
|
||||||
|
"""Additional check to see if the module is supported by the device.
|
||||||
|
|
||||||
|
Parent devices that report components of children such as P300 will not have
|
||||||
|
the auto_off_status is sysinfo.
|
||||||
|
"""
|
||||||
|
return "auto_off_status" in self._device.sys_info
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from ..device_type import DeviceType
|
from ..device_type import DeviceType
|
||||||
from ..deviceconfig import DeviceConfig
|
from ..deviceconfig import DeviceConfig
|
||||||
@ -34,7 +35,17 @@ class SmartChildDevice(SmartDevice):
|
|||||||
self.protocol = _ChildProtocolWrapper(self._id, parent.protocol)
|
self.protocol = _ChildProtocolWrapper(self._id, parent.protocol)
|
||||||
|
|
||||||
async def update(self, update_children: bool = True):
|
async def update(self, update_children: bool = True):
|
||||||
"""Noop update. The parent updates our internals."""
|
"""Update child module info.
|
||||||
|
|
||||||
|
The parent updates our internal info so just update modules with
|
||||||
|
their own queries.
|
||||||
|
"""
|
||||||
|
req: dict[str, Any] = {}
|
||||||
|
for module in self.modules.values():
|
||||||
|
if mod_query := module.query():
|
||||||
|
req.update(mod_query)
|
||||||
|
if req:
|
||||||
|
self._last_update = await self.protocol.query(req)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def create(cls, parent: SmartDevice, child_info, child_components):
|
async def create(cls, parent: SmartDevice, child_info, child_components):
|
||||||
|
@ -149,7 +149,7 @@ class SmartDevice(Device):
|
|||||||
if "child_device" in self._components and not self.children:
|
if "child_device" in self._components and not self.children:
|
||||||
await self._initialize_children()
|
await self._initialize_children()
|
||||||
|
|
||||||
async def update(self, update_children: bool = True):
|
async def update(self, update_children: bool = False):
|
||||||
"""Update the device."""
|
"""Update the device."""
|
||||||
if self.credentials is None and self.credentials_hash is None:
|
if self.credentials is None and self.credentials_hash is None:
|
||||||
raise AuthenticationError("Tapo plug requires authentication.")
|
raise AuthenticationError("Tapo plug requires authentication.")
|
||||||
@ -167,9 +167,14 @@ class SmartDevice(Device):
|
|||||||
self._last_update = resp = await self.protocol.query(req)
|
self._last_update = resp = await self.protocol.query(req)
|
||||||
|
|
||||||
self._info = self._try_get_response(resp, "get_device_info")
|
self._info = self._try_get_response(resp, "get_device_info")
|
||||||
|
|
||||||
|
# Call child update which will only update module calls, info is updated
|
||||||
|
# from get_child_device_list. update_children only affects hub devices, other
|
||||||
|
# devices will always update children to prevent errors on module access.
|
||||||
|
if update_children or self.device_type != DeviceType.Hub:
|
||||||
|
for child in self._children.values():
|
||||||
|
await child.update()
|
||||||
if child_info := self._try_get_response(resp, "get_child_device_list", {}):
|
if child_info := self._try_get_response(resp, "get_child_device_list", {}):
|
||||||
# TODO: we don't currently perform queries on children based on modules,
|
|
||||||
# but just update the information that is returned in the main query.
|
|
||||||
for info in child_info["child_device_list"]:
|
for info in child_info["child_device_list"]:
|
||||||
self._children[info["device_id"]]._update_internal_state(info)
|
self._children[info["device_id"]]._update_internal_state(info)
|
||||||
|
|
||||||
@ -352,8 +357,7 @@ class SmartDevice(Device):
|
|||||||
@property
|
@property
|
||||||
def time(self) -> datetime:
|
def time(self) -> datetime:
|
||||||
"""Return the time."""
|
"""Return the time."""
|
||||||
# TODO: Default to parent's time module for child devices
|
if self._parent and Module.Time in self._parent.modules:
|
||||||
if self._parent and Module.Time in self.modules:
|
|
||||||
_timemod = self._parent.modules[Module.Time]
|
_timemod = self._parent.modules[Module.Time]
|
||||||
else:
|
else:
|
||||||
_timemod = self.modules[Module.Time]
|
_timemod = self.modules[Module.Time]
|
||||||
|
@ -149,6 +149,11 @@ class FakeSmartTransport(BaseTransport):
|
|||||||
if child["device_id"] == device_id:
|
if child["device_id"] == device_id:
|
||||||
info = child
|
info = child
|
||||||
break
|
break
|
||||||
|
# Create the child_devices fixture section for fixtures generated before it was added
|
||||||
|
if "child_devices" not in self.info:
|
||||||
|
self.info["child_devices"] = {}
|
||||||
|
# Get the method calls made directly on the child devices
|
||||||
|
child_device_calls = self.info["child_devices"].setdefault(device_id, {})
|
||||||
|
|
||||||
# We only support get & set device info for now.
|
# We only support get & set device info for now.
|
||||||
if child_method == "get_device_info":
|
if child_method == "get_device_info":
|
||||||
@ -159,14 +164,27 @@ class FakeSmartTransport(BaseTransport):
|
|||||||
return {"error_code": 0}
|
return {"error_code": 0}
|
||||||
elif child_method == "set_preset_rules":
|
elif child_method == "set_preset_rules":
|
||||||
return self._set_child_preset_rules(info, child_params)
|
return self._set_child_preset_rules(info, child_params)
|
||||||
|
elif child_method in child_device_calls:
|
||||||
|
result = copy.deepcopy(child_device_calls[child_method])
|
||||||
|
return {"result": result, "error_code": 0}
|
||||||
elif (
|
elif (
|
||||||
# FIXTURE_MISSING is for service calls not in place when
|
# FIXTURE_MISSING is for service calls not in place when
|
||||||
# SMART fixtures started to be generated
|
# SMART fixtures started to be generated
|
||||||
missing_result := self.FIXTURE_MISSING_MAP.get(child_method)
|
missing_result := self.FIXTURE_MISSING_MAP.get(child_method)
|
||||||
) and missing_result[0] in self.components:
|
) and missing_result[0] in self.components:
|
||||||
result = copy.deepcopy(missing_result[1])
|
# Copy to info so it will work with update methods
|
||||||
|
child_device_calls[child_method] = copy.deepcopy(missing_result[1])
|
||||||
|
result = copy.deepcopy(info[child_method])
|
||||||
retval = {"result": result, "error_code": 0}
|
retval = {"result": result, "error_code": 0}
|
||||||
return retval
|
return retval
|
||||||
|
elif child_method[:4] == "set_":
|
||||||
|
target_method = f"get_{child_method[4:]}"
|
||||||
|
if target_method not in child_device_calls:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No {target_method} in child info, calling set before get not supported."
|
||||||
|
)
|
||||||
|
child_device_calls[target_method].update(child_params)
|
||||||
|
return {"error_code": 0}
|
||||||
else:
|
else:
|
||||||
# PARAMS error returned for KS240 when get_device_usage called
|
# PARAMS error returned for KS240 when get_device_usage called
|
||||||
# on parent device. Could be any error code though.
|
# on parent device. Could be any error code though.
|
||||||
|
@ -9,7 +9,7 @@ from pytest_mock import MockerFixture
|
|||||||
|
|
||||||
from kasa import Module
|
from kasa import Module
|
||||||
from kasa.smart import SmartDevice
|
from kasa.smart import SmartDevice
|
||||||
from kasa.tests.device_fixtures import parametrize
|
from kasa.tests.device_fixtures import get_parent_and_child_modules, parametrize
|
||||||
|
|
||||||
autooff = parametrize(
|
autooff = parametrize(
|
||||||
"has autooff", component_filter="auto_off", protocol_filter={"SMART"}
|
"has autooff", component_filter="auto_off", protocol_filter={"SMART"}
|
||||||
@ -33,13 +33,13 @@ async def test_autooff_features(
|
|||||||
dev: SmartDevice, feature: str, prop_name: str, type: type
|
dev: SmartDevice, feature: str, prop_name: str, type: type
|
||||||
):
|
):
|
||||||
"""Test that features are registered and work as expected."""
|
"""Test that features are registered and work as expected."""
|
||||||
autooff = dev.modules.get(Module.AutoOff)
|
autooff = next(get_parent_and_child_modules(dev, Module.AutoOff))
|
||||||
assert autooff is not None
|
assert autooff is not None
|
||||||
|
|
||||||
prop = getattr(autooff, prop_name)
|
prop = getattr(autooff, prop_name)
|
||||||
assert isinstance(prop, type)
|
assert isinstance(prop, type)
|
||||||
|
|
||||||
feat = dev.features[feature]
|
feat = autooff._device.features[feature]
|
||||||
assert feat.value == prop
|
assert feat.value == prop
|
||||||
assert isinstance(feat.value, type)
|
assert isinstance(feat.value, type)
|
||||||
|
|
||||||
@ -47,13 +47,13 @@ async def test_autooff_features(
|
|||||||
@autooff
|
@autooff
|
||||||
async def test_settings(dev: SmartDevice, mocker: MockerFixture):
|
async def test_settings(dev: SmartDevice, mocker: MockerFixture):
|
||||||
"""Test autooff settings."""
|
"""Test autooff settings."""
|
||||||
autooff = dev.modules.get(Module.AutoOff)
|
autooff = next(get_parent_and_child_modules(dev, Module.AutoOff))
|
||||||
assert autooff
|
assert autooff
|
||||||
|
|
||||||
enabled = dev.features["auto_off_enabled"]
|
enabled = autooff._device.features["auto_off_enabled"]
|
||||||
assert autooff.enabled == enabled.value
|
assert autooff.enabled == enabled.value
|
||||||
|
|
||||||
delay = dev.features["auto_off_minutes"]
|
delay = autooff._device.features["auto_off_minutes"]
|
||||||
assert autooff.delay == delay.value
|
assert autooff.delay == delay.value
|
||||||
|
|
||||||
call = mocker.spy(autooff, "call")
|
call = mocker.spy(autooff, "call")
|
||||||
@ -86,10 +86,10 @@ async def test_auto_off_at(
|
|||||||
dev: SmartDevice, mocker: MockerFixture, is_timer_active: bool
|
dev: SmartDevice, mocker: MockerFixture, is_timer_active: bool
|
||||||
):
|
):
|
||||||
"""Test auto-off at sensor."""
|
"""Test auto-off at sensor."""
|
||||||
autooff = dev.modules.get(Module.AutoOff)
|
autooff = next(get_parent_and_child_modules(dev, Module.AutoOff))
|
||||||
assert autooff
|
assert autooff
|
||||||
|
|
||||||
autooff_at = dev.features["auto_off_at"]
|
autooff_at = autooff._device.features["auto_off_at"]
|
||||||
|
|
||||||
mocker.patch.object(
|
mocker.patch.object(
|
||||||
type(autooff),
|
type(autooff),
|
||||||
|
@ -9,7 +9,7 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from kasa import KasaException, Module
|
from kasa import Device, KasaException, Module
|
||||||
from kasa.exceptions import SmartErrorCode
|
from kasa.exceptions import SmartErrorCode
|
||||||
from kasa.smart import SmartDevice
|
from kasa.smart import SmartDevice
|
||||||
|
|
||||||
@ -112,6 +112,11 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture):
|
|||||||
device_queries: dict[SmartDevice, dict[str, Any]] = {}
|
device_queries: dict[SmartDevice, dict[str, Any]] = {}
|
||||||
for mod in dev._modules.values():
|
for mod in dev._modules.values():
|
||||||
device_queries.setdefault(mod._device, {}).update(mod.query())
|
device_queries.setdefault(mod._device, {}).update(mod.query())
|
||||||
|
# Hubs do not query child modules by default.
|
||||||
|
if dev.device_type != Device.Type.Hub:
|
||||||
|
for child in dev.children:
|
||||||
|
for mod in child.modules.values():
|
||||||
|
device_queries.setdefault(mod._device, {}).update(mod.query())
|
||||||
|
|
||||||
spies = {}
|
spies = {}
|
||||||
for device in device_queries:
|
for device in device_queries:
|
||||||
@ -120,7 +125,8 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture):
|
|||||||
await dev.update()
|
await dev.update()
|
||||||
for device in device_queries:
|
for device in device_queries:
|
||||||
if device_queries[device]:
|
if device_queries[device]:
|
||||||
spies[device].assert_called_with(device_queries[device])
|
# Need assert any here because the child device updates use the parent's protocol
|
||||||
|
spies[device].assert_any_call(device_queries[device])
|
||||||
else:
|
else:
|
||||||
spies[device].assert_not_called()
|
spies[device].assert_not_called()
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user