Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions kasa/smart/modules/autooff.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,11 @@ def auto_off_at(self) -> datetime | None:
sysinfo = self._device.sys_info

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
13 changes: 12 additions & 1 deletion kasa/smart/smartchilddevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import logging
from typing import Any

from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
Expand Down Expand Up @@ -34,7 +35,17 @@ def __init__(
self.protocol = _ChildProtocolWrapper(self._id, parent.protocol)

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
async def create(cls, parent: SmartDevice, child_info, child_components):
Expand Down
14 changes: 9 additions & 5 deletions kasa/smart/smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ async def _negotiate(self):
if "child_device" in self._components and not self.children:
await self._initialize_children()

async def update(self, update_children: bool = True):
async def update(self, update_children: bool = False):
"""Update the device."""
if self.credentials is None and self.credentials_hash is None:
raise AuthenticationError("Tapo plug requires authentication.")
Expand All @@ -167,9 +167,14 @@ async def update(self, update_children: bool = True):
self._last_update = resp = await self.protocol.query(req)

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", {}):
# 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"]:
self._children[info["device_id"]]._update_internal_state(info)

Expand Down Expand Up @@ -352,8 +357,7 @@ def alias(self) -> str | None:
@property
def time(self) -> datetime:
"""Return the time."""
# TODO: Default to parent's time module for child devices
if self._parent and Module.Time in self.modules:
if self._parent and Module.Time in self._parent.modules:
_timemod = self._parent.modules[Module.Time]
else:
_timemod = self.modules[Module.Time]
Expand Down
20 changes: 19 additions & 1 deletion kasa/tests/fakeprotocol_smart.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ def _handle_control_child(self, params: dict):
if child["device_id"] == device_id:
info = child
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.
if child_method == "get_device_info":
Expand All @@ -159,14 +164,27 @@ def _handle_control_child(self, params: dict):
return {"error_code": 0}
elif child_method == "set_preset_rules":
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 (
# FIXTURE_MISSING is for service calls not in place when
# SMART fixtures started to be generated
missing_result := self.FIXTURE_MISSING_MAP.get(child_method)
) 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}
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:
# PARAMS error returned for KS240 when get_device_usage called
# on parent device. Could be any error code though.
Expand Down
16 changes: 8 additions & 8 deletions kasa/tests/smart/modules/test_autooff.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from kasa import Module
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(
"has autooff", component_filter="auto_off", protocol_filter={"SMART"}
Expand All @@ -33,27 +33,27 @@ async def test_autooff_features(
dev: SmartDevice, feature: str, prop_name: str, type: type
):
"""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

prop = getattr(autooff, prop_name)
assert isinstance(prop, type)

feat = dev.features[feature]
feat = autooff._device.features[feature]
assert feat.value == prop
assert isinstance(feat.value, type)


@autooff
async def test_settings(dev: SmartDevice, mocker: MockerFixture):
"""Test autooff settings."""
autooff = dev.modules.get(Module.AutoOff)
autooff = next(get_parent_and_child_modules(dev, Module.AutoOff))
assert autooff

enabled = dev.features["auto_off_enabled"]
enabled = autooff._device.features["auto_off_enabled"]
assert autooff.enabled == enabled.value

delay = dev.features["auto_off_minutes"]
delay = autooff._device.features["auto_off_minutes"]
assert autooff.delay == delay.value

call = mocker.spy(autooff, "call")
Expand Down Expand Up @@ -86,10 +86,10 @@ async def test_auto_off_at(
dev: SmartDevice, mocker: MockerFixture, is_timer_active: bool
):
"""Test auto-off at sensor."""
autooff = dev.modules.get(Module.AutoOff)
autooff = next(get_parent_and_child_modules(dev, Module.AutoOff))
assert autooff

autooff_at = dev.features["auto_off_at"]
autooff_at = autooff._device.features["auto_off_at"]

mocker.patch.object(
type(autooff),
Expand Down
10 changes: 8 additions & 2 deletions kasa/tests/test_smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import pytest
from pytest_mock import MockerFixture

from kasa import KasaException, Module
from kasa import Device, KasaException, Module
from kasa.exceptions import SmartErrorCode
from kasa.smart import SmartDevice

Expand Down Expand Up @@ -112,6 +112,11 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture):
device_queries: dict[SmartDevice, dict[str, Any]] = {}
for mod in dev._modules.values():
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 = {}
for device in device_queries:
Expand All @@ -120,7 +125,8 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture):
await dev.update()
for device in device_queries:
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:
spies[device].assert_not_called()

Expand Down