Merge remote-tracking branch 'upstream/master' into nw/fix_non_indexed_strip

This commit is contained in:
Steven B
2024-12-20 09:28:43 +00:00
474 changed files with 76947 additions and 13269 deletions

3
.gitattributes vendored
View File

@@ -1 +1,4 @@
*.sh text eol=lf
*.json text eol=lf
*.md text eol=lf
*.rst text eol=lf

49
.github/actions/setup/action.yaml vendored Normal file
View File

@@ -0,0 +1,49 @@
---
name: Setup Environment
description: Install uv, configure the system python, and the package dependencies
inputs:
uv-install-options:
default: ""
uv-version:
default: 0.4.16
python-version:
required: true
cache-pre-commit:
default: false
cache-version:
default: "v0.1"
runs:
using: composite
steps:
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- name: "Setup python"
uses: "actions/setup-python@v5"
id: setup-python
with:
python-version: "${{ inputs.python-version }}"
allow-prereleases: true
- name: "Install project"
shell: bash
run: |
uv sync ${{ inputs.uv-install-options }}
- name: Read pre-commit version
if: inputs.cache-pre-commit == 'true'
id: pre-commit-version
shell: bash
run: >-
echo "pre-commit-version=$(uv run pre-commit -V | awk '{print $2}')" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
if: inputs.cache-pre-commit == 'true'
name: Pre-commit cache
with:
path: ~/.cache/pre-commit/
key: cache-${{ inputs.cache-version }}-${{ runner.os }}-${{ runner.arch }}-pre-commit-${{ steps.pre-commit-version.outputs.pre-commit-version }}-python-${{ inputs.python-version }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}

View File

@@ -2,11 +2,13 @@ name: CI
on:
push:
branches: ["master"]
branches: ["master", "patch"]
pull_request:
branches: ["master"]
branches: ["master", "patch"]
workflow_dispatch: # to allow manual re-runs
env:
UV_VERSION: 0.4.16
jobs:
linting:
@@ -15,38 +17,23 @@ jobs:
strategy:
matrix:
python-version: ["3.12"]
python-version: ["3.13"]
steps:
- uses: "actions/checkout@v2"
- uses: "actions/setup-python@v2"
- name: "Checkout source files"
uses: "actions/checkout@v4"
- name: Setup environment
uses: ./.github/actions/setup
with:
python-version: "${{ matrix.python-version }}"
- name: "Install dependencies"
python-version: ${{ matrix.python-version }}
cache-pre-commit: true
uv-version: ${{ env.UV_VERSION }}
uv-install-options: "--all-extras"
- name: "Run pre-commit checks"
run: |
python -m pip install --upgrade pip poetry
poetry install
- name: "Linting and code formating (ruff)"
run: |
poetry run pre-commit run ruff --all-files
- name: "Typing checks (mypy)"
run: |
poetry run pre-commit run mypy --all-files
- name: "Run trailing-whitespace"
run: |
poetry run pre-commit run trailing-whitespace --all-files
- name: "Run end-of-file-fixer"
run: |
poetry run pre-commit run end-of-file-fixer --all-files
- name: "Run check-docstring-first"
run: |
poetry run pre-commit run check-docstring-first --all-files
- name: "Run debug-statements"
run: |
poetry run pre-commit run debug-statements --all-files
- name: "Run check-ast"
run: |
poetry run pre-commit run check-ast --all-files
uv run pre-commit run --all-files --verbose
tests:
name: Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }}
@@ -55,7 +42,7 @@ jobs:
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy-3.8", "pypy-3.10"]
python-version: ["3.11", "3.12", "3.13"]
os: [ubuntu-latest, macos-latest, windows-latest]
extras: [false, true]
exclude:
@@ -63,41 +50,19 @@ jobs:
extras: true
- os: windows-latest
extras: true
- os: ubuntu-latest
python-version: "pypy-3.8"
extras: true
- os: ubuntu-latest
python-version: "pypy-3.10"
extras: true
- os: ubuntu-latest
python-version: "3.8"
extras: true
- os: ubuntu-latest
python-version: "3.9"
extras: true
- os: ubuntu-latest
python-version: "3.10"
extras: true
steps:
- uses: "actions/checkout@v3"
- uses: "actions/setup-python@v4"
- uses: "actions/checkout@v4"
- name: Setup environment
uses: ./.github/actions/setup
with:
python-version: "${{ matrix.python-version }}"
- name: "Install dependencies (no speedups)"
if: matrix.extras == false
python-version: ${{ matrix.python-version }}
uv-version: ${{ env.UV_VERSION }}
uv-install-options: ${{ matrix.extras == true && '--all-extras' || '' }}
- name: "Run tests (with coverage)"
run: |
python -m pip install --upgrade pip poetry
poetry install
- name: "Install dependencies (with speedups)"
if: matrix.extras == true
run: |
python -m pip install --upgrade pip poetry
poetry install --extras speedups
- name: "Run tests"
run: |
poetry run pytest --cov kasa --cov-report xml
uv run pytest -n auto --cov kasa --cov-report xml
- name: "Upload coverage to Codecov"
uses: "codecov/codecov-action@v3"
uses: "codecov/codecov-action@v4"
with:
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -2,9 +2,9 @@ name: "CodeQL checks"
on:
push:
branches: [ master ]
branches: [ "master", "patch" ]
pull_request:
branches: [ master ]
branches: [ master, "patch" ]
schedule:
- cron: '44 17 * * 3'

View File

@@ -3,6 +3,10 @@ on:
release:
types: [published]
env:
UV_VERSION: 0.4.16
PYTHON_VERSION: 3.12
jobs:
build-n-publish:
name: Build release packages
@@ -11,25 +15,19 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@master
- name: Checkout source files
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
- name: Setup python
uses: actions/setup-python@v4
with:
python-version: "3.x"
python-version: ${{ env.PYTHON_VERSION }}
- name: Install pypa/build
run: >-
python -m
pip install
build
--user
- name: Build a binary wheel and a source tarball
run: >-
python -m
build
--sdist
--wheel
--outdir dist/
.
run: uv build
- name: Publish release on pypi
uses: pypa/gh-action-pypi-publish@release/v1

69
.github/workflows/stale.yml vendored Normal file
View File

@@ -0,0 +1,69 @@
name: Stale
# yamllint disable-line rule:truthy
on:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
jobs:
stale:
if: github.repository_owner == 'python-kasa'
runs-on: ubuntu-latest
steps:
- name: Stale issues and prs
uses: actions/stale@v9.0.0
with:
repo-token: ${{ github.token }}
days-before-stale: 90
days-before-close: 7
operations-per-run: 250
remove-stale-when-updated: true
stale-issue-label: "stale"
exempt-issue-labels: "no-stale,help-wanted,needs-more-information,waiting-for-reporter"
stale-pr-label: "stale"
exempt-pr-labels: "no-stale"
stale-pr-message: >
There hasn't been any activity on this pull request recently. This
pull request has been automatically marked as stale because of that
and will be closed if no further activity occurs within 7 days.
If you are the author of this PR, please leave a comment if you want
to keep it open. Also, please rebase your PR onto the latest dev
branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!
stale-issue-message: >
There hasn't been any activity on this issue recently. This issue has
been automatically marked as stale because of that. It will be closed
if no further activity occurs.
Please make sure to update to the latest python-kasa version and
check if that solves the issue.
Thank you for your contributions.
- name: Needs-more-information and waiting-for-reporter stale issues policy
uses: actions/stale@v9.0.0
with:
repo-token: ${{ github.token }}
only-labels: "needs-more-information,waiting-for-reporter"
days-before-stale: 21
days-before-close: 7
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 250
remove-stale-when-updated: true
stale-issue-label: "stale"
exempt-issue-labels: "no-stale,help-wanted"
stale-issue-message: >
There hasn't been any activity on this issue recently and it has
been waiting for the reporter to provide information or an update.
This issue has been automatically marked as stale because of that.
It will be closed if no further activity occurs.
Please make sure to update to the latest python-kasa version and
check if that solves the issue.
Thank you for your contributions.

View File

@@ -1,4 +1,11 @@
breaking_labels=breaking change
add-sections={"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]}}
release_branch=master
output=CHANGELOG.md
base=HISTORY.md
user=python-kasa
project=python-kasa
since-tag=0.3.5
release-branch=master
usernames-as-github-logins=true
breaking_labels=breaking change
add-sections={"new-device":{"prefix":"**Added support for devices:**","labels":["new device"]},"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]},"maintenance":{"prefix":"**Project maintenance:**","labels":["maintenance"]}}
exclude-labels=duplicate,question,invalid,wontfix,release-prep,stale
issues-wo-labels=false

View File

@@ -1,6 +1,14 @@
repos:
- repo: https://github.com/astral-sh/uv-pre-commit
# uv version.
rev: 0.4.16
hooks:
# Update the uv lockfile
- id: uv-lock
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
@@ -10,20 +18,36 @@ repos:
- id: check-ast
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.3
rev: v0.7.4
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.3.0
hooks:
- id: mypy
additional_dependencies: [types-click]
- repo: https://github.com/PyCQA/doc8
rev: 'v1.1.1'
hooks:
- id: doc8
additional_dependencies: [tomli]
- repo: local
hooks:
# Run mypy in the virtual environment so it uses the installed dependencies
# for more accurate checking than using the pre-commit mypy mirror
- id: mypy
name: mypy
entry: uv run mypy
language: system
types_or: [python, pyi]
require_serial: true
exclude: | # exclude required because --all-files passes py and pyi
(?x)^(
kasa/modulemapping\.py|
)$
- id: generate-supported
name: Generate supported devices
description: This hook generates the supported device sections of README.md and SUPPORTED.md
entry: uv run ./devtools/generate_supported.py
language: system # Required or pre-commit creates a new venv
types: [json]
pass_filenames: false # passing filenames causes the hook to run in batches against all-files

View File

@@ -6,6 +6,9 @@ build:
os: ubuntu-22.04
tools:
python: "3"
jobs:
pre_build:
- python -m sphinx -b linkcheck docs/source/ $READTHEDOCS_OUTPUT/linkcheck
python:
install:

File diff suppressed because it is too large Load Diff

4
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,4 @@
# Contributing to python-kasa
All types of contributions are very welcome.
To make the process as straight-forward as possible, we have written [some instructions in our docs](https://python-kasa.readthedocs.io/en/latest/contribute.html) to get you started.

362
README.md
View File

@@ -1,4 +1,4 @@
<h2 align="center">python-kasa</h2>
# python-kasa
[![PyPI version](https://badge.fury.io/py/python-kasa.svg)](https://badge.fury.io/py/python-kasa)
[![Build Status](https://github.com/python-kasa/python-kasa/actions/workflows/ci.yml/badge.svg)](https://github.com/python-kasa/python-kasa/actions/workflows/ci.yml)
@@ -20,18 +20,12 @@ You can install the most recent release using pip:
pip install python-kasa
```
If you are using cpython, it is recommended to install with `[speedups]` to enable orjson (faster json support):
```
pip install python-kasa[speedups]
```
With `[speedups]`, the protocol overhead is roughly an order of magnitude lower (benchmarks available in devtools).
Alternatively, you can clone this repository and use poetry to install the development version:
Alternatively, you can clone this repository and use `uv` to install the development version:
```
git clone https://github.com/python-kasa/python-kasa.git
cd python-kasa/
poetry install
uv sync --all-extras
uv run kasa
```
If you have not yet provisioned your device, [you can do so using the cli tool](https://python-kasa.readthedocs.io/en/latest/cli.html#provisioning).
@@ -39,7 +33,11 @@ If you have not yet provisioned your device, [you can do so using the cli tool](
## Discovering devices
Running `kasa discover` will send discovery packets to the default broadcast address (`255.255.255.255`) to discover supported devices.
If your system has multiple network interfaces, you can specify the broadcast address using the `--target` option.
If your device requires authentication to control it,
you need to pass the credentials using `--username` and `--password` options or define `KASA_USERNAME` and `KASA_PASSWORD` environment variables.
> [!NOTE]
> If your system has multiple network interfaces, you can specify the broadcast address using the `--target` option.
The `discover` command will automatically execute the `state` command on all the discovered devices:
@@ -47,259 +45,178 @@ The `discover` command will automatically execute the `state` command on all the
$ kasa discover
Discovering devices on 255.255.255.255 for 3 seconds
== Bulb McBulby - KL130(EU) ==
Host: 192.168.xx.xx
Port: 9999
Device state: True
== Generic information ==
Time: 2023-12-05 14:33:23 (tz: {'index': 6, 'err_code': 0}
Hardware: 1.0
Software: 1.8.8 Build 190613 Rel.123436
MAC (rssi): 1c:3b:f3:xx:xx:xx (-56)
Location: {'latitude': None, 'longitude': None}
== Bulb McBulby - L530 ==
Host: 192.0.2.123
Port: 80
Device state: False
Time: 2024-06-22 15:42:15+02:00 (tz: {'timezone': 'CEST'}
Hardware: 3.0
Software: 1.1.6 Build 240130 Rel.173828
MAC (rssi): 5C:E9:31:aa:bb:cc (-50)
== Primary features ==
State (state): False
Brightness (brightness): 11 (range: 0-100)
Color temperature (color_temperature): 0 (range: 2500-6500)
Light effect (light_effect): *Off* Party Relax
== Device specific information ==
Brightness: 16
Is dimmable: True
Color temperature: 2500
Valid temperature range: ColorTempRange(min=2500, max=9000)
HSV: HSV(hue=0, saturation=0, value=16)
Presets:
index=0 brightness=50 hue=0 saturation=0 color_temp=2500 custom=None id=None mode=None
index=1 brightness=100 hue=299 saturation=95 color_temp=0 custom=None id=None mode=None
index=2 brightness=100 hue=120 saturation=75 color_temp=0 custom=None id=None mode=None
index=3 brightness=100 hue=240 saturation=75 color_temp=0 custom=None id=None mode=None
== Information ==
Signal Level (signal_level): 2
Overheated (overheated): False
Cloud connection (cloud_connection): False
Update available (update_available): None
Device time (device_time): 2024-06-22 15:42:15+02:00
== Current State ==
<EmeterStatus power=2.4 voltage=None current=None total=None>
== Configuration ==
HSV (hsv): HSV(hue=35, saturation=70, value=11)
Auto update enabled (auto_update_enabled): False
Light preset (light_preset): *Not set* Light preset 1 Light preset 2 Light preset 3 Light preset 4 Light preset 5 Light preset 6 Light preset 7
Smooth transition on (smooth_transition_on): 2 (range: 0-60)
Smooth transition off (smooth_transition_off): 20 (range: 0-60)
== Modules ==
+ <Module Schedule (smartlife.iot.common.schedule) for 192.168.xx.xx>
+ <Module Usage (smartlife.iot.common.schedule) for 192.168.xx.xx>
+ <Module Antitheft (smartlife.iot.common.anti_theft) for 192.168.xx.xx>
+ <Module Time (smartlife.iot.common.timesetting) for 192.168.xx.xx>
+ <Module Emeter (smartlife.iot.common.emeter) for 192.168.xx.xx>
- <Module Countdown (countdown) for 192.168.xx.xx>
+ <Module Cloud (smartlife.iot.common.cloud) for 192.168.xx.xx>
== Debug ==
Device ID (device_id): soneuniqueidentifier
RSSI (rssi): -50 dBm
SSID (ssid): HomeNet
Current firmware version (current_firmware_version): 1.1.6 Build 240130 Rel.173828
Available firmware version (available_firmware_version): None
```
If your device requires authentication to control it,
you need to pass the credentials using `--username` and `--password` options.
## Basic functionalities
## Command line usage
All devices support a variety of common commands, including:
All devices support a variety of common commands (like `on`, `off`, and `state`).
The syntax to control device is `kasa --host <host> <command>`:
* `state` which returns state information
* `on` and `off` for turning the device on or off
* `emeter` (where applicable) to return energy consumption information
* `sysinfo` to return raw system information
```
$ kasa --host 192.0.2.123 on
```
The syntax to control device is `kasa --host <ip address> <command>`.
Use `kasa --help` ([or consult the documentation](https://python-kasa.readthedocs.io/en/latest/cli.html#kasa-help)) to get a list of all available commands and options.
Some examples of available options include JSON output (`--json`), defining timeouts (`--timeout` and `--discovery-timeout`).
Some examples of available options include JSON output (`--json`), more verbose output (`--verbose`), and defining timeouts (`--timeout` and `--discovery-timeout`).
Refer [the documentation](https://python-kasa.readthedocs.io/en/latest/cli.html) for more details.
Each individual command may also have additional options, which are shown when called with the `--help` option.
For example, `--transition` on bulbs requests a smooth state change, while `--name` and `--index` are used on power strips to select the socket to act on:
> [!NOTE]
> Each individual command may also have additional options, which are shown when called with the `--help` option.
### Feature interface
All devices are also controllable through a generic feature-based interface.
The available features differ from device to device and are accessible using `kasa feature` command:
```
$ kasa on --help
$ kasa --host 192.0.2.123 feature
== Primary features ==
State (state): False
Brightness (brightness): 11 (range: 0-100)
Color temperature (color_temperature): 0 (range: 2500-6500)
Light effect (light_effect): *Off* Party Relax
Usage: kasa on [OPTIONS]
== Information ==
Signal Level (signal_level): 2
Overheated (overheated): False
Cloud connection (cloud_connection): False
Update available (update_available): None
Device time (device_time): 2024-06-22 15:39:44+02:00
Turn the device on.
== Configuration ==
HSV (hsv): HSV(hue=35, saturation=70, value=11)
Auto update enabled (auto_update_enabled): False
Light preset (light_preset): *Not set* Light preset 1 Light preset 2 Light preset 3 Light preset 4 Light preset 5 Light preset 6 Light preset 7
Smooth transition on (smooth_transition_on): 2 (range: 0-60)
Smooth transition off (smooth_transition_off): 20 (range: 0-60)
Options:
--index INTEGER
--name TEXT
--transition INTEGER
--help Show this message and exit.
== Debug ==
Device ID (device_id): soneuniqueidentifier
RSSI (rssi): -50 dBm
SSID (ssid): HomeNet
Current firmware version (current_firmware_version): 1.1.6 Build 240130 Rel.173828
Available firmware version (available_firmware_version): None
```
### Bulbs
Common commands for bulbs and light strips include:
* `brightness` to control the brightness
* `hsv` to control the colors
* `temperature` to control the color temperatures
When executed without parameters, these commands will report the current state.
Some devices support `--transition` option to perform a smooth state change.
For example, the following turns the light to 30% brightness over a period of five seconds:
Some features present configuration that can be changed:
```
$ kasa --host <addr> brightness --transition 5000 30
kasa --host 192.0.2.123 feature color_temperature 2500
Changing color_temperature from 0 to 2500
New state: 2500
```
See `--help` for additional options and [the documentation](https://python-kasa.readthedocs.io/en/latest/smartbulb.html) for more details about supported features and limitations.
### Power strips
Each individual socket can be controlled separately by passing `--index` or `--name` to the command.
If neither option is defined, the commands act on the whole power strip.
For example:
```
$ kasa --host <addr> off # turns off all sockets
$ kasa --host <addr> off --name 'Socket1' # turns off socket named 'Socket1'
```
See `--help` for additional options and [the documentation](https://python-kasa.readthedocs.io/en/latest/smartstrip.html) for more details about supported features and limitations.
> [!NOTE]
> When controlling hub-connected devices, you need to pass the device ID of the connected device as an option: `kasa --host 192.0.2.200 feature --child someuniqueidentifier target_temperature 21`
## Energy meter
Running `kasa emeter` command will return the current consumption.
Possible options include `--year` and `--month` for retrieving historical state,
and reseting the counters can be done with `--erase`.
## Library usage
```
$ kasa emeter
== Emeter ==
Current state: {'total': 133.105, 'power': 108.223577, 'current': 0.54463, 'voltage': 225.296283}
import asyncio
from kasa import Discover
async def main():
dev = await Discover.discover_single("192.0.2.123", username="un@example.com", password="pw")
await dev.turn_on()
await dev.update()
if __name__ == "__main__":
asyncio.run(main())
```
# Library usage
If you want to use this library in your own project, a good starting point is [the tutorial in the documentation](https://python-kasa.readthedocs.io/en/latest/tutorial.html).
If you want to use this library in your own project, a good starting point is to check [the documentation on discovering devices](https://python-kasa.readthedocs.io/en/latest/discover.html).
You can find several code examples in the API documentation of each of the implementation base classes, check out the [documentation for the base class shared by all supported devices](https://python-kasa.readthedocs.io/en/latest/smartdevice.html).
You can find several code examples in the API documentation [How to guides](https://python-kasa.readthedocs.io/en/latest/guides.html).
[The library design and module structure is described in a separate page](https://python-kasa.readthedocs.io/en/latest/design.html).
The device type specific documentation can be found in their separate pages:
* [Plugs](https://python-kasa.readthedocs.io/en/latest/smartplug.html)
* [Bulbs](https://python-kasa.readthedocs.io/en/latest/smartbulb.html)
* [Dimmers](https://python-kasa.readthedocs.io/en/latest/smartdimmer.html)
* [Power strips](https://python-kasa.readthedocs.io/en/latest/smartstrip.html)
* [Light strips](https://python-kasa.readthedocs.io/en/latest/smartlightstrip.html)
Information about the library design and the way the devices work can be found in the [topics section](https://python-kasa.readthedocs.io/en/latest/topics.html).
## Contributing
Contributions are very welcome! To simplify the process, we are leveraging automated checks and tests for contributions.
### Setting up development environment
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.
### Code-style checks
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.
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.
### Running tests
You can run tests on the library by executing `pytest` in the source directory.
This will run the tests against contributed example responses, but you can also execute the tests against a real device:
```
$ pytest --ip <address>
```
Note that this will perform state changes on the device.
### Analyzing network captures
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.
Note, that this works currently only on kasa-branded devices which use port 9999 for communications.
Contributions are very welcome! The easiest way to contribute is by [creating a fixture file](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files) for the automated test suite if your device hardware and firmware version is not currently listed as supported.
Please refer to [our contributing guidelines](https://python-kasa.readthedocs.io/en/latest/contribute.html).
## Supported devices
In principle, most kasa-branded devices that are locally controllable using the official Kasa mobile app work with this library.
The following devices have been tested and confirmed as working. If your device is unlisted but working, please consider [contributing a fixture file](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files).
The following lists the devices that have been manually verified to work.
**If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `devtools/dump_devinfo.py` to generate one).**
> [!NOTE]
> The hub attached Tapo buttons S200B and S200D do not currently support alerting when the button is pressed.
### Plugs
> [!NOTE]
> Some firmware versions of Tapo Cameras will not authenticate unless you enable "Tapo Lab" > "Third-Party Compatibility" in the native Tapo app.
> Alternatively, you can factory reset and then prevent the device from accessing the internet.
* HS100
* HS103
* HS105
* HS107
* HS110
* KP100
* KP105
* KP115
* KP125
* KP125M [See note below](#tapo-and-newer-kasa-branded-devices)
* KP401
* EP10
* EP25 [See note below](#tapo-and-newer-kasa-branded-devices)
<!--Do not edit text inside the SUPPORTED section below -->
<!--SUPPORTED_START-->
### Supported Kasa devices
### Power Strips
- **Plugs**: EP10, EP25[^1], HS100[^2], HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M[^1], KP401
- **Power Strips**: EP40, EP40M[^1], HS107, HS300, KP200, KP303, KP400
- **Wall Switches**: ES20M, HS200[^2], HS210, HS220[^2], KP405, KS200, KS200M, KS205[^1], KS220, KS220M, KS225[^1], KS230, KS240[^1]
- **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110
- **Light Strips**: KL400L5, KL420L5, KL430
- **Hubs**: KH100[^1]
- **Hub-Connected Devices[^3]**: KE100[^1]
* EP40
* HS300
* KP303
* KP200 (in wall)
* KP400
* KP405 (dimmer)
### Supported Tapo[^1] devices
### Wall switches
- **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15
- **Power Strips**: P300, P304M, TP25
- **Wall Switches**: S500D, S505, S505D
- **Bulbs**: L510B, L510E, L530E, L630
- **Light Strips**: L900-10, L900-5, L920-5, L930-5
- **Cameras**: C100, C210, C325WB, C520WS, TC65, TC70
- **Hubs**: H100, H200
- **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315
* ES20M
* HS200
* HS210
* HS220
* KS200M (partial support, no motion, no daylight detection)
* KS220M (partial support, no motion, no daylight detection)
* KS230
<!--SUPPORTED_END-->
[^1]: Model requires authentication
[^2]: Newer versions require authentication
[^3]: Devices may work across TAPO/KASA branded hubs
### Bulbs
* LB100
* LB110
* LB120
* LB130
* LB230
* KL50
* KL60
* KL110
* KL120
* KL125
* KL130
* KL135
### Light strips
* KL400L5
* KL420L5
* KL430
### Tapo and newer Kasa branded devices
The library has recently added a limited supported for devices that carry Tapo branding.
At the moment, the following devices have been confirmed to work:
* Tapo P110 (plug)
* Tapo L530E (bulb)
* Tapo L900-5 (led strip)
* Tapo L900-10 (led strip)
* Kasa KS205 (Wifi/Matter Wall Switch)
* Kasa KS225 (Wifi/Matter Wall Dimmer Switch)
Some newer hardware versions of Kasa branded devices are now using the same protocol as
Tapo branded devices. Support for these devices is currently limited as per TAPO branded
devices:
* Kasa EP25 (plug) hw_version 2.6
* Kasa KP125M (plug)
**If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `devtools/dump_devinfo.py` to generate one).**
See [supported devices in our documentation](SUPPORTED.md) for more detailed information about tested hardware and software versions.
## Resources
### Developer Resources
* [softScheck's github contains lot of information and wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector)
* [softScheck's github contains lot of information and wireshark dissector](https://github.com/softScheck/tplink-smartplug)
* [TP-Link Smart Home Device Simulator](https://github.com/plasticrake/tplink-smarthome-simulator)
* [Unofficial API documentation](https://github.com/plasticrake/tplink-smarthome-api)
* [Another unofficial API documentation](https://github.com/whitslack/kasa)
@@ -310,17 +227,14 @@ devices:
* [Home Assistant](https://www.home-assistant.io/integrations/tplink/)
* [MQTT access to TP-Link devices, using python-kasa](https://github.com/flavio-fernandes/mqtt2kasa)
* [Homebridge Kasa Python Plug-In](https://github.com/ZeliardM/homebridge-kasa-python)
### TP-Link Tapo support
This library has recently added a limited supported for devices that carry Tapo branding.
That support is currently limited to the cli. The package `kasa.tapo` is in flux and if you
use it directly you should expect it could break in future releases until this statement is removed.
Other TAPO libraries are:
### Other related projects
* [PyTapo - Python library for communication with Tapo Cameras](https://github.com/JurajNyiri/pytapo)
* [Tapo P100 (Tapo P105/P100 plugs, Tapo L510E bulbs)](https://github.com/fishbigger/TapoP100)
* [Home Assistant integration](https://github.com/JurajNyiri/HomeAssistant-Tapo-Control)
* [Tapo P100 (Tapo plugs, Tapo bulbs)](https://github.com/fishbigger/TapoP100)
* [Home Assistant integration](https://github.com/fishbigger/HomeAssistant-Tapo-P100-Control)
* [plugp100, another tapo library](https://github.com/petretiandrea/plugp100)
* [Home Assistant integration](https://github.com/petretiandrea/home-assistant-tapo-p100)
* [rust and python implementation for tapo devices](https://github.com/mihai-dinculescu/tapo/)

View File

@@ -1,55 +1,313 @@
1. Set release information
# Releasing
## Requirements
* [github client](https://github.com/cli/cli#installation)
* [gitchub_changelog_generator](https://github.com/github-changelog-generator)
* [github access token](https://github.com/github-changelog-generator/github-changelog-generator#github-token)
## Export changelog token
```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.dev4
```
2. Update the version number
```bash
poetry version $NEW_RELEASE
```
3. Write a short and understandable summary for the release.
* Create a new issue and label it with release-summary
* Create $NEW_RELEASE milestone in github, and assign the issue to that
* Close the issue
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
```
4. Commit the changed files
## Set release information
0.3.5 should always be the previous release as it's the last pyhs100 release in HISTORY.md which is the changelog prior to github release notes.
```bash
git commit -av
export NEW_RELEASE=x.x.x.devx
```
5. Create a PR for the release.
## Normal releases from master
6. Get it merged, fetch the upstream master
### Create a branch for the release
```bash
git checkout master
git fetch upstream
git fetch upstream master
git rebase upstream/master
git checkout -b release/$NEW_RELEASE
```
### Update the version number
```bash
sed -i "0,/version = /{s/version = .*/version = \"${NEW_RELEASE}\"/}" pyproject.toml
```
### Update dependencies
```bash
uv sync --all-extras
uv lock --upgrade
uv sync --all-extras
```
### Run pre-commit and tests
```bash
uv run pre-commit run --all-files
uv run pytest -n auto
```
### Create release summary (skip for dev releases)
Write a short and understandable summary for the release. Can include images.
#### Create $NEW_RELEASE milestone in github
If not already created
#### Create new issue linked to the milestone
```bash
gh issue create --label "release-summary" --milestone $NEW_RELEASE --title "$NEW_RELEASE Release Summary" --body "**Release summary:**"
```
You can exclude the --body option to get an interactive editor or go into the issue on github and edit there.
#### Close the issue
Either via github or:
```bash
gh issue close ISSUE_NUMBER
```
### Generate changelog
Configuration settings are in `.github_changelog_generator`
#### For pre-release
EXCLUDE_TAGS will exclude all dev tags except for the current release dev tags.
Regex should be something like this `^((?!0\.7\.0)(.*dev\d))+`. The first match group negative matches on the current release and the second matches on releases ending with dev.
```bash
EXCLUDE_TAGS=${NEW_RELEASE%.dev*}; EXCLUDE_TAGS=${EXCLUDE_TAGS//"."/"\."}; EXCLUDE_TAGS="^((?!"$EXCLUDE_TAGS")(.*dev\d))+"
echo "$EXCLUDE_TAGS"
github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex "$EXCLUDE_TAGS"
```
#### For production
```bash
github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex 'dev\d$'
```
You can ignore warnings about missing PR commits like below as these relate to PRs to branches other than master:
```
Warning: PR 908 merge commit was not found in the release branch or tagged git history and no rebased SHA comment was found
```
### Export new release notes to variable
```bash
export RELEASE_NOTES=$(grep -Poz '(?<=\# Changelog\n\n)(.|\n)+?(?=\#\#)' CHANGELOG.md | tr '\0' '\n' )
echo "$RELEASE_NOTES" # Check the output and copy paste if neccessary
```
### Commit and push the changed files
```bash
git commit --all --verbose -m "Prepare $NEW_RELEASE"
git push upstream release/$NEW_RELEASE -u
```
### Create a PR for the release, merge it, and re-fetch the master
#### Create the PR
```
gh pr create --title "Prepare $NEW_RELEASE" --body "$RELEASE_NOTES" --label release-prep --base master
```
#### Merge the PR once the CI passes
Create a squash commit and add the markdown from the PR description to the commit description.
```bash
gh pr merge --squash --body "$RELEASE_NOTES"
```
### Rebase local master
```bash
git checkout master
git fetch upstream master
git rebase upstream/master
```
7. Tag the release (add short changelog as a tag commit message), push the tag to git
### Create a release tag
Note, add changelog release notes as the tag commit message so `gh release create --notes-from-tag` can be used to create a release draft.
```bash
git tag -a $NEW_RELEASE
git tag --annotate $NEW_RELEASE -m "$RELEASE_NOTES"
git push upstream $NEW_RELEASE
```
All tags on master branch will trigger a new release on pypi.
### Create release
8. Click the "Draft a new release" button on github, select the new tag and copy & paste the changelog into the description.
#### Pre-releases
```bash
gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=false --prerelease
```
#### Production release
```bash
gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=true
```
### Manually publish the release
Go to the linked URL, verify the contents, and click "release" button to trigger the release CI.
## Patch releases
This requires git commit signing to be enabled.
https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification
### Create release branch
#### For the first patch release since a new release only
```bash
export NEW_RELEASE=x.x.x.x
export CURRENT_RELEASE=x.x.x
```
```bash
git fetch upstream $CURRENT_RELEASE
git checkout patch
git fetch upstream patch
git rebase upstream/patch
git fetch upstream $CURRENT_RELEASE
git merge $CURRENT_RELEASE --ff-only
git push upstream patch -u
git checkout -b release/$NEW_RELEASE
```
#### For subsequent patch releases
```bash
export NEW_RELEASE=x.x.x.x
```
```bash
git checkout patch
git fetch upstream patch
git rebase upstream/patch
git checkout -b release/$NEW_RELEASE
```
### Cherry pick required commits
```bash
git cherry-pick commitSHA1 -S
git cherry-pick commitSHA2 -S
```
### Update the version number
```bash
sed -i "0,/version = /{s/version = .*/version = \"${NEW_RELEASE}\"/}" pyproject.toml
```
### Manually edit the changelog
github_changlog generator_does not work with patch releases so manually add the section for the new release to CHANGELOG.md.
### Export new release notes to variable
```bash
export RELEASE_NOTES=$(grep -Poz '(?<=\# Changelog\n\n)(.|\n)+?(?=\#\#)' CHANGELOG.md | tr '\0' '\n' )
echo "$RELEASE_NOTES" # Check the output and copy paste if neccessary
```
### Commit and push the changed files
```bash
git commit --all --verbose -m "Prepare $NEW_RELEASE" -S
git push upstream release/$NEW_RELEASE -u
```
### Create a PR for the release, merge it, and re-fetch patch
#### Create the PR
```
gh pr create --title "$NEW_RELEASE" --body "$RELEASE_NOTES" --label release-prep --base patch
```
#### Merge the PR once the CI passes
Create a **merge** commit and add the markdown from the PR description to the commit description.
```bash
gh pr merge --merge --body "$RELEASE_NOTES"
```
### Rebase local patch
```bash
git checkout patch
git fetch upstream patch
git rebase upstream/patch
```
### Create a release tag
```bash
git tag -s --annotate $NEW_RELEASE -m "$RELEASE_NOTES"
git push upstream $NEW_RELEASE
```
### Create release
```bash
gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=true
```
Then go into github, review and release
### Merge patch back to master
```bash
git checkout master
git fetch upstream master
git rebase upstream/master
git checkout -b janitor/merge_patch
git fetch upstream patch
git merge upstream/patch --no-commit
# If there are any merge conflicts run the following command which will simply make master win
# Do not run it if there are no conflicts as it will end up checking out upstream/master
git diff --name-only --diff-filter=U | xargs git checkout upstream/master
# Check the diff is as expected
git diff --staged
# The only diff should be the version in pyproject.toml and uv.lock, and CHANGELOG.md
# unless a change made on patch that was not part of a cherry-pick commit
# If there are any other unexpected diffs `git checkout upstream/master [thefilename]`
git commit -m "Merge patch into local master" -S
git push upstream janitor/merge_patch -u
gh pr create --title "Merge patch into master" --body '' --label release-prep --base master
```
#### Temporarily allow merge commits to master
1. Open [repository settings](https://github.com/python-kasa/python-kasa/settings)
2. From the left select `Rules` > `Rulesets`
3. Open `master` ruleset, under `Bypass list` select `+ Add bypass`
4. Check `Repository admin` > `Add selected`, select `Save changes`
#### Merge commit the PR
```bash
gh pr merge --merge --body ""
```
#### Revert allow merge commits
1. Under `Bypass list` select `...` next to `Repository admins`
2. `Delete bypass`, select `Save changes`

310
SUPPORTED.md Normal file
View File

@@ -0,0 +1,310 @@
# Supported devices
The following devices have been tested and confirmed as working. If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `python -m devtools.dump_devinfo` to generate one).
> [!NOTE]
> The hub attached Tapo buttons S200B and S200D do not currently support alerting when the button is pressed.
> [!NOTE]
> Some firmware versions of Tapo Cameras will not authenticate unless you enable "Tapo Lab" > "Third-Party Compatibility" in the native Tapo app.
> Alternatively, you can factory reset and then prevent the device from accessing the internet.
<!--Do not edit text inside the SUPPORTED section below -->
<!--SUPPORTED_START-->
## Kasa devices
Some newer Kasa devices require authentication. These are marked with [^1] in the list below.<br>Hub-Connected Devices may work across TAPO/KASA branded hubs even if they don't work across the native apps.
### Plugs
- **EP10**
- Hardware: 1.0 (US) / Firmware: 1.0.2
- **EP25**
- Hardware: 2.6 (US) / Firmware: 1.0.1[^1]
- Hardware: 2.6 (US) / Firmware: 1.0.2[^1]
- **HS100**
- Hardware: 1.0 (UK) / Firmware: 1.2.6
- Hardware: 4.1 (UK) / Firmware: 1.1.0[^1]
- Hardware: 1.0 (US) / Firmware: 1.2.5
- Hardware: 2.0 (US) / Firmware: 1.5.6
- **HS103**
- Hardware: 1.0 (US) / Firmware: 1.5.7
- Hardware: 2.1 (US) / Firmware: 1.1.2
- Hardware: 2.1 (US) / Firmware: 1.1.4
- **HS105**
- Hardware: 1.0 (US) / Firmware: 1.5.6
- **HS110**
- Hardware: 1.0 (EU) / Firmware: 1.2.5
- Hardware: 4.0 (EU) / Firmware: 1.0.4
- Hardware: 1.0 (US) / Firmware: 1.2.6
- **KP100**
- Hardware: 3.0 (US) / Firmware: 1.0.1
- **KP105**
- Hardware: 1.0 (UK) / Firmware: 1.0.5
- Hardware: 1.0 (UK) / Firmware: 1.0.7
- **KP115**
- Hardware: 1.0 (EU) / Firmware: 1.0.16
- Hardware: 1.0 (US) / Firmware: 1.0.17
- Hardware: 1.0 (US) / Firmware: 1.0.21
- **KP125**
- Hardware: 1.0 (US) / Firmware: 1.0.6
- **KP125M**
- Hardware: 1.0 (US) / Firmware: 1.1.3[^1]
- Hardware: 1.0 (US) / Firmware: 1.2.3[^1]
- **KP401**
- Hardware: 1.0 (US) / Firmware: 1.0.0
### Power Strips
- **EP40**
- Hardware: 1.0 (US) / Firmware: 1.0.2
- **EP40M**
- Hardware: 1.0 (US) / Firmware: 1.1.0[^1]
- **HS107**
- Hardware: 1.0 (US) / Firmware: 1.0.8
- **HS300**
- Hardware: 1.0 (US) / Firmware: 1.0.10
- Hardware: 1.0 (US) / Firmware: 1.0.21
- Hardware: 2.0 (US) / Firmware: 1.0.12
- Hardware: 2.0 (US) / Firmware: 1.0.3
- **KP200**
- Hardware: 3.0 (US) / Firmware: 1.0.3
- **KP303**
- Hardware: 1.0 (UK) / Firmware: 1.0.3
- Hardware: 2.0 (US) / Firmware: 1.0.3
- Hardware: 2.0 (US) / Firmware: 1.0.9
- **KP400**
- Hardware: 1.0 (US) / Firmware: 1.0.10
- Hardware: 2.0 (US) / Firmware: 1.0.6
- Hardware: 3.0 (US) / Firmware: 1.0.3
- Hardware: 3.0 (US) / Firmware: 1.0.4
### Wall Switches
- **ES20M**
- Hardware: 1.0 (US) / Firmware: 1.0.11
- Hardware: 1.0 (US) / Firmware: 1.0.8
- **HS200**
- Hardware: 2.0 (US) / Firmware: 1.5.7
- Hardware: 3.0 (US) / Firmware: 1.1.5
- Hardware: 5.0 (US) / Firmware: 1.0.11
- Hardware: 5.0 (US) / Firmware: 1.0.2
- Hardware: 5.26 (US) / Firmware: 1.0.3[^1]
- **HS210**
- Hardware: 1.0 (US) / Firmware: 1.5.8
- Hardware: 2.0 (US) / Firmware: 1.1.5
- **HS220**
- Hardware: 1.0 (US) / Firmware: 1.5.7
- Hardware: 2.0 (US) / Firmware: 1.0.3
- Hardware: 3.26 (US) / Firmware: 1.0.1[^1]
- **KP405**
- Hardware: 1.0 (US) / Firmware: 1.0.5
- Hardware: 1.0 (US) / Firmware: 1.0.6
- **KS200**
- Hardware: 1.0 (US) / Firmware: 1.0.8
- **KS200M**
- Hardware: 1.0 (US) / Firmware: 1.0.10
- Hardware: 1.0 (US) / Firmware: 1.0.11
- Hardware: 1.0 (US) / Firmware: 1.0.12
- Hardware: 1.0 (US) / Firmware: 1.0.8
- **KS205**
- Hardware: 1.0 (US) / Firmware: 1.0.2[^1]
- Hardware: 1.0 (US) / Firmware: 1.1.0[^1]
- **KS220**
- Hardware: 1.0 (US) / Firmware: 1.0.13
- **KS220M**
- Hardware: 1.0 (US) / Firmware: 1.0.4
- **KS225**
- Hardware: 1.0 (US) / Firmware: 1.0.2[^1]
- Hardware: 1.0 (US) / Firmware: 1.1.0[^1]
- **KS230**
- Hardware: 1.0 (US) / Firmware: 1.0.14
- **KS240**
- Hardware: 1.0 (US) / Firmware: 1.0.4[^1]
- Hardware: 1.0 (US) / Firmware: 1.0.5[^1]
- Hardware: 1.0 (US) / Firmware: 1.0.7[^1]
### Bulbs
- **KL110**
- Hardware: 1.0 (US) / Firmware: 1.8.11
- **KL120**
- Hardware: 1.0 (US) / Firmware: 1.8.11
- Hardware: 1.0 (US) / Firmware: 1.8.6
- **KL125**
- Hardware: 1.20 (US) / Firmware: 1.0.5
- Hardware: 2.0 (US) / Firmware: 1.0.7
- Hardware: 4.0 (US) / Firmware: 1.0.5
- **KL130**
- Hardware: 1.0 (EU) / Firmware: 1.8.8
- Hardware: 1.0 (US) / Firmware: 1.8.11
- **KL135**
- Hardware: 1.0 (US) / Firmware: 1.0.15
- Hardware: 1.0 (US) / Firmware: 1.0.6
- **KL50**
- Hardware: 1.0 (US) / Firmware: 1.1.13
- **KL60**
- Hardware: 1.0 (UN) / Firmware: 1.1.4
- Hardware: 1.0 (US) / Firmware: 1.1.13
- **LB110**
- Hardware: 1.0 (US) / Firmware: 1.8.11
### Light Strips
- **KL400L5**
- Hardware: 1.0 (US) / Firmware: 1.0.5
- Hardware: 1.0 (US) / Firmware: 1.0.8
- **KL420L5**
- Hardware: 1.0 (US) / Firmware: 1.0.2
- **KL430**
- Hardware: 2.0 (UN) / Firmware: 1.0.8
- Hardware: 1.0 (US) / Firmware: 1.0.10
- Hardware: 2.0 (US) / Firmware: 1.0.11
- Hardware: 2.0 (US) / Firmware: 1.0.8
- Hardware: 2.0 (US) / Firmware: 1.0.9
### Hubs
- **KH100**
- Hardware: 1.0 (EU) / Firmware: 1.2.3[^1]
- Hardware: 1.0 (EU) / Firmware: 1.5.12[^1]
- Hardware: 1.0 (UK) / Firmware: 1.5.6[^1]
### Hub-Connected Devices
- **KE100**
- Hardware: 1.0 (EU) / Firmware: 2.4.0[^1]
- Hardware: 1.0 (EU) / Firmware: 2.8.0[^1]
- Hardware: 1.0 (UK) / Firmware: 2.8.0[^1]
## Tapo devices
All Tapo devices require authentication.<br>Hub-Connected Devices may work across TAPO/KASA branded hubs even if they don't work across the native apps.
### Plugs
- **P100**
- Hardware: 1.0.0 (US) / Firmware: 1.1.3
- Hardware: 1.0.0 (US) / Firmware: 1.3.7
- Hardware: 1.0.0 (US) / Firmware: 1.4.0
- **P110**
- Hardware: 1.0 (EU) / Firmware: 1.0.7
- Hardware: 1.0 (EU) / Firmware: 1.2.3
- Hardware: 1.0 (UK) / Firmware: 1.3.0
- **P110M**
- Hardware: 1.0 (AU) / Firmware: 1.2.3
- Hardware: 1.0 (EU) / Firmware: 1.2.3
- **P115**
- Hardware: 1.0 (EU) / Firmware: 1.2.3
- Hardware: 1.0 (US) / Firmware: 1.1.3
- **P125M**
- Hardware: 1.0 (US) / Firmware: 1.1.0
- **P135**
- Hardware: 1.0 (US) / Firmware: 1.0.5
- **TP15**
- Hardware: 1.0 (US) / Firmware: 1.0.3
### Power Strips
- **P300**
- Hardware: 1.0 (EU) / Firmware: 1.0.13
- Hardware: 1.0 (EU) / Firmware: 1.0.15
- Hardware: 1.0 (EU) / Firmware: 1.0.7
- **P304M**
- Hardware: 1.0 (UK) / Firmware: 1.0.3
- **TP25**
- Hardware: 1.0 (US) / Firmware: 1.0.2
### Wall Switches
- **S500D**
- Hardware: 1.0 (US) / Firmware: 1.0.5
- **S505**
- Hardware: 1.0 (US) / Firmware: 1.0.2
- **S505D**
- Hardware: 1.0 (US) / Firmware: 1.1.0
### Bulbs
- **L510B**
- Hardware: 3.0 (EU) / Firmware: 1.0.5
- **L510E**
- Hardware: 3.0 (US) / Firmware: 1.0.5
- Hardware: 3.0 (US) / Firmware: 1.1.2
- **L530E**
- Hardware: 3.0 (EU) / Firmware: 1.0.6
- Hardware: 3.0 (EU) / Firmware: 1.1.0
- Hardware: 3.0 (EU) / Firmware: 1.1.6
- Hardware: 2.0 (US) / Firmware: 1.1.0
- **L630**
- Hardware: 1.0 (EU) / Firmware: 1.1.2
### Light Strips
- **L900-10**
- Hardware: 1.0 (EU) / Firmware: 1.0.17
- Hardware: 1.0 (US) / Firmware: 1.0.11
- **L900-5**
- Hardware: 1.0 (EU) / Firmware: 1.0.17
- Hardware: 1.0 (EU) / Firmware: 1.1.0
- **L920-5**
- Hardware: 1.0 (EU) / Firmware: 1.0.7
- Hardware: 1.0 (EU) / Firmware: 1.1.3
- Hardware: 1.0 (US) / Firmware: 1.1.0
- Hardware: 1.0 (US) / Firmware: 1.1.3
- **L930-5**
- Hardware: 1.0 (US) / Firmware: 1.1.2
### Cameras
- **C100**
- Hardware: 4.0 / Firmware: 1.3.14
- **C210**
- Hardware: 2.0 (EU) / Firmware: 1.4.2
- Hardware: 2.0 (EU) / Firmware: 1.4.3
- **C325WB**
- Hardware: 1.0 (EU) / Firmware: 1.1.17
- **C520WS**
- Hardware: 1.0 (US) / Firmware: 1.2.8
- **TC65**
- Hardware: 1.0 / Firmware: 1.3.9
- **TC70**
- Hardware: 3.0 / Firmware: 1.3.11
### Hubs
- **H100**
- Hardware: 1.0 (EU) / Firmware: 1.2.3
- Hardware: 1.0 (EU) / Firmware: 1.5.10
- Hardware: 1.0 (EU) / Firmware: 1.5.5
- **H200**
- Hardware: 1.0 (EU) / Firmware: 1.3.2
- Hardware: 1.0 (US) / Firmware: 1.3.6
### Hub-Connected Devices
- **S200B**
- Hardware: 1.0 (EU) / Firmware: 1.11.0
- Hardware: 1.0 (US) / Firmware: 1.12.0
- **S200D**
- Hardware: 1.0 (EU) / Firmware: 1.11.0
- Hardware: 1.0 (EU) / Firmware: 1.12.0
- **T100**
- Hardware: 1.0 (EU) / Firmware: 1.12.0
- **T110**
- Hardware: 1.0 (EU) / Firmware: 1.8.0
- Hardware: 1.0 (EU) / Firmware: 1.9.0
- Hardware: 1.0 (US) / Firmware: 1.9.0
- **T300**
- Hardware: 1.0 (EU) / Firmware: 1.7.0
- **T310**
- Hardware: 1.0 (EU) / Firmware: 1.5.0
- Hardware: 1.0 (US) / Firmware: 1.5.0
- **T315**
- Hardware: 1.0 (EU) / Firmware: 1.7.0
- Hardware: 1.0 (US) / Firmware: 1.8.0
<!--SUPPORTED_END-->
[^1]: Model requires authentication

View File

@@ -3,18 +3,24 @@
This directory contains some simple scripts that can be useful for developers.
## dump_devinfo
* Queries the device and returns a fixture that can be added to the test suite
* Queries the device (if --host is given) or discover devices and creates fixture files that can be added to the test suite.
```shell
Usage: dump_devinfo.py [OPTIONS] HOST
Usage: python -m devtools.dump_devinfo [OPTIONS]
Generate devinfo file for given device.
Generate devinfo files for devices.
Use --host (for a single device) or --target (for a complete network).
Options:
--host TEXT Target host.
--target TEXT Target network for discovery.
--username TEXT Username/email address to authenticate to device.
--password TEXT Password to use to authenticate to device.
--basedir TEXT Base directory for the git repository
--autosave Save without prompting
-d, --debug
--help Show this message and exit.
--username For authenticating devices.
--password
--help Show this message and exit.
```
## create_module_fixtures
@@ -93,3 +99,30 @@ id
New parser, parsing 100000 messages took 0.6339647499989951 seconds
Old parser, parsing 100000 messages took 9.473990250000497 seconds
```
## parse_pcap_klap
* A tool to allow KLAP data to be exported, in JSON, from a PCAP file of encrypted requests.
* NOTE: must install pyshark (`pip install pyshark`).
* pyshark requires Wireshark or tshark to be installed on windows and tshark to be installed
on linux (`apt get tshark`)
```shell
Usage: parse_pcap_klap.py [OPTIONS]
Export KLAP data in JSON format from a PCAP file.
Options:
--host TEXT the IP of the smart device as it appears in the pcap
file. [required]
--username TEXT Username/email address to authenticate to device.
[required]
--password TEXT Password to use to authenticate to device.
[required]
--pcap-file-path TEXT The path to the pcap file to parse. [required]
-o, --output TEXT The name of the output file, relative to the current
directory.
--help Show this message and exit.
```

View File

@@ -5,8 +5,9 @@ import timeit
import orjson
from kasa_crypt import decrypt, encrypt
from utils.data import REQUEST, WIRE_RESPONSE
from utils.original import OriginalTPLinkSmartHomeProtocol
from devtools.bench.utils.data import REQUEST, WIRE_RESPONSE
from devtools.bench.utils.original import OriginalTPLinkSmartHomeProtocol
def original_request_response() -> None:

View File

@@ -1,6 +1,5 @@
"""Test data for benchmarks."""
import json
from .original import OriginalTPLinkSmartHomeProtocol

View File

@@ -1,6 +1,7 @@
"""Original implementation of the TP-Link Smart Home protocol."""
import struct
from typing import Generator
from collections.abc import Generator
class OriginalTPLinkSmartHomeProtocol:

View File

@@ -1,33 +0,0 @@
"""Script that checks if README.md is missing devices that have fixtures."""
from kasa.tests.conftest import (
ALL_DEVICES,
BULBS,
DIMMERS,
LIGHT_STRIPS,
PLUGS,
STRIPS,
)
with open("README.md") as f:
readme = f.read()
typemap = {
"light strips": LIGHT_STRIPS,
"bulbs": BULBS,
"plugs": PLUGS,
"strips": STRIPS,
"dimmers": DIMMERS,
}
def _get_device_type(dev, typemap):
for typename, devs in typemap.items():
if dev in devs:
return typename
else:
return "Unknown type"
for dev in ALL_DEVICES:
if dev not in readme:
print(f"{dev} not listed in {_get_device_type(dev, typemap)}")

View File

@@ -6,18 +6,20 @@ This script can be used to create fixture files for individual modules.
import asyncio
import json
from pathlib import Path
from typing import cast
import typer
from kasa import Discover, SmartDevice
from kasa import Discover
from kasa.iot import IotDevice
app = typer.Typer()
def create_fixtures(dev: SmartDevice, outputdir: Path):
def create_fixtures(dev: IotDevice, outputdir: Path):
"""Iterate over supported modules and create version-specific fixture files."""
for name, module in dev.modules.items():
module_dir = outputdir / name
module_dir = outputdir / str(name)
if not module_dir.exists():
module_dir.mkdir(exist_ok=True, parents=True)
@@ -43,13 +45,14 @@ def create_module_fixtures(
"""Create module fixtures for given host/network."""
devs = []
if host is not None:
dev: SmartDevice = asyncio.run(Discover.discover_single(host))
dev: IotDevice = cast(IotDevice, asyncio.run(Discover.discover_single(host)))
devs.append(dev)
else:
if network is None:
network = "255.255.255.255"
devs = asyncio.run(Discover.discover(target=network)).values()
for dev in devs:
dev = cast(IotDevice, dev)
asyncio.run(dev.update())
for dev in devs:

File diff suppressed because it is too large Load Diff

231
devtools/generate_supported.py Executable file
View File

@@ -0,0 +1,231 @@
#!/usr/bin/env python
"""Script that checks supported devices and updates README.md and SUPPORTED.md."""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from string import Template
from typing import Any, NamedTuple
from kasa.device_type import DeviceType
from kasa.iot import IotDevice
from kasa.smart import SmartDevice
from kasa.smartcam import SmartCamDevice
class SupportedVersion(NamedTuple):
"""Supported version."""
region: str | None
hw: str
fw: str
auth: bool
# The order of devices in this dict drives the display order
DEVICE_TYPE_TO_PRODUCT_GROUP = {
DeviceType.Plug: "Plugs",
DeviceType.Strip: "Power Strips",
DeviceType.StripSocket: "Power Strips",
DeviceType.Dimmer: "Wall Switches",
DeviceType.WallSwitch: "Wall Switches",
DeviceType.Fan: "Wall Switches",
DeviceType.Bulb: "Bulbs",
DeviceType.LightStrip: "Light Strips",
DeviceType.Camera: "Cameras",
DeviceType.Hub: "Hubs",
DeviceType.Sensor: "Hub-Connected Devices",
DeviceType.Thermostat: "Hub-Connected Devices",
}
SUPPORTED_FILENAME = "SUPPORTED.md"
README_FILENAME = "README.md"
IOT_FOLDER = "tests/fixtures/iot/"
SMART_FOLDER = "tests/fixtures/smart/"
SMART_CHILD_FOLDER = "tests/fixtures/smart/child"
SMARTCAM_FOLDER = "tests/fixtures/smartcam/"
def generate_supported(args):
"""Generate the SUPPORTED.md from the fixtures."""
print_diffs = "--print-diffs" in args
running_in_ci = "CI" in os.environ
print("Generating supported devices")
if running_in_ci:
print_diffs = True
print("Detected running in CI")
supported = {"kasa": {}, "tapo": {}}
_get_supported_devices(supported, IOT_FOLDER, IotDevice)
_get_supported_devices(supported, SMART_FOLDER, SmartDevice)
_get_supported_devices(supported, SMART_CHILD_FOLDER, SmartDevice)
_get_supported_devices(supported, SMARTCAM_FOLDER, SmartCamDevice)
readme_updated = _update_supported_file(
README_FILENAME, _supported_summary(supported), print_diffs
)
supported_updated = _update_supported_file(
SUPPORTED_FILENAME, _supported_detail(supported), print_diffs
)
if not readme_updated and not supported_updated:
print("Supported devices unchanged.")
def _update_supported_file(filename, supported_text, print_diffs) -> bool:
with open(filename) as f:
contents = f.readlines()
start_index = end_index = None
for index, line in enumerate(contents):
if line == "<!--SUPPORTED_START-->\n":
start_index = index + 1
if line == "<!--SUPPORTED_END-->\n":
end_index = index
current_text = "".join(contents[start_index:end_index])
if current_text != supported_text:
print(
f"{filename} has been modified with updated "
+ "supported devices, add file to commit."
)
if print_diffs:
print("##CURRENT##")
print(current_text)
print("##NEW##")
print(supported_text)
new_contents = contents[:start_index]
end_contents = contents[end_index:]
new_contents.append(supported_text)
new_contents.extend(end_contents)
with open(filename, "w") as f:
new_contents_text = "".join(new_contents)
f.write(new_contents_text)
return True
return False
def _supported_summary(supported):
return _supported_text(
supported,
"### Supported $brand$auth devices\n\n$types\n",
"- **$type_$type_asterix**: $models\n",
)
def _supported_detail(supported):
return _supported_text(
supported,
"## $brand devices\n\n$preamble\n\n$types\n",
"### $type_\n\n$models\n",
"- **$model**\n$versions",
" - Hardware: $hw$region / Firmware: $fw$auth_flag\n",
)
def _supported_text(
supported, brand_template, types_template, model_template="", version_template=""
):
brandt = Template(brand_template)
typest = Template(types_template)
modelt = Template(model_template)
versst = Template(version_template)
brands = ""
version: SupportedVersion
for brand, types in supported.items():
preamble_text = (
"Some newer Kasa devices require authentication. "
+ "These are marked with [^1] in the list below."
if brand == "kasa"
else "All Tapo devices require authentication."
)
preamble_text += (
"<br>Hub-Connected Devices may work across TAPO/KASA branded "
+ "hubs even if they don't work across the native apps."
)
brand_text = brand.capitalize()
brand_auth = r"[^1]" if brand == "tapo" else ""
types_text = ""
for supported_type, models in sorted(
# Sort by device type order in the enum
types.items(),
key=lambda st: list(DEVICE_TYPE_TO_PRODUCT_GROUP.values()).index(st[0]),
):
models_list = []
models_text = ""
for model, versions in sorted(models.items()):
auth_count = 0
versions_text = ""
for version in sorted(versions):
region_text = f" ({version.region})" if version.region else ""
auth_count += 1 if version.auth else 0
vauth_flag = r"[^1]" if version.auth and brand == "kasa" else ""
if version_template:
versions_text += versst.substitute(
hw=version.hw,
fw=version.fw,
region=region_text,
auth_flag=vauth_flag,
)
if brand == "kasa" and auth_count > 0:
auth_flag = r"[^1]" if auth_count == len(versions) else r"[^2]"
else:
auth_flag = ""
if model_template:
models_text += modelt.substitute(
model=model, versions=versions_text, auth_flag=auth_flag
)
else:
models_list.append(f"{model}{auth_flag}")
models_text = models_text if models_text else ", ".join(models_list)
type_asterix = r"[^3]" if supported_type == "Hub-Connected Devices" else ""
types_text += typest.substitute(
type_=supported_type, type_asterix=type_asterix, models=models_text
)
brands += brandt.substitute(
brand=brand_text, types=types_text, auth=brand_auth, preamble=preamble_text
)
return brands
def _get_supported_devices(
supported: dict[str, Any],
fixture_location: str,
device_cls: type[IotDevice | SmartDevice | SmartCamDevice],
):
for file in Path(fixture_location).glob("*.json"):
with file.open() as f:
fixture_data = json.load(f)
model_info = device_cls._get_device_info(
fixture_data, fixture_data.get("discovery_result", {}).get("result")
)
supported_type = DEVICE_TYPE_TO_PRODUCT_GROUP[model_info.device_type]
stype = supported[model_info.brand].setdefault(supported_type, {})
smodel = stype.setdefault(model_info.long_name, [])
smodel.append(
SupportedVersion(
region=model_info.region,
hw=model_info.hardware_version,
fw=model_info.firmware_version,
auth=model_info.requires_auth,
)
)
def main():
"""Entry point to module."""
generate_supported(sys.argv[1:])
if __name__ == "__main__":
generate_supported(sys.argv[1:])

View File

@@ -0,0 +1,66 @@
"""Module for smart camera requests."""
from __future__ import annotations
SMARTCAM_REQUESTS: list[dict] = [
{"getAlertTypeList": {"msg_alarm": {"name": "alert_type"}}},
{"getNightVisionCapability": {"image_capability": {"name": ["supplement_lamp"]}}},
{"getDeviceInfo": {"device_info": {"name": ["basic_info"]}}},
{"getDetectionConfig": {"motion_detection": {"name": ["motion_det"]}}},
{"getPersonDetectionConfig": {"people_detection": {"name": ["detection"]}}},
{"getVehicleDetectionConfig": {"vehicle_detection": {"name": ["detection"]}}},
{"getBCDConfig": {"sound_detection": {"name": ["bcd"]}}},
{"getPetDetectionConfig": {"pet_detection": {"name": ["detection"]}}},
{"getBarkDetectionConfig": {"bark_detection": {"name": ["detection"]}}},
{"getMeowDetectionConfig": {"meow_detection": {"name": ["detection"]}}},
{"getGlassDetectionConfig": {"glass_detection": {"name": ["detection"]}}},
{"getTamperDetectionConfig": {"tamper_detection": {"name": "tamper_det"}}},
{"getLensMaskConfig": {"lens_mask": {"name": ["lens_mask_info"]}}},
{"getLdc": {"image": {"name": ["switch", "common"]}}},
{"getLastAlarmInfo": {"system": {"name": ["last_alarm_info"]}}},
{"getLedStatus": {"led": {"name": ["config"]}}},
{"getTargetTrackConfig": {"target_track": {"name": ["target_track_info"]}}},
{"getPresetConfig": {"preset": {"name": ["preset"]}}},
{"getFirmwareUpdateStatus": {"cloud_config": {"name": "upgrade_status"}}},
{"getMediaEncrypt": {"cet": {"name": ["media_encrypt"]}}},
{"getConnectionType": {"network": {"get_connection_type": []}}},
{
"getAlertConfig": {
"msg_alarm": {
"name": ["chn1_msg_alarm_info", "capability"],
"table": ["usr_def_audio"],
}
}
},
{"getAlertPlan": {"msg_alarm_plan": {"name": "chn1_msg_alarm_plan"}}},
{"getSirenTypeList": {"siren": {}}},
{"getSirenConfig": {"siren": {}}},
{"getLightTypeList": {"msg_alarm": {}}},
{"getSirenStatus": {"siren": {}}},
{"getLightFrequencyInfo": {"image": {"name": "common"}}},
{"getRotationStatus": {"image": {"name": ["switch"]}}},
{"getNightVisionModeConfig": {"image": {"name": "switch"}}},
{"getWhitelampStatus": {"image": {"get_wtl_status": ["null"]}}},
{"getWhitelampConfig": {"image": {"name": "switch"}}},
{"getMsgPushConfig": {"msg_push": {"name": ["chn1_msg_push_info"]}}},
{"getSdCardStatus": {"harddisk_manage": {"table": ["hd_info"]}}},
{"getCircularRecordingConfig": {"harddisk_manage": {"name": "harddisk"}}},
{"getRecordPlan": {"record_plan": {"name": ["chn1_channel"]}}},
{"getAudioConfig": {"audio_config": {"name": ["speaker", "microphone"]}}},
{"getFirmwareAutoUpgradeConfig": {"auto_upgrade": {"name": ["common"]}}},
{"getVideoQualities": {"video": {"name": ["main"]}}},
{"getVideoCapability": {"video_capability": {"name": "main"}}},
{"getTimezone": {"system": {"name": "basic"}}},
{"getClockStatus": {"system": {"name": "clock_status"}}},
{"getAppComponentList": {"app_component": {"name": "app_component_list"}}},
{"getChildDeviceComponentList": {"childControl": {"start_index": 0}}},
# single request only methods
{"get": {"function": {"name": ["module_spec"]}}},
{"get": {"cet": {"name": ["vhttpd"]}}},
{"get": {"motor": {"name": ["capability"]}}},
{"get": {"audio_capability": {"name": ["device_speaker", "device_microphone"]}}},
{"get": {"audio_config": {"name": ["speaker", "microphone"]}}},
{"getMatterSetupInfo": {"matter": {}}},
{"getConnectStatus": {"onboarding": {"get_connect_status": {}}}},
{"scanApList": {"onboarding": {"scan": {}}}},
]

View File

@@ -25,18 +25,18 @@ heart_beat
"""
from __future__ import annotations
import logging
from dataclasses import asdict, dataclass
from typing import List, Optional, Union
_LOGGER = logging.getLogger(__name__)
logging.getLogger("httpx").propagate = False
class SmartRequest:
"""Class to represent a smart protocol request."""
def __init__(self, method_name: str, params: Optional["SmartRequestParams"] = None):
def __init__(self, method_name: str, params: SmartRequestParams | None = None):
self.method_name = method_name
if params:
self.params = params.to_dict()
@@ -76,6 +76,13 @@ class SmartRequest:
start_index: int = 0
@dataclass
class GetScheduleRulesParams(SmartRequestParams):
"""Get Rules Params."""
start_index: int = 0
schedule_mode: str = ""
@dataclass
class GetTriggerLogsParams(SmartRequestParams):
"""Trigger Logs params."""
@@ -87,7 +94,7 @@ class SmartRequest:
class LedStatusParams(SmartRequestParams):
"""LED Status params."""
led_rule: Optional[str] = None
led_rule: str | None = None
@staticmethod
def from_bool(state: bool):
@@ -99,91 +106,107 @@ class SmartRequest:
class LightInfoParams(SmartRequestParams):
"""LightInfo params."""
brightness: Optional[int] = None
color_temp: Optional[int] = None
hue: Optional[int] = None
saturation: Optional[int] = None
brightness: int | None = None
color_temp: int | None = None
hue: int | None = None
saturation: int | None = None
@dataclass
class DynamicLightEffectParams(SmartRequestParams):
"""LightInfo params."""
enable: bool
id: Optional[str] = None
id: str | None = None
@staticmethod
def get_raw_request(
method: str, params: Optional[SmartRequestParams] = None
) -> "SmartRequest":
method: str, params: SmartRequestParams | None = None
) -> SmartRequest:
"""Send a raw request to the device."""
return SmartRequest(method, params)
@staticmethod
def component_nego() -> "SmartRequest":
def component_nego() -> SmartRequest:
"""Get quick setup component info."""
return SmartRequest("component_nego")
@staticmethod
def get_device_info() -> "SmartRequest":
def get_device_info() -> SmartRequest:
"""Get device info."""
return SmartRequest("get_device_info")
@staticmethod
def get_device_usage() -> "SmartRequest":
def get_device_usage() -> SmartRequest:
"""Get device usage."""
return SmartRequest("get_device_usage")
@staticmethod
def device_info_list() -> List["SmartRequest"]:
def device_info_list(ver_code) -> list[SmartRequest]:
"""Get device info list."""
if ver_code == 1:
return [SmartRequest.get_device_info()]
return [
SmartRequest.get_device_info(),
SmartRequest.get_device_usage(),
SmartRequest.get_auto_update_info(),
]
@staticmethod
def get_auto_update_info() -> "SmartRequest":
def get_auto_update_info() -> SmartRequest:
"""Get auto update info."""
return SmartRequest("get_auto_update_info")
@staticmethod
def firmware_info_list() -> List["SmartRequest"]:
def firmware_info_list() -> list[SmartRequest]:
"""Get info list."""
return [
SmartRequest.get_auto_update_info(),
SmartRequest.get_raw_request("get_fw_download_state"),
SmartRequest.get_raw_request("get_latest_fw"),
]
@staticmethod
def qs_component_nego() -> "SmartRequest":
def qs_component_nego() -> SmartRequest:
"""Get quick setup component info."""
return SmartRequest("qs_component_nego")
@staticmethod
def get_device_time() -> "SmartRequest":
def get_device_time() -> SmartRequest:
"""Get device time."""
return SmartRequest("get_device_time")
@staticmethod
def get_wireless_scan_info() -> "SmartRequest":
"""Get wireless scan info."""
return SmartRequest("get_wireless_scan_info")
def get_child_device_list() -> SmartRequest:
"""Get child device list."""
return SmartRequest("get_child_device_list")
@staticmethod
def get_schedule_rules(params: Optional[GetRulesParams] = None) -> "SmartRequest":
"""Get schedule rules."""
def get_child_device_component_list() -> SmartRequest:
"""Get child device component list."""
return SmartRequest("get_child_device_component_list")
@staticmethod
def get_wireless_scan_info(
params: GetRulesParams | None = None,
) -> SmartRequest:
"""Get wireless scan info."""
return SmartRequest(
"get_schedule_rules", params or SmartRequest.GetRulesParams()
"get_wireless_scan_info", params or SmartRequest.GetRulesParams()
)
@staticmethod
def get_next_event(params: Optional[GetRulesParams] = None) -> "SmartRequest":
def get_schedule_rules(params: GetRulesParams | None = None) -> SmartRequest:
"""Get schedule rules."""
return SmartRequest(
"get_schedule_rules", params or SmartRequest.GetScheduleRulesParams()
)
@staticmethod
def get_next_event(params: GetRulesParams | None = None) -> SmartRequest:
"""Get next scheduled event."""
return SmartRequest("get_next_event", params or SmartRequest.GetRulesParams())
@staticmethod
def schedule_info_list() -> List["SmartRequest"]:
def schedule_info_list() -> list[SmartRequest]:
"""Get schedule info list."""
return [
SmartRequest.get_schedule_rules(),
@@ -191,38 +214,38 @@ class SmartRequest:
]
@staticmethod
def get_countdown_rules(params: Optional[GetRulesParams] = None) -> "SmartRequest":
def get_countdown_rules(params: GetRulesParams | None = None) -> SmartRequest:
"""Get countdown rules."""
return SmartRequest(
"get_countdown_rules", params or SmartRequest.GetRulesParams()
)
@staticmethod
def get_antitheft_rules(params: Optional[GetRulesParams] = None) -> "SmartRequest":
def get_antitheft_rules(params: GetRulesParams | None = None) -> SmartRequest:
"""Get antitheft rules."""
return SmartRequest(
"get_antitheft_rules", params or SmartRequest.GetRulesParams()
)
@staticmethod
def get_led_info(params: Optional[LedStatusParams] = None) -> "SmartRequest":
def get_led_info(params: LedStatusParams | None = None) -> SmartRequest:
"""Get led info."""
return SmartRequest("get_led_info", params or SmartRequest.LedStatusParams())
@staticmethod
def get_auto_off_config(params: Optional[GetRulesParams] = None) -> "SmartRequest":
def get_auto_off_config(params: GetRulesParams | None = None) -> SmartRequest:
"""Get auto off config."""
return SmartRequest(
"get_auto_off_config", params or SmartRequest.GetRulesParams()
)
@staticmethod
def get_delay_action_info() -> "SmartRequest":
def get_delay_action_info() -> SmartRequest:
"""Get delay action info."""
return SmartRequest("get_delay_action_info")
@staticmethod
def auto_off_list() -> List["SmartRequest"]:
def auto_off_list() -> list[SmartRequest]:
"""Get energy usage."""
return [
SmartRequest.get_auto_off_config(),
@@ -230,25 +253,27 @@ class SmartRequest:
]
@staticmethod
def get_energy_usage() -> "SmartRequest":
def get_energy_usage() -> SmartRequest:
"""Get energy usage."""
return SmartRequest("get_energy_usage")
@staticmethod
def energy_monitoring_list() -> List["SmartRequest"]:
def energy_monitoring_list() -> list[SmartRequest]:
"""Get energy usage."""
return [
SmartRequest("get_energy_usage"),
SmartRequest("get_emeter_data"),
SmartRequest("get_emeter_vgain_igain"),
SmartRequest.get_raw_request("get_electricity_price_config"),
]
@staticmethod
def get_current_power() -> "SmartRequest":
def get_current_power() -> SmartRequest:
"""Get current power."""
return SmartRequest("get_current_power")
@staticmethod
def power_protection_list() -> List["SmartRequest"]:
def power_protection_list() -> list[SmartRequest]:
"""Get power protection info list."""
return [
SmartRequest.get_current_power(),
@@ -257,53 +282,66 @@ class SmartRequest:
]
@staticmethod
def get_preset_rules(params: Optional[GetRulesParams] = None) -> "SmartRequest":
def get_preset_rules(params: GetRulesParams | None = None) -> SmartRequest:
"""Get preset rules."""
return SmartRequest("get_preset_rules", params or SmartRequest.GetRulesParams())
@staticmethod
def get_auto_light_info() -> "SmartRequest":
def get_on_off_gradually_info(
params: SmartRequestParams | None = None,
) -> SmartRequest:
"""Get preset rules."""
return SmartRequest(
"get_on_off_gradually_info", params or SmartRequest.SmartRequestParams()
)
@staticmethod
def get_auto_light_info() -> SmartRequest:
"""Get auto light info."""
return SmartRequest("get_auto_light_info")
@staticmethod
def get_dynamic_light_effect_rules(
params: Optional[GetRulesParams] = None
) -> "SmartRequest":
params: GetRulesParams | None = None,
) -> SmartRequest:
"""Get dynamic light effect rules."""
return SmartRequest(
"get_dynamic_light_effect_rules", params or SmartRequest.GetRulesParams()
)
@staticmethod
def set_device_on(params: DeviceOnParams) -> "SmartRequest":
def set_device_on(params: DeviceOnParams) -> SmartRequest:
"""Set device on state."""
return SmartRequest("set_device_info", params)
@staticmethod
def set_light_info(params: LightInfoParams) -> "SmartRequest":
def set_light_info(params: LightInfoParams) -> SmartRequest:
"""Set color temperature."""
return SmartRequest("set_device_info", params)
@staticmethod
def set_dynamic_light_effect_rule_enable(
params: DynamicLightEffectParams
) -> "SmartRequest":
params: DynamicLightEffectParams,
) -> SmartRequest:
"""Enable dynamic light effect rule."""
return SmartRequest("set_dynamic_light_effect_rule_enable", params)
@staticmethod
def get_component_info_requests(component_nego_response) -> List["SmartRequest"]:
def get_component_info_requests(component_nego_response) -> list[SmartRequest]:
"""Get a list of requests based on the component info response."""
request_list = []
request_list: list[SmartRequest] = []
for component in component_nego_response["component_list"]:
if requests := COMPONENT_REQUESTS.get(component["id"]):
if (
requests := get_component_requests(
component["id"], int(component["ver_code"])
)
) is not None:
request_list.extend(requests)
return request_list
@staticmethod
def _create_request_dict(
smart_request: Union["SmartRequest", List["SmartRequest"]]
smart_request: SmartRequest | list[SmartRequest],
) -> dict:
"""Create request dict to be passed to SmartProtocol.query()."""
if isinstance(smart_request, list):
@@ -315,8 +353,17 @@ class SmartRequest:
return request
def get_component_requests(component_id, ver_code):
"""Get the requests supported by the component and version."""
if (cr := COMPONENT_REQUESTS.get(component_id)) is None:
return None
if callable(cr):
return SmartRequest._create_request_dict(cr(ver_code))
return SmartRequest._create_request_dict(cr)
COMPONENT_REQUESTS = {
"device": SmartRequest.device_info_list(),
"device": SmartRequest.device_info_list,
"firmware": SmartRequest.firmware_info_list(),
"quick_setup": [SmartRequest.qs_component_nego()],
"inherit": [SmartRequest.get_raw_request("get_inherit_info")],
@@ -325,33 +372,81 @@ COMPONENT_REQUESTS = {
"schedule": SmartRequest.schedule_info_list(),
"countdown": [SmartRequest.get_countdown_rules()],
"antitheft": [SmartRequest.get_antitheft_rules()],
"account": None,
"synchronize": None, # sync_env
"sunrise_sunset": None, # for schedules
"account": [],
"synchronize": [], # sync_env
"sunrise_sunset": [], # for schedules
"led": [SmartRequest.get_led_info()],
"cloud_connect": [SmartRequest.get_raw_request("get_connect_cloud_state")],
"iot_cloud": None,
"device_local_time": None,
"default_states": None, # in device_info
"iot_cloud": [],
"device_local_time": [],
"default_states": [], # in device_info
"auto_off": [SmartRequest.get_auto_off_config()],
"localSmart": None,
"localSmart": [],
"energy_monitoring": SmartRequest.energy_monitoring_list(),
"power_protection": SmartRequest.power_protection_list(),
"current_protection": None, # overcurrent in device_info
"matter": None,
"current_protection": [], # overcurrent in device_info
"matter": [SmartRequest.get_raw_request("get_matter_setup_info")],
"preset": [SmartRequest.get_preset_rules()],
"brightness": None, # in device_info
"color": None, # in device_info
"color_temperature": None, # in device_info
"brightness": [], # in device_info
"color": [], # in device_info
"color_temperature": [], # in device_info
"auto_light": [SmartRequest.get_auto_light_info()],
"light_effect": [SmartRequest.get_dynamic_light_effect_rules()],
"bulb_quick_control": None,
"on_off_gradually": [SmartRequest.get_raw_request("get_on_off_gradually_info")],
"light_strip": None,
"bulb_quick_control": [],
"on_off_gradually": [SmartRequest.get_on_off_gradually_info()],
"light_strip": [],
"light_strip_lighting_effect": [
SmartRequest.get_raw_request("get_lighting_effect")
],
"music_rhythm": None, # music_rhythm_enable in device_info
"music_rhythm": [], # music_rhythm_enable in device_info
"segment": [SmartRequest.get_raw_request("get_device_segment")],
"segment_effect": [SmartRequest.get_raw_request("get_segment_effect_rule")],
"device_load": [SmartRequest.get_raw_request("get_device_load_info")],
"child_quick_setup": [
SmartRequest.get_raw_request("get_support_child_device_category")
],
"alarm": [
SmartRequest.get_raw_request("get_support_alarm_type_list"),
SmartRequest.get_raw_request("get_alarm_configure"),
],
"alarm_logs": [SmartRequest.get_raw_request("get_alarm_triggers")],
"trigger_log": [
SmartRequest.get_raw_request(
"get_trigger_logs", SmartRequest.GetTriggerLogsParams()
)
],
"double_click": [SmartRequest.get_raw_request("get_double_click_info")],
"child_device": [
SmartRequest.get_raw_request("get_child_device_list"),
SmartRequest.get_raw_request("get_child_device_component_list"),
],
"control_child": [],
"homekit": [SmartRequest.get_raw_request("get_homekit_info")],
"dimmer_calibration": [],
"fan_control": [],
"overheat_protection": [],
# Vacuum components
"clean": [
SmartRequest.get_raw_request("getCleanRecords"),
SmartRequest.get_raw_request("getVacStatus"),
],
"battery": [SmartRequest.get_raw_request("getBatteryInfo")],
"consumables": [SmartRequest.get_raw_request("getConsumablesInfo")],
"direction_control": [],
"button_and_led": [],
"speaker": [
SmartRequest.get_raw_request("getSupportVoiceLanguage"),
SmartRequest.get_raw_request("getCurrentVoiceLanguage"),
],
"map": [
SmartRequest.get_raw_request("getMapInfo"),
SmartRequest.get_raw_request("getMapData"),
],
"auto_change_map": [SmartRequest.get_raw_request("getAutoChangeMap")],
"dust_bucket": [SmartRequest.get_raw_request("getAutoDustCollection")],
"mop": [SmartRequest.get_raw_request("getMopState")],
"do_not_disturb": [SmartRequest.get_raw_request("getDoNotDisturb")],
"charge_pose_clean": [],
"continue_breakpoint_sweep": [],
"goto_point": [],
}

View File

@@ -8,8 +8,8 @@ import click
import dpkt
from dpkt.ethernet import ETH_TYPE_IP, Ethernet
from kasa.cli import echo
from kasa.protocol import TPLinkSmartHomeProtocol
from kasa.cli.main import echo
from kasa.transports.xortransport import XorEncryption
def read_payloads_from_file(file):
@@ -34,7 +34,7 @@ def read_payloads_from_file(file):
data = transport.data
try:
decrypted = TPLinkSmartHomeProtocol.decrypt(data[4:])
decrypted = XorEncryption.decrypt(data[4:])
except Exception as ex:
echo(f"[red]Unable to decrypt the data, ignoring: {ex}[/red]")
continue
@@ -67,7 +67,7 @@ def parse_pcap(file):
for module, cmds in json_payload.items():
seen_items["modules"][module] += 1
if "err_code" in cmds:
echo("[red]Got error for module: %s[/red]" % cmds)
echo(f"[red]Got error for module: {cmds}[/red]")
continue
for cmd, response in cmds.items():

371
devtools/parse_pcap_klap.py Executable file
View File

@@ -0,0 +1,371 @@
#!/usr/bin/env python
"""
This code allow for the decryption of KlapV2 data from a pcap file.
It will output the decrypted data to a file.
This was designed and tested with a Tapo light strip setup using a cloud account.
"""
from __future__ import annotations
import asyncio
import codecs
import json
import re
from threading import Thread
import asyncclick as click
import pyshark
from cryptography.hazmat.primitives import padding
from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials
from kasa.deviceconfig import (
DeviceConfig,
DeviceConnectionParameters,
DeviceEncryptionType,
DeviceFamily,
)
from kasa.transports.klaptransport import KlapEncryptionSession, KlapTransportV2
def _get_seq_from_query(packet):
"""Return sequence number for the query."""
query = packet.http.get("request_uri_query")
if query is None:
raise Exception("No request_uri_query found")
# use regex to get: seq=(\d+)
seq = re.search(r"seq=(\d+)", query)
if seq is not None:
return int(seq.group(1))
raise Exception("Unable to find sequence number")
def _is_http_response_for_packet(response, packet):
"""Return True if the *response* contains a response for request in *packet*.
Different tshark versions use different field for the information.
"""
if not hasattr(response, "http"):
return False
if hasattr(response.http, "response_for_uri") and (
response.http.response_for_uri == packet.http.request_full_uri
):
return True
# tshark 4.4.0
return response.http.request_uri == packet.http.request_uri
class MyEncryptionSession(KlapEncryptionSession):
"""A custom KlapEncryptionSession class that allows for decryption."""
def decrypt(self, msg):
"""Decrypt the data."""
decryptor = self._cipher.decryptor()
dp = decryptor.update(msg[32:]) + decryptor.finalize()
unpadder = padding.PKCS7(128).unpadder()
plaintextbytes = unpadder.update(dp) + unpadder.finalize()
return plaintextbytes.decode("utf-8", "bad_chars_replacement")
class Operator:
"""A class that handles the data decryption, and the encryption session updating."""
def __init__(self, klap, creds):
self._local_seed: bytes | None = None
self._remote_seed: bytes | None = None
self._session: MyEncryptionSession | None = None
self._creds = creds
self._klap: KlapTransportV2 = klap
self._auth_hash = self._klap.generate_auth_hash(self._creds)
self._local_seed_auth_hash = None
self._remote_seed_auth_hash = None
self._seq = 0
def check_default_credentials(self):
"""Check whether default credentials were used.
Devices sometimes randomly accept the hardcoded default credentials
and the library handles that.
"""
for value in DEFAULT_CREDENTIALS.values():
default_credentials = get_default_credentials(value)
default_auth_hash = self._klap.generate_auth_hash(default_credentials)
default_credentials_seed_auth_hash = self._klap.handshake1_seed_auth_hash(
self._local_seed,
self._remote_seed,
default_auth_hash, # type: ignore
)
if self._remote_seed_auth_hash == default_credentials_seed_auth_hash:
return default_auth_hash
return None
def update_encryption_session(self):
"""Update the encryption session used for decrypting data.
It is called whenever the local_seed, remote_seed,
or remote_auth_hash is updated.
It checks if the seeds are set and, if they are, creates a new session.
Raises:
ValueError: If the auth hashes do not match.
"""
if self._local_seed is None or self._remote_seed is None:
self._session = None
else:
self._local_seed_auth_hash = self._klap.handshake1_seed_auth_hash(
self._local_seed, self._remote_seed, self._auth_hash
)
auth_hash = None
if self._remote_seed_auth_hash is not None:
if self._local_seed_auth_hash == self._remote_seed_auth_hash:
auth_hash = self._auth_hash
else:
auth_hash = self.check_default_credentials()
if not auth_hash:
raise ValueError(
"Local and remote auth hashes do not match. "
"This could mean an incorrect username and/or password."
)
self._session = MyEncryptionSession(
self._local_seed, self._remote_seed, auth_hash
)
self._session._seq = self._seq
self._session._generate_cipher()
@property
def seq(self) -> int:
"""Get the sequence number."""
return self._seq
@seq.setter
def seq(self, value: int):
if not isinstance(value, int):
raise ValueError("seq must be an integer")
self._seq = value
self.update_encryption_session()
@property
def local_seed(self) -> bytes | None:
"""Get the local seed."""
return self._local_seed
@local_seed.setter
def local_seed(self, value: bytes):
print("setting local_seed")
if not isinstance(value, bytes):
raise ValueError("local_seed must be bytes")
elif len(value) != 16:
raise ValueError("local_seed must be 16 bytes")
else:
self._local_seed = value
self._remote_seed_auth_hash = None
self._remote_seed = None
self.update_encryption_session()
@property
def remote_auth_hash(self) -> bytes | None:
"""Get the remote auth hash."""
return self._remote_seed_auth_hash
@remote_auth_hash.setter
def remote_auth_hash(self, value: bytes):
print("setting remote_auth_hash")
if not isinstance(value, bytes):
raise ValueError("remote_auth_hash must be bytes")
elif len(value) != 32:
raise ValueError("remote_auth_hash must be 32 bytes")
else:
self._remote_seed_auth_hash = value
self.update_encryption_session()
@property
def remote_seed(self) -> bytes | None:
"""Get the remote seed."""
return self._remote_seed
@remote_seed.setter
def remote_seed(self, value: bytes):
print("setting remote_seed")
if not isinstance(value, bytes):
raise ValueError("remote_seed must be bytes")
elif len(value) != 16:
raise ValueError("remote_seed must be 16 bytes")
else:
self._remote_seed = value
self.update_encryption_session()
# This function decrypts the data using the encryption session.
def decrypt(self, *args, **kwargs):
"""Decrypt the data using the encryption session."""
if self._session is None:
raise ValueError("No session available")
return self._session.decrypt(*args, **kwargs)
# This is a custom error handler that replaces bad characters with '*',
# in case something goes wrong in decryption.
# Without this, the decryption could yield an error.
def bad_chars_replacement(exception):
"""Replace bad characters with '*'."""
return ("*", exception.start + 1)
codecs.register_error("bad_chars_replacement", bad_chars_replacement)
def main(
loop: asyncio.AbstractEventLoop,
username,
password,
device_ip,
source_host,
pcap_file_path,
output_json_name=None,
):
"""Run the main function."""
asyncio.set_event_loop(loop)
capture = pyshark.FileCapture(pcap_file_path, display_filter="http", eventloop=loop)
# In an effort to keep this code tied into the original code
# (so that this can hopefully leverage any future codebase updates inheriently),
# some weird initialization is done here
creds = Credentials(username, password)
fake_connection = DeviceConnectionParameters(
DeviceFamily.SmartTapoBulb, DeviceEncryptionType.Klap
)
fake_device = DeviceConfig(
device_ip, connection_type=fake_connection, credentials=creds
)
operator = Operator(KlapTransportV2(config=fake_device), creds)
packets = []
# pyshark is a little weird in how it handles iteration,
# so this is a workaround to allow for (advanced) iteration over the packets.
while True:
try:
packet = capture.next()
packet_number = capture._current_packet
if packet.ip.src != source_host:
continue
# we only care about http packets
# this is redundant, as pyshark is set to only load http packets
if not hasattr(packet, "http"):
continue
uri = packet.http.get("request_uri_path", packet.http.get("request_uri"))
if uri is None:
continue
operator.seq = _get_seq_from_query(packet)
# Windows and linux file_data attribute returns different
# pretty format so get the raw field value.
data = packet.http.get_field_value("file_data", raw=True)
match uri:
case "/app/request":
if packet.ip.dst != device_ip:
continue
message = bytes.fromhex(data)
try:
plaintext = operator.decrypt(message)
payload = json.loads(plaintext)
print(json.dumps(payload, indent=2))
packets.append(payload)
except ValueError:
print("Insufficient data to decrypt thus far")
case "/app/handshake1":
if packet.ip.dst != device_ip:
continue
message = bytes.fromhex(data)
operator.local_seed = message
response = None
print(
f"got handshake1 in {packet_number}, "
f"looking for the response"
)
while (
True
): # we are going to now look for the response to this request
response = capture.next()
if _is_http_response_for_packet(response, packet):
print(f"found response in {packet_number}")
break
data = response.http.get_field_value("file_data", raw=True)
message = bytes.fromhex(data)
operator.remote_seed = message[0:16]
operator.remote_auth_hash = message[16:]
case "/app/handshake2":
continue # we don't care about this
case _:
continue
except StopIteration:
break
# save the final array to a file
if output_json_name is not None:
with open(output_json_name, "w") as f:
f.write(json.dumps(packets, indent=2))
f.write("\n" * 1)
f.close()
# Call close method which cleans up event loop
capture.close()
@click.command()
@click.option(
"--host",
required=True,
help="the IP of the smart device as it appears in the pcap file.",
)
@click.option(
"--source-host",
required=True,
help="the IP of the device communicating with the smart device.",
)
@click.option(
"--username",
required=True,
envvar="KASA_USERNAME",
help="Username/email address to authenticate to device.",
)
@click.option(
"--password",
required=True,
envvar="KASA_PASSWORD",
help="Password to use to authenticate to device.",
)
@click.option(
"--pcap-file-path",
required=True,
help="The path to the pcap file to parse.",
)
@click.option(
"-o",
"--output",
required=False,
help="The name of the output file, relative to the current directory.",
)
async def cli(username, password, host, source_host, pcap_file_path, output):
"""Export KLAP data in JSON format from a PCAP file."""
# pyshark does not work within a running event loop and we don't want to
# install click as well as asyncclick so run in a new thread.
loop = asyncio.new_event_loop()
thread = Thread(
target=main,
args=[loop, username, password, host, source_host, pcap_file_path, output],
daemon=True,
)
thread.start()
thread.join()
if __name__ == "__main__":
cli()

View File

@@ -1,4 +1,5 @@
"""Script for testing update performance on devices."""
import asyncio
import time

128
devtools/update_fixtures.py Normal file
View File

@@ -0,0 +1,128 @@
"""Module to mass update fixture files."""
import json
import logging
from collections.abc import Callable
from pathlib import Path
import asyncclick as click
from devtools.dump_devinfo import _wrap_redactors
from kasa.discover import NEW_DISCOVERY_REDACTORS, redact_data
from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS
from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS
FIXTURE_FOLDER = "tests/fixtures/"
_LOGGER = logging.getLogger(__name__)
def update_fixtures(update_func: Callable[[dict], bool], *, dry_run: bool) -> None:
"""Run the update function against the fixtures."""
for file in Path(FIXTURE_FOLDER).glob("**/*.json"):
with file.open("r") as f:
fixture_data = json.load(f)
if file.parent.name == "serialization":
continue
changed = update_func(fixture_data)
if changed:
click.echo(f"Will update {file.name}\n")
if changed and not dry_run:
with file.open("w") as f:
json.dump(fixture_data, f, sort_keys=True, indent=4)
f.write("\n")
def _discovery_result_update(info) -> bool:
"""Update discovery_result to be the raw result and error_code."""
if (disco_result := info.get("discovery_result")) and "result" not in disco_result:
info["discovery_result"] = {
"result": disco_result,
"error_code": 0,
}
return True
return False
def _child_device_id_update(info) -> bool:
"""Update child device ids to be the scrubbed ids from dump_devinfo."""
changed = False
if get_child_device_list := info.get("get_child_device_list"):
child_device_list = get_child_device_list["child_device_list"]
child_component_list = info["get_child_device_component_list"][
"child_component_list"
]
for index, child_device in enumerate(child_device_list):
child_component = child_component_list[index]
if "SCRUBBED" not in child_device["device_id"]:
dev_id = f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}"
click.echo(
f"child_device_id{index}: {child_device['device_id']} -> {dev_id}"
)
child_device["device_id"] = dev_id
child_component["device_id"] = dev_id
changed = True
if children := info.get("system", {}).get("get_sysinfo", {}).get("children"):
for index, child_device in enumerate(children):
if "SCRUBBED" not in child_device["id"]:
dev_id = f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}"
click.echo(f"child_device_id{index}: {child_device['id']} -> {dev_id}")
child_device["id"] = dev_id
changed = True
return changed
def _diff_data(fullkey, data1, data2, diffs):
if isinstance(data1, dict):
for k, v in data1.items():
_diff_data(fullkey + "/" + k, v, data2[k], diffs)
elif isinstance(data1, list):
for index, item in enumerate(data1):
_diff_data(fullkey + "/" + str(index), item, data2[index], diffs)
elif data1 != data2:
diffs[fullkey] = (data1, data2)
def _redactor_result_update(info) -> bool:
"""Update fixtures with the output using the common redactors."""
changed = False
redactors = IOT_REDACTORS if "system" in info else SMART_REDACTORS
for key, val in info.items():
if not isinstance(val, dict):
continue
if key == "discovery_result":
info[key] = redact_data(val, _wrap_redactors(NEW_DISCOVERY_REDACTORS))
else:
info[key] = redact_data(val, _wrap_redactors(redactors))
diffs: dict[str, tuple[str, str]] = {}
_diff_data(key, val, info[key], diffs)
if diffs:
for k, v in diffs.items():
click.echo(f"{k}: {v[0]} -> {v[1]}")
changed = True
return changed
@click.option(
"--dry-run/--no-dry-run",
default=False,
is_flag=True,
type=bool,
help="Perform a dry run without saving.",
)
@click.command()
async def cli(dry_run: bool) -> None:
"""Cli method fo rupdating fixtures."""
update_fixtures(_discovery_result_update, dry_run=dry_run)
update_fixtures(_child_device_id_update, dry_run=dry_run)
update_fixtures(_redactor_result_update, dry_run=dry_run)
if __name__ == "__main__":
cli()

3
docs/source/SUPPORTED.md Normal file
View File

@@ -0,0 +1,3 @@
```{include} ../../SUPPORTED.md
:relative-docs: doc/source
```

View File

@@ -51,6 +51,26 @@ You can provision your device without any extra apps by using the ``kasa wifi``
As with all other commands, you can also pass ``--help`` to both ``join`` and ``scan`` commands to see the available options.
.. note::
For devices requiring authentication, the device-stored credentials can be changed using
the ``update-credentials`` commands, for example, to match with other cloud-connected devices.
However, note that communications with devices provisioned using this method will stop working
when connected to the cloud.
.. note::
Some commands do not work if the device time is out-of-sync.
You can use ``kasa time sync`` command to set the device time from the system where the command is run.
.. warning::
At least some devices (e.g., Tapo lights L530 and L900) are known to have a watchdog that reboots them every 10 minutes if they are unable to connect to the cloud.
Although the communications are done locally, this will make these devices unavailable for a minute every time the device restarts.
This does not affect other devices to our current knowledge, but you have been warned.
``kasa --help``
***************

26
docs/source/codeinfo.md Normal file
View File

@@ -0,0 +1,26 @@
:::{note}
The library is fully async and methods that perform IO need to be run inside an async coroutine.
Code examples assume you are following them inside `asyncio REPL`:
```
$ python -m asyncio
```
Or the code is running inside an async function:
```py
import asyncio
from kasa import Discover
async def main():
dev = await Discover.discover_single("127.0.0.1",username="un@example.com",password="pw")
await dev.turn_on()
await dev.update()
if __name__ == "__main__":
asyncio.run(main())
```
**All of your code needs to run inside the same event loop so only call `asyncio.run` once.**
*The main entry point for the API is {meth}`~kasa.Discover.discover` and
{meth}`~kasa.Discover.discover_single` which return Device objects.
Most newer devices require your TP-Link cloud username and password, but this can be omitted for older devices.*
:::

View File

@@ -10,9 +10,10 @@
# 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('.'))
import os
import sys
sys.path.insert(0, os.path.abspath("..")) # Will find modules in the docs parent
# -- Project information -----------------------------------------------------
@@ -36,6 +37,10 @@ extensions = [
"myst_parser",
]
myst_enable_extensions = [
"colon_fence",
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]
@@ -61,6 +66,6 @@ todo_include_todos = True
myst_heading_anchors = 3
def setup(app):
def setup(app): # noqa: ANN201,ANN001
# add copybutton to hide the >>> prompts, see https://github.com/readthedocs/sphinx_rtd_theme/issues/167
app.add_js_file("copybutton.js")

86
docs/source/contribute.md Normal file
View File

@@ -0,0 +1,86 @@
# Contributing
You probably arrived to this page as you are interested in contributing to python-kasa in some form?
All types of contributions are very welcome, so thank you!
This page aims to help you to get started.
```{contents} Contents
:local:
```
## Setting up the development environment
To get started, simply clone this repository and initialize the development environment.
We are using [uv](https://github.com/astral-sh/uv) for dependency management, so after cloning the repository simply execute
`uv sync` which will install all necessary packages and create a virtual environment for you in `.venv`.
```
$ git clone https://github.com/python-kasa/python-kasa.git
$ cd python-kasa
$ uv sync --all-extras
```
## Code-style checks
We use several tools to automatically check all contributions as part of our CI pipeline.
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.
```{note}
You can also execute the pre-commit hooks on all files by executing `pre-commit run -a`
```
## Running tests
You can run tests on the library by executing `pytest` in the source directory:
```
$ uv run pytest kasa
```
This will run the tests against the contributed example responses.
```{note}
You can also execute the tests against a real device using `uv run pytest --ip=<address> --username=<username> --password=<password>`.
Note that this will perform state changes on the device.
```
## Analyzing network captures
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)
or the `parse_pcap.py` script contained inside the `devtools` directory.
Note, that this works currently only on kasa-branded devices which use port 9999 for communications.
## Contributing fixture files
One of the easiest ways to contribute is by creating a fixture file and uploading it for us.
These files will help us to improve the library and run tests against devices that we have no access to.
This library is tested against responses from real devices ("fixture files").
These files contain responses for selected, known device commands and are stored [in our test suite](https://github.com/python-kasa/python-kasa/tree/master/tests/fixtures).
You can generate these files by using the `dump_devinfo.py` script.
Note, that this script should be run inside the main source directory so that the generated files are stored in the correct directories.
The easiest way to do that is by doing:
```
$ git clone https://github.com/python-kasa/python-kasa.git
$ cd python-kasa
$ uv sync --all-extras
$ source .venv/bin/activate
$ python -m devtools.dump_devinfo --username <username> --password <password> --host 192.168.1.123
```
```{note}
You can also execute the script against a network by using `--target`: `python -m devtools.dump_devinfo --target 192.168.1.255`
```
The script will run queries against the device, and prompt at the end if you want to save the results.
If you choose to do so, it will save the fixture files directly in their correct place to make it easy to create a pull request.
```{note}
When adding new fixture files, you should run `pre-commit run -a` to re-generate the list of supported devices.
You may need to adjust `device_fixtures.py` to add a new model into the correct device categories. Verify that test pass by executing `uv run pytest kasa`.
```

67
docs/source/deprecated.md Normal file
View File

@@ -0,0 +1,67 @@
# 0.7 API changes
This page contains information about the major API changes in 0.7.
The previous API reference can be found below.
## Restructuring the library
This is the largest refactoring of the library and there are changes in all parts of the library.
Other than the three breaking changes below, all changes are backwards compatible, and you will get a deprecation warning with instructions to help porting your code over.
* The library has now been restructured into `iot` and `smart` packages to contain the respective protocol (command set) implementations. The old `Smart{Plug,Bulb,Lightstrip}` that do not require authentication are now accessible through `kasa.iot` package.
* Exception classes are renamed
* Using .connect() or discover() is the preferred way to construct device instances rather than initiating constructors on a device.
### Breaking changes
* `features()` now returns a dict of `(identifier, feature)` instead of barely used set of strings.
* The `supported_modules` attribute is removed from the device class.
* `state_information` returns information based on features. If you leveraged this property, you may need to adjust your keys.
## Module support for SMART devices
This release introduces modules to SMART devices (i.e., devices that require authentication, previously supported using the "tapo" package which has now been renamed to "smart") and uses the device-reported capabilities to initialize the modules supported by the device.
This allows us to support previously unknown devices for known and implemented features,
and makes it easy to add support for new features and device types in the future.
This inital release adds 26 modules to support a variety of features, including:
* Basic controls for various device (like color temperature, brightness, etc.)
* Light effects & presets
* Control LEDs
* Fan controls
* Thermostat controls
* Handling of firmware updates
* Some hub controls (like playing alarms, )
## Introspectable device features
The library now offers a generic way to access device features ("features"), making it possible to create interfaces without knowledge of the module/feature specific APIs.
We use this information to construct our cli tool status output, and you can use `kasa feature` to read and control them.
The upcoming homeassistant integration rewrite will also use these interfaces to provide access to features that were not easily available to homeassistant users, and simplifies extending the support for more devices and features in the future.
## Deprecated API Reference
```{currentmodule} kasa
```
The page contains the documentation for the deprecated library API that only works with the older kasa devices.
If you want to continue to use the old API for older devices,
you can use the classes in the `iot` module to avoid deprecation warnings.
```py
from kasa.iot import IotDevice, IotBulb, IotPlug, IotDimmer, IotStrip, IotLightStrip
```
```{toctree}
:maxdepth: 2
smartdevice
smartbulb
smartplug
smartdimmer
smartstrip
smartlightstrip
```

View File

@@ -1,72 +0,0 @@
.. py:module:: kasa.modules
.. _library_design:
Library Design & Modules
========================
This page aims to provide some details on the design and internals of this library.
You might be interested in this if you want to improve this library,
or if you are just looking to access some information that is not currently exposed.
.. contents:: Contents
:local:
.. _initialization:
Initialization
**************
Use :func:`~kasa.Discover.discover` to perform udp-based broadcast discovery on the network.
This will return you a list of device instances based on the discovery replies.
If the device's host is already known, you can use to construct a device instance with
:meth:`~kasa.SmartDevice.connect()`.
The :meth:`~kasa.SmartDevice.connect()` also enables support for connecting to new
KASA SMART protocol and TAPO devices directly using the parameter :class:`~kasa.DeviceConfig`.
Simply serialize the :attr:`~kasa.SmartDevice.config` property via :meth:`~kasa.DeviceConfig.to_dict()`
and then deserialize it later with :func:`~kasa.DeviceConfig.from_dict()`
and then pass it into :meth:`~kasa.SmartDevice.connect()`.
.. _update_cycle:
Update Cycle
************
When :meth:`~kasa.SmartDevice.update()` is called,
the library constructs a query to send to the device based on :ref:`supported modules <modules>`.
Internally, each module defines :meth:`~kasa.modules.Module.query()` to describe what they want query during the update.
The returned data is cached internally to avoid I/O on property accesses.
All properties defined both in the device class and in the module classes follow this principle.
While the properties are designed to provide a nice API to use for common use cases,
you may sometimes want to access the raw, cached data as returned by the device.
This can be done using the :attr:`~kasa.SmartDevice.internal_state` property.
.. _modules:
Modules
*******
The functionality provided by all :class:`~kasa.SmartDevice` instances is (mostly) done inside separate modules.
While the individual device-type specific classes provide an easy access for the most import features,
you can also access individual modules through :attr:`kasa.SmartDevice.modules`.
You can get the list of supported modules for a given device instance using :attr:`~kasa.SmartDevice.supported_modules`.
.. note::
If you only need some module-specific information,
you can call the wanted method on the module to avoid using :meth:`~kasa.SmartDevice.update`.
API documentation for modules
*****************************
.. automodule:: kasa.modules
:noindex:
:members:
:inherited-members:
:undoc-members:

View File

@@ -1,62 +0,0 @@
.. py:module:: kasa.discover
Discovering devices
===================
.. contents:: Contents
:local:
Discovery
*********
Discovery works by sending broadcast UDP packets to two known TP-link discovery ports, 9999 and 20002.
Port 9999 is used for legacy devices that do not use strong encryption and 20002 is for newer devices that use different
levels of encryption.
If a device uses port 20002 for discovery you will obtain some basic information from the device via discovery, but you
will need to await :func:`SmartDevice.update() <kasa.SmartDevice.update()>` to get full device information.
Credentials will most likely be required for port 20002 devices although if the device has never been connected to the tplink
cloud it may work without credentials.
To query or update the device requires authentication via :class:`Credentials <kasa.Credentials>` and if this is invalid or not provided it
will raise an :class:`AuthenticationException <kasa.AuthenticationException>`.
If discovery encounters an unsupported device when calling via :meth:`Discover.discover_single() <kasa.Discover.discover_single>`
it will raise a :class:`UnsupportedDeviceException <kasa.UnsupportedDeviceException>`.
If discovery encounters a device when calling :meth:`Discover.discover() <kasa.Discover.discover>`,
you can provide a callback to the ``on_unsupported`` parameter
to handle these.
Example:
.. code-block:: python
import asyncio
from kasa import Discover, Credentials
async def main():
device = await Discover.discover_single(
"127.0.0.1",
credentials=Credentials("myusername", "mypassword"),
discovery_timeout=10
)
await device.update() # Request the update
print(device.alias) # Print out the alias
devices = await Discover.discover(
credentials=Credentials("myusername", "mypassword"),
discovery_timeout=10
)
for ip, device in devices.items():
await device.update()
print(device.alias)
if __name__ == "__main__":
asyncio.run(main())
API documentation
*****************
.. autoclass:: kasa.Discover
:members:
:undoc-members:

View File

@@ -0,0 +1,13 @@
Some modules have attributes that may not be supported by the device.
These attributes will be annotated with a `FeatureAttribute` return type.
For example:
```py
@property
def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
"""Return the current HSV state of the bulb."""
```
You can test whether a `FeatureAttribute` is supported by the device with {meth}`kasa.Module.has_feature`
or {meth}`kasa.Module.get_feature` which will return `None` if not supported.
Calling these methods on attributes not annotated with a `FeatureAttribute` return type will return an error.

16
docs/source/guides.md Normal file
View File

@@ -0,0 +1,16 @@
# How-to Guides
Guides of how to perform common actions using the library.
```{toctree}
:maxdepth: 2
guides/discover
guides/connect
guides/device
guides/module
guides/feature
guides/light
guides/strip
guides/energy
```

View File

@@ -0,0 +1,10 @@
(connect_target)=
# Connect without discovery
:::{include} ../codeinfo.md
:::
```{eval-rst}
.. automodule:: kasa.deviceconfig
:noindex:
```

View File

@@ -0,0 +1,10 @@
(device_target)=
# Interact with devices
:::{include} ../codeinfo.md
:::
```{eval-rst}
.. automodule:: kasa.device
:noindex:
```

View File

@@ -0,0 +1,11 @@
(discover_target)=
# Discover devices
:::{include} ../codeinfo.md
:::
```{eval-rst}
.. automodule:: kasa.discover
:noindex:
```

View File

@@ -0,0 +1,27 @@
# Get Energy Consumption and Usage Statistics
:::{note}
In order to use the helper methods to calculate the statistics correctly, your devices need to have correct time set.
The devices use NTP (123/UDP) and public servers from [NTP Pool Project](https://www.ntppool.org/) to synchronize their time.
:::
## Energy Consumption
The availability of energy consumption sensors depend on the device.
While most of the bulbs support it, only specific switches (e.g., HS110) or strips (e.g., HS300) support it.
You can use {attr}`~Device.has_emeter` to check for the availability.
## Usage statistics
You can use {attr}`~Device.on_since` to query for the time the device has been turned on.
Some devices also support reporting the usage statistics on daily or monthly basis.
You can access this information using through the usage module ({class}`kasa.modules.Usage`):
```py
dev = SmartPlug("127.0.0.1")
usage = dev.modules["usage"]
print(f"Minutes on this month: {usage.usage_this_month}")
print(f"Minutes on today: {usage.usage_today}")
```

View File

@@ -0,0 +1,10 @@
(feature_target)=
# Interact with features
:::{include} ../codeinfo.md
:::
```{eval-rst}
.. automodule:: kasa.feature
:noindex:
```

View File

@@ -0,0 +1,26 @@
(light_target)=
# Interact with lights
:::{include} ../codeinfo.md
:::
```{eval-rst}
.. automodule:: kasa.interfaces.light
:noindex:
```
(lightpreset_target)=
## Presets
```{eval-rst}
.. automodule:: kasa.interfaces.lightpreset
:noindex:
```
(lighteffect_target)=
## Effects
```{eval-rst}
.. automodule:: kasa.interfaces.lighteffect
:noindex:
```

View File

@@ -0,0 +1,10 @@
(module_target)=
# Interact with modules
:::{include} ../codeinfo.md
:::
```{eval-rst}
.. automodule:: kasa.module
:noindex:
```

View File

@@ -0,0 +1,10 @@
(child_target)=
# Interact with child devices
:::{include} ../codeinfo.md
:::
```{eval-rst}
.. automodule:: kasa.smart.modules.childdevice
:noindex:
```

12
docs/source/index.md Normal file
View File

@@ -0,0 +1,12 @@
```{include} ../../README.md
```
```{toctree}
:maxdepth: 2
Home <self>
cli
library
contribute
SUPPORTED
```

View File

@@ -1,17 +0,0 @@
.. include:: ../../README.md
:parser: myst_parser.sphinx_
.. toctree::
:maxdepth: 2
Home <self>
cli
discover
smartdevice
design
smartbulb
smartplug
smartdimmer
smartstrip
smartlightstrip

15
docs/source/library.md Normal file
View File

@@ -0,0 +1,15 @@
# Library usage
```{currentmodule} kasa
```
The page contains all information about the library usage:
```{toctree}
:maxdepth: 2
tutorial
guides
topics
reference
deprecated
```

135
docs/source/reference.md Normal file
View File

@@ -0,0 +1,135 @@
# API Reference
## Discover
```{module} kasa
```
```{eval-rst}
.. autoclass:: Discover
:members:
```
## Device
% N.B. Credentials clashes with autodoc
```{eval-rst}
.. autoclass:: Device
:members:
:undoc-members:
:exclude-members: Credentials
```
## Device Config
```{eval-rst}
.. autoclass:: Credentials
:members:
:undoc-members:
```
```{eval-rst}
.. autoclass:: DeviceConfig
:members:
:undoc-members:
```
```{eval-rst}
.. autoclass:: DeviceFamily
:members:
:undoc-members:
```
```{eval-rst}
.. autoclass:: DeviceConnectionParameters
:members:
:undoc-members:
```
```{eval-rst}
.. autoclass:: DeviceEncryptionType
:members:
:undoc-members:
```
## Modules and Features
```{eval-rst}
.. autoclass:: Module
:members:
```
```{eval-rst}
.. autoclass:: Feature
:members:
:inherited-members:
:undoc-members:
```
```{eval-rst}
.. automodule:: kasa.interfaces
:members:
:inherited-members:
:undoc-members:
```
## Protocols and transports
```{eval-rst}
.. automodule:: kasa.protocols
:members:
:imported-members:
:undoc-members:
:exclude-members: SmartErrorCode
:no-index:
```
```{eval-rst}
.. automodule:: kasa.transports
:members:
:imported-members:
:undoc-members:
:no-index:
```
## Errors and exceptions
```{eval-rst}
.. autoclass:: kasa.exceptions.KasaException
:members:
:undoc-members:
```
```{eval-rst}
.. autoclass:: kasa.exceptions.DeviceError
:members:
:undoc-members:
```
```{eval-rst}
.. autoclass:: kasa.exceptions.AuthenticationError
:members:
:undoc-members:
```
```{eval-rst}
.. autoclass:: kasa.exceptions.UnsupportedDeviceError
:members:
:undoc-members:
```
```{eval-rst}
.. autoclass:: kasa.exceptions.TimeoutError
:members:
:undoc-members:
```

View File

@@ -67,13 +67,13 @@ API documentation
:members:
:undoc-members:
.. autoclass:: kasa.smartbulb.BehaviorMode
.. autoclass:: kasa.iot.iotbulb.BehaviorMode
:members:
.. autoclass:: kasa.TurnOnBehaviors
.. autoclass:: kasa.iot.iotbulb.TurnOnBehaviors
:members:
.. autoclass:: kasa.TurnOnBehavior
.. autoclass:: kasa.iot.iotbulb.TurnOnBehavior
:undoc-members:
:members:

View File

@@ -1,7 +1,7 @@
.. py:module:: kasa
.. py:currentmodule:: kasa
Common API
==========
Base Device
===========
.. contents:: Contents
:local:
@@ -13,7 +13,7 @@ The basic functionalities of all supported devices are accessible using the comm
The property accesses use the data obtained before by awaiting :func:`SmartDevice.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.
See :ref:`library_design` for more detailed information.
See :ref:`topics-update-cycle` for more detailed information.
.. note::
The device instances share the communication socket in background to optimize I/O accesses.
@@ -26,7 +26,7 @@ These methods will return the device response, which can be useful for some use
Errors are raised as :class:`SmartDeviceException` instances for the library user to handle.
Simple example script showing some functionality for legacy devices:
Simple example script showing some functionality:
.. code-block:: python
@@ -45,31 +45,6 @@ Simple example script showing some functionality for legacy devices:
if __name__ == "__main__":
asyncio.run(main())
If you are connecting to a newer KASA or TAPO device you can get the device via discovery or
connect directly with :class:`DeviceConfig`:
.. code-block:: python
import asyncio
from kasa import Discover, Credentials
async def main():
device = await Discover.discover_single(
"127.0.0.1",
credentials=Credentials("myusername", "mypassword"),
discovery_timeout=10
)
config = device.config # DeviceConfig.to_dict() can be used to store for later
# To connect directly later without discovery
later_device = await SmartDevice.connect(config=config)
await later_device.update()
print(later_device.alias) # Print out the alias
If you want to perform updates in a loop, you need to make sure that the device accesses are done in the same event loop:
.. code-block:: python
@@ -92,22 +67,6 @@ Refer to device type specific classes for more examples:
:class:`SmartPlug`, :class:`SmartBulb`, :class:`SmartStrip`,
:class:`SmartDimmer`, :class:`SmartLightStrip`.
DeviceConfig class
******************
The :class:`DeviceConfig` class can be used to initialise devices with parameters to allow them to be connected to without using
discovery.
This is required for newer KASA and TAPO devices that use different protocols for communication and will not respond
on port 9999 but instead use different encryption protocols over http port 80.
Currently there are three known types of encryption for TP-Link devices and two different protocols.
Devices with automatic firmware updates enabled may update to newer versions of the encryption without separate notice,
so discovery can be helpful to determine the correct config.
To connect directly pass a :class:`DeviceConfig` object to :meth:`SmartDevice.connect()`.
A :class:`DeviceConfig` can be constucted manually if you know the :attr:`DeviceConfig.connection_type` values for the device or
alternatively the config can be retrieved from :attr:`SmartDevice.config` post discovery and then re-used.
Energy Consumption and Usage Statistics
***************************************
@@ -144,25 +103,3 @@ API documentation
.. autoclass:: SmartDevice
:members:
:undoc-members:
.. autoclass:: DeviceConfig
:members:
:inherited-members:
:undoc-members:
:member-order: bysource
.. autoclass:: Credentials
:members:
:undoc-members:
.. autoclass:: SmartDeviceException
:members:
:undoc-members:
.. autoclass:: AuthenticationException
:members:
:undoc-members:
.. autoclass:: UnsupportedDeviceException
:members:
:undoc-members:

142
docs/source/topics.md Normal file
View File

@@ -0,0 +1,142 @@
# Topics
```{contents} Contents
:local:
```
These topics aim to provide some details on the design and internals of this library.
You might be interested in this if you want to improve this library,
or if you are just looking to access some information that is not currently exposed.
(topics-initialization)=
## Initialization
Use {func}`~kasa.Discover.discover` to perform udp-based broadcast discovery on the network.
This will return you a list of device instances based on the discovery replies.
If the device's host is already known, you can use to construct a device instance with
{meth}`~kasa.Device.connect()`.
The {meth}`~kasa.Device.connect()` also enables support for connecting to new
KASA SMART protocol and TAPO devices directly using the parameter {class}`~kasa.DeviceConfig`.
Simply serialize the {attr}`~kasa.Device.config` property via {meth}`~kasa.DeviceConfig.to_dict()`
and then deserialize it later with {func}`~kasa.DeviceConfig.from_dict()`
and then pass it into {meth}`~kasa.Device.connect()`.
(topics-discovery)=
## Discovery
Discovery works by sending broadcast UDP packets to two known TP-link discovery ports, 9999 and 20002.
Port 9999 is used for legacy devices that do not use strong encryption and 20002 is for newer devices that use different
levels of encryption.
If a device uses port 20002 for discovery you will obtain some basic information from the device via discovery, but you
will need to await {func}`Device.update() <kasa.Device.update()>` to get full device information.
Credentials will most likely be required for port 20002 devices although if the device has never been connected to the tplink
cloud it may work without credentials.
To query or update the device requires authentication via {class}`Credentials <kasa.Credentials>` and if this is invalid or not provided it
will raise an {class}`AuthenticationException <kasa.AuthenticationException>`.
If discovery encounters an unsupported device when calling via {meth}`Discover.discover_single() <kasa.Discover.discover_single>`
it will raise a {class}`UnsupportedDeviceException <kasa.UnsupportedDeviceException>`.
If discovery encounters a device when calling {func}`Discover.discover() <kasa.Discover.discover>`,
you can provide a callback to the ``on_unsupported`` parameter
to handle these.
(topics-deviceconfig)=
## DeviceConfig
The {class}`DeviceConfig` class can be used to initialise devices with parameters to allow them to be connected to without using
discovery.
This is required for newer KASA and TAPO devices that use different protocols for communication and will not respond
on port 9999 but instead use different encryption protocols over http port 80.
Currently there are three known types of encryption for TP-Link devices and two different protocols.
Devices with automatic firmware updates enabled may update to newer versions of the encryption without separate notice,
so discovery can be helpful to determine the correct config.
To connect directly pass a {class}`DeviceConfig` object to {meth}`Device.connect()`.
A {class}`DeviceConfig` can be constucted manually if you know the {attr}`DeviceConfig.connection_type` values for the device or
alternatively the config can be retrieved from {attr}`Device.config` post discovery and then re-used.
(topics-update-cycle)=
## Update Cycle
When {meth}`~kasa.Device.update()` is called,
the library constructs a query to send to the device based on :ref:`supported modules <modules>`.
Internally, each module defines {meth}`~kasa.modules.Module.query()` to describe what they want query during the update.
The returned data is cached internally to avoid I/O on property accesses.
All properties defined both in the device class and in the module classes follow this principle.
While the properties are designed to provide a nice API to use for common use cases,
you may sometimes want to access the raw, cached data as returned by the device.
This can be done using the {attr}`~kasa.Device.internal_state` property.
(topics-modules-and-features)=
## Modules and Features
The functionality provided by all {class}`~kasa.Device` instances is (mostly) done inside separate modules.
While the device class provides easy access for most device related attributes,
for components like `light` and `camera` you can access the module through {attr}`kasa.Device.modules`.
The module names are handily available as constants on {class}`~kasa.Module` and will return type aware values from the collection.
Features represent individual pieces of functionality within a module like brightness, hsv and temperature within a light module.
They allow for instrospection and can be accessed through {attr}`kasa.Device.features`.
Attributes can be accessed via a `Feature` or a module attribute depending on the use case.
Modules tend to provide richer functionality but using the features does not require an understanding of the module api.
:::{include} featureattributes.md
:::
(topics-protocols-and-transports)=
## Protocols and Transports
The library supports two different TP-Link protocols, ``IOT`` and ``SMART``.
``IOT`` is the original Kasa protocol and ``SMART`` is the newer protocol supported by TAPO devices and newer KASA devices.
The original protocol has a ``target``, ``command``, ``args`` interface whereas the new protocol uses a different set of
commands and has a ``method``, ``parameters`` interface.
Confusingly TP-Link originally called the Kasa line "Kasa Smart" and hence this library used "Smart" in a lot of the
module and class names but actually they were built to work with the ``IOT`` protocol.
In 2021 TP-Link started updating the underlying communication transport used by Kasa devices to make them more secure.
It switched from a TCP connection with static XOR type of encryption to a transport called ``KLAP`` which communicates
over http and uses handshakes to negotiate a dynamic encryption cipher.
This automatic update was put on hold and only seemed to affect UK HS100 models.
In 2023 TP-Link started updating the underlying communication transport used by Tapo devices to make them more secure.
It switched from AES encryption via public key exchange to use ``KLAP`` encryption and negotiation due to concerns
around impersonation with AES.
The encryption cipher is the same as for Kasa KLAP but the handshake seeds are slightly different.
Also in 2023 TP-Link started releasing newer Kasa branded devices using the ``SMART`` protocol.
This appears to be driven by hardware version rather than firmware.
In order to support these different configurations the library migrated from a single protocol class ``TPLinkSmartHomeProtocol``
to support pluggable transports and protocols.
The classes providing this functionality are:
- {class}`BaseProtocol <kasa.protocols.BaseProtocol>`
- {class}`IotProtocol <kasa.protocols.IotProtocol>`
- {class}`SmartProtocol <kasa.protocols.SmartProtocol>`
- {class}`BaseTransport <kasa.transports.BaseTransport>`
- {class}`XorTransport <kasa.transports.XorTransport>`
- {class}`AesTransport <kasa.transports.AesTransport>`
- {class}`KlapTransport <kasa.transports.KlapTransport>`
- {class}`KlapTransportV2 <kasa.transports.KlapTransportV2>`
(topics-errors-and-exceptions)=
## Errors and Exceptions
The base exception for all library errors is {class}`KasaException <kasa.exceptions.KasaException>`.
- If the device returns an error the library raises a {class}`DeviceError <kasa.exceptions.DeviceError>` which will usually contain an ``error_code`` with the detail.
- If the device fails to authenticate the library raises an {class}`AuthenticationError <kasa.exceptions.AuthenticationError>` which is derived
from {class}`DeviceError <kasa.exceptions.DeviceError>` and could contain an ``error_code`` depending on the type of failure.
- If the library encounters and unsupported deviceit raises an {class}`UnsupportedDeviceError <kasa.exceptions.UnsupportedDeviceError>`.
- If the device fails to respond within a timeout the library raises a {class}`TimeoutError <kasa.exceptions.TimeoutError>`.
- All other failures will raise the base {class}`KasaException <kasa.exceptions.KasaException>` class.

11
docs/source/tutorial.md Normal file
View File

@@ -0,0 +1,11 @@
# Getting started
:::{include} codeinfo.md
:::
```{eval-rst}
.. automodule:: tutorial
:members:
:inherited-members:
:undoc-members:
```

95
docs/tutorial.py Normal file
View File

@@ -0,0 +1,95 @@
# ruff: noqa
"""
>>> from kasa import Discover
:func:`~kasa.Discover.discover` returns a dict[str,Device] of devices on your network:
>>> devices = await Discover.discover(username="user@example.com", password="great_password")
>>> for dev in devices.values():
>>> await dev.update()
>>> print(dev.host)
127.0.0.1
127.0.0.2
127.0.0.3
127.0.0.4
127.0.0.5
:meth:`~kasa.Discover.discover_single` returns a single device by hostname:
>>> dev = await Discover.discover_single("127.0.0.3", username="user@example.com", password="great_password")
>>> await dev.update()
>>> dev.alias
Living Room Bulb
>>> dev.model
L530
>>> dev.rssi
-52
>>> dev.mac
5C:E9:31:00:00:00
You can update devices by calling different methods (e.g., ``set_``-prefixed ones).
Note, that these do not update the internal state, but you need to call :meth:`~kasa.Device.update()` to query the device again.
back to the device.
>>> await dev.set_alias("Dining Room")
>>> await dev.update()
>>> dev.alias
Dining Room
Different groups of functionality are supported by modules which you can access via :attr:`~kasa.Device.modules` with a typed
key from :class:`~kasa.Module`.
Modules will only be available on the device if they are supported but some individual features of a module may not be available for your device.
You can check the availability using ``has_feature()`` method.
>>> from kasa import Module
>>> Module.Light in dev.modules
True
>>> light = dev.modules[Module.Light]
>>> light.brightness
100
>>> await light.set_brightness(50)
>>> await dev.update()
>>> light.brightness
50
>>> light.has_feature("hsv")
True
>>> if light.has_feature("hsv"):
>>> print(light.hsv)
HSV(hue=0, saturation=100, value=50)
You can test if a module is supported by using `get` to access it.
>>> if effect := dev.modules.get(Module.LightEffect):
>>> print(effect.effect)
>>> print(effect.effect_list)
>>> if effect := dev.modules.get(Module.LightEffect):
>>> await effect.set_effect("Party")
>>> await dev.update()
>>> print(effect.effect)
Off
['Off', 'Party', 'Relax']
Party
Individual pieces of functionality are also exposed via features which you can access via :attr:`~kasa.Device.features` and will only be present if they are supported.
Features are similar to modules in that they provide functionality that may or may not be present.
Whereas modules group functionality into a common interface, features expose a single function that may or may not be part of a module.
The advantage of features is that they have a simple common interface of `id`, `name`, `value` and `set_value` so no need to learn the module API.
They are useful if you want write code that dynamically adapts as new features are added to the API.
>>> if auto_update := dev.features.get("auto_update_enabled"):
>>> print(auto_update.value)
False
>>> if auto_update:
>>> await auto_update.set_value(True)
>>> await dev.update()
>>> print(auto_update.value)
True
>>> for feat in dev.features.values():
>>> print(f"{feat.name}: {feat.value}")
Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nReboot: <Action>\nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: None\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: None\nCheck latest firmware: <Action>\nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nOverheated: False\nDevice time: 2024-02-23 02:40:15+01:00
"""

View File

@@ -1,70 +1,157 @@
"""Python interface for TP-Link's smart home devices.
All common, shared functionalities are available through `SmartDevice` class::
All common, shared functionalities are available through `Device` class::
x = SmartDevice("192.168.1.1")
print(x.sys_info)
>>> from kasa import Discover
>>> x = await Discover.discover_single("192.168.1.1")
>>> print(x.model)
For device type specific actions `SmartBulb`, `SmartPlug`, or `SmartStrip`
should be used instead.
For device type specific actions `modules` and `features` should be used instead.
Module-specific errors are raised as `SmartDeviceException` and are expected
Module-specific errors are raised as `KasaException` and are expected
to be handled by the user of the library.
"""
from importlib.metadata import version
from typing import TYPE_CHECKING, Any
from warnings import warn
from kasa.credentials import Credentials
from kasa.device import Device
from kasa.device_type import DeviceType
from kasa.deviceconfig import (
ConnectionType,
DeviceConfig,
DeviceFamilyType,
EncryptType,
DeviceConnectionParameters,
DeviceEncryptionType,
DeviceFamily,
)
from kasa.discover import Discover
from kasa.emeterstatus import EmeterStatus
from kasa.exceptions import (
AuthenticationException,
SmartDeviceException,
TimeoutException,
UnsupportedDeviceException,
AuthenticationError,
DeviceError,
KasaException,
TimeoutError,
UnsupportedDeviceError,
)
from kasa.iotprotocol import IotProtocol
from kasa.protocol import TPLinkProtocol, TPLinkSmartHomeProtocol
from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors
from kasa.smartdevice import DeviceType, SmartDevice
from kasa.smartdimmer import SmartDimmer
from kasa.smartlightstrip import SmartLightStrip
from kasa.smartplug import SmartPlug
from kasa.smartprotocol import SmartProtocol
from kasa.smartstrip import SmartStrip
from kasa.feature import Feature
from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState
from kasa.interfaces.thermostat import Thermostat, ThermostatState
from kasa.module import Module
from kasa.protocols import BaseProtocol, IotProtocol, SmartCamProtocol, SmartProtocol
from kasa.protocols.iotprotocol import _deprecated_TPLinkSmartHomeProtocol # noqa: F401
from kasa.smartcam.modules.camera import StreamResolution
from kasa.transports import BaseTransport
__version__ = version("python-kasa")
__all__ = [
"Discover",
"TPLinkSmartHomeProtocol",
"TPLinkProtocol",
"BaseProtocol",
"BaseTransport",
"IotProtocol",
"SmartProtocol",
"SmartBulb",
"SmartBulbPreset",
"SmartCamProtocol",
"LightState",
"TurnOnBehaviors",
"TurnOnBehavior",
"DeviceType",
"Feature",
"EmeterStatus",
"SmartDevice",
"SmartDeviceException",
"SmartPlug",
"SmartStrip",
"SmartDimmer",
"SmartLightStrip",
"AuthenticationException",
"UnsupportedDeviceException",
"TimeoutException",
"Device",
"Light",
"ColorTempRange",
"HSV",
"Plug",
"Module",
"KasaException",
"AuthenticationError",
"DeviceError",
"UnsupportedDeviceError",
"TimeoutError",
"Credentials",
"DeviceConfig",
"ConnectionType",
"EncryptType",
"DeviceFamilyType",
"DeviceConnectionParameters",
"DeviceEncryptionType",
"DeviceFamily",
"ThermostatState",
"Thermostat",
"StreamResolution",
]
from . import iot
from .iot.modules.lightpreset import IotLightPreset
deprecated_names = ["TPLinkSmartHomeProtocol"]
deprecated_smart_devices = {
"SmartDevice": iot.IotDevice,
"SmartPlug": iot.IotPlug,
"SmartBulb": iot.IotBulb,
"SmartLightStrip": iot.IotLightStrip,
"SmartStrip": iot.IotStrip,
"SmartDimmer": iot.IotDimmer,
"SmartBulbPreset": IotLightPreset,
}
deprecated_classes = {
"SmartDeviceException": KasaException,
"UnsupportedDeviceException": UnsupportedDeviceError,
"AuthenticationException": AuthenticationError,
"TimeoutException": TimeoutError,
"ConnectionType": DeviceConnectionParameters,
"EncryptType": DeviceEncryptionType,
"DeviceFamilyType": DeviceFamily,
}
if not TYPE_CHECKING:
def __getattr__(name: str) -> Any:
if name in deprecated_names:
warn(f"{name} is deprecated", DeprecationWarning, stacklevel=2)
return globals()[f"_deprecated_{name}"]
if name in deprecated_smart_devices:
new_class = deprecated_smart_devices[name]
package_name = ".".join(new_class.__module__.split(".")[:-1])
warn(
f"{name} is deprecated, use {new_class.__name__} from "
+ f"package {package_name} instead or use Discover.discover_single()"
+ " and Device.connect() to support new protocols",
DeprecationWarning,
stacklevel=2,
)
return new_class
if name in deprecated_classes:
new_class = deprecated_classes[name] # type: ignore[assignment]
msg = f"{name} is deprecated, use {new_class.__name__} instead"
warn(msg, DeprecationWarning, stacklevel=2)
return new_class
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
if TYPE_CHECKING:
SmartDevice = Device
SmartBulb = iot.IotBulb
SmartPlug = iot.IotPlug
SmartLightStrip = iot.IotLightStrip
SmartStrip = iot.IotStrip
SmartDimmer = iot.IotDimmer
SmartBulbPreset = IotLightPreset
SmartDeviceException = KasaException
UnsupportedDeviceException = UnsupportedDeviceError
AuthenticationException = AuthenticationError
TimeoutException = TimeoutError
ConnectionType = DeviceConnectionParameters
EncryptType = DeviceEncryptionType
DeviceFamilyType = DeviceFamily
# Instanstiate all classes so the type checkers catch abstract issues
from . import smart
smart.SmartDevice("127.0.0.1")
iot.IotDevice("127.0.0.1")
iot.IotPlug("127.0.0.1")
iot.IotBulb("127.0.0.1")
iot.IotLightStrip("127.0.0.1")
iot.IotStrip("127.0.0.1")
iot.IotDimmer("127.0.0.1")

View File

@@ -1,366 +0,0 @@
"""Implementation of the TP-Link AES transport.
Based on the work of https://github.com/petretiandrea/plugp100
under compatible GNU GPL3 license.
"""
import base64
import hashlib
import logging
import time
from typing import Optional
import httpx
from cryptography.hazmat.primitives import padding, serialization
from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from .credentials import Credentials
from .deviceconfig import DeviceConfig
from .exceptions import (
SMART_AUTHENTICATION_ERRORS,
SMART_RETRYABLE_ERRORS,
SMART_TIMEOUT_ERRORS,
AuthenticationException,
RetryableException,
SmartDeviceException,
SmartErrorCode,
TimeoutException,
)
from .json import dumps as json_dumps
from .json import loads as json_loads
from .protocol import BaseTransport
_LOGGER = logging.getLogger(__name__)
def _sha1(payload: bytes) -> str:
sha1_algo = hashlib.sha1() # noqa: S324
sha1_algo.update(payload)
return sha1_algo.hexdigest()
class AesTransport(BaseTransport):
"""Implementation of the AES encryption protocol.
AES is the name used in device discovery for TP-Link's TAPO encryption
protocol, sometimes used by newer firmware versions on kasa devices.
"""
DEFAULT_PORT: int = 80
SESSION_COOKIE_NAME = "TP_SESSIONID"
COMMON_HEADERS = {
"Content-Type": "application/json",
"requestByApp": "true",
"Accept": "application/json",
}
def __init__(
self,
*,
config: DeviceConfig,
) -> None:
super().__init__(config=config)
self._login_version = config.connection_type.login_version
if (
not self._credentials or self._credentials.username is None
) and not self._credentials_hash:
self._credentials = Credentials()
if self._credentials:
self._login_params = self._get_login_params()
else:
self._login_params = json_loads(
base64.b64decode(self._credentials_hash.encode()).decode() # type: ignore[union-attr]
)
self._default_http_client: Optional[httpx.AsyncClient] = None
self._handshake_done = False
self._encryption_session: Optional[AesEncyptionSession] = None
self._session_expire_at: Optional[float] = None
self._session_cookie = None
self._login_token = None
_LOGGER.debug("Created AES transport for %s", self._host)
@property
def default_port(self):
"""Default port for the transport."""
return self.DEFAULT_PORT
@property
def credentials_hash(self) -> str:
"""The hashed credentials used by the transport."""
return base64.b64encode(json_dumps(self._login_params).encode()).decode()
@property
def _http_client(self) -> httpx.AsyncClient:
if self._config.http_client:
return self._config.http_client
if not self._default_http_client:
self._default_http_client = httpx.AsyncClient()
return self._default_http_client
def _get_login_params(self):
"""Get the login parameters based on the login_version."""
un, pw = self.hash_credentials(self._login_version == 2)
password_field_name = "password2" if self._login_version == 2 else "password"
return {password_field_name: pw, "username": un}
def hash_credentials(self, login_v2):
"""Hash the credentials."""
if login_v2:
un = base64.b64encode(
_sha1(self._credentials.username.encode()).encode()
).decode()
pw = base64.b64encode(
_sha1(self._credentials.password.encode()).encode()
).decode()
else:
un = base64.b64encode(
_sha1(self._credentials.username.encode()).encode()
).decode()
pw = base64.b64encode(self._credentials.password.encode()).decode()
return un, pw
async def client_post(self, url, params=None, data=None, json=None, headers=None):
"""Send an http post request to the device."""
response_data = None
cookies = None
if self._session_cookie:
cookies = httpx.Cookies()
cookies.set(self.SESSION_COOKIE_NAME, self._session_cookie)
self._http_client.cookies.clear()
resp = await self._http_client.post(
url,
params=params,
data=data,
json=json,
timeout=self._timeout,
cookies=cookies,
headers=self.COMMON_HEADERS,
)
if resp.status_code == 200:
response_data = resp.json()
return resp.status_code, response_data
def _handle_response_error_code(self, resp_dict: dict, msg: str):
error_code = SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type]
if error_code == SmartErrorCode.SUCCESS:
return
msg = f"{msg}: {self._host}: {error_code.name}({error_code.value})"
if error_code in SMART_TIMEOUT_ERRORS:
raise TimeoutException(msg, error_code=error_code)
if error_code in SMART_RETRYABLE_ERRORS:
raise RetryableException(msg, error_code=error_code)
if error_code in SMART_AUTHENTICATION_ERRORS:
self._handshake_done = False
self._login_token = None
raise AuthenticationException(msg, error_code=error_code)
raise SmartDeviceException(msg, error_code=error_code)
async def send_secure_passthrough(self, request: str):
"""Send encrypted message as passthrough."""
url = f"http://{self._host}/app"
if self._login_token:
url += f"?token={self._login_token}"
encrypted_payload = self._encryption_session.encrypt(request.encode()) # type: ignore
passthrough_request = {
"method": "securePassthrough",
"params": {"request": encrypted_payload.decode()},
}
status_code, resp_dict = await self.client_post(url, json=passthrough_request)
# _LOGGER.debug(f"secure_passthrough response is {status_code}: {resp_dict}")
if status_code != 200:
raise SmartDeviceException(
f"{self._host} responded with an unexpected "
+ f"status code {status_code} to passthrough"
)
self._handle_response_error_code(
resp_dict, "Error sending secure_passthrough message"
)
response = self._encryption_session.decrypt( # type: ignore
resp_dict["result"]["response"].encode()
)
resp_dict = json_loads(response)
return resp_dict
async def perform_login(self):
"""Login to the device."""
self._login_token = None
login_request = {
"method": "login_device",
"params": self._login_params,
"request_time_milis": round(time.time() * 1000),
}
request = json_dumps(login_request)
resp_dict = await self.send_secure_passthrough(request)
self._handle_response_error_code(resp_dict, "Error logging in")
self._login_token = resp_dict["result"]["token"]
async def perform_handshake(self):
"""Perform the handshake."""
_LOGGER.debug("Will perform handshaking...")
_LOGGER.debug("Generating keypair")
self._handshake_done = False
self._session_expire_at = None
self._session_cookie = None
url = f"http://{self._host}/app"
key_pair = KeyPair.create_key_pair()
pub_key = (
"-----BEGIN PUBLIC KEY-----\n"
+ key_pair.get_public_key()
+ "\n-----END PUBLIC KEY-----\n"
)
handshake_params = {"key": pub_key}
_LOGGER.debug(f"Handshake params: {handshake_params}")
request_body = {"method": "handshake", "params": handshake_params}
_LOGGER.debug(f"Request {request_body}")
status_code, resp_dict = await self.client_post(url, json=request_body)
_LOGGER.debug(f"Device responded with: {resp_dict}")
if status_code != 200:
raise SmartDeviceException(
f"{self._host} responded with an unexpected "
+ f"status code {status_code} to handshake"
)
self._handle_response_error_code(resp_dict, "Unable to complete handshake")
handshake_key = resp_dict["result"]["key"]
self._session_cookie = self._http_client.cookies.get( # type: ignore
self.SESSION_COOKIE_NAME
)
if not self._session_cookie:
self._session_cookie = self._http_client.cookies.get( # type: ignore
"SESSIONID"
)
self._session_expire_at = time.time() + 86400
self._encryption_session = AesEncyptionSession.create_from_keypair(
handshake_key, key_pair
)
self._handshake_done = True
_LOGGER.debug("Handshake with %s complete", self._host)
def _handshake_session_expired(self):
"""Return true if session has expired."""
return (
self._session_expire_at is None
or self._session_expire_at - time.time() <= 0
)
async def send(self, request: str):
"""Send the request."""
if not self._handshake_done or self._handshake_session_expired():
await self.perform_handshake()
if not self._login_token:
await self.perform_login()
return await self.send_secure_passthrough(request)
async def close(self) -> None:
"""Close the protocol."""
client = self._default_http_client
self._default_http_client = None
self._handshake_done = False
self._login_token = None
if client:
await client.aclose()
class AesEncyptionSession:
"""Class for an AES encryption session."""
@staticmethod
def create_from_keypair(handshake_key: str, keypair):
"""Create the encryption session."""
handshake_key_bytes: bytes = base64.b64decode(handshake_key.encode("UTF-8"))
private_key_data = base64.b64decode(keypair.get_private_key().encode("UTF-8"))
private_key = serialization.load_der_private_key(private_key_data, None, None)
key_and_iv = private_key.decrypt(
handshake_key_bytes, asymmetric_padding.PKCS1v15()
)
if key_and_iv is None:
raise ValueError("Decryption failed!")
return AesEncyptionSession(key_and_iv[:16], key_and_iv[16:])
def __init__(self, key, iv):
self.cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
self.padding_strategy = padding.PKCS7(algorithms.AES.block_size)
def encrypt(self, data) -> bytes:
"""Encrypt the message."""
encryptor = self.cipher.encryptor()
padder = self.padding_strategy.padder()
padded_data = padder.update(data) + padder.finalize()
encrypted = encryptor.update(padded_data) + encryptor.finalize()
return base64.b64encode(encrypted)
def decrypt(self, data) -> str:
"""Decrypt the message."""
decryptor = self.cipher.decryptor()
unpadder = self.padding_strategy.unpadder()
decrypted = decryptor.update(base64.b64decode(data)) + decryptor.finalize()
unpadded_data = unpadder.update(decrypted) + unpadder.finalize()
return unpadded_data.decode()
class KeyPair:
"""Class for generating key pairs."""
@staticmethod
def create_key_pair(key_size: int = 1024):
"""Create a key pair."""
private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size)
public_key = private_key.public_key()
private_key_bytes = private_key.private_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
public_key_bytes = public_key.public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
return KeyPair(
private_key=base64.b64encode(private_key_bytes).decode("UTF-8"),
public_key=base64.b64encode(public_key_bytes).decode("UTF-8"),
)
def __init__(self, private_key: str, public_key: str):
self.private_key = private_key
self.public_key = public_key
def get_private_key(self) -> str:
"""Get the private key."""
return self.private_key
def get_public_key(self) -> str:
"""Get the public key."""
return self.public_key

27
kasa/cachedzoneinfo.py Normal file
View File

@@ -0,0 +1,27 @@
"""Module for caching ZoneInfos."""
from __future__ import annotations
import asyncio
from zoneinfo import ZoneInfo
class CachedZoneInfo(ZoneInfo):
"""Cache ZoneInfo objects."""
_cache: dict[str, ZoneInfo] = {}
@classmethod
async def get_cached_zone_info(cls, time_zone_str: str) -> ZoneInfo:
"""Get a cached zone info object."""
if cached := cls._cache.get(time_zone_str):
return cached
loop = asyncio.get_running_loop()
zinfo = await loop.run_in_executor(None, _get_zone_info, time_zone_str)
cls._cache[time_zone_str] = zinfo
return zinfo
def _get_zone_info(time_zone_str: str) -> ZoneInfo:
"""Get a time zone object for the given time zone string."""
return ZoneInfo(time_zone_str)

File diff suppressed because it is too large Load Diff

1
kasa/cli/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Package for the cli."""

6
kasa/cli/__main__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Main module."""
from kasa.cli.main import cli
if __name__ == "__main__":
cli()

241
kasa/cli/common.py Normal file
View File

@@ -0,0 +1,241 @@
"""Common cli module."""
from __future__ import annotations
import json
import re
import sys
from collections.abc import Callable
from contextlib import contextmanager
from functools import singledispatch, update_wrapper, wraps
from typing import TYPE_CHECKING, Any, Final
import asyncclick as click
from kasa import (
Device,
)
# Value for optional options if passed without a value
OPTIONAL_VALUE_FLAG: Final = "_FLAG_"
# Block list of commands which require no update
SKIP_UPDATE_COMMANDS = ["raw-command", "command"]
pass_dev = click.make_pass_decorator(Device) # type: ignore[type-abstract]
try:
from rich import print as _echo
except ImportError:
# Strip out rich formatting if rich is not installed
# but only lower case tags to avoid stripping out
# raw data from the device that is printed from
# the device state.
rich_formatting = re.compile(r"\[/?[a-z]+]")
def _strip_rich_formatting(echo_func):
"""Strip rich formatting from messages."""
@wraps(echo_func)
def wrapper(message=None, *args, **kwargs) -> None:
if message is not None:
message = rich_formatting.sub("", message)
echo_func(message, *args, **kwargs)
return wrapper
_echo = _strip_rich_formatting(click.echo)
def echo(*args, **kwargs) -> None:
"""Print a message."""
ctx = click.get_current_context().find_root()
if "json" not in ctx.params or ctx.params["json"] is False:
_echo(*args, **kwargs)
def error(msg: str) -> None:
"""Print an error and exit."""
echo(f"[bold red]{msg}[/bold red]")
sys.exit(1)
def json_formatter_cb(result: Any, **kwargs) -> None:
"""Format and output the result as JSON, if requested."""
if not kwargs.get("json"):
return
@singledispatch
def to_serializable(val):
"""Regular obj-to-string for json serialization.
The singledispatch trick is from hynek: https://hynek.me/articles/serialization/
"""
return str(val)
@to_serializable.register(Device)
def _device_to_serializable(val: Device):
"""Serialize smart device data, just using the last update raw payload."""
return val.internal_state
json_content = json.dumps(result, indent=4, default=to_serializable)
print(json_content)
def pass_dev_or_child(wrapped_function: Callable) -> Callable:
"""Pass the device or child to the click command based on the child options."""
child_help = (
"Child ID or alias for controlling sub-devices. "
"If no value provided will show an interactive prompt allowing you to "
"select a child."
)
child_index_help = "Child index controlling sub-devices"
@contextmanager
def patched_device_update(parent: Device, child: Device):
try:
orig_update = child.update
# patch child update method. Can be removed once update can be called
# directly on child devices
child.update = parent.update # type: ignore[method-assign]
yield child
finally:
child.update = orig_update # type: ignore[method-assign]
@click.pass_obj
@click.pass_context
@click.option(
"--child",
"--name",
is_flag=False,
flag_value=OPTIONAL_VALUE_FLAG,
default=None,
required=False,
type=click.STRING,
help=child_help,
)
@click.option(
"--child-index",
"--index",
required=False,
default=None,
type=click.INT,
help=child_index_help,
)
async def wrapper(ctx: click.Context, dev, *args, child, child_index, **kwargs):
if child := await _get_child_device(dev, child, child_index, ctx.info_name):
ctx.obj = ctx.with_resource(patched_device_update(dev, child))
dev = child
return await ctx.invoke(wrapped_function, dev, *args, **kwargs)
# Update wrapper function to look like wrapped function
return update_wrapper(wrapper, wrapped_function)
async def _get_child_device(
device: Device,
child_option: str | None,
child_index_option: int | None,
info_command: str | None,
) -> Device | None:
def _list_children():
return "\n".join(
[
f"{idx}: {child.device_id} ({child.alias})"
for idx, child in enumerate(device.children)
]
)
if child_option is None and child_index_option is None:
return None
if info_command in SKIP_UPDATE_COMMANDS:
# The device hasn't had update called (e.g. for cmd_command)
# The way child devices are accessed requires a ChildDevice to
# wrap the communications. Doing this properly would require creating
# a common interfaces for both IOT and SMART child devices.
# As a stop-gap solution, we perform an update instead.
await device.update()
if not device.children:
error(f"Device: {device.host} does not have children")
if child_option is not None and child_index_option is not None:
raise click.BadOptionUsage(
"child", "Use either --child or --child-index, not both."
)
if child_option is not None:
if child_option is OPTIONAL_VALUE_FLAG:
msg = _list_children()
child_index_option = click.prompt(
f"\n{msg}\nEnter the index number of the child device",
type=click.IntRange(0, len(device.children) - 1),
)
elif child := device.get_child_device(child_option):
echo(f"Targeting child device {child.alias}")
return child
else:
error(
"No child device found with device_id or name: "
f"{child_option} children are:\n{_list_children()}"
)
if TYPE_CHECKING:
assert isinstance(child_index_option, int)
if child_index_option + 1 > len(device.children) or child_index_option < 0:
error(
f"Invalid index {child_index_option}, "
f"device has {len(device.children)} children"
)
child_by_index = device.children[child_index_option]
echo(f"Targeting child device {child_by_index.alias}")
return child_by_index
def CatchAllExceptions(cls):
"""Capture all exceptions and prints them nicely.
Idea from https://stackoverflow.com/a/44347763 and
https://stackoverflow.com/questions/52213375
"""
def _handle_exception(debug, exc) -> None:
if isinstance(exc, click.ClickException):
raise
# Handle exit request from click.
if isinstance(exc, click.exceptions.Exit):
sys.exit(exc.exit_code)
if isinstance(exc, click.exceptions.Abort):
sys.exit(0)
echo(f"Raised error: {exc}")
if debug:
raise
echo("Run with --debug enabled to see stacktrace")
sys.exit(1)
class _CommandCls(cls):
_debug = False
async def make_context(self, info_name, args, parent=None, **extra):
self._debug = any(
[arg for arg in args if arg in ["--debug", "-d", "--verbose", "-v"]]
)
try:
return await super().make_context(
info_name, args, parent=parent, **extra
)
except Exception as exc:
_handle_exception(self._debug, exc)
async def invoke(self, ctx):
try:
return await super().invoke(ctx)
except Exception as exc:
_handle_exception(self._debug, exc)
return _CommandCls

211
kasa/cli/device.py Normal file
View File

@@ -0,0 +1,211 @@
"""Module for cli device commands."""
from __future__ import annotations
from pprint import pformat as pf
import asyncclick as click
from kasa import (
Device,
Module,
)
from kasa.smart import SmartDevice
from .common import (
echo,
error,
pass_dev,
pass_dev_or_child,
)
@click.group()
@pass_dev_or_child
def device(dev) -> None:
"""Commands to control basic device settings."""
@device.command()
@pass_dev_or_child
@click.pass_context
async def state(ctx, dev: Device):
"""Print out device state and versions."""
from .feature import _echo_all_features
verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False
echo(f"[bold]== {dev.alias} - {dev.model} ==[/bold]")
echo(f"Host: {dev.host}")
echo(f"Port: {dev.port}")
echo(f"Device state: {dev.is_on}")
echo(f"Time: {dev.time} (tz: {dev.timezone})")
echo(
f"Hardware: {dev.device_info.hardware_version}"
f"{' (' + dev.region + ')' if dev.region else ''}"
)
echo(
f"Firmware: {dev.device_info.firmware_version}"
f" {dev.device_info.firmware_build}"
)
echo(f"MAC (rssi): {dev.mac} ({dev.rssi})")
if verbose:
echo(f"Location: {dev.location}")
echo()
_echo_all_features(dev.features, verbose=verbose)
if verbose:
echo("\n[bold]== Modules ==[/bold]")
for module in dev.modules.values():
echo(f"[green]+ {module}[/green]")
if dev.children:
echo("\n[bold]== Children ==[/bold]")
for child in dev.children:
_echo_all_features(
child.features,
title_prefix=f"{child.alias} ({child.model})",
verbose=verbose,
indent="\t",
)
if verbose:
echo(f"\n\t[bold]== Child {child.alias} Modules ==[/bold]")
for module in child.modules.values():
echo(f"\t[green]+ {module}[/green]")
echo()
if verbose:
echo("\n\t[bold]== Protocol information ==[/bold]")
echo(f"\tCredentials hash: {dev.credentials_hash}")
echo()
from .discover import _echo_discovery_info
_echo_discovery_info(dev._discovery_info)
return dev.internal_state
@device.command()
@pass_dev_or_child
async def sysinfo(dev):
"""Print out full system information."""
echo("== System info ==")
echo(pf(dev.sys_info))
return dev.sys_info
@device.command()
@click.option("--transition", type=int, required=False)
@pass_dev_or_child
async def on(dev: Device, transition: int):
"""Turn the device on."""
echo(f"Turning on {dev.alias}")
return await dev.turn_on(transition=transition)
@device.command
@click.option("--transition", type=int, required=False)
@pass_dev_or_child
async def off(dev: Device, transition: int):
"""Turn the device off."""
echo(f"Turning off {dev.alias}")
return await dev.turn_off(transition=transition)
@device.command()
@click.option("--transition", type=int, required=False)
@pass_dev_or_child
async def toggle(dev: Device, transition: int):
"""Toggle the device on/off."""
if dev.is_on:
echo(f"Turning off {dev.alias}")
return await dev.turn_off(transition=transition)
echo(f"Turning on {dev.alias}")
return await dev.turn_on(transition=transition)
@device.command()
@click.argument("state", type=bool, required=False)
@pass_dev_or_child
async def led(dev: Device, state):
"""Get or set (Plug's) led state."""
if not (led := dev.modules.get(Module.Led)):
error("Device does not support led.")
return
if state is not None:
echo(f"Turning led to {state}")
return await led.set_led(state)
else:
echo(f"LED state: {led.led}")
return led.led
@device.command()
@click.argument("new_alias", required=False, default=None)
@pass_dev_or_child
async def alias(dev, new_alias):
"""Get or set the device (or plug) alias."""
if new_alias is not None:
echo(f"Setting alias to {new_alias}")
res = await dev.set_alias(new_alias)
await dev.update()
echo(f"Alias set to: {dev.alias}")
return res
echo(f"Alias: {dev.alias}")
if dev.children:
for plug in dev.children:
echo(f" * {plug.alias}")
return dev.alias
@device.command()
@click.option("--delay", default=1)
@pass_dev
async def reboot(plug, delay):
"""Reboot the device."""
echo("Rebooting the device..")
return await plug.reboot(delay)
@device.command()
@pass_dev
async def factory_reset(plug):
"""Reset device to factory settings."""
click.confirm(
"Do you really want to reset the device to factory settings?", abort=True
)
return await plug.factory_reset()
@device.command()
@pass_dev
@click.option(
"--username", required=True, prompt=True, help="New username to set on the device"
)
@click.option(
"--password", required=True, prompt=True, help="New password to set on the device"
)
async def update_credentials(dev, username, password):
"""Update device credentials for authenticated devices."""
if not isinstance(dev, SmartDevice):
error("Credentials can only be updated on authenticated devices.")
click.confirm("Do you really want to replace the existing credentials?", abort=True)
return await dev.update_credentials(username, password)
@device.command(name="logs")
@pass_dev_or_child
async def child_logs(dev):
"""Print child device trigger logs."""
if logs := dev.modules.get(Module.TriggerLogs):
await dev.update(update_children=True)
for entry in logs.logs:
print(entry)

342
kasa/cli/discover.py Normal file
View File

@@ -0,0 +1,342 @@
"""Module for cli discovery commands."""
from __future__ import annotations
import asyncio
from pprint import pformat as pf
import asyncclick as click
from kasa import (
AuthenticationError,
Credentials,
Device,
Discover,
UnsupportedDeviceError,
)
from kasa.discover import (
NEW_DISCOVERY_REDACTORS,
ConnectAttempt,
DiscoveredRaw,
DiscoveryResult,
)
from kasa.iot.iotdevice import _extract_sys_info
from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS
from kasa.protocols.protocol import redact_data
from ..json import dumps as json_dumps
from .common import echo, error
@click.group(invoke_without_command=True)
@click.pass_context
async def discover(ctx):
"""Discover devices in the network."""
if ctx.invoked_subcommand is None:
return await ctx.invoke(detail)
@discover.command()
@click.pass_context
async def detail(ctx):
"""Discover devices in the network using udp broadcasts."""
unsupported = []
auth_failed = []
sem = asyncio.Semaphore()
async def print_unsupported(unsupported_exception: UnsupportedDeviceError) -> None:
unsupported.append(unsupported_exception)
async with sem:
if unsupported_exception.discovery_result:
echo("== Unsupported device ==")
_echo_discovery_info(unsupported_exception.discovery_result)
echo()
else:
echo("== Unsupported device ==")
echo(f"\t{unsupported_exception}")
echo()
from .device import state
async def print_discovered(dev: Device) -> None:
async with sem:
try:
await dev.update()
except AuthenticationError:
auth_failed.append(dev._discovery_info)
echo("== Authentication failed for device ==")
_echo_discovery_info(dev._discovery_info)
echo()
else:
ctx.parent.obj = dev
await ctx.parent.invoke(state)
echo()
discovered = await _discover(
ctx, print_discovered=print_discovered, print_unsupported=print_unsupported
)
if ctx.parent.parent.params["host"]:
return discovered
echo(f"Found {len(discovered)} devices")
if unsupported:
echo(f"Found {len(unsupported)} unsupported devices")
if auth_failed:
echo(f"Found {len(auth_failed)} devices that failed to authenticate")
return discovered
@discover.command()
@click.option(
"--redact/--no-redact",
default=False,
is_flag=True,
type=bool,
help="Set flag to redact sensitive data from raw output.",
)
@click.pass_context
async def raw(ctx, redact: bool):
"""Return raw discovery data returned from devices."""
def print_raw(discovered: DiscoveredRaw):
if redact:
redactors = (
NEW_DISCOVERY_REDACTORS
if discovered["meta"]["port"] == Discover.DISCOVERY_PORT_2
else IOT_REDACTORS
)
discovered["discovery_response"] = redact_data(
discovered["discovery_response"], redactors
)
echo(json_dumps(discovered, indent=True))
return await _discover(ctx, print_raw=print_raw, do_echo=False)
@discover.command()
@click.pass_context
async def list(ctx):
"""List devices in the network in a table using udp broadcasts."""
sem = asyncio.Semaphore()
async def print_discovered(dev: Device):
cparams = dev.config.connection_type
infostr = (
f"{dev.host:<15} {dev.model:<9} {cparams.device_family.value:<20} "
f"{cparams.encryption_type.value:<7} {cparams.https:<5} "
f"{cparams.login_version or '-':<3}"
)
async with sem:
try:
await dev.update()
except AuthenticationError:
echo(f"{infostr} - Authentication failed")
except TimeoutError:
echo(f"{infostr} - Timed out")
except Exception as ex:
echo(f"{infostr} - Error: {ex}")
else:
echo(f"{infostr} {dev.alias}")
async def print_unsupported(unsupported_exception: UnsupportedDeviceError):
if host := unsupported_exception.host:
echo(f"{host:<15} UNSUPPORTED DEVICE")
echo(
f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} "
f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}"
)
return await _discover(
ctx,
print_discovered=print_discovered,
print_unsupported=print_unsupported,
do_echo=False,
)
async def _discover(
ctx, *, print_discovered=None, print_unsupported=None, print_raw=None, do_echo=True
):
params = ctx.parent.parent.params
target = params["target"]
username = params["username"]
password = params["password"]
discovery_timeout = params["discovery_timeout"]
timeout = params["timeout"]
host = params["host"]
port = params["port"]
credentials = Credentials(username, password) if username and password else None
if host:
echo(f"Discovering device {host} for {discovery_timeout} seconds")
return await Discover.discover_single(
host,
port=port,
credentials=credentials,
timeout=timeout,
discovery_timeout=discovery_timeout,
on_unsupported=print_unsupported,
on_discovered_raw=print_raw,
)
if do_echo:
echo(f"Discovering devices on {target} for {discovery_timeout} seconds")
discovered_devices = await Discover.discover(
target=target,
discovery_timeout=discovery_timeout,
on_discovered=print_discovered,
on_unsupported=print_unsupported,
port=port,
timeout=timeout,
credentials=credentials,
on_discovered_raw=print_raw,
)
for device in discovered_devices.values():
await device.protocol.close()
return discovered_devices
@discover.command()
@click.pass_context
async def config(ctx):
"""Bypass udp discovery and try to show connection config for a device.
Bypasses udp discovery and shows the parameters required to connect
directly to the device.
"""
params = ctx.parent.parent.params
username = params["username"]
password = params["password"]
timeout = params["timeout"]
host = params["host"]
port = params["port"]
if not host:
error("--host option must be supplied to discover config")
credentials = Credentials(username, password) if username and password else None
host_port = host + (f":{port}" if port else "")
def on_attempt(connect_attempt: ConnectAttempt, success: bool) -> None:
prot, tran, dev = connect_attempt
key_str = f"{prot.__name__} + {tran.__name__} + {dev.__name__}"
result = "succeeded" if success else "failed"
msg = f"Attempt to connect to {host_port} with {key_str} {result}"
echo(msg)
dev = await Discover.try_connect_all(
host, credentials=credentials, timeout=timeout, port=port, on_attempt=on_attempt
)
if dev:
cparams = dev.config.connection_type
echo("Managed to connect, cli options to connect are:")
echo(
f"--device-family {cparams.device_family.value} "
f"--encrypt-type {cparams.encryption_type.value} "
f"{'--https' if cparams.https else '--no-https'}"
)
else:
error(f"Unable to connect to {host}")
def _echo_dictionary(discovery_info: dict) -> None:
echo("\t[bold]== Discovery information ==[/bold]")
for key, value in discovery_info.items():
key_name = " ".join(x.capitalize() or "_" for x in key.split("_"))
key_name_and_spaces = "{:<15}".format(key_name + ":")
echo(f"\t{key_name_and_spaces}{value}")
def _echo_discovery_info(discovery_info) -> None:
# We don't have discovery info when all connection params are passed manually
if discovery_info is None:
return
if sysinfo := _extract_sys_info(discovery_info):
_echo_dictionary(sysinfo)
return
try:
dr = DiscoveryResult.from_dict(discovery_info)
except Exception:
_echo_dictionary(discovery_info)
return
def _conditional_echo(label, value):
if value:
ws = " " * (19 - len(label))
echo(f"\t{label}:{ws}{value}")
echo("\t[bold]== Discovery Result ==[/bold]")
_conditional_echo("Device Type", dr.device_type)
_conditional_echo("Device Model", dr.device_model)
_conditional_echo("Device Name", dr.device_name)
_conditional_echo("IP", dr.ip)
_conditional_echo("MAC", dr.mac)
_conditional_echo("Device Id (hash)", dr.device_id)
_conditional_echo("Owner (hash)", dr.owner)
_conditional_echo("FW Ver", dr.firmware_version)
_conditional_echo("HW Ver", dr.hw_ver)
_conditional_echo("HW Ver", dr.hardware_version)
_conditional_echo("Supports IOT Cloud", dr.is_support_iot_cloud)
_conditional_echo("OBD Src", dr.owner)
_conditional_echo("Factory Default", dr.factory_default)
_conditional_echo("Encrypt Type", dr.encrypt_type)
if mgt_encrypt_schm := dr.mgt_encrypt_schm:
_conditional_echo("Encrypt Type", mgt_encrypt_schm.encrypt_type)
_conditional_echo("Supports HTTPS", mgt_encrypt_schm.is_support_https)
_conditional_echo("HTTP Port", mgt_encrypt_schm.http_port)
_conditional_echo("Login version", mgt_encrypt_schm.lv)
_conditional_echo("Encrypt info", pf(dr.encrypt_info) if dr.encrypt_info else None)
_conditional_echo("Decrypted", pf(dr.decrypted_data) if dr.decrypted_data else None)
async def find_dev_from_alias(
alias: str,
credentials: Credentials | None,
target: str = "255.255.255.255",
timeout: int = 5,
attempts: int = 3,
) -> Device | None:
"""Discover a device identified by its alias."""
found_event = asyncio.Event()
found_device = []
seen_hosts = set()
async def on_discovered(dev: Device):
if dev.host in seen_hosts:
return
seen_hosts.add(dev.host)
try:
await dev.update()
except Exception as ex:
echo(f"Error querying device {dev.host}: {ex}")
return
finally:
await dev.protocol.close()
if not dev.alias:
echo(f"Skipping device {dev.host} with no alias")
return
if dev.alias.lower() == alias.lower():
found_device.append(dev)
found_event.set()
async def do_discover():
for _ in range(1, attempts):
await Discover.discover(
target=target,
timeout=timeout,
credentials=credentials,
on_discovered=on_discovered,
)
if found_event.is_set():
break
found_event.set()
asyncio.create_task(do_discover())
await found_event.wait()
return found_device[0] if found_device else None

142
kasa/cli/feature.py Normal file
View File

@@ -0,0 +1,142 @@
"""Module for cli feature commands."""
from __future__ import annotations
import ast
import asyncclick as click
from kasa import (
Device,
Feature,
)
from .common import (
echo,
error,
pass_dev_or_child,
)
def _echo_features(
features: dict[str, Feature],
title: str,
category: Feature.Category | None = None,
verbose: bool = False,
indent: str = "\t",
) -> None:
"""Print out a listing of features and their values."""
if category is not None:
features = {
id_: feat for id_, feat in features.items() if feat.category == category
}
echo(f"{indent}[bold]{title}[/bold]")
for _, feat in features.items():
try:
echo(f"{indent}{feat}")
if verbose:
echo(f"{indent}\tType: {feat.type}")
echo(f"{indent}\tCategory: {feat.category}")
echo(f"{indent}\tIcon: {feat.icon}")
except Exception as ex:
echo(f"{indent}{feat.name} ({feat.id}): [red]got exception ({ex})[/red]")
def _echo_all_features(
features, *, verbose=False, title_prefix=None, indent=""
) -> None:
"""Print out all features by category."""
if title_prefix is not None:
echo(f"[bold]\n{indent}== {title_prefix} ==[/bold]")
echo()
_echo_features(
features,
title="== Primary features ==",
category=Feature.Category.Primary,
verbose=verbose,
indent=indent,
)
echo()
_echo_features(
features,
title="== Information ==",
category=Feature.Category.Info,
verbose=verbose,
indent=indent,
)
echo()
_echo_features(
features,
title="== Configuration ==",
category=Feature.Category.Config,
verbose=verbose,
indent=indent,
)
echo()
_echo_features(
features,
title="== Debug ==",
category=Feature.Category.Debug,
verbose=verbose,
indent=indent,
)
@click.command(name="feature")
@click.argument("name", required=False)
@click.argument("value", required=False)
@pass_dev_or_child
@click.pass_context
async def feature(
ctx: click.Context,
dev: Device,
name: str,
value,
):
"""Access and modify features.
If no *name* is given, lists available features and their values.
If only *name* is given, the value of named feature is returned.
If both *name* and *value* are set, the described setting is changed.
"""
verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False
if not name:
_echo_all_features(dev.features, verbose=verbose, indent="")
if dev.children:
for child_dev in dev.children:
_echo_all_features(
child_dev.features,
verbose=verbose,
title_prefix=f"Child {child_dev.alias}",
indent="\t",
)
return
if name not in dev.features:
error(f"No feature by name '{name}'")
return
feat = dev.features[name]
if value is None and feat.type is Feature.Type.Action:
echo(f"Executing action {name}")
response = await dev.features[name].set_value(value)
echo(response)
return response
if value is None:
unit = f" {feat.unit}" if feat.unit else ""
echo(f"{feat.name} ({name}): {feat.value}{unit}")
return feat.value
value = ast.literal_eval(value)
echo(f"Changing {name} from {feat.value} to {value}")
response = await dev.features[name].set_value(value)
await dev.update()
echo(f"New state: {feat.value}")
return response

72
kasa/cli/lazygroup.py Normal file
View File

@@ -0,0 +1,72 @@
"""Module for lazily instantiating sub modules.
Taken from the click help files.
"""
from __future__ import annotations
import importlib
import asyncclick as click
class LazyGroup(click.Group):
"""Lazy group class."""
def __init__(self, *args, lazy_subcommands=None, **kwargs) -> None:
super().__init__(*args, **kwargs)
# lazy_subcommands is a map of the form:
#
# {command-name} -> {module-name}.{command-object-name}
#
self.lazy_subcommands = lazy_subcommands or {}
def list_commands(self, ctx):
"""List click commands."""
base = super().list_commands(ctx)
lazy = list(self.lazy_subcommands.keys())
return lazy + base
def get_command(self, ctx, cmd_name):
"""Get click command."""
if cmd_name in self.lazy_subcommands:
return self._lazy_load(cmd_name)
return super().get_command(ctx, cmd_name)
def format_commands(self, ctx, formatter) -> None:
"""Format the top level help output."""
sections: dict[str, list] = {}
for cmd, parent in self.lazy_subcommands.items():
sections.setdefault(parent, [])
cmd_obj = self.get_command(ctx, cmd)
help = cmd_obj.get_short_help_str()
sections[parent].append((cmd, help))
for section in sections:
if section:
header = (
f"Common {section} commands (also available "
f"under the `{section}` subcommand)"
)
else:
header = "Subcommands"
with formatter.section(header):
formatter.write_dl(sections[section])
def _lazy_load(self, cmd_name):
# lazily loading a command, first get the module name and attribute name
if not (import_path := self.lazy_subcommands[cmd_name]):
import_path = f".{cmd_name}.{cmd_name}"
else:
import_path = f".{import_path}.{cmd_name}"
modname, cmd_object_name = import_path.rsplit(".", 1)
# do the import
mod = importlib.import_module(modname, package=__package__)
# get the Command object from that module
cmd_object = getattr(mod, cmd_object_name)
# check the result to make debugging easier
if not isinstance(cmd_object, click.BaseCommand):
raise ValueError(
f"Lazy loading of {cmd_name} failed by returning "
"a non-command object"
)
return cmd_object

221
kasa/cli/light.py Normal file
View File

@@ -0,0 +1,221 @@
"""Module for cli light control commands."""
import asyncclick as click
from kasa import (
Device,
Module,
)
from kasa.iot import (
IotBulb,
)
from .common import echo, error, pass_dev_or_child
@click.group()
@pass_dev_or_child
def light(dev) -> None:
"""Commands to control light settings."""
@light.command()
@click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False)
@click.option("--transition", type=int, required=False)
@pass_dev_or_child
async def brightness(dev: Device, brightness: int, transition: int):
"""Get or set brightness."""
if not (light := dev.modules.get(Module.Light)) or not light.has_feature(
"brightness"
):
error("This device does not support brightness.")
return
if brightness is None:
echo(f"Brightness: {light.brightness}")
return light.brightness
else:
echo(f"Setting brightness to {brightness}")
return await light.set_brightness(brightness, transition=transition)
@light.command()
@click.argument(
"temperature", type=click.IntRange(2500, 9000), default=None, required=False
)
@click.option("--transition", type=int, required=False)
@pass_dev_or_child
async def temperature(dev: Device, temperature: int, transition: int):
"""Get or set color temperature."""
if not (light := dev.modules.get(Module.Light)) or not (
color_temp_feat := light.get_feature("color_temp")
):
error("Device does not support color temperature")
return
if temperature is None:
echo(f"Color temperature: {light.color_temp}")
valid_temperature_range = color_temp_feat.range
if valid_temperature_range != (0, 0):
echo("(min: {}, max: {})".format(*valid_temperature_range))
else:
echo(
"Temperature range unknown, please open a github issue"
f" or a pull request for model '{dev.model}'"
)
return color_temp_feat.range
else:
echo(f"Setting color temperature to {temperature}")
return await light.set_color_temp(temperature, transition=transition)
@light.command()
@click.argument("effect", type=click.STRING, default=None, required=False)
@click.pass_context
@pass_dev_or_child
async def effect(dev: Device, ctx, effect):
"""Set an effect."""
if not (light_effect := dev.modules.get(Module.LightEffect)):
error("Device does not support effects")
return
if effect is None:
echo(
f"Light effect: {light_effect.effect}\n"
+ f"Available Effects: {light_effect.effect_list}"
)
return light_effect.effect
if effect not in light_effect.effect_list:
raise click.BadArgumentUsage(
f"Effect must be one of: {light_effect.effect_list}", ctx
)
echo(f"Setting Effect: {effect}")
return await light_effect.set_effect(effect)
@light.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_or_child
async def hsv(dev: Device, ctx, h, s, v, transition):
"""Get or set color in HSV."""
if not (light := dev.modules.get(Module.Light)) or not light.has_feature("hsv"):
error("Device does not support colors")
return
if h is None and s is None and v is None:
echo(f"Current HSV: {light.hsv}")
return light.hsv
elif s is None or v is None:
raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx)
else:
echo(f"Setting HSV: {h} {s} {v}")
return await light.set_hsv(h, s, v, transition=transition)
@light.group(invoke_without_command=True)
@pass_dev_or_child
@click.pass_context
async def presets(ctx, dev):
"""List and modify bulb setting presets."""
if ctx.invoked_subcommand is None:
return await ctx.invoke(presets_list)
@presets.command(name="list")
@pass_dev_or_child
def presets_list(dev: Device):
"""List presets."""
if not (light_preset := dev.modules.get(Module.LightPreset)):
error("Device does not support light presets")
return
for idx, preset in enumerate(light_preset.preset_states_list):
echo(
f"[{idx}] Hue: {preset.hue or '':3} "
f"Saturation: {preset.saturation or '':3} "
f"Brightness/Value: {preset.brightness or '':3} "
f"Temp: {preset.color_temp or '':4}"
)
return light_preset.preset_states_list
@presets.command(name="modify")
@click.argument("index", type=int)
@click.option("--brightness", type=int, required=False, default=None)
@click.option("--hue", type=int, required=False, default=None)
@click.option("--saturation", type=int, required=False, default=None)
@click.option("--temperature", type=int, required=False, default=None)
@pass_dev_or_child
async def presets_modify(dev: Device, index, brightness, hue, saturation, temperature):
"""Modify a preset."""
if not (light_preset := dev.modules.get(Module.LightPreset)):
error("Device does not support light presets")
return
max_index = len(light_preset.preset_states_list) - 1
if index > len(light_preset.preset_states_list) - 1:
error(f"Invalid index, must be between 0 and {max_index}")
return
if all([val is None for val in {brightness, hue, saturation, temperature}]):
error("Need to supply at least one option to modify.")
return
# Preset names have `Not set`` as the first value
preset_name = light_preset.preset_list[index + 1]
preset = light_preset.preset_states_list[index]
echo(f"Preset {preset_name} currently: {preset}")
if brightness is not None and preset.brightness is not None:
preset.brightness = brightness
if hue is not None and preset.hue is not None:
preset.hue = hue
if saturation is not None and preset.saturation is not None:
preset.saturation = saturation
if temperature is not None and preset.temperature is not None:
preset.color_temp = temperature
echo(f"Updating preset {preset_name} to: {preset}")
return await light_preset.save_preset(preset_name, preset)
@light.command()
@pass_dev_or_child
@click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False))
@click.option("--last", is_flag=True)
@click.option("--preset", type=int)
async def turn_on_behavior(dev: Device, type, last, preset):
"""Modify bulb turn-on behavior."""
if dev.device_type is not Device.Type.Bulb or not isinstance(dev, IotBulb):
error("Presets only supported on iot bulbs")
return
settings = await dev.get_turn_on_behavior()
echo(f"Current turn on behavior: {settings}")
# Return if we are not setting the value
if not type and not last and not preset:
return settings
# If we are setting the value, the type has to be specified
if (last or preset) and type is None:
echo("To set the behavior, you need to define --type")
return
behavior = getattr(settings, type)
if last:
echo(f"Going to set {type} to last")
behavior.preset = None
elif preset is not None:
echo(f"Going to set {type} to preset {preset}")
behavior.preset = preset
return await dev.set_turn_on_behavior(settings)

434
kasa/cli/main.py Executable file
View File

@@ -0,0 +1,434 @@
"""Main module for cli tool."""
from __future__ import annotations
import ast
import asyncio
import json
import logging
import sys
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
import asyncclick as click
if TYPE_CHECKING:
from kasa import Device
from kasa.deviceconfig import DeviceEncryptionType
from .common import (
SKIP_UPDATE_COMMANDS,
CatchAllExceptions,
echo,
error,
json_formatter_cb,
pass_dev_or_child,
)
from .lazygroup import LazyGroup
TYPES = [
"plug",
"switch",
"bulb",
"dimmer",
"strip",
"lightstrip",
"smart",
"camera",
]
ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType]
DEFAULT_TARGET = "255.255.255.255"
def _legacy_type_to_class(_type: str) -> Any:
from kasa.iot import (
IotBulb,
IotDimmer,
IotLightStrip,
IotPlug,
IotStrip,
IotWallSwitch,
)
TYPE_TO_CLASS = {
"plug": IotPlug,
"switch": IotWallSwitch,
"bulb": IotBulb,
"dimmer": IotDimmer,
"strip": IotStrip,
"lightstrip": IotLightStrip,
}
return TYPE_TO_CLASS[_type]
@click.group(
invoke_without_command=True,
cls=CatchAllExceptions(LazyGroup),
lazy_subcommands={
"discover": None,
"device": None,
"feature": None,
"light": None,
"wifi": None,
"time": None,
"schedule": None,
"usage": None,
"energy": "usage",
# device commands runnnable at top level
"state": "device",
"on": "device",
"off": "device",
"toggle": "device",
"led": "device",
"alias": "device",
"reboot": "device",
"update_credentials": "device",
"sysinfo": "device",
# light commands runnnable at top level
"presets": "light",
"brightness": "light",
"hsv": "light",
"temperature": "light",
"effect": "light",
},
result_callback=json_formatter_cb,
)
@click.option(
"--host",
envvar="KASA_HOST",
required=False,
help="The host name or IP address of the device to connect to.",
)
@click.option(
"--port",
envvar="KASA_PORT",
required=False,
type=int,
help="The port of the device to connect to.",
)
@click.option(
"--alias",
envvar="KASA_NAME",
required=False,
help="The device name, or alias, of the device to connect to.",
)
@click.option(
"--target",
envvar="KASA_TARGET",
default=DEFAULT_TARGET,
required=False,
show_default=True,
help="The broadcast address to be used for discovery.",
)
@click.option(
"-v",
"--verbose",
envvar="KASA_VERBOSE",
required=False,
default=False,
is_flag=True,
help="Be more verbose on output",
)
@click.option(
"-d",
"--debug",
envvar="KASA_DEBUG",
default=False,
is_flag=True,
help="Print debug output",
)
@click.option(
"--type",
envvar="KASA_TYPE",
default=None,
type=click.Choice(TYPES, case_sensitive=False),
help="The device type in order to bypass discovery. Use `smart` for newer devices",
)
@click.option(
"--json/--no-json",
envvar="KASA_JSON",
default=False,
is_flag=True,
help="Output raw device response as JSON.",
)
@click.option(
"-e",
"--encrypt-type",
envvar="KASA_ENCRYPT_TYPE",
default=None,
type=click.Choice(ENCRYPT_TYPES, case_sensitive=False),
)
@click.option(
"-df",
"--device-family",
envvar="KASA_DEVICE_FAMILY",
default="SMART.TAPOPLUG",
help="Device family type, e.g. `SMART.KASASWITCH`. Deprecated use `--type smart`",
)
@click.option(
"-lv",
"--login-version",
envvar="KASA_LOGIN_VERSION",
default=2,
type=int,
help="The login version for device authentication. Defaults to 2",
)
@click.option(
"--https/--no-https",
envvar="KASA_HTTPS",
default=False,
is_flag=True,
type=bool,
help="Set flag if the device encryption uses https.",
)
@click.option(
"--timeout",
envvar="KASA_TIMEOUT",
default=5,
required=False,
show_default=True,
help="Timeout for device communications.",
)
@click.option(
"--discovery-timeout",
envvar="KASA_DISCOVERY_TIMEOUT",
default=10,
required=False,
show_default=True,
help="Timeout for discovery.",
)
@click.option(
"--username",
default=None,
required=False,
envvar="KASA_USERNAME",
help="Username/email address to authenticate to device.",
)
@click.option(
"--password",
default=None,
required=False,
envvar="KASA_PASSWORD",
help="Password to use to authenticate to device.",
)
@click.option(
"--credentials-hash",
default=None,
required=False,
envvar="KASA_CREDENTIALS_HASH",
help="Hashed credentials used to authenticate to the device.",
)
@click.version_option(package_name="python-kasa")
@click.pass_context
async def cli(
ctx,
host,
port,
alias,
target,
verbose,
debug,
type,
encrypt_type,
https,
device_family,
login_version,
json,
timeout,
discovery_timeout,
username,
password,
credentials_hash,
):
"""A tool for controlling TP-Link smart home devices.""" # noqa
# no need to perform any checks if we are just displaying the help
if "--help" in sys.argv:
# Context object is required to avoid crashing on sub-groups
ctx.obj = object()
return
if target != DEFAULT_TARGET and host:
error("--target is not a valid option for single host discovery")
logging_config: dict[str, Any] = {
"level": logging.DEBUG if debug > 0 else logging.INFO
}
try:
from rich.logging import RichHandler
rich_config = {
"show_time": False,
}
logging_config["handlers"] = [RichHandler(**rich_config)]
logging_config["format"] = "%(message)s"
except ImportError:
pass
# The configuration should be converted to use dictConfig,
# but this keeps mypy happy for now
logging.basicConfig(**logging_config) # type: ignore
if ctx.invoked_subcommand == "discover":
return
if alias is not None and host is not None:
raise click.BadOptionUsage("alias", "Use either --alias or --host, not both.")
if bool(password) != bool(username):
raise click.BadOptionUsage(
"username", "Using authentication requires both --username and --password"
)
if username:
from kasa.credentials import Credentials
credentials = Credentials(username=username, password=password)
else:
credentials = None
if host is None and alias is None:
if ctx.invoked_subcommand and ctx.invoked_subcommand != "discover":
error("Only discover is available without --host or --alias")
echo("No host name given, trying discovery..")
from .discover import discover
return await ctx.invoke(discover)
device_updated = False
if type is not None and type not in {"smart", "camera"}:
from kasa.deviceconfig import DeviceConfig
config = DeviceConfig(host=host, port_override=port, timeout=timeout)
dev = _legacy_type_to_class(type)(host, config=config)
elif type in {"smart", "camera"} or (device_family and encrypt_type):
if type == "camera":
encrypt_type = "AES"
https = True
login_version = 2
device_family = "SMART.IPCAMERA"
from kasa.device import Device
from kasa.deviceconfig import (
DeviceConfig,
DeviceConnectionParameters,
DeviceEncryptionType,
DeviceFamily,
)
if not encrypt_type:
encrypt_type = "KLAP"
ctype = DeviceConnectionParameters(
DeviceFamily(device_family),
DeviceEncryptionType(encrypt_type),
login_version,
https,
)
config = DeviceConfig(
host=host,
port_override=port,
credentials=credentials,
credentials_hash=credentials_hash,
timeout=timeout,
connection_type=ctype,
)
dev = await Device.connect(config=config)
device_updated = True
elif alias:
echo(f"Alias is given, using discovery to find host {alias}")
from .discover import find_dev_from_alias
dev = await find_dev_from_alias(
alias=alias, target=target, credentials=credentials
)
if not dev:
echo(f"No device with name {alias} found")
return
echo(f"Found hostname by alias: {dev.host}")
device_updated = True
else:
from .discover import discover
dev = await ctx.invoke(discover)
if not dev:
error(f"Unable to create device for {host}")
# Skip update on specific commands, or if device factory,
# that performs an update was used for the device.
if ctx.invoked_subcommand not in SKIP_UPDATE_COMMANDS and not device_updated:
await dev.update()
@asynccontextmanager
async def async_wrapped_device(device: Device):
try:
yield device
finally:
await device.disconnect()
ctx.obj = await ctx.with_async_resource(async_wrapped_device(dev))
if ctx.invoked_subcommand is None:
from .device import state
return await ctx.invoke(state)
@cli.command()
@pass_dev_or_child
async def shell(dev: Device) -> None:
"""Open interactive shell."""
echo(f"Opening shell for {dev}")
from ptpython.repl import embed
logging.getLogger("parso").setLevel(logging.WARNING) # prompt parsing
logging.getLogger("asyncio").setLevel(logging.WARNING)
loop = asyncio.get_event_loop()
try:
await embed( # type: ignore[func-returns-value]
globals=globals(),
locals=locals(),
return_asyncio_coroutine=True,
patch_stdout=True,
)
except EOFError:
loop.stop()
@cli.command()
@click.pass_context
@click.argument("module")
@click.argument("command")
@click.argument("parameters", default=None, required=False)
async def raw_command(ctx, module, command, parameters):
"""Run a raw command on the device."""
logging.warning("Deprecated, use 'kasa command --module %s %s'", module, command)
return await ctx.forward(cmd_command)
@cli.command(name="command")
@click.option("--module", required=False, help="Module for IOT protocol.")
@click.argument("command")
@click.argument("parameters", default=None, required=False)
@pass_dev_or_child
async def cmd_command(dev: Device, module, command, parameters):
"""Run a raw command on the device."""
if parameters is not None:
parameters = ast.literal_eval(parameters)
from kasa import KasaException
from kasa.iot import IotDevice
from kasa.smart import SmartDevice
if isinstance(dev, IotDevice):
res = await dev._query_helper(module, command, parameters)
elif isinstance(dev, SmartDevice):
res = await dev._query_helper(command, parameters)
else:
raise KasaException("Unexpected device type %s.", dev)
echo(json.dumps(res))
return res

46
kasa/cli/schedule.py Normal file
View File

@@ -0,0 +1,46 @@
"""Module for cli schedule commands.."""
from __future__ import annotations
import asyncclick as click
from .common import (
echo,
error,
pass_dev,
pass_dev_or_child,
)
@click.group()
@pass_dev
async def schedule(dev) -> None:
"""Scheduling commands."""
@schedule.command(name="list")
@pass_dev_or_child
@click.argument("type", default="schedule")
async def _schedule_list(dev, type):
"""Return the list of schedule actions for the given type."""
sched = dev.modules[type]
for rule in sched.rules:
print(rule)
else:
error(f"No rules of type {type}")
return sched.rules
@schedule.command(name="delete")
@pass_dev_or_child
@click.option("--id", type=str, required=True)
async def delete_rule(dev, id):
"""Delete rule from device."""
schedule = dev.modules["schedule"]
rule_to_delete = next(filter(lambda rule: (rule.id == id), schedule.rules), None)
if rule_to_delete:
echo(f"Deleting rule id {id}")
return await schedule.delete_rule(rule_to_delete)
else:
error(f"No rule with id {id} was found")

160
kasa/cli/time.py Normal file
View File

@@ -0,0 +1,160 @@
"""Module for cli time commands.."""
from __future__ import annotations
import zoneinfo
from datetime import datetime
import asyncclick as click
from kasa import (
Device,
Module,
)
from kasa.iot import IotDevice
from kasa.iot.iottimezone import get_matching_timezones
from .common import (
echo,
error,
pass_dev,
)
@click.group(invoke_without_command=True)
@click.pass_context
async def time(ctx: click.Context) -> None:
"""Get and set time."""
if ctx.invoked_subcommand is None:
await ctx.invoke(time_get)
@time.command(name="get")
@pass_dev
async def time_get(dev: Device):
"""Get the device time."""
res = dev.time
echo(f"Current time: {dev.time} ({dev.timezone})")
return res
@time.command(name="sync")
@click.option(
"--timezone",
type=str,
required=False,
default=None,
help="IANA timezone name, will use current device timezone if not provided.",
)
@click.option(
"--skip-confirm",
type=str,
required=False,
default=False,
is_flag=True,
help="Do not ask to confirm the timezone if an exact match is not found.",
)
@pass_dev
async def time_sync(dev: Device, timezone: str | None, skip_confirm: bool):
"""Set the device time to current time."""
if (time := dev.modules.get(Module.Time)) is None:
echo("Device does not have time module")
return
now = datetime.now()
tzinfo: zoneinfo.ZoneInfo | None = None
if timezone:
tzinfo = await _get_timezone(dev, timezone, skip_confirm)
if tzinfo.utcoffset(now) != now.astimezone().utcoffset():
error(
f"{timezone} has a different utc offset to local time,"
+ "syncing will produce unexpected results."
)
now = now.replace(tzinfo=tzinfo)
echo(f"Old time: {time.time} ({time.timezone})")
await time.set_time(now)
await dev.update()
echo(f"New time: {time.time} ({time.timezone})")
@time.command(name="set")
@click.argument("year", type=int)
@click.argument("month", type=int)
@click.argument("day", type=int)
@click.argument("hour", type=int)
@click.argument("minute", type=int)
@click.argument("seconds", type=int, required=False, default=0)
@click.option(
"--timezone",
type=str,
required=False,
default=None,
help="IANA timezone name, will use current device timezone if not provided.",
)
@click.option(
"--skip-confirm",
type=bool,
required=False,
default=False,
is_flag=True,
help="Do not ask to confirm the timezone if an exact match is not found.",
)
@pass_dev
async def time_set(
dev: Device,
year: int,
month: int,
day: int,
hour: int,
minute: int,
seconds: int,
timezone: str | None,
skip_confirm: bool,
):
"""Set the device time to the provided time."""
if (time := dev.modules.get(Module.Time)) is None:
echo("Device does not have time module")
return
tzinfo: zoneinfo.ZoneInfo | None = None
if timezone:
tzinfo = await _get_timezone(dev, timezone, skip_confirm)
echo(f"Old time: {time.time} ({time.timezone})")
await time.set_time(datetime(year, month, day, hour, minute, seconds, 0, tzinfo))
await dev.update()
echo(f"New time: {time.time} ({time.timezone})")
async def _get_timezone(dev, timezone, skip_confirm) -> zoneinfo.ZoneInfo:
"""Get the tzinfo from the timezone or return none."""
tzinfo: zoneinfo.ZoneInfo | None = None
if timezone not in zoneinfo.available_timezones():
error(f"{timezone} is not a valid IANA timezone.")
tzinfo = zoneinfo.ZoneInfo(timezone)
if skip_confirm is False and isinstance(dev, IotDevice):
matches = await get_matching_timezones(tzinfo)
if not matches:
error(f"Device cannot support {timezone} timezone.")
first = matches[0]
msg = (
f"An exact match for {timezone} could not be found, "
+ f"timezone will be set to {first}"
)
if len(matches) == 1:
click.confirm(msg, abort=True)
else:
msg = (
f"Supported timezones matching {timezone} are {', '.join(matches)}\n"
+ msg
)
click.confirm(msg, abort=True)
return tzinfo

113
kasa/cli/usage.py Normal file
View File

@@ -0,0 +1,113 @@
"""Module for cli usage commands.."""
from __future__ import annotations
from typing import cast
import asyncclick as click
from kasa import (
Device,
Module,
)
from kasa.interfaces import Energy
from kasa.iot.modules import Usage
from .common import (
echo,
error,
pass_dev_or_child,
)
@click.command()
@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False)
@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False)
@click.option("--erase", is_flag=True)
@pass_dev_or_child
async def energy(dev: Device, year, month, erase):
"""Query energy module for historical consumption.
Daily and monthly data provided in CSV format.
"""
echo("[bold]== Energy ==[/bold]")
if not (energy := dev.modules.get(Module.Energy)):
error("Device has no energy module.")
return
if (year or month or erase) and not energy.supports(
Energy.ModuleFeature.PERIODIC_STATS
):
error("Device does not support historical statistics")
return
if erase:
echo("Erasing emeter statistics..")
return await energy.erase_stats()
if year:
echo(f"== For year {year.year} ==")
echo("Month, usage (kWh)")
usage_data = await energy.get_monthly_stats(year=year.year)
elif month:
echo(f"== For month {month.month} of {month.year} ==")
echo("Day, usage (kWh)")
usage_data = await energy.get_daily_stats(year=month.year, month=month.month)
else:
# Call with no argument outputs summary data and returns
emeter_status = energy.status
echo("Current: {} A".format(emeter_status["current"]))
echo("Voltage: {} V".format(emeter_status["voltage"]))
echo("Power: {} W".format(emeter_status["power"]))
echo("Total consumption: {} kWh".format(emeter_status["total"]))
echo(f"Today: {energy.consumption_today} kWh")
echo(f"This month: {energy.consumption_this_month} kWh")
return emeter_status
# output any detailed usage data
for index, usage in usage_data.items():
echo(f"{index}, {usage}")
return usage_data
@click.command()
@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False)
@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False)
@click.option("--erase", is_flag=True)
@pass_dev_or_child
async def usage(dev: Device, year, month, erase):
"""Query usage for historical consumption.
Daily and monthly data provided in CSV format.
"""
echo("[bold]== Usage ==[/bold]")
usage = cast(Usage, dev.modules["usage"])
if erase:
echo("Erasing usage statistics..")
return await usage.erase_stats()
if year:
echo(f"== For year {year.year} ==")
echo("Month, usage (minutes)")
usage_data = await usage.get_monthstat(year=year.year)
elif month:
echo(f"== For month {month.month} of {month.year} ==")
echo("Day, usage (minutes)")
usage_data = await usage.get_daystat(year=month.year, month=month.month)
else:
# Call with no argument outputs summary data and returns
echo(f"Today: {usage.usage_today} minutes")
echo(f"This month: {usage.usage_this_month} minutes")
return usage
# output any detailed usage data
for index, usage in usage_data.items():
echo(f"{index}, {usage}")
return usage_data

50
kasa/cli/wifi.py Normal file
View File

@@ -0,0 +1,50 @@
"""Module for cli wifi commands."""
from __future__ import annotations
import asyncclick as click
from kasa import (
Device,
)
from .common import (
echo,
pass_dev,
)
@click.group()
@pass_dev
def wifi(dev) -> None:
"""Commands to control wifi settings."""
@wifi.command()
@pass_dev
async def scan(dev):
"""Scan for available wifi networks."""
echo("Scanning for wifi networks, wait a second..")
devs = await dev.wifi_scan()
echo(f"Found {len(devs)} wifi networks!")
for dev in devs:
echo(f"\t {dev}")
return devs
@wifi.command()
@click.argument("ssid")
@click.option("--keytype", prompt=True)
@click.option("--password", prompt=True, hide_input=True)
@pass_dev
async def join(dev: Device, ssid: str, password: str, keytype: str):
"""Join the given wifi network."""
echo(f"Asking the device to connect to {ssid}..")
res = await dev.wifi_join(ssid, password, keytype=keytype)
echo(
f"Response: {res} - if the device is not able to join the network, "
f"it will revert back to its previous state."
)
return res

View File

@@ -1,5 +1,8 @@
"""Credentials class for username / passwords."""
from __future__ import annotations
import base64
from dataclasses import dataclass, field
@@ -11,3 +14,18 @@ class Credentials:
username: str = field(default="", repr=False)
#: Password of the cloud account
password: str = field(default="", repr=False)
def get_default_credentials(tuple: tuple[str, str]) -> Credentials:
"""Return decoded default credentials."""
un = base64.b64decode(tuple[0].encode()).decode()
pw = base64.b64decode(tuple[1].encode()).decode()
return Credentials(un, pw)
DEFAULT_CREDENTIALS = {
"KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"),
"KASACAMERA": ("YWRtaW4=", "MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM="),
"TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="),
"TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="),
}

602
kasa/device.py Normal file
View File

@@ -0,0 +1,602 @@
"""Interact with TPLink Smart Home devices.
Once you have a device via :ref:`Discovery <discover_target>` or
:ref:`Connect <connect_target>` you can start interacting with a device.
>>> from kasa import Discover
>>>
>>> dev = await Discover.discover_single(
>>> "127.0.0.2",
>>> username="user@example.com",
>>> password="great_password"
>>> )
>>>
Most devices can be turned on and off
>>> await dev.turn_on()
>>> await dev.update()
>>> print(dev.is_on)
True
>>> await dev.turn_off()
>>> await dev.update()
>>> print(dev.is_on)
False
All devices provide several informational properties:
>>> dev.alias
Bedroom Lamp Plug
>>> dev.model
HS110
>>> dev.rssi
-71
>>> dev.mac
50:C7:BF:00:00:00
Some information can also be changed programmatically:
>>> await dev.set_alias("new alias")
>>> await dev.update()
>>> dev.alias
new alias
Devices support different functionality that are exposed via
:ref:`modules <module_target>` that you can access via :attr:`~kasa.Device.modules`:
>>> for module_name in dev.modules:
>>> print(module_name)
Energy
schedule
usage
anti_theft
Time
cloud
Led
>>> led_module = dev.modules["Led"]
>>> print(led_module.led)
False
>>> await led_module.set_led(True)
>>> await dev.update()
>>> print(led_module.led)
True
Individual pieces of functionality are also exposed via :ref:`features <feature_target>`
which you can access via :attr:`~kasa.Device.features` and will only be present if
they are supported.
Features are similar to modules in that they provide functionality that may or may
not be present.
Whereas modules group functionality into a common interface, features expose a single
function that may or may not be part of a module.
The advantage of features is that they have a simple common interface of `id`, `name`,
`value` and `set_value` so no need to learn the module API.
They are useful if you want write code that dynamically adapts as new features are
added to the API.
>>> for feature_name in dev.features:
>>> print(feature_name)
state
rssi
on_since
reboot
current_consumption
consumption_today
consumption_this_month
consumption_total
voltage
current
cloud_connection
led
>>> led_feature = dev.features["led"]
>>> print(led_feature.value)
True
>>> await led_feature.set_value(False)
>>> await dev.update()
>>> print(led_feature.value)
False
"""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, tzinfo
from typing import TYPE_CHECKING, Any, TypeAlias
from warnings import warn
from .credentials import Credentials as _Credentials
from .device_type import DeviceType
from .deviceconfig import (
DeviceConfig,
DeviceConnectionParameters,
DeviceEncryptionType,
DeviceFamily,
)
from .exceptions import KasaException
from .feature import Feature
from .module import Module
from .protocols import BaseProtocol, IotProtocol
from .transports import XorTransport
if TYPE_CHECKING:
from .modulemapping import ModuleMapping, ModuleName
@dataclass
class WifiNetwork:
"""Wifi network container."""
ssid: str
key_type: int
# These are available only on softaponboarding
cipher_type: int | None = None
bssid: str | None = None
channel: int | None = None
rssi: int | None = None
# For SMART devices
signal_level: int | None = None
_LOGGER = logging.getLogger(__name__)
@dataclass
class DeviceInfo:
"""Device Model Information."""
short_name: str
long_name: str
brand: str
device_family: str
device_type: DeviceType
hardware_version: str
firmware_version: str
firmware_build: str
requires_auth: bool
region: str | None
class Device(ABC):
"""Common device interface.
Do not instantiate this class directly, instead get a device instance from
:func:`Device.connect()`, :func:`Discover.discover()`
or :func:`Discover.discover_single()`.
"""
# All types required to create devices directly via connect are aliased here
# to avoid consumers having to do multiple imports.
#: The type of device
Type: TypeAlias = DeviceType
#: The credentials for authentication
Credentials: TypeAlias = _Credentials
#: Configuration for connecting to the device
Config: TypeAlias = DeviceConfig
#: The family of the device, e.g. SMART.KASASWITCH.
Family: TypeAlias = DeviceFamily
#: The encryption for the device, e.g. Klap or Aes
EncryptionType: TypeAlias = DeviceEncryptionType
#: The connection type for the device.
ConnectionParameters: TypeAlias = DeviceConnectionParameters
def __init__(
self,
host: str,
*,
config: DeviceConfig | None = None,
protocol: BaseProtocol | None = None,
) -> None:
"""Create a new Device instance.
:param str host: host name or IP address of the device
:param DeviceConfig config: device configuration
:param BaseProtocol protocol: protocol for communicating with the device
"""
if config and protocol:
protocol._transport._config = config
self.protocol: BaseProtocol = protocol or IotProtocol(
transport=XorTransport(config=config or DeviceConfig(host=host)),
)
self._last_update: dict[str, Any] = {}
_LOGGER.debug("Initializing %s of type %s", host, type(self))
self._device_type = DeviceType.Unknown
# TODO: typing Any is just as using dict | None would require separate
# checks in accessors. the @updated_required decorator does not ensure
# mypy that these are not accessed incorrectly.
self._discovery_info: dict[str, Any] | None = None
self._features: dict[str, Feature] = {}
self._parent: Device | None = None
self._children: Mapping[str, Device] = {}
@staticmethod
async def connect(
*,
host: str | None = None,
config: DeviceConfig | None = None,
) -> Device:
"""Connect to a single device by the given hostname or device configuration.
This method avoids the UDP based discovery process and
will connect directly to the device.
It is generally preferred to avoid :func:`discover_single()` and
use this function instead as it should perform better when
the WiFi network is congested or the device is not responding
to discovery requests.
:param host: Hostname of device to query
:param config: Connection parameters to ensure the correct protocol
and connection options are used.
:rtype: SmartDevice
:return: Object for querying/controlling found device.
"""
from .device_factory import connect # pylint: disable=import-outside-toplevel
return await connect(host=host, config=config) # type: ignore[arg-type]
@abstractmethod
async def update(self, update_children: bool = True) -> None:
"""Update the device."""
async def disconnect(self) -> None:
"""Disconnect and close any underlying connection resources."""
await self.protocol.close()
@property
@abstractmethod
def modules(self) -> ModuleMapping[Module]:
"""Return the device modules."""
@property
@abstractmethod
def is_on(self) -> bool:
"""Return true if the device is on."""
@property
def is_off(self) -> bool:
"""Return True if device is off."""
return not self.is_on
@abstractmethod
async def turn_on(self, **kwargs) -> dict:
"""Turn on the device."""
@abstractmethod
async def turn_off(self, **kwargs) -> dict:
"""Turn off the device."""
@abstractmethod
async def set_state(self, on: bool) -> dict:
"""Set the device state to *on*.
This allows turning the device on and off.
See also *turn_off* and *turn_on*.
"""
@property
def host(self) -> str:
"""The device host."""
return self.protocol._transport._host
@host.setter
def host(self, value: str) -> None:
"""Set the device host.
Generally used by discovery to set the hostname after ip discovery.
"""
self.protocol._transport._host = value
self.protocol._transport._config.host = value
@property
def port(self) -> int:
"""The device port."""
return self.protocol._transport._port
@property
def credentials(self) -> _Credentials | None:
"""The device credentials."""
return self.protocol._transport._credentials
@property
def credentials_hash(self) -> str | None:
"""The protocol specific hash of the credentials the device is using."""
return self.protocol._transport.credentials_hash
@property
def device_type(self) -> DeviceType:
"""Return the device type."""
return self._device_type
@abstractmethod
def update_from_discover_info(self, info: dict) -> None:
"""Update state from info from the discover call."""
@property
def config(self) -> DeviceConfig:
"""Return the device configuration."""
return self.protocol.config
@property
@abstractmethod
def model(self) -> str:
"""Returns the device model."""
@property
def region(self) -> str | None:
"""Returns the device region."""
return self.device_info.region
@property
def device_info(self) -> DeviceInfo:
"""Return device info."""
return self._get_device_info(self._last_update, self._discovery_info)
@staticmethod
@abstractmethod
def _get_device_info(
info: dict[str, Any], discovery_info: dict[str, Any] | None
) -> DeviceInfo:
"""Get device info."""
@property
@abstractmethod
def alias(self) -> str | None:
"""Returns the device alias or nickname."""
async def _raw_query(self, request: str | dict) -> dict:
"""Send a raw query to the device."""
return await self.protocol.query(request=request)
@property
def parent(self) -> Device | None:
"""Return the parent on child devices."""
return self._parent
@property
def children(self) -> Sequence[Device]:
"""Returns the child devices."""
return list(self._children.values())
def get_child_device(self, name_or_id: str) -> Device | None:
"""Return child device by its device_id or alias."""
if name_or_id in self._children:
return self._children[name_or_id]
name_lower = name_or_id.lower()
for child in self.children:
if child.alias and child.alias.lower() == name_lower:
return child
return None
@property
@abstractmethod
def sys_info(self) -> dict[str, Any]:
"""Returns the device info."""
def get_plug_by_name(self, name: str) -> Device:
"""Return child device for the given name."""
for p in self.children:
if p.alias == name:
return p
raise KasaException(f"Device has no child with {name}")
def get_plug_by_index(self, index: int) -> Device:
"""Return child device for the given index."""
if index + 1 > len(self.children) or index < 0:
raise KasaException(
f"Invalid index {index}, device has {len(self.children)} plugs"
)
return self.children[index]
@property
@abstractmethod
def time(self) -> datetime:
"""Return the time."""
@property
@abstractmethod
def timezone(self) -> tzinfo:
"""Return the timezone and time_difference."""
@property
@abstractmethod
def hw_info(self) -> dict:
"""Return hardware info for the device."""
@property
@abstractmethod
def location(self) -> dict:
"""Return the device location."""
@property
@abstractmethod
def rssi(self) -> int | None:
"""Return the rssi."""
@property
@abstractmethod
def mac(self) -> str:
"""Return the mac formatted with colons."""
@property
@abstractmethod
def device_id(self) -> str:
"""Return the device id."""
@property
@abstractmethod
def internal_state(self) -> dict:
"""Return all the internal state data."""
@property
def state_information(self) -> dict[str, Any]:
"""Return available features and their values."""
return {feat.name: feat.value for feat in self._features.values()}
@property
def features(self) -> dict[str, Feature]:
"""Return the list of supported features."""
return self._features
def _add_feature(self, feature: Feature) -> None:
"""Add a new feature to the device."""
if feature.id in self._features:
raise KasaException(f"Duplicate feature id {feature.id}")
assert feature.id is not None # TODO: hack for typing # noqa: S101
self._features[feature.id] = feature
@property
@abstractmethod
def has_emeter(self) -> bool:
"""Return if the device has emeter."""
@property
@abstractmethod
def on_since(self) -> datetime | None:
"""Return the time that the device was turned on or None if turned off.
This returns a cached value if the device reported value difference is under
five seconds to avoid device-caused jitter.
"""
@abstractmethod
async def wifi_scan(self) -> list[WifiNetwork]:
"""Scan for available wifi networks."""
@abstractmethod
async def wifi_join(
self, ssid: str, password: str, keytype: str = "wpa2_psk"
) -> dict:
"""Join the given wifi network."""
@abstractmethod
async def set_alias(self, alias: str) -> dict:
"""Set the device name (alias)."""
@abstractmethod
async def reboot(self, delay: int = 1) -> None:
"""Reboot the device.
Note that giving a delay of zero causes this to block,
as the device reboots immediately without responding to the call.
"""
@abstractmethod
async def factory_reset(self) -> None:
"""Reset device back to factory settings.
Note, this does not downgrade the firmware.
"""
def __repr__(self) -> str:
update_needed = " - update() needed" if not self._last_update else ""
if not self._last_update and not self._discovery_info:
return f"<{self.device_type} at {self.host}{update_needed}>"
return (
f"<{self.device_type} at {self.host} -"
f" {self.alias} ({self.model}){update_needed}>"
)
_deprecated_device_type_attributes = {
# is_type
"is_bulb": (None, DeviceType.Bulb),
"is_dimmer": (None, DeviceType.Dimmer),
"is_light_strip": (None, DeviceType.LightStrip),
"is_plug": (None, DeviceType.Plug),
"is_wallswitch": (None, DeviceType.WallSwitch),
"is_strip": (None, DeviceType.Strip),
"is_strip_socket": (None, DeviceType.StripSocket),
}
def _get_replacing_attr(
self, module_name: ModuleName | None, *attrs: Any
) -> str | None:
# If module name is None check self
if not module_name:
check = self
elif (check := self.modules.get(module_name)) is None:
return None
for attr in attrs:
# Use dir() as opposed to hasattr() to avoid raising exceptions
# from properties
if attr in dir(check):
return attr
return None
_deprecated_other_attributes = {
# light attributes
"is_color": (Module.Light, ["is_color"]),
"is_dimmable": (Module.Light, ["is_dimmable"]),
"is_variable_color_temp": (Module.Light, ["is_variable_color_temp"]),
"brightness": (Module.Light, ["brightness"]),
"set_brightness": (Module.Light, ["set_brightness"]),
"hsv": (Module.Light, ["hsv"]),
"set_hsv": (Module.Light, ["set_hsv"]),
"color_temp": (Module.Light, ["color_temp"]),
"set_color_temp": (Module.Light, ["set_color_temp"]),
"valid_temperature_range": (Module.Light, ["valid_temperature_range"]),
"has_effects": (Module.Light, ["has_effects"]),
"_deprecated_set_light_state": (Module.Light, ["has_effects"]),
# led attributes
"led": (Module.Led, ["led"]),
"set_led": (Module.Led, ["set_led"]),
# light effect attributes
# The return values for effect is a str instead of dict so the lightstrip
# modules have a _deprecated method to return the value as before.
"effect": (Module.LightEffect, ["_deprecated_effect", "effect"]),
# The return values for effect_list includes the Off effect so the lightstrip
# modules have a _deprecated method to return the values as before.
"effect_list": (Module.LightEffect, ["_deprecated_effect_list", "effect_list"]),
"set_effect": (Module.LightEffect, ["set_effect"]),
"set_custom_effect": (Module.LightEffect, ["set_custom_effect"]),
# light preset attributes
"presets": (Module.LightPreset, ["_deprecated_presets", "preset_states_list"]),
"save_preset": (Module.LightPreset, ["_deprecated_save_preset"]),
# Emeter attribues
"get_emeter_realtime": (Module.Energy, ["get_status"]),
"emeter_realtime": (Module.Energy, ["status"]),
"emeter_today": (Module.Energy, ["consumption_today"]),
"emeter_this_month": (Module.Energy, ["consumption_this_month"]),
"current_consumption": (Module.Energy, ["current_consumption"]),
"get_emeter_daily": (Module.Energy, ["get_daily_stats"]),
"get_emeter_monthly": (Module.Energy, ["get_monthly_stats"]),
# Other attributes
"supported_modules": (None, ["modules"]),
}
if not TYPE_CHECKING:
def __getattr__(self, name: str) -> Any:
# is_device_type
if dep_device_type_attr := self._deprecated_device_type_attributes.get(
name
):
msg = f"{name} is deprecated, use device_type property instead"
warn(msg, DeprecationWarning, stacklevel=2)
return self.device_type == dep_device_type_attr[1]
# Other deprecated attributes
if (dep_attr := self._deprecated_other_attributes.get(name)) and (
(replacing_attr := self._get_replacing_attr(dep_attr[0], *dep_attr[1]))
is not None
):
mod = dep_attr[0]
dev_or_mod = self.modules[mod] if mod else self
replacing = f"Module.{mod} in device.modules" if mod else replacing_attr
msg = f"{name} is deprecated, use: {replacing} instead"
warn(msg, DeprecationWarning, stacklevel=2)
return getattr(dev_or_mod, replacing_attr)
raise AttributeError(f"Device has no attribute {name!r}")

230
kasa/device_factory.py Executable file → Normal file
View File

@@ -1,36 +1,52 @@
"""Device creation via DeviceConfig."""
from __future__ import annotations
import logging
import time
from typing import Any, Dict, Optional, Tuple, Type
from typing import Any
from .aestransport import AesTransport
from .deviceconfig import DeviceConfig
from .exceptions import SmartDeviceException, UnsupportedDeviceException
from .iotprotocol import IotProtocol
from .klaptransport import KlapTransport, KlapTransportV2
from .protocol import (
BaseTransport,
TPLinkProtocol,
TPLinkSmartHomeProtocol,
_XorTransport,
from .device import Device
from .device_type import DeviceType
from .deviceconfig import DeviceConfig, DeviceFamily
from .exceptions import KasaException, UnsupportedDeviceError
from .iot import (
IotBulb,
IotCamera,
IotDevice,
IotDimmer,
IotLightStrip,
IotPlug,
IotStrip,
IotWallSwitch,
)
from .smartbulb import SmartBulb
from .smartdevice import SmartDevice
from .smartdimmer import SmartDimmer
from .smartlightstrip import SmartLightStrip
from .smartplug import SmartPlug
from .smartprotocol import SmartProtocol
from .smartstrip import SmartStrip
from .tapo import TapoBulb, TapoPlug
from .protocols import (
BaseProtocol,
IotProtocol,
SmartProtocol,
)
from .protocols.smartcamprotocol import SmartCamProtocol
from .smart import SmartDevice
from .smartcam import SmartCamDevice
from .transports import (
AesTransport,
BaseTransport,
KlapTransport,
KlapTransportV2,
LinkieTransportV2,
SslTransport,
XorTransport,
)
from .transports.sslaestransport import SslAesTransport
_LOGGER = logging.getLogger(__name__)
GET_SYSINFO_QUERY = {
"system": {"get_sysinfo": None},
GET_SYSINFO_QUERY: dict[str, dict[str, dict]] = {
"system": {"get_sysinfo": {}},
}
async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "SmartDevice":
async def connect(*, host: str | None = None, config: DeviceConfig) -> Device:
"""Connect to a single device by the given hostname or device configuration.
This method avoids the UDP based discovery process and
@@ -50,33 +66,48 @@ async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Smart
:return: Object for querying/controlling found device.
"""
if host and config or (not host and not config):
raise SmartDeviceException("One of host or config must be provded and not both")
raise KasaException("One of host or config must be provded and not both")
if host:
config = DeviceConfig(host=host)
if (protocol := get_protocol(config=config)) is None:
raise UnsupportedDeviceError(
f"Unsupported device for {config.host}: "
+ f"{config.connection_type.device_family.value}",
host=config.host,
)
try:
return await _connect(config, protocol)
except:
await protocol.close()
raise
async def _connect(config: DeviceConfig, protocol: BaseProtocol) -> Device:
debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)
if debug_enabled:
start_time = time.perf_counter()
def _perf_log(has_params, perf_type):
def _perf_log(has_params: bool, perf_type: str) -> None:
nonlocal start_time
if debug_enabled:
end_time = time.perf_counter()
_LOGGER.debug(
f"Device {config.host} with connection params {has_params} "
+ f"took {end_time - start_time:.2f} seconds to {perf_type}",
"Device %s with connection params %s took %.2f seconds to %s",
config.host,
has_params,
end_time - start_time,
perf_type,
)
start_time = time.perf_counter()
if (protocol := get_protocol(config=config)) is None:
raise UnsupportedDeviceException(
f"Unsupported device for {config.host}: "
+ f"{config.connection_type.device_family.value}"
)
device_class: type[Device] | None
device: Device | None = None
device_class: Optional[Type[SmartDevice]]
if isinstance(protocol, TPLinkSmartHomeProtocol):
if isinstance(protocol, IotProtocol) and isinstance(
protocol._transport, XorTransport
):
info = await protocol.query(GET_SYSINFO_QUERY)
_perf_log(True, "get_sysinfo")
device_class = get_device_class_from_sys_info(info)
@@ -86,79 +117,106 @@ async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Smart
_perf_log(True, "update")
return device
elif device_class := get_device_class_from_family(
config.connection_type.device_family.value
config.connection_type.device_family.value, https=config.connection_type.https
):
device = device_class(host=config.host, protocol=protocol)
await device.update()
_perf_log(True, "update")
return device
else:
raise UnsupportedDeviceException(
raise UnsupportedDeviceError(
f"Unsupported device for {config.host}: "
+ f"{config.connection_type.device_family.value}"
+ f"{config.connection_type.device_family.value}",
host=config.host,
)
def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[SmartDevice]:
def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]:
"""Find SmartDevice subclass for device described by passed data."""
if "system" not in info or "get_sysinfo" not in info["system"]:
raise SmartDeviceException("No 'system' or 'get_sysinfo' in response")
sysinfo: Dict[str, Any] = info["system"]["get_sysinfo"]
type_: Optional[str] = sysinfo.get("type", sysinfo.get("mic_type"))
if type_ is None:
raise SmartDeviceException("Unable to find the device type field!")
if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]:
return SmartDimmer
if "smartplug" in type_.lower():
if "children" in sysinfo:
return SmartStrip
return SmartPlug
if "smartbulb" in type_.lower():
if "length" in sysinfo: # strips have length
return SmartLightStrip
return SmartBulb
raise UnsupportedDeviceException("Unknown device type: %s" % type_)
def get_device_class_from_family(device_type: str) -> Optional[Type[SmartDevice]]:
"""Return the device class from the type name."""
supported_device_types: Dict[str, Type[SmartDevice]] = {
"SMART.TAPOPLUG": TapoPlug,
"SMART.TAPOBULB": TapoBulb,
"SMART.KASAPLUG": TapoPlug,
"SMART.KASASWITCH": TapoBulb,
"IOT.SMARTPLUGSWITCH": SmartPlug,
"IOT.SMARTBULB": SmartBulb,
TYPE_TO_CLASS = {
DeviceType.Bulb: IotBulb,
DeviceType.Plug: IotPlug,
DeviceType.Dimmer: IotDimmer,
DeviceType.Strip: IotStrip,
DeviceType.WallSwitch: IotWallSwitch,
DeviceType.LightStrip: IotLightStrip,
DeviceType.Camera: IotCamera,
}
return supported_device_types.get(device_type)
return TYPE_TO_CLASS[IotDevice._get_device_type_from_sys_info(sysinfo)]
def get_device_class_from_family(
device_type: str, *, https: bool, require_exact: bool = False
) -> type[Device] | None:
"""Return the device class from the type name."""
supported_device_types: dict[str, type[Device]] = {
"SMART.TAPOPLUG": SmartDevice,
"SMART.TAPOBULB": SmartDevice,
"SMART.TAPOSWITCH": SmartDevice,
"SMART.KASAPLUG": SmartDevice,
"SMART.TAPOHUB": SmartDevice,
"SMART.TAPOHUB.HTTPS": SmartCamDevice,
"SMART.KASAHUB": SmartDevice,
"SMART.KASASWITCH": SmartDevice,
"SMART.IPCAMERA.HTTPS": SmartCamDevice,
"SMART.TAPOROBOVAC": SmartDevice,
"IOT.SMARTPLUGSWITCH": IotPlug,
"IOT.SMARTBULB": IotBulb,
"IOT.IPCAMERA": IotCamera,
}
lookup_key = f"{device_type}{'.HTTPS' if https else ''}"
if (
(cls := supported_device_types.get(lookup_key)) is None
and device_type.startswith("SMART.")
and not require_exact
):
_LOGGER.debug("Unknown SMART device with %s, using SmartDevice", device_type)
cls = SmartDevice
return cls
def get_protocol(
config: DeviceConfig,
) -> Optional[TPLinkProtocol]:
"""Return the protocol from the connection name."""
protocol_name = config.connection_type.device_family.value.split(".")[0]
) -> BaseProtocol | None:
"""Return the protocol from the connection name.
For cameras and vacuums the device family is a simple mapping to
the protocol/transport. For other device types the transport varies
based on the discovery information.
"""
ctype = config.connection_type
protocol_name = ctype.device_family.value.split(".")[0]
if ctype.device_family is DeviceFamily.SmartIpCamera:
return SmartCamProtocol(transport=SslAesTransport(config=config))
if ctype.device_family is DeviceFamily.IotIpCamera:
return IotProtocol(transport=LinkieTransportV2(config=config))
if ctype.device_family is DeviceFamily.SmartTapoRobovac:
return SmartProtocol(transport=SslTransport(config=config))
protocol_transport_key = (
protocol_name + "." + config.connection_type.encryption_type.value
protocol_name
+ "."
+ ctype.encryption_type.value
+ (".HTTPS" if ctype.https else "")
)
supported_device_protocols: Dict[
str, Tuple[Type[TPLinkProtocol], Type[BaseTransport]]
_LOGGER.debug("Finding transport for %s", protocol_transport_key)
supported_device_protocols: dict[
str, tuple[type[BaseProtocol], type[BaseTransport]]
] = {
"IOT.XOR": (TPLinkSmartHomeProtocol, _XorTransport),
"IOT.XOR": (IotProtocol, XorTransport),
"IOT.KLAP": (IotProtocol, KlapTransport),
"SMART.AES": (SmartProtocol, AesTransport),
"SMART.KLAP": (SmartProtocol, KlapTransportV2),
# H200 is device family SMART.TAPOHUB and uses SmartCamProtocol so use
# https to distuingish from SmartProtocol devices
"SMART.AES.HTTPS": (SmartCamProtocol, SslAesTransport),
}
if protocol_transport_key not in supported_device_protocols:
if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)):
return None
protocol_class, transport_class = supported_device_protocols.get(
protocol_transport_key
) # type: ignore
return protocol_class(transport=transport_class(config=config))
protocol_cls, transport_cls = prot_tran_cls
return protocol_cls(transport=transport_cls(config=config))

View File

@@ -1,5 +1,6 @@
"""TP-Link device types."""
from __future__ import annotations
from enum import Enum
@@ -11,15 +12,20 @@ class DeviceType(Enum):
Plug = "plug"
Bulb = "bulb"
Strip = "strip"
Camera = "camera"
WallSwitch = "wallswitch"
StripSocket = "stripsocket"
Dimmer = "dimmer"
LightStrip = "lightstrip"
TapoPlug = "tapoplug"
TapoBulb = "tapobulb"
Sensor = "sensor"
Hub = "hub"
Fan = "fan"
Thermostat = "thermostat"
Vacuum = "vacuum"
Unknown = "unknown"
@staticmethod
def from_value(name: str) -> "DeviceType":
def from_value(name: str) -> DeviceType:
"""Return device type from string value."""
for device_type in DeviceType:
if device_type.value == name:

View File

@@ -1,18 +1,62 @@
"""Module for holding connection parameters."""
import logging
from dataclasses import asdict, dataclass, field, fields, is_dataclass
from enum import Enum
from typing import Dict, Optional, Union
"""Configuration for connecting directly to a device without discovery.
import httpx
If you are connecting to a newer KASA or TAPO device you can get the device
via discovery or connect directly with :class:`DeviceConfig`.
Discovery returns a list of discovered devices:
>>> from kasa import Discover, Device
>>> device = await Discover.discover_single(
>>> "127.0.0.3",
>>> username="user@example.com",
>>> password="great_password",
>>> )
>>> print(device.alias) # Alias is None because update() has not been called
None
>>> config_dict = device.config.to_dict()
>>> # DeviceConfig.to_dict() can be used to store for later
>>> print(config_dict)
{'host': '127.0.0.3', 'timeout': 5, 'credentials': {'username': 'user@example.com', \
'password': 'great_password'}, 'connection_type'\
: {'device_family': 'SMART.TAPOBULB', 'encryption_type': 'KLAP', 'login_version': 2, \
'https': False}, 'uses_http': True}
>>> later_device = await Device.connect(config=Device.Config.from_dict(config_dict))
>>> print(later_device.alias) # Alias is available as connect() calls update()
Living Room Bulb
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field, replace
from enum import Enum
from typing import TYPE_CHECKING, TypedDict
from aiohttp import ClientSession
from mashumaro import field_options, pass_through
from mashumaro.config import BaseConfig
from .credentials import Credentials
from .exceptions import SmartDeviceException
from .exceptions import KasaException
from .json import DataClassJSONMixin
if TYPE_CHECKING:
from aiohttp import ClientSession
_LOGGER = logging.getLogger(__name__)
class EncryptType(Enum):
class KeyPairDict(TypedDict):
"""Class to represent a public/private key pair."""
private: str
public: str
class DeviceEncryptionType(Enum):
"""Encrypt type enum."""
Klap = "KLAP"
@@ -20,158 +64,130 @@ class EncryptType(Enum):
Xor = "XOR"
class DeviceFamilyType(Enum):
class DeviceFamily(Enum):
"""Encrypt type enum."""
IotSmartPlugSwitch = "IOT.SMARTPLUGSWITCH"
IotSmartBulb = "IOT.SMARTBULB"
IotIpCamera = "IOT.IPCAMERA"
SmartKasaPlug = "SMART.KASAPLUG"
SmartKasaSwitch = "SMART.KASASWITCH"
SmartTapoPlug = "SMART.TAPOPLUG"
SmartTapoBulb = "SMART.TAPOBULB"
SmartTapoSwitch = "SMART.TAPOSWITCH"
SmartTapoHub = "SMART.TAPOHUB"
SmartKasaHub = "SMART.KASAHUB"
SmartIpCamera = "SMART.IPCAMERA"
SmartTapoRobovac = "SMART.TAPOROBOVAC"
def _dataclass_from_dict(klass, in_val):
if is_dataclass(klass):
fieldtypes = {f.name: f.type for f in fields(klass)}
val = {}
for dict_key in in_val:
if dict_key in fieldtypes and hasattr(fieldtypes[dict_key], "from_dict"):
val[dict_key] = fieldtypes[dict_key].from_dict(in_val[dict_key])
else:
val[dict_key] = _dataclass_from_dict(
fieldtypes[dict_key], in_val[dict_key]
)
return klass(**val)
else:
return in_val
class _DeviceConfigBaseMixin(DataClassJSONMixin):
"""Base class for serialization mixin."""
class Config(BaseConfig):
"""Serialization config."""
def _dataclass_to_dict(in_val):
fieldtypes = {f.name: f.type for f in fields(in_val) if f.compare}
out_val = {}
for field_name in fieldtypes:
val = getattr(in_val, field_name)
if val is None:
continue
elif hasattr(val, "to_dict"):
out_val[field_name] = val.to_dict()
elif is_dataclass(fieldtypes[field_name]):
out_val[field_name] = asdict(val)
else:
out_val[field_name] = val
return out_val
omit_none = True
@dataclass
class ConnectionType:
class DeviceConnectionParameters(_DeviceConfigBaseMixin):
"""Class to hold the the parameters determining connection type."""
device_family: DeviceFamilyType
encryption_type: EncryptType
login_version: Optional[int] = None
device_family: DeviceFamily
encryption_type: DeviceEncryptionType
login_version: int | None = None
https: bool = False
@staticmethod
def from_values(
device_family: str,
encryption_type: str,
login_version: Optional[int] = None,
) -> "ConnectionType":
login_version: int | None = None,
https: bool | None = None,
) -> DeviceConnectionParameters:
"""Return connection parameters from string values."""
try:
return ConnectionType(
DeviceFamilyType(device_family),
EncryptType(encryption_type),
if https is None:
https = False
return DeviceConnectionParameters(
DeviceFamily(device_family),
DeviceEncryptionType(encryption_type),
login_version,
https,
)
except (ValueError, TypeError) as ex:
raise SmartDeviceException(
raise KasaException(
f"Invalid connection parameters for {device_family}."
+ f"{encryption_type}.{login_version}"
) from ex
@staticmethod
def from_dict(connection_type_dict: Dict[str, str]) -> "ConnectionType":
"""Return connection parameters from dict."""
if (
isinstance(connection_type_dict, dict)
and (device_family := connection_type_dict.get("device_family"))
and (encryption_type := connection_type_dict.get("encryption_type"))
):
if login_version := connection_type_dict.get("login_version"):
login_version = int(login_version) # type: ignore[assignment]
return ConnectionType.from_values(
device_family,
encryption_type,
login_version, # type: ignore[arg-type]
)
raise SmartDeviceException(
f"Invalid connection type data for {connection_type_dict}"
)
def to_dict(self) -> Dict[str, Union[str, int]]:
"""Convert connection params to dict."""
result: Dict[str, Union[str, int]] = {
"device_family": self.device_family.value,
"encryption_type": self.encryption_type.value,
}
if self.login_version:
result["login_version"] = self.login_version
return result
@dataclass
class DeviceConfig:
class DeviceConfig(_DeviceConfigBaseMixin):
"""Class to represent paramaters that determine how to connect to devices."""
DEFAULT_TIMEOUT = 5
#: IP address or hostname
host: str
#: Timeout for querying the device
timeout: Optional[int] = DEFAULT_TIMEOUT
timeout: int | None = DEFAULT_TIMEOUT
#: Override the default 9999 port to support port forwarding
port_override: Optional[int] = None
port_override: int | None = None
#: Credentials for devices requiring authentication
credentials: Optional[Credentials] = None
credentials: Credentials | None = None
#: Credentials hash for devices requiring authentication.
#: If credentials are also supplied they take precendence over credentials_hash.
#: Credentials hash can be retrieved from :attr:`SmartDevice.credentials_hash`
credentials_hash: Optional[str] = None
#: Credentials hash can be retrieved from :attr:`Device.credentials_hash`
credentials_hash: str | None = None
#: The protocol specific type of connection. Defaults to the legacy type.
connection_type: ConnectionType = field(
default_factory=lambda: ConnectionType(
DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Xor, 1
batch_size: int | None = None
#: The batch size for protoools supporting multiple request batches.
connection_type: DeviceConnectionParameters = field(
default_factory=lambda: DeviceConnectionParameters(
DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor
)
)
#: True if the device uses http. Consumers should retrieve rather than set this
#: in order to determine whether they should pass a custom http client if desired.
uses_http: bool = False
# compare=False will be excluded from the serialization and object comparison.
#: Set a custom http_client for the device to use.
http_client: Optional[httpx.AsyncClient] = field(default=None, compare=False)
http_client: ClientSession | None = field(
default=None,
compare=False,
metadata=field_options(serialize="omit", deserialize=pass_through),
)
def __post_init__(self):
aes_keys: KeyPairDict | None = None
def __post_init__(self) -> None:
if self.connection_type is None:
self.connection_type = ConnectionType(
DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Xor
self.connection_type = DeviceConnectionParameters(
DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor
)
def to_dict(
def to_dict_control_credentials(
self,
*,
credentials_hash: Optional[str] = None,
credentials_hash: str | None = None,
exclude_credentials: bool = False,
) -> Dict[str, Dict[str, str]]:
"""Convert device config to dict."""
if credentials_hash is not None or exclude_credentials:
self.credentials = None
if credentials_hash:
self.credentials_hash = credentials_hash
return _dataclass_to_dict(self)
) -> dict[str, dict[str, str]]:
"""Convert deviceconfig to dict controlling how to serialize credentials.
@staticmethod
def from_dict(cparam_dict: Dict[str, Dict[str, str]]) -> "DeviceConfig":
"""Return device config from dict."""
return _dataclass_from_dict(DeviceConfig, cparam_dict)
If credentials_hash is provided credentials will be None.
If credentials_hash is '' credentials_hash and credentials will be None.
exclude credentials controls whether to include credentials.
The defaults are the same as calling to_dict().
"""
if credentials_hash is None:
if not exclude_credentials:
return self.to_dict()
else:
return replace(self, credentials=None).to_dict()
return replace(
self,
credentials_hash=credentials_hash if credentials_hash else None,
credentials=None,
).to_dict()

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
"""Module for emeter container."""
from __future__ import annotations
import logging
from typing import Optional
_LOGGER = logging.getLogger(__name__)
@@ -16,7 +18,7 @@ class EmeterStatus(dict):
"""
@property
def voltage(self) -> Optional[float]:
def voltage(self) -> float | None:
"""Return voltage in V."""
try:
return self["voltage"]
@@ -24,7 +26,7 @@ class EmeterStatus(dict):
return None
@property
def power(self) -> Optional[float]:
def power(self) -> float | None:
"""Return power in W."""
try:
return self["power"]
@@ -32,7 +34,7 @@ class EmeterStatus(dict):
return None
@property
def current(self) -> Optional[float]:
def current(self) -> float | None:
"""Return current in A."""
try:
return self["current"]
@@ -40,20 +42,20 @@ class EmeterStatus(dict):
return None
@property
def total(self) -> Optional[float]:
def total(self) -> float | None:
"""Return total in kWh."""
try:
return self["total"]
except ValueError:
return None
def __repr__(self):
def __repr__(self) -> str:
return (
f"<EmeterStatus power={self.power} voltage={self.voltage}"
f" current={self.current} total={self.total}>"
)
def __getitem__(self, item):
def __getitem__(self, item: str) -> float | None:
"""Return value in wanted units."""
valid_keys = [
"voltage_mv",
@@ -79,8 +81,11 @@ class EmeterStatus(dict):
return super().__getitem__(item[: item.find("_")]) * 1000
else: # downscale
for i in super().keys(): # noqa: SIM118
if i.startswith(item):
return self.__getitem__(i) / 1000
if (
i.startswith(item)
and (value := self.__getitem__(i)) is not None
):
return value / 1000
_LOGGER.debug(f"Unable to find value for '{item}'")
_LOGGER.debug("Unable to find value for '%s'", item)
return None

View File

@@ -1,39 +1,76 @@
"""python-kasa exceptions."""
from __future__ import annotations
from asyncio import TimeoutError as _asyncioTimeoutError
from enum import IntEnum
from typing import Optional
from functools import cache
from typing import Any
class SmartDeviceException(Exception):
"""Base exception for device errors."""
class KasaException(Exception):
"""Base exception for library errors."""
def __init__(self, *args, **kwargs):
self.error_code: Optional["SmartErrorCode"] = kwargs.get("error_code", None)
class TimeoutError(KasaException, _asyncioTimeoutError):
"""Timeout exception for device errors."""
def __repr__(self) -> str:
return KasaException.__repr__(self)
def __str__(self) -> str:
return KasaException.__str__(self)
class _ConnectionError(KasaException):
"""Connection exception for device errors."""
class UnsupportedDeviceError(KasaException):
"""Exception for trying to connect to unsupported devices."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
self.discovery_result = kwargs.get("discovery_result")
self.host = kwargs.get("host")
super().__init__(*args)
class UnsupportedDeviceException(SmartDeviceException):
"""Exception for trying to connect to unsupported devices."""
class DeviceError(KasaException):
"""Base exception for device errors."""
def __init__(self, *args, **kwargs):
self.discovery_result = kwargs.get("discovery_result")
super().__init__(*args, **kwargs)
def __init__(self, *args: Any, **kwargs: Any) -> None:
self.error_code: SmartErrorCode | None = kwargs.get("error_code")
super().__init__(*args)
def __repr__(self) -> str:
err_code = self.error_code.__repr__() if self.error_code else ""
return f"{self.__class__.__name__}({err_code})"
def __str__(self) -> str:
err_code = f" (error_code={self.error_code.name})" if self.error_code else ""
return super().__str__() + err_code
class AuthenticationException(SmartDeviceException):
class AuthenticationError(DeviceError):
"""Base exception for device authentication errors."""
class RetryableException(SmartDeviceException):
class _RetryableError(DeviceError):
"""Retryable exception for device errors."""
class TimeoutException(SmartDeviceException):
"""Timeout exception for device errors."""
class SmartErrorCode(IntEnum):
"""Enum for SMART Error Codes."""
def __str__(self) -> str:
return f"{self.name}({self.value})"
@staticmethod
@cache
def from_int(value: int) -> SmartErrorCode:
"""Convert an integer to a SmartErrorCode."""
return SmartErrorCode(value)
SUCCESS = 0
# Transport Errors
@@ -42,6 +79,8 @@ class SmartErrorCode(IntEnum):
HTTP_TRANSPORT_FAILED_ERROR = 1112
LOGIN_FAILED_ERROR = 1111
HAND_SHAKE_FAILED_ERROR = 1100
#: Real description unknown, seen after an encryption-changing fw upgrade
TRANSPORT_UNKNOWN_CREDENTIALS_ERROR = 1003
TRANSPORT_NOT_AVAILABLE_ERROR = 1002
CMD_COMMAND_CANCEL_ERROR = 1001
NULL_TRANSPORT_ERROR = 1000
@@ -88,11 +127,66 @@ class SmartErrorCode(IntEnum):
DST_ERROR = -2301
DST_SAVE_ERROR = -2302
SYSTEM_ERROR = -40101
INVALID_ARGUMENTS = -40209
# Camera error codes
SESSION_EXPIRED = -40401
HOMEKIT_LOGIN_FAIL = -40412
DEVICE_BLOCKED = -40404
DEVICE_FACTORY = -40405
OUT_OF_LIMIT = -40406
OTHER_ERROR = -40407
SYSTEM_BLOCKED = -40408
NONCE_EXPIRED = -40409
FFS_NONE_PWD = -90000
TIMEOUT_ERROR = 40108
UNSUPPORTED_METHOD = -40106
ONE_SECOND_REPEAT_REQUEST = -40109
INVALID_NONCE = -40413
PROTOCOL_FORMAT_ERROR = -40210
IP_CONFLICT = -40321
DIAGNOSE_TYPE_NOT_SUPPORT = -69051
DIAGNOSE_TASK_FULL = -69052
DIAGNOSE_TASK_BUSY = -69053
DIAGNOSE_INTERNAL_ERROR = -69055
DIAGNOSE_ID_NOT_FOUND = -69056
DIAGNOSE_TASK_NULL = -69057
CLOUD_LINK_DOWN = -69060
ONVIF_SET_WRONG_TIME = -69061
CLOUD_NTP_NO_RESPONSE = -69062
CLOUD_GET_WRONG_TIME = -69063
SNTP_SRV_NO_RESPONSE = -69064
SNTP_GET_WRONG_TIME = -69065
LINK_UNCONNECTED = -69076
WIFI_SIGNAL_WEAK = -69077
LOCAL_NETWORK_POOR = -69078
CLOUD_NETWORK_POOR = -69079
INTER_NETWORK_POOR = -69080
DNS_TIMEOUT = -69081
DNS_ERROR = -69082
PING_NO_RESPONSE = -69083
DHCP_MULTI_SERVER = -69084
DHCP_ERROR = -69085
STREAM_SESSION_CLOSE = -69094
STREAM_BITRATE_EXCEPTION = -69095
STREAM_FULL = -69096
STREAM_NO_INTERNET = -69097
HARDWIRED_NOT_FOUND = -72101
# Library internal for unknown error codes
INTERNAL_UNKNOWN_ERROR = -100_000
# Library internal for query errors
INTERNAL_QUERY_ERROR = -100_001
SMART_RETRYABLE_ERRORS = [
SmartErrorCode.TRANSPORT_NOT_AVAILABLE_ERROR,
SmartErrorCode.HTTP_TRANSPORT_FAILED_ERROR,
SmartErrorCode.UNSPECIFIC_ERROR,
SmartErrorCode.SESSION_TIMEOUT_ERROR,
SmartErrorCode.SESSION_EXPIRED,
SmartErrorCode.INVALID_NONCE,
]
SMART_AUTHENTICATION_ERRORS = [
@@ -100,8 +194,6 @@ SMART_AUTHENTICATION_ERRORS = [
SmartErrorCode.LOGIN_FAILED_ERROR,
SmartErrorCode.AES_DECODE_FAIL_ERROR,
SmartErrorCode.HAND_SHAKE_FAILED_ERROR,
]
SMART_TIMEOUT_ERRORS = [
SmartErrorCode.SESSION_TIMEOUT_ERROR,
SmartErrorCode.TRANSPORT_UNKNOWN_CREDENTIALS_ERROR,
SmartErrorCode.HOMEKIT_LOGIN_FAIL,
]

305
kasa/feature.py Normal file
View File

@@ -0,0 +1,305 @@
"""Interact with feature.
Features are implemented by devices to represent individual pieces of functionality like
state, time, firmware.
>>> from kasa import Discover, Module
>>>
>>> dev = await Discover.discover_single(
>>> "127.0.0.3",
>>> username="user@example.com",
>>> password="great_password"
>>> )
>>> await dev.update()
>>> print(dev.alias)
Living Room Bulb
Features allow for instrospection and can be interacted with as new features are added
to the API:
>>> for feature_id, feature in dev.features.items():
>>> print(f"{feature.name} ({feature_id}): {feature.value}")
Device ID (device_id): 0000000000000000000000000000000000000000
State (state): True
Signal Level (signal_level): 2
RSSI (rssi): -52
SSID (ssid): #MASKED_SSID#
Reboot (reboot): <Action>
Brightness (brightness): 100
Cloud connection (cloud_connection): True
HSV (hsv): HSV(hue=0, saturation=100, value=100)
Color temperature (color_temperature): 2700
Auto update enabled (auto_update_enabled): False
Update available (update_available): None
Current firmware version (current_firmware_version): 1.1.6 Build 240130 Rel.173828
Available firmware version (available_firmware_version): None
Check latest firmware (check_latest_firmware): <Action>
Light effect (light_effect): Off
Light preset (light_preset): Not set
Smooth transition on (smooth_transition_on): 2
Smooth transition off (smooth_transition_off): 2
Overheated (overheated): False
Device time (device_time): 2024-02-23 02:40:15+01:00
To see whether a device supports a feature, check for the existence of it:
>>> if feature := dev.features.get("brightness"):
>>> print(feature.value)
100
You can update the value of a feature
>>> await feature.set_value(50)
>>> await dev.update()
>>> print(feature.value)
50
Features have types that can be used for introspection:
>>> feature = dev.features["light_preset"]
>>> print(feature.type)
Type.Choice
>>> print(feature.choices)
['Not set', 'Light preset 1', 'Light preset 2', 'Light preset 3',\
'Light preset 4', 'Light preset 5', 'Light preset 6', 'Light preset 7']
"""
from __future__ import annotations
import logging
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from enum import Enum, auto
from functools import cached_property
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .device import Device
_LOGGER = logging.getLogger(__name__)
@dataclass
class Feature:
"""Feature defines a generic interface for device features."""
class Type(Enum):
"""Type to help decide how to present the feature."""
#: Sensor is an informative read-only value
Sensor = auto()
#: BinarySensor is a read-only boolean
BinarySensor = auto()
#: Switch is a boolean setting
Switch = auto()
#: Action triggers some action on device
Action = auto()
#: Number defines a numeric setting
#: See :attr:`range_getter`, :attr:`Feature.minimum_value`,
#: and :attr:`maximum_value`
Number = auto()
#: Choice defines a setting with pre-defined values
Choice = auto()
Unknown = -1
# Aliases for easy access
Sensor = Type.Sensor
BinarySensor = Type.BinarySensor
Switch = Type.Switch
Action = Type.Action
Number = Type.Number
Choice = Type.Choice
DEFAULT_MAX = 2**16 # Arbitrary max
class Category(Enum):
"""Category hint to allow feature grouping."""
#: Primary features control the device state directly.
#: Examples include turning the device on/off, or adjusting its brightness.
Primary = auto()
#: Config features change device behavior without immediate state changes.
Config = auto()
#: Informative/sensor features deliver some potentially interesting information.
Info = auto()
#: Debug features deliver more verbose information then informative features.
#: You may want to hide these per default to avoid cluttering your UI.
Debug = auto()
#: The default category if none is specified.
Unset = -1
#: Device instance required for getting and setting values
device: Device
#: Identifier
id: str
#: User-friendly short description
name: str
#: Type of the feature
type: Feature.Type
#: Callable or name of the property that allows accessing the value
attribute_getter: str | Callable | None = None
#: Callable coroutine or name of the method that allows changing the value
attribute_setter: str | Callable[..., Coroutine[Any, Any, Any]] | None = None
#: Container storing the data, this overrides 'device' for getters
container: Any = None
#: Icon suggestion
icon: str | None = None
#: Attribute containing the name of the unit getter property.
#: If set, this property will be used to get the *unit*.
unit_getter: str | Callable[[], str] | None = None
#: Category hint for downstreams
category: Feature.Category = Category.Unset
# Display hints offer a way suggest how the value should be shown to users
#: Hint to help rounding the sensor values to given after-comma digits
precision_hint: int | None = None
#: Attribute containing the name of the range getter property.
#: If set, this property will be used to set *minimum_value* and *maximum_value*.
range_getter: str | Callable[[], tuple[int, int]] | None = None
#: Attribute name of the choices getter property.
#: If set, this property will be used to get *choices*.
choices_getter: str | Callable[[], list[str]] | None = None
def __post_init__(self) -> None:
"""Handle late-binding of members."""
# Populate minimum & maximum values, if range_getter is given
self._container = self.container if self.container is not None else self.device
# Set the category, if unset
if self.category is Feature.Category.Unset:
if self.attribute_setter:
self.category = Feature.Category.Config
else:
self.category = Feature.Category.Info
if self.type in (
Feature.Type.Sensor,
Feature.Type.BinarySensor,
):
if self.category == Feature.Category.Config:
raise ValueError(
f"Invalid type for configurable feature: {self.name} ({self.id}):"
f" {self.type}"
)
elif self.attribute_setter is not None:
raise ValueError(
f"Read-only feat defines attribute_setter: {self.name} ({self.id}):"
)
def _get_property_value(self, getter: str | Callable | None) -> Any:
if getter is None:
return None
if isinstance(getter, str):
return getattr(self._container, getter)
if callable(getter):
return getter()
raise ValueError("Invalid getter: %s", getter) # pragma: no cover
@property
def choices(self) -> list[str] | None:
"""List of choices."""
return self._get_property_value(self.choices_getter)
@property
def unit(self) -> str | None:
"""Unit if applicable."""
return self._get_property_value(self.unit_getter)
@cached_property
def range(self) -> tuple[int, int] | None:
"""Range of values if applicable."""
return self._get_property_value(self.range_getter)
@property
def maximum_value(self) -> int:
"""Maximum value."""
if range := self.range:
return range[1]
return self.DEFAULT_MAX
@property
def minimum_value(self) -> int:
"""Minimum value."""
if range := self.range:
return range[0]
return 0
@property
def value(self) -> int | float | bool | str | Enum | None:
"""Return the current value."""
if self.type == Feature.Type.Action:
return "<Action>"
if self.attribute_getter is None:
raise ValueError("Not an action and no attribute_getter set")
container = self.container if self.container is not None else self.device
if callable(self.attribute_getter):
return self.attribute_getter(container)
return getattr(container, self.attribute_getter)
async def set_value(self, value: int | float | bool | str | Enum | None) -> Any:
"""Set the value."""
if self.attribute_setter is None:
raise ValueError("Tried to set read-only feature.")
if self.type == Feature.Type.Number: # noqa: SIM102
if not isinstance(value, int | float):
raise ValueError("value must be a number")
if value < self.minimum_value or value > self.maximum_value:
raise ValueError(
f"Value {value} out of range "
f"[{self.minimum_value}, {self.maximum_value}]"
)
elif self.type == Feature.Type.Choice: # noqa: SIM102
if not self.choices or value not in self.choices:
raise ValueError(
f"Unexpected value for {self.name}: {value}"
f" - allowed: {self.choices}"
)
if callable(self.attribute_setter):
attribute_setter = self.attribute_setter
else:
container = self.container if self.container is not None else self.device
attribute_setter = getattr(container, self.attribute_setter)
if self.type == Feature.Type.Action:
return await attribute_setter()
return await attribute_setter(value)
def __repr__(self) -> str:
try:
value = self.value
choices = self.choices
except Exception as ex:
return f"Unable to read value ({self.id}): {ex}"
if self.type == Feature.Type.Choice:
if not isinstance(choices, list) or value not in choices:
_LOGGER.warning(
"Invalid value for for choice %s (%s): %s not in %s",
self.name,
self.id,
value,
choices,
)
return (
f"{self.name} ({self.id}): invalid value '{value}' not in {choices}"
)
value = " ".join(
[f"*{choice}*" if choice == value else choice for choice in choices]
)
if self.precision_hint is not None and isinstance(value, float):
value = round(value, self.precision_hint)
s = f"{self.name} ({self.id}): {value}"
if self.unit is not None:
s += f" {self.unit}"
if self.type == Feature.Type.Number:
s += f" (range: {self.minimum_value}-{self.maximum_value})"
return s

164
kasa/httpclient.py Normal file
View File

@@ -0,0 +1,164 @@
"""Module for HttpClientSession class."""
from __future__ import annotations
import asyncio
import logging
import ssl
import time
from typing import Any
import aiohttp
from yarl import URL
from .deviceconfig import DeviceConfig
from .exceptions import (
KasaException,
TimeoutError,
_ConnectionError,
)
from .json import loads as json_loads
_LOGGER = logging.getLogger(__name__)
def get_cookie_jar() -> aiohttp.CookieJar:
"""Return a new cookie jar with the correct options for device communication."""
return aiohttp.CookieJar(unsafe=True, quote_cookie=False)
class HttpClient:
"""HttpClient Class."""
# Some devices (only P100 so far) close the http connection after each request
# and aiohttp doesn't seem to handle it. If a Client OS error is received the
# http client will start ensuring that sequential requests have a wait delay.
WAIT_BETWEEN_REQUESTS_ON_OSERROR = 0.25
def __init__(self, config: DeviceConfig) -> None:
self._config = config
self._client_session: aiohttp.ClientSession | None = None
self._jar = aiohttp.CookieJar(unsafe=True, quote_cookie=False)
self._last_url = URL(f"http://{self._config.host}/")
self._wait_between_requests = 0.0
self._last_request_time = 0.0
@property
def client(self) -> aiohttp.ClientSession:
"""Return the underlying http client."""
if self._config.http_client and issubclass(
self._config.http_client.__class__, aiohttp.ClientSession
):
return self._config.http_client
if not self._client_session:
self._client_session = aiohttp.ClientSession(cookie_jar=get_cookie_jar())
return self._client_session
async def post(
self,
url: URL,
*,
params: dict[str, Any] | None = None,
data: bytes | None = None,
json: dict | Any | None = None,
headers: dict[str, str] | None = None,
cookies_dict: dict[str, str] | None = None,
ssl: ssl.SSLContext | bool = False,
) -> tuple[int, dict | bytes | None]:
"""Send an http post request to the device.
If the request is provided via the json parameter json will be returned.
"""
# Once we know a device needs a wait between sequential queries always wait
# first rather than keep erroring then waiting.
if self._wait_between_requests:
now = time.monotonic()
gap = now - self._last_request_time
if gap < self._wait_between_requests:
sleep = self._wait_between_requests - gap
_LOGGER.debug(
"Device %s waiting %s seconds to send request",
self._config.host,
sleep,
)
await asyncio.sleep(sleep)
_LOGGER.debug("Posting to %s", url)
response_data = None
self._last_url = url
self.client.cookie_jar.clear()
return_json = bool(json)
if self._config.timeout is None:
_LOGGER.warning("Request timeout is set to None.")
client_timeout = aiohttp.ClientTimeout(total=self._config.timeout)
# If json is not a dict send as data.
# This allows the json parameter to be used to pass other
# types of data such as async_generator and still have json
# returned.
if json and not isinstance(json, dict):
data = json
json = None
try:
resp = await self.client.post(
url,
params=params,
data=data,
json=json,
timeout=client_timeout,
cookies=cookies_dict,
headers=headers,
ssl=ssl,
)
async with resp:
if resp.status == 200:
response_data = await resp.read()
if return_json:
response_data = json_loads(response_data.decode())
except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex:
if not self._wait_between_requests:
_LOGGER.debug(
"Device %s received an os error, "
"enabling sequential request delay: %s",
self._config.host,
ex,
)
self._wait_between_requests = self.WAIT_BETWEEN_REQUESTS_ON_OSERROR
self._last_request_time = time.monotonic()
raise _ConnectionError(
f"Device connection error: {self._config.host}: {ex}", ex
) from ex
except (aiohttp.ServerTimeoutError, TimeoutError) as ex:
raise TimeoutError(
"Unable to query the device, "
+ f"timed out: {self._config.host}: {ex}",
ex,
) from ex
except Exception as ex:
raise KasaException(
f"Unable to query the device: {self._config.host}: {ex}", ex
) from ex
# For performance only request system time if waiting is enabled
if self._wait_between_requests:
self._last_request_time = time.monotonic()
return resp.status, response_data
def get_cookie(self, cookie_name: str) -> str | None:
"""Return the cookie with cookie_name."""
if cookie := self.client.cookie_jar.filter_cookies(self._last_url).get(
cookie_name
):
return cookie.value
return None
async def close(self) -> None:
"""Close the ClientSession."""
client = self._client_session
self._client_session = None
if client:
await client.close()

View File

@@ -0,0 +1,23 @@
"""Package for interfaces."""
from .energy import Energy
from .fan import Fan
from .led import Led
from .light import Light, LightState
from .lighteffect import LightEffect
from .lightpreset import LightPreset
from .thermostat import Thermostat, ThermostatState
from .time import Time
__all__ = [
"Fan",
"Energy",
"Led",
"Light",
"LightEffect",
"LightState",
"LightPreset",
"Thermostat",
"ThermostatState",
"Time",
]

194
kasa/interfaces/energy.py Normal file
View File

@@ -0,0 +1,194 @@
"""Module for base energy module."""
from __future__ import annotations
from abc import ABC, abstractmethod
from enum import IntFlag, auto
from typing import TYPE_CHECKING, Any
from warnings import warn
from ..emeterstatus import EmeterStatus
from ..feature import Feature
from ..module import Module
class Energy(Module, ABC):
"""Base interface to represent an Energy module."""
class ModuleFeature(IntFlag):
"""Features supported by the device."""
#: Device reports :attr:`voltage` and :attr:`current`
VOLTAGE_CURRENT = auto()
#: Device reports :attr:`consumption_total`
CONSUMPTION_TOTAL = auto()
#: Device reports periodic stats via :meth:`get_daily_stats`
#: and :meth:`get_monthly_stats`
PERIODIC_STATS = auto()
_supported: ModuleFeature = ModuleFeature(0)
def supports(self, module_feature: ModuleFeature) -> bool:
"""Return True if module supports the feature."""
return module_feature in self._supported
def _initialize_features(self) -> None:
"""Initialize features."""
device = self._device
self._add_feature(
Feature(
device,
name="Current consumption",
attribute_getter="current_consumption",
container=self,
unit_getter=lambda: "W",
id="current_consumption",
precision_hint=1,
category=Feature.Category.Primary,
type=Feature.Type.Sensor,
)
)
self._add_feature(
Feature(
device,
name="Today's consumption",
attribute_getter="consumption_today",
container=self,
unit_getter=lambda: "kWh",
id="consumption_today",
precision_hint=3,
category=Feature.Category.Info,
type=Feature.Type.Sensor,
)
)
self._add_feature(
Feature(
device,
id="consumption_this_month",
name="This month's consumption",
attribute_getter="consumption_this_month",
container=self,
unit_getter=lambda: "kWh",
precision_hint=3,
category=Feature.Category.Info,
type=Feature.Type.Sensor,
)
)
if self.supports(self.ModuleFeature.CONSUMPTION_TOTAL):
self._add_feature(
Feature(
device,
name="Total consumption since reboot",
attribute_getter="consumption_total",
container=self,
unit_getter=lambda: "kWh",
id="consumption_total",
precision_hint=3,
category=Feature.Category.Info,
type=Feature.Type.Sensor,
)
)
if self.supports(self.ModuleFeature.VOLTAGE_CURRENT):
self._add_feature(
Feature(
device,
name="Voltage",
attribute_getter="voltage",
container=self,
unit_getter=lambda: "V",
id="voltage",
precision_hint=1,
category=Feature.Category.Primary,
type=Feature.Type.Sensor,
)
)
self._add_feature(
Feature(
device,
name="Current",
attribute_getter="current",
container=self,
unit_getter=lambda: "A",
id="current",
precision_hint=2,
category=Feature.Category.Primary,
type=Feature.Type.Sensor,
)
)
@property
@abstractmethod
def status(self) -> EmeterStatus:
"""Return current energy readings."""
@property
@abstractmethod
def current_consumption(self) -> float | None:
"""Get the current power consumption in Watt."""
@property
@abstractmethod
def consumption_today(self) -> float | None:
"""Return today's energy consumption in kWh."""
@property
@abstractmethod
def consumption_this_month(self) -> float | None:
"""Return this month's energy consumption in kWh."""
@property
@abstractmethod
def consumption_total(self) -> float | None:
"""Return total consumption since last reboot in kWh."""
@property
@abstractmethod
def current(self) -> float | None:
"""Return the current in A."""
@property
@abstractmethod
def voltage(self) -> float | None:
"""Get the current voltage in V."""
@abstractmethod
async def get_status(self) -> EmeterStatus:
"""Return real-time statistics."""
@abstractmethod
async def erase_stats(self) -> dict:
"""Erase all stats."""
@abstractmethod
async def get_daily_stats(
self, *, year: int | None = None, month: int | None = None, kwh: bool = True
) -> dict:
"""Return daily stats for the given year & month.
The return value is a dictionary of {day: energy, ...}.
"""
@abstractmethod
async def get_monthly_stats(
self, *, year: int | None = None, kwh: bool = True
) -> dict:
"""Return monthly stats for the given year."""
_deprecated_attributes = {
"emeter_today": "consumption_today",
"emeter_this_month": "consumption_this_month",
"realtime": "status",
"get_realtime": "get_status",
"erase_emeter_stats": "erase_stats",
"get_daystat": "get_daily_stats",
"get_monthstat": "get_monthly_stats",
}
if not TYPE_CHECKING:
def __getattr__(self, name: str) -> Any:
if attr := self._deprecated_attributes.get(name):
msg = f"{name} is deprecated, use {attr} instead"
warn(msg, DeprecationWarning, stacklevel=2)
return getattr(self, attr)
raise AttributeError(f"Energy module has no attribute {name!r}")

23
kasa/interfaces/fan.py Normal file
View File

@@ -0,0 +1,23 @@
"""Module for Fan Interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Annotated
from ..module import FeatureAttribute, Module
class Fan(Module, ABC):
"""Interface for a Fan."""
@property
@abstractmethod
def fan_speed_level(self) -> Annotated[int, FeatureAttribute()]:
"""Return fan speed level."""
@abstractmethod
async def set_fan_speed_level(
self, level: int
) -> Annotated[dict, FeatureAttribute()]:
"""Set fan speed level."""

38
kasa/interfaces/led.py Normal file
View File

@@ -0,0 +1,38 @@
"""Module for base light effect module."""
from __future__ import annotations
from abc import ABC, abstractmethod
from ..feature import Feature
from ..module import Module
class Led(Module, ABC):
"""Base interface to represent a LED module."""
def _initialize_features(self) -> None:
"""Initialize features."""
device = self._device
self._add_feature(
Feature(
device=device,
container=self,
name="LED",
id="led",
icon="mdi:led",
attribute_getter="led",
attribute_setter="set_led",
type=Feature.Type.Switch,
category=Feature.Category.Config,
)
)
@property
@abstractmethod
def led(self) -> bool:
"""Return current led status."""
@abstractmethod
async def set_led(self, enable: bool) -> dict:
"""Set led."""

199
kasa/interfaces/light.py Normal file
View File

@@ -0,0 +1,199 @@
"""Interact with a TPLink Light.
>>> from kasa import Discover, Module
>>>
>>> dev = await Discover.discover_single(
>>> "127.0.0.3",
>>> username="user@example.com",
>>> password="great_password"
>>> )
>>> await dev.update()
>>> print(dev.alias)
Living Room Bulb
Lights, like any other supported devices, can be turned on and off:
>>> print(dev.is_on)
>>> await dev.turn_on()
>>> await dev.update()
>>> print(dev.is_on)
True
Get the light module to interact:
>>> light = dev.modules[Module.Light]
You can use the ``has_feature()`` method to check for supported features:
>>> light.has_feature("brightness")
True
>>> light.has_feature("hsv")
True
>>> light.has_feature("color_temp")
True
All known bulbs support changing the brightness:
>>> light.brightness
100
>>> await light.set_brightness(50)
>>> await dev.update()
>>> light.brightness
50
Bulbs supporting color temperature can be queried for the supported range:
>>> if color_temp_feature := light.get_feature("color_temp"):
>>> print(f"{color_temp_feature.minimum_value}, {color_temp_feature.maximum_value}")
2500, 6500
>>> await light.set_color_temp(3000)
>>> await dev.update()
>>> light.color_temp
3000
Color bulbs can be adjusted by passing hue, saturation and value:
>>> await light.set_hsv(180, 100, 80)
>>> await dev.update()
>>> light.hsv
HSV(hue=180, saturation=100, value=80)
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Annotated, NamedTuple
from ..module import FeatureAttribute, Module
@dataclass
class LightState:
"""Class for smart light preset info."""
light_on: bool | None = None
brightness: int | None = None
hue: int | None = None
saturation: int | None = None
color_temp: int | None = None
transition: int | None = None
class ColorTempRange(NamedTuple):
"""Color temperature range."""
min: int
max: int
class HSV(NamedTuple):
"""Hue-saturation-value."""
hue: int
saturation: int
value: int
class Light(Module, ABC):
"""Base class for TP-Link Light."""
@property
@abstractmethod
def is_dimmable(self) -> bool:
"""Whether the light supports brightness changes."""
@property
@abstractmethod
def is_color(self) -> bool:
"""Whether the bulb supports color changes."""
@property
@abstractmethod
def is_variable_color_temp(self) -> bool:
"""Whether the bulb supports color temperature changes."""
@property
@abstractmethod
def valid_temperature_range(self) -> ColorTempRange:
"""Return the device-specific white temperature range (in Kelvin).
:return: White temperature range in Kelvin (minimum, maximum)
"""
@property
@abstractmethod
def has_effects(self) -> bool:
"""Return True if the device supports effects."""
@property
@abstractmethod
def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
"""Return the current HSV state of the bulb.
:return: hue, saturation and value (degrees, %, %)
"""
@property
@abstractmethod
def color_temp(self) -> Annotated[int, FeatureAttribute()]:
"""Whether the bulb supports color temperature changes."""
@property
@abstractmethod
def brightness(self) -> Annotated[int, FeatureAttribute()]:
"""Return the current brightness in percentage."""
@abstractmethod
async def set_hsv(
self,
hue: int,
saturation: int,
value: int | None = None,
*,
transition: int | None = None,
) -> Annotated[dict, FeatureAttribute()]:
"""Set new HSV.
Note, transition is not supported and will be ignored.
: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.
"""
@abstractmethod
async def set_color_temp(
self, temp: int, *, brightness: int | None = None, transition: int | None = None
) -> Annotated[dict, FeatureAttribute()]:
"""Set the color temperature of the device in kelvin.
Note, transition is not supported and will be ignored.
:param int temp: The new color temperature, in Kelvin
:param int transition: transition in milliseconds.
"""
@abstractmethod
async def set_brightness(
self, brightness: int, *, transition: int | None = None
) -> Annotated[dict, FeatureAttribute()]:
"""Set the brightness in percentage.
Note, transition is not supported and will be ignored.
:param int brightness: brightness in percent
:param int transition: transition in milliseconds.
"""
@property
@abstractmethod
def state(self) -> LightState:
"""Return the current light state."""
@abstractmethod
async def set_state(self, state: LightState) -> dict:
"""Set the light state."""

View File

@@ -0,0 +1,120 @@
"""Interact with a TPLink Light Effect.
>>> from kasa import Discover, Module, LightState
>>>
>>> dev = await Discover.discover_single(
>>> "127.0.0.3",
>>> username="user@example.com",
>>> password="great_password"
>>> )
>>> await dev.update()
>>> print(dev.alias)
Living Room Bulb
Light effects are accessed via the LightPreset module. To list available presets
>>> light_effect = dev.modules[Module.LightEffect]
>>> light_effect.effect_list
['Off', 'Party', 'Relax']
To view the currently selected effect:
>>> light_effect.effect
Off
To activate a light effect:
>>> await light_effect.set_effect("Party")
>>> await dev.update()
>>> light_effect.effect
Party
If the device supports it you can set custom effects:
>>> if light_effect.has_custom_effects:
>>> effect_list = { "brightness", 50 }
>>> await light_effect.set_custom_effect(effect_list)
>>> light_effect.has_custom_effects # The device in this examples does not support \
custom effects
False
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from ..feature import Feature
from ..module import Module
class LightEffect(Module, ABC):
"""Interface to represent a light effect module."""
LIGHT_EFFECTS_OFF = "Off"
def _initialize_features(self) -> None:
"""Initialize features."""
device = self._device
self._add_feature(
Feature(
device,
id="light_effect",
name="Light effect",
container=self,
attribute_getter="effect",
attribute_setter="set_effect",
category=Feature.Category.Primary,
type=Feature.Type.Choice,
choices_getter="effect_list",
)
)
@property
@abstractmethod
def has_custom_effects(self) -> bool:
"""Return True if the device supports setting custom effects."""
@property
@abstractmethod
def effect(self) -> str:
"""Return effect state or name."""
@property
@abstractmethod
def effect_list(self) -> list[str]:
"""Return built-in effects list.
Example:
['Aurora', 'Bubbling Cauldron', ...]
"""
@abstractmethod
async def set_effect(
self,
effect: str,
*,
brightness: int | None = None,
transition: int | None = None,
) -> dict:
"""Set an effect on the device.
If brightness or transition is defined,
its value will be used instead of the effect-specific default.
See :meth:`effect_list` for available effects,
or use :meth:`set_custom_effect` for custom effects.
:param str effect: The effect to set
:param int brightness: The wanted brightness
:param int transition: The wanted transition time
"""
@abstractmethod
async def set_custom_effect(
self,
effect_dict: dict,
) -> dict:
"""Set a custom effect on the device.
:param str effect_dict: The custom effect dict to set
"""

View File

@@ -0,0 +1,144 @@
"""Interact with TPLink Light Presets.
>>> from kasa import Discover, Module, LightState
>>>
>>> dev = await Discover.discover_single(
>>> "127.0.0.3",
>>> username="user@example.com",
>>> password="great_password"
>>> )
>>> await dev.update()
>>> print(dev.alias)
Living Room Bulb
Light presets are accessed via the LightPreset module. To list available presets
>>> light_preset = dev.modules[Module.LightPreset]
>>> light_preset.preset_list
['Not set', 'Light preset 1', 'Light preset 2', 'Light preset 3',\
'Light preset 4', 'Light preset 5', 'Light preset 6', 'Light preset 7']
To view the currently selected preset:
>>> light_preset.preset
Not set
To view the actual light state for the presets:
>>> len(light_preset.preset_states_list)
7
>>> light_preset.preset_states_list[0]
LightState(light_on=None, brightness=50, hue=0,\
saturation=100, color_temp=2700, transition=None)
To set a preset as active:
>>> dev.modules[Module.Light].state # This is only needed to show the example working
LightState(light_on=True, brightness=100, hue=0,\
saturation=100, color_temp=2700, transition=None)
>>> await light_preset.set_preset("Light preset 1")
>>> await dev.update()
>>> light_preset.preset
Light preset 1
>>> dev.modules[Module.Light].state # This is only needed to show the example working
LightState(light_on=True, brightness=50, hue=0,\
saturation=100, color_temp=2700, transition=None)
You can save a new preset state if the device supports it:
>>> if light_preset.has_save_preset:
>>> new_preset_state = LightState(light_on=True, brightness=75, hue=0,\
saturation=100, color_temp=2700, transition=None)
>>> await light_preset.save_preset("Light preset 1", new_preset_state)
>>> await dev.update()
>>> light_preset.preset # Saving updates the preset state for the preset, it does not \
set the preset
Not set
>>> light_preset.preset_states_list[0]
LightState(light_on=None, brightness=75, hue=0,\
saturation=100, color_temp=2700, transition=None)
If you manually set the light state to a preset state it will show that preset as \
active:
>>> await dev.modules[Module.Light].set_brightness(75)
>>> await dev.update()
>>> light_preset.preset
Light preset 1
"""
from __future__ import annotations
from abc import abstractmethod
from collections.abc import Sequence
from ..feature import Feature
from ..module import Module
from .light import LightState
class LightPreset(Module):
"""Base interface for light preset module."""
PRESET_NOT_SET = "Not set"
def _initialize_features(self) -> None:
"""Initialize features."""
device = self._device
self._add_feature(
Feature(
device,
id="light_preset",
name="Light preset",
container=self,
attribute_getter="preset",
attribute_setter="set_preset",
category=Feature.Category.Config,
type=Feature.Type.Choice,
choices_getter="preset_list",
)
)
@property
@abstractmethod
def preset_list(self) -> list[str]:
"""Return list of preset names.
Example:
['Off', 'Preset 1', 'Preset 2', ...]
"""
@property
@abstractmethod
def preset_states_list(self) -> Sequence[LightState]:
"""Return list of preset states.
Example:
['Off', 'Preset 1', 'Preset 2', ...]
"""
@property
@abstractmethod
def preset(self) -> str:
"""Return current preset name."""
@abstractmethod
async def set_preset(
self,
preset_name: str,
) -> dict:
"""Set a light preset for the device."""
@abstractmethod
async def save_preset(
self,
preset_name: str,
preset_info: LightState,
) -> dict:
"""Update the preset with *preset_name* with the new *preset_info*."""
@property
@abstractmethod
def has_save_preset(self) -> bool:
"""Return True if the device supports updating presets."""

View File

@@ -0,0 +1,65 @@
"""Interact with a TPLink Thermostat."""
from __future__ import annotations
from abc import ABC, abstractmethod
from enum import Enum
from typing import Annotated, Literal
from ..module import FeatureAttribute, Module
class ThermostatState(Enum):
"""Thermostat state."""
Heating = "heating"
Calibrating = "progress_calibration"
Idle = "idle"
Off = "off"
Unknown = "unknown"
class Thermostat(Module, ABC):
"""Base class for TP-Link Thermostat."""
@property
@abstractmethod
def state(self) -> bool:
"""Return thermostat state."""
@abstractmethod
async def set_state(self, enabled: bool) -> dict:
"""Set thermostat state."""
@property
@abstractmethod
def mode(self) -> ThermostatState:
"""Return thermostat state."""
@property
@abstractmethod
def target_temperature(self) -> Annotated[float, FeatureAttribute()]:
"""Return target temperature."""
@abstractmethod
async def set_target_temperature(
self, target: float
) -> Annotated[dict, FeatureAttribute()]:
"""Set target temperature."""
@property
@abstractmethod
def temperature(self) -> Annotated[float, FeatureAttribute()]:
"""Return current humidity in percentage."""
return self._device.sys_info["current_temp"]
@property
@abstractmethod
def temperature_unit(self) -> Literal["celsius", "fahrenheit"]:
"""Return current temperature unit."""
@abstractmethod
async def set_temperature_unit(
self, unit: Literal["celsius", "fahrenheit"]
) -> dict:
"""Set the device temperature unit."""

26
kasa/interfaces/time.py Normal file
View File

@@ -0,0 +1,26 @@
"""Module for time interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime, tzinfo
from ..module import Module
class Time(Module, ABC):
"""Base class for tplink time module."""
@property
@abstractmethod
def time(self) -> datetime:
"""Return timezone aware current device time."""
@property
@abstractmethod
def timezone(self) -> tzinfo:
"""Return current timezone."""
@abstractmethod
async def set_time(self, dt: datetime) -> dict:
"""Set the device time."""

20
kasa/iot/__init__.py Normal file
View File

@@ -0,0 +1,20 @@
"""Package for supporting legacy kasa devices."""
from .iotbulb import IotBulb
from .iotcamera import IotCamera
from .iotdevice import IotDevice
from .iotdimmer import IotDimmer
from .iotlightstrip import IotLightStrip
from .iotplug import IotPlug, IotWallSwitch
from .iotstrip import IotStrip
__all__ = [
"IotDevice",
"IotPlug",
"IotBulb",
"IotStrip",
"IotDimmer",
"IotLightStrip",
"IotWallSwitch",
"IotCamera",
]

View File

@@ -1,6 +1,8 @@
"""Module for light strip effects (LB*, KL*, KB*)."""
from typing import List, cast
from __future__ import annotations
from typing import cast
EFFECT_AURORA = {
"custom": 0,
@@ -292,5 +294,5 @@ EFFECTS_LIST_V1 = [
EFFECT_VALENTINES,
]
EFFECT_NAMES_V1: List[str] = [cast(str, effect["name"]) for effect in EFFECTS_LIST_V1]
EFFECT_NAMES_V1: list[str] = [cast(str, effect["name"]) for effect in EFFECTS_LIST_V1]
EFFECT_MAPPING_V1 = {effect["name"]: effect for effect in EFFECTS_LIST_V1}

View File

@@ -1,50 +1,34 @@
"""Module for bulbs (LB*, KL*, KB*)."""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from enum import Enum
from typing import Any, Dict, List, NamedTuple, Optional, cast
from typing import Annotated, cast
try:
from pydantic.v1 import BaseModel, Field, root_validator
except ImportError:
from pydantic import BaseModel, Field, root_validator
from mashumaro import DataClassDictMixin
from mashumaro.config import BaseConfig
from mashumaro.types import Alias
from .deviceconfig import DeviceConfig
from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage
from .protocol import TPLinkProtocol
from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update
class ColorTempRange(NamedTuple):
"""Color temperature range."""
min: int
max: int
class HSV(NamedTuple):
"""Hue-saturation-value."""
hue: int
saturation: int
value: int
class SmartBulbPreset(BaseModel):
"""Bulb configuration preset."""
index: int
brightness: int
# These are not available for effect mode presets on light strips
hue: Optional[int]
saturation: Optional[int]
color_temp: Optional[int]
# Variables for effect mode presets
custom: Optional[int]
id: Optional[str]
mode: Optional[int]
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..interfaces.light import HSV, ColorTempRange
from ..module import Module
from ..protocols import BaseProtocol
from .iotdevice import IotDevice, KasaException, requires_update
from .modules import (
Antitheft,
Cloud,
Countdown,
Emeter,
Light,
LightPreset,
Schedule,
Time,
Usage,
)
class BehaviorMode(str, Enum):
@@ -54,9 +38,12 @@ class BehaviorMode(str, Enum):
Last = "last_status"
#: Use chosen preset.
Preset = "customize_preset"
#: Circadian
Circadian = "circadian"
class TurnOnBehavior(BaseModel):
@dataclass
class TurnOnBehavior(DataClassDictMixin):
"""Model to present a single turn on behavior.
:param int preset: the index number of wanted preset.
@@ -67,34 +54,30 @@ class TurnOnBehavior(BaseModel):
to contain either the preset index, or ``None`` for the last known state.
"""
#: Index of preset to use, or ``None`` for the last known state.
preset: Optional[int] = Field(alias="index", default=None)
class Config(BaseConfig):
"""Serialization config."""
omit_none = True
serialize_by_alias = True
#: Wanted behavior
mode: BehaviorMode
@root_validator
def _mode_based_on_preset(cls, values):
"""Set the mode based on the preset value."""
if values["preset"] is not None:
values["mode"] = BehaviorMode.Preset
else:
values["mode"] = BehaviorMode.Last
return values
class Config:
"""Configuration to make the validator run when changing the values."""
validate_assignment = True
#: Index of preset to use, or ``None`` for the last known state.
preset: Annotated[int | None, Alias("index")] = None
brightness: int | None = None
color_temp: int | None = None
hue: int | None = None
saturation: int | None = None
class TurnOnBehaviors(BaseModel):
@dataclass
class TurnOnBehaviors(DataClassDictMixin):
"""Model to contain turn on behaviors."""
#: The behavior when the bulb is turned on programmatically.
soft: TurnOnBehavior = Field(alias="soft_on")
soft: Annotated[TurnOnBehavior, Alias("soft_on")]
#: The behavior when the bulb has been off from mains power.
hard: TurnOnBehavior = Field(alias="hard_on")
hard: Annotated[TurnOnBehavior, Alias("hard_on")]
TPLINK_KELVIN = {
@@ -104,7 +87,7 @@ TPLINK_KELVIN = {
"KB130": ColorTempRange(2500, 9000),
"KL130": ColorTempRange(2500, 9000),
"KL125": ColorTempRange(2500, 6500),
"KL135": ColorTempRange(2500, 6500),
"KL135": ColorTempRange(2500, 9000),
r"KL120\(EU\)": ColorTempRange(2700, 6500),
r"KL120\(US\)": ColorTempRange(2700, 5000),
r"KL430": ColorTempRange(2500, 9000),
@@ -116,7 +99,7 @@ NON_COLOR_MODE_FLAGS = {"transition_period", "on_off"}
_LOGGER = logging.getLogger(__name__)
class SmartBulb(SmartDevice):
class IotBulb(IotDevice):
r"""Representation of a TP-Link Smart Bulb.
To initialize, you have to await :func:`update()` at least once.
@@ -127,12 +110,12 @@ class SmartBulb(SmartDevice):
so you must await :func:`update()` to fetch updates values from the device.
Errors reported by the device are raised as
:class:`SmartDeviceExceptions <kasa.exceptions.SmartDeviceException>`,
:class:`KasaException <kasa.exceptions.KasaException>`,
and should be handled by the user of the library.
Examples:
>>> import asyncio
>>> bulb = SmartBulb("127.0.0.1")
>>> bulb = IotBulb("127.0.0.1")
>>> asyncio.run(bulb.update())
>>> print(bulb.alias)
Bulb2
@@ -197,10 +180,10 @@ class SmartBulb(SmartDevice):
Bulb configuration presets can be accessed using the :func:`presets` property:
>>> bulb.presets
[SmartBulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), SmartBulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)]
>>> [ preset.to_dict() for preset in bulb.presets }
[{'brightness': 50, 'hue': 0, 'saturation': 0, 'color_temp': 2700, 'index': 0}, {'brightness': 100, 'hue': 0, 'saturation': 75, 'color_temp': 0, 'index': 1}, {'brightness': 100, 'hue': 120, 'saturation': 75, 'color_temp': 0, 'index': 2}, {'brightness': 100, 'hue': 240, 'saturation': 75, 'color_temp': 0, 'index': 3}]
To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset`
To modify an existing preset, pass :class:`~kasa.interfaces.light.LightPreset`
instance to :func:`save_preset` method:
>>> preset = bulb.presets[0]
@@ -208,6 +191,7 @@ class SmartBulb(SmartDevice):
50
>>> preset.brightness = 100
>>> asyncio.run(bulb.save_preset(preset))
>>> asyncio.run(bulb.update())
>>> bulb.presets[0].brightness
100
@@ -221,49 +205,59 @@ class SmartBulb(SmartDevice):
self,
host: str,
*,
config: Optional[DeviceConfig] = None,
protocol: Optional[TPLinkProtocol] = None,
config: DeviceConfig | None = None,
protocol: BaseProtocol | None = None,
) -> None:
super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.Bulb
self.add_module("schedule", Schedule(self, "smartlife.iot.common.schedule"))
self.add_module("usage", Usage(self, "smartlife.iot.common.schedule"))
self.add_module("antitheft", Antitheft(self, "smartlife.iot.common.anti_theft"))
self.add_module("time", Time(self, "smartlife.iot.common.timesetting"))
self.add_module("emeter", Emeter(self, self.emeter_type))
self.add_module("countdown", Countdown(self, "countdown"))
self.add_module("cloud", Cloud(self, "smartlife.iot.common.cloud"))
async def _initialize_modules(self) -> None:
"""Initialize modules not added in init."""
await super()._initialize_modules()
self.add_module(
Module.IotSchedule, Schedule(self, "smartlife.iot.common.schedule")
)
self.add_module(Module.IotUsage, Usage(self, "smartlife.iot.common.schedule"))
self.add_module(
Module.IotAntitheft, Antitheft(self, "smartlife.iot.common.anti_theft")
)
self.add_module(Module.Time, Time(self, "smartlife.iot.common.timesetting"))
self.add_module(Module.Energy, Emeter(self, self.emeter_type))
self.add_module(Module.IotCountdown, Countdown(self, "countdown"))
self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud"))
self.add_module(Module.Light, Light(self, self.LIGHT_SERVICE))
self.add_module(Module.LightPreset, LightPreset(self, self.LIGHT_SERVICE))
@property # type: ignore
@requires_update
def is_color(self) -> bool:
def _is_color(self) -> bool:
"""Whether the bulb supports color changes."""
sys_info = self.sys_info
return bool(sys_info["is_color"])
@property # type: ignore
@requires_update
def is_dimmable(self) -> bool:
def _is_dimmable(self) -> bool:
"""Whether the bulb supports brightness changes."""
sys_info = self.sys_info
return bool(sys_info["is_dimmable"])
@property # type: ignore
@requires_update
def is_variable_color_temp(self) -> bool:
def _is_variable_color_temp(self) -> bool:
"""Whether the bulb supports color temperature changes."""
sys_info = self.sys_info
return bool(sys_info["is_variable_color_temp"])
@property # type: ignore
@requires_update
def valid_temperature_range(self) -> ColorTempRange:
def _valid_temperature_range(self) -> ColorTempRange:
"""Return the device-specific white temperature range (in Kelvin).
:return: White temperature range in Kelvin (minimum, maximum)
"""
if not self.is_variable_color_temp:
raise SmartDeviceException("Color temperature not supported")
if not self._is_variable_color_temp:
raise KasaException("Color temperature not supported")
for model, temp_range in TPLINK_KELVIN.items():
sys_info = self.sys_info
@@ -275,11 +269,11 @@ class SmartBulb(SmartDevice):
@property # type: ignore
@requires_update
def light_state(self) -> Dict[str, str]:
def light_state(self) -> dict[str, str]:
"""Query the light state."""
light_state = self.sys_info["light_state"]
if light_state is None:
raise SmartDeviceException(
raise KasaException(
"The device has no light_state or you have not called update()"
)
@@ -294,11 +288,11 @@ class SmartBulb(SmartDevice):
@property # type: ignore
@requires_update
def has_effects(self) -> bool:
def _has_effects(self) -> bool:
"""Return True if the device supports effects."""
return "lighting_effect_state" in self.sys_info
async def get_light_details(self) -> Dict[str, int]:
async def get_light_details(self) -> dict[str, int]:
"""Return light details.
Example::
@@ -311,32 +305,36 @@ class SmartBulb(SmartDevice):
async def get_turn_on_behavior(self) -> TurnOnBehaviors:
"""Return the behavior for turning the bulb on."""
return TurnOnBehaviors.parse_obj(
return TurnOnBehaviors.from_dict(
await self._query_helper(self.LIGHT_SERVICE, "get_default_behavior")
)
async def set_turn_on_behavior(self, behavior: TurnOnBehaviors):
async def set_turn_on_behavior(self, behavior: TurnOnBehaviors) -> dict:
"""Set the behavior for turning the bulb on.
If you do not want to manually construct the behavior object,
you should use :func:`get_turn_on_behavior` to get the current settings.
"""
return await self._query_helper(
self.LIGHT_SERVICE, "set_default_behavior", behavior.dict(by_alias=True)
self.LIGHT_SERVICE, "set_default_behavior", behavior.to_dict()
)
async def get_light_state(self) -> Dict[str, Dict]:
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, *, transition: Optional[int] = None
) -> Dict:
async def _set_light_state(
self, state: dict, *, transition: int | None = None
) -> dict:
"""Set the light state."""
state = {**state}
if transition is not None:
state["transition_period"] = transition
if "brightness" in state:
self._raise_for_invalid_brightness(state["brightness"])
# if no on/off is defined, turn on the light
if "on_off" not in state:
state["on_off"] = 1
@@ -357,35 +355,33 @@ class SmartBulb(SmartDevice):
@property # type: ignore
@requires_update
def hsv(self) -> HSV:
def _hsv(self) -> HSV:
"""Return the current HSV state of the bulb.
:return: hue, saturation and value (degrees, %, %)
"""
if not self.is_color:
raise SmartDeviceException("Bulb does not support color.")
if not self._is_color:
raise KasaException("Bulb does not support color.")
light_state = cast(dict, self.light_state)
hue = light_state["hue"]
saturation = light_state["saturation"]
value = light_state["brightness"]
value = self._brightness
return HSV(hue, saturation, value)
def _raise_for_invalid_brightness(self, value):
if not isinstance(value, int) or not (0 <= value <= 100):
raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)")
# Simple HSV(hue, saturation, value) is less efficent than below
# due to the cpython implementation.
return tuple.__new__(HSV, (hue, saturation, value))
@requires_update
async def set_hsv(
async def _set_hsv(
self,
hue: int,
saturation: int,
value: Optional[int] = None,
value: int | None = None,
*,
transition: Optional[int] = None,
) -> Dict:
transition: int | None = None,
) -> dict:
"""Set new HSV.
:param int hue: hue in degrees
@@ -393,13 +389,17 @@ class SmartBulb(SmartDevice):
: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.")
if not self._is_color:
raise KasaException("Bulb does not support color.")
if not isinstance(hue, int) or not (0 <= hue <= 360):
if not isinstance(hue, int):
raise TypeError("Hue must be an integer.")
if not (0 <= hue <= 360):
raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)")
if not isinstance(saturation, int) or not (0 <= saturation <= 100):
if not isinstance(saturation, int):
raise TypeError("Saturation must be an integer.")
if not (0 <= saturation <= 100):
raise ValueError(
f"Invalid saturation value: {saturation} (valid range: 0-100%)"
)
@@ -414,31 +414,31 @@ class SmartBulb(SmartDevice):
self._raise_for_invalid_brightness(value)
light_state["brightness"] = value
return await self.set_light_state(light_state, transition=transition)
return await self._set_light_state(light_state, transition=transition)
@property # type: ignore
@requires_update
def color_temp(self) -> int:
def _color_temp(self) -> int:
"""Return color temperature of the device in kelvin."""
if not self.is_variable_color_temp:
raise SmartDeviceException("Bulb does not support colortemp.")
if not self._is_variable_color_temp:
raise KasaException("Bulb does not support colortemp.")
light_state = self.light_state
return int(light_state["color_temp"])
@requires_update
async def set_color_temp(
self, temp: int, *, brightness=None, transition: Optional[int] = None
) -> Dict:
async def _set_color_temp(
self, temp: int, *, brightness: int | None = None, transition: int | None = 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.")
if not self._is_variable_color_temp:
raise KasaException("Bulb does not support colortemp.")
valid_temperature_range = self.valid_temperature_range
valid_temperature_range = self._valid_temperature_range
if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]:
raise ValueError(
"Temperature should be between {} and {}, was {}".format(
@@ -450,51 +450,47 @@ class SmartBulb(SmartDevice):
if brightness is not None:
light_state["brightness"] = brightness
return await self.set_light_state(light_state, transition=transition)
return await self._set_light_state(light_state, transition=transition)
def _raise_for_invalid_brightness(self, value: int) -> None:
if not isinstance(value, int):
raise TypeError("Brightness must be an integer")
if not (0 <= value <= 100):
raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)")
@property # type: ignore
@requires_update
def brightness(self) -> int:
def _brightness(self) -> int:
"""Return the current brightness in percentage."""
if not self.is_dimmable: # pragma: no cover
raise SmartDeviceException("Bulb is not dimmable.")
if not self._is_dimmable: # pragma: no cover
raise KasaException("Bulb is not dimmable.")
# If the device supports effects and one is active, we get the brightness
# from the effect. This is not required when setting the brightness as
# the device handles it via set_light_state
if (
light_effect := self.modules.get(Module.IotLightEffect)
) is not None and light_effect.effect != light_effect.LIGHT_EFFECTS_OFF:
return light_effect.brightness
light_state = self.light_state
return int(light_state["brightness"])
@requires_update
async def set_brightness(
self, brightness: int, *, transition: Optional[int] = None
) -> Dict:
async def _set_brightness(
self, brightness: int, *, transition: int | None = 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.")
if not self._is_dimmable: # pragma: no cover
raise KasaException("Bulb is not dimmable.")
self._raise_for_invalid_brightness(brightness)
light_state = {"brightness": brightness}
return await self.set_light_state(light_state, transition=transition)
@property # type: ignore
@requires_update
def state_information(self) -> Dict[str, Any]:
"""Return bulb-specific state information."""
info: Dict[str, Any] = {
"Brightness": self.brightness,
"Is dimmable": self.is_dimmable,
}
if self.is_variable_color_temp:
info["Color temperature"] = self.color_temp
info["Valid temperature range"] = self.valid_temperature_range
if self.is_color:
info["HSV"] = self.hsv
info["Presets"] = self.presets
return info
return await self._set_light_state(light_state, transition=transition)
@property # type: ignore
@requires_update
@@ -503,19 +499,19 @@ class SmartBulb(SmartDevice):
light_state = self.light_state
return bool(light_state["on_off"])
async def turn_off(self, *, transition: Optional[int] = None, **kwargs) -> Dict:
async def turn_off(self, *, transition: int | None = None, **kwargs) -> dict:
"""Turn the bulb off.
:param int transition: transition in milliseconds.
"""
return await self.set_light_state({"on_off": 0}, transition=transition)
return await self._set_light_state({"on_off": 0}, transition=transition)
async def turn_on(self, *, transition: Optional[int] = None, **kwargs) -> Dict:
async def turn_on(self, *, transition: int | None = None, **kwargs) -> dict:
"""Turn the bulb on.
:param int transition: transition in milliseconds.
"""
return await self.set_light_state({"on_off": 1}, transition=transition)
return await self._set_light_state({"on_off": 1}, transition=transition)
@property # type: ignore
@requires_update
@@ -523,7 +519,7 @@ class SmartBulb(SmartDevice):
"""Return that the bulb has an emeter."""
return True
async def set_alias(self, alias: str) -> None:
async def set_alias(self, alias: str) -> dict:
"""Set the device name (alias).
Overridden to use a different module name.
@@ -532,28 +528,6 @@ class SmartBulb(SmartDevice):
"smartlife.iot.common.system", "set_dev_alias", {"alias": alias}
)
@property # type: ignore
@requires_update
def presets(self) -> List[SmartBulbPreset]:
"""Return a list of available bulb setting presets."""
return [SmartBulbPreset(**vals) for vals in self.sys_info["preferred_state"]]
async def save_preset(self, preset: SmartBulbPreset):
"""Save a setting preset.
You can either construct a preset object manually, or pass an existing one
obtained using :func:`presets`.
"""
if len(self.presets) == 0:
raise SmartDeviceException("Device does not supported saving presets")
if preset.index >= len(self.presets):
raise SmartDeviceException("Invalid preset index")
return await self._query_helper(
self.LIGHT_SERVICE, "set_preferred_state", preset.dict(exclude_none=True)
)
@property
def max_device_response_size(self) -> int:
"""Returns the maximum response size the device can safely construct."""

42
kasa/iot/iotcamera.py Normal file
View File

@@ -0,0 +1,42 @@
"""Module for cameras."""
from __future__ import annotations
import logging
from datetime import datetime, tzinfo
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..protocols import BaseProtocol
from .iotdevice import IotDevice
_LOGGER = logging.getLogger(__name__)
class IotCamera(IotDevice):
"""Representation of a TP-Link Camera."""
def __init__(
self,
host: str,
*,
config: DeviceConfig | None = None,
protocol: BaseProtocol | None = None,
) -> None:
super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.Camera
@property
def time(self) -> datetime:
"""Get the camera's time."""
return datetime.fromtimestamp(self.sys_info["system_time"])
@property
def timezone(self) -> tzinfo:
"""Get the camera's timezone."""
return None # type: ignore
@property # type: ignore
def is_on(self) -> bool:
"""Return whether device is on."""
return True

777
kasa/iot/iotdevice.py Executable file
View File

@@ -0,0 +1,777 @@
"""Python library supporting TP-Link Smart Home devices.
The communication protocol was reverse engineered by Lubomir Stroetmann and
Tobias Esser in 'Reverse Engineering the TP-Link HS110':
https://www.softscheck.com/en/reverse-engineering-tp-link-hs110/
This library reuses codes and concepts of the TP-Link WiFi SmartPlug Client
at https://github.com/softScheck/tplink-smartplug, developed by Lubomir
Stroetmann which is licensed under the Apache License, Version 2.0.
You may obtain a copy of the license at
http://www.apache.org/licenses/LICENSE-2.0
"""
from __future__ import annotations
import functools
import inspect
import logging
from collections.abc import Callable, Mapping, Sequence
from datetime import datetime, timedelta, tzinfo
from typing import TYPE_CHECKING, Any, cast
from warnings import warn
from ..device import Device, DeviceInfo, WifiNetwork
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..exceptions import KasaException
from ..feature import Feature
from ..module import Module
from ..modulemapping import ModuleMapping, ModuleName
from ..protocols import BaseProtocol
from .iotmodule import IotModule, merge
from .modules import Emeter
_LOGGER = logging.getLogger(__name__)
def requires_update(f: Callable) -> Any:
"""Indicate that `update` should be called before accessing this method.""" # noqa: D202
if inspect.iscoroutinefunction(f):
@functools.wraps(f)
async def wrapped(*args: Any, **kwargs: Any) -> Any:
self = args[0]
if not self._last_update and (
self._sys_info is None or f.__name__ not in self._sys_info
):
raise KasaException("You need to await update() to access the data")
return await f(*args, **kwargs)
else:
@functools.wraps(f)
def wrapped(*args: Any, **kwargs: Any) -> Any:
self = args[0]
if not self._last_update and (
self._sys_info is None or f.__name__ not in self._sys_info
):
raise KasaException("You need to await update() to access the data")
return f(*args, **kwargs)
f.requires_update = True # type: ignore[attr-defined]
return wrapped
@functools.lru_cache
def _parse_features(features: str) -> set[str]:
"""Parse features string."""
return set(features.split(":"))
def _extract_sys_info(info: dict[str, Any]) -> dict[str, Any]:
"""Return the system info structure."""
sysinfo_default = info.get("system", {}).get("get_sysinfo", {})
sysinfo_nest = sysinfo_default.get("system", {})
if len(sysinfo_nest) > len(sysinfo_default) and isinstance(sysinfo_nest, dict):
return sysinfo_nest
return sysinfo_default
class IotDevice(Device):
"""Base class for all supported device types.
You don't usually want to initialize this class manually,
but either use :class:`Discover` class, or use one of the subclasses:
* :class:`IotPlug`
* :class:`IotBulb`
* :class:`IotStrip`
* :class:`IotDimmer`
* :class:`IotLightStrip`
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
:class:`KasaException <kasa.exceptions.KasaException>`,
and should be handled by the user of the library.
Examples:
>>> import asyncio
>>> dev = IotDevice("127.0.0.1")
>>> asyncio.run(dev.update())
All devices provide several informational properties:
>>> dev.alias
Bedroom Lamp Plug
>>> dev.model
HS110
>>> dev.rssi
-71
>>> dev.mac
50:C7:BF:00:00:00
Some information can also be changed programmatically:
>>> 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': '00000000000000000000000000000000',
'fwId': '00000000000000000000000000000000',
'oemId': '00000000000000000000000000000000',
'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
<EmeterStatus power=0.928511 voltage=231.067823 current=0.014937 total=55.139>
>>> 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}
"""
emeter_type = "emeter"
def __init__(
self,
host: str,
*,
config: DeviceConfig | None = None,
protocol: BaseProtocol | None = None,
) -> None:
"""Create a new IotDevice instance."""
super().__init__(host=host, config=config, protocol=protocol)
self._sys_info: Any = None # TODO: this is here to avoid changing tests
self._supported_modules: dict[str | ModuleName[Module], IotModule] | None = None
self._legacy_features: set[str] = set()
self._children: Mapping[str, IotDevice] = {}
self._modules: dict[str | ModuleName[Module], IotModule] = {}
self._on_since: datetime | None = None
@property
def children(self) -> Sequence[IotDevice]:
"""Return list of children."""
return list(self._children.values())
@property
@requires_update
def modules(self) -> ModuleMapping[IotModule]:
"""Return the device modules."""
if TYPE_CHECKING:
return cast(ModuleMapping[IotModule], self._supported_modules)
return self._supported_modules
def add_module(self, name: str | ModuleName[Module], module: IotModule) -> None:
"""Register a module."""
if name in self._modules:
_LOGGER.debug("Module %s already registered, ignoring...", name)
return
_LOGGER.debug("Adding module %s", module)
self._modules[name] = module
def _create_request(
self,
target: str,
cmd: str,
arg: dict | None = None,
child_ids: list | None = None,
) -> dict:
if arg is None:
arg = {}
request: dict[str, Any] = {target: {cmd: arg}}
if child_ids is not None:
request = {"context": {"child_ids": child_ids}, target: {cmd: arg}}
return request
def _verify_emeter(self) -> None:
"""Raise an exception if there is no emeter."""
if not self.has_emeter:
raise KasaException("Device has no emeter")
if self.emeter_type not in self._last_update:
raise KasaException("update() required prior accessing emeter")
async def _query_helper(
self,
target: str,
cmd: str,
arg: dict | None = None,
child_ids: list | None = None,
) -> dict:
"""Query device, return results or raise an exception.
:param target: Target system {system, time, emeter, ..}
:param cmd: Command to execute
:param arg: payload dict to be send to the device
:param child_ids: ids of child devices
:return: Unwrapped result for the call.
"""
request = self._create_request(target, cmd, arg, child_ids)
try:
response = await self._raw_query(request=request)
except Exception as ex:
raise KasaException(f"Communication error on {target}:{cmd}") from ex
if target not in response:
raise KasaException(f"No required {target} in response: {response}")
result = response[target]
if "err_code" in result and result["err_code"] != 0:
raise KasaException(f"Error on {target}.{cmd}: {result}")
if cmd not in result:
raise KasaException(f"No command in response: {response}")
result = result[cmd]
if "err_code" in result and result["err_code"] != 0:
raise KasaException(f"Error on {target} {cmd}: {result}")
if "err_code" in result:
del result["err_code"]
return result
@property # type: ignore
@requires_update
def features(self) -> dict[str, Feature]:
"""Return a set of features that the device supports."""
return self._features
@property # type: ignore
@requires_update
def has_emeter(self) -> bool:
"""Return True if device has an energy meter."""
return "ENE" in self._legacy_features
async def get_sys_info(self) -> dict[str, Any]:
"""Retrieve system information."""
return await self._query_helper("system", "get_sysinfo")
async def update(self, update_children: bool = True) -> None:
"""Query the device to update the data.
Needed for properties that are decorated with `requires_update`.
"""
req = {}
req.update(self._create_request("system", "get_sysinfo"))
# If this is the initial update, check only for the sysinfo
# This is necessary as some devices crash on unexpected modules
# See #105, #120, #161
if not self._last_update:
_LOGGER.debug("Performing the initial update to obtain sysinfo")
response = await self.protocol.query(req)
self._last_update = response
self._set_sys_info(_extract_sys_info(response))
if not self._modules:
await self._initialize_modules()
await self._modular_update(req)
self._set_sys_info(_extract_sys_info(self._last_update))
for module in self._modules.values():
await module._post_update_hook()
if not self._features:
await self._initialize_features()
async def _initialize_modules(self) -> None:
"""Initialize modules not added in init."""
if self.has_emeter:
_LOGGER.debug(
"The device has emeter, querying its information along sysinfo"
)
self.add_module(Module.Energy, Emeter(self, self.emeter_type))
async def _initialize_features(self) -> None:
"""Initialize common features."""
self._add_feature(
Feature(
self,
id="state",
name="State",
attribute_getter="is_on",
attribute_setter="set_state",
type=Feature.Type.Switch,
category=Feature.Category.Primary,
)
)
self._add_feature(
Feature(
device=self,
id="rssi",
name="RSSI",
attribute_getter="rssi",
icon="mdi:signal",
unit_getter=lambda: "dBm",
category=Feature.Category.Debug,
type=Feature.Type.Sensor,
)
)
# iot strips calculate on_since from the children
if "on_time" in self._sys_info or self.device_type == Device.Type.Strip:
self._add_feature(
Feature(
device=self,
id="on_since",
name="On since",
attribute_getter="on_since",
icon="mdi:clock",
category=Feature.Category.Info,
type=Feature.Type.Sensor,
)
)
self._add_feature(
Feature(
device=self,
id="reboot",
name="Reboot",
attribute_setter="reboot",
icon="mdi:restart",
category=Feature.Category.Debug,
type=Feature.Type.Action,
)
)
for module in self.modules.values():
module._initialize_features()
for module_feat in module._module_features.values():
self._add_feature(module_feat)
async def _modular_update(self, req: dict) -> None:
"""Execute an update query."""
request_list = []
est_response_size = 1024 if "system" in req else 0
for module in self._modules.values():
if not module.is_supported:
_LOGGER.debug("Module %s not supported, skipping", module)
continue
est_response_size += module.estimated_query_response_size
if est_response_size > self.max_device_response_size:
request_list.append(req)
req = {}
est_response_size = module.estimated_query_response_size
q = module.query()
_LOGGER.debug("Adding query for %s: %s", module, q)
req = merge(req, q)
request_list.append(req)
responses = [
await self.protocol.query(request) for request in request_list if request
]
# Preserve the last update and merge
# responses on top of it so we remember
# which modules are not supported, otherwise
# every other update will query for them
update: dict = self._last_update.copy() if self._last_update else {}
for response in responses:
for k, v in response.items():
# The same module could have results in different responses
# i.e. smartlife.iot.common.schedule for Usage and
# Schedule, so need to call update(**v) here. If a module is
# not supported the response
# {'err_code': -1, 'err_msg': 'module not support'}
# become top level key/values of the response so check for dict
if isinstance(v, dict):
update.setdefault(k, {}).update(**v)
self._last_update = update
# IOT modules are added as default but could be unsupported post first update
if self._supported_modules is None:
supported = {}
for module_name, module in self._modules.items():
if module.is_supported:
supported[module_name] = module
self._supported_modules = supported
def update_from_discover_info(self, info: dict[str, Any]) -> None:
"""Update state from info from the discover call."""
self._discovery_info = info
if "system" in info and (sys_info := info["system"].get("get_sysinfo")):
self._last_update = info
self._set_sys_info(sys_info)
else:
# This allows setting of some info properties directly
# from partial discovery info that will then be found
# by the requires_update decorator
discovery_model = info["device_model"]
no_region_model, _, _ = discovery_model.partition("(")
self._set_sys_info({**info, "model": no_region_model})
def _set_sys_info(self, sys_info: dict[str, Any]) -> None:
"""Set sys_info."""
self._sys_info = sys_info
if features := sys_info.get("feature"):
self._legacy_features = _parse_features(features)
@property # type: ignore
@requires_update
def sys_info(self) -> dict[str, Any]:
"""
Return system information.
Do not call this function from within the SmartDevice
class itself as @requires_update will be affected for other properties.
"""
return self._sys_info # type: ignore
@property
@requires_update
def model(self) -> str:
"""Returns the device model."""
if self._last_update:
return self.device_info.short_name
return self._sys_info["model"]
@property # type: ignore
def alias(self) -> str | None:
"""Return device name (alias)."""
sys_info = self._sys_info
return sys_info.get("alias") if sys_info else None
async def set_alias(self, alias: str) -> dict:
"""Set the device name (alias)."""
return await self._query_helper("system", "set_dev_alias", {"alias": alias})
@property
@requires_update
def time(self) -> datetime:
"""Return current time from the device."""
return self.modules[Module.Time].time
@property
@requires_update
def timezone(self) -> tzinfo:
"""Return the current timezone."""
return self.modules[Module.Time].timezone
async def get_time(self) -> datetime:
"""Return current time from the device, if available."""
msg = "Use `time` property instead, this call will be removed in the future."
warn(msg, DeprecationWarning, stacklevel=2)
return self.time
async def get_timezone(self) -> tzinfo:
"""Return timezone information."""
msg = (
"Use `timezone` property instead, this call will be removed in the future."
)
warn(msg, DeprecationWarning, stacklevel=2)
return self.timezone
@property # type: ignore
@requires_update
def hw_info(self) -> dict:
"""Return hardware information.
This returns just a selection of sysinfo keys that are related to hardware.
"""
keys = [
"sw_ver",
"hw_ver",
"mac",
"mic_mac",
"type",
"mic_type",
"hwId",
"fwId",
"oemId",
"dev_name",
]
sys_info = self._sys_info
return {key: sys_info[key] for key in keys if key in sys_info}
@property # type: ignore
@requires_update
def location(self) -> dict:
"""Return geographical location."""
sys_info = self._sys_info
loc = {"latitude": None, "longitude": None}
if "latitude" in sys_info and "longitude" in sys_info:
loc["latitude"] = sys_info["latitude"]
loc["longitude"] = sys_info["longitude"]
elif "latitude_i" in sys_info and "longitude_i" in sys_info:
loc["latitude"] = sys_info["latitude_i"] / 10000
loc["longitude"] = sys_info["longitude_i"] / 10000
else:
_LOGGER.debug("Unsupported device location.")
return loc
@property # type: ignore
@requires_update
def rssi(self) -> int | None:
"""Return WiFi signal strength (rssi)."""
rssi = self._sys_info.get("rssi")
return None if rssi is None else int(rssi)
@property # type: ignore
@requires_update
def mac(self) -> str:
"""Return mac address.
:return: mac address in hexadecimal with colons, e.g. 01:23:45:67:89:ab
"""
sys_info = self._sys_info
mac = sys_info.get("mac", sys_info.get("mic_mac"))
if not mac:
raise KasaException(
"Unknown mac, please submit a bug report with sys_info output."
)
mac = mac.replace("-", ":")
# Format a mac that has no colons (usually from mic_mac field)
if ":" not in mac:
mac = ":".join(format(s, "02x") for s in bytes.fromhex(mac))
return mac
async def set_mac(self, mac: str) -> dict:
"""Set the mac address.
:param str mac: mac in hexadecimal with colons, e.g. 01:23:45:67:89:ab
"""
return await self._query_helper("system", "set_mac_addr", {"mac": mac})
async def reboot(self, delay: int = 1) -> None:
"""Reboot the device.
Note that giving a delay of zero causes this to block,
as the device reboots immediately without responding to the call.
"""
await self._query_helper("system", "reboot", {"delay": delay})
async def factory_reset(self) -> None:
"""Reset device back to factory settings.
Note, this does not downgrade the firmware.
"""
await self._query_helper("system", "reset")
async def turn_off(self, **kwargs) -> dict:
"""Turn off the device."""
raise NotImplementedError("Device subclass needs to implement this.")
async def turn_on(self, **kwargs) -> dict:
"""Turn device on."""
raise NotImplementedError("Device subclass needs to implement this.")
@property # type: ignore
@requires_update
def is_on(self) -> bool:
"""Return True if the device is on."""
raise NotImplementedError("Device subclass needs to implement this.")
async def set_state(self, on: bool) -> dict:
"""Set the device state."""
if on:
return await self.turn_on()
else:
return await self.turn_off()
@property # type: ignore
@requires_update
def on_since(self) -> datetime | None:
"""Return the time that the device was turned on or None if turned off.
This returns a cached value if the device reported value difference is under
five seconds to avoid device-caused jitter.
"""
if self.is_off or "on_time" not in self._sys_info:
self._on_since = None
return None
on_time = self._sys_info["on_time"]
on_since = self.time - timedelta(seconds=on_time)
if not self._on_since or timedelta(
seconds=0
) < on_since - self._on_since > timedelta(seconds=5):
self._on_since = on_since
return self._on_since
@property # type: ignore
@requires_update
def device_id(self) -> str:
"""Return unique ID for the device.
If not overridden, this is the MAC address of the device.
Individual sockets on strips will override this.
"""
return self.mac
async def wifi_scan(self) -> list[WifiNetwork]: # noqa: D202
"""Scan for available wifi networks."""
async def _scan(target: str) -> dict:
return await self._query_helper(target, "get_scaninfo", {"refresh": 1})
try:
info = await _scan("netif")
except KasaException as ex:
_LOGGER.debug(
"Unable to scan using 'netif', retrying with 'softaponboarding': %s", ex
)
info = await _scan("smartlife.iot.common.softaponboarding")
if "ap_list" not in info:
raise KasaException(f"Invalid response for wifi scan: {info}")
return [WifiNetwork(**x) for x in info["ap_list"]]
async def wifi_join(self, ssid: str, password: str, keytype: str = "3") -> dict: # noqa: D202
"""Join the given wifi network.
If joining the network fails, the device will return to AP mode after a while.
"""
async def _join(target: str, payload: dict) -> dict:
return await self._query_helper(target, "set_stainfo", payload)
payload = {"ssid": ssid, "password": password, "key_type": int(keytype)}
try:
return await _join("netif", payload)
except KasaException as ex:
_LOGGER.debug(
"Unable to join using 'netif', retrying with 'softaponboarding': %s", ex
)
return await _join("smartlife.iot.common.softaponboarding", payload)
@property
def max_device_response_size(self) -> int:
"""Returns the maximum response size the device can safely construct."""
return 16 * 1024
@property
def internal_state(self) -> Any:
"""Return the internal state of the instance.
The returned object contains the raw results from the last update call.
This should only be used for debugging purposes.
"""
return self._last_update or self._discovery_info
@staticmethod
def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType:
"""Find SmartDevice subclass for device described by passed data."""
if "system" in info.get("system", {}).get("get_sysinfo", {}):
return DeviceType.Camera
if "system" not in info or "get_sysinfo" not in info["system"]:
raise KasaException("No 'system' or 'get_sysinfo' in response")
sysinfo: dict[str, Any] = _extract_sys_info(info)
type_: str | None = sysinfo.get("type", sysinfo.get("mic_type"))
if type_ is None:
raise KasaException("Unable to find the device type field!")
if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]:
return DeviceType.Dimmer
if "smartplug" in type_.lower():
if "children" in sysinfo:
return DeviceType.Strip
if (dev_name := sysinfo.get("dev_name")) and "light" in dev_name.lower():
return DeviceType.WallSwitch
return DeviceType.Plug
if "smartbulb" in type_.lower():
if "length" in sysinfo: # strips have length
return DeviceType.LightStrip
return DeviceType.Bulb
_LOGGER.warning("Unknown device type %s, falling back to plug", type_)
return DeviceType.Plug
@staticmethod
def _get_device_info(
info: dict[str, Any], discovery_info: dict[str, Any] | None
) -> DeviceInfo:
"""Get model information for a device."""
sys_info = _extract_sys_info(info)
# Get model and region info
region = None
device_model = sys_info["model"]
long_name, _, region = device_model.partition("(")
if region: # All iot devices have region but just in case
region = region.replace(")", "")
# Get other info
device_family = sys_info.get("type", sys_info.get("mic_type"))
device_type = IotDevice._get_device_type_from_sys_info(info)
fw_version_full = sys_info["sw_ver"]
firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1)
auth = bool(discovery_info and ("mgt_encrypt_schm" in discovery_info))
return DeviceInfo(
short_name=long_name,
long_name=long_name,
brand="kasa",
device_family=device_family,
device_type=device_type,
hardware_version=sys_info["hw_ver"],
firmware_version=firmware_version,
firmware_build=firmware_build,
requires_auth=auth,
region=region,
)

View File

@@ -1,12 +1,17 @@
"""Module for dimmers (currently only HS220)."""
from enum import Enum
from typing import Any, Dict, Optional
from kasa.deviceconfig import DeviceConfig
from kasa.modules import AmbientLight, Motion
from kasa.protocol import TPLinkProtocol
from kasa.smartdevice import DeviceType, SmartDeviceException, requires_update
from kasa.smartplug import SmartPlug
from __future__ import annotations
from enum import Enum
from typing import Any
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..module import Module
from ..protocols import BaseProtocol
from .iotdevice import KasaException, requires_update
from .iotplug import IotPlug
from .modules import AmbientLight, Light, Motion
class ButtonAction(Enum):
@@ -32,7 +37,7 @@ class FadeType(Enum):
FadeOff = "fade_off"
class SmartDimmer(SmartPlug):
class IotDimmer(IotPlug):
r"""Representation of a TP-Link Smart Dimmer.
Dimmers work similarly to plugs, but provide also support for
@@ -45,12 +50,12 @@ class SmartDimmer(SmartPlug):
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,
Errors reported by the device are raised as :class:`KasaException`\s,
and should be handled by the user of the library.
Examples:
>>> import asyncio
>>> dimmer = SmartDimmer("192.168.1.105")
>>> dimmer = IotDimmer("192.168.1.105")
>>> asyncio.run(dimmer.turn_on())
>>> dimmer.brightness
25
@@ -69,40 +74,45 @@ class SmartDimmer(SmartPlug):
self,
host: str,
*,
config: Optional[DeviceConfig] = None,
protocol: Optional[TPLinkProtocol] = None,
config: DeviceConfig | None = None,
protocol: BaseProtocol | None = None,
) -> None:
super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.Dimmer
async def _initialize_modules(self) -> None:
"""Initialize modules."""
await super()._initialize_modules()
# TODO: need to be verified if it's okay to call these on HS220 w/o these
# TODO: need to be figured out what's the best approach to detect support
self.add_module("motion", Motion(self, "smartlife.iot.PIR"))
self.add_module("ambient", AmbientLight(self, "smartlife.iot.LAS"))
self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR"))
self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS"))
self.add_module(Module.Light, Light(self, "light"))
@property # type: ignore
@requires_update
def brightness(self) -> int:
def _brightness(self) -> int:
"""Return current brightness on dimmers.
Will return a range between 0 - 100.
"""
if not self.is_dimmable:
raise SmartDeviceException("Device is not dimmable.")
if not self._is_dimmable:
raise KasaException("Device is not dimmable.")
sys_info = self.sys_info
return int(sys_info["brightness"])
@requires_update
async def set_brightness(
self, brightness: int, *, transition: Optional[int] = None
):
async def _set_brightness(
self, brightness: int, *, transition: int | None = None
) -> dict:
"""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 self._is_dimmable:
raise KasaException("Device is not dimmable.")
if not isinstance(brightness, int):
raise ValueError(
@@ -110,7 +120,9 @@ class SmartDimmer(SmartPlug):
)
if not 0 <= brightness <= 100:
raise ValueError("Brightness value %s is not valid." % brightness)
raise ValueError(
f"Invalid brightness value: {brightness} (valid range: 0-100%)"
)
# Dimmers do not support a brightness of 0, but bulbs do.
# Coerce 0 to 1 to maintain the same interface between dimmers and bulbs.
@@ -124,7 +136,7 @@ class SmartDimmer(SmartPlug):
self.DIMMER_SERVICE, "set_brightness", {"brightness": brightness}
)
async def turn_off(self, *, transition: Optional[int] = None, **kwargs):
async def turn_off(self, *, transition: int | None = None, **kwargs) -> dict:
"""Turn the bulb off.
:param int transition: transition duration in milliseconds.
@@ -135,37 +147,38 @@ class SmartDimmer(SmartPlug):
return await super().turn_off()
@requires_update
async def turn_on(self, *, transition: Optional[int] = None, **kwargs):
async def turn_on(self, *, transition: int | None = None, **kwargs) -> dict:
"""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
brightness=self._brightness, transition=transition
)
return await super().turn_on()
async def set_dimmer_transition(self, brightness: int, transition: int):
async def set_dimmer_transition(self, brightness: int, transition: int) -> dict:
"""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)
)
raise TypeError(f"Brightness must be an integer, not {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)
f"Invalid brightness value: {brightness} (valid range: 0-100%)"
)
# If zero set to 1 millisecond
if transition == 0:
transition = 1
if not isinstance(transition, int):
raise TypeError(f"Transition must be integer, not of {type(transition)}.")
if transition <= 0:
raise ValueError("Transition value %s is not valid." % transition)
raise ValueError(f"Transition value {transition} is not valid.")
return await self._query_helper(
self.DIMMER_SERVICE,
@@ -174,7 +187,7 @@ class SmartDimmer(SmartPlug):
)
@requires_update
async def get_behaviors(self):
async def get_behaviors(self) -> dict:
"""Return button behavior settings."""
behaviors = await self._query_helper(
self.DIMMER_SERVICE, "get_default_behavior", {}
@@ -183,8 +196,8 @@ class SmartDimmer(SmartPlug):
@requires_update
async def set_button_action(
self, action_type: ActionType, action: ButtonAction, index: Optional[int] = None
):
self, action_type: ActionType, action: ButtonAction, index: int | None = None
) -> dict:
"""Set action to perform on button click/hold.
:param action_type ActionType: whether to control double click or hold action.
@@ -194,32 +207,35 @@ class SmartDimmer(SmartPlug):
"""
action_type_setter = f"set_{action_type}"
payload: Dict[str, Any] = {"mode": str(action)}
payload: dict[str, Any] = {"mode": str(action)}
if index is not None:
payload["index"] = index
await self._query_helper(self.DIMMER_SERVICE, action_type_setter, payload)
return await self._query_helper(
self.DIMMER_SERVICE, action_type_setter, payload
)
@requires_update
async def set_fade_time(self, fade_type: FadeType, time: int):
async def set_fade_time(self, fade_type: FadeType, time: int) -> dict:
"""Set time for fade in / fade out."""
fade_type_setter = f"set_{fade_type}_time"
payload = {"fadeTime": time}
await self._query_helper(self.DIMMER_SERVICE, fade_type_setter, payload)
return await self._query_helper(self.DIMMER_SERVICE, fade_type_setter, payload)
@property # type: ignore
@requires_update
def is_dimmable(self) -> bool:
def _is_dimmable(self) -> bool:
"""Whether the switch supports brightness changes."""
sys_info = self.sys_info
return "brightness" in sys_info
@property # type: ignore
@requires_update
def state_information(self) -> Dict[str, Any]:
"""Return switch-specific state information."""
info = super().state_information
info["Brightness"] = self.brightness
@property
def _is_variable_color_temp(self) -> bool:
"""Whether the device supports variable color temp."""
return False
return info
@property
def _is_color(self) -> bool:
"""Whether the device supports color."""
return False

72
kasa/iot/iotlightstrip.py Normal file
View File

@@ -0,0 +1,72 @@
"""Module for light strips (KL430)."""
from __future__ import annotations
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..module import Module
from ..protocols import BaseProtocol
from .iotbulb import IotBulb
from .iotdevice import requires_update
from .modules.lighteffect import LightEffect
class IotLightStrip(IotBulb):
"""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 = IotLightStrip("127.0.0.1")
>>> asyncio.run(strip.update())
>>> print(strip.alias)
Bedroom Lightstrip
Getting the length of the strip:
>>> strip.length
16
Currently active effect:
>>> strip.effect
{'brightness': 100, 'custom': 0, 'enable': 0,
'id': 'bCTItKETDFfrKANolgldxfgOakaarARs', 'name': 'Flicker'}
.. 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,
*,
config: DeviceConfig | None = None,
protocol: BaseProtocol | None = None,
) -> None:
super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.LightStrip
async def _initialize_modules(self) -> None:
"""Initialize modules not added in init."""
await super()._initialize_modules()
self.add_module(
Module.LightEffect,
LightEffect(self, "smartlife.iot.lighting_effect"),
)
@property # type: ignore
@requires_update
def length(self) -> int:
"""Return length of the strip."""
return self.sys_info["length"]

76
kasa/iot/iotmodule.py Normal file
View File

@@ -0,0 +1,76 @@
"""Base class for IOT module implementations."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from ..exceptions import KasaException
from ..module import Module
_LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING:
from .iotdevice import IotDevice
def _merge_dict(dest: dict, source: dict) -> dict:
"""Update dict recursively."""
for k, v in source.items():
if k in dest and type(v) is dict: # noqa: E721 - only accepts `dict` type
_merge_dict(dest[k], v)
else:
dest[k] = v
return dest
merge = _merge_dict
class IotModule(Module):
"""Base class implemention for all IOT modules."""
_device: IotDevice
async def call(self, method: str, params: dict | None = None) -> dict:
"""Call the given method with the given parameters."""
return await self._device._query_helper(self._module, method, params)
def query_for_command(self, query: str, params: dict | None = None) -> dict:
"""Create a request object for the given parameters."""
return self._device._create_request(self._module, query, params)
@property
def estimated_query_response_size(self) -> int:
"""Estimated maximum size of query response.
The inheriting modules implement this to estimate how large a query response
will be so that queries can be split should an estimated response be too large
"""
return 256 # Estimate for modules that don't specify
@property
def data(self) -> dict[str, Any]:
"""Return the module specific raw data from the last update."""
dev = self._device
q = self.query()
if not q:
return dev.sys_info
if self._module not in dev._last_update:
raise KasaException(
f"You need to call update() prior accessing module data"
f" for '{self._module}'"
)
return dev._last_update[self._module]
@property
def is_supported(self) -> bool:
"""Return whether the module is supported by the device."""
if self._module not in self._device._last_update:
_LOGGER.debug("Initial update, so consider supported: %s", self._module)
return True
return "err_code" not in self.data

104
kasa/iot/iotplug.py Normal file
View File

@@ -0,0 +1,104 @@
"""Module for smart plugs (HS100, HS110, ..)."""
from __future__ import annotations
import logging
from typing import Any
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..module import Module
from ..protocols import BaseProtocol
from .iotdevice import IotDevice, requires_update
from .modules import AmbientLight, Antitheft, Cloud, Led, Motion, Schedule, Time, Usage
_LOGGER = logging.getLogger(__name__)
class IotPlug(IotDevice):
r"""Representation of a TP-Link Smart Plug.
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:`KasaException`\s,
and should be handled by the user of the library.
Examples:
>>> import asyncio
>>> plug = IotPlug("127.0.0.1")
>>> asyncio.run(plug.update())
>>> plug.alias
Bedroom Lamp Plug
Setting the LED state:
>>> asyncio.run(plug.set_led(True))
>>> asyncio.run(plug.update())
>>> plug.led
True
For more examples, see the :class:`Device` class.
"""
def __init__(
self,
host: str,
*,
config: DeviceConfig | None = None,
protocol: BaseProtocol | None = None,
) -> None:
super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.Plug
async def _initialize_modules(self) -> None:
"""Initialize modules."""
await super()._initialize_modules()
self.add_module(Module.IotSchedule, Schedule(self, "schedule"))
self.add_module(Module.IotUsage, Usage(self, "schedule"))
self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft"))
self.add_module(Module.Time, Time(self, "time"))
self.add_module(Module.IotCloud, Cloud(self, "cnCloud"))
self.add_module(Module.Led, Led(self, "system"))
@property # type: ignore
@requires_update
def is_on(self) -> bool:
"""Return whether device is on."""
sys_info = self.sys_info
return bool(sys_info["relay_state"])
async def turn_on(self, **kwargs: Any) -> dict:
"""Turn the switch on."""
return await self._query_helper("system", "set_relay_state", {"state": 1})
async def turn_off(self, **kwargs: Any) -> dict:
"""Turn the switch off."""
return await self._query_helper("system", "set_relay_state", {"state": 0})
class IotWallSwitch(IotPlug):
"""Representation of a TP-Link Smart Wall Switch."""
def __init__(
self,
host: str,
*,
config: DeviceConfig | None = None,
protocol: BaseProtocol | None = None,
) -> None:
super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.WallSwitch
async def _initialize_modules(self) -> None:
"""Initialize modules."""
await super()._initialize_modules()
if (dev_name := self.sys_info["dev_name"]) and "PIR" in dev_name:
self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR"))
self.add_module(
Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS")
)

Some files were not shown because too many files have changed in this diff Show More