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, DeviceEncryptionType,
DeviceFamily, DeviceFamily,
) )
from .emeterstatus import EmeterStatus
from .exceptions import KasaException from .exceptions import KasaException
from .feature import Feature from .feature import Feature
from .iotprotocol import IotProtocol from .iotprotocol import IotProtocol
@ -323,27 +322,6 @@ class Device(ABC):
def on_since(self) -> datetime | None: def on_since(self) -> datetime | None:
"""Return the time that the device was turned on or None if turned off.""" """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 @abstractmethod
async def wifi_scan(self) -> list[WifiNetwork]: async def wifi_scan(self) -> list[WifiNetwork]:
"""Scan for available wifi networks.""" """Scan for available wifi networks."""
@ -373,12 +351,15 @@ class Device(ABC):
} }
def _get_replacing_attr(self, module_name: ModuleName, *attrs): 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 return None
for attr in attrs: for attr in attrs:
if hasattr(self.modules[module_name], attr): if hasattr(check, attr):
return getattr(self.modules[module_name], attr) return attr
return None return None
@ -411,6 +392,16 @@ class Device(ABC):
# light preset attributes # light preset attributes
"presets": (Module.LightPreset, ["_deprecated_presets", "preset_states_list"]), "presets": (Module.LightPreset, ["_deprecated_presets", "preset_states_list"]),
"save_preset": (Module.LightPreset, ["_deprecated_save_preset"]), "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): def __getattr__(self, name):
@ -427,11 +418,10 @@ class Device(ABC):
(replacing_attr := self._get_replacing_attr(dep_attr[0], *dep_attr[1])) (replacing_attr := self._get_replacing_attr(dep_attr[0], *dep_attr[1]))
is not None is not None
): ):
module_name = dep_attr[0] mod = dep_attr[0]
msg = ( dev_or_mod = self.modules[mod] if mod else self
f"{name} is deprecated, use: " replacing = f"Module.{mod} in device.modules" if mod else replacing_attr
+ f"Module.{module_name} in device.modules instead" msg = f"{name} is deprecated, use: {replacing} instead"
)
warn(msg, DeprecationWarning, stacklevel=1) warn(msg, DeprecationWarning, stacklevel=1)
return replacing_attr return getattr(dev_or_mod, replacing_attr)
raise AttributeError(f"Device has no attribute {name!r}") raise AttributeError(f"Device has no attribute {name!r}")

View File

@ -1,5 +1,6 @@
"""Package for interfaces.""" """Package for interfaces."""
from .energy import Energy
from .fan import Fan from .fan import Fan
from .led import Led from .led import Led
from .light import Light, LightState from .light import Light, LightState
@ -8,6 +9,7 @@ from .lightpreset import LightPreset
__all__ = [ __all__ = [
"Fan", "Fan",
"Energy",
"Led", "Led",
"Light", "Light",
"LightEffect", "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") Module.IotAntitheft, Antitheft(self, "smartlife.iot.common.anti_theft")
) )
self.add_module(Module.IotTime, Time(self, "smartlife.iot.common.timesetting")) 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.IotCountdown, Countdown(self, "countdown"))
self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud")) self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud"))
self.add_module(Module.Light, Light(self, self.LIGHT_SERVICE)) 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 ..device import Device, WifiNetwork
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..emeterstatus import EmeterStatus
from ..exceptions import KasaException from ..exceptions import KasaException
from ..feature import Feature from ..feature import Feature
from ..module import Module from ..module import Module
@ -188,7 +187,7 @@ class IotDevice(Device):
super().__init__(host=host, config=config, protocol=protocol) super().__init__(host=host, config=config, protocol=protocol)
self._sys_info: Any = None # TODO: this is here to avoid changing tests 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._legacy_features: set[str] = set()
self._children: Mapping[str, IotDevice] = {} self._children: Mapping[str, IotDevice] = {}
self._modules: dict[str | ModuleName[Module], IotModule] = {} self._modules: dict[str | ModuleName[Module], IotModule] = {}
@ -199,15 +198,16 @@ class IotDevice(Device):
return list(self._children.values()) return list(self._children.values())
@property @property
@requires_update
def modules(self) -> ModuleMapping[IotModule]: def modules(self) -> ModuleMapping[IotModule]:
"""Return the device modules.""" """Return the device modules."""
if TYPE_CHECKING: if TYPE_CHECKING:
return cast(ModuleMapping[IotModule], self._modules) return cast(ModuleMapping[IotModule], self._supported_modules)
return self._modules return self._supported_modules
def add_module(self, name: str | ModuleName[Module], module: IotModule): def add_module(self, name: str | ModuleName[Module], module: IotModule):
"""Register a module.""" """Register a module."""
if name in self.modules: if name in self._modules:
_LOGGER.debug("Module %s already registered, ignoring..." % name) _LOGGER.debug("Module %s already registered, ignoring..." % name)
return return
@ -272,14 +272,6 @@ class IotDevice(Device):
"""Return a set of features that the device supports.""" """Return a set of features that the device supports."""
return self._features 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 @property # type: ignore
@requires_update @requires_update
def has_emeter(self) -> bool: def has_emeter(self) -> bool:
@ -321,6 +313,11 @@ class IotDevice(Device):
async def _initialize_modules(self): async def _initialize_modules(self):
"""Initialize modules not added in init.""" """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): async def _initialize_features(self):
"""Initialize common features.""" """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() module._initialize_features()
for module_feat in module._module_features.values(): for module_feat in module._module_features.values():
self._add_feature(module_feat) self._add_feature(module_feat)
async def _modular_update(self, req: dict) -> None: async def _modular_update(self, req: dict) -> None:
"""Execute an update query.""" """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 = [] request_list = []
est_response_size = 1024 if "system" in req else 0 est_response_size = 1024 if "system" in req else 0
for module in self._modules.values(): for module in self._modules.values():
@ -411,6 +392,15 @@ class IotDevice(Device):
update = {**update, **response} update = {**update, **response}
self._last_update = update 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: def update_from_discover_info(self, info: dict[str, Any]) -> None:
"""Update state from info from the discover call.""" """Update state from info from the discover call."""
self._discovery_info = info self._discovery_info = info
@ -557,74 +547,6 @@ class IotDevice(Device):
""" """
return await self._query_helper("system", "set_mac_addr", {"mac": mac}) 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: async def reboot(self, delay: int = 1) -> None:
"""Reboot the device. """Reboot the device.

