Simplify API documentation by using doctests (#73)

* Add doctests to SmartBulb

* Add SmartDevice doctests, cleanup README.md

* add doctests for smartplug and smartstrip

* add discover doctests

* Fix bulb mock

* add smartdimmer doctests

* add sphinx-generated docs, cleanup readme a bit

* remove sphinx-click as it does not work with asyncclick

* in preparation for rtd hooking, move doc deps to be separate from dev deps

* pytestmark needs to be applied separately for each and every file, this fixes the tests

* use pathlib for resolving relative paths

* Skip discovery doctest on python3.7

The code is just fine, but some reason the mocking behaves differently between 3.7 and 3.8.
The latter seems to accept a discrete object for asyncio.run where the former expects a coroutine..
This commit is contained in:
Teemu R 2020-06-30 02:29:52 +02:00 committed by GitHub
parent 99e0c4a418
commit f9a987ca18
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 748 additions and 303 deletions

View File

@ -2,6 +2,7 @@
exclude = .git,.tox,__pycache__,kasa/tests/newfakes.py,kasa/tests/test_fixtures.py
max-line-length = 88
per-file-ignores =
kasa/tests/*.py:D100,D101,D102,D103,D104
kasa/tests/*.py:D100,D101,D102,D103,D104,F401
docs/source/conf.py:D100,D103
ignore = D105, D107, E203, E501, W503
max-complexity = 18

127
README.md
View File

@ -37,30 +37,10 @@ This project is a maintainer-made fork of [pyHS100](https://github.com/GadgetRea
**Contributions (be it adding missing features, fixing bugs or improving documentation) are more than welcome, feel free to submit pull requests! See below for instructions for setting up a development environment.**
# Usage
The package is shipped with a console tool named kasa, please refer to ```kasa --help``` for detailed usage.
The device to which the commands are sent is chosen by `KASA_HOST` environment variable or passing `--host <address>` as an option.
To see what is being sent to and received from the device, specify option `--debug`.
To avoid discovering the devices when executing commands its type can be passed by specifying either `--plug` or `--bulb`,
if no type is given its type will be discovered automatically with a small delay.
Some commands (such as reading energy meter values and setting color of bulbs) additional parameters are required,
which you can find by adding `--help` after the command, e.g. `kasa emeter --help` or `kasa hsv --help`.
If no command is given, the `state` command will be executed to query the device state.
## Initial Setup
You can provision your device without any extra apps by using the `kasa wifi` command:
1. If the device is unprovisioned, connect to its open network
2. Use `kasa discover` (or check the routes) to locate the IP address of the device (likely 192.168.0.1)
3. Scan for available networks using `kasa wifi scan`
4. Join/change the network using `kasa wifi join` command, see `--help` for details.
## Discovering devices
The devices can be discovered either by using `kasa discover` or by calling `kasa` without any parameters.
In both cases supported devices are discovered from the same broadcast domain, and their current state will be queried and printed out.
```
$ kasa
@ -108,112 +88,7 @@ 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. In practice this means that property accesses do no I/O and are dependent, while I/O producing methods need to be awaited.
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
`Discover.discover()` can be used to discover supported devices in the local network.
The return value is a dictionary keyed with the IP address and the value holds a ready-to-use instance of the detected device type.
Example:
```python
import asyncio
from kasa import Discover
devices = asyncio.run(Discover.discover())
for addr, dev in devices.items():
asyncio.run(dev.update())
print(f"{addr} >> {dev}")
```
```
$ python example.py
<SmartPlug at 192.168.XXX.XXX (My Smart Plug), is_on: True - dev specific: {'LED state': True, 'On since': datetime.datetime(2017, 3, 26, 18, 29, 17, 52073)}>
```
## Querying basic information
```python
import asyncio
from kasa import SmartPlug
from pprint import pformat as pf
plug = SmartPlug("192.168.XXX.XXX")
asyncio.run(plug.update())
print("Hardware: %s" % pf(plug.hw_info))
print("Full sysinfo: %s" % pf(plug.sys_info))
```
The rest of the examples assume that you have initialized an instance.
## State & switching
Devices can be turned on and off by either calling appropriate methods on the device object.
```python
print("Current state: %s" % plug.is_on)
await plug.turn_off()
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))
print("Per month: %s" % await plug.get_emeter_monthly(year=2016))
```
## Bulb and dimmer-specific APIs
The bulb API is likewise straightforward, so please refer to its API documentation.
Information about supported features can be queried by using properties prefixed with `is_`, e.g. `is_dimmable`.
### Setting the brightness
```python
import asyncio
from kasa import SmartBulb
bulb = SmartBulb("192.168.1.123")
asyncio.run(bulb.update())
if bulb.is_dimmable:
asyncio.run(bulb.set_brightness(100))
asyncio.run(bulb.update())
print(bulb.brightness)
```
### Setting the color temperature
```python
if bulb.is_variable_color_temp:
await bulb.set_color_temp(3000)
await bulb.update()
print(bulb.color_temp)
```
### Setting the color
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)
```
You can find several code examples in [the API documentation](broken link).
## Contributing

20
docs/Makefile Normal file
View File

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

35
docs/make.bat Normal file
View File

@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

View File

@ -0,0 +1,65 @@
// Copyright 2014 PSF. Licensed under the PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
// File originates from the cpython source found in Doc/tools/sphinxext/static/copybutton.js
$(document).ready(function() {
/* Add a [>>>] button on the top-right corner of code samples to hide
* the >>> and ... prompts and the output and thus make the code
* copyable. */
var div = $('.highlight-python .highlight,' +
'.highlight-default .highlight,' +
'.highlight-python3 .highlight')
var pre = div.find('pre');
// get the styles from the current theme
pre.parent().parent().css('position', 'relative');
var hide_text = 'Hide the prompts and output';
var show_text = 'Show the prompts and output';
var border_width = pre.css('border-top-width');
var border_style = pre.css('border-top-style');
var border_color = pre.css('border-top-color');
var button_styles = {
'cursor':'pointer', 'position': 'absolute', 'top': '0', 'right': '0',
'border-color': border_color, 'border-style': border_style,
'border-width': border_width, 'color': border_color, 'text-size': '75%',
'font-family': 'monospace', 'padding-left': '0.2em', 'padding-right': '0.2em',
'border-radius': '0 3px 0 0'
}
// create and add the button to all the code blocks that contain >>>
div.each(function(index) {
var jthis = $(this);
if (jthis.find('.gp').length > 0) {
var button = $('<span class="copybutton">&gt;&gt;&gt;</span>');
button.css(button_styles)
button.attr('title', hide_text);
button.data('hidden', 'false');
jthis.prepend(button);
}
// tracebacks (.gt) contain bare text elements that need to be
// wrapped in a span to work with .nextUntil() (see later)
jthis.find('pre:has(.gt)').contents().filter(function() {
return ((this.nodeType == 3) && (this.data.trim().length > 0));
}).wrap('<span>');
});
// define the behavior of the button when it's clicked
$('.copybutton').click(function(e){
e.preventDefault();
var button = $(this);
if (button.data('hidden') === 'false') {
// hide the code output
button.parent().find('.go, .gp, .gt').hide();
button.next('pre').find('.gt').nextUntil('.gp, .go').css('visibility', 'hidden');
button.css('text-decoration', 'line-through');
button.attr('title', show_text);
button.data('hidden', 'true');
} else {
// show the code output
button.parent().find('.go, .gp, .gt').show();
button.next('pre').find('.gt').nextUntil('.gp, .go').css('visibility', 'visible');
button.css('text-decoration', 'none');
button.attr('title', hide_text);
button.data('hidden', 'false');
}
});
});

