mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-09-05 13:43:53 +00:00
Merge remote-tracking branch 'upstream/master' into nw/fix_non_indexed_strip
This commit is contained in:
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -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
49
.github/actions/setup/action.yaml
vendored
Normal 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') }}
|
||||
89
.github/workflows/ci.yml
vendored
89
.github/workflows/ci.yml
vendored
@@ -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 }}
|
||||
|
||||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -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'
|
||||
|
||||
|
||||
28
.github/workflows/publish.yml
vendored
28
.github/workflows/publish.yml
vendored
@@ -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
69
.github/workflows/stale.yml
vendored
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
1272
CHANGELOG.md
1272
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
4
CONTRIBUTING.md
Normal file
4
CONTRIBUTING.md
Normal 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
362
README.md
@@ -1,4 +1,4 @@
|
||||
<h2 align="center">python-kasa</h2>
|
||||
# python-kasa
|
||||
|
||||
[](https://badge.fury.io/py/python-kasa)
|
||||
[](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/)
|
||||
|
||||
324
RELEASING.md
324
RELEASING.md
@@ -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
310
SUPPORTED.md
Normal 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
|
||||
@@ -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.
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Test data for benchmarks."""
|
||||
|
||||
|
||||
import json
|
||||
|
||||
from .original import OriginalTPLinkSmartHomeProtocol
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)}")
|
||||
@@ -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
231
devtools/generate_supported.py
Executable 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:])
|
||||
66
devtools/helpers/smartcamrequests.py
Normal file
66
devtools/helpers/smartcamrequests.py
Normal 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": {}}}},
|
||||
]
|
||||
@@ -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": [],
|
||||
}
|
||||
|
||||
@@ -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
371
devtools/parse_pcap_klap.py
Executable 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()
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Script for testing update performance on devices."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
|
||||
128
devtools/update_fixtures.py
Normal file
128
devtools/update_fixtures.py
Normal 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
3
docs/source/SUPPORTED.md
Normal file
@@ -0,0 +1,3 @@
|
||||
```{include} ../../SUPPORTED.md
|
||||
:relative-docs: doc/source
|
||||
```
|
||||
@@ -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
26
docs/source/codeinfo.md
Normal 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.*
|
||||
:::
|
||||
@@ -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
86
docs/source/contribute.md
Normal 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
67
docs/source/deprecated.md
Normal 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
|
||||
```
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
13
docs/source/featureattributes.md
Normal file
13
docs/source/featureattributes.md
Normal 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
16
docs/source/guides.md
Normal 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
|
||||
```
|
||||
10
docs/source/guides/connect.md
Normal file
10
docs/source/guides/connect.md
Normal file
@@ -0,0 +1,10 @@
|
||||
(connect_target)=
|
||||
# Connect without discovery
|
||||
|
||||
:::{include} ../codeinfo.md
|
||||
:::
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: kasa.deviceconfig
|
||||
:noindex:
|
||||
```
|
||||
10
docs/source/guides/device.md
Normal file
10
docs/source/guides/device.md
Normal file
@@ -0,0 +1,10 @@
|
||||
(device_target)=
|
||||
# Interact with devices
|
||||
|
||||
:::{include} ../codeinfo.md
|
||||
:::
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: kasa.device
|
||||
:noindex:
|
||||
```
|
||||
11
docs/source/guides/discover.md
Normal file
11
docs/source/guides/discover.md
Normal file
@@ -0,0 +1,11 @@
|
||||
(discover_target)=
|
||||
# Discover devices
|
||||
|
||||
:::{include} ../codeinfo.md
|
||||
:::
|
||||
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: kasa.discover
|
||||
:noindex:
|
||||
```
|
||||
27
docs/source/guides/energy.md
Normal file
27
docs/source/guides/energy.md
Normal 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}")
|
||||
```
|
||||
10
docs/source/guides/feature.md
Normal file
10
docs/source/guides/feature.md
Normal file
@@ -0,0 +1,10 @@
|
||||
(feature_target)=
|
||||
# Interact with features
|
||||
|
||||
:::{include} ../codeinfo.md
|
||||
:::
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: kasa.feature
|
||||
:noindex:
|
||||
```
|
||||
26
docs/source/guides/light.md
Normal file
26
docs/source/guides/light.md
Normal 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:
|
||||
```
|
||||
10
docs/source/guides/module.md
Normal file
10
docs/source/guides/module.md
Normal file
@@ -0,0 +1,10 @@
|
||||
(module_target)=
|
||||
# Interact with modules
|
||||
|
||||
:::{include} ../codeinfo.md
|
||||
:::
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: kasa.module
|
||||
:noindex:
|
||||
```
|
||||
10
docs/source/guides/strip.md
Normal file
10
docs/source/guides/strip.md
Normal 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
12
docs/source/index.md
Normal file
@@ -0,0 +1,12 @@
|
||||
```{include} ../../README.md
|
||||
```
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
Home <self>
|
||||
cli
|
||||
library
|
||||
contribute
|
||||
SUPPORTED
|
||||
```
|
||||
@@ -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
15
docs/source/library.md
Normal 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
135
docs/source/reference.md
Normal 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:
|
||||
```
|
||||
@@ -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:
|
||||
|
||||
@@ -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
142
docs/source/topics.md
Normal 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
11
docs/source/tutorial.md
Normal 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
95
docs/tutorial.py
Normal 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
|
||||
"""
|
||||
163
kasa/__init__.py
163
kasa/__init__.py
@@ -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")
|
||||
|
||||
@@ -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
27
kasa/cachedzoneinfo.py
Normal 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)
|
||||
1048
kasa/cli.py
1048
kasa/cli.py
File diff suppressed because it is too large
Load Diff
1
kasa/cli/__init__.py
Normal file
1
kasa/cli/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Package for the cli."""
|
||||
6
kasa/cli/__main__.py
Normal file
6
kasa/cli/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Main module."""
|
||||
|
||||
from kasa.cli.main import cli
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
241
kasa/cli/common.py
Normal file
241
kasa/cli/common.py
Normal 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
211
kasa/cli/device.py
Normal 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
342
kasa/cli/discover.py
Normal 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
142
kasa/cli/feature.py
Normal 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
72
kasa/cli/lazygroup.py
Normal 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
221
kasa/cli/light.py
Normal 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
434
kasa/cli/main.py
Executable 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
46
kasa/cli/schedule.py
Normal 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
160
kasa/cli/time.py
Normal 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
113
kasa/cli/usage.py
Normal 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
50
kasa/cli/wifi.py
Normal 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
|
||||
@@ -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
602
kasa/device.py
Normal 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
230
kasa/device_factory.py
Executable file → Normal 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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
885
kasa/discover.py
885
kasa/discover.py
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
305
kasa/feature.py
Normal 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
164
kasa/httpclient.py
Normal 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()
|
||||
23
kasa/interfaces/__init__.py
Normal file
23
kasa/interfaces/__init__.py
Normal 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
194
kasa/interfaces/energy.py
Normal 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
23
kasa/interfaces/fan.py
Normal 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
38
kasa/interfaces/led.py
Normal 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
199
kasa/interfaces/light.py
Normal 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."""
|
||||
120
kasa/interfaces/lighteffect.py
Normal file
120
kasa/interfaces/lighteffect.py
Normal 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
|
||||
"""
|
||||
144
kasa/interfaces/lightpreset.py
Normal file
144
kasa/interfaces/lightpreset.py
Normal 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."""
|
||||
65
kasa/interfaces/thermostat.py
Normal file
65
kasa/interfaces/thermostat.py
Normal 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
26
kasa/interfaces/time.py
Normal 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
20
kasa/iot/__init__.py
Normal 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",
|
||||
]
|
||||
@@ -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}
|
||||
@@ -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
42
kasa/iot/iotcamera.py
Normal 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
777
kasa/iot/iotdevice.py
Executable 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,
|
||||
)
|
||||
@@ -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
72
kasa/iot/iotlightstrip.py
Normal 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
76
kasa/iot/iotmodule.py
Normal 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
104
kasa/iot/iotplug.py
Normal 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
Reference in New Issue
Block a user