diff --git a/kasa/device.py b/kasa/device.py index 10722f69..53b71d85 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -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}") diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py index 31b9bc33..6a12bc68 100644 --- a/kasa/interfaces/__init__.py +++ b/kasa/interfaces/__init__.py @@ -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", diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py new file mode 100644 index 00000000..c1ce3a60 --- /dev/null +++ b/kasa/interfaces/energy.py @@ -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}") diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 36209360..26c73096 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -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)) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 1048034d..102d6a4d 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -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. diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 1ad1bdb8..c2f2bb86 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -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 diff --git a/kasa/iot/modules/emeter.py b/kasa/iot/modules/emeter.py index 53fb20da..7ae89e5b 100644 --- a/kasa/iot/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -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, ...}. diff --git a/kasa/iot/modules/led.py b/kasa/iot/modules/led.py index 6c4ca02a..48301f23 100644 --- a/kasa/iot/modules/led.py +++ b/kasa/iot/modules/led.py @@ -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 diff --git a/kasa/module.py b/kasa/module.py index a2a9c931..177c2baa 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -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" diff --git a/kasa/smart/modules/energy.py b/kasa/smart/modules/energy.py index 55b5088e..3edbddb4 100644 --- a/kasa/smart/modules/energy.py +++ b/kasa/smart/modules/energy.py @@ -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") diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index f4e3eb58..5a2f99e5 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -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.""" diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index c6d412c7..07e764cb 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -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): diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index a8fe75ed..b710ec73 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -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 diff --git a/kasa/tests/test_iotdevice.py b/kasa/tests/test_iotdevice.py index d5c76192..f43258e4 100644 --- a/kasa/tests/test_iotdevice.py +++ b/kasa/tests/test_iotdevice.py @@ -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