View File

@ -9,16 +9,17 @@ from typing import Any
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..emeterstatus import EmeterStatus
from ..exceptions import KasaException from ..exceptions import KasaException
from ..feature import Feature from ..feature import Feature
from ..interfaces import Energy
from ..module import Module from ..module import Module
from ..protocol import BaseProtocol from ..protocol import BaseProtocol
from .iotdevice import ( from .iotdevice import (
EmeterStatus,
IotDevice, IotDevice,
merge,
requires_update, requires_update,
) )
from .iotmodule import IotModule
from .iotplug import IotPlug from .iotplug import IotPlug
from .modules import Antitheft, Countdown, Schedule, Time, Usage from .modules import Antitheft, Countdown, Schedule, Time, Usage
@ -97,11 +98,20 @@ class IotStrip(IotDevice):
super().__init__(host=host, config=config, protocol=protocol) super().__init__(host=host, config=config, protocol=protocol)
self.emeter_type = "emeter" self.emeter_type = "emeter"
self._device_type = DeviceType.Strip 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.IotAntitheft, Antitheft(self, "anti_theft"))
self.add_module(Module.IotSchedule, Schedule(self, "schedule")) self.add_module(Module.IotSchedule, Schedule(self, "schedule"))
self.add_module(Module.IotUsage, Usage(self, "schedule")) self.add_module(Module.IotUsage, Usage(self, "schedule"))
self.add_module(Module.IotTime, Time(self, "time")) self.add_module(Module.IotTime, Time(self, "time"))
self.add_module(Module.IotCountdown, Countdown(self, "countdown")) 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 @property # type: ignore
@requires_update @requires_update
@ -114,10 +124,12 @@ class IotStrip(IotDevice):
Needed for methods that are decorated with `requires_update`. Needed for methods that are decorated with `requires_update`.
""" """
# Super initializes modules and features
await super().update(update_children) await super().update(update_children)
initialize_children = not self.children
# Initialize the child devices during the first update. # Initialize the child devices during the first update.
if not self.children: if initialize_children:
children = self.sys_info["children"] children = self.sys_info["children"]
_LOGGER.debug("Initializing %s child sockets", len(children)) _LOGGER.debug("Initializing %s child sockets", len(children))
self._children = { self._children = {
@ -127,12 +139,22 @@ class IotStrip(IotDevice):
for child in children for child in children
} }
for child in self._children.values(): 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: for plug in self.children:
await plug.update() 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): async def turn_on(self, **kwargs):
"""Turn the strip on.""" """Turn the strip on."""
await self._query_helper("system", "set_relay_state", {"state": 1}) 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) 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 class StripEmeter(IotModule, Energy):
async def get_emeter_realtime(self) -> EmeterStatus: """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.""" """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 # Voltage is averaged since each read will result
# in a slightly different voltage since they are not atomic # 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) return EmeterStatus(emeter_rt)
@requires_update async def get_daily_stats(
async def get_emeter_daily(
self, year: int | None = None, month: int | None = None, kwh: bool = True self, year: int | None = None, month: int | None = None, kwh: bool = True
) -> dict: ) -> dict:
"""Retrieve daily statistics for a given month. """Retrieve daily statistics for a given month.
@ -176,11 +220,10 @@ class IotStrip(IotDevice):
:return: mapping of day of month to value :return: mapping of day of month to value
""" """
return await self._async_get_emeter_sum( 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_monthly_stats(
async def get_emeter_monthly(
self, year: int | None = None, kwh: bool = True self, year: int | None = None, kwh: bool = True
) -> dict: ) -> dict:
"""Retrieve monthly statistics for a given year. """Retrieve monthly statistics for a given year.
@ -189,44 +232,68 @@ class IotStrip(IotDevice):
:param kwh: return usage in kWh (default: True) :param kwh: return usage in kWh (default: True)
""" """
return await self._async_get_emeter_sum( 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: async def _async_get_emeter_sum(self, func: str, kwargs: dict[str, Any]) -> dict:
"""Retreive emeter stats for a time period from children.""" """Retrieve emeter stats for a time period from children."""
self._verify_emeter()
return merge_sums( 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_stats(self):
async def erase_emeter_stats(self):
"""Erase energy meter statistics for all plugs.""" """Erase energy meter statistics for all plugs."""
for plug in self.children: for plug in self._device.children:
await plug.erase_emeter_stats() await plug.modules[Module.Energy].erase_stats()
@property # type: ignore @property # type: ignore
@requires_update def consumption_this_month(self) -> float | None:
def emeter_this_month(self) -> float | None:
"""Return this month's energy consumption in kWh.""" """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 @property # type: ignore
@requires_update def consumption_today(self) -> float | None:
def emeter_today(self) -> float | None:
"""Return this month's energy consumption in kWh.""" """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 @property # type: ignore
@requires_update def consumption_total(self) -> float | None:
def emeter_realtime(self) -> EmeterStatus: """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.""" """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 # Voltage is averaged since each read will result
# in a slightly different voltage since they are not atomic # 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) 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): class IotStripPlug(IotPlug):
"""Representation of a single socket in a power strip. """Representation of a single socket in a power strip.
@ -275,9 +342,10 @@ class IotStripPlug(IotPlug):
icon="mdi:clock", icon="mdi:clock",
) )
) )
# If the strip plug has it's own modules we should call initialize for module in self._supported_modules.values():
# features for the modules here. However the _initialize_modules function module._initialize_features()
# above does not seem to be called. for module_feat in module._module_features.values():
self._add_feature(module_feat)
async def update(self, update_children: bool = True): async def update(self, update_children: bool = True):
"""Query the device to update the data. """Query the device to update the data.
@ -285,26 +353,8 @@ class IotStripPlug(IotPlug):
Needed for properties that are decorated with `requires_update`. Needed for properties that are decorated with `requires_update`.
""" """
await self._modular_update({}) await self._modular_update({})
if not self._features:
def _create_emeter_request(self, year: int | None = None, month: int | None = None): await self._initialize_features()
"""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
def _create_request( def _create_request(
self, target: str, cmd: str, arg: dict | None = None, child_ids=None 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 datetime import datetime
from ... import Device
from ...emeterstatus import EmeterStatus from ...emeterstatus import EmeterStatus
from ...feature import Feature from ...interfaces.energy import Energy as EnergyInterface
from .usage import Usage from .usage import Usage
class Emeter(Usage): class Emeter(Usage, EnergyInterface):
"""Emeter module.""" """Emeter module."""
def __init__(self, device: Device, module: str): def _post_update_hook(self) -> None:
super().__init__(device, module) self._supported = EnergyInterface.ModuleFeature.PERIODIC_STATS
self._add_feature( if (
Feature( "voltage_mv" in self.data["get_realtime"]
device, or "voltage" in self.data["get_realtime"]
name="Current consumption", ):
attribute_getter="current_consumption", self._supported = (
container=self, self._supported | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT
unit="W",
id="current_power_w", # for homeassistant backwards compat
precision_hint=1,
category=Feature.Category.Primary,
) )
) if (
self._add_feature( "total_wh" in self.data["get_realtime"]
Feature( or "total" in self.data["get_realtime"]
device, ):
name="Today's consumption", self._supported = (
attribute_getter="emeter_today", self._supported | EnergyInterface.ModuleFeature.CONSUMPTION_TOTAL
container=self,
unit="kWh",
id="today_energy_kwh", # for homeassistant backwards compat
precision_hint=3,
category=Feature.Category.Info,
) )
)
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 @property # type: ignore
def realtime(self) -> EmeterStatus: def status(self) -> EmeterStatus:
"""Return current energy readings.""" """Return current energy readings."""
return EmeterStatus(self.data["get_realtime"]) return EmeterStatus(self.data["get_realtime"])
@property @property
def emeter_today(self) -> float | None: def consumption_today(self) -> float | None:
"""Return today's energy consumption in kWh.""" """Return today's energy consumption in kWh."""
raw_data = self.daily_data raw_data = self.daily_data
today = datetime.now().day today = datetime.now().day
data = self._convert_stat_data(raw_data, entry_key="day", key=today) data = self._convert_stat_data(raw_data, entry_key="day", key=today)
return data.get(today) return data.get(today, 0.0)
@property @property
def emeter_this_month(self) -> float | None: def consumption_this_month(self) -> float | None:
"""Return this month's energy consumption in kWh.""" """Return this month's energy consumption in kWh."""
raw_data = self.monthly_data raw_data = self.monthly_data
current_month = datetime.now().month current_month = datetime.now().month
data = self._convert_stat_data(raw_data, entry_key="month", key=current_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 @property
def current_consumption(self) -> float | None: def current_consumption(self) -> float | None:
"""Get the current power consumption in Watt.""" """Get the current power consumption in Watt."""
return self.realtime.power return self.status.power
@property @property
def emeter_total(self) -> float | None: def consumption_total(self) -> float | None:
"""Return total consumption since last reboot in kWh.""" """Return total consumption since last reboot in kWh."""
return self.realtime.total return self.status.total
@property @property
def current(self) -> float | None: def current(self) -> float | None:
"""Return the current in A.""" """Return the current in A."""
return self.realtime.current return self.status.current
@property @property
def voltage(self) -> float | None: def voltage(self) -> float | None:
"""Get the current voltage in V.""" """Get the current voltage in V."""
return self.realtime.voltage return self.status.voltage
async def erase_stats(self): async def erase_stats(self):
"""Erase all stats. """Erase all stats.
@ -136,11 +77,11 @@ class Emeter(Usage):
""" """
return await self.call("erase_emeter_stat") return await self.call("erase_emeter_stat")
async def get_realtime(self): async def get_status(self) -> EmeterStatus:
"""Return real-time statistics.""" """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. """Return daily stats for the given year & month.
The return value is a dictionary of {day: energy, ...}. 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) data = self._convert_stat_data(data["day_list"], entry_key="day", kwh=kwh)
return data 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. """Return monthly stats for the given year.
The return value is a dictionary of {month: energy, ...}. 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): async def set_led(self, state: bool):
"""Set the state of the led (night mode).""" """Set the state of the led (night mode)."""
return await self.call("set_led_off", {"off": int(not state)}) 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 # Common Modules
Energy: Final[ModuleName[interfaces.Energy]] = ModuleName("Energy")
Fan: Final[ModuleName[interfaces.Fan]] = ModuleName("Fan")
LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect") LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect")
Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led") Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led")
Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light") Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light")
@ -42,7 +44,6 @@ class Module(ABC):
IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient")
IotAntitheft: Final[ModuleName[iot.Antitheft]] = ModuleName("anti_theft") IotAntitheft: Final[ModuleName[iot.Antitheft]] = ModuleName("anti_theft")
IotCountdown: Final[ModuleName[iot.Countdown]] = ModuleName("countdown") IotCountdown: Final[ModuleName[iot.Countdown]] = ModuleName("countdown")
IotEmeter: Final[ModuleName[iot.Emeter]] = ModuleName("emeter")
IotMotion: Final[ModuleName[iot.Motion]] = ModuleName("motion") IotMotion: Final[ModuleName[iot.Motion]] = ModuleName("motion")
IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule") IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule")
IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage") IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage")
@ -62,8 +63,6 @@ class Module(ABC):
) )
ContactSensor: Final[ModuleName[smart.ContactSensor]] = ModuleName("ContactSensor") ContactSensor: Final[ModuleName[smart.ContactSensor]] = ModuleName("ContactSensor")
DeviceModule: Final[ModuleName[smart.DeviceModule]] = ModuleName("DeviceModule") 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") Firmware: Final[ModuleName[smart.Firmware]] = ModuleName("Firmware")
FrostProtection: Final[ModuleName[smart.FrostProtection]] = ModuleName( FrostProtection: Final[ModuleName[smart.FrostProtection]] = ModuleName(
"FrostProtection" "FrostProtection"

View File

@ -2,60 +2,17 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from ...emeterstatus import EmeterStatus from ...emeterstatus import EmeterStatus
from ...feature import Feature from ...exceptions import KasaException
from ...interfaces.energy import Energy as EnergyInterface
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
if TYPE_CHECKING:
from ..smartdevice import SmartDevice
class Energy(SmartModule, EnergyInterface):
class Energy(SmartModule):
"""Implementation of energy monitoring module.""" """Implementation of energy monitoring module."""
REQUIRED_COMPONENT = "energy_monitoring" 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: def query(self) -> dict:
"""Query to execute during the update cycle.""" """Query to execute during the update cycle."""
req = { req = {
@ -66,9 +23,9 @@ class Energy(SmartModule):
return req return req
@property @property
def current_power(self) -> float | None: def current_consumption(self) -> float | None:
"""Current power in watts.""" """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 power / 1_000
return None return None
@ -79,23 +36,64 @@ class Energy(SmartModule):
return en return en
return self.data return self.data
@property def _get_status_from_energy(self, energy) -> EmeterStatus:
def emeter_realtime(self):
"""Get the emeter status."""
# TODO: Perhaps we should get rid of emeterstatus altogether for smartdevices
return EmeterStatus( return EmeterStatus(
{ {
"power_mw": self.energy.get("current_power"), "power_mw": energy.get("current_power"),
"total": self.energy.get("today_energy") / 1_000, "total": energy.get("today_energy") / 1_000,
} }
) )
@property @property
def emeter_this_month(self) -> float | None: def status(self):
"""Get the emeter value for this month.""" """Get the emeter status."""
return self.energy.get("month_energy") 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 @property
def emeter_today(self) -> float | None: def consumption_this_month(self) -> float | None:
"""Get the emeter value for today.""" """Get the emeter value for this month in kWh."""
return self.energy.get("today_energy") 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 import Device, WifiNetwork
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..emeterstatus import EmeterStatus
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
from ..feature import Feature from ..feature import Feature
from ..module import Module from ..module import Module
@ -477,31 +476,6 @@ class SmartDevice(Device):
self._discovery_info = info self._discovery_info = info
self._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]: async def wifi_scan(self) -> list[WifiNetwork]:
"""Scan for available wifi networks.""" """Scan for available wifi networks."""

View File

@ -163,12 +163,7 @@ async def _test_attribute(
if is_expected and will_raise: if is_expected and will_raise:
ctx = pytest.raises(will_raise) ctx = pytest.raises(will_raise)
elif is_expected: elif is_expected:
ctx = pytest.deprecated_call( ctx = pytest.deprecated_call(match=(f"{attribute_name} is deprecated, use:"))
match=(
f"{attribute_name} is deprecated, use: Module."
+ f"{module_name} in device.modules instead"
)
)
else: else:
ctx = pytest.raises( ctx = pytest.raises(
AttributeError, match=f"Device has no attribute '{attribute_name}'" 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, "led", bool(led_module), "Led")
await _test_attribute(dev, "set_led", bool(led_module), "Led", True) 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): async def test_deprecated_light_preset_attributes(dev: Device):

View File

@ -10,8 +10,9 @@ from voluptuous import (
Schema, Schema,
) )
from kasa import EmeterStatus, KasaException from kasa import Device, EmeterStatus, Module
from kasa.iot import IotDevice from kasa.interfaces.energy import Energy
from kasa.iot import IotDevice, IotStrip
from kasa.iot.modules.emeter import Emeter from kasa.iot.modules.emeter import Emeter
from .conftest import has_emeter, has_emeter_iot, no_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): async def test_no_emeter(dev):
assert not dev.has_emeter assert not dev.has_emeter
with pytest.raises(KasaException): with pytest.raises(AttributeError):
await dev.get_emeter_realtime() await dev.get_emeter_realtime()
# Only iot devices support the historical stats so other # Only iot devices support the historical stats so other
# devices will not implement the methods below # devices will not implement the methods below
if isinstance(dev, IotDevice): if isinstance(dev, IotDevice):
with pytest.raises(KasaException): with pytest.raises(AttributeError):
await dev.get_emeter_daily() await dev.get_emeter_daily()
with pytest.raises(KasaException): with pytest.raises(AttributeError):
await dev.get_emeter_monthly() await dev.get_emeter_monthly()
with pytest.raises(KasaException): with pytest.raises(AttributeError):
await dev.erase_emeter_stats() await dev.erase_emeter_stats()
@ -128,11 +129,11 @@ async def test_erase_emeter_stats(dev):
@has_emeter_iot @has_emeter_iot
async def test_current_consumption(dev): async def test_current_consumption(dev):
if dev.has_emeter: if dev.has_emeter:
x = await dev.current_consumption() x = dev.current_consumption
assert isinstance(x, float) assert isinstance(x, float)
assert x >= 0.0 assert x >= 0.0
else: else:
assert await dev.current_consumption() is None assert dev.current_consumption is None
async def test_emeterstatus_missing_current(): 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} {"day": now.day, "energy_wh": 500, "month": now.month, "year": now.year}
) )
assert emeter.emeter_today == 0.500 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() dev._legacy_features = set()
spy = mocker.spy(dev.protocol, "query") spy = mocker.spy(dev.protocol, "query")
await dev.update() 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 # See #105, #120, #161
assert spy.call_count == 2 assert spy.call_count == 2 + child_calls
@device_iot @device_iot