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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 201 additions and 101 deletions

View File

@ -109,10 +109,13 @@ The commands are straightforward, so feel free to check `--help` for instruction
# Library usage
The property accesses use the data obtained before by awaiting `update()`.
The values are cached until the next update call.
Each method changing the state of the device will automatically update the cached state.
The values are cached until the next update call. In practice this means that property accesses do no I/O and are dependent, while I/O producing methods need to be awaited.
Errors are raised as `SmartDeviceException` instances for the user to handle.
Methods changing the state of the device do not invalidate the cache (i.e., there is no implicit `update()`).
You can assume that the operation has succeeded if no exception is raised.
These methods will return the device response, which can be useful for some use cases.
Errors are raised as `SmartDeviceException` instances for the library user to handle.
## Discovering devices
@ -160,6 +163,13 @@ await plug.turn_on()
```
## Getting emeter status (if applicable)
The `update()` call will automatically fetch the following emeter information:
* Current consumption (accessed through `emeter_realtime` property)
* Today's consumption (`emeter_today`)
* This month's consumption (`emeter_this_month`)
You can also request this information separately:
```python
print("Current consumption: %s" % await plug.get_emeter_realtime())
print("Per day: %s" % await plug.get_emeter_daily(year=2016, month=12))
@ -182,6 +192,7 @@ asyncio.run(bulb.update())
if bulb.is_dimmable:
asyncio.run(bulb.set_brightness(100))
asyncio.run(bulb.update())
print(bulb.brightness)
```
@ -189,6 +200,7 @@ if bulb.is_dimmable:
```python
if bulb.is_variable_color_temp:
await bulb.set_color_temp(3000)
await bulb.update()
print(bulb.color_temp)
```
@ -199,6 +211,7 @@ Hue is given in degrees (0-360) and saturation and value in percentage.
```python
if bulb.is_color:
await bulb.set_hsv(180, 100, 100) # set to cyan
await bulb.update()
print(bulb.hsv)
```

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