Add common energy module and deprecate device emeter attributes (#976)

Consolidates logic for energy monitoring across smart and iot devices.
Deprecates emeter attributes in favour of common names.
This commit is contained in:
Steven B
2024-06-17 11:22:05 +01:00
committed by GitHub
parent 51a972542f
commit b4a6df2b5c
14 changed files with 487 additions and 382 deletions

View File

@@ -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))

View File

@@ -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.

View File

@@ -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

View File

@@ -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, ...}.

View File

@@ -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