Add common energy module and deprecate device emeter attributes (#976)

Consolidates logic for energy monitoring across smart and iot devices.
Deprecates emeter attributes in favour of common names.
This commit is contained in:
Steven B 2024-06-17 11:22:05 +01:00 committed by GitHub
parent 51a972542f
commit b4a6df2b5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 487 additions and 382 deletions

View File

@ -19,7 +19,6 @@ from .deviceconfig import (
DeviceEncryptionType,
DeviceFamily,
)
from .emeterstatus import EmeterStatus
from .exceptions import KasaException
from .feature import Feature
from .iotprotocol import IotProtocol
@ -323,27 +322,6 @@ class Device(ABC):
def on_since(self) -> datetime | None:
"""Return the time that the device was turned on or None if turned off."""
@abstractmethod
async def get_emeter_realtime(self) -> EmeterStatus:
"""Retrieve current energy readings."""
@property
@abstractmethod
def emeter_realtime(self) -> EmeterStatus:
"""Get the emeter status."""
@property
@abstractmethod
def emeter_this_month(self) -> float | None:
"""Get the emeter value for this month."""
@property
@abstractmethod
def emeter_today(self) -> float | None | Any:
"""Get the emeter value for today."""
# Return type of Any ensures consumers being shielded from the return
# type by @update_required are not affected.
@abstractmethod
async def wifi_scan(self) -> list[WifiNetwork]:
"""Scan for available wifi networks."""
@ -373,12 +351,15 @@ class Device(ABC):
}
def _get_replacing_attr(self, module_name: ModuleName, *attrs):
if module_name not in self.modules:
# If module name is None check self
if not module_name:
check = self
elif (check := self.modules.get(module_name)) is None:
return None
for attr in attrs:
if hasattr(self.modules[module_name], attr):
return getattr(self.modules[module_name], attr)
if hasattr(check, attr):
return attr
return None
@ -411,6 +392,16 @@ class Device(ABC):
# light preset attributes
"presets": (Module.LightPreset, ["_deprecated_presets", "preset_states_list"]),
"save_preset": (Module.LightPreset, ["_deprecated_save_preset"]),
# Emeter attribues
"get_emeter_realtime": (Module.Energy, ["get_status"]),
"emeter_realtime": (Module.Energy, ["status"]),
"emeter_today": (Module.Energy, ["consumption_today"]),
"emeter_this_month": (Module.Energy, ["consumption_this_month"]),
"current_consumption": (Module.Energy, ["current_consumption"]),
"get_emeter_daily": (Module.Energy, ["get_daily_stats"]),
"get_emeter_monthly": (Module.Energy, ["get_monthly_stats"]),
# Other attributes
"supported_modules": (None, ["modules"]),
}
def __getattr__(self, name):
@ -427,11 +418,10 @@ class Device(ABC):
(replacing_attr := self._get_replacing_attr(dep_attr[0], *dep_attr[1]))
is not None
):
module_name = dep_attr[0]
msg = (
f"{name} is deprecated, use: "
+ f"Module.{module_name} in device.modules instead"
)
mod = dep_attr[0]
dev_or_mod = self.modules[mod] if mod else self
replacing = f"Module.{mod} in device.modules" if mod else replacing_attr
msg = f"{name} is deprecated, use: {replacing} instead"
warn(msg, DeprecationWarning, stacklevel=1)
return replacing_attr
return getattr(dev_or_mod, replacing_attr)
raise AttributeError(f"Device has no attribute {name!r}")

View File