28
docs/source/cli.rst Normal file
View File

@ -0,0 +1,28 @@
Command-line usage
==================
The package is shipped with a console tool named kasa, please refer to ``kasa --help`` for detailed usage.
The device to which the commands are sent is chosen by `KASA_HOST` environment variable or passing ``--host <address>`` as an option.
To see what is being sent to and received from the device, specify option ``--debug``.
To avoid discovering the devices when executing commands its type can be passed by specifying either ``--plug`` or ``--bulb``,
if no type is given its type will be discovered automatically with a small delay.
Some commands (such as reading energy meter values and setting color of bulbs) additional parameters are required,
which you can find by adding ``--help`` after the command, e.g. ``kasa emeter --help`` or ``kasa hsv --help``.
If no command is given, the ``state`` command will be executed to query the device state.
Provisioning
~~~~~~~~~~~~
You can provision your device without any extra apps by using the ``kasa wifi`` command:
1. If the device is unprovisioned, connect to its open network
2. Use ``kasa discover`` (or check the routes) to locate the IP address of the device (likely 192.168.0.1)
3. Scan for available networks using ``kasa wifi scan``
4. Join/change the network using ``kasa wifi join`` command, see ``--help`` for details.
``kasa --help``
~~~~~~~~~~~~~~~
.. program-output:: kasa --help

70
docs/source/conf.py Normal file
View File

@ -0,0 +1,70 @@
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
# -- Project information -----------------------------------------------------
project = "python-kasa"
copyright = "2020, python-kasa developers"
author = "python-kasa developers"
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.coverage",
"sphinx.ext.viewcode",
"sphinxcontrib.programoutput",
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = [] # type: ignore
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = "sphinx_rtd_theme"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ["_static"]
def setup(app):
# add copybutton to hide the >>> prompts, see https://github.com/readthedocs/sphinx_rtd_theme/issues/167
app.add_js_file("copybutton.js")
# see https://github.com/readthedocs/recommonmark/issues/191#issuecomment-622369992
from m2r import MdInclude
app.add_config_value("no_underscore_emphasis", False, "env")
app.add_config_value("m2r_parse_relative_links", False, "env")
app.add_config_value("m2r_anonymous_references", False, "env")
app.add_config_value("m2r_disable_inline_math", False, "env")
app.add_directive("mdinclude", MdInclude)

