From b4a6df2b5cef00066d1d8279be019329b5e680a2 Mon Sep 17 00:00:00 2001
From: Steven B <51370195+sdb9696@users.noreply.github.com>
Date: Mon, 17 Jun 2024 11:22:05 +0100
Subject: [PATCH] Add common energy module and deprecate device emeter
 attributes (#976)

Consolidates logic for energy monitoring across smart and iot devices.
Deprecates emeter attributes in favour of common names.
---
 kasa/device.py               |  52 ++++------
 kasa/interfaces/__init__.py  |   2 +
 kasa/interfaces/energy.py    | 181 +++++++++++++++++++++++++++++++++++
 kasa/iot/iotbulb.py          |   2 +-
 kasa/iot/iotdevice.py        | 118 ++++-------------------
 kasa/iot/iotstrip.py         | 166 +++++++++++++++++++++-----------
 kasa/iot/modules/emeter.py   | 119 ++++++-----------------
 kasa/iot/modules/led.py      |   5 +
 kasa/module.py               |   5 +-
 kasa/smart/modules/energy.py | 118 +++++++++++------------
 kasa/smart/smartdevice.py    |  26 -----
 kasa/tests/test_device.py    |  20 ++--
 kasa/tests/test_emeter.py    |  44 +++++++--
 kasa/tests/test_iotdevice.py |  11 ++-
 14 files changed, 487 insertions(+), 382 deletions(-)
 create mode 100644 kasa/interfaces/energy.py

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