Skip to content

Commit

Permalink
Defer module updates for less volatile modules (python-kasa#1052)
Browse files Browse the repository at this point in the history
Addresses stability issues on older hw device versions

 - Handles module timeout errors better by querying modules individually on errors and disabling problematic modules like Firmware that go out to the internet to get updates.
- Addresses an issue with the Led module on P100 hardware version 1.0 which appears to have a memory leak and will cause the device to crash after approximately 500 calls.
- Delays updates of modules that do not have regular changes like LightPreset and LightEffect and enables them to be updated on the next update cycle only if required values have changed.
  • Loading branch information
sdb9696 authored Jul 11, 2024
1 parent a044063 commit 7fd5c21
Show file tree
Hide file tree
Showing 16 changed files with 364 additions and 56 deletions.
14 changes: 12 additions & 2 deletions kasa/aestransport.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None:
try:
error_code = SmartErrorCode.from_int(error_code_raw)
except ValueError:
_LOGGER.warning("Received unknown error code: %s", error_code_raw)
_LOGGER.warning(
"Device %s received unknown error code: %s", self._host, error_code_raw
)
error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR
if error_code is SmartErrorCode.SUCCESS:
return
Expand Down Expand Up @@ -216,18 +218,26 @@ async def perform_login(self):
"""Login to the device."""
try:
await self.try_login(self._login_params)
_LOGGER.debug(
"%s: logged in with provided credentials",
self._host,
)
except AuthenticationError as aex:
try:
if aex.error_code is not SmartErrorCode.LOGIN_ERROR:
raise aex
_LOGGER.debug(
"%s: trying login with default TAPO credentials",
self._host,
)
if self._default_credentials is None:
self._default_credentials = get_default_credentials(
DEFAULT_CREDENTIALS["TAPO"]
)
await self.perform_handshake()
await self.try_login(self._get_login_params(self._default_credentials))
_LOGGER.debug(
"%s: logged in with default credentials",
"%s: logged in with default TAPO credentials",
self._host,
)
except (AuthenticationError, _ConnectionError, TimeoutError):
Expand Down
2 changes: 2 additions & 0 deletions kasa/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ def from_int(value: int) -> SmartErrorCode:

# Library internal for unknown error codes
INTERNAL_UNKNOWN_ERROR = -100_000
# Library internal for query errors
INTERNAL_QUERY_ERROR = -100_001


SMART_RETRYABLE_ERRORS = [
Expand Down
23 changes: 19 additions & 4 deletions kasa/httpclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,21 @@ async def post(
now = time.time()
gap = now - self._last_request_time
if gap < self._wait_between_requests:
await asyncio.sleep(self._wait_between_requests - gap)
sleep = self._wait_between_requests - gap
_LOGGER.debug(
"Device %s waiting %s seconds to send request",
self._config.host,
sleep,
)
await asyncio.sleep(sleep)

_LOGGER.debug("Posting to %s", url)
response_data = None
self._last_url = url
self.client.cookie_jar.clear()
return_json = bool(json)
client_timeout = aiohttp.ClientTimeout(total=self._config.timeout)

# If json is not a dict send as data.
# This allows the json parameter to be used to pass other
# types of data such as async_generator and still have json
Expand All @@ -95,9 +103,10 @@ async def post(
params=params,
data=data,
json=json,
timeout=self._config.timeout,
timeout=client_timeout,
cookies=cookies_dict,
headers=headers,
ssl=False,
)
async with resp:
if resp.status == 200:
Expand All @@ -106,9 +115,15 @@ async def post(
response_data = json_loads(response_data.decode())

except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex:
if isinstance(ex, aiohttp.ClientOSError):
if not self._wait_between_requests:
_LOGGER.debug(
"Device %s received an os error, "
"enabling sequential request delay: %s",
self._config.host,
ex,
)
self._wait_between_requests = self.WAIT_BETWEEN_REQUESTS_ON_OSERROR
self._last_request_time = time.time()
self._last_request_time = time.time()
raise _ConnectionError(
f"Device connection error: {self._config.host}: {ex}", ex
) from ex
Expand Down
1 change: 1 addition & 0 deletions kasa/smart/modules/cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class Cloud(SmartModule):

QUERY_GETTER_NAME = "get_connect_cloud_state"
REQUIRED_COMPONENT = "cloud_connect"
MINIMUM_UPDATE_INTERVAL_SECS = 60

def _post_update_hook(self):
"""Perform actions after a device update.
Expand Down
12 changes: 4 additions & 8 deletions kasa/smart/modules/firmware.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from pydantic.v1 import BaseModel, Field, validator

from ...feature import Feature
from ..smartmodule import SmartModule
from ..smartmodule import SmartModule, allow_update_after

if TYPE_CHECKING:
from ..smartdevice import SmartDevice
Expand Down Expand Up @@ -66,6 +66,7 @@ class Firmware(SmartModule):
"""Implementation of firmware module."""

REQUIRED_COMPONENT = "firmware"
MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24

def __init__(self, device: SmartDevice, module: str):
super().__init__(device, module)
Expand Down Expand Up @@ -122,13 +123,6 @@ def query(self) -> dict:
req["get_auto_update_info"] = None
return req

def _post_update_hook(self):
"""Perform actions after a device update.
Overrides the default behaviour to disable a module if the query returns
an error because some of the module still functions.
"""

@property
def current_firmware(self) -> str:
"""Return the current firmware version."""
Expand Down Expand Up @@ -162,6 +156,7 @@ async def get_update_state(self) -> DownloadState:
state = resp["get_fw_download_state"]
return DownloadState(**state)

@allow_update_after
async def update(
self, progress_cb: Callable[[DownloadState], Coroutine] | None = None
):
Expand Down Expand Up @@ -219,6 +214,7 @@ def auto_update_enabled(self):
and self.data["get_auto_update_info"]["enable"]
)

@allow_update_after
async def set_auto_update_enabled(self, enabled: bool):
"""Change autoupdate setting."""
data = {**self.data["get_auto_update_info"], "enable": enabled}
Expand Down
5 changes: 4 additions & 1 deletion kasa/smart/modules/led.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@
from __future__ import annotations

from ...interfaces.led import Led as LedInterface
from ..smartmodule import SmartModule
from ..smartmodule import SmartModule, allow_update_after


class Led(SmartModule, LedInterface):
"""Implementation of led controls."""

REQUIRED_COMPONENT = "led"
QUERY_GETTER_NAME = "get_led_info"
# Led queries can cause device to crash on P100
MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60

def query(self) -> dict:
"""Query to execute during the update cycle."""
Expand All @@ -29,6 +31,7 @@ def led(self):
"""Return current led status."""
return self.data["led_rule"] != "never"

@allow_update_after
async def set_led(self, enable: bool):
"""Set led.
Expand Down
5 changes: 4 additions & 1 deletion kasa/smart/modules/lighteffect.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@
from typing import Any

from ..effects import SmartLightEffect
from ..smartmodule import Module, SmartModule
from ..smartmodule import Module, SmartModule, allow_update_after


class LightEffect(SmartModule, SmartLightEffect):
"""Implementation of dynamic light effects."""

REQUIRED_COMPONENT = "light_effect"
QUERY_GETTER_NAME = "get_dynamic_light_effect_rules"
MINIMUM_UPDATE_INTERVAL_SECS = 60
AVAILABLE_BULB_EFFECTS = {
"L1": "Party",
"L2": "Relax",
Expand Down Expand Up @@ -130,6 +131,7 @@ def brightness(self) -> int:

return brightness

@allow_update_after
async def set_brightness(
self,
brightness: int,
Expand All @@ -156,6 +158,7 @@ def _replace_brightness(data, new_brightness):

return await self.call("edit_dynamic_light_effect_rule", new_effect)

@allow_update_after
async def set_custom_effect(
self,
effect_dict: dict,
Expand Down
4 changes: 3 additions & 1 deletion kasa/smart/modules/lightpreset.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from ...interfaces import LightPreset as LightPresetInterface
from ...interfaces import LightState
from ..smartmodule import SmartModule
from ..smartmodule import SmartModule, allow_update_after

if TYPE_CHECKING:
from ..smartdevice import SmartDevice
Expand All @@ -22,6 +22,7 @@ class LightPreset(SmartModule, LightPresetInterface):

REQUIRED_COMPONENT = "preset"
QUERY_GETTER_NAME = "get_preset_rules"
MINIMUM_UPDATE_INTERVAL_SECS = 60

SYS_INFO_STATE_KEY = "preset_state"

Expand Down Expand Up @@ -124,6 +125,7 @@ async def set_preset(
raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}")
await self._device.modules[SmartModule.Light].set_state(preset)

@allow_update_after
async def save_preset(
self,
preset_name: str,
Expand Down
4 changes: 3 additions & 1 deletion kasa/smart/modules/lightstripeffect.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from typing import TYPE_CHECKING

from ..effects import EFFECT_MAPPING, EFFECT_NAMES, SmartLightEffect
from ..smartmodule import Module, SmartModule
from ..smartmodule import Module, SmartModule, allow_update_after

if TYPE_CHECKING:
from ..smartdevice import SmartDevice
Expand Down Expand Up @@ -84,6 +84,7 @@ def effect_list(self) -> list[str]:
"""
return self._effect_list

@allow_update_after
async def set_effect(
self,
effect: str,
Expand Down Expand Up @@ -126,6 +127,7 @@ async def set_effect(

await self.set_custom_effect(effect_dict)

@allow_update_after
async def set_custom_effect(
self,
effect_dict: dict,
Expand Down
6 changes: 5 additions & 1 deletion kasa/smart/modules/lighttransition.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from ...exceptions import KasaException
from ...feature import Feature
from ..smartmodule import SmartModule
from ..smartmodule import SmartModule, allow_update_after

if TYPE_CHECKING:
from ..smartdevice import SmartDevice
Expand All @@ -23,6 +23,7 @@ class LightTransition(SmartModule):

REQUIRED_COMPONENT = "on_off_gradually"
QUERY_GETTER_NAME = "get_on_off_gradually_info"
MINIMUM_UPDATE_INTERVAL_SECS = 60
MAXIMUM_DURATION = 60

# Key in sysinfo that indicates state can be retrieved from there.
Expand Down Expand Up @@ -136,6 +137,7 @@ def _post_update_hook(self) -> None:
"max_duration": off_max,
}

@allow_update_after
async def set_enabled(self, enable: bool):
"""Enable gradual on/off."""
if not self._supports_on_and_off:
Expand Down Expand Up @@ -168,6 +170,7 @@ def _turn_on_transition_max(self) -> int:
# v3 added max_duration, we default to 60 when it's not available
return self._on_state["max_duration"]

@allow_update_after
async def set_turn_on_transition(self, seconds: int):
"""Set turn on transition in seconds.
Expand Down Expand Up @@ -203,6 +206,7 @@ def _turn_off_transition_max(self) -> int:
# v3 added max_duration, we default to 60 when it's not available
return self._off_state["max_duration"]

@allow_update_after
async def set_turn_off_transition(self, seconds: int):
"""Set turn on transition in seconds.
Expand Down
2 changes: 2 additions & 0 deletions 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
import time
from typing import Any

from ..device_type import DeviceType
Expand Down Expand Up @@ -54,6 +55,7 @@ async def _update(self, update_children: bool = True):
req.update(mod_query)
if req:
self._last_update = await self.protocol.query(req)
self._last_update_time = time.time()

@classmethod
async def create(cls, parent: SmartDevice, child_info, child_components):
Expand Down
Loading

0 comments on commit 7fd5c21

Please sign in to comment.