@ -1,5 +1,6 @@
"""Package for interfaces."""
from .energy import Energy
from .fan import Fan
from .led import Led
from .light import Light, LightState
@ -8,6 +9,7 @@ from .lightpreset import LightPreset
__all__ = [
"Fan",
"Energy",
"Led",
"Light",
"LightEffect",

181
kasa/interfaces/energy.py Normal file
View File

@ -0,0 +1,181 @@
"""Module for base energy module."""
from __future__ import annotations
from abc import ABC, abstractmethod
from enum import IntFlag, auto
from warnings import warn
from ..emeterstatus import EmeterStatus
from ..feature import Feature
from ..module import Module
class Energy(Module, ABC):
"""Base interface to represent an Energy module."""
class ModuleFeature(IntFlag):
"""Features supported by the device."""
#: Device reports :attr:`voltage` and :attr:`current`
VOLTAGE_CURRENT = auto()
#: Device reports :attr:`consumption_total`
CONSUMPTION_TOTAL = auto()
#: Device reports periodic stats via :meth:`get_daily_stats`
#: and :meth:`get_monthly_stats`
PERIODIC_STATS = auto()
_supported: ModuleFeature = ModuleFeature(0)
def supports(self, module_feature: ModuleFeature) -> bool:
"""Return True if module supports the feature."""
return module_feature in self._supported
def _initialize_features(self):
"""Initialize features."""
device = self._device
self._add_feature(
Feature(
device,
name="Current consumption",
attribute_getter="current_consumption",
container=self,
unit="W",
id="current_consumption",
precision_hint=1,
category=Feature.Category.Primary,
)
)
self._add_feature(
Feature(
device,
name="Today's consumption",
attribute_getter="consumption_today",
container=self,
unit="kWh",
id="consumption_today",
precision_hint=3,
category=Feature.Category.Info,
)
)
self._add_feature(
Feature(
device,
id="consumption_this_month",
name="This month's consumption",
attribute_getter="consumption_this_month",
container=self,
unit="kWh",
precision_hint=3,
category=Feature.Category.Info,
)
)
if self.supports(self.ModuleFeature.CONSUMPTION_TOTAL):
self._add_feature(
Feature(
device,
name="Total consumption since reboot",
attribute_getter="consumption_total",
container=self,
unit="kWh",
id="consumption_total",
precision_hint=3,
category=Feature.Category.Info,
)
)
if self.supports(self.ModuleFeature.VOLTAGE_CURRENT):
self._add_feature(
Feature(
device,
name="Voltage",
attribute_getter="voltage",
container=self,
unit="V",
id="voltage",
precision_hint=1,
category=Feature.Category.Primary,
)
)
self._add_feature(
Feature(
device,
name="Current",
attribute_getter="current",
container=self,
unit="A",
id="current",
precision_hint=2,
category=Feature.Category.Primary,
)
)
@property
@abstractmethod
def status(self) -> EmeterStatus:
"""Return current energy readings."""
@property
@abstractmethod
def current_consumption(self) -> float | None:
"""Get the current power consumption in Watt."""
@property
@abstractmethod
def consumption_today(self) -> float | None:
"""Return today's energy consumption in kWh."""
@property
@abstractmethod
def consumption_this_month(self) -> float | None:
"""Return this month's energy consumption in kWh."""
@property
@abstractmethod
def consumption_total(self) -> float | None:
"""Return total consumption since last reboot in kWh."""
@property
@abstractmethod
def current(self) -> float | None:
"""Return the current in A."""
@property
@abstractmethod
def voltage(self) -> float | None:
"""Get the current voltage in V."""
@abstractmethod
async def get_status(self):
"""Return real-time statistics."""
@abstractmethod
async def erase_stats(self):
"""Erase all stats."""
@abstractmethod
async def get_daily_stats(self, *, year=None, month=None, kwh=True) -> dict:
"""Return daily stats for the given year & month.
The return value is a dictionary of {day: energy, ...}.
"""
@abstractmethod
async def get_monthly_stats(self, *, year=None, kwh=True) -> dict:
"""Return monthly stats for the given year."""
_deprecated_attributes = {
"emeter_today": "consumption_today",
"emeter_this_month": "consumption_this_month",
"realtime": "status",
"get_realtime": "get_status",
"erase_emeter_stats": "erase_stats",
"get_daystat": "get_daily_stats",
"get_monthstat": "get_monthly_stats",
}
def __getattr__(self, name):
if attr := self._deprecated_attributes.get(name):
msg = f"{name} is deprecated, use {attr} instead"
warn(msg, DeprecationWarning, stacklevel=1)
return getattr(self, attr)
raise AttributeError(f"Energy module has no attribute {name!r}")

View File

@ -220,7 +220,7 @@ class IotBulb(IotDevice):
Module.IotAntitheft, Antitheft(self, "smartlife.iot.common.anti_theft")
)
self.add_module(Module.IotTime, Time(self, "smartlife.iot.common.timesetting"))
self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type))
self.add_module(Module.Energy, Emeter(self, self.emeter_type))
self.add_module(Module.IotCountdown, Countdown(self, "countdown"))
self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud"))
self.add_module(Module.Light, Light(self, self.LIGHT_SERVICE))

View File