17
docs/source/discover.rst Normal file
View File

@ -0,0 +1,17 @@
Discovering devices
===================
.. code-block::
import asyncio
from kasa import Discover
devices = asyncio.run(Discover.discover())
for addr, dev in devices.items():
asyncio.run(dev.update())
print(f"{addr} >> {dev}")
.. autoclass:: kasa.Discover
:members:
:undoc-members:

17
docs/source/index.rst Normal file
View File

@ -0,0 +1,17 @@
python-kasa documentation
=========================
.. mdinclude:: ../../README.md
.. toctree::
:maxdepth: 2
Home <self>
cli
discover
smartdevice
smartbulb
smartplug
smartdimmer
smartstrip

View File

@ -0,0 +1,6 @@
Bulbs
===========
.. autoclass:: kasa.SmartBulb
:members:
:undoc-members:

View File

@ -0,0 +1,18 @@
Common API
======================
The basic functionalities of all supported devices are accessible using the common :class:`SmartDevice` base class.
The property accesses use the data obtained before by awaiting :func:`update()`.
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.
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 :class:`SmartDeviceException` instances for the library user to handle.
.. autoclass:: kasa.SmartDevice
:members:
:undoc-members:

View File

@ -0,0 +1,6 @@
Dimmers
=======
.. autoclass:: kasa.SmartDimmer
:members:
:undoc-members:

View File

@ -0,0 +1,6 @@
Plugs
=====
.. autoclass:: kasa.SmartPlug
:members:
:undoc-members:

View File

@ -0,0 +1,6 @@
Smart strips
============
.. autoclass:: kasa.SmartStrip
:members:
:undoc-members:

View File

@ -21,7 +21,7 @@ OnDiscoveredCallable = Callable[[SmartDevice], Awaitable[None]]
class _DiscoverProtocol(asyncio.DatagramProtocol):
"""Implementation of the discovery protocol handler.
This is internal class, use :func:Discover.discover: instead.
This is internal class, use :func:`Discover.discover`: instead.
"""
discovered_devices: Dict[str, SmartDevice]
@ -72,6 +72,7 @@ class _DiscoverProtocol(asyncio.DatagramProtocol):
device_class = Discover._get_device_class(info)
device = device_class(ip)
asyncio.ensure_future(device.update())
self.discovered_devices[ip] = device
self.discovered_devices_raw[ip] = info
@ -93,16 +94,36 @@ class _DiscoverProtocol(asyncio.DatagramProtocol):
class Discover:
"""Discover TPLink Smart Home devices.
The main entry point for this library is Discover.discover(),
The main entry point for this library is :func:`Discover.discover()`,
which returns a dictionary of the found devices. The key is the IP address
of the device and the value contains ready-to-use, SmartDevice-derived
device object.
discover_single() can be used to initialize a single device given its
:func:`discover_single()` can be used to initialize a single device given its
IP address. If the type of the device and its IP address is already known,
you can initialize the corresponding device class directly without this.
The protocol uses UDP broadcast datagrams on port 9999 for discovery.
Examples:
Discovery returns a list of discovered devices:
>>> import asyncio
>>> found_devices = asyncio.run(Discover.discover())
>>> [dev.alias for dev in found_devices]
['TP-LINK_Power Strip_CF69']
Discovery can also be targeted to a specific broadcast address instead of the 255.255.255.255:
>>> asyncio.run(Discover.discover(target="192.168.8.255"))
It is also possible to pass a coroutine to be executed for each found device:
>>> async def print_alias(dev):
>>> print(f"Discovered {dev.alias}")
>>> devices = asyncio.run(Discover.discover(on_discovered=print_alias))
"""
DISCOVERY_PORT = 9999
@ -130,12 +151,13 @@ class Discover:
to detect available supported devices in the local network,
and waits for given timeout for answers from devices.
If given, `on_discovered` coroutine will get passed with the SmartDevice as parameter.
The results of the discovery can be accessed either via `discovered_devices` (SmartDevice-derived) or
`discovered_devices_raw` (JSON objects).
If given, `on_discovered` coroutine will get passed with the :class:`SmartDevice`-derived object as parameter.
The results of the discovery are returned either as a list of :class:`SmartDevice`-derived objects
or as raw response dictionaries objects (if `return_raw` is True).
:param target: The target broadcast address (e.g. 192.168.xxx.255).
:param on_discovered:
:param on_discovered: coroutine to execute on discovery
:param timeout: How long to wait for responses, defaults to 5
:param discovery_packets: Number of discovery packets are broadcasted.
:param return_raw: True to return JSON objects instead of Devices.
@ -180,7 +202,9 @@ class Discover:
device_class = Discover._get_device_class(info)
if device_class is not None:
return device_class(host)
dev = device_class(host)
await dev.update()
return dev
raise SmartDeviceException("Unable to discover device, received: %s" % info)

