From 6fb5171f1f10da08e8c5eee051fb4ba2715cc5a7 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Thu, 13 Jun 2024 11:35:50 +0100 Subject: [PATCH 1/6] Add common energy module and deprecate device emeter attributes --- kasa/device.py | 34 ++----- kasa/interfaces/__init__.py | 2 + kasa/interfaces/energy.py | 176 +++++++++++++++++++++++++++++++++++ kasa/iot/iotbulb.py | 2 +- kasa/iot/iotdevice.py | 114 +++++------------------ kasa/iot/iotstrip.py | 165 ++++++++++++++++++++------------ kasa/iot/modules/emeter.py | 111 ++++++---------------- kasa/iot/modules/led.py | 5 + kasa/module.py | 5 +- kasa/smart/modules/energy.py | 120 ++++++++++++++---------- kasa/smart/smartdevice.py | 26 ------ kasa/tests/test_emeter.py | 12 +-- kasa/tests/test_iotdevice.py | 11 ++- 13 files changed, 436 insertions(+), 347 deletions(-) create mode 100644 kasa/interfaces/energy.py diff --git a/kasa/device.py b/kasa/device.py index 10722f69b..56cb8bc54 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -19,7 +19,6 @@ DeviceEncryptionType, DeviceFamily, ) -from .emeterstatus import EmeterStatus from .exceptions import KasaException from .feature import Feature from .iotprotocol import IotProtocol @@ -323,27 +322,6 @@ def has_emeter(self) -> bool: 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.""" @@ -378,7 +356,7 @@ def _get_replacing_attr(self, module_name: ModuleName, *attrs): for attr in attrs: if hasattr(self.modules[module_name], attr): - return getattr(self.modules[module_name], attr) + return attr return None @@ -411,6 +389,14 @@ def _get_replacing_attr(self, module_name: ModuleName, *attrs): # 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_daystat"]), + "get_emeter_monthly": (Module.Energy, ["get_monthstat"]), } def __getattr__(self, name): @@ -433,5 +419,5 @@ def __getattr__(self, name): + f"Module.{module_name} in device.modules instead" ) warn(msg, DeprecationWarning, stacklevel=1) - return replacing_attr + return getattr(self.modules[module_name], replacing_attr) raise AttributeError(f"Device has no attribute {name!r}") diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py index 31b9bc33d..6a12bc681 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 @@ __all__ = [ "Fan", + "Energy", "Led", "Light", "LightEffect", diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py new file mode 100644 index 000000000..30a8fa15f --- /dev/null +++ b/kasa/interfaces/energy.py @@ -0,0 +1,176 @@ +"""Module for base energy module.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from warnings import warn + +from ..emeterstatus import EmeterStatus +from ..feature import Feature +from ..module import Module + + +class Energy(Module, ABC): + """Base interface to represent a LED module.""" + + 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.has_total_consumption: + 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.has_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_a", + 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.""" + + @property + @abstractmethod + def has_voltage_current(self) -> bool: + """Return True if the device reports current and voltage.""" + + @property + @abstractmethod + def has_total_consumption(self) -> bool: + """Return True if device reports total energy consumption since last reboot.""" + + @abstractmethod + async def get_status(self): + """Return real-time statistics.""" + + @property + @abstractmethod + def has_periodic_stats(self) -> bool: + """Return True if device can report statistics for different time periods.""" + + @abstractmethod + async def erase_stats(self): + """Erase all stats.""" + + @abstractmethod + async def get_daystat(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_monthstat(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", + } + + 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 362093609..26c73096a 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -220,7 +220,7 @@ async def _initialize_modules(self): 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 c7631763b..d8d6d37d2 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -23,7 +23,6 @@ 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 @@ def __init__( 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 @@ def children(self) -> Sequence[IotDevice]: 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 @@ -278,7 +278,9 @@ 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()) + if self._supported_modules is None: + return [] + return list(self._supported_modules.keys()) @property # type: ignore @requires_update @@ -321,6 +323,11 @@ async def update(self, update_children: bool = True): 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.""" @@ -356,29 +363,13 @@ async def _initialize_features(self): ) ) - 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(): @@ -410,6 +401,15 @@ async def _modular_update(self, req: dict) -> None: 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 @@ -556,74 +556,6 @@ async def set_mac(self, mac): """ return await self._query_helper("system", "set_mac_addr", {"mac": mac}) - @property - @requires_update - def emeter_realtime(self) -> EmeterStatus: - """Return current energy readings.""" - self._verify_emeter() - return EmeterStatus(self.modules[Module.IotEmeter].realtime) - - async def get_emeter_realtime(self) -> EmeterStatus: - """Retrieve current energy readings.""" - self._verify_emeter() - return EmeterStatus(await self.modules[Module.IotEmeter].get_realtime()) - - @property - @requires_update - def emeter_today(self) -> float | None: - """Return today's energy consumption in kWh.""" - self._verify_emeter() - return self.modules[Module.IotEmeter].emeter_today - - @property - @requires_update - def emeter_this_month(self) -> float | None: - """Return this month's energy consumption in kWh.""" - self._verify_emeter() - return self.modules[Module.IotEmeter].emeter_this_month - - async def get_emeter_daily( - self, year: int | None = None, month: int | None = None, kwh: bool = True - ) -> dict: - """Retrieve daily statistics for a given month. - - :param year: year for which to retrieve statistics (default: this year) - :param month: month for which to retrieve statistics (default: this - month) - :param kwh: return usage in kWh (default: True) - :return: mapping of day of month to value - """ - self._verify_emeter() - return await self.modules[Module.IotEmeter].get_daystat( - year=year, month=month, kwh=kwh - ) - - @requires_update - async def get_emeter_monthly( - self, year: int | None = None, kwh: bool = True - ) -> dict: - """Retrieve monthly statistics for a given year. - - :param year: year for which to retrieve statistics (default: this year) - :param kwh: return usage in kWh (default: True) - :return: dict: mapping of month to value - """ - self._verify_emeter() - return await self.modules[Module.IotEmeter].get_monthstat(year=year, kwh=kwh) - - @requires_update - async def erase_emeter_stats(self) -> dict: - """Erase energy meter statistics.""" - self._verify_emeter() - return await self.modules[Module.IotEmeter].erase_stats() - - @requires_update - async def current_consumption(self) -> float: - """Get the current power consumption in Watt.""" - self._verify_emeter() - response = self.emeter_realtime - return float(response["power"]) - async def reboot(self, delay: int = 1) -> None: """Reboot the device. diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index dde57faaf..427dd6c6f 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -9,16 +9,17 @@ 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 @@ def __init__( 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 @@ async def update(self, update_children: bool = True): 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,9 +139,9 @@ async def update(self, update_children: bool = True): 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() @@ -150,21 +162,37 @@ def on_since(self) -> datetime | None: return max(plug.on_since for plug in self.children if plug.on_since is not None) - async def current_consumption(self) -> float: + +class StripEmeter(IotModule, Energy): + """Energy module implementation to aggregate child modules.""" + + def __init__(self, device: IotStrip, module: str): + super().__init__(device, module) + self._device = device + + def query(self): + """Return the base query.""" + return {} + + @property + def current_consumption(self) -> float | None: """Get the current power consumption in watts.""" - return sum([await plug.current_consumption() for plug in self.children]) + return sum( + v if (v := plug.modules[Module.Energy].current_consumption) else 0.0 + for plug in self._device.children + ) - @requires_update - async def get_emeter_realtime(self) -> EmeterStatus: + 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_daystat( self, year: int | None = None, month: int | None = None, kwh: bool = True ) -> dict: """Retrieve daily statistics for a given month. @@ -176,57 +204,93 @@ async def get_emeter_daily( :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_daystat", {"year": year, "month": month, "kwh": kwh} ) - @requires_update - async def get_emeter_monthly( - self, year: int | None = None, kwh: bool = True - ) -> dict: + async def get_monthstat(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 await self._async_get_emeter_sum( - "get_emeter_monthly", {"year": year, "kwh": kwh} + "get_monthstat", {"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): + @property + def has_periodic_stats(self) -> bool: + """Return True if device can report statistics for different time periods.""" + return True + + 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 + + @property + def has_voltage_current(self) -> bool: + """Return True if the device reports current and voltage.""" + return True + + @property + def has_total_consumption(self) -> bool: + """Return True if device reports total energy consumption since last reboot.""" + return True + class IotStripPlug(IotPlug): """Representation of a single socket in a power strip. @@ -275,9 +339,10 @@ async def _initialize_features(self): 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 +350,8 @@ async def update(self, update_children: bool = True): 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 53fb20da5..7771ed17b 100644 --- a/kasa/iot/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -6,95 +6,23 @@ 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, - ) - ) - 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, - ) - ) - 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 @@ -102,7 +30,7 @@ def emeter_today(self) -> float | None: return data.get(today) @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 @@ -112,22 +40,37 @@ def emeter_this_month(self) -> float | None: @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 + + @property + def has_voltage_current(self) -> bool: + """Return True if the device reports current and voltage.""" + return True + + @property + def has_total_consumption(self) -> bool: + """Return True if device reports total energy consumption since last reboot.""" + return True + + @property + def has_periodic_stats(self) -> bool: + """Return True if device can report statistics for different time periods.""" + return True async def erase_stats(self): """Erase all stats. @@ -136,9 +79,9 @@ async def erase_stats(self): """ 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: """Return daily stats for the given year & month. diff --git a/kasa/iot/modules/led.py b/kasa/iot/modules/led.py index 6c4ca02aa..48301f237 100644 --- a/kasa/iot/modules/led.py +++ b/kasa/iot/modules/led.py @@ -30,3 +30,8 @@ def led(self) -> bool: 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 a2a9c931a..177c2baa1 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 55b5088e7..8f07ccf17 100644 --- a/kasa/smart/modules/energy.py +++ b/kasa/smart/modules/energy.py @@ -5,57 +5,19 @@ 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 + pass -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,7 +28,7 @@ def query(self) -> dict: 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"): return power / 1_000 @@ -79,23 +41,79 @@ def energy(self): 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: + 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 consumption_this_month(self) -> float | None: """Get the emeter value for this month.""" return self.energy.get("month_energy") @property - def emeter_today(self) -> float | None: + def consumption_today(self) -> float | None: """Get the emeter value for today.""" return self.energy.get("today_energy") + + @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 + + @property + def has_voltage_current(self) -> bool: + """Return True if the device reports current and voltage.""" + return False + + @property + def has_total_consumption(self) -> bool: + """Return True if device reports total energy consumption since last reboot.""" + return False + + async def _deprecated_get_realtime(self) -> EmeterStatus: + """Retrieve current energy readings.""" + return self.status + + @property + def has_periodic_stats(self) -> bool: + """Return True if device can report statistics for different time periods.""" + return False + + async def erase_stats(self): + """Erase all stats.""" + raise KasaException("Device does not support periodic statistics") + + async def get_daystat(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_monthstat(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 26bf1396d..305f08ca2 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -11,7 +11,6 @@ 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 @@ -464,31 +463,6 @@ def update_from_discover_info(self, info): 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 - @property def on_since(self) -> datetime | None: """Return the time that the device was turned on or None if turned off.""" diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index a8fe75edd..1003b8d0f 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -38,14 +38,14 @@ 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): await dev.erase_emeter_stats() @@ -99,7 +99,7 @@ async def test_get_emeter_monthly(dev): assert v * 1000 == v2 -@has_emeter_iot +@has_emeter async def test_emeter_status(dev): assert dev.has_emeter @@ -128,11 +128,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(): diff --git a/kasa/tests/test_iotdevice.py b/kasa/tests/test_iotdevice.py index d5c76192b..f43258e45 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 From 14a64c67d2d7b18c0f1b1f0b41aab913461c60f0 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Thu, 13 Jun 2024 12:53:48 +0100 Subject: [PATCH 2/6] Fix tests --- kasa/interfaces/energy.py | 2 +- kasa/tests/test_emeter.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py index 30a8fa15f..62a5fc67c 100644 --- a/kasa/interfaces/energy.py +++ b/kasa/interfaces/energy.py @@ -85,7 +85,7 @@ def _initialize_features(self): attribute_getter="current", container=self, unit="A", - id="current_a", + id="current", precision_hint=2, category=Feature.Category.Primary, ) diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index 1003b8d0f..33b270b37 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -10,7 +10,7 @@ Schema, ) -from kasa import EmeterStatus, KasaException +from kasa import EmeterStatus from kasa.iot import IotDevice from kasa.iot.modules.emeter import Emeter @@ -47,7 +47,7 @@ async def test_no_emeter(dev): await dev.get_emeter_daily() with pytest.raises(AttributeError): await dev.get_emeter_monthly() - with pytest.raises(KasaException): + with pytest.raises(AttributeError): await dev.erase_emeter_stats() @@ -99,7 +99,7 @@ async def test_get_emeter_monthly(dev): assert v * 1000 == v2 -@has_emeter +@has_emeter_iot async def test_emeter_status(dev): assert dev.has_emeter From 3510e7f7880318ba4c83b736a5b515336f281929 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Thu, 13 Jun 2024 13:24:58 +0100 Subject: [PATCH 3/6] Return 0 for periodic stats if device off --- kasa/iot/modules/emeter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/iot/modules/emeter.py b/kasa/iot/modules/emeter.py index 7771ed17b..1580854e1 100644 --- a/kasa/iot/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -27,7 +27,7 @@ def consumption_today(self) -> float | None: 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 consumption_this_month(self) -> float | None: @@ -35,7 +35,7 @@ def consumption_this_month(self) -> float | None: 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: From 2daba5989c00d803566053ba6d4cb3e34f62d4e3 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 14 Jun 2024 08:32:26 +0100 Subject: [PATCH 4/6] Update post review --- kasa/device.py | 24 ++++++++++++++---------- kasa/interfaces/energy.py | 6 ++++-- kasa/iot/iotdevice.py | 10 ---------- kasa/iot/iotstrip.py | 10 ++++++---- kasa/iot/modules/emeter.py | 4 ++-- kasa/smart/modules/energy.py | 14 +++++++------- kasa/tests/test_device.py | 20 ++++++++++++++------ 7 files changed, 47 insertions(+), 41 deletions(-) diff --git a/kasa/device.py b/kasa/device.py index 56cb8bc54..53b71d859 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -351,11 +351,14 @@ def __repr__(self): } 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): + if hasattr(check, attr): return attr return None @@ -395,8 +398,10 @@ def _get_replacing_attr(self, module_name: ModuleName, *attrs): "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_daystat"]), - "get_emeter_monthly": (Module.Energy, ["get_monthstat"]), + "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): @@ -413,11 +418,10 @@ def __getattr__(self, name): (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 getattr(self.modules[module_name], replacing_attr) + return getattr(dev_or_mod, replacing_attr) raise AttributeError(f"Device has no attribute {name!r}") diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py index 62a5fc67c..9f2645f88 100644 --- a/kasa/interfaces/energy.py +++ b/kasa/interfaces/energy.py @@ -150,14 +150,14 @@ async def erase_stats(self): """Erase all stats.""" @abstractmethod - 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, ...}. """ @abstractmethod - 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.""" _deprecated_attributes = { @@ -166,6 +166,8 @@ async def get_monthstat(self, *, year=None, kwh=True) -> dict: "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): diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index d8d6d37d2..83dfd3786 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -272,16 +272,6 @@ def features(self) -> dict[str, Feature]: """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? - if self._supported_modules is None: - return [] - return list(self._supported_modules.keys()) - @property # type: ignore @requires_update def has_emeter(self) -> bool: diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 427dd6c6f..f66c86b97 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -192,7 +192,7 @@ async def get_status(self) -> EmeterStatus: ) return EmeterStatus(emeter_rt) - async def get_daystat( + 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. @@ -204,17 +204,19 @@ async def get_daystat( :return: mapping of day of month to value """ return await self._async_get_emeter_sum( - "get_daystat", {"year": year, "month": month, "kwh": kwh} + "get_daily_stats", {"year": year, "month": month, "kwh": kwh} ) - async def get_monthstat(self, year: int | None = None, kwh: bool = True) -> dict: + async def get_monthly_stats( + 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 await self._async_get_emeter_sum( - "get_monthstat", {"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: diff --git a/kasa/iot/modules/emeter.py b/kasa/iot/modules/emeter.py index 1580854e1..35b4f14eb 100644 --- a/kasa/iot/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -83,7 +83,7 @@ async def get_status(self) -> EmeterStatus: """Return real-time statistics.""" 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, ...}. @@ -92,7 +92,7 @@ async def get_daystat(self, *, year=None, month=None, kwh=True) -> dict: 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/smart/modules/energy.py b/kasa/smart/modules/energy.py index 8f07ccf17..f1c529fc0 100644 --- a/kasa/smart/modules/energy.py +++ b/kasa/smart/modules/energy.py @@ -30,7 +30,7 @@ def query(self) -> dict: @property 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 @@ -61,13 +61,13 @@ async def get_status(self): @property def consumption_this_month(self) -> float | None: - """Get the emeter value for this month.""" - return self.energy.get("month_energy") + """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.""" - return self.energy.get("today_energy") + """Get the emeter value for today in kWh.""" + return self.energy.get("today_energy") / 1_000 @property def consumption_total(self) -> float | None: @@ -107,13 +107,13 @@ async def erase_stats(self): """Erase all stats.""" raise KasaException("Device does not support periodic statistics") - 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, ...}. """ raise KasaException("Device does not support periodic statistics") - 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.""" raise KasaException("Device does not support periodic statistics") diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index c6d412c73..07e764cbf 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): From 4c120c4d4fb0f63c7a55c8e18a3a4e65bfaae163 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 14 Jun 2024 17:46:19 +0100 Subject: [PATCH 5/6] Replace has attributes with supported flags --- kasa/interfaces/energy.py | 37 ++++++++++++++++++------------------ kasa/iot/iotstrip.py | 27 +++++++++----------------- kasa/iot/modules/emeter.py | 27 +++++++++----------------- kasa/smart/modules/energy.py | 19 ++++-------------- 4 files changed, 41 insertions(+), 69 deletions(-) diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py index 9f2645f88..cbfa33862 100644 --- a/kasa/interfaces/energy.py +++ b/kasa/interfaces/energy.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from enum import IntFlag, auto from warnings import warn from ..emeterstatus import EmeterStatus @@ -11,7 +12,22 @@ class Energy(Module, ABC): - """Base interface to represent a LED module.""" + """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() + + @abstractmethod + def supports(self, module_feature: ModuleFeature) -> bool: + """Return True if module supports the feature.""" def _initialize_features(self): """Initialize features.""" @@ -52,7 +68,7 @@ def _initialize_features(self): category=Feature.Category.Info, ) ) - if self.has_total_consumption: + if self.supports(self.ModuleFeature.CONSUMPTION_TOTAL): self._add_feature( Feature( device, @@ -65,7 +81,7 @@ def _initialize_features(self): category=Feature.Category.Info, ) ) - if self.has_voltage_current: + if self.supports(self.ModuleFeature.VOLTAGE_CURRENT): self._add_feature( Feature( device, @@ -126,25 +142,10 @@ def current(self) -> float | None: def voltage(self) -> float | None: """Get the current voltage in V.""" - @property - @abstractmethod - def has_voltage_current(self) -> bool: - """Return True if the device reports current and voltage.""" - - @property - @abstractmethod - def has_total_consumption(self) -> bool: - """Return True if device reports total energy consumption since last reboot.""" - @abstractmethod async def get_status(self): """Return real-time statistics.""" - @property - @abstractmethod - def has_periodic_stats(self) -> bool: - """Return True if device can report statistics for different time periods.""" - @abstractmethod async def erase_stats(self): """Erase all stats.""" diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index f66c86b97..eaf781ee8 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -166,9 +166,15 @@ def on_since(self) -> datetime | None: class StripEmeter(IotModule, Energy): """Energy module implementation to aggregate child modules.""" - def __init__(self, device: IotStrip, module: str): - super().__init__(device, module) - self._device = device + _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 self._supported & module_feature == module_feature def query(self): """Return the base query.""" @@ -228,11 +234,6 @@ async def _async_get_emeter_sum(self, func: str, kwargs: dict[str, Any]) -> dict ] ) - @property - def has_periodic_stats(self) -> bool: - """Return True if device can report statistics for different time periods.""" - return True - async def erase_stats(self): """Erase energy meter statistics for all plugs.""" for plug in self._device.children: @@ -283,16 +284,6 @@ def voltage(self) -> float | None: """Get the current voltage in V.""" return self.status.voltage - @property - def has_voltage_current(self) -> bool: - """Return True if the device reports current and voltage.""" - return True - - @property - def has_total_consumption(self) -> bool: - """Return True if device reports total energy consumption since last reboot.""" - return True - class IotStripPlug(IotPlug): """Representation of a single socket in a power strip. diff --git a/kasa/iot/modules/emeter.py b/kasa/iot/modules/emeter.py index 35b4f14eb..5a227ae49 100644 --- a/kasa/iot/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -4,7 +4,6 @@ from datetime import datetime -from ... import Device from ...emeterstatus import EmeterStatus from ...interfaces.energy import Energy as EnergyInterface from .usage import Usage @@ -13,8 +12,15 @@ class Emeter(Usage, EnergyInterface): """Emeter module.""" - def __init__(self, device: Device, module: str): - super().__init__(device, module) + _supported = ( + EnergyInterface.ModuleFeature.CONSUMPTION_TOTAL + | EnergyInterface.ModuleFeature.PERIODIC_STATS + | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT + ) + + def supports(self, module_feature: EnergyInterface.ModuleFeature) -> bool: + """Return True if module supports the feature.""" + return self._supported & module_feature == module_feature @property # type: ignore def status(self) -> EmeterStatus: @@ -57,21 +63,6 @@ def voltage(self) -> float | None: """Get the current voltage in V.""" return self.status.voltage - @property - def has_voltage_current(self) -> bool: - """Return True if the device reports current and voltage.""" - return True - - @property - def has_total_consumption(self) -> bool: - """Return True if device reports total energy consumption since last reboot.""" - return True - - @property - def has_periodic_stats(self) -> bool: - """Return True if device can report statistics for different time periods.""" - return True - async def erase_stats(self): """Erase all stats. diff --git a/kasa/smart/modules/energy.py b/kasa/smart/modules/energy.py index f1c529fc0..fc1e09049 100644 --- a/kasa/smart/modules/energy.py +++ b/kasa/smart/modules/energy.py @@ -18,6 +18,10 @@ class Energy(SmartModule, EnergyInterface): REQUIRED_COMPONENT = "energy_monitoring" + def supports(self, module_feature: EnergyInterface.ModuleFeature) -> bool: + """Return True if module supports the feature.""" + return False + def query(self) -> dict: """Query to execute during the update cycle.""" req = { @@ -84,25 +88,10 @@ def voltage(self) -> float | None: """Get the current voltage in V.""" return None - @property - def has_voltage_current(self) -> bool: - """Return True if the device reports current and voltage.""" - return False - - @property - def has_total_consumption(self) -> bool: - """Return True if device reports total energy consumption since last reboot.""" - return False - async def _deprecated_get_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" return self.status - @property - def has_periodic_stats(self) -> bool: - """Return True if device can report statistics for different time periods.""" - return False - async def erase_stats(self): """Erase all stats.""" raise KasaException("Device does not support periodic statistics") From b98b3f73f2bef98bdef20fd6994997c9a2b9f2de Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 14 Jun 2024 20:24:16 +0100 Subject: [PATCH 6/6] Update post review --- kasa/interfaces/energy.py | 4 +++- kasa/iot/iotstrip.py | 12 +++++++++++- kasa/iot/modules/emeter.py | 25 ++++++++++++++++--------- kasa/smart/modules/energy.py | 9 --------- kasa/tests/test_emeter.py | 32 ++++++++++++++++++++++++++++++-- 5 files changed, 60 insertions(+), 22 deletions(-) diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py index cbfa33862..c1ce3a603 100644 --- a/kasa/interfaces/energy.py +++ b/kasa/interfaces/energy.py @@ -25,9 +25,11 @@ class ModuleFeature(IntFlag): #: and :meth:`get_monthly_stats` PERIODIC_STATS = auto() - @abstractmethod + _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.""" diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index eaf781ee8..ca196b1ab 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -145,6 +145,16 @@ async def update(self, update_children: bool = True): 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}) @@ -174,7 +184,7 @@ class StripEmeter(IotModule, Energy): def supports(self, module_feature: Energy.ModuleFeature) -> bool: """Return True if module supports the feature.""" - return self._supported & module_feature == module_feature + return module_feature in self._supported def query(self): """Return the base query.""" diff --git a/kasa/iot/modules/emeter.py b/kasa/iot/modules/emeter.py index 5a227ae49..7ae89e5b6 100644 --- a/kasa/iot/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -12,15 +12,22 @@ class Emeter(Usage, EnergyInterface): """Emeter module.""" - _supported = ( - EnergyInterface.ModuleFeature.CONSUMPTION_TOTAL - | EnergyInterface.ModuleFeature.PERIODIC_STATS - | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT - ) - - def supports(self, module_feature: EnergyInterface.ModuleFeature) -> bool: - """Return True if module supports the feature.""" - return self._supported & module_feature == module_feature + 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 + ) + if ( + "total_wh" in self.data["get_realtime"] + or "total" in self.data["get_realtime"] + ): + self._supported = ( + self._supported | EnergyInterface.ModuleFeature.CONSUMPTION_TOTAL + ) @property # type: ignore def status(self) -> EmeterStatus: diff --git a/kasa/smart/modules/energy.py b/kasa/smart/modules/energy.py index fc1e09049..3edbddb47 100644 --- a/kasa/smart/modules/energy.py +++ b/kasa/smart/modules/energy.py @@ -2,26 +2,17 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...emeterstatus import EmeterStatus from ...exceptions import KasaException from ...interfaces.energy import Energy as EnergyInterface from ..smartmodule import SmartModule -if TYPE_CHECKING: - pass - class Energy(SmartModule, EnergyInterface): """Implementation of energy monitoring module.""" REQUIRED_COMPONENT = "energy_monitoring" - def supports(self, module_feature: EnergyInterface.ModuleFeature) -> bool: - """Return True if module supports the feature.""" - return False - def query(self) -> dict: """Query to execute during the update cycle.""" req = { diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index 33b270b37..b710ec73f 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -10,8 +10,9 @@ Schema, ) -from kasa import EmeterStatus -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 @@ -173,3 +174,30 @@ def data(self): {"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