@ -23,7 +23,6 @@ from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast
from ..device import Device, WifiNetwork
from ..deviceconfig import DeviceConfig
from ..emeterstatus import EmeterStatus
from ..exceptions import KasaException
from ..feature import Feature
from ..module import Module
@ -188,7 +187,7 @@ class IotDevice(Device):
super().__init__(host=host, config=config, protocol=protocol)
self._sys_info: Any = None # TODO: this is here to avoid changing tests
self._supported_modules: dict[str, IotModule] | None = None
self._supported_modules: dict[str | ModuleName[Module], IotModule] | None = None
self._legacy_features: set[str] = set()
self._children: Mapping[str, IotDevice] = {}
self._modules: dict[str | ModuleName[Module], IotModule] = {}
@ -199,15 +198,16 @@ class IotDevice(Device):
return list(self._children.values())
@property
@requires_update
def modules(self) -> ModuleMapping[IotModule]:
"""Return the device modules."""
if TYPE_CHECKING:
return cast(ModuleMapping[IotModule], self._modules)
return self._modules
return cast(ModuleMapping[IotModule], self._supported_modules)
return self._supported_modules
def add_module(self, name: str | ModuleName[Module], module: IotModule):
"""Register a module."""
if name in self.modules:
if name in self._modules:
_LOGGER.debug("Module %s already registered, ignoring..." % name)
return
@ -272,14 +272,6 @@ class IotDevice(Device):
"""Return a set of features that the device supports."""
return self._features
@property # type: ignore
@requires_update
def supported_modules(self) -> list[str | ModuleName[Module]]:
"""Return a set of modules supported by the device."""
# TODO: this should rather be called `features`, but we don't want to break
# the API now. Maybe just deprecate it and point the users to use this?
return list(self._modules.keys())
@property # type: ignore
@requires_update
def has_emeter(self) -> bool:
@ -321,6 +313,11 @@ class IotDevice(Device):
async def _initialize_modules(self):
"""Initialize modules not added in init."""
if self.has_emeter:
_LOGGER.debug(
"The device has emeter, querying its information along sysinfo"
)
self.add_module(Module.Energy, Emeter(self, self.emeter_type))
async def _initialize_features(self):
"""Initialize common features."""
@ -357,29 +354,13 @@ class IotDevice(Device):
)
)
for module in self._modules.values():
for module in self._supported_modules.values():
module._initialize_features()
for module_feat in module._module_features.values():
self._add_feature(module_feat)
async def _modular_update(self, req: dict) -> None:
"""Execute an update query."""
if self.has_emeter:
_LOGGER.debug(
"The device has emeter, querying its information along sysinfo"
)
self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type))
# TODO: perhaps modules should not have unsupported modules,
# making separate handling for this unnecessary
if self._supported_modules is None:
supported = {}
for module in self._modules.values():
if module.is_supported:
supported[module._module] = module
self._supported_modules = supported
request_list = []
est_response_size = 1024 if "system" in req else 0
for module in self._modules.values():
@ -411,6 +392,15 @@ class IotDevice(Device):
update = {**update, **response}
self._last_update = update
# IOT modules are added as default but could be unsupported post first update
if self._supported_modules is None:
supported = {}
for module_name, module in self._modules.items():
if module.is_supported:
supported[module_name] = module
self._supported_modules = supported
def update_from_discover_info(self, info: dict[str, Any]) -> None:
"""Update state from info from the discover call."""
self._discovery_info = info
@ -557,74 +547,6 @@ class IotDevice(Device):
"""
return await self._query_helper("system", "set_mac_addr", {"mac": mac})
@property
@requires_update
def emeter_realtime(self) -> EmeterStatus:
"""Return current energy readings."""
self._verify_emeter()
return EmeterStatus(self.modules[Module.IotEmeter].realtime)
async def get_emeter_realtime(self) -> EmeterStatus:
"""Retrieve current energy readings."""
self._verify_emeter()
return EmeterStatus(await self.modules[Module.IotEmeter].get_realtime())
@property
@requires_update
def emeter_today(self) -> float | None:
"""Return today's energy consumption in kWh."""
self._verify_emeter()
return self.modules[Module.IotEmeter].emeter_today
@property
@requires_update
def emeter_this_month(self) -> float | None:
"""Return this month's energy consumption in kWh."""
self._verify_emeter()
return self.modules[Module.IotEmeter].emeter_this_month
async def get_emeter_daily(
self, year: int | None = None, month: int | None = None, kwh: bool = True
) -> dict:
"""Retrieve daily statistics for a given month.
:param year: year for which to retrieve statistics (default: this year)
:param month: month for which to retrieve statistics (default: this
month)
:param kwh: return usage in kWh (default: True)
:return: mapping of day of month to value
"""
self._verify_emeter()
return await self.modules[Module.IotEmeter].get_daystat(
year=year, month=month, kwh=kwh
)
@requires_update
async def get_emeter_monthly(
self, year: int | None = None, kwh: bool = True
) -> dict:
"""Retrieve monthly statistics for a given year.
:param year: year for which to retrieve statistics (default: this year)
:param kwh: return usage in kWh (default: True)
:return: dict: mapping of month to value
"""
self._verify_emeter()
return await self.modules[Module.IotEmeter].get_monthstat(year=year, kwh=kwh)
@requires_update
async def erase_emeter_stats(self) -> dict:
"""Erase energy meter statistics."""
self._verify_emeter()
return await self.modules[Module.IotEmeter].erase_stats()
@requires_update
async def current_consumption(self) -> float:
"""Get the current power consumption in Watt."""
self._verify_emeter()
response = self.emeter_realtime
return float(response["power"])
async def reboot(self, delay: int = 1) -> None:
"""Reboot the device.

View File

@ -9,16 +9,17 @@ from typing import Any
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..emeterstatus import EmeterStatus
from ..exceptions import KasaException
from ..feature import Feature
from ..interfaces import Energy
from ..module import Module
from ..protocol import BaseProtocol
from .iotdevice import (
EmeterStatus,
IotDevice,
merge,
requires_update,
)
from .iotmodule import IotModule
from .iotplug import IotPlug
from .modules import Antitheft, Countdown, Schedule, Time, Usage
@ -97,11 +98,20 @@ class IotStrip(IotDevice):
super().__init__(host=host, config=config, protocol=protocol)
self.emeter_type = "emeter"
self._device_type = DeviceType.Strip
async def _initialize_modules(self):
"""Initialize modules."""
# Strip has different modules to plug so do not call super
self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft"))
self.add_module(Module.IotSchedule, Schedule(self, "schedule"))
self.add_module(Module.IotUsage, Usage(self, "schedule"))
self.add_module(Module.IotTime, Time(self, "time"))
self.add_module(Module.IotCountdown, Countdown(self, "countdown"))
if self.has_emeter:
_LOGGER.debug(
"The device has emeter, querying its information along sysinfo"
)
self.add_module(Module.Energy, StripEmeter(self, self.emeter_type))
@property # type: ignore
@requires_update
@ -114,10 +124,12 @@ class IotStrip(IotDevice):
Needed for methods that are decorated with `requires_update`.
"""
# Super initializes modules and features
await super().update(update_children)
initialize_children = not self.children
# Initialize the child devices during the first update.
if not self.children:
if initialize_children:
children = self.sys_info["children"]
_LOGGER.debug("Initializing %s child sockets", len(children))
self._children = {
@ -127,12 +139,22 @@ class IotStrip(IotDevice):
for child in children
}
for child in self._children.values():
await child._initialize_features()
await child._initialize_modules()
if update_children and self.has_emeter:
if update_children:
for plug in self.children:
await plug.update()
if not self.features:
await self._initialize_features()
async def _initialize_features(self):
"""Initialize common features."""
# Do not initialize features until children are created
if not self.children:
return
await super()._initialize_features()
async def turn_on(self, **kwargs):
"""Turn the strip on."""
await self._query_helper("system", "set_relay_state", {"state": 1})
@ -150,21 +172,43 @@ class IotStrip(IotDevice):
return min(plug.on_since for plug in self.children if plug.on_since is not None)
async def current_consumption(self) -> float:
"""Get the current power consumption in watts."""
return sum([await plug.current_consumption() for plug in self.children])
@requires_update
async def get_emeter_realtime(self) -> EmeterStatus:
class StripEmeter(IotModule, Energy):
"""Energy module implementation to aggregate child modules."""
_supported = (
Energy.ModuleFeature.CONSUMPTION_TOTAL
| Energy.ModuleFeature.PERIODIC_STATS
| Energy.ModuleFeature.VOLTAGE_CURRENT
)
def supports(self, module_feature: Energy.ModuleFeature) -> bool:
"""Return True if module supports the feature."""
return module_feature in self._supported
def query(self):
"""Return the base query."""
return {}
@property
def current_consumption(self) -> float | None:
"""Get the current power consumption in watts."""
return sum(
v if (v := plug.modules[Module.Energy].current_consumption) else 0.0
for plug in self._device.children
)
async def get_status(self) -> EmeterStatus:
"""Retrieve current energy readings."""
emeter_rt = await self._async_get_emeter_sum("get_emeter_realtime", {})
emeter_rt = await self._async_get_emeter_sum("get_status", {})
# Voltage is averaged since each read will result
# in a slightly different voltage since they are not atomic
emeter_rt["voltage_mv"] = int(emeter_rt["voltage_mv"] / len(self.children))
emeter_rt["voltage_mv"] = int(
emeter_rt["voltage_mv"] / len(self._device.children)
)
return EmeterStatus(emeter_rt)
@requires_update
async def get_emeter_daily(
async def get_daily_stats(
self, year: int | None = None, month: int | None = None, kwh: bool = True
) -> dict:
"""Retrieve daily statistics for a given month.
@ -176,11 +220,10 @@ class IotStrip(IotDevice):
:return: mapping of day of month to value
"""
return await self._async_get_emeter_sum(
"get_emeter_daily", {"year": year, "month": month, "kwh": kwh}
"get_daily_stats", {"year": year, "month": month, "kwh": kwh}
)
@requires_update
async def get_emeter_monthly(
async def get_monthly_stats(
self, year: int | None = None, kwh: bool = True
) -> dict:
"""Retrieve monthly statistics for a given year.
@ -189,44 +232,68 @@ class IotStrip(IotDevice):
:param kwh: return usage in kWh (default: True)
"""
return await self._async_get_emeter_sum(
"get_emeter_monthly", {"year": year, "kwh": kwh}
"get_monthly_stats", {"year": year, "kwh": kwh}
)
async def _async_get_emeter_sum(self, func: str, kwargs: dict[str, Any]) -> dict:
"""Retreive emeter stats for a time period from children."""
self._verify_emeter()
"""Retrieve emeter stats for a time period from children."""
return merge_sums(
[await getattr(plug, func)(**kwargs) for plug in self.children]
[
await getattr(plug.modules[Module.Energy], func)(**kwargs)
for plug in self._device.children
]
)
@requires_update
async def erase_emeter_stats(self):
async def erase_stats(self):
"""Erase energy meter statistics for all plugs."""
for plug in self.children:
await plug.erase_emeter_stats()
for plug in self._device.children:
await plug.modules[Module.Energy].erase_stats()
@property # type: ignore
@requires_update
def emeter_this_month(self) -> float | None:
def consumption_this_month(self) -> float | None:
"""Return this month's energy consumption in kWh."""
return sum(v if (v := plug.emeter_this_month) else 0 for plug in self.children)
return sum(
v if (v := plug.modules[Module.Energy].consumption_this_month) else 0.0
for plug in self._device.children
)
@property # type: ignore
@requires_update
def emeter_today(self) -> float | None:
def consumption_today(self) -> float | None:
"""Return this month's energy consumption in kWh."""
return sum(v if (v := plug.emeter_today) else 0 for plug in self.children)
return sum(
v if (v := plug.modules[Module.Energy].consumption_today) else 0.0
for plug in self._device.children
)
@property # type: ignore
@requires_update
def emeter_realtime(self) -> EmeterStatus:
def consumption_total(self) -> float | None:
"""Return total energy consumption since reboot in kWh."""
return sum(
v if (v := plug.modules[Module.Energy].consumption_total) else 0.0
for plug in self._device.children
)
@property # type: ignore
def status(self) -> EmeterStatus:
"""Return current energy readings."""
emeter = merge_sums([plug.emeter_realtime for plug in self.children])
emeter = merge_sums(
[plug.modules[Module.Energy].status for plug in self._device.children]
)
# Voltage is averaged since each read will result
# in a slightly different voltage since they are not atomic
emeter["voltage_mv"] = int(emeter["voltage_mv"] / len(self.children))
emeter["voltage_mv"] = int(emeter["voltage_mv"] / len(self._device.children))
return EmeterStatus(emeter)
@property
def current(self) -> float | None:
"""Return the current in A."""
return self.status.current
@property
def voltage(self) -> float | None:
"""Get the current voltage in V."""
return self.status.voltage
class IotStripPlug(IotPlug):
"""Representation of a single socket in a power strip.
@ -275,9 +342,10 @@ class IotStripPlug(IotPlug):
icon="mdi:clock",
)
)
# If the strip plug has it's own modules we should call initialize
# features for the modules here. However the _initialize_modules function
# above does not seem to be called.
for module in self._supported_modules.values():
module._initialize_features()
for module_feat in module._module_features.values():
self._add_feature(module_feat)
async def update(self, update_children: bool = True):
"""Query the device to update the data.
@ -285,26 +353,8 @@ class IotStripPlug(IotPlug):
Needed for properties that are decorated with `requires_update`.
"""
await self._modular_update({})
def _create_emeter_request(self, year: int | None = None, month: int | None = None):
"""Create a request for requesting all emeter statistics at once."""
if year is None:
year = datetime.now().year
if month is None:
month = datetime.now().month
req: dict[str, Any] = {}
merge(req, self._create_request("emeter", "get_realtime"))
merge(req, self._create_request("emeter", "get_monthstat", {"year": year}))
merge(
req,
self._create_request(
"emeter", "get_daystat", {"month": month, "year": year}
),
)
return req
if not self._features:
await self._initialize_features()
def _create_request(
self, target: str, cmd: str, arg: dict | None = None, child_ids=None

View File

@ -4,130 +4,71 @@ from __future__ import annotations
from datetime import datetime
from ... import Device
from ...emeterstatus import EmeterStatus
from ...feature import Feature
from ...interfaces.energy import Energy as EnergyInterface
from .usage import Usage
class Emeter(Usage):
class Emeter(Usage, EnergyInterface):
"""Emeter module."""
def __init__(self, device: Device, module: str):
super().__init__(device, module)
self._add_feature(
Feature(
device,
name="Current consumption",
attribute_getter="current_consumption",
container=self,
unit="W",
id="current_power_w", # for homeassistant backwards compat
precision_hint=1,
category=Feature.Category.Primary,
def _post_update_hook(self) -> None:
self._supported = EnergyInterface.ModuleFeature.PERIODIC_STATS
if (
"voltage_mv" in self.data["get_realtime"]
or "voltage" in self.data["get_realtime"]
):
self._supported = (
self._supported | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT
)
)
self._add_feature(
Feature(
device,
name="Today's consumption",
attribute_getter="emeter_today",
container=self,
unit="kWh",
id="today_energy_kwh", # for homeassistant backwards compat
precision_hint=3,
category=Feature.Category.Info,
if (
"total_wh" in self.data["get_realtime"]
or "total" in self.data["get_realtime"]
):
self._supported = (
self._supported | EnergyInterface.ModuleFeature.CONSUMPTION_TOTAL
)
)
self._add_feature(
Feature(
device,
id="consumption_this_month",
name="This month's consumption",
attribute_getter="emeter_this_month",
container=self,
unit="kWh",
precision_hint=3,
category=Feature.Category.Info,
)
)
self._add_feature(
Feature(
device,
name="Total consumption since reboot",
attribute_getter="emeter_total",
container=self,
unit="kWh",
id="total_energy_kwh", # for homeassistant backwards compat
precision_hint=3,
category=Feature.Category.Info,
)
)
self._add_feature(
Feature(
device,
name="Voltage",
attribute_getter="voltage",
container=self,
unit="V",
id="voltage", # for homeassistant backwards compat
precision_hint=1,
category=Feature.Category.Primary,
)
)
self._add_feature(
Feature(
device,
name="Current",
attribute_getter="current",
container=self,
unit="A",
id="current_a", # for homeassistant backwards compat
precision_hint=2,
category=Feature.Category.Primary,
)
)
@property # type: ignore
def realtime(self) -> EmeterStatus:
def status(self) -> EmeterStatus:
"""Return current energy readings."""
return EmeterStatus(self.data["get_realtime"])
@property
def emeter_today(self) -> float | None:
def consumption_today(self) -> float | None:
"""Return today's energy consumption in kWh."""
raw_data = self.daily_data
today = datetime.now().day
data = self._convert_stat_data(raw_data, entry_key="day", key=today)
return data.get(today)
return data.get(today, 0.0)
@property
def emeter_this_month(self) -> float | None:
def consumption_this_month(self) -> float | None:
"""Return this month's energy consumption in kWh."""
raw_data = self.monthly_data
current_month = datetime.now().month
data = self._convert_stat_data(raw_data, entry_key="month", key=current_month)
return data.get(current_month)
return data.get(current_month, 0.0)
@property
def current_consumption(self) -> float | None:
"""Get the current power consumption in Watt."""
return self.realtime.power
return self.status.power
@property
def emeter_total(self) -> float | None:
def consumption_total(self) -> float | None:
"""Return total consumption since last reboot in kWh."""
return self.realtime.total
return self.status.total
@property
def current(self) -> float | None:
"""Return the current in A."""
return self.realtime.current
return self.status.current
@property
def voltage(self) -> float | None:
"""Get the current voltage in V."""
return self.realtime.voltage
return self.status.voltage
async def erase_stats(self):
"""Erase all stats.
@ -136,11 +77,11 @@ class Emeter(Usage):
"""
return await self.call("erase_emeter_stat")
async def get_realtime(self):
async def get_status(self) -> EmeterStatus:
"""Return real-time statistics."""
return await self.call("get_realtime")
return EmeterStatus(await self.call("get_realtime"))
async def get_daystat(self, *, year=None, month=None, kwh=True) -> dict:
async def get_daily_stats(self, *, year=None, month=None, kwh=True) -> dict:
"""Return daily stats for the given year & month.
The return value is a dictionary of {day: energy, ...}.
@ -149,7 +90,7 @@ class Emeter(Usage):
data = self._convert_stat_data(data["day_list"], entry_key="day", kwh=kwh)
return data
async def get_monthstat(self, *, year=None, kwh=True) -> dict:
async def get_monthly_stats(self, *, year=None, kwh=True) -> dict:
"""Return monthly stats for the given year.
The return value is a dictionary of {month: energy, ...}.

View File

@ -30,3 +30,8 @@ class Led(IotModule, LedInterface):
async def set_led(self, state: bool):
"""Set the state of the led (night mode)."""
return await self.call("set_led_off", {"off": int(not state)})
@property
def is_supported(self) -> bool:
"""Return whether the module is supported by the device."""
return "led_off" in self.data

View File

@ -33,6 +33,8 @@ class Module(ABC):
"""
# Common Modules
Energy: Final[ModuleName[interfaces.Energy]] = ModuleName("Energy")
Fan: Final[ModuleName[interfaces.Fan]] = ModuleName("Fan")
LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect")
Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led")
Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light")
@ -42,7 +44,6 @@ class Module(ABC):
IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient")
IotAntitheft: Final[ModuleName[iot.Antitheft]] = ModuleName("anti_theft")
IotCountdown: Final[ModuleName[iot.Countdown]] = ModuleName("countdown")
IotEmeter: Final[ModuleName[iot.Emeter]] = ModuleName("emeter")
IotMotion: Final[ModuleName[iot.Motion]] = ModuleName("motion")
IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule")
IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage")
@ -62,8 +63,6 @@ class Module(ABC):
)
ContactSensor: Final[ModuleName[smart.ContactSensor]] = ModuleName("ContactSensor")
DeviceModule: Final[ModuleName[smart.DeviceModule]] = ModuleName("DeviceModule")
Energy: Final[ModuleName[smart.Energy]] = ModuleName("Energy")
Fan: Final[ModuleName[smart.Fan]] = ModuleName("Fan")
Firmware: Final[ModuleName[smart.Firmware]] = ModuleName("Firmware")
FrostProtection: Final[ModuleName[smart.FrostProtection]] = ModuleName(
"FrostProtection"

View File

@ -2,60 +2,17 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from ...emeterstatus import EmeterStatus
from ...feature import Feature
from ...exceptions import KasaException
from ...interfaces.energy import Energy as EnergyInterface
from ..smartmodule import SmartModule
if TYPE_CHECKING:
from ..smartdevice import SmartDevice
class Energy(SmartModule):
class Energy(SmartModule, EnergyInterface):
"""Implementation of energy monitoring module."""
REQUIRED_COMPONENT = "energy_monitoring"
def __init__(self, device: SmartDevice, module: str):
super().__init__(device, module)
self._add_feature(
Feature(
device,
"consumption_current",
name="Current consumption",
attribute_getter="current_power",
container=self,
unit="W",
precision_hint=1,
category=Feature.Category.Primary,
)
)
self._add_feature(
Feature(
device,
"consumption_today",
name="Today's consumption",
attribute_getter="emeter_today",
container=self,
unit="Wh",
precision_hint=2,
category=Feature.Category.Info,
)
)
self._add_feature(
Feature(
device,
"consumption_this_month",
name="This month's consumption",
attribute_getter="emeter_this_month",
container=self,
unit="Wh",
precision_hint=2,
category=Feature.Category.Info,
)
)
def query(self) -> dict:
"""Query to execute during the update cycle."""
req = {
@ -66,9 +23,9 @@ class Energy(SmartModule):
return req
@property
def current_power(self) -> float | None:
def current_consumption(self) -> float | None:
"""Current power in watts."""
if power := self.energy.get("current_power"):
if (power := self.energy.get("current_power")) is not None:
return power / 1_000
return None
@ -79,23 +36,64 @@ class Energy(SmartModule):
return en
return self.data
@property
def emeter_realtime(self):
"""Get the emeter status."""
# TODO: Perhaps we should get rid of emeterstatus altogether for smartdevices
def _get_status_from_energy(self, energy) -> EmeterStatus:
return EmeterStatus(
{
"power_mw": self.energy.get("current_power"),
"total": self.energy.get("today_energy") / 1_000,
"power_mw": energy.get("current_power"),
"total": energy.get("today_energy") / 1_000,
}
)
@property
def emeter_this_month(self) -> float | None:
"""Get the emeter value for this month."""
return self.energy.get("month_energy")
def status(self):
"""Get the emeter status."""
return self._get_status_from_energy(self.energy)
async def get_status(self):
"""Return real-time statistics."""
res = await self.call("get_energy_usage")
return self._get_status_from_energy(res["get_energy_usage"])
@property
def emeter_today(self) -> float | None:
"""Get the emeter value for today."""
return self.energy.get("today_energy")
def consumption_this_month(self) -> float | None:
"""Get the emeter value for this month in kWh."""
return self.energy.get("month_energy") / 1_000
@property
def consumption_today(self) -> float | None:
"""Get the emeter value for today in kWh."""
return self.energy.get("today_energy") / 1_000
@property
def consumption_total(self) -> float | None:
"""Return total consumption since last reboot in kWh."""
return None
@property
def current(self) -> float | None:
"""Return the current in A."""
return None
@property
def voltage(self) -> float | None:
"""Get the current voltage in V."""
return None
async def _deprecated_get_realtime(self) -> EmeterStatus:
"""Retrieve current energy readings."""
return self.status
async def erase_stats(self):
"""Erase all stats."""
raise KasaException("Device does not support periodic statistics")
async def get_daily_stats(self, *, year=None, month=None, kwh=True) -> dict:
"""Return daily stats for the given year & month.
The return value is a dictionary of {day: energy, ...}.
"""
raise KasaException("Device does not support periodic statistics")
async def get_monthly_stats(self, *, year=None, kwh=True) -> dict:
"""Return monthly stats for the given year."""
raise KasaException("Device does not support periodic statistics")

View File

@ -11,7 +11,6 @@ from ..aestransport import AesTransport
from ..device import Device, WifiNetwork
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..emeterstatus import EmeterStatus
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
from ..feature import Feature
from ..module import Module
@ -477,31 +476,6 @@ class SmartDevice(Device):
self._discovery_info = info
self._info = info
async def get_emeter_realtime(self) -> EmeterStatus:
"""Retrieve current energy readings."""
_LOGGER.warning("Deprecated, use `emeter_realtime`.")
if not self.has_emeter:
raise KasaException("Device has no emeter")
return self.emeter_realtime
@property
def emeter_realtime(self) -> EmeterStatus:
"""Get the emeter status."""
energy = self.modules[Module.Energy]
return energy.emeter_realtime
@property
def emeter_this_month(self) -> float | None:
"""Get the emeter value for this month."""
energy = self.modules[Module.Energy]
return energy.emeter_this_month
@property
def emeter_today(self) -> float | None:
"""Get the emeter value for today."""
energy = self.modules[Module.Energy]
return energy.emeter_today
async def wifi_scan(self) -> list[WifiNetwork]:
"""Scan for available wifi networks."""

View File

@ -163,12 +163,7 @@ async def _test_attribute(
if is_expected and will_raise:
ctx = pytest.raises(will_raise)
elif is_expected:
ctx = pytest.deprecated_call(
match=(
f"{attribute_name} is deprecated, use: Module."
+ f"{module_name} in device.modules instead"
)
)
ctx = pytest.deprecated_call(match=(f"{attribute_name} is deprecated, use:"))
else:
ctx = pytest.raises(
AttributeError, match=f"Device has no attribute '{attribute_name}'"
@ -239,6 +234,19 @@ async def test_deprecated_other_attributes(dev: Device):
await _test_attribute(dev, "led", bool(led_module), "Led")
await _test_attribute(dev, "set_led", bool(led_module), "Led", True)
await _test_attribute(dev, "supported_modules", True, None)
async def test_deprecated_emeter_attributes(dev: Device):
energy_module = dev.modules.get(Module.Energy)
await _test_attribute(dev, "get_emeter_realtime", bool(energy_module), "Energy")
await _test_attribute(dev, "emeter_realtime", bool(energy_module), "Energy")
await _test_attribute(dev, "emeter_today", bool(energy_module), "Energy")
await _test_attribute(dev, "emeter_this_month", bool(energy_module), "Energy")
await _test_attribute(dev, "current_consumption", bool(energy_module), "Energy")
await _test_attribute(dev, "get_emeter_daily", bool(energy_module), "Energy")
await _test_attribute(dev, "get_emeter_monthly", bool(energy_module), "Energy")
async def test_deprecated_light_preset_attributes(dev: Device):

View File

@ -10,8 +10,9 @@ from voluptuous import (
Schema,
)
from kasa import EmeterStatus, KasaException
from kasa.iot import IotDevice
from kasa import Device, EmeterStatus, Module
from kasa.interfaces.energy import Energy
from kasa.iot import IotDevice, IotStrip
from kasa.iot.modules.emeter import Emeter
from .conftest import has_emeter, has_emeter_iot, no_emeter
@ -38,16 +39,16 @@ CURRENT_CONSUMPTION_SCHEMA = Schema(
async def test_no_emeter(dev):
assert not dev.has_emeter
with pytest.raises(KasaException):
with pytest.raises(AttributeError):
await dev.get_emeter_realtime()
# Only iot devices support the historical stats so other
# devices will not implement the methods below
if isinstance(dev, IotDevice):
with pytest.raises(KasaException):
with pytest.raises(AttributeError):
await dev.get_emeter_daily()
with pytest.raises(KasaException):
with pytest.raises(AttributeError):
await dev.get_emeter_monthly()
with pytest.raises(KasaException):
with pytest.raises(AttributeError):
await dev.erase_emeter_stats()
@ -128,11 +129,11 @@ async def test_erase_emeter_stats(dev):
@has_emeter_iot
async def test_current_consumption(dev):
if dev.has_emeter:
x = await dev.current_consumption()
x = dev.current_consumption
assert isinstance(x, float)
assert x >= 0.0
else:
assert await dev.current_consumption() is None
assert dev.current_consumption is None
async def test_emeterstatus_missing_current():
@ -173,3 +174,30 @@ async def test_emeter_daily():
{"day": now.day, "energy_wh": 500, "month": now.month, "year": now.year}
)
assert emeter.emeter_today == 0.500
@has_emeter
async def test_supported(dev: Device):
energy_module = dev.modules.get(Module.Energy)
assert energy_module
if isinstance(dev, IotDevice):
info = (
dev._last_update
if not isinstance(dev, IotStrip)
else dev.children[0].internal_state
)
emeter = info[energy_module._module]["get_realtime"]
has_total = "total" in emeter or "total_wh" in emeter
has_voltage_current = "voltage" in emeter or "voltage_mv" in emeter
assert (
energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is has_total
)
assert (
energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT)
is has_voltage_current
)
assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is True
else:
assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is False
assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is False
assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is False

View File

@ -116,9 +116,16 @@ async def test_initial_update_no_emeter(dev, mocker):
dev._legacy_features = set()
spy = mocker.spy(dev.protocol, "query")
await dev.update()
# 2 calls are necessary as some devices crash on unexpected modules
# child calls will happen if a child has a module with a query (e.g. schedule)
child_calls = 0
for child in dev.children:
for module in child.modules.values():
if module.query():
child_calls += 1
break
# 2 parent are necessary as some devices crash on unexpected modules
# See #105, #120, #161
assert spy.call_count == 2
assert spy.call_count == 2 + child_calls
@device_iot