View File

@ -23,53 +23,69 @@ TPLINK_KELVIN = {
class SmartBulb(SmartDevice):
"""Representation of a TP-Link Smart Bulb.
Usage example:
```python
p = SmartBulb("192.168.1.105")
await p.update()
To initialize, you have to await :func:`update()` at least once.
This will allow accessing the properties using the exposed properties.
# print the devices alias
print(p.alias)
All changes to the device are done using awaitable methods,
which will not change the cached values, but you must await :func:`update()` separately.
# change state of bulb
await p.turn_on()
await p.update()
assert p.is_on
await p.turn_off()
# query and print current state of plug
print(p.state_information)
# check whether the bulb supports color changes
if p.is_color:
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)
# check whether the bulb supports setting color temperature
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)
# check whether the bulb is dimmable
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)
```
Errors reported by the device are raised as SmartDeviceExceptions,
Errors reported by the device are raised as :class:`SmartDeviceException`s,
and should be handled by the user of the library.
Examples:
>>> import asyncio
>>> bulb = SmartBulb("127.0.0.1")
>>> asyncio.run(bulb.update())
>>> print(bulb.alias)
KL130 office bulb
Bulbs, like any other supported devices, can be turned on and off:
>>> asyncio.run(bulb.turn_off())
>>> asyncio.run(bulb.turn_on())
>>> asyncio.run(bulb.update())
>>> print(bulb.is_on)
True
You can use the is_-prefixed properties to check for supported features
>>> bulb.is_dimmable
True
>>> bulb.is_color
True
>>> bulb.is_variable_color_temp
True
All known bulbs support changing the brightness:
>>> bulb.brightness
30
>>> asyncio.run(bulb.set_brightness(50))
>>> asyncio.run(bulb.update())
>>> bulb.brightness
50
Bulbs supporting color temperature can be queried to know which range is accepted:
>>> bulb.valid_temperature_range
(2500, 9000)
>>> asyncio.run(bulb.set_color_temp(3000))
>>> asyncio.run(bulb.update())
>>> bulb.color_temp
3000
Color bulbs can be adjusted by passing hue, saturation and value:
>>> asyncio.run(bulb.set_hsv(180, 100, 80))
>>> asyncio.run(bulb.update())
>>> bulb.hsv
(180, 100, 80)
If you don't want to use the default transitions, you can pass `transition` in milliseconds.
This applies to all transitions (turn_on, turn_off, set_hsv, set_color_temp, set_brightness).
The following changes the brightness over a period of 10 seconds:
>>> asyncio.run(bulb.set_brightness(100, transition=10_000))
"""
LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice"

View File

@ -118,7 +118,98 @@ def requires_update(f):
class SmartDevice:
"""Base class for all supported device types."""
"""Base class for all supported device types.
You don't usually want to construct this class which implements the shared common interfaces.
The recommended way is to either use the discovery functionality, or construct one of the subclasses:
* :class:`SmartPlug`
* :class:`SmartBulb`
* :class:`SmartStrip`
* :class:`SmartDimmer`
To initialize, you have to await :func:`update()` at least once.
This will allow accessing the properties using the exposed properties.
All changes to the device are done using awaitable methods,
which will not change the cached values, but you must await update() separately.
Errors reported by the device are raised as SmartDeviceExceptions,
and should be handled by the user of the library.
Examples:
>>> import asyncio
>>> dev = SmartDevice("127.0.0.1")
>>> asyncio.run(dev.update())
All devices provide several informational properties:
>>> dev.alias
Kitchen
>>> dev.model
HS110(EU)
>>> dev.rssi
-71
>>> dev.mac
50:C7:BF:01:F8:CD
Some information can also be changed programatically:
>>> asyncio.run(dev.set_alias("new alias"))
>>> asyncio.run(dev.set_mac("01:23:45:67:89:ab"))
>>> asyncio.run(dev.update())
>>> dev.alias
new alias
>>> dev.mac
01:23:45:67:89:ab
When initialized using discovery or using a subclass, you can check the type of the device:
>>> dev.is_bulb
False
>>> dev.is_strip
False
>>> dev.is_plug
True
You can also get the hardware and software as a dict, or access the full device response:
>>> dev.hw_info
{'sw_ver': '1.2.5 Build 171213 Rel.101523',
'hw_ver': '1.0',
'mac': '01:23:45:67:89:ab',
'type': 'IOT.SMARTPLUGSWITCH',
'hwId': '45E29DA8382494D2E82688B52A0B2EB5',
'fwId': '00000000000000000000000000000000',
'oemId': '3D341ECE302C0642C99E31CE2430544B',
'dev_name': 'Wi-Fi Smart Plug With Energy Monitoring'}
>>> dev.sys_info
All devices can be turned on and off:
>>> asyncio.run(dev.turn_off())
>>> asyncio.run(dev.turn_on())
>>> asyncio.run(dev.update())
>>> dev.is_on
True
Some devices provide energy consumption meter, and regular update will already fetch some information:
>>> dev.has_emeter
True
>>> dev.emeter_realtime
{'current': 0.015342, 'err_code': 0, 'power': 0.983971, 'total': 32.448, 'voltage': 235.595234}
>>> dev.emeter_today
>>> dev.emeter_this_month
You can also query the historical data (note that these needs to be awaited), keyed with month/day:
>>> asyncio.run(dev.get_emeter_monthly(year=2016))
{11: 1.089, 12: 1.582}
>>> asyncio.run(dev.get_emeter_daily(year=2016, month=11))
{24: 0.026, 25: 0.109}
"""
def __init__(self, host: str) -> None:
"""Create a new SmartDevice instance.
@ -382,6 +473,9 @@ class SmartDevice:
@requires_update
def emeter_today(self) -> Optional[float]:
"""Return today's energy consumption in kWh."""
if not self.has_emeter:
raise SmartDeviceException("Device has no emeter")
raw_data = self._last_update[self.emeter_type]["get_daystat"]["day_list"]
data = self._emeter_convert_emeter_data(raw_data)
today = datetime.now().day
@ -395,6 +489,9 @@ class SmartDevice:
@requires_update
def emeter_this_month(self) -> Optional[float]:
"""Return this month's energy consumption in kWh."""
if not self.has_emeter:
raise SmartDeviceException("Device has no emeter")
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
@ -485,7 +582,7 @@ class SmartDevice:
response = EmeterStatus(await self.get_emeter_realtime())
return response["power"]
async def reboot(self, delay=1) -> None:
async def reboot(self, delay: int = 1) -> None:
"""Reboot the device.
Note that giving a delay of zero causes this to block,

View File

@ -9,18 +9,30 @@ class SmartDimmer(SmartPlug):
"""Representation of a TP-Link Smart Dimmer.
Dimmers work similarly to plugs, but provide also support for
adjusting the brightness. This class extends SmartPlug interface.
adjusting the brightness. This class extends :class:`SmartPlug` interface.
Example:
```
dimmer = SmartDimmer("192.168.1.105")
await dimmer.turn_on()
print("Current brightness: %s" % dimmer.brightness)
To initialize, you have to await :func:`update()` at least once.
This will allow accessing the properties using the exposed properties.
await dimmer.set_brightness(100)
```
All changes to the device are done using awaitable methods,
which will not change the cached values, but you must await :func:`update()` separately.
Refer to SmartPlug for the full API.
Errors reported by the device are raised as :class:`SmartDeviceException`s,
and should be handled by the user of the library.
Examples:
>>> import asyncio
>>> dimmer = SmartDimmer("192.168.1.105")
>>> asyncio.run(dimmer.turn_on())
>>> dimmer.brightness
25
>>> asyncio.run(dimmer.set_brightness(50))
>>> asyncio.run(dimmer.update())
>>> dimmer.brightness
50
Refer to :class:`SmartPlug` for the full API.
"""
DIMMER_SERVICE = "smartlife.iot.dimmer"

View File

@ -10,24 +10,30 @@ _LOGGER = logging.getLogger(__name__)
class SmartPlug(SmartDevice):
"""Representation of a TP-Link Smart Switch.
Usage example:
```python
p = SmartPlug("192.168.1.105")
To initialize, you have to await :func:`update()` at least once.
This will allow accessing the properties using the exposed properties.
# print the devices alias
print(p.alias)
All changes to the device are done using awaitable methods,
which will not change the cached values, but you must await :func:`update()` separately.
# change state of plug
await p.turn_on()
assert p.is_on is True
await p.turn_off()
# print current state of plug
print(p.state_information)
```
Errors reported by the device are raised as SmartDeviceExceptions,
Errors reported by the device are raised as :class:`SmartDeviceException`s,
and should be handled by the user of the library.
Examples:
>>> import asyncio
>>> plug = SmartPlug("127.0.0.1")
>>> asyncio.run(plug.update())
>>> plug.alias
Kitchen
Setting the LED state:
>>> asyncio.run(plug.set_led(True))
>>> asyncio.run(plug.update())
>>> plug.led
True
For more examples, see the :class:`SmartDevice` class.
"""
def __init__(self, host: str) -> None:

View File

@ -18,28 +18,52 @@ _LOGGER = logging.getLogger(__name__)
class SmartStrip(SmartDevice):
"""Representation of a TP-Link Smart Power Strip.
Usage example when used as library:
```python
p = SmartStrip("192.168.1.105")
A strip consists of the parent device and its children.
All methods of the parent act on all children, while the child devices
share the common API with the :class:`SmartPlug` class.
# query the state of the strip
await p.update()
print(p.is_on)
To initialize, you have to await :func:`update()` at least once.
This will allow accessing the properties using the exposed properties.
# change state of all outlets
await p.turn_on()
await p.turn_off()
All changes to the device are done using awaitable methods,
which will not change the cached values, but you must await :func:`update()` separately.
# individual outlets are accessible through plugs variable
for plug in p.plugs:
print(f"{p}: {p.is_on}")
# change state of a single outlet
await p.plugs[0].turn_on()
```
Errors reported by the device are raised as SmartDeviceExceptions,
Errors reported by the device are raised as :class:`SmartDeviceException`s,
and should be handled by the user of the library.
Examples:
>>> import asyncio
>>> strip = SmartStrip("127.0.0.1")
>>> asyncio.run(strip.update())
>>> strip.alias
TP-LINK_Power Strip_CF69
All methods act on the whole strip:
>>> for plug in strip.children:
>>> print(f"{plug.alias}: {plug.is_on}")
Plug 1: True
Plug 2: False
Plug 3: False
>>> strip.is_on
True
>>> asyncio.run(strip.turn_off())
Accessing individual plugs can be done using the `children` property:
>>> len(strip.children)
3
>>> for plug in strip.children:
>>> print(f"{plug.alias}: {plug.is_on}")
Plug 1: False
Plug 2: False
Plug 3: False
>>> asyncio.run(strip.children[1].turn_on())
>>> asyncio.run(strip.update())
>>> strip.is_on
True
For more examples, see the :class:`SmartDevice` class.
"""
def __init__(self, host: str) -> None:
@ -212,7 +236,7 @@ class SmartStripPlug(SmartPlug):
def is_on(self) -> bool:
"""Return whether device is on."""
info = self._get_child_info()
return info["state"]
return bool(info["state"])
@property # type: ignore
@requires_update

View File

@ -3,6 +3,7 @@ import glob
import json
import os
from os.path import basename
from pathlib import Path, PurePath
from unittest.mock import MagicMock
import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342
@ -100,6 +101,38 @@ async def handle_turn_on(dev, turn_on):
pytestmark = pytest.mark.asyncio
def device_for_file(model):
for d in STRIPS:
if d in model:
return SmartStrip
for d in PLUGS:
if d in model:
return SmartPlug
for d in BULBS:
if d in model:
return SmartBulb
for d in DIMMERS:
if d in model:
return SmartDimmer
raise Exception("Unable to find type for %s", model)
def get_device_for_file(file):
# if the wanted file is not an absolute path, prepend the fixtures directory
p = Path(file)
if not p.is_absolute():
p = Path(__file__).parent / "fixtures" / file
with open(p) as f:
sysinfo = json.load(f)
model = basename(file)
p = device_for_file(model)(host="123.123.123.123")
p.protocol = FakeTransportProtocol(sysinfo)
asyncio.run(p.update())
return p
@pytest.fixture(params=SUPPORTED_DEVICES)
def dev(request):
"""Device fixture.
@ -117,29 +150,7 @@ def dev(request):
return d
raise Exception("Unable to find type for %s" % ip)
def device_for_file(model):
for d in STRIPS:
if d in model:
return SmartStrip
for d in PLUGS:
if d in model:
return SmartPlug
for d in BULBS:
if d in model:
return SmartBulb
for d in DIMMERS:
if d in model:
return SmartDimmer
raise Exception("Unable to find type for %s", model)
with open(file) as f:
sysinfo = json.load(f)
model = basename(file)
p = device_for_file(model)(host="123.123.123.123")
p.protocol = FakeTransportProtocol(sysinfo)
asyncio.run(p.update())
yield p
return get_device_for_file(file)
def pytest_addoption(parser):

View File

@ -27,7 +27,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "Nick office tplink",
"alias": "KL130 office bulb",
"ctrl_protocols": {
"name": "Linkie",
"version": "1.0"
@ -45,7 +45,7 @@
"is_factory": false,
"is_variable_color_temp": 1,
"light_state": {
"brightness": 0,
"brightness": 30,
"color_temp": 0,
"hue": 15,
"mode": "normal",

View File

@ -240,42 +240,41 @@ emeter_commands = {
}
def error(target, cmd="no-command", msg="default msg"):
return {target: {cmd: {"err_code": -1323, "msg": msg}}}
def error(msg="default msg"):
return {"err_code": -1323, "msg": msg}
def success(target, cmd, res):
def success(res):
if res:
res.update({"err_code": 0})
else:
res = {"err_code": 0}
return {target: {cmd: res}}
return res
class FakeTransportProtocol(TPLinkSmartHomeProtocol):
def __init__(self, info):
self.discovery_data = info
proto = FakeTransportProtocol.baseproto
for target in info:
# print("target %s" % target)
for cmd in info[target]:
# print("initializing tgt %s cmd %s" % (target, cmd))
proto[target][cmd] = info[target][cmd]
# if we have emeter support, check for it
# if we have emeter support, we need to add the missing pieces
for module in ["emeter", "smartlife.iot.common.emeter"]:
if module not in info:
# TODO required for old tests
continue
if "get_realtime" in info[module]:
get_realtime_res = info[module]["get_realtime"]
# TODO remove when removing old tests
if callable(get_realtime_res):
get_realtime_res = get_realtime_res()
if (
"err_code" not in get_realtime_res
or not get_realtime_res["err_code"]
):
proto[module] = emeter_commands[module]
for etype in ["get_realtime", "get_daystat", "get_monthstat"]:
if etype in info[module]: # if the fixture has the data, use it
# print("got %s %s from fixture: %s" % (module, etype, info[module][etype]))
proto[module][etype] = info[module][etype]
else: # otherwise fall back to the static one
dummy_data = emeter_commands[module][etype]
# print("got %s %s from dummy: %s" % (module, etype, dummy_data))
proto[module][etype] = dummy_data
# print("initialized: %s" % proto[module])
self.proto = proto
def set_alias(self, x, child_ids=[]):
@ -309,7 +308,7 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
def set_mac(self, x, *args):
_LOGGER.debug("Setting mac to %s", x)
self.proto["system"]["get_sysinfo"]["mac"] = x
self.proto["system"]["get_sysinfo"]["mac"] = x["mac"]
def set_hs220_brightness(self, x, *args):
_LOGGER.debug("Setting brightness to %s", x)
@ -345,9 +344,10 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
if not light_state["on_off"] and "on_off" not in x:
light_state = light_state["dft_on_state"]
_LOGGER.debug("Current state: %s", light_state)
_LOGGER.debug("Old state: %s", light_state)
for key in x:
light_state[key] = x[key]
_LOGGER.debug("New state: %s", light_state)
def light_state(self, x, *args):
light_state = self.proto["system"]["get_sysinfo"]["light_state"]
@ -417,13 +417,14 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
except KeyError:
child_ids = []
target = next(iter(request))
if target not in proto.keys():
return error(target, msg="target not found")
def get_response_for_module(target):
cmd = next(iter(request[target]))
if target not in proto.keys():
return error(msg="target not found")
def get_response_for_command(cmd):
if cmd not in proto[target].keys():
return error(target, cmd, msg="command not found")
return error(msg=f"command {cmd} not found")
params = request[target][cmd]
_LOGGER.debug(f"Going to execute {target}.{cmd} (params: {params}).. ")
@ -431,12 +432,24 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
if callable(proto[target][cmd]):
res = proto[target][cmd](self, params, child_ids)
_LOGGER.debug("[callable] %s.%s: %s", target, cmd, res)
# verify that change didn't break schema, requires refactoring..
# TestSmartPlug.sysinfo_schema(self.proto["system"]["get_sysinfo"])
return success(target, cmd, res)
return success(res)
elif isinstance(proto[target][cmd], dict):
res = proto[target][cmd]
_LOGGER.debug("[static] %s.%s: %s", target, cmd, res)
return success(target, cmd, res)
return success(res)
else:
raise NotImplementedError(f"target {target} cmd {cmd}")
from collections import defaultdict
cmd_responses = defaultdict(dict)
for cmd in request[target]:
cmd_responses[target][cmd] = get_response_for_command(cmd)
return cmd_responses
response = {}
for target in request:
response.update(get_response_for_module(target))
return response

View File

@ -10,6 +10,7 @@ from .conftest import (
non_color_bulb,
non_dimmable,
non_variable_temp,
pytestmark,
turn_on,
variable_temp,
)

View File

@ -1,12 +1,9 @@
import pytest
from asyncclick.testing import CliRunner
from kasa import SmartDevice
from kasa.cli import alias, brightness, emeter, raw_command, state, sysinfo
from .conftest import handle_turn_on, turn_on
pytestmark = pytest.mark.asyncio
from .conftest import handle_turn_on, pytestmark, turn_on
async def test_sysinfo(dev):

View File

@ -2,7 +2,7 @@ import pytest
from kasa import SmartDimmer
from .conftest import dimmer, handle_turn_on, turn_on
from .conftest import dimmer, handle_turn_on, pytestmark, turn_on
@dimmer

View File

@ -3,10 +3,7 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342
from kasa import DeviceType, Discover, SmartDevice, SmartDeviceException
from .conftest import bulb, dimmer, plug, strip
# to avoid adding this for each async function separately
pytestmark = pytest.mark.asyncio
from .conftest import bulb, dimmer, plug, pytestmark, strip
@plug

View File

@ -2,7 +2,7 @@ import pytest
from kasa import SmartDeviceException
from .conftest import has_emeter, no_emeter
from .conftest import has_emeter, no_emeter, pytestmark
from .newfakes import CURRENT_CONSUMPTION_SCHEMA

View File

@ -1,6 +1,6 @@
from kasa import DeviceType
from .conftest import plug
from .conftest import plug, pytestmark
from .newfakes import PLUG_SCHEMA

View File

@ -4,6 +4,7 @@ import pytest
from ..exceptions import SmartDeviceException
from ..protocol import TPLinkSmartHomeProtocol
from .conftest import pytestmark
@pytest.mark.parametrize("retry_count", [1, 3, 5])

View File

@ -0,0 +1,65 @@
import sys
import pytest
import xdoctest
from kasa.tests.conftest import get_device_for_file
def test_bulb_examples(mocker):
"""Use KL130 (bulb with all features) to test the doctests."""
p = get_device_for_file("KL130(US)_1.0.json")
mocker.patch("kasa.smartbulb.SmartBulb", return_value=p)
mocker.patch("kasa.smartbulb.SmartBulb.update")
res = xdoctest.doctest_module("kasa.smartbulb", "all")
assert not res["failed"]
def test_smartdevice_examples(mocker):
"""Use HS110 for emeter examples."""
p = get_device_for_file("HS110(EU)_1.0_real.json")
mocker.patch("kasa.smartdevice.SmartDevice", return_value=p)
mocker.patch("kasa.smartdevice.SmartDevice.update")
res = xdoctest.doctest_module("kasa.smartdevice", "all")
assert not res["failed"]
def test_plug_examples(mocker):
"""Test plug examples."""
p = get_device_for_file("HS110(EU)_1.0_real.json")
mocker.patch("kasa.smartplug.SmartPlug", return_value=p)
mocker.patch("kasa.smartplug.SmartPlug.update")
res = xdoctest.doctest_module("kasa.smartplug", "all")
assert not res["failed"]
def test_strip_examples(mocker):
"""Test strip examples."""
p = get_device_for_file("KP303(UK)_1.0.json")
mocker.patch("kasa.smartstrip.SmartStrip", return_value=p)
mocker.patch("kasa.smartstrip.SmartStrip.update")
res = xdoctest.doctest_module("kasa.smartstrip", "all")
assert not res["failed"]
def test_dimmer_examples(mocker):
"""Test dimmer examples."""
p = get_device_for_file("HS220(US)_1.0_real.json")
mocker.patch("kasa.smartdimmer.SmartDimmer", return_value=p)
mocker.patch("kasa.smartdimmer.SmartDimmer.update")
res = xdoctest.doctest_module("kasa.smartdimmer", "all")
assert not res["failed"]
@pytest.mark.skipif(
sys.version_info < (3, 8), reason="3.7 handles asyncio.run differently"
)
def test_discovery_examples(mocker):
"""Test discovery examples."""
p = get_device_for_file("KP303(UK)_1.0.json")
# This succeeds on python 3.8 but fails on 3.7
# ValueError: a coroutine was expected, got [<DeviceType.Strip model KP303(UK) ...
mocker.patch("kasa.discover.Discover.discover", return_value=[p])
res = xdoctest.doctest_module("kasa.discover", "all")
assert not res["failed"]

View File

@ -5,7 +5,7 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342
from kasa import SmartDeviceException
from .conftest import handle_turn_on, turn_on
from .conftest import handle_turn_on, pytestmark, turn_on
from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol

View File

@ -4,7 +4,7 @@ import pytest
from kasa import SmartDeviceException, SmartStrip
from .conftest import handle_turn_on, strip, turn_on
from .conftest import handle_turn_on, pytestmark, strip, turn_on
@strip

View File

@ -18,11 +18,17 @@ python = "^3.7"
importlib-metadata = "*"
asyncclick = "^7"
# required only for docs
sphinx = { version = "^3.1.1", optional = true }
m2r = { version = "^0.2.1", optional = true }
sphinx_rtd_theme = { version = "^0.5.0", optional = true }
sphinxcontrib-programoutput = { version = "^0.16", optional = true }
[tool.poetry.dev-dependencies]
pytest = "^5"
pytest-azurepipelines = "^0.8"
pytest-cov = "^2.8"
pytest-asyncio = "^0.11"
pytest-asyncio = "^0.12"
pytest-sugar = "*"
pre-commit = "*"
voluptuous = "*"
@ -30,6 +36,11 @@ toml = "*"
tox = "*"
pytest-mock = "^3.1.0"
codecov = "^2.0"
xdoctest = "^0.12"
[tool.poetry.extras]
docs = ["sphinx", "sphinx_rtd_theme", "m2r", "sphinxcontrib-programoutput"]
[tool.isort]
multi_line_output = 3