mirror of
https://github.com/python-kasa/python-kasa.git
synced 2025-08-09 20:24:02 +00:00
Handle module errors more robustly and add query params to light preset and transition (#1036)
Ensures that all modules try to access their data in `_post_update_hook` in a safe manner and disable themselves if there's an error. Also adds parameters to get_preset_rules and get_on_off_gradually_info to fix issues with recent firmware updates. [#1033](https://github.com/python-kasa/python-kasa/issues/1033)
This commit is contained in:
@@ -19,12 +19,6 @@ class AutoOff(SmartModule):
|
||||
|
||||
def _initialize_features(self):
|
||||
"""Initialize features after the initial update."""
|
||||
if not isinstance(self.data, dict):
|
||||
_LOGGER.warning(
|
||||
"No data available for module, skipping %s: %s", self, self.data
|
||||
)
|
||||
return
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
|
@@ -43,6 +43,10 @@ class BatterySensor(SmartModule):
|
||||
)
|
||||
)
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
return {}
|
||||
|
||||
@property
|
||||
def battery(self):
|
||||
"""Return battery level."""
|
||||
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...exceptions import SmartErrorCode
|
||||
from ...feature import Feature
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
@@ -18,6 +17,13 @@ class Cloud(SmartModule):
|
||||
QUERY_GETTER_NAME = "get_connect_cloud_state"
|
||||
REQUIRED_COMPONENT = "cloud_connect"
|
||||
|
||||
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 the logic here is to treat that as not connected.
|
||||
"""
|
||||
|
||||
def __init__(self, device: SmartDevice, module: str):
|
||||
super().__init__(device, module)
|
||||
|
||||
@@ -37,6 +43,6 @@ class Cloud(SmartModule):
|
||||
@property
|
||||
def is_connected(self):
|
||||
"""Return True if device is connected to the cloud."""
|
||||
if isinstance(self.data, SmartErrorCode):
|
||||
if self._has_data_error():
|
||||
return False
|
||||
return self.data["status"] == 0
|
||||
|
@@ -10,6 +10,13 @@ class DeviceModule(SmartModule):
|
||||
|
||||
REQUIRED_COMPONENT = "device"
|
||||
|
||||
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 this module is critical.
|
||||
"""
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
query = {
|
||||
|
@@ -13,7 +13,6 @@ from typing import TYPE_CHECKING, Any, Callable, Optional
|
||||
from async_timeout import timeout as asyncio_timeout
|
||||
from pydantic.v1 import BaseModel, Field, validator
|
||||
|
||||
from ...exceptions import SmartErrorCode
|
||||
from ...feature import Feature
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
@@ -123,6 +122,13 @@ class Firmware(SmartModule):
|
||||
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."""
|
||||
@@ -136,11 +142,11 @@ class Firmware(SmartModule):
|
||||
@property
|
||||
def firmware_update_info(self):
|
||||
"""Return latest firmware information."""
|
||||
fw = self.data.get("get_latest_fw") or self.data
|
||||
if not self._device.is_cloud_connected or isinstance(fw, SmartErrorCode):
|
||||
if not self._device.is_cloud_connected or self._has_data_error():
|
||||
# Error in response, probably disconnected from the cloud.
|
||||
return UpdateInfo(type=0, need_to_upgrade=False)
|
||||
|
||||
fw = self.data.get("get_latest_fw") or self.data
|
||||
return UpdateInfo.parse_obj(fw)
|
||||
|
||||
@property
|
||||
|
@@ -14,6 +14,10 @@ class FrostProtection(SmartModule):
|
||||
REQUIRED_COMPONENT = "frost_protection"
|
||||
QUERY_GETTER_NAME = "get_frost_protection"
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
return {}
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Return True if frost protection is on."""
|
||||
|
@@ -45,6 +45,10 @@ class HumiditySensor(SmartModule):
|
||||
)
|
||||
)
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
return {}
|
||||
|
||||
@property
|
||||
def humidity(self):
|
||||
"""Return current humidity in percentage."""
|
||||
|
@@ -140,7 +140,7 @@ class LightPreset(SmartModule, LightPresetInterface):
|
||||
"""Query to execute during the update cycle."""
|
||||
if self._state_in_sysinfo: # Child lights can have states in the child info
|
||||
return {}
|
||||
return {self.QUERY_GETTER_NAME: None}
|
||||
return {self.QUERY_GETTER_NAME: {"start_index": 0}}
|
||||
|
||||
async def _check_supported(self):
|
||||
"""Additional check to see if the module is supported by the device.
|
||||
|
@@ -230,7 +230,7 @@ class LightTransition(SmartModule):
|
||||
if self._state_in_sysinfo:
|
||||
return {}
|
||||
else:
|
||||
return {self.QUERY_GETTER_NAME: None}
|
||||
return {self.QUERY_GETTER_NAME: {}}
|
||||
|
||||
async def _check_supported(self):
|
||||
"""Additional check to see if the module is supported by the device."""
|
||||
|
@@ -32,6 +32,10 @@ class ReportMode(SmartModule):
|
||||
)
|
||||
)
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
return {}
|
||||
|
||||
@property
|
||||
def report_interval(self):
|
||||
"""Reporting interval of a sensor device."""
|
||||
|
@@ -58,6 +58,10 @@ class TemperatureSensor(SmartModule):
|
||||
)
|
||||
)
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
return {}
|
||||
|
||||
@property
|
||||
def temperature(self):
|
||||
"""Return current humidity in percentage."""
|
||||
|
@@ -177,11 +177,20 @@ class SmartDevice(Device):
|
||||
self._children[info["device_id"]]._update_internal_state(info)
|
||||
|
||||
# Call handle update for modules that want to update internal data
|
||||
for module in self._modules.values():
|
||||
module._post_update_hook()
|
||||
errors = []
|
||||
for module_name, module in self._modules.items():
|
||||
if not self._handle_module_post_update_hook(module):
|
||||
errors.append(module_name)
|
||||
for error in errors:
|
||||
self._modules.pop(error)
|
||||
|
||||
for child in self._children.values():
|
||||
for child_module in child._modules.values():
|
||||
child_module._post_update_hook()
|
||||
errors = []
|
||||
for child_module_name, child_module in child._modules.items():
|
||||
if not self._handle_module_post_update_hook(child_module):
|
||||
errors.append(child_module_name)
|
||||
for error in errors:
|
||||
child._modules.pop(error)
|
||||
|
||||
# We can first initialize the features after the first update.
|
||||
# We make here an assumption that every device has at least a single feature.
|
||||
@@ -190,6 +199,19 @@ class SmartDevice(Device):
|
||||
|
||||
_LOGGER.debug("Got an update: %s", self._last_update)
|
||||
|
||||
def _handle_module_post_update_hook(self, module: SmartModule) -> bool:
|
||||
try:
|
||||
module._post_update_hook()
|
||||
return True
|
||||
except Exception as ex:
|
||||
_LOGGER.error(
|
||||
"Error processing %s for device %s, module will be unavailable: %s",
|
||||
module.name,
|
||||
self.host,
|
||||
ex,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _initialize_modules(self):
|
||||
"""Initialize modules based on component negotiation response."""
|
||||
from .smartmodule import SmartModule
|
||||
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..exceptions import KasaException
|
||||
from ..exceptions import DeviceError, KasaException, SmartErrorCode
|
||||
from ..module import Module
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -41,6 +41,14 @@ class SmartModule(Module):
|
||||
"""Name of the module."""
|
||||
return getattr(self, "NAME", self.__class__.__name__)
|
||||
|
||||
def _post_update_hook(self): # noqa: B027
|
||||
"""Perform actions after a device update.
|
||||
|
||||
Any modules overriding this should ensure that self.data is
|
||||
accessed unless the module should remain active despite errors.
|
||||
"""
|
||||
assert self.data # noqa: S101
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle.
|
||||
|
||||
@@ -87,6 +95,11 @@ class SmartModule(Module):
|
||||
|
||||
filtered_data = {k: v for k, v in dev._last_update.items() if k in q_keys}
|
||||
|
||||
for data_item in filtered_data:
|
||||
if isinstance(filtered_data[data_item], SmartErrorCode):
|
||||
raise DeviceError(
|
||||
f"{data_item} for {self.name}", error_code=filtered_data[data_item]
|
||||
)
|
||||
if len(filtered_data) == 1:
|
||||
return next(iter(filtered_data.values()))
|
||||
|
||||
@@ -110,3 +123,10 @@ class SmartModule(Module):
|
||||
color_temp_range but only supports one value.
|
||||
"""
|
||||
return True
|
||||
|
||||
def _has_data_error(self) -> bool:
|
||||
try:
|
||||
assert self.data # noqa: S101
|
||||
return False
|
||||
except DeviceError:
|
||||
return True
|
||||
|
Reference in New Issue
Block a user