diff --git a/.flake8 b/.flake8 index c584b928b..d4e8f681b 100644 --- a/.flake8 +++ b/.flake8 @@ -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 diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 000000000..0413384f0 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,8 @@ +build: + image: latest + +python: + version: 3.7 + pip_install: true + extra_requirements: + - docs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..74fe8175d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,811 @@ +# Changelog + +## [0.4.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev1) (2020-07-28) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev0...0.4.0.dev1) + +**Implemented enhancements:** + +- KL430 support [\#67](https://github.com/python-kasa/python-kasa/issues/67) +- Improve retry logic for discovery, messaging \(was: Handle empty responses\) [\#38](https://github.com/python-kasa/python-kasa/issues/38) +- Add support for lightstrips \(KL430\) [\#74](https://github.com/python-kasa/python-kasa/pull/74) ([rytilahti](https://github.com/rytilahti)) + +**Closed issues:** + +- I don't python... how do I make this executable? [\#88](https://github.com/python-kasa/python-kasa/issues/88) +- ImportError: cannot import name 'smartplug' [\#87](https://github.com/python-kasa/python-kasa/issues/87) +- Support for P100 Smart Plug [\#83](https://github.com/python-kasa/python-kasa/issues/83) +- not able to pip install the library [\#82](https://github.com/python-kasa/python-kasa/issues/82) +- Discover.discover\(\) add selecting network interface \[pull request\] [\#78](https://github.com/python-kasa/python-kasa/issues/78) +- LB100 unable to turn on or off the lights [\#68](https://github.com/python-kasa/python-kasa/issues/68) +- Improve poetry usage documentation [\#60](https://github.com/python-kasa/python-kasa/issues/60) +- sys\_info not None fails assertion [\#55](https://github.com/python-kasa/python-kasa/issues/55) +- Upload pre-release to pypi for easier testing [\#17](https://github.com/python-kasa/python-kasa/issues/17) + +**Merged pull requests:** + +- add a small example script to show library usage [\#90](https://github.com/python-kasa/python-kasa/pull/90) ([rytilahti](https://github.com/rytilahti)) +- add .readthedocs.yml required for poetry builds [\#89](https://github.com/python-kasa/python-kasa/pull/89) ([rytilahti](https://github.com/rytilahti)) +- Improve installation instructions [\#86](https://github.com/python-kasa/python-kasa/pull/86) ([rytilahti](https://github.com/rytilahti)) +- cli: Fix incorrect use of asyncio.run for temperature command [\#85](https://github.com/python-kasa/python-kasa/pull/85) ([rytilahti](https://github.com/rytilahti)) +- Add --transition to bulb-specific cli commands, fix turn\_{on,off} signatures [\#81](https://github.com/python-kasa/python-kasa/pull/81) ([rytilahti](https://github.com/rytilahti)) +- Improve bulb API, force turn on for all light changes as offline changes are not supported [\#76](https://github.com/python-kasa/python-kasa/pull/76) ([rytilahti](https://github.com/rytilahti)) +- Simplify API documentation by using doctests [\#73](https://github.com/python-kasa/python-kasa/pull/73) ([rytilahti](https://github.com/rytilahti)) +- Bulbs: allow specifying transition for state changes [\#70](https://github.com/python-kasa/python-kasa/pull/70) ([rytilahti](https://github.com/rytilahti)) +- Add transition support for SmartDimmer [\#69](https://github.com/python-kasa/python-kasa/pull/69) ([connorproctor](https://github.com/connorproctor)) + +## [0.4.0.dev0](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev0) (2020-05-27) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.3.5...0.4.0.dev0) + +**Implemented enhancements:** + +- Add commands to control the wifi settings [\#45](https://github.com/python-kasa/python-kasa/pull/45) ([rytilahti](https://github.com/rytilahti)) + +**Fixed bugs:** + +- HSV cli command not working [\#43](https://github.com/python-kasa/python-kasa/issues/43) + +**Closed issues:** + +- Pull request \#54 broke installer? [\#66](https://github.com/python-kasa/python-kasa/issues/66) +- RFC: remove implicit updates after state changes? [\#61](https://github.com/python-kasa/python-kasa/issues/61) +- How to install? [\#57](https://github.com/python-kasa/python-kasa/issues/57) +- Request all necessary information during update\(\) [\#53](https://github.com/python-kasa/python-kasa/issues/53) +- HS107 Support [\#37](https://github.com/python-kasa/python-kasa/issues/37) +- Separate dimmer-related code from smartplug class [\#33](https://github.com/python-kasa/python-kasa/issues/33) +- Add Mac OSX and Windows for CI [\#30](https://github.com/python-kasa/python-kasa/issues/30) +- KP303\(UK\) does not pass check with pytest [\#27](https://github.com/python-kasa/python-kasa/issues/27) +- Remove sync interface wrapper [\#12](https://github.com/python-kasa/python-kasa/issues/12) +- Mass close pyhs100 issues and PRs [\#11](https://github.com/python-kasa/python-kasa/issues/11) +- Update readme [\#10](https://github.com/python-kasa/python-kasa/issues/10) +- Add contribution guidelines and instructions [\#8](https://github.com/python-kasa/python-kasa/issues/8) +- Convert discovery to use asyncio [\#7](https://github.com/python-kasa/python-kasa/issues/7) +- Python Version? [\#4](https://github.com/python-kasa/python-kasa/issues/4) +- Fix failing tests: KeyError: 'relay\_state' [\#2](https://github.com/python-kasa/python-kasa/issues/2) + +**Merged pull requests:** + +- Add parse\_pcap to devtools, improve readme on contributing [\#84](https://github.com/python-kasa/python-kasa/pull/84) ([rytilahti](https://github.com/rytilahti)) +- Add retries to protocol queries [\#65](https://github.com/python-kasa/python-kasa/pull/65) ([rytilahti](https://github.com/rytilahti)) +- General cleanups all around \(janitoring\) [\#63](https://github.com/python-kasa/python-kasa/pull/63) ([rytilahti](https://github.com/rytilahti)) +- Improve dimmer support [\#62](https://github.com/python-kasa/python-kasa/pull/62) ([rytilahti](https://github.com/rytilahti)) +- Optimize I/O access [\#59](https://github.com/python-kasa/python-kasa/pull/59) ([rytilahti](https://github.com/rytilahti)) +- Remove unnecessary f-string definition to make tests pass [\#58](https://github.com/python-kasa/python-kasa/pull/58) ([rytilahti](https://github.com/rytilahti)) +- Convert to use poetry & pyproject.toml for dep & build management [\#54](https://github.com/python-kasa/python-kasa/pull/54) ([rytilahti](https://github.com/rytilahti)) +- Add fixture for KL60 [\#52](https://github.com/python-kasa/python-kasa/pull/52) ([rytilahti](https://github.com/rytilahti)) +- Ignore D202 where necessary [\#50](https://github.com/python-kasa/python-kasa/pull/50) ([rytilahti](https://github.com/rytilahti)) +- Support wifi scan & join for bulbs using a different interface [\#49](https://github.com/python-kasa/python-kasa/pull/49) ([rytilahti](https://github.com/rytilahti)) +- Return on\_since only when its available and the device is on [\#48](https://github.com/python-kasa/python-kasa/pull/48) ([rytilahti](https://github.com/rytilahti)) +- Allow 0 brightness for smartdimmer [\#47](https://github.com/python-kasa/python-kasa/pull/47) ([rytilahti](https://github.com/rytilahti)) +- async++, small powerstrip improvements [\#46](https://github.com/python-kasa/python-kasa/pull/46) ([rytilahti](https://github.com/rytilahti)) +- Check for emeter support on power strips/multiple plug outlets [\#41](https://github.com/python-kasa/python-kasa/pull/41) ([acmay](https://github.com/acmay)) +- Remove unnecessary cache [\#40](https://github.com/python-kasa/python-kasa/pull/40) ([rytilahti](https://github.com/rytilahti)) +- Add in some new device types [\#39](https://github.com/python-kasa/python-kasa/pull/39) ([acmay](https://github.com/acmay)) +- Add test fixture for KL130 [\#35](https://github.com/python-kasa/python-kasa/pull/35) ([bdraco](https://github.com/bdraco)) +- Move dimmer support to its own class [\#34](https://github.com/python-kasa/python-kasa/pull/34) ([rytilahti](https://github.com/rytilahti)) +- Fix azure pipeline badge [\#32](https://github.com/python-kasa/python-kasa/pull/32) ([rytilahti](https://github.com/rytilahti)) +- Enable Windows & OSX builds [\#31](https://github.com/python-kasa/python-kasa/pull/31) ([rytilahti](https://github.com/rytilahti)) +- Depend on py3.7+ for tox, add python 3.8 to azure pipeline targets [\#29](https://github.com/python-kasa/python-kasa/pull/29) ([rytilahti](https://github.com/rytilahti)) +- Add KP303 to the list of powerstrips [\#28](https://github.com/python-kasa/python-kasa/pull/28) ([rytilahti](https://github.com/rytilahti)) +- Move child socket handling to its own SmartStripPlug class [\#26](https://github.com/python-kasa/python-kasa/pull/26) ([rytilahti](https://github.com/rytilahti)) +- Adding KP303 to supported devices [\#25](https://github.com/python-kasa/python-kasa/pull/25) ([epicalex](https://github.com/epicalex)) +- use pytestmark to avoid repeating asyncio mark [\#24](https://github.com/python-kasa/python-kasa/pull/24) ([rytilahti](https://github.com/rytilahti)) +- Cleanup constructors by removing ioloop and protocol arguments [\#23](https://github.com/python-kasa/python-kasa/pull/23) ([rytilahti](https://github.com/rytilahti)) +- Add \(some\) tests to the cli tool [\#22](https://github.com/python-kasa/python-kasa/pull/22) ([rytilahti](https://github.com/rytilahti)) +- Test against the newly added device fixtures [\#21](https://github.com/python-kasa/python-kasa/pull/21) ([rytilahti](https://github.com/rytilahti)) +- move testing reqs to requirements\_test.txt, add pytest-asyncio for pipelines [\#20](https://github.com/python-kasa/python-kasa/pull/20) ([rytilahti](https://github.com/rytilahti)) +- Remove unused save option and add scrubbing [\#19](https://github.com/python-kasa/python-kasa/pull/19) ([TheGardenMonkey](https://github.com/TheGardenMonkey)) +- Add real kasa device dumps [\#18](https://github.com/python-kasa/python-kasa/pull/18) ([TheGardenMonkey](https://github.com/TheGardenMonkey)) +- Fix dump-discover to use asyncio.run [\#16](https://github.com/python-kasa/python-kasa/pull/16) ([rytilahti](https://github.com/rytilahti)) +- Add device\_id property, rename context to child\_id [\#15](https://github.com/python-kasa/python-kasa/pull/15) ([rytilahti](https://github.com/rytilahti)) +- Remove sync interface, add asyncio discovery [\#14](https://github.com/python-kasa/python-kasa/pull/14) ([rytilahti](https://github.com/rytilahti)) +- Remove --ip which was just an alias to --host [\#6](https://github.com/python-kasa/python-kasa/pull/6) ([rytilahti](https://github.com/rytilahti)) +- Set minimum requirement to python 3.7 [\#5](https://github.com/python-kasa/python-kasa/pull/5) ([rytilahti](https://github.com/rytilahti)) +- change ID of Azure Pipeline [\#3](https://github.com/python-kasa/python-kasa/pull/3) ([basnijholt](https://github.com/basnijholt)) +- Mass rename to \(python-\)kasa [\#1](https://github.com/python-kasa/python-kasa/pull/1) ([rytilahti](https://github.com/rytilahti)) + +Historical pyHS100 changelog +============================ + +0.3.5 (2019-04-13) +------------ + +- Fix for SmartStrip repr (#169) [Alex] + + * Added unit tests for repr. + + * Fix repr for SmartStrip. + + Fixes #165 + +- Smartstrip: return on_since state information only when the socket is on (#161) [Teemu R] + + * Smartstrip: return on_since state information only when the socket is on + + Fixes #160 + + * add proper cli printout for hs300 child sockets + + * iterate over range, not an integer + +- Bulb: add the temperature range to state_information, inform the user if the info is missing when calling temperature (#163) [Teemu R] + +- Fix Discover#discover incorrect documentation (#159) [Georgi Kirichkov] + + The documentation states the timeout defaults to 5 seconds, but in the definition of the method timeout is set to 3 + +- Add kelvin range for KL130 (#156) [dieselrabbit] + + * Add kelvin range for KL130 (new color bulb) + + * Add kelvin range for KL120 + + Unable to test this personally as I don't have this bulb. + +- Add LB230. [Teemu R] + + Works according to https://github.com/home-assistant/home-assistant.io/pull/8090 + +- Add KL series of bulbs. [Teemu R] + + works according to https://github.com/home-assistant/home-assistant.io/pull/8134 + + +0.3.4 (2019-01-16) +------------ + +There are two notable changes (and other small fixes) in this minor release thanks to our contributors: + +* Support for HS300 smartstrip (thanks to jimboca!) +* The hue range for light bulbs is fixed (thanks to nkonopinski, Annika Jacobs and Joe Zach!) + + +- Updated valid range to 360 (with passing tests) (#153) [Annika Jacobs, Zac Koch] + + * Updated valid range to 360 + + with it set to 359 it will not show the color red. Just tested this with a buddies bulb - same model/fw + https://imgur.com/a/kSNZIuL + +- Add support for HS300 power strip (#137) [jimboca] + +- Add HS103 to readme. [Teemu R] + +- Avoid 'referenced before assignment' exception (#150) [Kevron Rees] + +- Cli: show an error for incorrect hsv values (#142) [Annika Jacobs] + + Raising an exception if an incomplete HSV colour is provided. + +- Add a "Reviewed by Hound" badge (#139) [Scott Albertson] + +- Change valid hue range to 0-359 (fixes #130), update README.md & test + (#140) [Annika Jacobs, nkonopinski] + + Tested on LB130(EU) hardware 1.0, firmware 1.8.6 Build 180809 Rel.091659 + +- Remove deprecated identify, this has been deprecated for long enough. + (#136) [Teemu R] + + * Remove deprecated identify, this has been deprecated for long enough. + +- Add missed test for invalid types. [Teemu R] + +- Update README to include mention about hs220. [Teemu R] + +- Add tests and pretty output for HS220, fix minor issues in tests. + [Teemu R] + +- Add reboot command to restart the device (#129) [Teemu R] + + +0.3.3 (2018-09-06) +------------------ + +This release contains a breaking change for hsv setter, which is changed to accept +the new brightness value in percentage instead of an integer between 1 and 255. + +The alias support has been extended to allow changing the alias, as well as accessing +the device using it (without specifying an IP address or a hostname), which can be +useful in some setups. Furthermore utf8-encoded aliases are now handled correctly. + +- Fix bug that changed brightness at each HSV update (#124) [Sebastian Templ] + + * Fix bug that changed brightness at each hsv update + + The HSV setter should accept a percentage for the brightness + value but actually assumed the brightness to be in absolute values + between 1 and 255. + This resulted in brightness reductions at each HSV update, in + steps of 100% -> 100/255=39% -> 39/255=15% -> ... (see also + https://github.com/home-assistant/home-assistant/issues/15582, + where I originally reported this bug). + + * Modify HSV property to return brightness in percent + + Switch from reported brightness values of 1..255 to percentage + values, for consistency with the apidoc and 8761dd8. + + * Add checks and tests for the hsv setter + + - make sure that new (hue, saturation, brightness) values are + within their valid ranges (0..255, 0..100, 0..100) and raise + SmartDeviceException if they are not + - add test function for the hsv setter + +- Allow using alias instead of IP address or hostname (#127) [kwazel] + + * Added option to control devices by device name + + * set unused ip address to dont-care + + * spend less time discovering by devicename, removed command + + * consistent use of alias instead of device name + + * processed review comments + + * Return when no device with alias has been found + +- Add 'alias' command for querying and setting the alias (#126) [Teemu R] + + * add 'alias' command for querying and setting the alias + + * calculate coverage only on library files, e.g., ignoring cli and test files + + * remove py34 and add py37 + + * readd py33, remove it from travis as it seems to be a travis limitation only + + * use xenial dist for travis, regular does not support py37.. + +- Support Unicode strings in encrypt/decrypt (#125) [Anders Melchiorsen] + + +0.3.2 (2018-06-17) +------------------ + +- Add bulb valid temperature range (#122) [Thibault Cohen] + + +0.3.1 (2018-06-16) +------------------ + +This release adds a few improvements, most importantly: + +* emeter support for new HS110 hardware/firmware revision. + +* HS220 supports now dimming. + +Breaking changes: + +* get_emeter_daily & get_emeter_monthly will report back in kwh on bulbs, making the API consistent with the smart plugs. + +- Fix emeter support for newer HS110 firmwares (#107) [Teemu R] + + * Add support for new-style emeter + + This commit adds a straightforward dict-extending container, + which converts between the old and new keys of the get_emeter_realtime() + Furthermore the unit tests are converted to base on HS100 + instead of HS110. + + This is the first step to fix #103, other emeter-using functionality + has not yet been converted, only getting the current consumption. + + * fix a couple of linting issues + + * Convert new-style emeter values also for get_emeter_daily() and get_emeter_monthly() + + * Adds a new 'kwh' parameter for those calls, which defaults to True + * This changes the behavior of bulbs emeter reporting, use False if you prefer the preciser values + +- Update pypi description (#102) [Teemu R] + + * update pypi description + + * add wall switches + +- Update smartplug.py to support dimming in HS220 (#115) [JsChiSurf] + + * Update smartplug.py to support dimming in HS220 + + Switch functions essentially as a "plug" with the addition to support for dimming, for which can be test for by verifying existence of +'brightness' array value. + + * Attempt at updates to pass validator + + * Maybe this time? :-) + + * Add more detail to comment blocks + + Make clear in requests for current brightness level the expected return values, and note that light will turn on when setting a brightness +level, if not already on. This makes clear that a state change request (turn_on) does NOT have to be made first when setting brightness. + + * Update smartplug.py + + * Update smartplug.py + + Fixes #114 + +- Add python_requires for >= 3.4. [Teemu Rytilahti] + +- Add hs210. [Teemu R] + + Based on user report: https://community.home-assistant.io/t/tp-link-hs210-3-way-kit/39762/6 + +- Add support for DNS host names (#104) [K Henriksson] + +- Use direct device type discovery for devices (#106) [K Henriksson] + + This is more efficient than enumerating all devices and checking the IP. + +- Cli: add 'time' command to get the current time from the device. + [Teemu Rytilahti] + +- Created a docker file to aid dev setup (#99) [TheSmokingGnu] + + * created a docker file to aid dev setup + + * fixed review comments in README and Dockerfile + + * review comments to simplify the docker run command + + +0.3.0 (2017-09-14) +------------------ + +This is the first release after a while and aims to improve the robustness all-around. +To make this happen we have decided to break the API and drop the support for Python 2. + +API break: + * Python2 support has been dropped. + * pyHS100/pyHS100.py has been splitted to smartdevice.py, smartplug.py and smartbulb.py, no one should have ever accessed these directly though. + * SmartPlugException is no more, SmartDeviceException is used by both SmartPlug and SmartBulb + * Discovery has been moved from TPLinkSmartHomeProtocol into its own class for easier 3rd party use. + * SmartDevice's identify() and `features` will emit a warning when used. These will likely be dropped or revised in the future and their use should be avoided. + +Other changes: + + * CLI tool supports device discovery and is usable without specifying device type or IP for testing + * CLI tool supports changing bulb-specific settings + * Library support & unit tests are extended to cover more devices. + - Supported plugs: HS100, HS105, HS110 + - Supported switches: HS200 + - Supported bulbs: LB100, LB110, LB120, LB130 + +- Bump the version. [Teemu Rytilahti] + +- Revise README, fixes #86. [Teemu Rytilahti] + +- Update the changelog. [Teemu Rytilahti] + +- Local test clean (#96) [Sean Gollschewsky] + + * Add ignores for working coverage/tox/IDE files. + + * Allow tox not to fail if python version is not available. + +- Move SmartDeviceException to SmartDevice, and remove types.py complet… + (#95) [Teemu R] + + * move SmartDeviceException to SmartDevice, and remove types.py completely. fixes #94 + + * do not import skipIf anymore + +- Move has_emeter implementation from SmartDevice to SmartPlug, avoid + using features() internally (#93) [Teemu R] + + * move has_emeter implementation from SmartDevice to SmartPlug, avoid using features() internally + + * add stacklevel to deprecation warnings to see where they are really called + + * make tests pass on a real device. if PLUG_IP is not None, the tests will be run on a device at the defined IP address + +- Add typing hints to make it easier for 3rd party developers to use the + library (#90) [Teemu R] + + * add typing hints to make it easier for 3rd party developers to use the library + + * remove unused devicetype enum to support python3.3 + + * add python 3.3 to travis and tox, install typing module in setup.py +- Execute coveralls only on travis, fixes #84 (#91) [Teemu R] + +- Make flake8 pass by some rewording. [Teemu Rytilahti] + +- Make hound a bit more happier. [Teemu Rytilahti] + +- Deprecate features and identify, use state_information in __repr__ instead of identify. [Teemu Rytilahti] + +- Fix smartbulb hsv documentation, values are degrees and percentages instead of 0-255. [Teemu Rytilahti] + +- Another try, just with module name. [Teemu Rytilahti] + +- Make tox run pytest-cov, add coveralls. [Teemu Rytilahti] + +- Prevent failure if device's sysinfo does not have a "feature" attribute. (#77) [Sean Gollschewsky] + +- Allow None for rssi, add a missing newline to fakes.py. [Teemu Rytilahti] + +- Add hs100 tests. [Teemu Rytilahti] + +- Make tests to test against all known device variants. [Teemu Rytilahti] + +- Remove unused tplinksmarthomeprotocol import. [Teemu Rytilahti] + +- Fix hs105 mac to pass the test, wrap sysinfo_lb110 properly inside 'system' [Teemu Rytilahti] + +- Return None instead of False for emeter related actions. [Teemu Rytilahti] + +- Wrap sysinfo to defaultdict to return None for keys which do not exist, makes unsupported keys not to fail hard (#72) [Teemu R] + +- Add hs100 example to fakes.py, thanks to Semant1ka on #67 (#74) [Teemu R] + +- Discover refactoring, enhancements to the cli tool (#71) [Teemu R] + + * Discover refactoring, enhancements to the cli tool + + * Discover tries to detect the type of the device from sysinfo response + * Discover.discover() returns an IP address keyed dictionary, + values are initialized instances of the automatically detected device type. + + * When no IP is given, autodetect all supported devices and print out their states + * When only IP but no type is given, autodetect type and make a call based on that information. + * One can define --bulb or --plug to skip the detection. + + * renamed pyHS100.py -> smartdevice.py + + * SmartPlugException -> SmartDeviceException in comments + + * fix mic_type check + + * make time() return None on failure as we don't know which devices support getting the time and it's used in the cli tool + + * hw_info: check if key exists before accessing it, add mic_mac and mic_type + + * Check for mic_mac on mac, based on work by kdschloesser on issue #59 + + * make hound happy, __init__ on SmartDevice cannot error out so removing 'raises' documentation + +- Add LB110 sysinfo (#75) [Sean Gollschewsky] + + * Add LB110 sysinfo + + * Linting. + +- Add @pass_dev to hsv, adjust ranges (#70) [Teemu R] + + * add @pass_dev to hsv command, it was always broken + + * Hue goes up to 360, saturation and value are up to 100(%) + +- Extract shared types (exceptions, enums), add module level doc, rename exception to be generic. [Teemu Rytilahti] + +- Add check to ensure devices with lat/lon with `_i` suffix are supported (#54) (#56) [Matt LeBrun] + + * Add check to ensure devices with lat/lon with `_i` suffix are supported (#54) + + * Add .gitignore for posterity + +- Generalize smartdevice class and add bulb support for the cli tool (#50) [Teemu R] + + Fixes #48 and #51. The basic functionality should work on all types of supported devices, for bulb specific commands it is currently necessary to specify ```--bulb```. + +- Refactor and drop py2 support (#49) [Teemu R] + + * move is_off property to SmartDevice, implement is_on for bulb and use it + + * refactor by moving smartbulb and smartplug to their own classes + + * drop python2 compatibility, make flake8 happy + + * travis: remove 2.7, add 3.6 + +0.2.4.2 (2017-04-08) +-------------------- +- Add installation requirement for future package. [Teemu Rytilahti] + +0.2.4.1 (2017-03-26) +-------------------- +- Cli: display an error if no ip is given. [Teemu Rytilahti] + + +0.2.4 (2017-03-26) +------------------ + +- Add new client tool (#42) [Teemu R] + + * Add new client tool + + After installing the package pyhs100 command-line tool can be used + to control the plug. + + See --help for its usage, most of the features for plugs are implemented, + some of the shared functionality works for bulbs too. + + * Add discover command + + * Delete old examples, the cli works as an example well enough + +- Ignore OSError on socket.shutdown() [Teemu Rytilahti] + + This fixes #22 and obsoletes PR #23. +- Set color temp to 0 when trying to change color (#36) [pete1450] + + * set color temp to 0 when trying to change color + + * changed tabs to spaces + +- Add changelog & add .gitchangelog.rc (#28) [Teemu R] + + This commits adds .gitchangelog.rc for changelog generation. + To generate, simply run gitchangelog. + +- Discover: Catch socket.timeout and debug log it (#34) [Teemu R] + + Fixes #33 + +- Add flake8 to tox, disable qa on pyHS100/__init__.py, fix py27 + compatibility (#31) [Teemu R] + +- Add support for TP-Link smartbulbs (#30) [Matthew Garrett] + + * Add support for new-style protocol + + Newer devices (including my LB130) seem to include the request length in + the previously empty message header, and ignore requests that lack it. They + also don't send an empty packet as the final part of a response, which can + lead to hangs. Add support for this, with luck not breaking existing devices + in the process. + + * Fix tests + + We now include the request length in the encrypted packet header, so strip + the header rather than assuming that it's just zeroes. + + * Create a SmartDevice parent class + + Add a generic SmartDevice class that SmartPlug can inherit from, in + preparation for adding support for other device types. + + * Add support for TP-Link smartbulbs + + These bulbs use the same protocol as the smart plugs, but have additional + commands for controlling bulb-specific features. In addition, the bulbs + have their emeter under a different target and return responses that + include the energy unit in the key names. + + * Add tests for bulbs + + Not entirely comprehensive, but has pretty much the same level of testing + as plugs + + +0.2.3 (2017-01-11) +------------------ + +- Add .gitchnagelog.rc for changelog generation. to generate, simply + install and run gitchangelog. [Teemu Rytilahti] + +- Version bump. [GadgetReactor] + +- Initial steps to remove caching (#26) [Teemu R] + + This commit removes caching of sysinfo to avoid + inconsistent states as described in issue #14. + + Each an every access for properties will cause a request + to be made to the device. To avoid this, user of the library + may want to access sys_info() directly instead of using the helpers. + + Currently sys_info() returns raw json object where-as helpers do + parse information for easier consumption; current state is just to + provide a PoC how it looks compared to having an active update() + for fetching the info. + +- Make tests runnable without device (#24) [Teemu R] + + * Make tests runnable without device + + Adds preliminary support for fake devices, thanks to + hoveeman's sysinfos from issue #14, + making running tests possible without a device. + + At the moment we have only HS110 and HS200 infos available, and tests + are currently run only against HS110 data. + + * Make tests py27 compatible + +- Add device discovery (#25) [Teemu R] + + * add (untested) discover mode + + * Keep discovery and normal communication separate, uppercase magic consts + + This sepearates the earlier test code for discovering devices, + and adds 5 sec timeout for gathering responses from potential devices. + + This commit also uppercases magic constants. + + Discovery & communication tested with HS110. + + * update readme with example how to discover devices, pep8ify + +- Add timeout to query (#19) [Austin] + +- Refactor & add unittests for almost all functionality, add tox for + running tests on py27 and py35 (#17) [Teemu R] + + * Refactor & add unittests for almost all functionality, add tox for running tests on py27 and py35 + + This commit adds unit tests for current api functionality. + - currently no mocking, all tests are run on the device. + - the library is now compatible with python 2.7 and python 3.5, use tox for tests + - schema checks are done with voluptuous + + refactoring: + - protocol is separated into its own file, smartplug adapted to receive protocol worker as parameter. + - cleaned up the initialization routine, initialization is done on use, not on creation of smartplug + - added model and features properties, identity kept for backwards compatibility + - no more storing of local variables outside _sys_info, paves a way to handle state changes sanely (without complete reinitialization) + + * Fix CI warnings, remove unused leftover code + + * Rename _initialize to _fetch_sysinfo, as that's what it does. + + * examples.cli: fix identify call, prettyprint sysinfo, update readme which had false format for led setting + + * Add tox-travis for automated testing. + +0.2.2 (2016-12-13) +------------------ + +- Version bump (#16) [Georgi Kirichkov] + +- Read all data from the device, disable double-encoding, implement more + APIs, refactor querying, update README (#11) [Teemu R] + + * Read from socket until no data available, disable double string encoding + + HS110 sends sometimes datagrams in chunks especially for get_daystat, + this patch makes it to read until there is no more data to be read. + + As json.dumps() does JSON encoding already, there's no need to str() + the year or month either. + + * Add cli.py, a simple script to query devices for debugging purposes. + + * allow easier importing with from pyHS100 import SmartPlug + + * move cli.py to examples, add short usage into README.md + + * Implement more available APIs, refactor querying code. + + This commit adds access to new properties, both read & write, while keeping the old one (mostly) intact. + Querying is refactored to be done inside _query_helper() method, + which unwraps results automatically and rises SmartPlugException() in case of errors. + Errors are to be handled by clients. + + New features: + * Setting device alias (plug.alias = "name") + * led read & write + * icon read (doesn't seem to return anything without cloud support at least), write API is not known, throws an exception currently + * time read (returns datetime), time write implemented, but not working even when no error is returned from the device + * timezone read + * mac read & write, writing is untested for now. + + Properties for easier access: + * hw_info: return hw-specific elements from sysinfo + * on_since: pretty-printed from sysinfo + * location: latitude and longitued from sysinfo + * rssi: rssi from sysinfo + + * Update README.md with examples of available features. + + * Handle comments from mweinelt + + * Refactor state handling, use booleans instead of strings + + * Fix issues raised during the review. + + Following issues are addressed by this commit: + * All API is more or less commented (including return types, exceptions, ..) + * Converted state to use + * Added properties is_on, is_off for those who don't want to check against strings. + * Handled most issues reported by pylint. + * Adjusted _query_helper() to strip off err_code from the result object. + * Fixed broken format() syntax for string formattings. + + * Fix ci woes plus one typo. + + * Do initialization after changing device properties, fix nits. + +- Constants will be static members of SmartPlug. [Martin Weinelt] + +- Set up hound-ci. [Martin Weinelt] + +- Normalize docstrings, address flake8 & pylint recommendations. [Martin + Weinelt] + +- Properly detect advertised features, expose alias. [Martin Weinelt] + +- Externalize the TP-Link Smart Home Protocol. [Martin Weinelt] + +- HS200 support. [GadgetReactor] + + Update version to reflect latest changes + +- Adding in support for the HS200 Wall switch referencing issues (#4), + simplifying model determination. [Stephen Maggard] + +- Adding in support for the HS200 Wall switch referencing issues (#4), + simplifying model determination. [Stephen Maggard] + +- Adding in support for the HS200 Wall switch referencing issues (#4) + [Stephen Maggard] + +- Refactors state property to use get_info() and removes hs100_status() + [Georgi Kirichkov] + +- Adds model check to current_consumption() and removes whitespace. + [Georgi Kirichkov] + +- Fixes indentation and removes extra whitespaces. [Georgi Kirichkov] + +- Update setup.py. [GadgetReactor] + +- Update LICENSE. [GadgetReactor] + + Updated to GPLv3 (instead of just copy and pasting) + +0.2.0 (2016-10-17) +------------------ + +- Bumps the module version to 0.2.0. [Georgi Kirichkov] + +- Adds additional comments, for better compliance with the Apache + license. [Georgi Kirichkov] + +- Makes the socket sending code compatible with both Python 2 and python + 3. [Georgi Kirichkov] + + Adds a shutdown to the socket used to send commands + +- Refactors state() to use turn_on() and turn_off() [Georgi Kirichkov] + +- Adds Energy Meter commands available on the TP-Link HS110. [Georgi + Kirichkov] + + Also adds turn_on() and turn_off() commands to supplement the state + +- Update pyHS100.py. [GadgetReactor] + +- Update __init__.py. [GadgetReactor] + +- Update __init__.py. [GadgetReactor] + +0.1.2 (2016-07-09) +------------------ + +- 0.1.2. [GadgetReactor] + +- Update setup.py. [GadgetReactor] + +- Update setup.py. [GadgetReactor] + +- Delete pyHS100.py. [GadgetReactor] + +- Create pyHS100.py. [GadgetReactor] + +- Create __init__.py. [GadgetReactor] + +- Create setup.py. [GadgetReactor] + +- Create pyHS100.py. [GadgetReactor] + +- Initial commit. [GadgetReactor] + + +\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* diff --git a/CHANGELOG b/HISTORY.md similarity index 99% rename from CHANGELOG rename to HISTORY.md index aa634aefc..c42befd5a 100644 --- a/CHANGELOG +++ b/HISTORY.md @@ -1,5 +1,5 @@ -Changelog -========= +Historical pyHS100 changelog +============================ 0.3.5 (2019-04-13) ------------ diff --git a/HOWTO_RELEASE b/HOWTO_RELEASE deleted file mode 100644 index 6174eb528..000000000 --- a/HOWTO_RELEASE +++ /dev/null @@ -1,22 +0,0 @@ -# Preparing the changelog - -Looks like gitchangelog is not as nice as thought before, -however, you can generate the changelog by doing this: - -$ gitchangelog 0.x.y..HEAD - -and manually placing the output to CHANGELOG and fixing -the newlines between commit messages, and cleaning up the -unnecessary entries, and finally commiting the file. - -After that prepare a pull request containing the changed, -release-related files (CHANGELOG, setup.py, README, ..). - -# After release merge is done - -$ git tag -a v0.x.y -m "0.x.y" -$ git push --tags - -# Pushing to pypi - -$ python setup.py sdist bdist_wheel upload diff --git a/README.md b/README.md index b3399dc9b..09329ef18 100644 --- a/README.md +++ b/README.md @@ -3,64 +3,28 @@ [![PyPI version](https://badge.fury.io/py/python-kasa.svg)](https://badge.fury.io/py/python-kasa) [![Build Status](https://dev.azure.com/python-kasa/python-kasa/_apis/build/status/python-kasa.python-kasa?branchName=master)](https://dev.azure.com/python-kasa/python-kasa/_build/latest?definitionId=2&branchName=master) [![Coverage Status](https://coveralls.io/repos/github/python-kasa/python-kasa/badge.svg?branch=master)](https://coveralls.io/github/python-kasa/python-kasa?branch=master) +[![Documentation Status](https://readthedocs.org/projects/python-kasa/badge/?version=latest)](https://python-kasa.readthedocs.io/en/latest/?badge=latest) python-kasa is a Python library to control TPLink smart home devices (plugs, wall switches, power strips, and bulbs) using asyncio. This project is a maintainer-made fork of [pyHS100](https://github.com/GadgetReactor/pyHS100) project. +## Getting started + +You can install the most recent release using pip. Until +``` +pip install python-kasa --pre +``` + +Alternatively, you can clone this repository and use poetry to install the development version: +``` +git clone https://github.com/python-kasa/python-kasa.git +cd python-kasa/ +poetry install +``` -**Supported devices** - -* Plugs - * HS100 - * HS103 - * HS105 - * HS107 - * HS110 -* Power Strips - * HS300 - * KP303 -* Wall switches - * HS200 - * HS210 - * HS220 -* Bulbs - * LB100 - * LB110 - * LB120 - * LB130 - * LB230 - * KL60 - * KL110 - * KL120 - * KL130 - -**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
` 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. +After installation, the devices can be discovered either by using `kasa discover` or by calling `kasa` without any parameters. ``` $ kasa @@ -81,6 +45,8 @@ Location: {'latitude': XXXX, 'longitude': XXXX} Current state: {'total': 133.082, 'power': 100.418681, 'current': 0.510967, 'voltage': 225.600477} ``` +Use `kasa --help` to get list of all available commands, or alternatively, [consult the documentation](https://python-kasa.readthedocs.io/en/latest/cli.html). + ## Basic controls All devices support a variety of common commands, including: @@ -108,131 +74,74 @@ 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. +You can find several code examples in [the API documentation](https://python-kasa.readthedocs.io). -## 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 - -``` - -## Querying basic information +## Contributing -```python -import asyncio -from kasa import SmartPlug -from pprint import pformat as pf +Contributions are very welcome! To simplify the process, we are leveraging automated checks and tests for contributions. -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)) -``` +### Setting up development environment -The rest of the examples assume that you have initialized an instance. +To get started, simply clone this repository and initialize the development environment. +We are using [poetry](https://python-poetry.org) for dependency management, so after cloning the repository simply execute +`poetry install` which will install all necessary packages and create a virtual environment for you. -## State & switching +### Code-style checks -Devices can be turned on and off by either calling appropriate methods on the device object. +We use several tools to automatically check all contributions. The simplest way to verify that everything is formatted properly +before creating a pull request, consider activating the pre-commit hooks by executing `pre-commit install`. +This will make sure that the checks are passing when you do a commit. -```python -print("Current state: %s" % plug.is_on) -await plug.turn_off() -await plug.turn_on() -``` +You can also execute the checks by running either `tox -e lint` to only do the linting checks, or `tox` to also execute the tests. -## 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`) +### Analyzing network captures -You can also request this information separately: +The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device. +After capturing the traffic, you can either use the [softScheck's wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) +or the `parse_pcap.py` script contained inside the `devtools` directory. -```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 +## Supported devices -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`. +### Plugs -### Setting the brightness +* HS100 +* HS103 +* HS105 +* HS107 +* HS110 -```python -import asyncio -from kasa import SmartBulb +### Power Strips -bulb = SmartBulb("192.168.1.123") -asyncio.run(bulb.update()) +* HS300 +* KP303 -if bulb.is_dimmable: - asyncio.run(bulb.set_brightness(100)) - asyncio.run(bulb.update()) - print(bulb.brightness) -``` +### Wall switches -### Setting the color temperature -```python -if bulb.is_variable_color_temp: - await bulb.set_color_temp(3000) - await bulb.update() - print(bulb.color_temp) -``` +* HS200 +* HS210 +* HS220 -### Setting the color +### Bulbs -Hue is given in degrees (0-360) and saturation and value in percentage. +* LB100 +* LB110 +* LB120 +* LB130 +* LB230 +* KL60 +* KL110 +* KL120 +* KL130 -```python -if bulb.is_color: - await bulb.set_hsv(180, 100, 100) # set to cyan - await bulb.update() - print(bulb.hsv) -``` +### Light strips -## Contributing +* KL430 -Contributions are very welcome! To simplify the process, we are leveraging automated checks and tests for contributions. +**Contributions (be it adding missing features, fixing bugs or improving documentation) are more than welcome, feel free to submit pull requests!** ### Resources * [softScheck's github contains lot of information and wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) * [https://github.com/plasticrake/tplink-smarthome-simulator](tplink-smarthome-simulator) - -### Setting up development environment - -```bash -poetry install -pre-commit install -``` - -### Code-style checks - -We use several tools to automatically check all contributions, which are run automatically when you commit your code. - -If you want to manually execute the checks, you can run `tox -e lint` to do the linting checks or `tox` to also execute the tests. +* [Unofficial API documentation](https://github.com/plasticrake/tplink-smarthome-api/blob/master/API.md) diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 000000000..75a775edb --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,63 @@ +1. Set release information + +```bash +# export PREVIOUS_RELEASE=$(git describe --abbrev=0) +export PREVIOUS_RELEASE=0.3.5 # generate the full changelog since last pyhs100 release +export NEW_RELEASE=0.4.0.pre1 +``` + +2. Update the version number + +```bash +poetry version $NEW_RELEASE +``` + +3. Generate changelog + +```bash +# gem install github_changelog_generator --pre +# https://github.com/github-changelog-generator/github-changelog-generator#github-token +export CHANGELOG_GITHUB_TOKEN=token +github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md +``` + +3. Write a short and understandable summary for the release. + +4. Commit the changed files + +```bash +git commit -av +``` + +5. Create a PR for the release. + +6. Get it merged, fetch the upstream master + +```bash +git checkout master +git fetch upstream +git rebase upstream/master +``` + +5. Tag the release (add short changelog as a tag commit message), push the tag to git + +```bash +git tag -a $NEW_RELEASE +git push upstream $NEW_RELEASE +``` + +7. Upload new version to pypi + +If not done already, create an API key for pypi (https://pypi.org/manage/account/token/) and configure it: +``` +poetry config pypi-token.pypi +``` + +To build & release: + +```bash +poetry build +poetry publish +``` + +8. Click the "Draft a new release" button on github, select the new tag and copy & paste the changelog into the description. diff --git a/devtools/parse_pcap.py b/devtools/parse_pcap.py new file mode 100644 index 000000000..f9a55c88d --- /dev/null +++ b/devtools/parse_pcap.py @@ -0,0 +1,105 @@ +"""Parse pcaps for TP-Link communications.""" + +import json +from collections import Counter, defaultdict +from pprint import pformat as pf +from pprint import pprint as pp + +import click +import dpkt +from dpkt.ethernet import ETH_TYPE_IP, Ethernet +from kasa.protocol import TPLinkSmartHomeProtocol + + +def read_payloads_from_file(file): + """Read the given pcap file and yield json payloads.""" + pcap = dpkt.pcap.Reader(file) + for ts, pkt in pcap: + eth = Ethernet(pkt) + if eth.type != ETH_TYPE_IP: + continue + + ip = eth.ip + if ip.p == 6: + transport = ip.tcp + elif ip == 17: + transport = ip.udp + else: + continue + + if transport.sport != 9999 and transport.dport != 9999: + continue + + data = transport.data + + try: + decrypted = TPLinkSmartHomeProtocol.decrypt(data[4:]) + except Exception as ex: + click.echo( + click.style(f"Unable to decrypt the data, ignoring: {ex}", fg="red") + ) + continue + + try: + json_payload = json.loads(decrypted) + except Exception as ex: + click.echo( + click.style(f"Unable to parse payload, ignoring: {ex}", fg="red") + ) + continue + + if not json_payload: # ignore empty payloads + click.echo(click.style("Got empty payload, ignoring", fg="red")) + continue + + yield json_payload + + +@click.command() +@click.argument("file", type=click.File("rb")) +def parse_pcap(file): + """Parse pcap file and pretty print the communications and some statistics.""" + seen_items = defaultdict(Counter) + + for json_payload in read_payloads_from_file(file): + context = json_payload.pop("context", "") + for module, cmds in json_payload.items(): + seen_items["modules"][module] += 1 + if "err_code" in cmds: + click.echo(click.style("Got error for module: %s" % cmds, fg="red")) + continue + + for cmd, response in cmds.items(): + seen_items["commands"][cmd] += 1 + seen_items["full_command"][f"{module}.{cmd}"] += 1 + if response is None: + continue + direction = ">>" + style = {} + if response is None: + print("got none as response for %s, weird?" % (cmd)) + continue + if "err_code" in response: + direction = "<<" + if response["err_code"] != 0: + seen_items["errorcodes"][response["err_code"]] += 1 + seen_items["errors"][response["err_msg"]] += 1 + print(response) + style = {"bold": True, "fg": "red"} + else: + style = {"fg": "green"} + + context_str = f" [ctx: {context}]" if context else "" + + click.echo( + click.style( + f"{direction}{context_str} {module}.{cmd}: {pf(response)}", + **style, + ) + ) + + pp(seen_items) + + +if __name__ == "__main__": + parse_pcap() diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 000000000..d0c3cbf10 --- /dev/null +++ b/docs/Makefile @@ -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) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 000000000..6247f7e23 --- /dev/null +++ b/docs/make.bat @@ -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 diff --git a/docs/source/_static/copybutton.js b/docs/source/_static/copybutton.js new file mode 100644 index 000000000..a8e45151e --- /dev/null +++ b/docs/source/_static/copybutton.js @@ -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 = $('>>>'); + 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(''); + }); + + // 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'); + } + }); +}); diff --git a/docs/source/cli.rst b/docs/source/cli.rst new file mode 100644 index 000000000..0d1989dbf --- /dev/null +++ b/docs/source/cli.rst @@ -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
`` 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 diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 000000000..7e718402d --- /dev/null +++ b/docs/source/conf.py @@ -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) diff --git a/docs/source/discover.rst b/docs/source/discover.rst new file mode 100644 index 000000000..f47f50d72 --- /dev/null +++ b/docs/source/discover.rst @@ -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: diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 000000000..59897b394 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,18 @@ +python-kasa documentation +========================= + +.. mdinclude:: ../../README.md + +.. toctree:: + :maxdepth: 2 + + + Home + cli + discover + smartdevice + smartbulb + smartplug + smartdimmer + smartstrip + smartlightstrip diff --git a/docs/source/smartbulb.rst b/docs/source/smartbulb.rst new file mode 100644 index 000000000..76f66224e --- /dev/null +++ b/docs/source/smartbulb.rst @@ -0,0 +1,6 @@ +Bulbs +=========== + +.. autoclass:: kasa.SmartBulb + :members: + :undoc-members: diff --git a/docs/source/smartdevice.rst b/docs/source/smartdevice.rst new file mode 100644 index 000000000..dd08ac911 --- /dev/null +++ b/docs/source/smartdevice.rst @@ -0,0 +1,45 @@ +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. + +Simple example script showing some functionality: + +.. code-block:: python + + import asyncio + from kasa import SmartPlug + + async def main(): + p = SmartPlug("127.0.0.1") + + await p.update() + print(p.alias) + + await p.turn_off() + + + if __name__ == "__main__": + asyncio.run(main()) + + +Refer to device type specific classes for more examples: + +* :class:`SmartPlug` +* :class:`SmartBulb` +* :class:`SmartStrip` +* :class:`SmartDimmer` +* :class:`SmartLightStrip` + +.. autoclass:: kasa.SmartDevice + :members: + :undoc-members: diff --git a/docs/source/smartdimmer.rst b/docs/source/smartdimmer.rst new file mode 100644 index 000000000..f55d571cf --- /dev/null +++ b/docs/source/smartdimmer.rst @@ -0,0 +1,6 @@ +Dimmers +======= + +.. autoclass:: kasa.SmartDimmer + :members: + :undoc-members: diff --git a/docs/source/smartlightstrip.rst b/docs/source/smartlightstrip.rst new file mode 100644 index 000000000..b02342ed9 --- /dev/null +++ b/docs/source/smartlightstrip.rst @@ -0,0 +1,6 @@ +Light strips +============ + +.. autoclass:: kasa.SmartLightStrip + :members: + :undoc-members: diff --git a/docs/source/smartplug.rst b/docs/source/smartplug.rst new file mode 100644 index 000000000..75b342cb0 --- /dev/null +++ b/docs/source/smartplug.rst @@ -0,0 +1,6 @@ +Plugs +===== + +.. autoclass:: kasa.SmartPlug + :members: + :undoc-members: diff --git a/docs/source/smartstrip.rst b/docs/source/smartstrip.rst new file mode 100644 index 000000000..b6c9ff903 --- /dev/null +++ b/docs/source/smartstrip.rst @@ -0,0 +1,6 @@ +Smart strips +============ + +.. autoclass:: kasa.SmartStrip + :members: + :undoc-members: diff --git a/kasa/__init__.py b/kasa/__init__.py index e77aa7dde..911a7dc39 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -18,6 +18,7 @@ from kasa.smartbulb import SmartBulb from kasa.smartdevice import DeviceType, EmeterStatus, SmartDevice from kasa.smartdimmer import SmartDimmer +from kasa.smartlightstrip import SmartLightStrip from kasa.smartplug import SmartPlug from kasa.smartstrip import SmartStrip @@ -35,4 +36,5 @@ "SmartPlug", "SmartStrip", "SmartDimmer", + "SmartLightStrip", ] diff --git a/kasa/cli.py b/kasa/cli.py index 2538f0891..ed1264101 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -1,5 +1,4 @@ """python-kasa cli tool.""" -import asyncio import json import logging import re @@ -8,7 +7,14 @@ import asyncclick as click -from kasa import Discover, SmartBulb, SmartDevice, SmartPlug, SmartStrip +from kasa import ( + Discover, + SmartBulb, + SmartDevice, + SmartLightStrip, + SmartPlug, + SmartStrip, +) click.anyio_backend = "asyncio" @@ -38,10 +44,11 @@ @click.option("-d", "--debug", default=False, is_flag=True) @click.option("--bulb", default=False, is_flag=True) @click.option("--plug", default=False, is_flag=True) +@click.option("--lightstrip", default=False, is_flag=True) @click.option("--strip", default=False, is_flag=True) @click.version_option() @click.pass_context -async def cli(ctx, host, alias, target, debug, bulb, plug, strip): +async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip): """A tool for controlling TP-Link smart home devices.""" # noqa if debug: logging.basicConfig(level=logging.DEBUG) @@ -65,7 +72,7 @@ async def cli(ctx, host, alias, target, debug, bulb, plug, strip): await ctx.invoke(discover) return else: - if not bulb and not plug and not strip: + if not bulb and not plug and not strip and not lightstrip: click.echo("No --strip nor --bulb nor --plug given, discovering..") dev = await Discover.discover_single(host) elif bulb: @@ -74,6 +81,8 @@ async def cli(ctx, host, alias, target, debug, bulb, plug, strip): dev = SmartPlug(host) elif strip: dev = SmartStrip(host) + elif lightstrip: + dev = SmartLightStrip(host) else: click.echo("Unable to detect type, use --strip or --bulb or --plug!") return @@ -337,8 +346,9 @@ async def emeter(dev: SmartDevice, year, month, erase): @cli.command() @click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) +@click.option("--transition", type=int, required=False) @pass_dev -async def brightness(dev, brightness): +async def brightness(dev: SmartBulb, brightness: int, transition: int): """Get or set brightness.""" await dev.update() if not dev.is_dimmable: @@ -348,15 +358,16 @@ async def brightness(dev, brightness): click.echo(f"Brightness: {dev.brightness}") else: click.echo(f"Setting brightness to {brightness}") - click.echo(await dev.set_brightness(brightness)) + click.echo(await dev.set_brightness(brightness, transition=transition)) @cli.command() @click.argument( "temperature", type=click.IntRange(2500, 9000), default=None, required=False ) +@click.option("--transition", type=int, required=False) @pass_dev -async def temperature(dev: SmartBulb, temperature): +async def temperature(dev: SmartBulb, temperature: int, transition: int): """Get or set color temperature.""" await dev.update() if temperature is None: @@ -371,16 +382,17 @@ async def temperature(dev: SmartBulb, temperature): ) else: click.echo(f"Setting color temperature to {temperature}") - asyncio.run(dev.set_color_temp(temperature)) + await dev.set_color_temp(temperature, transition=transition) @cli.command() @click.argument("h", type=click.IntRange(0, 360), default=None, required=False) @click.argument("s", type=click.IntRange(0, 100), default=None, required=False) @click.argument("v", type=click.IntRange(0, 100), default=None, required=False) +@click.option("--transition", type=int, required=False) @click.pass_context @pass_dev -async def hsv(dev, ctx, h, s, v): +async def hsv(dev, ctx, h, s, v, transition): """Get or set color in HSV. (Bulb only).""" await dev.update() if h is None or s is None or v is None: @@ -389,7 +401,7 @@ async def hsv(dev, ctx, h, s, v): raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx) else: click.echo(f"Setting HSV: {h} {s} {v}") - click.echo(await dev.set_hsv(h, s, v)) + click.echo(await dev.set_hsv(h, s, v, transition=transition)) @cli.command() @@ -415,8 +427,9 @@ async def time(dev): @cli.command() @click.option("--index", type=int, required=False) @click.option("--name", type=str, required=False) +@click.option("--transition", type=int, required=False) @pass_dev -async def on(dev: SmartDevice, index, name): +async def on(dev: SmartDevice, index: int, name: str, transition: int): """Turn the device on.""" await dev.update() if index is not None or name is not None: @@ -430,14 +443,15 @@ async def on(dev: SmartDevice, index, name): dev = dev.get_plug_by_name(name) click.echo(f"Turning on {dev.alias}") - await dev.turn_on() + await dev.turn_on(transition=transition) @cli.command() @click.option("--index", type=int, required=False) @click.option("--name", type=str, required=False) +@click.option("--transition", type=int, required=False) @pass_dev -async def off(dev, index, name): +async def off(dev: SmartDevice, index: int, name: str, transition: int): """Turn the device off.""" await dev.update() if index is not None or name is not None: @@ -451,7 +465,7 @@ async def off(dev, index, name): dev = dev.get_plug_by_name(name) click.echo(f"Turning off {dev.alias}") - await dev.turn_off() + await dev.turn_off(transition=transition) @cli.command() diff --git a/kasa/discover.py b/kasa/discover.py index ef7f4d4ce..9fe0f4514 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -9,6 +9,7 @@ from kasa.smartbulb import SmartBulb from kasa.smartdevice import SmartDevice, SmartDeviceException from kasa.smartdimmer import SmartDimmer +from kasa.smartlightstrip import SmartLightStrip from kasa.smartplug import SmartPlug from kasa.smartstrip import SmartStrip @@ -21,7 +22,7 @@ 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 +73,7 @@ def datagram_received(self, data, addr) -> None: 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 +95,36 @@ def connection_lost(self, ex): 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 +152,13 @@ async def 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 +203,9 @@ async def discover_single(host: str) -> SmartDevice: 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) @@ -203,11 +228,19 @@ def _get_device_class(info: dict) -> Type[SmartDevice]: and "get_dimmer_parameters" in info["smartlife.iot.dimmer"] ): return SmartDimmer + elif "smartplug" in type_.lower() and "children" in sysinfo: return SmartStrip + elif "smartplug" in type_.lower(): + if "children" in sysinfo: + return SmartStrip + return SmartPlug elif "smartbulb" in type_.lower(): + if "length" in sysinfo: # strips have length + return SmartLightStrip + return SmartBulb raise SmartDeviceException("Unknown device type: %s", type_) diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index e6d5d5523..be81c1346 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -17,62 +17,80 @@ "KL130": (2500, 9000), r"KL120\(EU\)": (2700, 6500), r"KL120\(US\)": (2700, 5000), + r"KL430\(US\)": (2500, 9000), } class SmartBulb(SmartDevice): """Representation of a TP-Link Smart Bulb. - Usage example: - ```python - p = SmartBulb("192.168.1.105") - await p.update() - - # print the devices alias - print(p.alias) - - # 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, + 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 :func:`update()` separately. + + 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" + SET_LIGHT_METHOD = "transition_light_state" def __init__(self, host: str) -> None: super().__init__(host=host) @@ -137,15 +155,44 @@ def light_state(self) -> Dict[str, str]: return light_state + async def get_light_details(self) -> Dict[str, int]: + """Return light details. + + Example: + {'lamp_beam_angle': 290, 'min_voltage': 220, 'max_voltage': 240, + 'wattage': 5, 'incandescent_equivalent': 40, 'max_lumens': 450, + 'color_rendering_index': 80} + """ + return await self._query_helper(self.LIGHT_SERVICE, "get_light_details") + + async def get_turn_on_behavior(self) -> Dict: + """Return the behavior for turning the bulb on. + + Example: + {'soft_on': {'mode': 'last_status'}, + 'hard_on': {'mode': 'last_status'}} + """ + return await self._query_helper(self.LIGHT_SERVICE, "get_default_behavior") + 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: + async def set_light_state(self, state: Dict, *, transition: int = None) -> Dict: """Set the light state.""" + if transition is not None: + state["transition_period"] = transition + + # if no on/off is defined, turn on the light + if "on_off" not in state: + state["on_off"] = 1 + + # This is necessary to allow turning on into a specific state + state["ignore_default"] = 1 + light_state = await self._query_helper( - self.LIGHT_SERVICE, "transition_light_state", state + self.LIGHT_SERVICE, self.SET_LIGHT_METHOD, state ) return light_state @@ -174,12 +221,15 @@ def _raise_for_invalid_brightness(self, value): ) @requires_update - async def set_hsv(self, hue: int, saturation: int, value: int): + async def set_hsv( + self, hue: int, saturation: int, value: int, *, transition: int = None + ) -> Dict: """Set new HSV. :param int hue: hue in degrees :param int saturation: saturation in percentage [0,100] :param int value: value in percentage [0, 100] + :param int transition: transition in milliseconds. """ if not self.is_color: raise SmartDeviceException("Bulb does not support color.") @@ -203,7 +253,8 @@ async def set_hsv(self, hue: int, saturation: int, value: int): "brightness": value, "color_temp": 0, } - await self.set_light_state(light_state) + + return await self.set_light_state(light_state, transition=transition) @property # type: ignore @requires_update @@ -216,8 +267,14 @@ def color_temp(self) -> int: return int(light_state["color_temp"]) @requires_update - async def set_color_temp(self, temp: int) -> None: - """Set the color temperature of the device in kelvin.""" + async def set_color_temp( + self, temp: int, *, brightness=None, transition: int = None + ) -> Dict: + """Set the color temperature of the device in kelvin. + + :param int temp: The new color temperature, in Kelvin + :param int transition: transition in milliseconds. + """ if not self.is_variable_color_temp: raise SmartDeviceException("Bulb does not support colortemp.") @@ -229,7 +286,10 @@ async def set_color_temp(self, temp: int) -> None: ) light_state = {"color_temp": temp} - await self.set_light_state(light_state) + if brightness is not None: + light_state["brightness"] = brightness + + return await self.set_light_state(light_state, transition=transition) @property # type: ignore @requires_update @@ -242,15 +302,19 @@ def brightness(self) -> int: return int(light_state["brightness"]) @requires_update - async def set_brightness(self, brightness: int) -> None: - """Set the brightness in percentage.""" + async def set_brightness(self, brightness: int, *, transition: int = None) -> Dict: + """Set the brightness in percentage. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ if not self.is_dimmable: # pragma: no cover raise SmartDeviceException("Bulb is not dimmable.") self._raise_for_invalid_brightness(brightness) light_state = {"brightness": brightness} - await self.set_light_state(light_state) + return await self.set_light_state(light_state, transition=transition) @property # type: ignore @requires_update @@ -275,13 +339,19 @@ def is_on(self) -> bool: light_state = self.light_state return bool(light_state["on_off"]) - async def turn_off(self) -> None: - """Turn the bulb off.""" - await self.set_light_state({"on_off": 0}) + async def turn_off(self, *, transition: int = None, **kwargs) -> Dict: + """Turn the bulb off. - async def turn_on(self) -> None: - """Turn the bulb on.""" - await self.set_light_state({"on_off": 1}) + :param int transition: transition in milliseconds. + """ + return await self.set_light_state({"on_off": 0}, transition=transition) + + async def turn_on(self, *, transition: int = None, **kwargs) -> Dict: + """Turn the bulb on. + + :param int transition: transition in milliseconds. + """ + return await self.set_light_state({"on_off": 1}, transition=transition) @property # type: ignore @requires_update diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index cd2e8f5f9..19589bbad 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -32,6 +32,7 @@ class DeviceType(Enum): Bulb = 2 Strip = 3 Dimmer = 4 + LightStrip = 5 Unknown = -1 @@ -118,7 +119,99 @@ def wrapped(*args, **kwargs): 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` + * :class:`SmartLightStrip` + + 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 +475,9 @@ def update(d, u): @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 +491,9 @@ def emeter_today(self) -> Optional[float]: @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 +584,7 @@ async def current_consumption(self) -> float: 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, @@ -493,7 +592,7 @@ async def reboot(self, delay=1) -> None: """ await self._query_helper("system", "reboot", {"delay": delay}) - async def turn_off(self) -> None: + async def turn_off(self, **kwargs) -> Dict: """Turn off the device.""" raise NotImplementedError("Device subclass needs to implement this.") @@ -503,7 +602,7 @@ def is_off(self) -> bool: """Return True if device is off.""" return not self.is_on - async def turn_on(self) -> None: + async def turn_on(self, **kwargs) -> Dict: """Turn device on.""" raise NotImplementedError("Device subclass needs to implement this.") @@ -605,6 +704,11 @@ def is_bulb(self) -> bool: """Return True if the device is a bulb.""" return self._device_type == DeviceType.Bulb + @property + def is_light_strip(self) -> bool: + """Return True if the device is a led strip.""" + return self._device_type == DeviceType.LightStrip + @property def is_plug(self) -> bool: """Return True if the device is a plug.""" diff --git a/kasa/smartdimmer.py b/kasa/smartdimmer.py index e85493b13..8e5cb1527 100644 --- a/kasa/smartdimmer.py +++ b/kasa/smartdimmer.py @@ -9,20 +9,34 @@ 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" + def __init__(self, host: str) -> None: super().__init__(host) self._device_type = DeviceType.Dimmer @@ -41,19 +55,83 @@ def brightness(self) -> int: return int(sys_info["brightness"]) @requires_update - async def set_brightness(self, value: int): - """Set the new dimmer brightness level in percentage.""" + async def set_brightness(self, brightness: int, *, transition: int = None): + """Set the new dimmer brightness level in percentage. + + :param int transition: transition duration in milliseconds. + Using a transition will cause the dimmer to turn on. + """ if not self.is_dimmable: raise SmartDeviceException("Device is not dimmable.") - if not isinstance(value, int): - raise ValueError("Brightness must be integer, " "not of %s.", type(value)) - elif 0 <= value <= 100: - return await self._query_helper( - "smartlife.iot.dimmer", "set_brightness", {"brightness": value} + if not isinstance(brightness, int): + raise ValueError( + "Brightness must be integer, " "not of %s.", type(brightness) + ) + + if not 0 <= brightness <= 100: + raise ValueError("Brightness value %s is not valid." % brightness) + + # Dimmers do not support a brightness of 0, but bulbs do. + # Coerce 0 to 1 to maintain the same interface between dimmers and bulbs. + if brightness == 0: + brightness = 1 + + if transition is not None: + return await self.set_dimmer_transition(brightness, transition) + + return await self._query_helper( + self.DIMMER_SERVICE, "set_brightness", {"brightness": brightness} + ) + + async def turn_off(self, *, transition: int = None, **kwargs): + """Turn the bulb off. + + :param int transition: transition duration in milliseconds. + """ + if transition is not None: + return await self.set_dimmer_transition(brightness=0, transition=transition) + + return await super().turn_off() + + @requires_update + async def turn_on(self, *, transition: int = None, **kwargs): + """Turn the bulb on. + + :param int transition: transition duration in milliseconds. + """ + if transition is not None: + return await self.set_dimmer_transition( + brightness=self.brightness, transition=transition + ) + + return await super().turn_on() + + async def set_dimmer_transition(self, brightness: int, transition: int): + """Turn the bulb on to brightness percentage over transition milliseconds. + + A brightness value of 0 will turn off the dimmer. + """ + if not isinstance(brightness, int): + raise ValueError( + "Brightness must be integer, " "not of %s.", type(brightness) + ) + + if not 0 <= brightness <= 100: + raise ValueError("Brightness value %s is not valid." % brightness) + + if not isinstance(transition, int): + raise ValueError( + "Transition must be integer, " "not of %s.", type(transition) ) - else: - raise ValueError("Brightness value %s is not valid." % value) + if transition <= 0: + raise ValueError("Transition value %s is not valid." % transition) + + return await self._query_helper( + self.DIMMER_SERVICE, + "set_dimmer_transition", + {"brightness": brightness, "duration": transition}, + ) @property # type: ignore @requires_update diff --git a/kasa/smartlightstrip.py b/kasa/smartlightstrip.py new file mode 100644 index 000000000..c579fec20 --- /dev/null +++ b/kasa/smartlightstrip.py @@ -0,0 +1,75 @@ +"""Module for light strips (KL430).""" +from typing import Any, Dict + +from .smartbulb import SmartBulb +from .smartdevice import DeviceType, requires_update + + +class SmartLightStrip(SmartBulb): + """Representation of a TP-Link Smart light strip. + + Light strips work similarly to bulbs, but use a different service for controlling, + and expose some extra information (such as length and active effect). + This class extends :class:`SmartBulb` interface. + + Examples: + >>> import asyncio + >>> strip = SmartLightStrip("127.0.0.1") + >>> asyncio.run(strip.update()) + >>> print(strip.alias) + KL430 pantry lightstrip + + Getting the length of the strip: + + >>> strip.length + 16 + + Currently active effect: + + >>> strip.effect + {'brightness': 50, 'custom': 0, 'enable': 0, 'id': '', 'name': ''} + + .. note:: + The device supports some features that are not currently implemented, + feel free to find out how to control them and create a PR! + + + See :class:`SmartBulb` for more examples. + """ + + LIGHT_SERVICE = "smartlife.iot.lightStrip" + SET_LIGHT_METHOD = "set_light_state" + + def __init__(self, host: str) -> None: + super().__init__(host) + self._device_type = DeviceType.LightStrip + + @property # type: ignore + @requires_update + def length(self) -> int: + """Return length of the strip.""" + return self.sys_info["length"] + + @property # type: ignore + @requires_update + def effect(self) -> Dict: + """Return effect state. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + return self.sys_info["lighting_effect_state"] + + @property # type: ignore + @requires_update + def state_information(self) -> Dict[str, Any]: + """Return strip specific state information.""" + info = super().state_information + + info["Length"] = self.length + + return info diff --git a/kasa/smartplug.py b/kasa/smartplug.py index 55904eb8d..d23bc9396 100644 --- a/kasa/smartplug.py +++ b/kasa/smartplug.py @@ -10,24 +10,30 @@ 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() + Errors reported by the device are raised as :class:`SmartDeviceException`s, + and should be handled by the user of the library. - # print current state of plug - print(p.state_information) - ``` + Examples: + >>> import asyncio + >>> plug = SmartPlug("127.0.0.1") + >>> asyncio.run(plug.update()) + >>> plug.alias + Kitchen - Errors reported by the device are raised as SmartDeviceExceptions, - and should be handled by the user of the library. + 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: @@ -42,11 +48,11 @@ def is_on(self) -> bool: sys_info = self.sys_info return bool(sys_info["relay_state"]) - async def turn_on(self): + async def turn_on(self, **kwargs): """Turn the switch on.""" return await self._query_helper("system", "set_relay_state", {"state": 1}) - async def turn_off(self): + async def turn_off(self, **kwargs): """Turn the switch off.""" return await self._query_helper("system", "set_relay_state", {"state": 0}) diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index e5c3a1afb..222c73e45 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -18,28 +18,52 @@ 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: @@ -73,12 +97,12 @@ async def update(self): SmartStripPlug(self.host, parent=self, child_id=child["id"]) ) - async def turn_on(self): + async def turn_on(self, **kwargs): """Turn the strip on.""" await self._query_helper("system", "set_relay_state", {"state": 1}) await self.update() - async def turn_off(self): + async def turn_off(self, **kwargs): """Turn the strip off.""" await self._query_helper("system", "set_relay_state", {"state": 0}) await self.update() @@ -212,7 +236,7 @@ async def _query_helper( 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 diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index f2b4c178f..69f1f3b72 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -3,11 +3,19 @@ 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 -from kasa import Discover, SmartBulb, SmartDimmer, SmartPlug, SmartStrip +from kasa import ( + Discover, + SmartBulb, + SmartDimmer, + SmartLightStrip, + SmartPlug, + SmartStrip, +) from .newfakes import FakeTransportProtocol @@ -16,9 +24,11 @@ ) -BULBS = {"KL60", "LB100", "LB120", "LB130", "KL120", "KL130"} -VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL130"} -COLOR_BULBS = {"LB130", "KL130"} +LIGHT_STRIPS = {"KL430"} +BULBS = {"KL60", "LB100", "LB120", "LB130", "KL120", "KL130", *LIGHT_STRIPS} +VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL130", "KL430", *LIGHT_STRIPS} +COLOR_BULBS = {"LB130", "KL130", *LIGHT_STRIPS} + PLUGS = {"HS100", "HS103", "HS105", "HS110", "HS200", "HS210"} STRIPS = {"HS107", "HS300", "KP303", "KP400"} @@ -64,9 +74,12 @@ def name_for_filename(x): plug = parametrize("plugs", PLUGS, ids=name_for_filename) strip = parametrize("strips", STRIPS, ids=name_for_filename) dimmer = parametrize("dimmers", DIMMERS, ids=name_for_filename) +lightstrip = parametrize("lightstrips", LIGHT_STRIPS, ids=name_for_filename) # This ensures that every single file inside fixtures/ is being placed in some category -categorized_fixtures = set(dimmer.args[1] + strip.args[1] + plug.args[1] + bulb.args[1]) +categorized_fixtures = set( + dimmer.args[1] + strip.args[1] + plug.args[1] + bulb.args[1] + lightstrip.args[1] +) diff = set(SUPPORTED_DEVICES) - set(categorized_fixtures) if diff: for file in diff: @@ -100,6 +113,46 @@ 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 + + # Light strips are recognized also as bulbs, so this has to go first + for d in LIGHT_STRIPS: + if d in model: + return SmartLightStrip + + 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 +170,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): diff --git a/kasa/tests/fixtures/KL130(US)_1.0.json b/kasa/tests/fixtures/KL130(US)_1.0.json index 49c16ec51..b07044a65 100644 --- a/kasa/tests/fixtures/KL130(US)_1.0.json +++ b/kasa/tests/fixtures/KL130(US)_1.0.json @@ -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", diff --git a/kasa/tests/fixtures/KL430(US)_1.0.json b/kasa/tests/fixtures/KL430(US)_1.0.json new file mode 100644 index 000000000..f12e7d500 --- /dev/null +++ b/kasa/tests/fixtures/KL430(US)_1.0.json @@ -0,0 +1,70 @@ +{ + "emeter": { + "err_code": -1, + "err_msg": "module not support" + }, + "smartlife.iot.common.emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 8729, + "total_wh": 21, + "voltage_mv": 0 + } + }, + "smartlife.iot.dimmer": { + "err_code": -1, + "err_msg": "module not support" + }, + "smartlife.iot.smartbulb.lightingservice": { + "err_code": -1, + "err_msg": "module not support" + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "KL430 pantry lightstrip", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Kasa Smart Light Strip, Multicolor", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "length": 16, + "light_state": { + "brightness": 50, + "color_temp": 3630, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "lighting_effect_state": { + "brightness": 50, + "custom": 0, + "enable": 0, + "id": "", + "name": "" + }, + "longitude_i": 0, + "mic_mac": "CC32E5230F55", + "mic_type": "IOT.SMARTBULB", + "model": "KL430(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [], + "rssi": -56, + "status": "new", + "sw_ver": "1.0.10 Build 200522 Rel.104340" + } + } +} diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index c54dec3be..55c3e00cb 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -240,42 +240,41 @@ def get_daystat_units(obj, x, *args): } -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,42 +308,55 @@ def set_led_off(self, x, *args): 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) self.proto["system"]["get_sysinfo"]["brightness"] = x["brightness"] - def transition_light_state(self, x, *args): - _LOGGER.debug("Setting light state to %s", x) + def set_hs220_dimmer_transition(self, x, *args): + _LOGGER.debug("Setting dimmer transition to %s", x) + brightness = x["brightness"] + if brightness == 0: + self.proto["system"]["get_sysinfo"]["relay_state"] = 0 + else: + self.proto["system"]["get_sysinfo"]["relay_state"] = 1 + self.proto["system"]["get_sysinfo"]["brightness"] = x["brightness"] + + def transition_light_state(self, state_changes, *args): + _LOGGER.debug("Setting light state to %s", state_changes) 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 - if "on_off" in x: - 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["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} + _LOGGER.debug("Current light state: %s", light_state) + new_state = light_state - self.proto["system"]["get_sysinfo"]["light_state"] = new_state + if state_changes["on_off"] == 1: # turn on requested + if not light_state[ + "on_off" + ]: # if we were off, use the dft_on_state as a base + _LOGGER.debug("Bulb was off, using dft_on_state") + new_state = light_state["dft_on_state"] - return + # override the existing settings + new_state.update(state_changes) - if not light_state["on_off"] and "on_off" not in x: - light_state = light_state["dft_on_state"] + if ( + not state_changes["on_off"] and "dft_on_state" not in light_state + ): # if not already off, pack the data inside dft_on_state + _LOGGER.debug( + "Bulb was on and turn_off was requested, saving to dft_on_state" + ) + new_state = {"dft_on_state": light_state, "on_off": 0} - _LOGGER.debug("Current state: %s", light_state) - for key in x: - light_state[key] = x[key] + _LOGGER.debug("New light state: %s", new_state) + self.proto["system"]["get_sysinfo"]["light_state"] = new_state def light_state(self, x, *args): 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.debug("reporting light state: %s", light_state) - if light_state["on_off"]: + # TODO: hack to go around KL430 fixture differences + if light_state["on_off"] and "dft_on_state" in light_state: return light_state["dft_on_state"] else: return light_state @@ -374,6 +386,11 @@ def light_state(self, x, *args): "get_light_state": light_state, "transition_light_state": transition_light_state, }, + # lightstrip follows the same payloads but uses different module & method + "smartlife.iot.lightStrip": { + "set_light_state": transition_light_state, + "get_light_state": light_state, + }, "time": { "get_time": { "year": 2017, @@ -392,7 +409,10 @@ def light_state(self, x, *args): "set_timezone": None, }, # HS220 brightness, different setter and getter - "smartlife.iot.dimmer": {"set_brightness": set_hs220_brightness}, + "smartlife.iot.dimmer": { + "set_brightness": set_hs220_brightness, + "set_dimmer_transition": set_hs220_dimmer_transition, + }, } async def query(self, host, request, port=9999): @@ -405,26 +425,39 @@ async def query(self, host, request, port=9999): except KeyError: child_ids = [] - target = next(iter(request)) - if target not in proto.keys(): - return error(target, msg="target not found") - - cmd = next(iter(request[target])) - if cmd not in proto[target].keys(): - return error(target, cmd, msg="command not found") - - params = request[target][cmd] - _LOGGER.debug(f"Going to execute {target}.{cmd} (params: {params}).. ") - - 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) - elif isinstance(proto[target][cmd], dict): - res = proto[target][cmd] - _LOGGER.debug("[static] %s.%s: %s", target, cmd, res) - return success(target, cmd, res) - else: - raise NotImplementedError(f"target {target} cmd {cmd}") + def get_response_for_module(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(msg=f"command {cmd} not found") + + params = request[target][cmd] + _LOGGER.debug(f"Going to execute {target}.{cmd} (params: {params}).. ") + + if callable(proto[target][cmd]): + res = proto[target][cmd](self, params, child_ids) + _LOGGER.debug("[callable] %s.%s: %s", 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(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 diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index c3365d9f7..7d6e45e02 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -10,6 +10,7 @@ non_color_bulb, non_dimmable, non_variable_temp, + pytestmark, turn_on, variable_temp, ) @@ -23,8 +24,10 @@ async def test_bulb_sysinfo(dev): assert dev.model is not None - assert dev.device_type == DeviceType.Bulb - assert dev.is_bulb + # TODO: remove special handling for lightstrip + if not dev.is_light_strip: + assert dev.device_type == DeviceType.Bulb + assert dev.is_bulb @bulb @@ -69,6 +72,17 @@ async def test_hsv(dev, turn_on): assert brightness == 1 +@color_bulb +async def test_set_hsv_transition(dev, mocker): + set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") + await dev.set_hsv(10, 10, 100, transition=1000) + + set_light_state.assert_called_with( + {"hue": 10, "saturation": 10, "brightness": 100, "color_temp": 0}, + transition=1000, + ) + + @color_bulb @turn_on async def test_invalid_hsv(dev, turn_on): @@ -123,6 +137,14 @@ async def test_try_set_colortemp(dev, turn_on): assert dev.color_temp == 2700 +@variable_temp +async def test_set_color_temp_transition(dev, mocker): + set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") + await dev.set_color_temp(2700, transition=100) + + set_light_state.assert_called_with({"color_temp": 2700}, transition=100) + + @variable_temp async def test_unknown_temp_range(dev, monkeypatch): with pytest.raises(SmartDeviceException): @@ -166,6 +188,26 @@ async def test_dimmable_brightness(dev, turn_on): await dev.set_brightness("foo") +@bulb +async def test_turn_on_transition(dev, mocker): + set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") + await dev.turn_on(transition=1000) + + set_light_state.assert_called_with({"on_off": 1}, transition=1000) + + await dev.turn_off(transition=100) + + set_light_state.assert_called_with({"on_off": 0}, transition=100) + + +@bulb +async def test_dimmable_brightness_transition(dev, mocker): + set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") + await dev.set_brightness(10, transition=1000) + + set_light_state.assert_called_with({"brightness": 10}, transition=1000) + + @dimmable async def test_invalid_brightness(dev): assert dev.is_dimmable diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index dd174608a..1b94d4897 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -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): diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py new file mode 100644 index 000000000..96a1021a6 --- /dev/null +++ b/kasa/tests/test_dimmer.py @@ -0,0 +1,134 @@ +import pytest + +from kasa import SmartDimmer + +from .conftest import dimmer, handle_turn_on, pytestmark, turn_on + + +@dimmer +@turn_on +async def test_set_brightness(dev, turn_on): + await handle_turn_on(dev, turn_on) + + await dev.set_brightness(99) + assert dev.brightness == 99 + assert dev.is_on == turn_on + + await dev.set_brightness(0) + assert dev.brightness == 1 + assert dev.is_on == turn_on + + +@dimmer +@turn_on +async def test_set_brightness_transition(dev, turn_on, mocker): + await handle_turn_on(dev, turn_on) + query_helper = mocker.spy(SmartDimmer, "_query_helper") + + await dev.set_brightness(99, transition=1000) + + assert dev.brightness == 99 + assert dev.is_on + query_helper.assert_called_with( + mocker.ANY, + "smartlife.iot.dimmer", + "set_dimmer_transition", + {"brightness": 99, "duration": 1000}, + ) + + await dev.set_brightness(0, transition=1000) + assert dev.brightness == 1 + + +@dimmer +async def test_set_brightness_invalid(dev): + for invalid_brightness in [-1, 101, 0.5]: + with pytest.raises(ValueError): + await dev.set_brightness(invalid_brightness) + + for invalid_transition in [-1, 0, 0.5]: + with pytest.raises(ValueError): + await dev.set_brightness(1, transition=invalid_transition) + + +@dimmer +async def test_turn_on_transition(dev, mocker): + query_helper = mocker.spy(SmartDimmer, "_query_helper") + original_brightness = dev.brightness + + await dev.turn_on(transition=1000) + + assert dev.is_on + assert dev.brightness == original_brightness + query_helper.assert_called_with( + mocker.ANY, + "smartlife.iot.dimmer", + "set_dimmer_transition", + {"brightness": original_brightness, "duration": 1000}, + ) + + +@dimmer +async def test_turn_off_transition(dev, mocker): + await handle_turn_on(dev, True) + query_helper = mocker.spy(SmartDimmer, "_query_helper") + original_brightness = dev.brightness + + await dev.turn_off(transition=1000) + + assert dev.is_off + assert dev.brightness == original_brightness + query_helper.assert_called_with( + mocker.ANY, + "smartlife.iot.dimmer", + "set_dimmer_transition", + {"brightness": 0, "duration": 1000}, + ) + + +@dimmer +@turn_on +async def test_set_dimmer_transition(dev, turn_on, mocker): + await handle_turn_on(dev, turn_on) + query_helper = mocker.spy(SmartDimmer, "_query_helper") + + await dev.set_dimmer_transition(99, 1000) + + assert dev.is_on + assert dev.brightness == 99 + query_helper.assert_called_with( + mocker.ANY, + "smartlife.iot.dimmer", + "set_dimmer_transition", + {"brightness": 99, "duration": 1000}, + ) + + +@dimmer +@turn_on +async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): + await handle_turn_on(dev, turn_on) + original_brightness = dev.brightness + query_helper = mocker.spy(SmartDimmer, "_query_helper") + + await dev.set_dimmer_transition(0, 1000) + + assert dev.is_off + assert dev.brightness == original_brightness + query_helper.assert_called_with( + mocker.ANY, + "smartlife.iot.dimmer", + "set_dimmer_transition", + {"brightness": 0, "duration": 1000}, + ) + + +@dimmer +async def test_set_dimmer_transition_invalid(dev): + for invalid_brightness in [-1, 101, 0.5]: + with pytest.raises(ValueError): + await dev.set_dimmer_transition(invalid_brightness, 1000) + + for invalid_transition in [-1, 0, 0.5]: + with pytest.raises(ValueError): + await dev.set_dimmer_transition(1, invalid_transition) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 45121d1de..529ad8d63 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -3,10 +3,7 @@ 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, lightstrip, plug, pytestmark, strip @plug @@ -19,8 +16,10 @@ async def test_type_detection_plug(dev: SmartDevice): @bulb async def test_type_detection_bulb(dev: SmartDevice): d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") - assert d.is_bulb - assert d.device_type == DeviceType.Bulb + # TODO: light_strip is a special case for now to force bulb tests on it + if not d.is_light_strip: + assert d.is_bulb + assert d.device_type == DeviceType.Bulb @strip @@ -37,6 +36,13 @@ async def test_type_detection_dimmer(dev: SmartDevice): assert d.device_type == DeviceType.Dimmer +@lightstrip +async def test_type_detection_lightstrip(dev: SmartDevice): + d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") + assert d.is_light_strip + assert d.device_type == DeviceType.LightStrip + + async def test_type_unknown(): invalid_info = {"system": {"get_sysinfo": {"type": "nosuchtype"}}} with pytest.raises(SmartDeviceException): diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index ecdb42417..5cdd50677 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -2,7 +2,7 @@ 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 diff --git a/kasa/tests/test_plug.py b/kasa/tests/test_plug.py index d9f118250..a301095e6 100644 --- a/kasa/tests/test_plug.py +++ b/kasa/tests/test_plug.py @@ -1,6 +1,6 @@ from kasa import DeviceType -from .conftest import plug +from .conftest import plug, pytestmark from .newfakes import PLUG_SCHEMA diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index 0a8291e1c..51c01d49d 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -4,6 +4,7 @@ from ..exceptions import SmartDeviceException from ..protocol import TPLinkSmartHomeProtocol +from .conftest import pytestmark @pytest.mark.parametrize("retry_count", [1, 3, 5]) diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py new file mode 100644 index 000000000..c4d9f693e --- /dev/null +++ b/kasa/tests/test_readme_examples.py @@ -0,0 +1,74 @@ +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"] + + +def test_lightstrip_examples(mocker): + """Test lightstrip examples.""" + p = get_device_for_file("KL430(US)_1.0.json") + mocker.patch("kasa.smartlightstrip.SmartLightStrip", return_value=p) + mocker.patch("kasa.smartlightstrip.SmartLightStrip.update") + res = xdoctest.doctest_module("kasa.smartlightstrip", "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 ["] @@ -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