mirror of
				https://github.com/python-kasa/python-kasa.git
				synced 2025-10-31 04:31:54 +00:00 
			
		
		
		
	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:
		| @@ -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}") | ||||
|   | ||||
| @@ -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
									
								
							
							
						
						
									
										181
									
								
								kasa/interfaces/energy.py
									
									
									
									
									
										Normal 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}") | ||||
| @@ -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)) | ||||
|   | ||||
| @@ -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. | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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, ...}. | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
| @@ -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") | ||||
|   | ||||
| @@ -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.""" | ||||
|  | ||||
|   | ||||
| @@ -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): | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Steven B
					Steven B