mirror of
https://github.com/python-kasa/python-kasa.git
synced 2025-12-02 06:08:17 +00:00
Merge remote-tracking branch 'upstream/master' into feat/light_module_feats
This commit is contained in:
@@ -16,6 +16,7 @@ from .energy import Energy
|
||||
from .fan import Fan
|
||||
from .firmware import Firmware
|
||||
from .frostprotection import FrostProtection
|
||||
from .homekit import HomeKit
|
||||
from .humiditysensor import HumiditySensor
|
||||
from .led import Led
|
||||
from .light import Light
|
||||
@@ -23,6 +24,7 @@ from .lighteffect import LightEffect
|
||||
from .lightpreset import LightPreset
|
||||
from .lightstripeffect import LightStripEffect
|
||||
from .lighttransition import LightTransition
|
||||
from .matter import Matter
|
||||
from .motionsensor import MotionSensor
|
||||
from .overheatprotection import OverheatProtection
|
||||
from .reportmode import ReportMode
|
||||
@@ -66,4 +68,6 @@ __all__ = [
|
||||
"Thermostat",
|
||||
"SmartLightEffect",
|
||||
"OverheatProtection",
|
||||
"HomeKit",
|
||||
"Matter",
|
||||
]
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NoReturn
|
||||
from typing import Any, NoReturn
|
||||
|
||||
from ...emeterstatus import EmeterStatus
|
||||
from ...exceptions import KasaException
|
||||
from ...exceptions import DeviceError, KasaException
|
||||
from ...interfaces.energy import Energy as EnergyInterface
|
||||
from ..smartmodule import SmartModule, raise_if_update_error
|
||||
|
||||
@@ -15,12 +15,39 @@ class Energy(SmartModule, EnergyInterface):
|
||||
|
||||
REQUIRED_COMPONENT = "energy_monitoring"
|
||||
|
||||
_energy: dict[str, Any]
|
||||
_current_consumption: float | None
|
||||
|
||||
async def _post_update_hook(self) -> None:
|
||||
if "voltage_mv" in self.data.get("get_emeter_data", {}):
|
||||
try:
|
||||
data = self.data
|
||||
except DeviceError as de:
|
||||
self._energy = {}
|
||||
self._current_consumption = None
|
||||
raise de
|
||||
|
||||
# If version is 1 then data is get_energy_usage
|
||||
self._energy = data.get("get_energy_usage", data)
|
||||
|
||||
if "voltage_mv" in data.get("get_emeter_data", {}):
|
||||
self._supported = (
|
||||
self._supported | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT
|
||||
)
|
||||
|
||||
if (power := self._energy.get("current_power")) is not None or (
|
||||
power := data.get("get_emeter_data", {}).get("power_mw")
|
||||
) is not None:
|
||||
self._current_consumption = power / 1_000
|
||||
# Fallback if get_energy_usage does not provide current_power,
|
||||
# which can happen on some newer devices (e.g. P304M).
|
||||
# This may not be valid scenario as it pre-dates trying get_emeter_data
|
||||
elif (
|
||||
power := self.data.get("get_current_power", {}).get("current_power")
|
||||
) is not None:
|
||||
self._current_consumption = power
|
||||
else:
|
||||
self._current_consumption = None
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
req = {
|
||||
@@ -33,28 +60,21 @@ class Energy(SmartModule, EnergyInterface):
|
||||
return req
|
||||
|
||||
@property
|
||||
@raise_if_update_error
|
||||
def current_consumption(self) -> float | None:
|
||||
"""Current power in watts."""
|
||||
if (power := self.energy.get("current_power")) is not None or (
|
||||
power := self.data.get("get_emeter_data", {}).get("power_mw")
|
||||
) is not None:
|
||||
return power / 1_000
|
||||
# Fallback if get_energy_usage does not provide current_power,
|
||||
# which can happen on some newer devices (e.g. P304M).
|
||||
elif (
|
||||
power := self.data.get("get_current_power", {}).get("current_power")
|
||||
) is not None:
|
||||
return power
|
||||
return None
|
||||
def optional_response_keys(self) -> list[str]:
|
||||
"""Return optional response keys for the module."""
|
||||
if self.supported_version > 1:
|
||||
return ["get_energy_usage"]
|
||||
return []
|
||||
|
||||
@property
|
||||
def current_consumption(self) -> float | None:
|
||||
"""Current power in watts."""
|
||||
return self._current_consumption
|
||||
|
||||
@property
|
||||
@raise_if_update_error
|
||||
def energy(self) -> dict:
|
||||
"""Return get_energy_usage results."""
|
||||
if en := self.data.get("get_energy_usage"):
|
||||
return en
|
||||
return self.data
|
||||
return self._energy
|
||||
|
||||
def _get_status_from_energy(self, energy: dict) -> EmeterStatus:
|
||||
return EmeterStatus(
|
||||
@@ -83,16 +103,18 @@ class Energy(SmartModule, EnergyInterface):
|
||||
return self._get_status_from_energy(res["get_energy_usage"])
|
||||
|
||||
@property
|
||||
@raise_if_update_error
|
||||
def consumption_this_month(self) -> float | None:
|
||||
"""Get the emeter value for this month in kWh."""
|
||||
return self.energy.get("month_energy", 0) / 1_000
|
||||
if (month := self.energy.get("month_energy")) is not None:
|
||||
return month / 1_000
|
||||
return None
|
||||
|
||||
@property
|
||||
@raise_if_update_error
|
||||
def consumption_today(self) -> float | None:
|
||||
"""Get the emeter value for today in kWh."""
|
||||
return self.energy.get("today_energy", 0) / 1_000
|
||||
if (today := self.energy.get("today_energy")) is not None:
|
||||
return today / 1_000
|
||||
return None
|
||||
|
||||
@property
|
||||
@raise_if_update_error
|
||||
|
||||
32
kasa/smart/modules/homekit.py
Normal file
32
kasa/smart/modules/homekit.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Implementation of homekit module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ...feature import Feature
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
|
||||
class HomeKit(SmartModule):
|
||||
"""Implementation of homekit module."""
|
||||
|
||||
QUERY_GETTER_NAME: str = "get_homekit_info"
|
||||
REQUIRED_COMPONENT = "homekit"
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features after the initial update."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="homekit_setup_code",
|
||||
name="Homekit setup code",
|
||||
container=self,
|
||||
attribute_getter=lambda x: x.info["mfi_setup_code"],
|
||||
type=Feature.Type.Sensor,
|
||||
category=Feature.Category.Debug,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def info(self) -> dict[str, str]:
|
||||
"""Homekit mfi setup info."""
|
||||
return self.data
|
||||
@@ -136,16 +136,17 @@ class Light(SmartModule, LightInterface):
|
||||
return self._light_state
|
||||
|
||||
async def _post_update_hook(self) -> None:
|
||||
if self._device.is_on is False:
|
||||
device = self._device
|
||||
if device.is_on is False:
|
||||
state = LightState(light_on=False)
|
||||
else:
|
||||
state = LightState(light_on=True)
|
||||
if Module.Brightness in self._device.modules:
|
||||
if Module.Brightness in device.modules:
|
||||
state.brightness = self.brightness
|
||||
if Module.Color in self._device.modules:
|
||||
if Module.Color in device.modules:
|
||||
hsv = self.hsv
|
||||
state.hue = hsv.hue
|
||||
state.saturation = hsv.saturation
|
||||
if Module.ColorTemperature in self._device.modules:
|
||||
if Module.ColorTemperature in device.modules:
|
||||
state.color_temp = self.color_temp
|
||||
self._light_state = state
|
||||
|
||||
43
kasa/smart/modules/matter.py
Normal file
43
kasa/smart/modules/matter.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Implementation of matter module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ...feature import Feature
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
|
||||
class Matter(SmartModule):
|
||||
"""Implementation of matter module."""
|
||||
|
||||
QUERY_GETTER_NAME: str = "get_matter_setup_info"
|
||||
REQUIRED_COMPONENT = "matter"
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features after the initial update."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="matter_setup_code",
|
||||
name="Matter setup code",
|
||||
container=self,
|
||||
attribute_getter=lambda x: x.info["setup_code"],
|
||||
type=Feature.Type.Sensor,
|
||||
category=Feature.Category.Debug,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="matter_setup_payload",
|
||||
name="Matter setup payload",
|
||||
container=self,
|
||||
attribute_getter=lambda x: x.info["setup_payload"],
|
||||
type=Feature.Type.Sensor,
|
||||
category=Feature.Category.Debug,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def info(self) -> dict[str, str]:
|
||||
"""Matter setup info."""
|
||||
return self.data
|
||||
@@ -6,10 +6,11 @@ import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..device import DeviceInfo
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper
|
||||
from .smartdevice import SmartDevice
|
||||
from .smartdevice import ComponentsRaw, SmartDevice
|
||||
from .smartmodule import SmartModule
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -37,7 +38,7 @@ class SmartChildDevice(SmartDevice):
|
||||
self,
|
||||
parent: SmartDevice,
|
||||
info: dict,
|
||||
component_info: dict,
|
||||
component_info_raw: ComponentsRaw,
|
||||
*,
|
||||
config: DeviceConfig | None = None,
|
||||
protocol: SmartProtocol | None = None,
|
||||
@@ -47,7 +48,24 @@ class SmartChildDevice(SmartDevice):
|
||||
super().__init__(parent.host, config=parent.config, protocol=_protocol)
|
||||
self._parent = parent
|
||||
self._update_internal_state(info)
|
||||
self._components = component_info
|
||||
self._components_raw = component_info_raw
|
||||
self._components = self._parse_components(self._components_raw)
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device info.
|
||||
|
||||
Child device does not have it info and components in _last_update so
|
||||
this overrides the base implementation to call _get_device_info with
|
||||
info and components combined as they would be in _last_update.
|
||||
"""
|
||||
return self._get_device_info(
|
||||
{
|
||||
"get_device_info": self._info,
|
||||
"component_nego": self._components_raw,
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
async def update(self, update_children: bool = True) -> None:
|
||||
"""Update child module info.
|
||||
@@ -84,7 +102,7 @@ class SmartChildDevice(SmartDevice):
|
||||
cls,
|
||||
parent: SmartDevice,
|
||||
child_info: dict,
|
||||
child_components: dict,
|
||||
child_components_raw: ComponentsRaw,
|
||||
protocol: SmartProtocol | None = None,
|
||||
*,
|
||||
last_update: dict | None = None,
|
||||
@@ -97,7 +115,7 @@ class SmartChildDevice(SmartDevice):
|
||||
derived from the parent.
|
||||
"""
|
||||
child: SmartChildDevice = cls(
|
||||
parent, child_info, child_components, protocol=protocol
|
||||
parent, child_info, child_components_raw, protocol=protocol
|
||||
)
|
||||
if last_update:
|
||||
child._last_update = last_update
|
||||
|
||||
@@ -7,9 +7,9 @@ import logging
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import UTC, datetime, timedelta, tzinfo
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import TYPE_CHECKING, Any, TypeAlias, cast
|
||||
|
||||
from ..device import Device, WifiNetwork, _DeviceInfo
|
||||
from ..device import Device, DeviceInfo, WifiNetwork
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
|
||||
@@ -40,6 +40,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
# same issue, homekit perhaps?
|
||||
NON_HUB_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud]
|
||||
|
||||
ComponentsRaw: TypeAlias = dict[str, list[dict[str, int | str]]]
|
||||
|
||||
|
||||
# Device must go last as the other interfaces also inherit Device
|
||||
# and python needs a consistent method resolution order.
|
||||
@@ -61,13 +63,12 @@ class SmartDevice(Device):
|
||||
)
|
||||
super().__init__(host=host, config=config, protocol=_protocol)
|
||||
self.protocol: SmartProtocol
|
||||
self._components_raw: dict[str, Any] | None = None
|
||||
self._components_raw: ComponentsRaw | None = None
|
||||
self._components: dict[str, int] = {}
|
||||
self._state_information: dict[str, Any] = {}
|
||||
self._modules: dict[str | ModuleName[Module], SmartModule] = {}
|
||||
self._parent: SmartDevice | None = None
|
||||
self._children: Mapping[str, SmartDevice] = {}
|
||||
self._last_update = {}
|
||||
self._last_update_time: float | None = None
|
||||
self._on_since: datetime | None = None
|
||||
self._info: dict[str, Any] = {}
|
||||
@@ -82,10 +83,8 @@ class SmartDevice(Device):
|
||||
self.internal_state.update(resp)
|
||||
|
||||
children = self.internal_state["get_child_device_list"]["child_device_list"]
|
||||
children_components = {
|
||||
child["device_id"]: {
|
||||
comp["id"]: int(comp["ver_code"]) for comp in child["component_list"]
|
||||
}
|
||||
children_components_raw = {
|
||||
child["device_id"]: child
|
||||
for child in self.internal_state["get_child_device_component_list"][
|
||||
"child_component_list"
|
||||
]
|
||||
@@ -96,7 +95,7 @@ class SmartDevice(Device):
|
||||
child_info["device_id"]: await SmartChildDevice.create(
|
||||
parent=self,
|
||||
child_info=child_info,
|
||||
child_components=children_components[child_info["device_id"]],
|
||||
child_components_raw=children_components_raw[child_info["device_id"]],
|
||||
)
|
||||
for child_info in children
|
||||
}
|
||||
@@ -131,6 +130,13 @@ class SmartDevice(Device):
|
||||
f"{request} not found in {responses} for device {self.host}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_components(components_raw: ComponentsRaw) -> dict[str, int]:
|
||||
return {
|
||||
str(comp["id"]): int(comp["ver_code"])
|
||||
for comp in components_raw["component_list"]
|
||||
}
|
||||
|
||||
async def _negotiate(self) -> None:
|
||||
"""Perform initialization.
|
||||
|
||||
@@ -151,12 +157,9 @@ class SmartDevice(Device):
|
||||
self._info = self._try_get_response(resp, "get_device_info")
|
||||
|
||||
# Create our internal presentation of available components
|
||||
self._components_raw = cast(dict, resp["component_nego"])
|
||||
self._components_raw = cast(ComponentsRaw, resp["component_nego"])
|
||||
|
||||
self._components = {
|
||||
comp["id"]: int(comp["ver_code"])
|
||||
for comp in self._components_raw["component_list"]
|
||||
}
|
||||
self._components = self._parse_components(self._components_raw)
|
||||
|
||||
if "child_device" in self._components and not self.children:
|
||||
await self._initialize_children()
|
||||
@@ -493,18 +496,13 @@ class SmartDevice(Device):
|
||||
@property
|
||||
def model(self) -> str:
|
||||
"""Returns the device model."""
|
||||
return str(self._info.get("model"))
|
||||
# If update hasn't been called self._device_info can't be used
|
||||
if self._last_update:
|
||||
return self.device_info.short_name
|
||||
|
||||
@property
|
||||
def _model_region(self) -> str:
|
||||
"""Return device full model name and region."""
|
||||
if (disco := self._discovery_info) and (
|
||||
disco_model := disco.get("device_model")
|
||||
):
|
||||
return disco_model
|
||||
# Some devices have the region in the specs element.
|
||||
region = f"({specs})" if (specs := self._info.get("specs")) else ""
|
||||
return f"{self.model}{region}"
|
||||
disco_model = str(self._info.get("device_model"))
|
||||
long_name, _, _ = disco_model.partition("(")
|
||||
return long_name
|
||||
|
||||
@property
|
||||
def alias(self) -> str | None:
|
||||
@@ -804,7 +802,7 @@ class SmartDevice(Device):
|
||||
@staticmethod
|
||||
def _get_device_info(
|
||||
info: dict[str, Any], discovery_info: dict[str, Any] | None
|
||||
) -> _DeviceInfo:
|
||||
) -> DeviceInfo:
|
||||
"""Get model information for a device."""
|
||||
di = info["get_device_info"]
|
||||
components = [comp["id"] for comp in info["component_nego"]["component_list"]]
|
||||
@@ -833,7 +831,7 @@ class SmartDevice(Device):
|
||||
# Brand inferred from SMART.KASAPLUG/SMART.TAPOPLUG etc.
|
||||
brand = devicetype[:4].lower()
|
||||
|
||||
return _DeviceInfo(
|
||||
return DeviceInfo(
|
||||
short_name=short_name,
|
||||
long_name=long_name,
|
||||
brand=brand,
|
||||
|
||||
@@ -57,7 +57,7 @@ class SmartModule(Module):
|
||||
#: Module is initialized, if any of the given keys exists in the sysinfo
|
||||
SYSINFO_LOOKUP_KEYS: list[str] = []
|
||||
#: Query to execute during the main update cycle
|
||||
QUERY_GETTER_NAME: str
|
||||
QUERY_GETTER_NAME: str = ""
|
||||
|
||||
REGISTERED_MODULES: dict[str, type[SmartModule]] = {}
|
||||
|
||||
@@ -72,6 +72,7 @@ class SmartModule(Module):
|
||||
self._last_update_time: float | None = None
|
||||
self._last_update_error: KasaException | None = None
|
||||
self._error_count = 0
|
||||
self._logged_remove_keys: list[str] = []
|
||||
|
||||
def __init_subclass__(cls, **kwargs) -> None:
|
||||
# We only want to register submodules in a modules package so that
|
||||
@@ -138,7 +139,9 @@ class SmartModule(Module):
|
||||
|
||||
Default implementation uses the raw query getter w/o parameters.
|
||||
"""
|
||||
return {self.QUERY_GETTER_NAME: None}
|
||||
if self.QUERY_GETTER_NAME:
|
||||
return {self.QUERY_GETTER_NAME: None}
|
||||
return {}
|
||||
|
||||
async def call(self, method: str, params: dict | None = None) -> dict:
|
||||
"""Call a method.
|
||||
@@ -147,6 +150,15 @@ class SmartModule(Module):
|
||||
"""
|
||||
return await self._device._query_helper(method, params)
|
||||
|
||||
@property
|
||||
def optional_response_keys(self) -> list[str]:
|
||||
"""Return optional response keys for the module.
|
||||
|
||||
Defaults to no keys. Overriding this and providing keys will remove
|
||||
instead of raise on error.
|
||||
"""
|
||||
return []
|
||||
|
||||
@property
|
||||
def data(self) -> dict[str, Any]:
|
||||
"""Return response data for the module.
|
||||
@@ -179,12 +191,31 @@ class SmartModule(Module):
|
||||
|
||||
filtered_data = {k: v for k, v in dev._last_update.items() if k in q_keys}
|
||||
|
||||
remove_keys: list[str] = []
|
||||
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 data_item in self.optional_response_keys:
|
||||
remove_keys.append(data_item)
|
||||
else:
|
||||
raise DeviceError(
|
||||
f"{data_item} for {self.name}",
|
||||
error_code=filtered_data[data_item],
|
||||
)
|
||||
|
||||
for key in remove_keys:
|
||||
if key not in self._logged_remove_keys:
|
||||
self._logged_remove_keys.append(key)
|
||||
_LOGGER.debug(
|
||||
"Removed key %s from response for device %s as it returned "
|
||||
"error: %s. This message will only be logged once per key.",
|
||||
key,
|
||||
self._device.host,
|
||||
filtered_data[key],
|
||||
)
|
||||
if len(filtered_data) == 1:
|
||||
|
||||
filtered_data.pop(key)
|
||||
|
||||
if len(filtered_data) == 1 and not remove_keys:
|
||||
return next(iter(filtered_data.values()))
|
||||
|
||||
return filtered_data
|
||||
|
||||
Reference in New Issue
Block a user