Optimize I/O access (#59)

* Optimize I/O access

A single update() will now fetch information from all interesting modules,
including the current device state and the emeter information.

In practice, this will allow dropping the number of I/O reqs per homeassistant update cycle to 1,
which is paramount at least for bulbs which are very picky about sequential accesses.
This can be further extend to other modules/methods, if needed.

Currently fetched data:
* sysinfo
* realtime, today's and this months emeter stats

New properties:
* emeter_realtime: return the most recent emeter information, update()-version of get_emeter_realtime()
* emeter_today: returning today's energy consumption
* emeter_this_month: same for this month

Other changes:
* Accessing @requires_update properties will cause SmartDeviceException if the device has not ever been update()d
* Fix __repr__ for devices that haven't been updated
* Smartbulb uses now the state data from get_sysinfo instead of separately querying the bulb service
* SmartStrip's state_information no longer lists onsince for separate plugs
* The above mentioned properties are now printed out by cli
* Simplify is_on handling for bulbs

* remove implicit updates, return device responses for actions, update README.md instructions. fixes #61
This commit is contained in:
Teemu R
2020-05-24 17:57:54 +02:00
committed by GitHub
parent 012436c494
commit 836f1701b9
10 changed files with 201 additions and 101 deletions

View File

@@ -292,7 +292,7 @@ async def raw_command(dev: SmartDevice, module, command, parameters):
@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False)
@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False)
@click.option("--erase", is_flag=True)
async def emeter(dev, year, month, erase):
async def emeter(dev: SmartDevice, year, month, erase):
"""Query emeter for historical consumption."""
click.echo(click.style("== Emeter ==", bold=True))
await dev.update()
@@ -311,17 +311,21 @@ async def emeter(dev, year, month, erase):
elif month:
click.echo(f"== For month {month.month} of {month.year} ==")
emeter_status = await dev.get_emeter_daily(year=month.year, month=month.month)
else:
emeter_status = await dev.get_emeter_realtime()
click.echo("== Current State ==")
emeter_status = dev.emeter_realtime
if isinstance(emeter_status, list):
for plug in emeter_status:
index = emeter_status.index(plug) + 1
click.echo(f"Plug {index}: {plug}")
else:
click.echo(str(emeter_status))
click.echo("Current: %s A" % emeter_status["current"])
click.echo("Voltage: %s V" % emeter_status["voltage"])
click.echo("Power: %s W" % emeter_status["power"])
click.echo("Total consumption: %s kWh" % emeter_status["total"])
click.echo("Today: %s kWh" % dev.emeter_today)
click.echo("This month: %s kWh" % dev.emeter_this_month)
@cli.command()

View File

@@ -1,6 +1,6 @@
"""Module for bulbs."""
import re
from typing import Any, Dict, Optional, Tuple
from typing import Any, Dict, Tuple, cast
from kasa.smartdevice import (
DeviceType,
@@ -33,6 +33,7 @@ class SmartBulb(SmartDevice):
# change state of bulb
await p.turn_on()
await p.update()
assert p.is_on
await p.turn_off()
@@ -44,6 +45,7 @@ class SmartBulb(SmartDevice):
print("we got color!")
# set the color to an HSV tuple
await p.set_hsv(180, 100, 100)
await p.update()
# get the current HSV value
print(p.hsv)
@@ -51,6 +53,7 @@ class SmartBulb(SmartDevice):
if p.is_variable_color_temp:
# set the color temperature in Kelvin
await p.set_color_temp(3000)
await p.update()
# get the current color temperature
print(p.color_temp)
@@ -59,6 +62,7 @@ class SmartBulb(SmartDevice):
if p.is_dimmable:
# set the bulb to 50% brightness
await p.set_brightness(50)
await p.update()
# check the current brightness
print(p.brightness)
@@ -74,7 +78,6 @@ class SmartBulb(SmartDevice):
super().__init__(host=host)
self.emeter_type = "smartlife.iot.common.emeter"
self._device_type = DeviceType.Bulb
self._light_state = None
@property # type: ignore
@requires_update
@@ -126,19 +129,28 @@ class SmartBulb(SmartDevice):
return temp_range
return (0, 0)
async def update(self):
"""Update `sys_info and `light_state`."""
self._sys_info = await self.get_sys_info()
self._light_state = await self.get_light_state()
@property # type: ignore
@requires_update
def light_state(self) -> Optional[Dict[str, Dict]]:
def light_state(self) -> Dict[str, str]:
"""Query the light state."""
return self._light_state
light_state = self._last_update["system"]["get_sysinfo"]["light_state"]
if light_state is None:
raise SmartDeviceException(
"The device has no light_state or you have not called update()"
)
# if the bulb is off, its state is stored under a different key
# as is_on property depends on on_off itself, we check it here manually
is_on = light_state["on_off"]
if not is_on:
off_state = {**light_state["dft_on_state"], "on_off": is_on}
return cast(dict, off_state)
return light_state
async def get_light_state(self) -> Dict[str, Dict]:
"""Query the light state."""
# TODO: add warning and refer to use light.state?
return await self._query_helper(self.LIGHT_SERVICE, "get_light_state")
async def set_light_state(self, state: Dict) -> Dict:
@@ -146,7 +158,6 @@ class SmartBulb(SmartDevice):
light_state = await self._query_helper(
self.LIGHT_SERVICE, "transition_light_state", state
)
await self.update()
return light_state
@property # type: ignore
@@ -160,15 +171,11 @@ class SmartBulb(SmartDevice):
if not self.is_color:
raise SmartDeviceException("Bulb does not support color.")
light_state = self.light_state
if not self.is_on:
hue = light_state["dft_on_state"]["hue"]
saturation = light_state["dft_on_state"]["saturation"]
value = light_state["dft_on_state"]["brightness"]
else:
hue = light_state["hue"]
saturation = light_state["saturation"]
value = light_state["brightness"]
light_state = cast(dict, self.light_state)
hue = light_state["hue"]
saturation = light_state["saturation"]
value = light_state["brightness"]
return hue, saturation, value
@@ -222,10 +229,7 @@ class SmartBulb(SmartDevice):
raise SmartDeviceException("Bulb does not support colortemp.")
light_state = self.light_state
if not self.is_on:
return int(light_state["dft_on_state"]["color_temp"])
else:
return int(light_state["color_temp"])
return int(light_state["color_temp"])
@requires_update
async def set_color_temp(self, temp: int) -> None:
@@ -258,10 +262,7 @@ class SmartBulb(SmartDevice):
raise SmartDeviceException("Bulb is not dimmable.")
light_state = self.light_state
if not self.is_on:
return int(light_state["dft_on_state"]["brightness"])
else:
return int(light_state["brightness"])
return int(light_state["brightness"])
@requires_update
async def set_brightness(self, brightness: int) -> None:

View File

@@ -99,7 +99,10 @@ def requires_update(f):
@functools.wraps(f)
async def wrapped(*args, **kwargs):
self = args[0]
assert self._sys_info is not None
if self._last_update is None:
raise SmartDeviceException(
"You need to await update() to access the data"
)
return await f(*args, **kwargs)
else:
@@ -107,7 +110,10 @@ def requires_update(f):
@functools.wraps(f)
def wrapped(*args, **kwargs):
self = args[0]
assert self._sys_info is not None
if self._last_update is None:
raise SmartDeviceException(
"You need to await update() to access the data"
)
return f(*args, **kwargs)
f.requires_update = True
@@ -129,7 +135,20 @@ class SmartDevice:
self.emeter_type = "emeter"
_LOGGER.debug("Initializing %s of type %s", self.host, type(self))
self._device_type = DeviceType.Unknown
self._sys_info: Optional[Dict] = None
# TODO: typing Any is just as using Optional[Dict] would require separate checks in
# accessors. the @updated_required decorator does not ensure mypy that these
# are not accessed incorrectly.
self._last_update: Any = None
self._sys_info: Any = None # TODO: this is here to avoid changing tests
def _create_request(
self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None
):
request: Dict[str, Any] = {target: {cmd: arg}}
if child_ids is not None:
request = {"context": {"child_ids": child_ids}, target: {cmd: arg}}
return request
async def _query_helper(
self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None
@@ -139,13 +158,12 @@ class SmartDevice:
:param target: Target system {system, time, emeter, ..}
:param cmd: Command to execute
:param arg: JSON object passed as parameter to the command
:param child_ids: ids of child devices
:return: Unwrapped result for the call.
:rtype: dict
:raises SmartDeviceException: if command was not executed correctly
"""
request: Dict[str, Any] = {target: {cmd: arg}}
if child_ids is not None:
request = {"context": {"child_ids": child_ids}, target: {cmd: arg}}
request = self._create_request(target, cmd, arg, child_ids)
try:
response = await self.protocol.query(host=self.host, request=request)
@@ -196,7 +214,15 @@ class SmartDevice:
Needed for methods that are decorated with `requires_update`.
"""
self._sys_info = await self.get_sys_info()
req = {}
req.update(self._create_request("system", "get_sysinfo"))
# Check for emeter if we were never updated, or if the device has emeter
if self._last_update is None or self.has_emeter:
req.update(self._create_emeter_request())
self._last_update = await self.protocol.query(self.host, req)
# TODO: keep accessible for tests
self._sys_info = self._last_update["system"]["get_sysinfo"]
@property # type: ignore
@requires_update
@@ -207,8 +233,7 @@ class SmartDevice:
:rtype dict
:raises SmartDeviceException: on error
"""
assert self._sys_info is not None
return self._sys_info
return self._sys_info # type: ignore
@property # type: ignore
@requires_update
@@ -239,8 +264,7 @@ class SmartDevice:
:param alias: New alias (name)
:raises SmartDeviceException: on error
"""
await self._query_helper("system", "set_dev_alias", {"alias": alias})
await self.update()
return await self._query_helper("system", "set_dev_alias", {"alias": alias})
async def get_icon(self) -> Dict:
"""Return device icon.
@@ -415,10 +439,17 @@ class SmartDevice:
:param str mac: mac in hexadecimal with colons, e.g. 01:23:45:67:89:ab
:raises SmartDeviceException: on error
"""
await self._query_helper("system", "set_mac_addr", {"mac": mac})
await self.update()
return await self._query_helper("system", "set_mac_addr", {"mac": mac})
@property # type: ignore
@requires_update
def emeter_realtime(self) -> EmeterStatus:
"""Return current emeter status."""
if not self.has_emeter:
raise SmartDeviceException("Device has no emeter")
return EmeterStatus(self._last_update[self.emeter_type]["get_realtime"])
async def get_emeter_realtime(self) -> EmeterStatus:
"""Retrieve current energy readings.
@@ -431,7 +462,83 @@ class SmartDevice:
return EmeterStatus(await self._query_helper(self.emeter_type, "get_realtime"))
def _create_emeter_request(self, year: int = None, month: int = None):
"""Create a Internal method for building a request for all emeter statistics at once."""
if year is None:
year = datetime.now().year
if month is None:
month = datetime.now().month
import collections.abc
def update(d, u):
"""Update dict recursively."""
for k, v in u.items():
if isinstance(v, collections.abc.Mapping):
d[k] = update(d.get(k, {}), v)
else:
d[k] = v
return d
req: Dict[str, Any] = {}
update(req, self._create_request(self.emeter_type, "get_realtime"))
update(
req, self._create_request(self.emeter_type, "get_monthstat", {"year": year})
)
update(
req,
self._create_request(
self.emeter_type, "get_daystat", {"month": month, "year": year}
),
)
return req
@property # type: ignore
@requires_update
def emeter_today(self) -> Optional[float]:
"""Return today's energy consumption in kWh."""
raw_data = self._last_update[self.emeter_type]["get_daystat"]["day_list"]
data = self._emeter_convert_emeter_data(raw_data)
today = datetime.now().day
if today in data:
return data[today]
return None
@property # type: ignore
@requires_update
def emeter_this_month(self) -> Optional[float]:
"""Return this month's energy consumption in kWh."""
raw_data = self._last_update[self.emeter_type]["get_monthstat"]["month_list"]
data = self._emeter_convert_emeter_data(raw_data)
current_month = datetime.now().month
if current_month in data:
return data[current_month]
return None
def _emeter_convert_emeter_data(self, data, kwh=True) -> Dict:
"""Return emeter information keyed with the day/month.."""
response = [EmeterStatus(**x) for x in data]
if not response:
return {}
energy_key = "energy_wh"
if kwh:
energy_key = "energy"
entry_key = "month"
if "day" in response[0]:
entry_key = "day"
data = {entry[entry_key]: entry[energy_key] for entry in response}
return data
async def get_emeter_daily(
self, year: int = None, month: int = None, kwh: bool = True
) -> Dict:
@@ -456,15 +563,8 @@ class SmartDevice:
response = await self._query_helper(
self.emeter_type, "get_daystat", {"month": month, "year": year}
)
response = [EmeterStatus(**x) for x in response["day_list"]]
key = "energy_wh"
if kwh:
key = "energy"
data = {entry["day"]: entry[key] for entry in response}
return data
return self._emeter_convert_emeter_data(response["day_list"], kwh)
@requires_update
async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict:
@@ -485,13 +585,8 @@ class SmartDevice:
response = await self._query_helper(
self.emeter_type, "get_monthstat", {"year": year}
)
response = [EmeterStatus(**x) for x in response["month_list"]]
key = "energy_wh"
if kwh:
key = "energy"
return {entry["month"]: entry[key] for entry in response}
return self._emeter_convert_emeter_data(response["month_list"], kwh)
@requires_update
async def erase_emeter_stats(self):
@@ -503,8 +598,7 @@ class SmartDevice:
if not self.has_emeter:
raise SmartDeviceException("Device has no emeter")
await self._query_helper(self.emeter_type, "erase_emeter_stat", None)
await self.update()
return await self._query_helper(self.emeter_type, "erase_emeter_stat", None)
@requires_update
async def current_consumption(self) -> float:
@@ -673,11 +767,6 @@ class SmartDevice:
return False
def __repr__(self):
return "<{} model {} at {} ({}), is_on: {} - dev specific: {}>".format(
self.__class__.__name__,
self.model,
self.host,
self.alias,
self.is_on,
self.state_information,
)
if self._last_update is None:
return f"<{self._device_type} at {self.host} - update() needed>"
return f"<{self._device_type} model {self.model} at {self.host} ({self.alias}), is_on: {self.is_on} - dev specific: {self.state_information}>"

View File

@@ -60,10 +60,9 @@ class SmartDimmer(SmartPlug):
if not isinstance(value, int):
raise ValueError("Brightness must be integer, " "not of %s.", type(value))
elif 0 <= value <= 100:
await self._query_helper(
return await self._query_helper(
"smartlife.iot.dimmer", "set_brightness", {"brightness": value}
)
await self.update()
else:
raise ValueError("Brightness value %s is not valid." % value)

View File

@@ -50,16 +50,14 @@ class SmartPlug(SmartDevice):
:raises SmartDeviceException: on error
"""
await self._query_helper("system", "set_relay_state", {"state": 1})
await self.update()
return await self._query_helper("system", "set_relay_state", {"state": 1})
async def turn_off(self):
"""Turn the switch off.
:raises SmartDeviceException: on error
"""
await self._query_helper("system", "set_relay_state", {"state": 0})
await self.update()
return await self._query_helper("system", "set_relay_state", {"state": 0})
@property # type: ignore
@requires_update
@@ -78,8 +76,9 @@ class SmartPlug(SmartDevice):
:param bool state: True to set led on, False to set led off
:raises SmartDeviceException: on error
"""
await self._query_helper("system", "set_led_off", {"off": int(not state)})
await self.update()
return await self._query_helper(
"system", "set_led_off", {"off": int(not state)}
)
@property # type: ignore
@requires_update

View File

@@ -146,12 +146,11 @@ class SmartStrip(SmartDevice):
:return: Strip information dict, keys in user-presentable form.
:rtype: dict
"""
state: Dict[str, Any] = {"LED state": self.led}
for plug in self.plugs:
if plug.is_on:
state["Plug %s on since" % str(plug)] = self.on_since
return state
return {
"LED state": self.led,
"Childs count": len(self.plugs),
"On since": self.on_since,
}
async def current_consumption(self) -> float:
"""Get the current power consumption in watts.
@@ -218,6 +217,7 @@ class SmartStrip(SmartDevice):
plug_emeter_monthly = await plug.get_emeter_monthly(year=year, kwh=kwh)
for month, value in plug_emeter_monthly:
emeter_monthly[month] += value
return emeter_monthly
@requires_update
@@ -245,7 +245,8 @@ class SmartStripPlug(SmartPlug):
self.parent = parent
self.child_id = child_id
self._sys_info = {**self.parent.sys_info, **self._get_child_info()}
self._last_update = parent._last_update
self._sys_info = parent._sys_info
async def update(self):
"""Override the update to no-op and inform the user."""

View File

@@ -313,9 +313,7 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
def transition_light_state(self, x, *args):
_LOGGER.debug("Setting light state to %s", x)
light_state = self.proto["smartlife.iot.smartbulb.lightingservice"][
"get_light_state"
]
light_state = self.proto["system"]["get_sysinfo"]["light_state"]
# The required change depends on the light state,
# exception being turning the bulb on and off
@@ -323,15 +321,11 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
if x["on_off"] and not light_state["on_off"]: # turning on
new_state = light_state["dft_on_state"]
new_state["on_off"] = 1
self.proto["smartlife.iot.smartbulb.lightingservice"][
"get_light_state"
] = new_state
self.proto["system"]["get_sysinfo"]["light_state"] = new_state
elif not x["on_off"] and light_state["on_off"]:
new_state = {"dft_on_state": light_state, "on_off": 0}
self.proto["smartlife.iot.smartbulb.lightingservice"][
"get_light_state"
] = new_state
self.proto["system"]["get_sysinfo"]["light_state"] = new_state
return
@@ -343,11 +337,9 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
light_state[key] = x[key]
def light_state(self, x, *args):
light_state = self.proto["smartlife.iot.smartbulb.lightingservice"][
"get_light_state"
]
light_state = self.proto["system"]["get_sysinfo"]["light_state"]
# Our tests have light state off, so we simply return the dft_on_state when device is on.
_LOGGER.info("reporting light state: %s", light_state)
_LOGGER.debug("reporting light state: %s", light_state)
if light_state["on_off"]:
return light_state["dft_on_state"]
else:

View File

@@ -66,7 +66,7 @@ async def test_emeter(dev: SmartDevice, mocker):
assert "Device has no emeter" in res.output
return
assert "Current State" in res.output
assert "== Emeter ==" in res.output
monthly = mocker.patch.object(dev, "get_emeter_monthly")
res = await runner.invoke(emeter, ["--year", "1900"], obj=dev)

View File

@@ -419,9 +419,11 @@ async def test_children_alias(dev):
for plug in dev.plugs:
original = plug.alias
await plug.set_alias(alias=test_alias)
await dev.update() # TODO: set_alias does not call parent's update()..
assert plug.alias == test_alias
await plug.set_alias(alias=original)
await dev.update() # TODO: set_alias does not call parent's update()..
assert plug.alias == original