mirror of
https://github.com/python-kasa/python-kasa.git
synced 2025-04-25 08:06:25 +00:00
Compare commits
140 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
579fd5aa2a | ||
![]() |
8501390c61 | ||
![]() |
f0abc2800d | ||
![]() |
f488492c7d | ||
![]() |
29195fa639 | ||
![]() |
8b138698b8 | ||
![]() |
ad8a0eebec | ||
![]() |
668e32d3a5 | ||
![]() |
d5187dc6f1 | ||
![]() |
cbab40a59e | ||
![]() |
bff5409d22 | ||
![]() |
8259d28b12 | ||
![]() |
44c561b04d | ||
![]() |
ebd370da74 | ||
![]() |
82fbe1226e | ||
![]() |
09e73faca3 | ||
![]() |
781d07f6a2 | ||
![]() |
1df05af208 | ||
![]() |
656c88771a | ||
![]() |
d857cc68bb | ||
![]() |
62c1dd87dc | ||
![]() |
ba6d6560f4 | ||
![]() |
7f2a1be392 | ||
![]() |
0aa1242a00 | ||
![]() |
5b9b89769a | ||
![]() |
9b7bf367ae | ||
![]() |
09fce3f426 | ||
![]() |
b701441215 | ||
![]() |
b6a584971a | ||
![]() |
988eb96bd1 | ||
![]() |
5e57f8bd6c | ||
![]() |
bd43e0f7d2 | ||
![]() |
57c4ffa8a3 | ||
![]() |
54bb53899e | ||
![]() |
acc0e9a80a | ||
![]() |
307173487a | ||
![]() |
7b1b14d1e6 | ||
![]() |
fa0f7157c6 | ||
![]() |
a03a4b1d63 | ||
![]() |
05085462d3 | ||
![]() |
bca5576425 | ||
![]() |
2d26f91981 | ||
![]() |
fd6067e5a0 | ||
![]() |
980f6a38ca | ||
![]() |
773801cad5 | ||
![]() |
d27697c50f | ||
![]() |
b23019e748 | ||
![]() |
17356c10f1 | ||
![]() |
bc97c0794a | ||
![]() |
0f185f1905 | ||
![]() |
2ab42f59b3 | ||
![]() |
1355e85f8e | ||
![]() |
4e7e18cef1 | ||
![]() |
2542516009 | ||
![]() |
3c98efb015 | ||
![]() |
68f50aa763 | ||
![]() |
d03f535568 | ||
![]() |
1be87674bf | ||
![]() |
be34dbd387 | ||
![]() |
57f6c4138a | ||
![]() |
589d15091a | ||
![]() |
a211cc0af5 | ||
![]() |
333a36bf42 | ||
![]() |
6420d76351 | ||
![]() |
660b9f81de | ||
![]() |
2e3b1bc376 | ||
![]() |
debcff9f9b | ||
![]() |
3c038fc13b | ||
![]() |
7b3dde9aa0 | ||
![]() |
40886ef24d | ||
![]() |
7d508b5092 | ||
![]() |
48a07a2970 | ||
![]() |
6aa019280b | ||
![]() |
1f45f425a0 | ||
![]() |
08639a3a7b | ||
![]() |
6e0be2ea1f | ||
![]() |
e097b45984 | ||
![]() |
0a95a41ab6 | ||
![]() |
883d52209e | ||
![]() |
361697a239 | ||
![]() |
5d49623d5d | ||
![]() |
d0aba68e7a | ||
![]() |
63f4f82791 | ||
![]() |
9b1be1c0b2 | ||
![]() |
d81cf1b3b6 | ||
![]() |
cef0e571a0 | ||
![]() |
522c78350e | ||
![]() |
8418ba3eef | ||
![]() |
93ca3ad2e1 | ||
![]() |
296af3192e | ||
![]() |
fe88b52e19 | ||
![]() |
83eb73cc7f | ||
![]() |
d890b0a3ac | ||
![]() |
b5f49a3c8a | ||
![]() |
b78e09caa0 | ||
![]() |
47934dbf96 | ||
![]() |
ba273f308e | ||
![]() |
37ef7b0463 | ||
![]() |
14d5629de1 | ||
![]() |
c6c4490a49 | ||
![]() |
fe072657b4 | ||
![]() |
5918e4daa7 | ||
![]() |
d03a387a74 | ||
![]() |
e206d9b4df | ||
![]() |
62345be916 | ||
![]() |
e9109447a7 | ||
![]() |
031ebcd97f | ||
![]() |
f8503e4df6 | ||
![]() |
c439530f93 | ||
![]() |
59e5073509 | ||
![]() |
2ca6d3ebe9 | ||
![]() |
223f3318ea | ||
![]() |
5f84c69774 | ||
![]() |
7709bb967f | ||
![]() |
f8a46f74cd | ||
![]() |
8cb5c2e180 | ||
![]() |
032cd5d2cc | ||
![]() |
bf8f0adabe | ||
![]() |
464683e09b | ||
![]() |
ed0481918c | ||
![]() |
2f87ccd201 | ||
![]() |
fd74b07e2c | ||
![]() |
cb89342be1 | ||
![]() |
6d9b4421fe | ||
![]() |
7b9fe7f693 | ||
![]() |
611cd66266 | ||
![]() |
5a596dbcc9 | ||
![]() |
5465b66dee | ||
![]() |
7e8b83edb9 | ||
![]() |
be8b7139b8 | ||
![]() |
1c9ee4d537 | ||
![]() |
8814d94989 | ||
![]() |
4eed945e00 | ||
![]() |
123ea107b1 | ||
![]() |
74b59d7f98 | ||
![]() |
9966c6094a | ||
![]() |
9a52056522 | ||
![]() |
d122b48788 | ||
![]() |
5ef8f21b4d | ||
![]() |
fcb604e435 |
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
@ -2,11 +2,22 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master", "patch"]
|
||||
branches:
|
||||
- master
|
||||
- patch
|
||||
pull_request:
|
||||
branches: ["master", "patch"]
|
||||
branches:
|
||||
- master
|
||||
- patch
|
||||
- 'feat/**'
|
||||
- 'fix/**'
|
||||
- 'janitor/**'
|
||||
workflow_dispatch: # to allow manual re-runs
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
UV_VERSION: 0.4.16
|
||||
|
||||
|
15
.github/workflows/codeql-analysis.yml
vendored
15
.github/workflows/codeql-analysis.yml
vendored
@ -2,12 +2,23 @@ name: "CodeQL checks"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master", "patch" ]
|
||||
branches:
|
||||
- master
|
||||
- patch
|
||||
pull_request:
|
||||
branches: [ master, "patch" ]
|
||||
branches:
|
||||
- master
|
||||
- patch
|
||||
- 'feat/**'
|
||||
- 'fix/**'
|
||||
- 'janitor/**'
|
||||
schedule:
|
||||
- cron: '44 17 * * 3'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
|
@ -2,13 +2,13 @@ repos:
|
||||
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
# uv version.
|
||||
rev: 0.4.16
|
||||
rev: 0.5.30
|
||||
hooks:
|
||||
# Update the uv lockfile
|
||||
- id: uv-lock
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
@ -16,16 +16,20 @@ repos:
|
||||
- id: check-yaml
|
||||
- id: debug-statements
|
||||
- id: check-ast
|
||||
- id: pretty-format-json
|
||||
args:
|
||||
- "--autofix"
|
||||
- "--indent=4"
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.7.4
|
||||
rev: v0.9.6
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/PyCQA/doc8
|
||||
rev: 'v1.1.1'
|
||||
rev: 'v1.1.2'
|
||||
hooks:
|
||||
- id: doc8
|
||||
additional_dependencies: [tomli]
|
||||
|
@ -2,6 +2,10 @@ version: 2
|
||||
|
||||
formats: all
|
||||
|
||||
sphinx:
|
||||
configuration: docs/source/conf.py
|
||||
|
||||
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
|
292
CHANGELOG.md
292
CHANGELOG.md
@ -1,5 +1,273 @@
|
||||
# Changelog
|
||||
|
||||
## [0.10.2](https://github.com/python-kasa/python-kasa/tree/0.10.2) (2025-02-12)
|
||||
|
||||
[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.10.1...0.10.2)
|
||||
|
||||
**Release summary:**
|
||||
|
||||
- Bugfix for [#1499](https://github.com/python-kasa/python-kasa/issues/1499).
|
||||
- Support for L530B and C110 devices.
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- H100 - Raised error: not enough values to unpack \(expected 2, got 1\) [\#1499](https://github.com/python-kasa/python-kasa/issues/1499)
|
||||
- Do not crash on missing build number in fw version [\#1500](https://github.com/python-kasa/python-kasa/pull/1500) (@rytilahti)
|
||||
|
||||
**Added support for devices:**
|
||||
|
||||
- Add C110\(EU\) 2.0 1.4.3 fixture [\#1503](https://github.com/python-kasa/python-kasa/pull/1503) (@sdb9696)
|
||||
- Add L530B\(EU\) 3.0 1.1.9 fixture [\#1502](https://github.com/python-kasa/python-kasa/pull/1502) (@sdb9696)
|
||||
|
||||
**Project maintenance:**
|
||||
|
||||
- Add fixtures for new versions of H100, P110, and T100 devices [\#1501](https://github.com/python-kasa/python-kasa/pull/1501) (@LXGaming)
|
||||
- Add L530E\(TW\) 2.0 1.1.1 fixture [\#1497](https://github.com/python-kasa/python-kasa/pull/1497) (@bluehomewu)
|
||||
|
||||
## [0.10.1](https://github.com/python-kasa/python-kasa/tree/0.10.1) (2025-02-02)
|
||||
|
||||
[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.10.0...0.10.1)
|
||||
|
||||
**Release summary:**
|
||||
|
||||
Small patch release for bugfixes
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- dustbin\_mode: add 'off' mode for cleaner downstream impl [\#1488](https://github.com/python-kasa/python-kasa/pull/1488) (@rytilahti)
|
||||
- Add Dimmer Configuration Support [\#1484](https://github.com/python-kasa/python-kasa/pull/1484) (@ryenitcher)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Do not return empty string for custom light effect name [\#1491](https://github.com/python-kasa/python-kasa/pull/1491) (@sdb9696)
|
||||
- Add FeatureAttributes to smartcam Alarm [\#1489](https://github.com/python-kasa/python-kasa/pull/1489) (@sdb9696)
|
||||
|
||||
**Project maintenance:**
|
||||
|
||||
- Add module.device to the public api [\#1478](https://github.com/python-kasa/python-kasa/pull/1478) (@sdb9696)
|
||||
|
||||
## [0.10.0](https://github.com/python-kasa/python-kasa/tree/0.10.0) (2025-01-26)
|
||||
|
||||
[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.9.1...0.10.0)
|
||||
|
||||
**Release summary:**
|
||||
|
||||
This release brings support for many new devices, including completely new device types:
|
||||
|
||||
- Support for Tapo robot vacuums. Special thanks to @steveredden, @MAXIGAMESSUPPER, and veep60 for helping to get this implemented!
|
||||
- Support for hub attached cameras and doorbells (H200)
|
||||
- Improved support for hubs (including pairing & better chime controls)
|
||||
- Support for many new camera and doorbell device models, including C220, C720, D100C, D130, and D230
|
||||
|
||||
Many thanks to testers and new contributors - @steveredden, @DawidPietrykowski, @Obbay2, @andrewome, @ryenitcher and @etmmvdp!
|
||||
|
||||
**Breaking changes:**
|
||||
|
||||
- `uses_http` is now a readonly property of device config. Consumers that relied on `uses_http` to be persisted with `DeviceConfig.to_dict()` will need to store the value separately.
|
||||
- `is_color`, `is_dimmable`, `is_variable_color_temp`, `valid_temperate_range`, and `has_effects` attributes from the `Light` module are deprecated, consumers should use `has_feature("hsv")`, `has_feature("brightness")`, `has_feature("color_temp")`, `get_feature("color_temp").range`, and `Module.LightEffect in dev.modules` respectively. Calling the deprecated attributes will emit a `DeprecationWarning` and type checkers will fail them.
|
||||
- `alarm_volume` on the `smart.Alarm` module is changed from `str` to `int`
|
||||
|
||||
**Breaking changes:**
|
||||
|
||||
- Make uses\_http a readonly property of device config [\#1449](https://github.com/python-kasa/python-kasa/pull/1449) (@sdb9696)
|
||||
- Allow passing alarm parameter overrides [\#1340](https://github.com/python-kasa/python-kasa/pull/1340) (@rytilahti)
|
||||
- Deprecate legacy light module is\_capability checks [\#1297](https://github.com/python-kasa/python-kasa/pull/1297) (@sdb9696)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Expose more battery sensors for D230 [\#1451](https://github.com/python-kasa/python-kasa/issues/1451)
|
||||
- dumping HTTP POST Body for Tapo Vacuum \(RV30 Plus\) [\#937](https://github.com/python-kasa/python-kasa/issues/937)
|
||||
- Add smartcam pet detection toggle module [\#1465](https://github.com/python-kasa/python-kasa/pull/1465) (@DawidPietrykowski)
|
||||
- Add childlock module for vacuums [\#1461](https://github.com/python-kasa/python-kasa/pull/1461) (@rytilahti)
|
||||
- Add ultra mode \(fanspeed = 5\) for vacuums [\#1459](https://github.com/python-kasa/python-kasa/pull/1459) (@rytilahti)
|
||||
- Add setting to change carpet clean mode [\#1458](https://github.com/python-kasa/python-kasa/pull/1458) (@rytilahti)
|
||||
- Add setting to change clean count [\#1457](https://github.com/python-kasa/python-kasa/pull/1457) (@rytilahti)
|
||||
- Add mop module [\#1456](https://github.com/python-kasa/python-kasa/pull/1456) (@rytilahti)
|
||||
- Enable dynamic hub child creation and deletion on update [\#1454](https://github.com/python-kasa/python-kasa/pull/1454) (@sdb9696)
|
||||
- Expose current cleaning information [\#1453](https://github.com/python-kasa/python-kasa/pull/1453) (@rytilahti)
|
||||
- Add battery module to smartcam devices [\#1452](https://github.com/python-kasa/python-kasa/pull/1452) (@sdb9696)
|
||||
- Allow update of camera modules after setting values [\#1450](https://github.com/python-kasa/python-kasa/pull/1450) (@sdb9696)
|
||||
- Update hub children on first update and delay subsequent updates [\#1438](https://github.com/python-kasa/python-kasa/pull/1438) (@sdb9696)
|
||||
- Implement vacuum dustbin module \(dust\_bucket\) [\#1423](https://github.com/python-kasa/python-kasa/pull/1423) (@rytilahti)
|
||||
- Add smartcam child device support for smartcam hubs [\#1413](https://github.com/python-kasa/python-kasa/pull/1413) (@sdb9696)
|
||||
- Add vacuum speaker controls [\#1332](https://github.com/python-kasa/python-kasa/pull/1332) (@rytilahti)
|
||||
- Add consumables module for vacuums [\#1327](https://github.com/python-kasa/python-kasa/pull/1327) (@rytilahti)
|
||||
- Add ADC Value to PIR Enabled Switches [\#1263](https://github.com/python-kasa/python-kasa/pull/1263) (@ryenitcher)
|
||||
- Add support for cleaning records [\#945](https://github.com/python-kasa/python-kasa/pull/945) (@rytilahti)
|
||||
- Initial support for vacuums \(clean module\) [\#944](https://github.com/python-kasa/python-kasa/pull/944) (@rytilahti)
|
||||
- Add support for pairing devices with hubs [\#859](https://github.com/python-kasa/python-kasa/pull/859) (@rytilahti)
|
||||
- Add common alarm interface [\#1479](https://github.com/python-kasa/python-kasa/pull/1479) (@sdb9696)
|
||||
- Add common childsetup interface [\#1470](https://github.com/python-kasa/python-kasa/pull/1470) (@sdb9696)
|
||||
- Add childsetup module to smartcam hubs [\#1469](https://github.com/python-kasa/python-kasa/pull/1469) (@sdb9696)
|
||||
- Only log one warning per unknown clean error code and status [\#1462](https://github.com/python-kasa/python-kasa/pull/1462) (@rytilahti)
|
||||
- Add support for doorbells and chimes [\#1435](https://github.com/python-kasa/python-kasa/pull/1435) (@steveredden)
|
||||
- Allow https for klaptransport [\#1415](https://github.com/python-kasa/python-kasa/pull/1415) (@rytilahti)
|
||||
- Add powerprotection module [\#1337](https://github.com/python-kasa/python-kasa/pull/1337) (@rytilahti)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- TP-Link HS300 Wi-Fi Power-Strip - "Parent On/Off" not functioning. [\#637](https://github.com/python-kasa/python-kasa/issues/637)
|
||||
- Report 0 for instead of None for zero current and voltage [\#1483](https://github.com/python-kasa/python-kasa/pull/1483) (@ryenitcher)
|
||||
- ssltransport: use debug logger for sending requests [\#1443](https://github.com/python-kasa/python-kasa/pull/1443) (@rytilahti)
|
||||
- Fix discover cli command with host [\#1437](https://github.com/python-kasa/python-kasa/pull/1437) (@sdb9696)
|
||||
- Fallback to is\_low for batterysensor's battery\_low [\#1420](https://github.com/python-kasa/python-kasa/pull/1420) (@rytilahti)
|
||||
- Convert carpet\_clean\_mode to carpet\_boost switch [\#1486](https://github.com/python-kasa/python-kasa/pull/1486) (@rytilahti)
|
||||
- Change category for empty dustbin feature from Primary to Config [\#1485](https://github.com/python-kasa/python-kasa/pull/1485) (@rytilahti)
|
||||
- Disable iot camera creation until more complete [\#1480](https://github.com/python-kasa/python-kasa/pull/1480) (@sdb9696)
|
||||
- Add error code 7 for clean module [\#1474](https://github.com/python-kasa/python-kasa/pull/1474) (@rytilahti)
|
||||
- Fix iot strip turn on and off from parent [\#639](https://github.com/python-kasa/python-kasa/pull/639) (@Obbay2)
|
||||
|
||||
**Added support for devices:**
|
||||
|
||||
- Add C220\(EU\) 1.0 1.2.2 camera fixture [\#1466](https://github.com/python-kasa/python-kasa/pull/1466) (@DawidPietrykowski)
|
||||
- Add D230\(EU\) 1.20 1.1.19 fixture [\#1448](https://github.com/python-kasa/python-kasa/pull/1448) (@sdb9696)
|
||||
- Add fixture for C720 camera [\#1433](https://github.com/python-kasa/python-kasa/pull/1433) (@steveredden)
|
||||
- Add D130\(US\) 1.0 1.1.9 fixture [\#1476](https://github.com/python-kasa/python-kasa/pull/1476) (@sdb9696)
|
||||
- Add D100C\(US\) 1.0 1.1.3 fixture [\#1475](https://github.com/python-kasa/python-kasa/pull/1475) (@sdb9696)
|
||||
|
||||
**Project maintenance:**
|
||||
|
||||
- Enable CI workflow on PRs to feat/ fix/ and janitor/ [\#1471](https://github.com/python-kasa/python-kasa/pull/1471) (@sdb9696)
|
||||
- Add commit-hook to prettify JSON files [\#1455](https://github.com/python-kasa/python-kasa/pull/1455) (@rytilahti)
|
||||
- Add required sphinx.configuration [\#1446](https://github.com/python-kasa/python-kasa/pull/1446) (@rytilahti)
|
||||
- Add more redactors for smartcams [\#1439](https://github.com/python-kasa/python-kasa/pull/1439) (@sdb9696)
|
||||
- Add KS230\(US\) 2.0 1.0.11 IOT Fixture [\#1430](https://github.com/python-kasa/python-kasa/pull/1430) (@ZeliardM)
|
||||
- Update ruff to 0.9 [\#1482](https://github.com/python-kasa/python-kasa/pull/1482) (@sdb9696)
|
||||
- Cancel in progress CI workflows after new pushes [\#1481](https://github.com/python-kasa/python-kasa/pull/1481) (@sdb9696)
|
||||
- Update test framework to support smartcam device discovery. [\#1477](https://github.com/python-kasa/python-kasa/pull/1477) (@sdb9696)
|
||||
- Add tests for dump\_devinfo parent/child smartcam fixture generation [\#1428](https://github.com/python-kasa/python-kasa/pull/1428) (@sdb9696)
|
||||
- Raise errors on single smartcam child requests [\#1427](https://github.com/python-kasa/python-kasa/pull/1427) (@sdb9696)
|
||||
|
||||
## [0.9.1](https://github.com/python-kasa/python-kasa/tree/0.9.1) (2025-01-06)
|
||||
|
||||
[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.9.0...0.9.1)
|
||||
|
||||
**Release summary:**
|
||||
|
||||
- Support for hub-attached wall switches S210 and S220
|
||||
- Support for older firmware on Tapo cameras
|
||||
- Bugfixes and improvements
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add support for Tapo hub-attached switch devices [\#1421](https://github.com/python-kasa/python-kasa/pull/1421) (@sdb9696)
|
||||
- Use repr\(\) for enum values in Feature.\_\_repr\_\_ [\#1414](https://github.com/python-kasa/python-kasa/pull/1414) (@rytilahti)
|
||||
- Update SslAesTransport for older firmware versions [\#1362](https://github.com/python-kasa/python-kasa/pull/1362) (@sdb9696)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- T310 not detected with H200 Hub [\#1409](https://github.com/python-kasa/python-kasa/issues/1409)
|
||||
- Fix incorrect obd src echo [\#1412](https://github.com/python-kasa/python-kasa/pull/1412) (@rytilahti)
|
||||
- Backoff after xor timeout and improve error reporting [\#1424](https://github.com/python-kasa/python-kasa/pull/1424) (@bdraco)
|
||||
- Handle smartcam partial list responses [\#1411](https://github.com/python-kasa/python-kasa/pull/1411) (@sdb9696)
|
||||
|
||||
**Added support for devices:**
|
||||
|
||||
- Add S220 fixture [\#1419](https://github.com/python-kasa/python-kasa/pull/1419) (@rytilahti)
|
||||
- Add S210 fixture [\#1418](https://github.com/python-kasa/python-kasa/pull/1418) (@rytilahti)
|
||||
|
||||
**Documentation updates:**
|
||||
|
||||
- Improve exception messages on credential mismatches [\#1417](https://github.com/python-kasa/python-kasa/pull/1417) (@rytilahti)
|
||||
|
||||
**Project maintenance:**
|
||||
|
||||
- Add HS210\(US\) 3.0 1.0.10 IOT Fixture [\#1405](https://github.com/python-kasa/python-kasa/pull/1405) (@ZeliardM)
|
||||
- Add C210 2.0 1.3.11 fixture [\#1406](https://github.com/python-kasa/python-kasa/pull/1406) (@sdb9696)
|
||||
- Change smartcam detection features to category config [\#1402](https://github.com/python-kasa/python-kasa/pull/1402) (@sdb9696)
|
||||
|
||||
## [0.9.0](https://github.com/python-kasa/python-kasa/tree/0.9.0) (2024-12-21)
|
||||
|
||||
[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.8.1...0.9.0)
|
||||
|
||||
**Release highlights:**
|
||||
|
||||
- Improvements to Tapo camera support:
|
||||
- C100, C225, C325WB, C520WS and TC70 now supported.
|
||||
- Support for motion, person, tamper, and baby cry detection.
|
||||
- Initial support for Tapo robovacs.
|
||||
- API extended with `FeatureAttributes` for consumers to test for [supported features](https://python-kasa.readthedocs.io/en/stable/topics.html#modules-and-features).
|
||||
- Experimental support for Kasa cameras[^1]
|
||||
|
||||
[^1]: Currently limited to devices not yet provisioned via the Tapo app - Many thanks to @puxtril!
|
||||
|
||||
**Breaking changes:**
|
||||
|
||||
- Use DeviceInfo consistently across devices [\#1338](https://github.com/python-kasa/python-kasa/pull/1338) (@sdb9696)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add bare-bones matter modules to smart and smartcam devices [\#1371](https://github.com/python-kasa/python-kasa/pull/1371) (@sdb9696)
|
||||
- Add bare bones homekit modules smart and smartcam devices [\#1370](https://github.com/python-kasa/python-kasa/pull/1370) (@sdb9696)
|
||||
- cli: print model, https, and lv for discover list [\#1339](https://github.com/python-kasa/python-kasa/pull/1339) (@rytilahti)
|
||||
- Add LinkieTransportV2 and basic IOT.IPCAMERA support [\#1270](https://github.com/python-kasa/python-kasa/pull/1270) (@Puxtril)
|
||||
- Add ssltransport for robovacs [\#943](https://github.com/python-kasa/python-kasa/pull/943) (@rytilahti)
|
||||
- Add rssi and signal\_level to smartcam [\#1392](https://github.com/python-kasa/python-kasa/pull/1392) (@sdb9696)
|
||||
- Add smartcam detection modules [\#1389](https://github.com/python-kasa/python-kasa/pull/1389) (@sdb9696)
|
||||
- Return raw discovery result in cli discover raw [\#1342](https://github.com/python-kasa/python-kasa/pull/1342) (@sdb9696)
|
||||
- Improve overheat reporting [\#1335](https://github.com/python-kasa/python-kasa/pull/1335) (@rytilahti)
|
||||
- Provide alternative camera urls [\#1316](https://github.com/python-kasa/python-kasa/pull/1316) (@sdb9696)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Tapo H200 Hub does not work with python-kasa [\#1149](https://github.com/python-kasa/python-kasa/issues/1149)
|
||||
- Fix lens mask required component and state [\#1386](https://github.com/python-kasa/python-kasa/pull/1386) (@sdb9696)
|
||||
- Add LensMask module to smartcam [\#1385](https://github.com/python-kasa/python-kasa/pull/1385) (@sdb9696)
|
||||
- Treat smartcam 500 errors after handshake as retryable [\#1395](https://github.com/python-kasa/python-kasa/pull/1395) (@sdb9696)
|
||||
- Do not error when accessing smart device\_type before update [\#1319](https://github.com/python-kasa/python-kasa/pull/1319) (@sdb9696)
|
||||
- Fallback to other module data on get\_energy\_usage errors [\#1245](https://github.com/python-kasa/python-kasa/pull/1245) (@rytilahti)
|
||||
|
||||
**Added support for devices:**
|
||||
|
||||
- Add P210M\(US\) 1.0 1.0.3 fixture [\#1399](https://github.com/python-kasa/python-kasa/pull/1399) (@sdb9696)
|
||||
- Add C225\(US\) 2.0 1.0.11 fixture [\#1398](https://github.com/python-kasa/python-kasa/pull/1398) (@sdb9696)
|
||||
- Add P306\(US\) 1.0 1.1.2 fixture [\#1396](https://github.com/python-kasa/python-kasa/pull/1396) (@nakanaela)
|
||||
- Add TC70 3.0 1.3.11 fixture [\#1390](https://github.com/python-kasa/python-kasa/pull/1390) (@sdb9696)
|
||||
- Add KS200 \(US\) IOT Fixture and P115 \(US\) Smart Fixture [\#1355](https://github.com/python-kasa/python-kasa/pull/1355) (@ZeliardM)
|
||||
- Add C520WS camera fixture [\#1352](https://github.com/python-kasa/python-kasa/pull/1352) (@Happy-Cadaver)
|
||||
- Add C325WB\(EU\) 1.0 1.1.17 Fixture [\#1379](https://github.com/python-kasa/python-kasa/pull/1379) (@sdb9696)
|
||||
- Add C100 4.0 1.3.14 Fixture [\#1378](https://github.com/python-kasa/python-kasa/pull/1378) (@sdb9696)
|
||||
|
||||
**Documentation updates:**
|
||||
|
||||
- Update docs for Tapo Lab Third-Party compatibility [\#1380](https://github.com/python-kasa/python-kasa/pull/1380) (@sdb9696)
|
||||
- Add homebridge-kasa-python link to README [\#1367](https://github.com/python-kasa/python-kasa/pull/1367) (@rytilahti)
|
||||
- Add link to related homeassistant-tapo-control [\#1333](https://github.com/python-kasa/python-kasa/pull/1333) (@rytilahti)
|
||||
- Update docs for new FeatureAttribute behaviour [\#1365](https://github.com/python-kasa/python-kasa/pull/1365) (@sdb9696)
|
||||
|
||||
**Project maintenance:**
|
||||
|
||||
- Add P135 1.0 1.2.0 fixture [\#1397](https://github.com/python-kasa/python-kasa/pull/1397) (@sdb9696)
|
||||
- Update C520WS fixture with new methods [\#1384](https://github.com/python-kasa/python-kasa/pull/1384) (@sdb9696)
|
||||
- Miscellaneous minor fixes to dump\_devinfo [\#1382](https://github.com/python-kasa/python-kasa/pull/1382) (@sdb9696)
|
||||
- Add timeout parameter to dump\_devinfo [\#1381](https://github.com/python-kasa/python-kasa/pull/1381) (@sdb9696)
|
||||
- Force single for some smartcam requests [\#1374](https://github.com/python-kasa/python-kasa/pull/1374) (@sdb9696)
|
||||
- Pass raw components to SmartChildDevice init [\#1363](https://github.com/python-kasa/python-kasa/pull/1363) (@sdb9696)
|
||||
- Fix line endings in device\_fixtures.py [\#1361](https://github.com/python-kasa/python-kasa/pull/1361) (@sdb9696)
|
||||
- Tweak RELEASING.md instructions for patch releases [\#1347](https://github.com/python-kasa/python-kasa/pull/1347) (@sdb9696)
|
||||
- Scrub more vacuum keys [\#1328](https://github.com/python-kasa/python-kasa/pull/1328) (@rytilahti)
|
||||
- Remove unnecessary check for python \<3.10 [\#1326](https://github.com/python-kasa/python-kasa/pull/1326) (@rytilahti)
|
||||
- Add vacuum component queries to dump\_devinfo [\#1320](https://github.com/python-kasa/python-kasa/pull/1320) (@rytilahti)
|
||||
- Handle missing mgt\_encryption\_schm in discovery [\#1318](https://github.com/python-kasa/python-kasa/pull/1318) (@sdb9696)
|
||||
- Follow main package structure for tests [\#1317](https://github.com/python-kasa/python-kasa/pull/1317) (@rytilahti)
|
||||
- Handle smartcam device blocked response [\#1393](https://github.com/python-kasa/python-kasa/pull/1393) (@sdb9696)
|
||||
- Handle KeyboardInterrupts in the cli better [\#1391](https://github.com/python-kasa/python-kasa/pull/1391) (@sdb9696)
|
||||
- Simplify get\_protocol to prevent clashes with smartcam and robovac [\#1377](https://github.com/python-kasa/python-kasa/pull/1377) (@sdb9696)
|
||||
- Add smartcam modules to package inits [\#1376](https://github.com/python-kasa/python-kasa/pull/1376) (@sdb9696)
|
||||
- Enable saving of fixture files without git clone [\#1375](https://github.com/python-kasa/python-kasa/pull/1375) (@sdb9696)
|
||||
- Add new methods to dump\_devinfo [\#1373](https://github.com/python-kasa/python-kasa/pull/1373) (@sdb9696)
|
||||
- Update cli, light modules, and docs to use FeatureAttributes [\#1364](https://github.com/python-kasa/python-kasa/pull/1364) (@sdb9696)
|
||||
- Update dump\_devinfo for raw discovery json and common redactors [\#1358](https://github.com/python-kasa/python-kasa/pull/1358) (@sdb9696)
|
||||
|
||||
## [0.8.1](https://github.com/python-kasa/python-kasa/tree/0.8.1) (2024-12-06)
|
||||
|
||||
[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.8.0...0.8.1)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix update errors on hubs with unsupported children [\#1344](https://github.com/python-kasa/python-kasa/pull/1344) (@sdb9696)
|
||||
- Fix smartcam missing device id [\#1343](https://github.com/python-kasa/python-kasa/pull/1343) (@sdb9696)
|
||||
|
||||
## [0.8.0](https://github.com/python-kasa/python-kasa/tree/0.8.0) (2024-11-26)
|
||||
|
||||
[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.7...0.8.0)
|
||||
@ -35,28 +303,28 @@ Special thanks to @ryenitcher and @Puxtril for their new contributions to the im
|
||||
- Update cli feature command for actions not to require a value [\#1264](https://github.com/python-kasa/python-kasa/pull/1264) (@sdb9696)
|
||||
- Add pan tilt camera module [\#1261](https://github.com/python-kasa/python-kasa/pull/1261) (@sdb9696)
|
||||
- Add alarm module for smartcamera hubs [\#1258](https://github.com/python-kasa/python-kasa/pull/1258) (@sdb9696)
|
||||
- Move TAPO smartcamera out of experimental package [\#1255](https://github.com/python-kasa/python-kasa/pull/1255) (@sdb9696)
|
||||
- Add SmartCamera Led Module [\#1249](https://github.com/python-kasa/python-kasa/pull/1249) (@sdb9696)
|
||||
- Use component queries to select smartcamera modules [\#1248](https://github.com/python-kasa/python-kasa/pull/1248) (@sdb9696)
|
||||
- Print formatting for IotLightPreset [\#1216](https://github.com/python-kasa/python-kasa/pull/1216) (@Puxtril)
|
||||
- Allow getting Annotated features from modules [\#1018](https://github.com/python-kasa/python-kasa/pull/1018) (@sdb9696)
|
||||
- Move TAPO smartcamera out of experimental package [\#1255](https://github.com/python-kasa/python-kasa/pull/1255) (@sdb9696)
|
||||
- Use component queries to select smartcamera modules [\#1248](https://github.com/python-kasa/python-kasa/pull/1248) (@sdb9696)
|
||||
- Add common Thermostat module [\#977](https://github.com/python-kasa/python-kasa/pull/977) (@sdb9696)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- TP-Link Tapo S505D cannot disable gradual on/off [\#1309](https://github.com/python-kasa/python-kasa/issues/1309)
|
||||
- Inconsistent emeter information between features and emeter cli [\#1308](https://github.com/python-kasa/python-kasa/issues/1308)
|
||||
- How to dump power usage after latest updates? [\#1306](https://github.com/python-kasa/python-kasa/issues/1306)
|
||||
- kasa.discover: Got unsupported connection type: 'device\_family': 'SMART.IPCAMERA' [\#1267](https://github.com/python-kasa/python-kasa/issues/1267)
|
||||
- device \_\_repr\_\_ fails if no sys\_info [\#1262](https://github.com/python-kasa/python-kasa/issues/1262)
|
||||
- Tapo P110M: Error processing Energy for device, module will be unavailable: get\_energy\_usage for Energy [\#1243](https://github.com/python-kasa/python-kasa/issues/1243)
|
||||
- Listing light presets throws error [\#1201](https://github.com/python-kasa/python-kasa/issues/1201)
|
||||
- Include duration when disabling smooth transition on/off [\#1313](https://github.com/python-kasa/python-kasa/pull/1313) (@rytilahti)
|
||||
- Expose energy command to cli [\#1307](https://github.com/python-kasa/python-kasa/pull/1307) (@rytilahti)
|
||||
- Inconsistent emeter information between features and emeter cli [\#1308](https://github.com/python-kasa/python-kasa/issues/1308)
|
||||
- Make discovery on unsupported devices less noisy [\#1291](https://github.com/python-kasa/python-kasa/pull/1291) (@rytilahti)
|
||||
- Fix repr for device created with no sysinfo or discovery info" [\#1266](https://github.com/python-kasa/python-kasa/pull/1266) (@sdb9696)
|
||||
- Fix discovery by alias for smart devices [\#1260](https://github.com/python-kasa/python-kasa/pull/1260) (@sdb9696)
|
||||
- Make \_\_repr\_\_ work on discovery info [\#1233](https://github.com/python-kasa/python-kasa/pull/1233) (@rytilahti)
|
||||
- Include duration when disabling smooth transition on/off [\#1313](https://github.com/python-kasa/python-kasa/pull/1313) (@rytilahti)
|
||||
- Expose energy command to cli [\#1307](https://github.com/python-kasa/python-kasa/pull/1307) (@rytilahti)
|
||||
|
||||
**Added support for devices:**
|
||||
|
||||
@ -70,13 +338,11 @@ Special thanks to @ryenitcher and @Puxtril for their new contributions to the im
|
||||
**Documentation updates:**
|
||||
|
||||
- Use markdown footnotes in supported.md [\#1310](https://github.com/python-kasa/python-kasa/pull/1310) (@sdb9696)
|
||||
- Update docs for the new module attributes has/get feature [\#1301](https://github.com/python-kasa/python-kasa/pull/1301) (@sdb9696)
|
||||
- Fixup contributing.md for running test against a real device [\#1236](https://github.com/python-kasa/python-kasa/pull/1236) (@sdb9696)
|
||||
- Update docs for the new module attributes has/get feature [\#1301](https://github.com/python-kasa/python-kasa/pull/1301) (@sdb9696)
|
||||
|
||||
**Project maintenance:**
|
||||
|
||||
- Rename tests/smartcamera to tests/smartcam [\#1315](https://github.com/python-kasa/python-kasa/pull/1315) (@sdb9696)
|
||||
- Do not error on smartcam hub attached smartcam child devices [\#1314](https://github.com/python-kasa/python-kasa/pull/1314) (@sdb9696)
|
||||
- Add P110M\(EU\) fixture [\#1305](https://github.com/python-kasa/python-kasa/pull/1305) (@sdb9696)
|
||||
- Run tests with caplog in a single worker [\#1304](https://github.com/python-kasa/python-kasa/pull/1304) (@sdb9696)
|
||||
- Rename smartcamera to smartcam [\#1300](https://github.com/python-kasa/python-kasa/pull/1300) (@sdb9696)
|
||||
@ -106,15 +372,17 @@ Special thanks to @ryenitcher and @Puxtril for their new contributions to the im
|
||||
- Add linkcheck to readthedocs CI [\#1253](https://github.com/python-kasa/python-kasa/pull/1253) (@rytilahti)
|
||||
- Update cli energy command to use energy module [\#1252](https://github.com/python-kasa/python-kasa/pull/1252) (@sdb9696)
|
||||
- Consolidate warnings for fixtures missing child devices [\#1251](https://github.com/python-kasa/python-kasa/pull/1251) (@sdb9696)
|
||||
- Update smartcamera fixtures with components [\#1250](https://github.com/python-kasa/python-kasa/pull/1250) (@sdb9696)
|
||||
- Move transports into their own package [\#1247](https://github.com/python-kasa/python-kasa/pull/1247) (@rytilahti)
|
||||
- Fix warnings in our test suite [\#1246](https://github.com/python-kasa/python-kasa/pull/1246) (@rytilahti)
|
||||
- Move tests folder to top level of project [\#1242](https://github.com/python-kasa/python-kasa/pull/1242) (@sdb9696)
|
||||
- Fix test framework running against real devices [\#1235](https://github.com/python-kasa/python-kasa/pull/1235) (@sdb9696)
|
||||
- Add Additional Firmware Test Fixures [\#1234](https://github.com/python-kasa/python-kasa/pull/1234) (@ryenitcher)
|
||||
- Update DiscoveryResult to use Mashumaro instead of pydantic [\#1231](https://github.com/python-kasa/python-kasa/pull/1231) (@sdb9696)
|
||||
- Update fixture for ES20M 1.0.11 [\#1215](https://github.com/python-kasa/python-kasa/pull/1215) (@rytilahti)
|
||||
- Enable ruff check for ANN [\#1139](https://github.com/python-kasa/python-kasa/pull/1139) (@rytilahti)
|
||||
- Rename tests/smartcamera to tests/smartcam [\#1315](https://github.com/python-kasa/python-kasa/pull/1315) (@sdb9696)
|
||||
- Do not error on smartcam hub attached smartcam child devices [\#1314](https://github.com/python-kasa/python-kasa/pull/1314) (@sdb9696)
|
||||
- Update smartcamera fixtures with components [\#1250](https://github.com/python-kasa/python-kasa/pull/1250) (@sdb9696)
|
||||
- Fix warnings in our test suite [\#1246](https://github.com/python-kasa/python-kasa/pull/1246) (@rytilahti)
|
||||
- Fix test framework running against real devices [\#1235](https://github.com/python-kasa/python-kasa/pull/1235) (@sdb9696)
|
||||
- Update DiscoveryResult to use Mashumaro instead of pydantic [\#1231](https://github.com/python-kasa/python-kasa/pull/1231) (@sdb9696)
|
||||
|
||||
**Closed issues:**
|
||||
|
||||
|
20
README.md
20
README.md
@ -178,14 +178,18 @@ The following devices have been tested and confirmed as working. If your device
|
||||
> [!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-->
|
||||
### Supported Kasa devices
|
||||
|
||||
- **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, KS200M, KS205[^1], KS220, KS220M, KS225[^1], KS230, KS240[^1]
|
||||
- **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110
|
||||
- **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, LB100, LB110
|
||||
- **Light Strips**: KL400L5, KL420L5, KL430
|
||||
- **Hubs**: KH100[^1]
|
||||
- **Hub-Connected Devices[^3]**: KE100[^1]
|
||||
@ -193,11 +197,13 @@ The following devices have been tested and confirmed as working. If your device
|
||||
### Supported Tapo[^1] devices
|
||||
|
||||
- **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15
|
||||
- **Power Strips**: P300, P304M, TP25
|
||||
- **Wall Switches**: S500D, S505, S505D
|
||||
- **Bulbs**: L510B, L510E, L530E, L630
|
||||
- **Power Strips**: P210M, P300, P304M, P306, TP25
|
||||
- **Wall Switches**: S210, S220, S500D, S505, S505D
|
||||
- **Bulbs**: L510B, L510E, L530B, L530E, L630
|
||||
- **Light Strips**: L900-10, L900-5, L920-5, L930-5
|
||||
- **Cameras**: C210, TC65
|
||||
- **Cameras**: C100, C110, C210, C220, C225, C325WB, C520WS, C720, TC65, TC70
|
||||
- **Doorbells and chimes**: D100C, D130, D230
|
||||
- **Vacuums**: RV20 Max Plus, RV30 Max
|
||||
- **Hubs**: H100, H200
|
||||
- **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315
|
||||
|
||||
@ -223,10 +229,12 @@ See [supported devices in our documentation](SUPPORTED.md) for more detailed inf
|
||||
|
||||
* [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)
|
||||
|
||||
### Other related projects
|
||||
|
||||
* [PyTapo - Python library for communication with Tapo Cameras](https://github.com/JurajNyiri/pytapo)
|
||||
* [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)
|
||||
|
14
RELEASING.md
14
RELEASING.md
@ -44,9 +44,10 @@ uv lock --upgrade
|
||||
uv sync --all-extras
|
||||
```
|
||||
|
||||
### Run pre-commit and tests
|
||||
### Update and run pre-commit and tests
|
||||
|
||||
```bash
|
||||
pre-commit autoupdate
|
||||
uv run pre-commit run --all-files
|
||||
uv run pytest -n auto
|
||||
```
|
||||
@ -124,6 +125,12 @@ git push upstream release/$NEW_RELEASE -u
|
||||
gh pr create --title "Prepare $NEW_RELEASE" --body "$RELEASE_NOTES" --label release-prep --base master
|
||||
```
|
||||
|
||||
To update the PR after refreshing the changelog:
|
||||
|
||||
```
|
||||
gh pr edit --body "$RELEASE_NOTES"
|
||||
```
|
||||
|
||||
#### Merge the PR once the CI passes
|
||||
|
||||
Create a squash commit and add the markdown from the PR description to the commit description.
|
||||
@ -283,9 +290,12 @@ 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 CHANGELOG.md
|
||||
# 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
|
||||
|
61
SUPPORTED.md
61
SUPPORTED.md
@ -5,6 +5,9 @@ The following devices have been tested and confirmed as working. If your device
|
||||
> [!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-->
|
||||
@ -90,6 +93,7 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th
|
||||
- **HS210**
|
||||
- Hardware: 1.0 (US) / Firmware: 1.5.8
|
||||
- Hardware: 2.0 (US) / Firmware: 1.1.5
|
||||
- Hardware: 3.0 (US) / Firmware: 1.0.10
|
||||
- **HS220**
|
||||
- Hardware: 1.0 (US) / Firmware: 1.5.7
|
||||
- Hardware: 2.0 (US) / Firmware: 1.0.3
|
||||
@ -97,6 +101,8 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th
|
||||
- **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
|
||||
@ -112,8 +118,10 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th
|
||||
- **KS225**
|
||||
- Hardware: 1.0 (US) / Firmware: 1.0.2[^1]
|
||||
- Hardware: 1.0 (US) / Firmware: 1.1.0[^1]
|
||||
- Hardware: 1.0 (US) / Firmware: 1.1.1[^1]
|
||||
- **KS230**
|
||||
- Hardware: 1.0 (US) / Firmware: 1.0.14
|
||||
- Hardware: 2.0 (US) / Firmware: 1.0.11
|
||||
- **KS240**
|
||||
- Hardware: 1.0 (US) / Firmware: 1.0.4[^1]
|
||||
- Hardware: 1.0 (US) / Firmware: 1.0.5[^1]
|
||||
@ -141,6 +149,8 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th
|
||||
- **KL60**
|
||||
- Hardware: 1.0 (UN) / Firmware: 1.1.4
|
||||
- Hardware: 1.0 (US) / Firmware: 1.1.13
|
||||
- **LB100**
|
||||
- Hardware: 1.0 (US) / Firmware: 1.8.11
|
||||
- **LB110**
|
||||
- Hardware: 1.0 (US) / Firmware: 1.8.11
|
||||
|
||||
@ -184,6 +194,7 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
|
||||
- Hardware: 1.0.0 (US) / Firmware: 1.3.7
|
||||
- Hardware: 1.0.0 (US) / Firmware: 1.4.0
|
||||
- **P110**
|
||||
- Hardware: 1.0 (AU) / Firmware: 1.3.1
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.0.7
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.2.3
|
||||
- Hardware: 1.0 (UK) / Firmware: 1.3.0
|
||||
@ -192,26 +203,36 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
|
||||
- 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
|
||||
- Hardware: 1.0 (US) / Firmware: 1.2.0
|
||||
- **TP15**
|
||||
- Hardware: 1.0 (US) / Firmware: 1.0.3
|
||||
|
||||
### Power Strips
|
||||
|
||||
- **P210M**
|
||||
- Hardware: 1.0 (US) / Firmware: 1.0.3
|
||||
- **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
|
||||
- **P306**
|
||||
- Hardware: 1.0 (US) / Firmware: 1.1.2
|
||||
- **TP25**
|
||||
- Hardware: 1.0 (US) / Firmware: 1.0.2
|
||||
|
||||
### Wall Switches
|
||||
|
||||
- **S210**
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.9.0
|
||||
- **S220**
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.9.0
|
||||
- **S500D**
|
||||
- Hardware: 1.0 (US) / Firmware: 1.0.5
|
||||
- **S505**
|
||||
@ -226,10 +247,13 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
|
||||
- **L510E**
|
||||
- Hardware: 3.0 (US) / Firmware: 1.0.5
|
||||
- Hardware: 3.0 (US) / Firmware: 1.1.2
|
||||
- **L530B**
|
||||
- Hardware: 3.0 (EU) / Firmware: 1.1.9
|
||||
- **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 (TW) / Firmware: 1.1.1
|
||||
- Hardware: 2.0 (US) / Firmware: 1.1.0
|
||||
- **L630**
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.1.2
|
||||
@ -248,24 +272,60 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
|
||||
- Hardware: 1.0 (US) / Firmware: 1.1.0
|
||||
- Hardware: 1.0 (US) / Firmware: 1.1.3
|
||||
- **L930-5**
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.2.5
|
||||
- Hardware: 1.0 (US) / Firmware: 1.1.2
|
||||
|
||||
### Cameras
|
||||
|
||||
- **C100**
|
||||
- Hardware: 4.0 / Firmware: 1.3.14
|
||||
- **C110**
|
||||
- Hardware: 2.0 (EU) / Firmware: 1.4.3
|
||||
- **C210**
|
||||
- Hardware: 2.0 / Firmware: 1.3.11
|
||||
- Hardware: 2.0 (EU) / Firmware: 1.4.2
|
||||
- Hardware: 2.0 (EU) / Firmware: 1.4.3
|
||||
- **C220**
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.2.2
|
||||
- **C225**
|
||||
- Hardware: 2.0 (US) / Firmware: 1.0.11
|
||||
- **C325WB**
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.1.17
|
||||
- **C520WS**
|
||||
- Hardware: 1.0 (US) / Firmware: 1.2.8
|
||||
- **C720**
|
||||
- Hardware: 1.0 (US) / Firmware: 1.2.3
|
||||
- **TC65**
|
||||
- Hardware: 1.0 / Firmware: 1.3.9
|
||||
- **TC70**
|
||||
- Hardware: 3.0 / Firmware: 1.3.11
|
||||
|
||||
### Doorbells and chimes
|
||||
|
||||
- **D100C**
|
||||
- Hardware: 1.0 (US) / Firmware: 1.1.3
|
||||
- **D130**
|
||||
- Hardware: 1.0 (US) / Firmware: 1.1.9
|
||||
- **D230**
|
||||
- Hardware: 1.20 (EU) / Firmware: 1.1.19
|
||||
|
||||
### Vacuums
|
||||
|
||||
- **RV20 Max Plus**
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.0.7
|
||||
- **RV30 Max**
|
||||
- Hardware: 1.0 (US) / Firmware: 1.2.0
|
||||
|
||||
### Hubs
|
||||
|
||||
- **H100**
|
||||
- Hardware: 1.0 (AU) / Firmware: 1.5.23
|
||||
- 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 (EU) / Firmware: 1.3.6
|
||||
- Hardware: 1.0 (US) / Firmware: 1.3.6
|
||||
|
||||
### Hub-Connected Devices
|
||||
@ -278,6 +338,7 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.12.0
|
||||
- **T100**
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.12.0
|
||||
- Hardware: 1.0 (US) / Firmware: 1.12.0
|
||||
- **T110**
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.8.0
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.9.0
|
||||
|
@ -10,8 +10,6 @@ and finally execute a query to query all of them at once.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import collections.abc
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
@ -19,6 +17,7 @@ import re
|
||||
import sys
|
||||
import traceback
|
||||
from collections import defaultdict, namedtuple
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from pprint import pprint
|
||||
from typing import Any
|
||||
@ -39,30 +38,83 @@ from kasa import (
|
||||
)
|
||||
from kasa.device_factory import get_protocol
|
||||
from kasa.deviceconfig import DeviceEncryptionType, DeviceFamily
|
||||
from kasa.discover import DiscoveryResult
|
||||
from kasa.discover import (
|
||||
NEW_DISCOVERY_REDACTORS,
|
||||
DiscoveredRaw,
|
||||
DiscoveryResult,
|
||||
)
|
||||
from kasa.exceptions import SmartErrorCode
|
||||
from kasa.protocols import IotProtocol
|
||||
from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS
|
||||
from kasa.protocols.protocol import redact_data
|
||||
from kasa.protocols.smartcamprotocol import (
|
||||
SmartCamProtocol,
|
||||
_ChildCameraProtocolWrapper,
|
||||
)
|
||||
from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS
|
||||
from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper
|
||||
from kasa.smart import SmartChildDevice, SmartDevice
|
||||
from kasa.smartcam import SmartCamDevice
|
||||
from kasa.smartcam import SmartCamChild, SmartCamDevice
|
||||
from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT
|
||||
|
||||
Call = namedtuple("Call", "module method")
|
||||
FixtureResult = namedtuple("FixtureResult", "filename, folder, data")
|
||||
FixtureResult = namedtuple("FixtureResult", "filename, folder, data, protocol_suffix")
|
||||
|
||||
SMART_FOLDER = "tests/fixtures/smart/"
|
||||
SMARTCAM_FOLDER = "tests/fixtures/smartcam/"
|
||||
SMART_CHILD_FOLDER = "tests/fixtures/smart/child/"
|
||||
SMARTCAM_CHILD_FOLDER = "tests/fixtures/smartcam/child/"
|
||||
IOT_FOLDER = "tests/fixtures/iot/"
|
||||
|
||||
SMART_PROTOCOL_SUFFIX = "SMART"
|
||||
SMARTCAM_SUFFIX = "SMARTCAM"
|
||||
SMART_CHILD_SUFFIX = "SMART.CHILD"
|
||||
SMARTCAM_CHILD_SUFFIX = "SMARTCAM.CHILD"
|
||||
IOT_SUFFIX = "IOT"
|
||||
|
||||
NO_GIT_FIXTURE_FOLDER = "kasa-fixtures"
|
||||
|
||||
ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _wrap_redactors(redactors: dict[str, Callable[[Any], Any] | None]):
|
||||
"""Wrap the redactors for dump_devinfo.
|
||||
|
||||
Will replace all partial REDACT_ values with zeros.
|
||||
If the data item is already scrubbed by dump_devinfo will leave as-is.
|
||||
"""
|
||||
|
||||
def _wrap(key: str) -> Any:
|
||||
def _wrapped(redactor: Callable[[Any], Any] | None) -> Any | None:
|
||||
if redactor is None:
|
||||
return lambda x: "**SCRUBBED**"
|
||||
|
||||
def _redact_to_zeros(x: Any) -> Any:
|
||||
if isinstance(x, str) and "REDACT" in x:
|
||||
return re.sub(r"\w", "0", x)
|
||||
if isinstance(x, dict):
|
||||
for k, v in x.items():
|
||||
x[k] = _redact_to_zeros(v)
|
||||
return x
|
||||
|
||||
def _scrub(x: Any) -> Any:
|
||||
if key in {"ip", "local_ip"}:
|
||||
return "127.0.0.123"
|
||||
# Already scrubbed by dump_devinfo
|
||||
if isinstance(x, str) and "SCRUBBED" in x:
|
||||
return x
|
||||
default = redactor(x)
|
||||
return _redact_to_zeros(default)
|
||||
|
||||
return _scrub
|
||||
|
||||
return _wrapped(redactors[key])
|
||||
|
||||
return {key: _wrap(key) for key in redactors}
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class SmartCall:
|
||||
"""Class for smart and smartcam calls."""
|
||||
@ -74,103 +126,6 @@ class SmartCall:
|
||||
supports_multiple: bool = True
|
||||
|
||||
|
||||
def scrub(res):
|
||||
"""Remove identifiers from the given dict."""
|
||||
keys_to_scrub = [
|
||||
"deviceId",
|
||||
"fwId",
|
||||
"hwId",
|
||||
"oemId",
|
||||
"mac",
|
||||
"mic_mac",
|
||||
"latitude_i",
|
||||
"longitude_i",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"la", # lat on ks240
|
||||
"lo", # lon on ks240
|
||||
"owner",
|
||||
"device_id",
|
||||
"ip",
|
||||
"ssid",
|
||||
"hw_id",
|
||||
"fw_id",
|
||||
"oem_id",
|
||||
"nickname",
|
||||
"alias",
|
||||
"bssid",
|
||||
"channel",
|
||||
"original_device_id", # for child devices on strips
|
||||
"parent_device_id", # for hub children
|
||||
"setup_code", # matter
|
||||
"setup_payload", # matter
|
||||
"mfi_setup_code", # mfi_ for homekit
|
||||
"mfi_setup_id",
|
||||
"mfi_token_token",
|
||||
"mfi_token_uuid",
|
||||
"dev_id",
|
||||
"device_name",
|
||||
"device_alias",
|
||||
"connect_ssid",
|
||||
"encrypt_info",
|
||||
"local_ip",
|
||||
"username",
|
||||
]
|
||||
|
||||
for k, v in res.items():
|
||||
if isinstance(v, collections.abc.Mapping):
|
||||
if k == "encrypt_info":
|
||||
if "data" in v:
|
||||
v["data"] = ""
|
||||
if "key" in v:
|
||||
v["key"] = ""
|
||||
else:
|
||||
res[k] = scrub(res.get(k))
|
||||
elif (
|
||||
isinstance(v, list)
|
||||
and len(v) > 0
|
||||
and isinstance(v[0], collections.abc.Mapping)
|
||||
):
|
||||
res[k] = [scrub(vi) for vi in v]
|
||||
else:
|
||||
if k in keys_to_scrub:
|
||||
if k in ["mac", "mic_mac"]:
|
||||
# Some macs have : or - as a separator and others do not
|
||||
if len(v) == 12:
|
||||
v = f"{v[:6]}000000"
|
||||
else:
|
||||
delim = ":" if ":" in v else "-"
|
||||
rest = delim.join(
|
||||
format(s, "02x") for s in bytes.fromhex("000000")
|
||||
)
|
||||
v = f"{v[:8]}{delim}{rest}"
|
||||
elif k in ["latitude", "latitude_i", "longitude", "longitude_i"]:
|
||||
v = 0
|
||||
elif k in ["ip", "local_ip"]:
|
||||
v = "127.0.0.123"
|
||||
elif k in ["ssid"]:
|
||||
# Need a valid base64 value here
|
||||
v = base64.b64encode(b"#MASKED_SSID#").decode()
|
||||
elif k in ["nickname"]:
|
||||
v = base64.b64encode(b"#MASKED_NAME#").decode()
|
||||
elif k in ["alias", "device_alias", "device_name", "username"]:
|
||||
v = "#MASKED_NAME#"
|
||||
elif isinstance(res[k], int):
|
||||
v = 0
|
||||
elif k in ["device_id", "dev_id"] and "SCRUBBED" in v:
|
||||
pass # already scrubbed
|
||||
elif k == ["device_id", "dev_id"] and len(v) > 40:
|
||||
# retain the last two chars when scrubbing child ids
|
||||
end = v[-2:]
|
||||
v = re.sub(r"\w", "0", v)
|
||||
v = v[:40] + end
|
||||
else:
|
||||
v = re.sub(r"\w", "0", v)
|
||||
|
||||
res[k] = v
|
||||
return res
|
||||
|
||||
|
||||
def default_to_regular(d):
|
||||
"""Convert nested defaultdicts to regular ones.
|
||||
|
||||
@ -195,9 +150,19 @@ async def handle_device(
|
||||
]
|
||||
|
||||
for fixture_result in fixture_results:
|
||||
save_filename = Path(basedir) / fixture_result.folder / fixture_result.filename
|
||||
save_folder = Path(basedir) / fixture_result.folder
|
||||
if save_folder.exists():
|
||||
save_filename = save_folder / f"{fixture_result.filename}.json"
|
||||
else:
|
||||
# If being run without git clone
|
||||
save_folder = Path(basedir) / NO_GIT_FIXTURE_FOLDER
|
||||
save_folder.mkdir(exist_ok=True)
|
||||
save_filename = (
|
||||
save_folder
|
||||
/ f"{fixture_result.filename}-{fixture_result.protocol_suffix}.json"
|
||||
)
|
||||
|
||||
pprint(scrub(fixture_result.data))
|
||||
pprint(fixture_result.data)
|
||||
if autosave:
|
||||
save = "y"
|
||||
else:
|
||||
@ -288,6 +253,12 @@ async def handle_device(
|
||||
type=bool,
|
||||
help="Set flag if the device encryption uses https.",
|
||||
)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
required=False,
|
||||
default=15,
|
||||
help="Timeout for queries.",
|
||||
)
|
||||
@click.option("--port", help="Port override", type=int)
|
||||
async def cli(
|
||||
host,
|
||||
@ -305,6 +276,7 @@ async def cli(
|
||||
device_family,
|
||||
login_version,
|
||||
port,
|
||||
timeout,
|
||||
):
|
||||
"""Generate devinfo files for devices.
|
||||
|
||||
@ -313,6 +285,11 @@ async def cli(
|
||||
if debug:
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
raw_discovery = {}
|
||||
|
||||
def capture_raw(discovered: DiscoveredRaw):
|
||||
raw_discovery[discovered["meta"]["ip"]] = discovered["discovery_response"]
|
||||
|
||||
credentials = Credentials(username=username, password=password)
|
||||
if host is not None:
|
||||
if discovery_info:
|
||||
@ -323,13 +300,16 @@ async def cli(
|
||||
connection_type = DeviceConnectionParameters.from_values(
|
||||
dr.device_type,
|
||||
dr.mgt_encrypt_schm.encrypt_type,
|
||||
dr.mgt_encrypt_schm.lv,
|
||||
login_version=dr.mgt_encrypt_schm.lv,
|
||||
https=dr.mgt_encrypt_schm.is_support_https,
|
||||
http_port=dr.mgt_encrypt_schm.http_port,
|
||||
)
|
||||
dc = DeviceConfig(
|
||||
host=host,
|
||||
connection_type=connection_type,
|
||||
port_override=port,
|
||||
credentials=credentials,
|
||||
timeout=timeout,
|
||||
)
|
||||
device = await Device.connect(config=dc)
|
||||
await handle_device(
|
||||
@ -351,6 +331,7 @@ async def cli(
|
||||
port_override=port,
|
||||
credentials=credentials,
|
||||
connection_type=ctype,
|
||||
timeout=timeout,
|
||||
)
|
||||
if protocol := get_protocol(config):
|
||||
await handle_device(basedir, autosave, protocol, batch_size=batch_size)
|
||||
@ -365,12 +346,17 @@ async def cli(
|
||||
credentials=credentials,
|
||||
port=port,
|
||||
discovery_timeout=discovery_timeout,
|
||||
timeout=timeout,
|
||||
on_discovered_raw=capture_raw,
|
||||
)
|
||||
discovery_info = raw_discovery[device.host]
|
||||
if decrypted_data := device._discovery_info.get("decrypted_data"):
|
||||
discovery_info["result"]["decrypted_data"] = decrypted_data
|
||||
await handle_device(
|
||||
basedir,
|
||||
autosave,
|
||||
device.protocol,
|
||||
discovery_info=device._discovery_info,
|
||||
discovery_info=discovery_info,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
else:
|
||||
@ -379,21 +365,29 @@ async def cli(
|
||||
f" {target}. Use --target to override."
|
||||
)
|
||||
devices = await Discover.discover(
|
||||
target=target, credentials=credentials, discovery_timeout=discovery_timeout
|
||||
target=target,
|
||||
credentials=credentials,
|
||||
discovery_timeout=discovery_timeout,
|
||||
timeout=timeout,
|
||||
on_discovered_raw=capture_raw,
|
||||
)
|
||||
click.echo(f"Detected {len(devices)} devices")
|
||||
for dev in devices.values():
|
||||
discovery_info = raw_discovery[dev.host]
|
||||
if decrypted_data := dev._discovery_info.get("decrypted_data"):
|
||||
discovery_info["result"]["decrypted_data"] = decrypted_data
|
||||
|
||||
await handle_device(
|
||||
basedir,
|
||||
autosave,
|
||||
dev.protocol,
|
||||
discovery_info=dev._discovery_info,
|
||||
discovery_info=discovery_info,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
|
||||
|
||||
async def get_legacy_fixture(
|
||||
protocol: IotProtocol, *, discovery_info: dict[str, Any] | None
|
||||
protocol: IotProtocol, *, discovery_info: dict[str, dict[str, Any]] | None
|
||||
) -> FixtureResult:
|
||||
"""Get fixture for legacy IOT style protocol."""
|
||||
items = [
|
||||
@ -463,11 +457,21 @@ async def get_legacy_fixture(
|
||||
_echo_error(f"Unable to query all successes at once: {ex}")
|
||||
finally:
|
||||
await protocol.close()
|
||||
|
||||
final = redact_data(final, _wrap_redactors(IOT_REDACTORS))
|
||||
|
||||
# Scrub the child device ids
|
||||
if children := final.get("system", {}).get("get_sysinfo", {}).get("children"):
|
||||
for index, child in enumerate(children):
|
||||
if "id" not in child:
|
||||
_LOGGER.error("Could not find a device for the child device: %s", child)
|
||||
else:
|
||||
child["id"] = f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}"
|
||||
|
||||
if discovery_info and not discovery_info.get("system"):
|
||||
# Need to recreate a DiscoverResult here because we don't want the aliases
|
||||
# in the fixture, we want the actual field names as returned by the device.
|
||||
dr = DiscoveryResult.from_dict(discovery_info)
|
||||
final["discovery_result"] = dr.to_dict()
|
||||
final["discovery_result"] = redact_data(
|
||||
discovery_info, _wrap_redactors(NEW_DISCOVERY_REDACTORS)
|
||||
)
|
||||
|
||||
click.echo(f"Got {len(successes)} successes")
|
||||
click.echo(click.style("## device info file ##", bold=True))
|
||||
@ -477,9 +481,14 @@ async def get_legacy_fixture(
|
||||
hw_version = sysinfo["hw_ver"]
|
||||
sw_version = sysinfo["sw_ver"]
|
||||
sw_version = sw_version.split(" ", maxsplit=1)[0]
|
||||
save_filename = f"{model}_{hw_version}_{sw_version}.json"
|
||||
save_filename = f"{model}_{hw_version}_{sw_version}"
|
||||
copy_folder = IOT_FOLDER
|
||||
return FixtureResult(filename=save_filename, folder=copy_folder, data=final)
|
||||
return FixtureResult(
|
||||
filename=save_filename,
|
||||
folder=copy_folder,
|
||||
data=final,
|
||||
protocol_suffix=IOT_SUFFIX,
|
||||
)
|
||||
|
||||
|
||||
def _echo_error(msg: str):
|
||||
@ -716,15 +725,6 @@ async def get_smart_test_calls(protocol: SmartProtocol):
|
||||
successes = []
|
||||
child_device_components = {}
|
||||
|
||||
extra_test_calls = [
|
||||
SmartCall(
|
||||
module="temp_humidity_records",
|
||||
request=SmartRequest.get_raw_request("get_temp_humidity_records").to_dict(),
|
||||
should_succeed=False,
|
||||
child_device_id="",
|
||||
),
|
||||
]
|
||||
|
||||
click.echo("Testing component_nego call ..", nl=False)
|
||||
responses = await _make_requests_or_exit(
|
||||
protocol,
|
||||
@ -803,8 +803,6 @@ async def get_smart_test_calls(protocol: SmartProtocol):
|
||||
click.echo(f"Skipping {component_id}..", nl=False)
|
||||
click.echo(click.style("UNSUPPORTED", fg="yellow"))
|
||||
|
||||
test_calls.extend(extra_test_calls)
|
||||
|
||||
# Child component calls
|
||||
for child_device_id, child_components in child_device_components.items():
|
||||
test_calls.append(
|
||||
@ -830,32 +828,87 @@ async def get_smart_test_calls(protocol: SmartProtocol):
|
||||
else:
|
||||
click.echo(f"Skipping {component_id}..", nl=False)
|
||||
click.echo(click.style("UNSUPPORTED", fg="yellow"))
|
||||
# Add the extra calls for each child
|
||||
for extra_call in extra_test_calls:
|
||||
extra_child_call = dataclasses.replace(
|
||||
extra_call, child_device_id=child_device_id
|
||||
)
|
||||
test_calls.append(extra_child_call)
|
||||
|
||||
return test_calls, successes
|
||||
|
||||
|
||||
def get_smart_child_fixture(response):
|
||||
def get_smart_child_fixture(response, model_info, folder, suffix):
|
||||
"""Get a seperate fixture for the child device."""
|
||||
model_info = SmartDevice._get_device_info(response, None)
|
||||
hw_version = model_info.hardware_version
|
||||
fw_version = model_info.firmware_version
|
||||
model = model_info.long_name
|
||||
if model_info.region is not None:
|
||||
model = f"{model}({model_info.region})"
|
||||
save_filename = f"{model}_{hw_version}_{fw_version}.json"
|
||||
save_filename = f"{model}_{hw_version}_{fw_version}"
|
||||
return FixtureResult(
|
||||
filename=save_filename, folder=SMART_CHILD_FOLDER, data=response
|
||||
filename=save_filename,
|
||||
folder=folder,
|
||||
data=response,
|
||||
protocol_suffix=suffix,
|
||||
)
|
||||
|
||||
|
||||
def scrub_child_device_ids(
|
||||
main_response: dict, child_responses: dict
|
||||
) -> dict[str, str]:
|
||||
"""Scrub all the child device ids in the responses."""
|
||||
# Make the scrubbed id map
|
||||
scrubbed_child_id_map = {
|
||||
device_id: f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}"
|
||||
for index, device_id in enumerate(child_responses.keys())
|
||||
if device_id != ""
|
||||
}
|
||||
|
||||
for child_id, response in child_responses.items():
|
||||
scrubbed_child_id = scrubbed_child_id_map[child_id]
|
||||
# scrub the device id in the child's get info response
|
||||
# The checks for the device_id will ensure we can get a fixture
|
||||
# even if the data is unexpectedly not available although it should
|
||||
# always be there
|
||||
if "get_device_info" in response and "device_id" in response["get_device_info"]:
|
||||
response["get_device_info"]["device_id"] = scrubbed_child_id
|
||||
elif (
|
||||
basic_info := response.get("getDeviceInfo", {})
|
||||
.get("device_info", {})
|
||||
.get("basic_info")
|
||||
) and "dev_id" in basic_info:
|
||||
basic_info["dev_id"] = scrubbed_child_id
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Cannot find device id in child get device info: %s", child_id
|
||||
)
|
||||
|
||||
# Scrub the device ids in the parent for smart protocol
|
||||
if gc := main_response.get("get_child_device_component_list"):
|
||||
for child in gc["child_component_list"]:
|
||||
device_id = child["device_id"]
|
||||
child["device_id"] = scrubbed_child_id_map[device_id]
|
||||
for child in main_response["get_child_device_list"]["child_device_list"]:
|
||||
device_id = child["device_id"]
|
||||
child["device_id"] = scrubbed_child_id_map[device_id]
|
||||
|
||||
# Scrub the device ids in the parent for the smart camera protocol
|
||||
if gc := main_response.get("getChildDeviceComponentList"):
|
||||
for child in gc["child_component_list"]:
|
||||
device_id = child["device_id"]
|
||||
child["device_id"] = scrubbed_child_id_map[device_id]
|
||||
for child in main_response["getChildDeviceList"]["child_device_list"]:
|
||||
if device_id := child.get("device_id"):
|
||||
child["device_id"] = scrubbed_child_id_map[device_id]
|
||||
continue
|
||||
elif dev_id := child.get("dev_id"):
|
||||
child["dev_id"] = scrubbed_child_id_map[dev_id]
|
||||
continue
|
||||
_LOGGER.error("Could not find a device id for the child device: %s", child)
|
||||
|
||||
return scrubbed_child_id_map
|
||||
|
||||
|
||||
async def get_smart_fixtures(
|
||||
protocol: SmartProtocol, *, discovery_info: dict[str, Any] | None, batch_size: int
|
||||
protocol: SmartProtocol,
|
||||
*,
|
||||
discovery_info: dict[str, dict[str, Any]] | None,
|
||||
batch_size: int,
|
||||
) -> list[FixtureResult]:
|
||||
"""Get fixture for new TAPO style protocol."""
|
||||
if isinstance(protocol, SmartCamProtocol):
|
||||
@ -907,21 +960,19 @@ async def get_smart_fixtures(
|
||||
finally:
|
||||
await protocol.close()
|
||||
|
||||
# Put all the successes into a dict[child_device_id or "", successes[]]
|
||||
device_requests: dict[str, list[SmartCall]] = {}
|
||||
for success in successes:
|
||||
device_request = device_requests.setdefault(success.child_device_id, [])
|
||||
device_request.append(success)
|
||||
|
||||
scrubbed_device_ids = {
|
||||
device_id: f"SCRUBBED_CHILD_DEVICE_ID_{index}"
|
||||
for index, device_id in enumerate(device_requests.keys())
|
||||
if device_id != ""
|
||||
}
|
||||
|
||||
final = await _make_final_calls(
|
||||
protocol, device_requests[""], "All successes", batch_size, child_device_id=""
|
||||
)
|
||||
fixture_results = []
|
||||
|
||||
# Make the final child calls
|
||||
child_responses = {}
|
||||
for child_device_id, requests in device_requests.items():
|
||||
if child_device_id == "":
|
||||
continue
|
||||
@ -932,77 +983,118 @@ async def get_smart_fixtures(
|
||||
batch_size,
|
||||
child_device_id=child_device_id,
|
||||
)
|
||||
child_responses[child_device_id] = response
|
||||
|
||||
scrubbed = scrubbed_device_ids[child_device_id]
|
||||
if "get_device_info" in response and "device_id" in response["get_device_info"]:
|
||||
response["get_device_info"]["device_id"] = scrubbed
|
||||
# If the child is a different model to the parent create a seperate fixture
|
||||
if "get_device_info" in final:
|
||||
parent_model = final["get_device_info"]["model"]
|
||||
elif "getDeviceInfo" in final:
|
||||
parent_model = final["getDeviceInfo"]["device_info"]["basic_info"][
|
||||
"device_model"
|
||||
]
|
||||
# scrub the child ids
|
||||
scrubbed_child_id_map = scrub_child_device_ids(final, child_responses)
|
||||
|
||||
# Redact data from the main device response. _wrap_redactors ensure we do
|
||||
# not redact the scrubbed child device ids and replaces REDACTED_partial_id
|
||||
# with zeros
|
||||
final = redact_data(final, _wrap_redactors(SMART_REDACTORS))
|
||||
|
||||
# smart cam child devices provide more information in getChildDeviceList on the
|
||||
# parent than they return when queried directly for getDeviceInfo so we will store
|
||||
# it in the child fixture.
|
||||
if smart_cam_child_list := final.get("getChildDeviceList"):
|
||||
child_infos_on_parent = {
|
||||
info["device_id"]: info
|
||||
for info in smart_cam_child_list["child_device_list"]
|
||||
}
|
||||
|
||||
for child_id, response in child_responses.items():
|
||||
scrubbed_child_id = scrubbed_child_id_map[child_id]
|
||||
|
||||
# Get the parent model for checking whether to create a seperate child fixture
|
||||
if model := final.get("get_device_info", {}).get("model"):
|
||||
parent_model = model
|
||||
elif (
|
||||
device_model := final.get("getDeviceInfo", {})
|
||||
.get("device_info", {})
|
||||
.get("basic_info", {})
|
||||
.get("device_model")
|
||||
):
|
||||
parent_model = device_model
|
||||
else:
|
||||
raise KasaException("Cannot determine parent device model.")
|
||||
parent_model = None
|
||||
_LOGGER.error("Cannot determine parent device model.")
|
||||
|
||||
# different model smart child device
|
||||
if (
|
||||
"component_nego" in response
|
||||
and "get_device_info" in response
|
||||
and (child_model := response["get_device_info"].get("model"))
|
||||
(child_model := response.get("get_device_info", {}).get("model"))
|
||||
and parent_model
|
||||
and child_model != parent_model
|
||||
):
|
||||
fixture_results.append(get_smart_child_fixture(response))
|
||||
response = redact_data(response, _wrap_redactors(SMART_REDACTORS))
|
||||
model_info = SmartDevice._get_device_info(response, None)
|
||||
fixture_results.append(
|
||||
get_smart_child_fixture(
|
||||
response, model_info, SMART_CHILD_FOLDER, SMART_CHILD_SUFFIX
|
||||
)
|
||||
)
|
||||
# different model smartcam child device
|
||||
elif (
|
||||
(
|
||||
child_model := response.get("getDeviceInfo", {})
|
||||
.get("device_info", {})
|
||||
.get("basic_info", {})
|
||||
.get("device_model")
|
||||
)
|
||||
and parent_model
|
||||
and child_model != parent_model
|
||||
):
|
||||
response = redact_data(response, _wrap_redactors(SMART_REDACTORS))
|
||||
# There is more info in the childDeviceList on the parent
|
||||
# particularly the region is needed here.
|
||||
child_info_from_parent = child_infos_on_parent[scrubbed_child_id]
|
||||
response[CHILD_INFO_FROM_PARENT] = child_info_from_parent
|
||||
model_info = SmartCamChild._get_device_info(response, None)
|
||||
fixture_results.append(
|
||||
get_smart_child_fixture(
|
||||
response, model_info, SMARTCAM_CHILD_FOLDER, SMARTCAM_CHILD_SUFFIX
|
||||
)
|
||||
)
|
||||
# same model child device
|
||||
else:
|
||||
cd = final.setdefault("child_devices", {})
|
||||
cd[scrubbed] = response
|
||||
cd[scrubbed_child_id] = response
|
||||
|
||||
# Scrub the device ids in the parent for smart protocol
|
||||
if gc := final.get("get_child_device_component_list"):
|
||||
for child in gc["child_component_list"]:
|
||||
device_id = child["device_id"]
|
||||
child["device_id"] = scrubbed_device_ids[device_id]
|
||||
for child in final["get_child_device_list"]["child_device_list"]:
|
||||
device_id = child["device_id"]
|
||||
child["device_id"] = scrubbed_device_ids[device_id]
|
||||
|
||||
# Scrub the device ids in the parent for the smart camera protocol
|
||||
if gc := final.get("getChildDeviceList"):
|
||||
for child in gc["child_device_list"]:
|
||||
if device_id := child.get("device_id"):
|
||||
child["device_id"] = scrubbed_device_ids[device_id]
|
||||
continue
|
||||
if device_id := child.get("dev_id"):
|
||||
child["dev_id"] = scrubbed_device_ids[device_id]
|
||||
continue
|
||||
_LOGGER.error("Could not find a device for the child device: %s", child)
|
||||
|
||||
# Need to recreate a DiscoverResult here because we don't want the aliases
|
||||
# in the fixture, we want the actual field names as returned by the device.
|
||||
discovery_result = None
|
||||
if discovery_info:
|
||||
dr = DiscoveryResult.from_dict(discovery_info) # type: ignore
|
||||
final["discovery_result"] = dr.to_dict()
|
||||
final["discovery_result"] = redact_data(
|
||||
discovery_info, _wrap_redactors(NEW_DISCOVERY_REDACTORS)
|
||||
)
|
||||
discovery_result = discovery_info["result"]
|
||||
|
||||
click.echo(f"Got {len(successes)} successes")
|
||||
click.echo(click.style("## device info file ##", bold=True))
|
||||
|
||||
if "get_device_info" in final:
|
||||
# smart protocol
|
||||
model_info = SmartDevice._get_device_info(final, discovery_info)
|
||||
model_info = SmartDevice._get_device_info(final, discovery_result)
|
||||
copy_folder = SMART_FOLDER
|
||||
protocol_suffix = SMART_PROTOCOL_SUFFIX
|
||||
else:
|
||||
# smart camera protocol
|
||||
model_info = SmartCamDevice._get_device_info(final, discovery_info)
|
||||
model_info = SmartCamDevice._get_device_info(final, discovery_result)
|
||||
copy_folder = SMARTCAM_FOLDER
|
||||
protocol_suffix = SMARTCAM_SUFFIX
|
||||
hw_version = model_info.hardware_version
|
||||
sw_version = model_info.firmware_version
|
||||
model = model_info.long_name
|
||||
if model_info.region is not None:
|
||||
model = f"{model}({model_info.region})"
|
||||
|
||||
save_filename = f"{model}_{hw_version}_{sw_version}.json"
|
||||
save_filename = f"{model}_{hw_version}_{sw_version}"
|
||||
|
||||
fixture_results.insert(
|
||||
0, FixtureResult(filename=save_filename, folder=copy_folder, data=final)
|
||||
0,
|
||||
FixtureResult(
|
||||
filename=save_filename,
|
||||
folder=copy_folder,
|
||||
data=final,
|
||||
protocol_suffix=protocol_suffix,
|
||||
),
|
||||
)
|
||||
return fixture_results
|
||||
|
||||
|
@ -13,7 +13,7 @@ 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
|
||||
from kasa.smartcam import SmartCamChild, SmartCamDevice
|
||||
|
||||
|
||||
class SupportedVersion(NamedTuple):
|
||||
@ -36,6 +36,9 @@ DEVICE_TYPE_TO_PRODUCT_GROUP = {
|
||||
DeviceType.Bulb: "Bulbs",
|
||||
DeviceType.LightStrip: "Light Strips",
|
||||
DeviceType.Camera: "Cameras",
|
||||
DeviceType.Doorbell: "Doorbells and chimes",
|
||||
DeviceType.Chime: "Doorbells and chimes",
|
||||
DeviceType.Vacuum: "Vacuums",
|
||||
DeviceType.Hub: "Hubs",
|
||||
DeviceType.Sensor: "Hub-Connected Devices",
|
||||
DeviceType.Thermostat: "Hub-Connected Devices",
|
||||
@ -49,6 +52,7 @@ IOT_FOLDER = "tests/fixtures/iot/"
|
||||
SMART_FOLDER = "tests/fixtures/smart/"
|
||||
SMART_CHILD_FOLDER = "tests/fixtures/smart/child"
|
||||
SMARTCAM_FOLDER = "tests/fixtures/smartcam/"
|
||||
SMARTCAM_CHILD_FOLDER = "tests/fixtures/smartcam/child"
|
||||
|
||||
|
||||
def generate_supported(args):
|
||||
@ -66,6 +70,7 @@ def generate_supported(args):
|
||||
_get_supported_devices(supported, SMART_FOLDER, SmartDevice)
|
||||
_get_supported_devices(supported, SMART_CHILD_FOLDER, SmartDevice)
|
||||
_get_supported_devices(supported, SMARTCAM_FOLDER, SmartCamDevice)
|
||||
_get_supported_devices(supported, SMARTCAM_CHILD_FOLDER, SmartCamChild)
|
||||
|
||||
readme_updated = _update_supported_file(
|
||||
README_FILENAME, _supported_summary(supported), print_diffs
|
||||
@ -205,7 +210,7 @@ def _get_supported_devices(
|
||||
fixture_data = json.load(f)
|
||||
|
||||
model_info = device_cls._get_device_info(
|
||||
fixture_data, fixture_data.get("discovery_result")
|
||||
fixture_data, fixture_data.get("discovery_result", {}).get("result")
|
||||
)
|
||||
|
||||
supported_type = DEVICE_TYPE_TO_PRODUCT_GROUP[model_info.device_type]
|
||||
@ -214,7 +219,7 @@ def _get_supported_devices(
|
||||
smodel = stype.setdefault(model_info.long_name, [])
|
||||
smodel.append(
|
||||
SupportedVersion(
|
||||
region=model_info.region,
|
||||
region=model_info.region if model_info.region else "",
|
||||
hw=model_info.hardware_version,
|
||||
fw=model_info.firmware_version,
|
||||
auth=model_info.requires_auth,
|
||||
|
@ -60,4 +60,7 @@ SMARTCAM_REQUESTS: list[dict] = [
|
||||
{"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": {}}}},
|
||||
]
|
||||
|
@ -118,6 +118,16 @@ class SmartRequest:
|
||||
enable: bool
|
||||
id: str | None = None
|
||||
|
||||
@dataclass
|
||||
class GetCleanAttrParams(SmartRequestParams):
|
||||
"""CleanAttr params.
|
||||
|
||||
Decides which cleaning settings are requested
|
||||
"""
|
||||
|
||||
#: type can be global or pose
|
||||
type: str = "global"
|
||||
|
||||
@staticmethod
|
||||
def get_raw_request(
|
||||
method: str, params: SmartRequestParams | None = None
|
||||
@ -415,6 +425,7 @@ COMPONENT_REQUESTS = {
|
||||
"get_trigger_logs", SmartRequest.GetTriggerLogsParams()
|
||||
)
|
||||
],
|
||||
"temp_humidity_record": [SmartRequest.get_raw_request("get_temp_humidity_records")],
|
||||
"double_click": [SmartRequest.get_raw_request("get_double_click_info")],
|
||||
"child_device": [
|
||||
SmartRequest.get_raw_request("get_child_device_list"),
|
||||
@ -425,4 +436,37 @@ COMPONENT_REQUESTS = {
|
||||
"dimmer_calibration": [],
|
||||
"fan_control": [],
|
||||
"overheat_protection": [],
|
||||
# Vacuum components
|
||||
"clean": [
|
||||
SmartRequest.get_raw_request("getCarpetClean"),
|
||||
SmartRequest.get_raw_request("getCleanRecords"),
|
||||
SmartRequest.get_raw_request("getVacStatus"),
|
||||
SmartRequest.get_raw_request("getAreaUnit"),
|
||||
SmartRequest.get_raw_request("getCleanInfo"),
|
||||
SmartRequest.get_raw_request("getCleanStatus"),
|
||||
SmartRequest("getCleanAttr", SmartRequest.GetCleanAttrParams()),
|
||||
],
|
||||
"battery": [SmartRequest.get_raw_request("getBatteryInfo")],
|
||||
"consumables": [SmartRequest.get_raw_request("getConsumablesInfo")],
|
||||
"direction_control": [],
|
||||
"button_and_led": [SmartRequest.get_raw_request("getChildLockInfo")],
|
||||
"speaker": [
|
||||
SmartRequest.get_raw_request("getSupportVoiceLanguage"),
|
||||
SmartRequest.get_raw_request("getCurrentVoiceLanguage"),
|
||||
SmartRequest.get_raw_request("getVolume"),
|
||||
],
|
||||
"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"),
|
||||
SmartRequest.get_raw_request("getDustCollectionInfo"),
|
||||
],
|
||||
"mop": [SmartRequest.get_raw_request("getMopState")],
|
||||
"do_not_disturb": [SmartRequest.get_raw_request("getDoNotDisturb")],
|
||||
"charge_pose_clean": [],
|
||||
"continue_breakpoint_sweep": [],
|
||||
"goto_point": [],
|
||||
}
|
||||
|
@ -286,8 +286,7 @@ def main(
|
||||
operator.local_seed = message
|
||||
response = None
|
||||
print(
|
||||
f"got handshake1 in {packet_number}, "
|
||||
f"looking for the response"
|
||||
f"got handshake1 in {packet_number}, looking for the response"
|
||||
)
|
||||
while (
|
||||
True
|
||||
|
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()
|
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.
|
@ -1,6 +1,10 @@
|
||||
|
||||
# Get Energy Consumption and Usage Statistics
|
||||
|
||||
:::{note}
|
||||
The documentation on this page applies only to KASA-branded devices.
|
||||
:::
|
||||
|
||||
:::{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.
|
||||
|
@ -8,3 +8,10 @@
|
||||
.. automodule:: kasa.smart.modules.childdevice
|
||||
:noindex:
|
||||
```
|
||||
|
||||
## Pairing and unpairing
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: kasa.interfaces.childsetup
|
||||
:noindex:
|
||||
```
|
||||
|
@ -13,11 +13,13 @@
|
||||
|
||||
## Device
|
||||
|
||||
% N.B. Credentials clashes with autodoc
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: Device
|
||||
:members:
|
||||
:undoc-members:
|
||||
:exclude-members: Credentials
|
||||
```
|
||||
|
||||
|
||||
@ -28,7 +30,6 @@
|
||||
.. autoclass:: Credentials
|
||||
:members:
|
||||
:undoc-members:
|
||||
:noindex:
|
||||
```
|
||||
|
||||
|
||||
@ -61,15 +62,11 @@
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: Module
|
||||
:noindex:
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
```
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: Feature
|
||||
:noindex:
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
@ -77,7 +74,6 @@
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: kasa.interfaces
|
||||
:noindex:
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
@ -85,64 +81,29 @@
|
||||
|
||||
## Protocols and transports
|
||||
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: kasa.protocols.BaseProtocol
|
||||
.. automodule:: kasa.protocols
|
||||
:members:
|
||||
:inherited-members:
|
||||
:imported-members:
|
||||
:undoc-members:
|
||||
:exclude-members: SmartErrorCode
|
||||
:no-index:
|
||||
```
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: kasa.protocols.IotProtocol
|
||||
.. automodule:: kasa.transports
|
||||
:members:
|
||||
:inherited-members:
|
||||
:imported-members:
|
||||
:undoc-members:
|
||||
:no-index:
|
||||
```
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: kasa.protocols.SmartProtocol
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
```
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: kasa.transports.BaseTransport
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
```
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: kasa.transports.XorTransport
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
```
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: kasa.transports.KlapTransport
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
```
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: kasa.transports.KlapTransportV2
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
```
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: kasa.transports.AesTransport
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
```
|
||||
|
||||
## Errors and exceptions
|
||||
|
||||
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: kasa.exceptions.KasaException
|
||||
:members:
|
||||
@ -171,3 +132,4 @@
|
||||
.. autoclass:: kasa.exceptions.TimeoutError
|
||||
:members:
|
||||
:undoc-members:
|
||||
```
|
||||
|
@ -80,14 +80,17 @@ This can be done using the {attr}`~kasa.Device.internal_state` property.
|
||||
## Modules and Features
|
||||
|
||||
The functionality provided by all {class}`~kasa.Device` 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.Device.modules`.
|
||||
You can get the list of supported modules for a given device instance using {attr}`~kasa.Device.supported_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.
|
||||
|
||||
```{note}
|
||||
If you only need some module-specific information,
|
||||
you can call the wanted method on the module to avoid using {meth}`~kasa.Device.update`.
|
||||
```
|
||||
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
|
||||
@ -137,96 +140,3 @@ The base exception for all library errors is {class}`KasaException <kasa.excepti
|
||||
- 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.
|
||||
|
||||
<!-- Commenting out this section keeps git seeing the change as a rename.
|
||||
|
||||
API documentation for modules and features
|
||||
******************************************
|
||||
|
||||
.. autoclass:: kasa.Module
|
||||
:noindex:
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
|
||||
.. automodule:: kasa.interfaces
|
||||
:noindex:
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
|
||||
.. autoclass:: kasa.Feature
|
||||
:noindex:
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
|
||||
|
||||
|
||||
API documentation for protocols and transports
|
||||
**********************************************
|
||||
|
||||
.. autoclass:: kasa.protocols.BaseProtocol
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
|
||||
.. autoclass:: kasa.protocols.IotProtocol
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
|
||||
.. autoclass:: kasa.protocols.SmartProtocol
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
|
||||
.. autoclass:: kasa.transports.BaseTransport
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
|
||||
.. autoclass:: kasa.transports.XorTransport
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
|
||||
.. autoclass:: kasa.transports.KlapTransport
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
|
||||
.. autoclass:: kasa.transports.KlapTransportV2
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
|
||||
.. autoclass:: kasa.transports.AesTransport
|
||||
:members:
|
||||
:inherited-members:
|
||||
:undoc-members:
|
||||
|
||||
API documentation for errors and exceptions
|
||||
*******************************************
|
||||
|
||||
.. autoclass:: kasa.exceptions.KasaException
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
.. autoclass:: kasa.exceptions.DeviceError
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
.. autoclass:: kasa.exceptions.AuthenticationError
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
.. autoclass:: kasa.exceptions.UnsupportedDeviceError
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
.. autoclass:: kasa.exceptions.TimeoutError
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
-->
|
||||
|
@ -13,6 +13,7 @@
|
||||
127.0.0.3
|
||||
127.0.0.4
|
||||
127.0.0.5
|
||||
127.0.0.6
|
||||
|
||||
:meth:`~kasa.Discover.discover_single` returns a single device by hostname:
|
||||
|
||||
@ -40,7 +41,7 @@ Different groups of functionality are supported by modules which you can access
|
||||
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 ``is_``-prefixed properties like `is_color`.
|
||||
You can check the availability using ``has_feature()`` method.
|
||||
|
||||
>>> from kasa import Module
|
||||
>>> Module.Light in dev.modules
|
||||
@ -52,9 +53,9 @@ True
|
||||
>>> await dev.update()
|
||||
>>> light.brightness
|
||||
50
|
||||
>>> light.is_color
|
||||
>>> light.has_feature("hsv")
|
||||
True
|
||||
>>> if light.is_color:
|
||||
>>> if light.has_feature("hsv"):
|
||||
>>> print(light.hsv)
|
||||
HSV(hue=0, saturation=100, value=50)
|
||||
|
||||
@ -91,5 +92,5 @@ False
|
||||
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#\nOverheated: False\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\nDevice time: 2024-02-23 02:40:15+01:00
|
||||
Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nReboot: <Action>\nDevice time: 2024-02-23 02:40:15+01:00\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
|
||||
"""
|
||||
|
@ -38,8 +38,9 @@ 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, SmartProtocol
|
||||
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")
|
||||
@ -51,6 +52,7 @@ __all__ = [
|
||||
"BaseTransport",
|
||||
"IotProtocol",
|
||||
"SmartProtocol",
|
||||
"SmartCamProtocol",
|
||||
"LightState",
|
||||
"TurnOnBehaviors",
|
||||
"TurnOnBehavior",
|
||||
@ -75,6 +77,7 @@ __all__ = [
|
||||
"DeviceFamily",
|
||||
"ThermostatState",
|
||||
"Thermostat",
|
||||
"StreamResolution",
|
||||
]
|
||||
|
||||
from . import iot
|
||||
|
@ -2,13 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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
|
||||
from gettext import gettext
|
||||
from typing import TYPE_CHECKING, Any, Final, NoReturn
|
||||
|
||||
import asyncclick as click
|
||||
|
||||
@ -55,7 +57,7 @@ def echo(*args, **kwargs) -> None:
|
||||
_echo(*args, **kwargs)
|
||||
|
||||
|
||||
def error(msg: str) -> None:
|
||||
def error(msg: str) -> NoReturn:
|
||||
"""Print an error and exit."""
|
||||
echo(f"[bold red]{msg}[/bold red]")
|
||||
sys.exit(1)
|
||||
@ -66,6 +68,16 @@ def json_formatter_cb(result: Any, **kwargs) -> None:
|
||||
if not kwargs.get("json"):
|
||||
return
|
||||
|
||||
# Calling the discover command directly always returns a DeviceDict so if host
|
||||
# was specified just format the device json
|
||||
if (
|
||||
(host := kwargs.get("host"))
|
||||
and isinstance(result, dict)
|
||||
and (dev := result.get(host))
|
||||
and isinstance(dev, Device)
|
||||
):
|
||||
result = dev
|
||||
|
||||
@singledispatch
|
||||
def to_serializable(val):
|
||||
"""Regular obj-to-string for json serialization.
|
||||
@ -83,6 +95,25 @@ def json_formatter_cb(result: Any, **kwargs) -> None:
|
||||
print(json_content)
|
||||
|
||||
|
||||
async def invoke_subcommand(
|
||||
command: click.BaseCommand,
|
||||
ctx: click.Context,
|
||||
args: list[str] | None = None,
|
||||
**extra: Any,
|
||||
) -> Any:
|
||||
"""Invoke a click subcommand.
|
||||
|
||||
Calling ctx.Invoke() treats the command like a simple callback and doesn't
|
||||
process any result_callbacks so we use this pattern from the click docs
|
||||
https://click.palletsprojects.com/en/stable/exceptions/#what-if-i-don-t-want-that.
|
||||
"""
|
||||
if args is None:
|
||||
args = []
|
||||
sub_ctx = await command.make_context(command.name, args, parent=ctx, **extra)
|
||||
async with sub_ctx:
|
||||
return await command.invoke(sub_ctx)
|
||||
|
||||
|
||||
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 = (
|
||||
@ -238,4 +269,19 @@ def CatchAllExceptions(cls):
|
||||
except Exception as exc:
|
||||
_handle_exception(self._debug, exc)
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
"""Run the coroutine in the event loop and print any exceptions.
|
||||
|
||||
python click catches KeyboardInterrupt in main, raises Abort()
|
||||
and does sys.exit. asyncclick doesn't properly handle a coroutine
|
||||
receiving CancelledError on a KeyboardInterrupt, so we catch the
|
||||
KeyboardInterrupt here once asyncio.run has re-raised it. This
|
||||
avoids large stacktraces when a user presses Ctrl-C.
|
||||
"""
|
||||
try:
|
||||
asyncio.run(self.main(*args, **kwargs))
|
||||
except KeyboardInterrupt:
|
||||
click.echo(gettext("\nAborted!"), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return _CommandCls
|
||||
|
@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pprint import pformat as pf
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import asyncclick as click
|
||||
|
||||
@ -41,8 +42,14 @@ async def state(ctx, dev: Device):
|
||||
echo(f"Device state: {dev.is_on}")
|
||||
|
||||
echo(f"Time: {dev.time} (tz: {dev.timezone})")
|
||||
echo(f"Hardware: {dev.hw_info['hw_ver']}")
|
||||
echo(f"Software: {dev.hw_info['sw_ver']}")
|
||||
echo(
|
||||
f"Hardware: {dev.device_info.hardware_version}"
|
||||
f"{' (' + dev.region + ')' if dev.region else ''}"
|
||||
)
|
||||
echo(
|
||||
f"Firmware: {dev.device_info.firmware_version}"
|
||||
f"{' ' + build if (build := dev.device_info.firmware_build) else ''}"
|
||||
)
|
||||
echo(f"MAC (rssi): {dev.mac} ({dev.rssi})")
|
||||
if verbose:
|
||||
echo(f"Location: {dev.location}")
|
||||
@ -76,6 +83,8 @@ async def state(ctx, dev: Device):
|
||||
echo()
|
||||
from .discover import _echo_discovery_info
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert dev._discovery_info
|
||||
_echo_discovery_info(dev._discovery_info)
|
||||
|
||||
return dev.internal_state
|
||||
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pprint import pformat as pf
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
import asyncclick as click
|
||||
|
||||
@ -14,22 +15,53 @@ from kasa import (
|
||||
Discover,
|
||||
UnsupportedDeviceError,
|
||||
)
|
||||
from kasa.discover import ConnectAttempt, DiscoveryResult
|
||||
from kasa.discover import (
|
||||
NEW_DISCOVERY_REDACTORS,
|
||||
ConnectAttempt,
|
||||
DeviceDict,
|
||||
DiscoveredRaw,
|
||||
DiscoveryResult,
|
||||
OnDiscoveredCallable,
|
||||
OnDiscoveredRawCallable,
|
||||
OnUnsupportedCallable,
|
||||
)
|
||||
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):
|
||||
async def discover(ctx: click.Context):
|
||||
"""Discover devices in the network."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
return await ctx.invoke(detail)
|
||||
|
||||
|
||||
@discover.result_callback()
|
||||
@click.pass_context
|
||||
async def _close_protocols(ctx: click.Context, discovered: DeviceDict):
|
||||
"""Close all the device protocols if discover was invoked directly by the user."""
|
||||
if _discover_is_root_cmd(ctx):
|
||||
for dev in discovered.values():
|
||||
await dev.disconnect()
|
||||
return discovered
|
||||
|
||||
|
||||
def _discover_is_root_cmd(ctx: click.Context) -> bool:
|
||||
"""Will return true if discover was invoked directly by the user."""
|
||||
root_ctx = ctx.find_root()
|
||||
return (
|
||||
root_ctx.invoked_subcommand is None or root_ctx.invoked_subcommand == "discover"
|
||||
)
|
||||
|
||||
|
||||
@discover.command()
|
||||
@click.pass_context
|
||||
async def detail(ctx):
|
||||
async def detail(ctx: click.Context) -> DeviceDict:
|
||||
"""Discover devices in the network using udp broadcasts."""
|
||||
unsupported = []
|
||||
auth_failed = []
|
||||
@ -50,10 +82,14 @@ async def detail(ctx):
|
||||
from .device import state
|
||||
|
||||
async def print_discovered(dev: Device) -> None:
|
||||
if TYPE_CHECKING:
|
||||
assert ctx.parent
|
||||
async with sem:
|
||||
try:
|
||||
await dev.update()
|
||||
except AuthenticationError:
|
||||
if TYPE_CHECKING:
|
||||
assert dev._discovery_info
|
||||
auth_failed.append(dev._discovery_info)
|
||||
echo("== Authentication failed for device ==")
|
||||
_echo_discovery_info(dev._discovery_info)
|
||||
@ -63,8 +99,12 @@ async def detail(ctx):
|
||||
await ctx.parent.invoke(state)
|
||||
echo()
|
||||
|
||||
discovered = await _discover(ctx, print_discovered, print_unsupported)
|
||||
if ctx.parent.parent.params["host"]:
|
||||
discovered = await _discover(
|
||||
ctx,
|
||||
print_discovered=print_discovered if _discover_is_root_cmd(ctx) else None,
|
||||
print_unsupported=print_unsupported,
|
||||
)
|
||||
if ctx.find_root().params["host"]:
|
||||
return discovered
|
||||
|
||||
echo(f"Found {len(discovered)} devices")
|
||||
@ -77,22 +117,54 @@ async def detail(ctx):
|
||||
|
||||
|
||||
@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 list(ctx):
|
||||
async def raw(ctx: click.Context, redact: bool) -> DeviceDict:
|
||||
"""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: click.Context) -> DeviceDict:
|
||||
"""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} {cparams.device_family.value:<20} "
|
||||
f"{cparams.encryption_type.value:<7}"
|
||||
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}")
|
||||
|
||||
@ -100,12 +172,28 @@ async def list(ctx):
|
||||
if host := unsupported_exception.host:
|
||||
echo(f"{host:<15} UNSUPPORTED DEVICE")
|
||||
|
||||
echo(f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}")
|
||||
return await _discover(ctx, print_discovered, print_unsupported, do_echo=False)
|
||||
echo(
|
||||
f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} "
|
||||
f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}"
|
||||
)
|
||||
discovered = await _discover(
|
||||
ctx,
|
||||
print_discovered=print_discovered,
|
||||
print_unsupported=print_unsupported,
|
||||
do_echo=False,
|
||||
)
|
||||
return discovered
|
||||
|
||||
|
||||
async def _discover(ctx, print_discovered, print_unsupported, *, do_echo=True):
|
||||
params = ctx.parent.parent.params
|
||||
async def _discover(
|
||||
ctx: click.Context,
|
||||
*,
|
||||
print_discovered: OnDiscoveredCallable | None = None,
|
||||
print_unsupported: OnUnsupportedCallable | None = None,
|
||||
print_raw: OnDiscoveredRawCallable | None = None,
|
||||
do_echo=True,
|
||||
) -> DeviceDict:
|
||||
params = ctx.find_root().params
|
||||
target = params["target"]
|
||||
username = params["username"]
|
||||
password = params["password"]
|
||||
@ -117,15 +205,23 @@ async def _discover(ctx, print_discovered, print_unsupported, *, do_echo=True):
|
||||
credentials = Credentials(username, password) if username and password else None
|
||||
|
||||
if host:
|
||||
host = cast(str, host)
|
||||
echo(f"Discovering device {host} for {discovery_timeout} seconds")
|
||||
return await Discover.discover_single(
|
||||
dev = 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 dev:
|
||||
if print_discovered:
|
||||
await print_discovered(dev)
|
||||
return {host: dev}
|
||||
else:
|
||||
return {}
|
||||
if do_echo:
|
||||
echo(f"Discovering devices on {target} for {discovery_timeout} seconds")
|
||||
discovered_devices = await Discover.discover(
|
||||
@ -136,23 +232,21 @@ async def _discover(ctx, print_discovered, print_unsupported, *, do_echo=True):
|
||||
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):
|
||||
async def config(ctx: click.Context) -> DeviceDict:
|
||||
"""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
|
||||
params = ctx.find_root().params
|
||||
username = params["username"]
|
||||
password = params["password"]
|
||||
timeout = params["timeout"]
|
||||
@ -167,8 +261,11 @@ async def config(ctx):
|
||||
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__}"
|
||||
prot, tran, dev, https = connect_attempt
|
||||
key_str = (
|
||||
f"{prot.__name__} + {tran.__name__} + {dev.__name__}"
|
||||
f" + {'https' if https else 'http'}"
|
||||
)
|
||||
result = "succeeded" if success else "failed"
|
||||
msg = f"Attempt to connect to {host_port} with {key_str} {result}"
|
||||
echo(msg)
|
||||
@ -184,6 +281,7 @@ async def config(ctx):
|
||||
f"--encrypt-type {cparams.encryption_type.value} "
|
||||
f"{'--https' if cparams.https else '--no-https'}"
|
||||
)
|
||||
return {host: dev}
|
||||
else:
|
||||
error(f"Unable to connect to {host}")
|
||||
|
||||
@ -196,13 +294,13 @@ def _echo_dictionary(discovery_info: dict) -> None:
|
||||
echo(f"\t{key_name_and_spaces}{value}")
|
||||
|
||||
|
||||
def _echo_discovery_info(discovery_info) -> None:
|
||||
def _echo_discovery_info(discovery_info: dict) -> None:
|
||||
# We don't have discovery info when all connection params are passed manually
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
if "system" in discovery_info and "get_sysinfo" in discovery_info["system"]:
|
||||
_echo_dictionary(discovery_info["system"]["get_sysinfo"])
|
||||
if sysinfo := _extract_sys_info(discovery_info):
|
||||
_echo_dictionary(sysinfo)
|
||||
return
|
||||
|
||||
try:
|
||||
@ -228,12 +326,14 @@ def _echo_discovery_info(discovery_info) -> None:
|
||||
_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("OBD Src", dr.obd_src)
|
||||
_conditional_echo("Factory Default", dr.factory_default)
|
||||
_conditional_echo("Encrypt Type", dr.mgt_encrypt_schm.encrypt_type)
|
||||
_conditional_echo("Encrypt Type", dr.encrypt_type)
|
||||
_conditional_echo("Supports HTTPS", dr.mgt_encrypt_schm.is_support_https)
|
||||
_conditional_echo("HTTP Port", dr.mgt_encrypt_schm.http_port)
|
||||
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)
|
||||
|
||||
|
@ -6,10 +6,7 @@ import ast
|
||||
|
||||
import asyncclick as click
|
||||
|
||||
from kasa import (
|
||||
Device,
|
||||
Feature,
|
||||
)
|
||||
from kasa import Device, Feature
|
||||
|
||||
from .common import (
|
||||
echo,
|
||||
@ -133,7 +130,22 @@ async def feature(
|
||||
echo(f"{feat.name} ({name}): {feat.value}{unit}")
|
||||
return feat.value
|
||||
|
||||
value = ast.literal_eval(value)
|
||||
try:
|
||||
# Attempt to parse as python literal.
|
||||
value = ast.literal_eval(value)
|
||||
except ValueError:
|
||||
# The value is probably an unquoted string, so we'll raise an error,
|
||||
# and tell the user to quote the string.
|
||||
raise click.exceptions.BadParameter(
|
||||
f'{repr(value)} for {name} (Perhaps you forgot to "quote" the value?)'
|
||||
) from SyntaxError
|
||||
except SyntaxError:
|
||||
# There are likely miss-matched quotes or odd characters in the input,
|
||||
# so abort and complain to the user.
|
||||
raise click.exceptions.BadParameter(
|
||||
f"{repr(value)} for {name}"
|
||||
) from SyntaxError
|
||||
|
||||
echo(f"Changing {name} from {feat.value} to {value}")
|
||||
response = await dev.features[name].set_value(value)
|
||||
await dev.update()
|
||||
|
95
kasa/cli/hub.py
Normal file
95
kasa/cli/hub.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""Hub-specific commands."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import asyncclick as click
|
||||
|
||||
from kasa import DeviceType, Module, SmartDevice
|
||||
from kasa.smart import SmartChildDevice
|
||||
|
||||
from .common import (
|
||||
echo,
|
||||
error,
|
||||
pass_dev,
|
||||
)
|
||||
|
||||
|
||||
def pretty_category(cat: str):
|
||||
"""Return pretty category for paired devices."""
|
||||
return SmartChildDevice.CHILD_DEVICE_TYPE_MAP.get(cat)
|
||||
|
||||
|
||||
@click.group()
|
||||
@pass_dev
|
||||
async def hub(dev: SmartDevice):
|
||||
"""Commands controlling hub child device pairing."""
|
||||
if dev.device_type is not DeviceType.Hub:
|
||||
error(f"{dev} is not a hub.")
|
||||
|
||||
if dev.modules.get(Module.ChildSetup) is None:
|
||||
error(f"{dev} does not have child setup module.")
|
||||
|
||||
|
||||
@hub.command(name="list")
|
||||
@pass_dev
|
||||
async def hub_list(dev: SmartDevice):
|
||||
"""List hub paired child devices."""
|
||||
for c in dev.children:
|
||||
echo(f"{c.device_id}: {c}")
|
||||
|
||||
|
||||
@hub.command(name="supported")
|
||||
@pass_dev
|
||||
async def hub_supported(dev: SmartDevice):
|
||||
"""List supported hub child device categories."""
|
||||
cs = dev.modules[Module.ChildSetup]
|
||||
|
||||
for cat in cs.supported_categories:
|
||||
echo(f"Supports: {cat}")
|
||||
|
||||
|
||||
@hub.command(name="pair")
|
||||
@click.option("--timeout", default=10)
|
||||
@pass_dev
|
||||
async def hub_pair(dev: SmartDevice, timeout: int):
|
||||
"""Pair all pairable device.
|
||||
|
||||
This will pair any child devices currently in pairing mode.
|
||||
"""
|
||||
cs = dev.modules[Module.ChildSetup]
|
||||
|
||||
echo(f"Finding new devices for {timeout} seconds...")
|
||||
|
||||
pair_res = await cs.pair(timeout=timeout)
|
||||
if not pair_res:
|
||||
echo("No devices found.")
|
||||
|
||||
for child in pair_res:
|
||||
echo(
|
||||
f"Paired {child['name']} ({child['device_model']}, "
|
||||
f"{pretty_category(child['category'])}) with id {child['device_id']}"
|
||||
)
|
||||
|
||||
|
||||
@hub.command(name="unpair")
|
||||
@click.argument("device_id")
|
||||
@pass_dev
|
||||
async def hub_unpair(dev, device_id: str):
|
||||
"""Unpair given device."""
|
||||
cs = dev.modules[Module.ChildSetup]
|
||||
|
||||
# Accessing private here, as the property exposes only values
|
||||
if device_id not in dev._children:
|
||||
error(f"{dev} does not have children with identifier {device_id}")
|
||||
|
||||
res = await cs.unpair(device_id=device_id)
|
||||
# Give the device some time to update its internal state, just in case.
|
||||
await asyncio.sleep(1)
|
||||
await dev.update()
|
||||
|
||||
if device_id not in dev._children:
|
||||
echo(f"Unpaired {device_id}")
|
||||
else:
|
||||
error(f"Failed to unpair {device_id}")
|
||||
|
||||
return res
|
@ -66,7 +66,6 @@ class LazyGroup(click.Group):
|
||||
# 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"
|
||||
f"Lazy loading of {cmd_name} failed by returning a non-command object"
|
||||
)
|
||||
return cmd_object
|
||||
|
@ -25,7 +25,9 @@ def light(dev) -> None:
|
||||
@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.is_dimmable:
|
||||
if not (light := dev.modules.get(Module.Light)) or not light.has_feature(
|
||||
"brightness"
|
||||
):
|
||||
error("This device does not support brightness.")
|
||||
return
|
||||
|
||||
@ -45,13 +47,15 @@ async def brightness(dev: Device, brightness: int, transition: int):
|
||||
@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 light.is_variable_color_temp:
|
||||
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 = light.valid_temperature_range
|
||||
valid_temperature_range = color_temp_feat.range
|
||||
if valid_temperature_range != (0, 0):
|
||||
echo("(min: {}, max: {})".format(*valid_temperature_range))
|
||||
else:
|
||||
@ -59,7 +63,7 @@ async def temperature(dev: Device, temperature: int, transition: int):
|
||||
"Temperature range unknown, please open a github issue"
|
||||
f" or a pull request for model '{dev.model}'"
|
||||
)
|
||||
return light.valid_temperature_range
|
||||
return color_temp_feat.range
|
||||
else:
|
||||
echo(f"Setting color temperature to {temperature}")
|
||||
return await light.set_color_temp(temperature, transition=transition)
|
||||
@ -99,7 +103,7 @@ async def effect(dev: Device, ctx, effect):
|
||||
@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.is_color:
|
||||
if not (light := dev.modules.get(Module.Light)) or not light.has_feature("hsv"):
|
||||
error("Device does not support colors")
|
||||
return
|
||||
|
||||
|
@ -22,6 +22,7 @@ from .common import (
|
||||
CatchAllExceptions,
|
||||
echo,
|
||||
error,
|
||||
invoke_subcommand,
|
||||
json_formatter_cb,
|
||||
pass_dev_or_child,
|
||||
)
|
||||
@ -92,6 +93,8 @@ def _legacy_type_to_class(_type: str) -> Any:
|
||||
"hsv": "light",
|
||||
"temperature": "light",
|
||||
"effect": "light",
|
||||
"vacuum": "vacuum",
|
||||
"hub": "hub",
|
||||
},
|
||||
result_callback=json_formatter_cb,
|
||||
)
|
||||
@ -295,9 +298,10 @@ async def cli(
|
||||
echo("No host name given, trying discovery..")
|
||||
from .discover import discover
|
||||
|
||||
return await ctx.invoke(discover)
|
||||
return await invoke_subcommand(discover, ctx)
|
||||
|
||||
device_updated = False
|
||||
device_discovered = False
|
||||
|
||||
if type is not None and type not in {"smart", "camera"}:
|
||||
from kasa.deviceconfig import DeviceConfig
|
||||
@ -308,6 +312,7 @@ async def cli(
|
||||
if type == "camera":
|
||||
encrypt_type = "AES"
|
||||
https = True
|
||||
login_version = 2
|
||||
device_family = "SMART.IPCAMERA"
|
||||
|
||||
from kasa.device import Device
|
||||
@ -350,12 +355,14 @@ async def cli(
|
||||
return
|
||||
echo(f"Found hostname by alias: {dev.host}")
|
||||
device_updated = True
|
||||
else:
|
||||
else: # host will be set
|
||||
from .discover import discover
|
||||
|
||||
dev = await ctx.invoke(discover)
|
||||
if not dev:
|
||||
discovered = await invoke_subcommand(discover, ctx)
|
||||
if not discovered:
|
||||
error(f"Unable to create device for {host}")
|
||||
dev = discovered[host]
|
||||
device_discovered = True
|
||||
|
||||
# Skip update on specific commands, or if device factory,
|
||||
# that performs an update was used for the device.
|
||||
@ -371,11 +378,14 @@ async def cli(
|
||||
|
||||
ctx.obj = await ctx.with_async_resource(async_wrapped_device(dev))
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
# discover command has already invoked state
|
||||
if ctx.invoked_subcommand is None and not device_discovered:
|
||||
from .device import state
|
||||
|
||||
return await ctx.invoke(state)
|
||||
|
||||
return dev
|
||||
|
||||
|
||||
@cli.command()
|
||||
@pass_dev_or_child
|
||||
|
84
kasa/cli/vacuum.py
Normal file
84
kasa/cli/vacuum.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""Module for cli vacuum commands.."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncclick as click
|
||||
|
||||
from kasa import (
|
||||
Device,
|
||||
Module,
|
||||
)
|
||||
|
||||
from .common import (
|
||||
error,
|
||||
pass_dev_or_child,
|
||||
)
|
||||
|
||||
|
||||
@click.group(invoke_without_command=False)
|
||||
@click.pass_context
|
||||
async def vacuum(ctx: click.Context) -> None:
|
||||
"""Vacuum commands."""
|
||||
|
||||
|
||||
@vacuum.group(invoke_without_command=True, name="records")
|
||||
@pass_dev_or_child
|
||||
async def records_group(dev: Device) -> None:
|
||||
"""Access cleaning records."""
|
||||
if not (rec := dev.modules.get(Module.CleanRecords)):
|
||||
error("This device does not support records.")
|
||||
|
||||
data = rec.parsed_data
|
||||
latest = data.last_clean
|
||||
click.echo(
|
||||
f"Totals: {rec.total_clean_area} {rec.area_unit} in {rec.total_clean_time} "
|
||||
f"(cleaned {rec.total_clean_count} times)"
|
||||
)
|
||||
click.echo(f"Last clean: {latest.clean_area} {rec.area_unit} @ {latest.clean_time}")
|
||||
click.echo("Execute `kasa vacuum records list` to list all records.")
|
||||
|
||||
|
||||
@records_group.command(name="list")
|
||||
@pass_dev_or_child
|
||||
async def records_list(dev: Device) -> None:
|
||||
"""List all cleaning records."""
|
||||
if not (rec := dev.modules.get(Module.CleanRecords)):
|
||||
error("This device does not support records.")
|
||||
|
||||
data = rec.parsed_data
|
||||
for record in data.records:
|
||||
click.echo(
|
||||
f"* {record.timestamp}: cleaned {record.clean_area} {rec.area_unit}"
|
||||
f" in {record.clean_time}"
|
||||
)
|
||||
|
||||
|
||||
@vacuum.group(invoke_without_command=True, name="consumables")
|
||||
@pass_dev_or_child
|
||||
@click.pass_context
|
||||
async def consumables(ctx: click.Context, dev: Device) -> None:
|
||||
"""List device consumables."""
|
||||
if not (cons := dev.modules.get(Module.Consumables)):
|
||||
error("This device does not support consumables.")
|
||||
|
||||
if not ctx.invoked_subcommand:
|
||||
for c in cons.consumables.values():
|
||||
click.echo(f"{c.name} ({c.id}): {c.used} used, {c.remaining} remaining")
|
||||
|
||||
|
||||
@consumables.command(name="reset")
|
||||
@click.argument("consumable_id", required=True)
|
||||
@pass_dev_or_child
|
||||
async def reset_consumable(dev: Device, consumable_id: str) -> None:
|
||||
"""Reset the consumable used/remaining time."""
|
||||
cons = dev.modules[Module.Consumables]
|
||||
|
||||
if consumable_id not in cons.consumables:
|
||||
error(
|
||||
f"Consumable {consumable_id} not found in "
|
||||
f"device consumables: {', '.join(cons.consumables.keys())}."
|
||||
)
|
||||
|
||||
await cons.reset_consumable(consumable_id)
|
||||
|
||||
click.echo(f"Consumable {consumable_id} reset")
|
@ -25,6 +25,7 @@ def get_default_credentials(tuple: tuple[str, str]) -> Credentials:
|
||||
|
||||
DEFAULT_CREDENTIALS = {
|
||||
"KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"),
|
||||
"KASACAMERA": ("YWRtaW4=", "MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM="),
|
||||
"TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="),
|
||||
"TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="),
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ All devices provide several informational properties:
|
||||
>>> dev.alias
|
||||
Bedroom Lamp Plug
|
||||
>>> dev.model
|
||||
HS110(EU)
|
||||
HS110
|
||||
>>> dev.rssi
|
||||
-71
|
||||
>>> dev.mac
|
||||
@ -107,7 +107,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, tzinfo
|
||||
from typing import TYPE_CHECKING, Any, TypeAlias
|
||||
@ -151,7 +151,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DeviceInfo:
|
||||
class DeviceInfo:
|
||||
"""Device Model Information."""
|
||||
|
||||
short_name: str
|
||||
@ -161,7 +161,7 @@ class _DeviceInfo:
|
||||
device_type: DeviceType
|
||||
hardware_version: str
|
||||
firmware_version: str
|
||||
firmware_build: str
|
||||
firmware_build: str | None
|
||||
requires_auth: bool
|
||||
region: str | None
|
||||
|
||||
@ -208,7 +208,7 @@ class Device(ABC):
|
||||
self.protocol: BaseProtocol = protocol or IotProtocol(
|
||||
transport=XorTransport(config=config or DeviceConfig(host=host)),
|
||||
)
|
||||
self._last_update: Any = None
|
||||
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
|
||||
@ -334,9 +334,21 @@ class Device(ABC):
|
||||
"""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 _model_region(self) -> str:
|
||||
"""Return device full model name and region."""
|
||||
def _get_device_info(
|
||||
info: dict[str, Any], discovery_info: dict[str, Any] | None
|
||||
) -> DeviceInfo:
|
||||
"""Get device info."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
@ -525,19 +537,52 @@ class Device(ABC):
|
||||
|
||||
return None
|
||||
|
||||
def _get_deprecated_callable_attribute(self, name: str) -> Any | None:
|
||||
vals: dict[str, tuple[ModuleName, Callable[[Any], Any], str]] = {
|
||||
"is_dimmable": (
|
||||
Module.Light,
|
||||
lambda c: c.has_feature("brightness"),
|
||||
'light_module.has_feature("brightness")',
|
||||
),
|
||||
"is_color": (
|
||||
Module.Light,
|
||||
lambda c: c.has_feature("hsv"),
|
||||
'light_module.has_feature("hsv")',
|
||||
),
|
||||
"is_variable_color_temp": (
|
||||
Module.Light,
|
||||
lambda c: c.has_feature("color_temp"),
|
||||
'light_module.has_feature("color_temp")',
|
||||
),
|
||||
"valid_temperature_range": (
|
||||
Module.Light,
|
||||
lambda c: c._deprecated_valid_temperature_range(),
|
||||
'minimum and maximum value of get_feature("color_temp")',
|
||||
),
|
||||
"has_effects": (
|
||||
Module.Light,
|
||||
lambda c: Module.LightEffect in c._device.modules,
|
||||
"Module.LightEffect in device.modules",
|
||||
),
|
||||
}
|
||||
if mod_call_msg := vals.get(name):
|
||||
mod, call, msg = mod_call_msg
|
||||
msg = f"{name} is deprecated, use: {msg} instead"
|
||||
warn(msg, DeprecationWarning, stacklevel=2)
|
||||
if (module := self.modules.get(mod)) is None:
|
||||
raise AttributeError(f"Device has no attribute {name!r}")
|
||||
return call(module)
|
||||
|
||||
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"]),
|
||||
@ -576,6 +621,9 @@ class Device(ABC):
|
||||
msg = f"{name} is deprecated, use device_type property instead"
|
||||
warn(msg, DeprecationWarning, stacklevel=2)
|
||||
return self.device_type == dep_device_type_attr[1]
|
||||
# callable
|
||||
if (result := self._get_deprecated_callable_attribute(name)) is not None:
|
||||
return result
|
||||
# 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]))
|
||||
|
57
kasa/device_factory.py
Executable file → Normal file
57
kasa/device_factory.py
Executable file → Normal file
@ -8,7 +8,7 @@ from typing import Any
|
||||
|
||||
from .device import Device
|
||||
from .device_type import DeviceType
|
||||
from .deviceconfig import DeviceConfig
|
||||
from .deviceconfig import DeviceConfig, DeviceEncryptionType, DeviceFamily
|
||||
from .exceptions import KasaException, UnsupportedDeviceError
|
||||
from .iot import (
|
||||
IotBulb,
|
||||
@ -32,6 +32,8 @@ from .transports import (
|
||||
BaseTransport,
|
||||
KlapTransport,
|
||||
KlapTransportV2,
|
||||
LinkieTransportV2,
|
||||
SslTransport,
|
||||
XorTransport,
|
||||
)
|
||||
from .transports.sslaestransport import SslAesTransport
|
||||
@ -137,6 +139,8 @@ def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]:
|
||||
DeviceType.Strip: IotStrip,
|
||||
DeviceType.WallSwitch: IotWallSwitch,
|
||||
DeviceType.LightStrip: IotLightStrip,
|
||||
# Disabled until properly implemented
|
||||
# DeviceType.Camera: IotCamera,
|
||||
}
|
||||
return TYPE_TO_CLASS[IotDevice._get_device_type_from_sys_info(sysinfo)]
|
||||
|
||||
@ -155,8 +159,12 @@ def get_device_class_from_family(
|
||||
"SMART.KASAHUB": SmartDevice,
|
||||
"SMART.KASASWITCH": SmartDevice,
|
||||
"SMART.IPCAMERA.HTTPS": SmartCamDevice,
|
||||
"SMART.TAPODOORBELL.HTTPS": SmartCamDevice,
|
||||
"SMART.TAPOROBOVAC.HTTPS": SmartDevice,
|
||||
"IOT.SMARTPLUGSWITCH": IotPlug,
|
||||
"IOT.SMARTBULB": IotBulb,
|
||||
# Disabled until properly implemented
|
||||
# "IOT.IPCAMERA": IotCamera,
|
||||
}
|
||||
lookup_key = f"{device_type}{'.HTTPS' if https else ''}"
|
||||
if (
|
||||
@ -167,21 +175,55 @@ def get_device_class_from_family(
|
||||
_LOGGER.debug("Unknown SMART device with %s, using SmartDevice", device_type)
|
||||
cls = SmartDevice
|
||||
|
||||
if cls is not None:
|
||||
_LOGGER.debug("Using %s for %s", cls.__name__, device_type)
|
||||
|
||||
return cls
|
||||
|
||||
|
||||
def get_protocol(
|
||||
config: DeviceConfig,
|
||||
) -> BaseProtocol | None:
|
||||
"""Return the protocol from the connection name."""
|
||||
protocol_name = config.connection_type.device_family.value.split(".")[0]
|
||||
def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol | None:
|
||||
"""Return the protocol from the device config.
|
||||
|
||||
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.
|
||||
|
||||
:param config: Device config to derive protocol
|
||||
:param strict: Require exact match on encrypt type
|
||||
"""
|
||||
_LOGGER.debug("Finding protocol for %s", config.host)
|
||||
ctype = config.connection_type
|
||||
protocol_name = ctype.device_family.value.split(".")[0]
|
||||
_LOGGER.debug("Finding protocol for %s", ctype.device_family)
|
||||
|
||||
if ctype.device_family in {
|
||||
DeviceFamily.SmartIpCamera,
|
||||
DeviceFamily.SmartTapoDoorbell,
|
||||
}:
|
||||
if strict and ctype.encryption_type is not DeviceEncryptionType.Aes:
|
||||
return None
|
||||
return SmartCamProtocol(transport=SslAesTransport(config=config))
|
||||
|
||||
if ctype.device_family is DeviceFamily.IotIpCamera:
|
||||
if strict and ctype.encryption_type is not DeviceEncryptionType.Xor:
|
||||
return None
|
||||
return IotProtocol(transport=LinkieTransportV2(config=config))
|
||||
|
||||
# Older FW used a different transport
|
||||
if (
|
||||
ctype.device_family is DeviceFamily.SmartTapoRobovac
|
||||
and ctype.encryption_type is DeviceEncryptionType.Aes
|
||||
):
|
||||
return SmartProtocol(transport=SslTransport(config=config))
|
||||
|
||||
protocol_transport_key = (
|
||||
protocol_name
|
||||
+ "."
|
||||
+ ctype.encryption_type.value
|
||||
+ (".HTTPS" if ctype.https else "")
|
||||
)
|
||||
|
||||
_LOGGER.debug("Finding transport for %s", protocol_transport_key)
|
||||
supported_device_protocols: dict[
|
||||
str, tuple[type[BaseProtocol], type[BaseTransport]]
|
||||
] = {
|
||||
@ -189,6 +231,9 @@ def get_protocol(
|
||||
"IOT.KLAP": (IotProtocol, KlapTransport),
|
||||
"SMART.AES": (SmartProtocol, AesTransport),
|
||||
"SMART.KLAP": (SmartProtocol, KlapTransportV2),
|
||||
"SMART.KLAP.HTTPS": (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 not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)):
|
||||
|
@ -21,6 +21,9 @@ class DeviceType(Enum):
|
||||
Hub = "hub"
|
||||
Fan = "fan"
|
||||
Thermostat = "thermostat"
|
||||
Vacuum = "vacuum"
|
||||
Chime = "chime"
|
||||
Doorbell = "doorbell"
|
||||
Unknown = "unknown"
|
||||
|
||||
@staticmethod
|
||||
|
@ -20,7 +20,7 @@ None
|
||||
{'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}
|
||||
'https': False, 'http_port': 80}}
|
||||
|
||||
>>> later_device = await Device.connect(config=Device.Config.from_dict(config_dict))
|
||||
>>> print(later_device.alias) # Alias is available as connect() calls update()
|
||||
@ -69,6 +69,7 @@ class DeviceFamily(Enum):
|
||||
|
||||
IotSmartPlugSwitch = "IOT.SMARTPLUGSWITCH"
|
||||
IotSmartBulb = "IOT.SMARTBULB"
|
||||
IotIpCamera = "IOT.IPCAMERA"
|
||||
SmartKasaPlug = "SMART.KASAPLUG"
|
||||
SmartKasaSwitch = "SMART.KASASWITCH"
|
||||
SmartTapoPlug = "SMART.TAPOPLUG"
|
||||
@ -77,6 +78,9 @@ class DeviceFamily(Enum):
|
||||
SmartTapoHub = "SMART.TAPOHUB"
|
||||
SmartKasaHub = "SMART.KASAHUB"
|
||||
SmartIpCamera = "SMART.IPCAMERA"
|
||||
SmartTapoRobovac = "SMART.TAPOROBOVAC"
|
||||
SmartTapoChime = "SMART.TAPOCHIME"
|
||||
SmartTapoDoorbell = "SMART.TAPODOORBELL"
|
||||
|
||||
|
||||
class _DeviceConfigBaseMixin(DataClassJSONMixin):
|
||||
@ -96,13 +100,16 @@ class DeviceConnectionParameters(_DeviceConfigBaseMixin):
|
||||
encryption_type: DeviceEncryptionType
|
||||
login_version: int | None = None
|
||||
https: bool = False
|
||||
http_port: int | None = None
|
||||
|
||||
@staticmethod
|
||||
def from_values(
|
||||
device_family: str,
|
||||
encryption_type: str,
|
||||
*,
|
||||
login_version: int | None = None,
|
||||
https: bool | None = None,
|
||||
http_port: int | None = None,
|
||||
) -> DeviceConnectionParameters:
|
||||
"""Return connection parameters from string values."""
|
||||
try:
|
||||
@ -113,6 +120,7 @@ class DeviceConnectionParameters(_DeviceConfigBaseMixin):
|
||||
DeviceEncryptionType(encryption_type),
|
||||
login_version,
|
||||
https,
|
||||
http_port=http_port,
|
||||
)
|
||||
except (ValueError, TypeError) as ex:
|
||||
raise KasaException(
|
||||
@ -146,9 +154,12 @@ class DeviceConfig(_DeviceConfigBaseMixin):
|
||||
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
|
||||
|
||||
@property
|
||||
def uses_http(self) -> bool:
|
||||
"""True if the device uses http."""
|
||||
ctype = self.connection_type
|
||||
return ctype.encryption_type is not DeviceEncryptionType.Xor or ctype.https
|
||||
|
||||
#: Set a custom http_client for the device to use.
|
||||
http_client: ClientSession | None = field(
|
||||
|
250
kasa/discover.py
250
kasa/discover.py
@ -22,7 +22,7 @@ Discovery returns a dict of {ip: discovered devices}:
|
||||
>>>
|
||||
>>> found_devices = await Discover.discover()
|
||||
>>> [dev.model for dev in found_devices.values()]
|
||||
['KP303(UK)', 'HS110(EU)', 'L530E', 'KL430(US)', 'HS220(US)']
|
||||
['KP303', 'HS110', 'L530E', 'KL430', 'HS220', 'H200']
|
||||
|
||||
You can pass username and password for devices requiring authentication
|
||||
|
||||
@ -31,21 +31,21 @@ You can pass username and password for devices requiring authentication
|
||||
>>> password="great_password",
|
||||
>>> )
|
||||
>>> print(len(devices))
|
||||
5
|
||||
6
|
||||
|
||||
You can also pass a :class:`kasa.Credentials`
|
||||
|
||||
>>> creds = Credentials("user@example.com", "great_password")
|
||||
>>> devices = await Discover.discover(credentials=creds)
|
||||
>>> print(len(devices))
|
||||
5
|
||||
6
|
||||
|
||||
Discovery can also be targeted to a specific broadcast address instead of
|
||||
the default 255.255.255.255:
|
||||
|
||||
>>> found_devices = await Discover.discover(target="127.0.0.255", credentials=creds)
|
||||
>>> print(len(found_devices))
|
||||
5
|
||||
6
|
||||
|
||||
Basic information is available on the device from the discovery broadcast response
|
||||
but it is important to call device.update() after discovery if you want to access
|
||||
@ -65,17 +65,18 @@ It is also possible to pass a coroutine to be executed for each found device:
|
||||
>>> print(f"Discovered {dev.alias} (model: {dev.model})")
|
||||
>>>
|
||||
>>> devices = await Discover.discover(on_discovered=print_dev_info, credentials=creds)
|
||||
Discovered Bedroom Power Strip (model: KP303(UK))
|
||||
Discovered Bedroom Lamp Plug (model: HS110(EU))
|
||||
Discovered Bedroom Power Strip (model: KP303)
|
||||
Discovered Bedroom Lamp Plug (model: HS110)
|
||||
Discovered Living Room Bulb (model: L530)
|
||||
Discovered Bedroom Lightstrip (model: KL430(US))
|
||||
Discovered Living Room Dimmer Switch (model: HS220(US))
|
||||
Discovered Bedroom Lightstrip (model: KL430)
|
||||
Discovered Living Room Dimmer Switch (model: HS220)
|
||||
Discovered Tapo Hub (model: H200)
|
||||
|
||||
Discovering a single device returns a kasa.Device object.
|
||||
|
||||
>>> device = await Discover.discover_single("127.0.0.1", credentials=creds)
|
||||
>>> device.model
|
||||
'KP303(UK)'
|
||||
'KP303'
|
||||
|
||||
"""
|
||||
|
||||
@ -99,6 +100,7 @@ from typing import (
|
||||
Annotated,
|
||||
Any,
|
||||
NamedTuple,
|
||||
TypedDict,
|
||||
cast,
|
||||
)
|
||||
|
||||
@ -123,7 +125,7 @@ from kasa.exceptions import (
|
||||
TimeoutError,
|
||||
UnsupportedDeviceError,
|
||||
)
|
||||
from kasa.iot.iotdevice import IotDevice
|
||||
from kasa.iot.iotdevice import IotDevice, _extract_sys_info
|
||||
from kasa.json import DataClassJSONMixin
|
||||
from kasa.json import dumps as json_dumps
|
||||
from kasa.json import loads as json_loads
|
||||
@ -145,17 +147,46 @@ class ConnectAttempt(NamedTuple):
|
||||
protocol: type
|
||||
transport: type
|
||||
device: type
|
||||
https: bool
|
||||
|
||||
|
||||
class DiscoveredMeta(TypedDict):
|
||||
"""Meta info about discovery response."""
|
||||
|
||||
ip: str
|
||||
port: int
|
||||
|
||||
|
||||
class DiscoveredRaw(TypedDict):
|
||||
"""Try to connect attempt."""
|
||||
|
||||
meta: DiscoveredMeta
|
||||
discovery_response: dict
|
||||
|
||||
|
||||
OnDiscoveredCallable = Callable[[Device], Coroutine]
|
||||
OnDiscoveredRawCallable = Callable[[DiscoveredRaw], None]
|
||||
OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Coroutine]
|
||||
OnConnectAttemptCallable = Callable[[ConnectAttempt, bool], None]
|
||||
DeviceDict = dict[str, Device]
|
||||
|
||||
NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = {
|
||||
DECRYPTED_REDACTORS: dict[str, Callable[[Any], Any] | None] = {
|
||||
"connect_ssid": lambda x: "#MASKED_SSID#" if x else "",
|
||||
"device_id": lambda x: "REDACTED_" + x[9::],
|
||||
"owner": lambda x: "REDACTED_" + x[9::],
|
||||
}
|
||||
|
||||
NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = {
|
||||
"device_id": lambda x: "REDACTED_" + x[9::],
|
||||
"device_name": lambda x: "#MASKED_NAME#" if x else "",
|
||||
"owner": lambda x: "REDACTED_" + x[9::],
|
||||
"mac": mask_mac,
|
||||
"master_device_id": lambda x: "REDACTED_" + x[9::],
|
||||
"group_id": lambda x: "REDACTED_" + x[9::],
|
||||
"group_name": lambda x: "I01BU0tFRF9TU0lEIw==",
|
||||
"encrypt_info": lambda x: {**x, "key": "", "data": ""},
|
||||
"ip": lambda x: x, # don't redact but keep listed here for dump_devinfo
|
||||
"decrypted_data": lambda x: redact_data(x, DECRYPTED_REDACTORS),
|
||||
}
|
||||
|
||||
|
||||
@ -213,6 +244,7 @@ class _DiscoverProtocol(asyncio.DatagramProtocol):
|
||||
self,
|
||||
*,
|
||||
on_discovered: OnDiscoveredCallable | None = None,
|
||||
on_discovered_raw: OnDiscoveredRawCallable | None = None,
|
||||
target: str = "255.255.255.255",
|
||||
discovery_packets: int = 3,
|
||||
discovery_timeout: int = 5,
|
||||
@ -237,6 +269,7 @@ class _DiscoverProtocol(asyncio.DatagramProtocol):
|
||||
self.unsupported_device_exceptions: dict = {}
|
||||
self.invalid_device_exceptions: dict = {}
|
||||
self.on_unsupported = on_unsupported
|
||||
self.on_discovered_raw = on_discovered_raw
|
||||
self.credentials = credentials
|
||||
self.timeout = timeout
|
||||
self.discovery_timeout = discovery_timeout
|
||||
@ -326,12 +359,22 @@ class _DiscoverProtocol(asyncio.DatagramProtocol):
|
||||
config.timeout = self.timeout
|
||||
try:
|
||||
if port == self.discovery_port:
|
||||
device = Discover._get_device_instance_legacy(data, config)
|
||||
json_func = Discover._get_discovery_json_legacy
|
||||
device_func = Discover._get_device_instance_legacy
|
||||
elif port == Discover.DISCOVERY_PORT_2:
|
||||
config.uses_http = True
|
||||
device = Discover._get_device_instance(data, config)
|
||||
json_func = Discover._get_discovery_json
|
||||
device_func = Discover._get_device_instance
|
||||
else:
|
||||
return
|
||||
info = json_func(data, ip)
|
||||
if self.on_discovered_raw is not None:
|
||||
self.on_discovered_raw(
|
||||
{
|
||||
"discovery_response": info,
|
||||
"meta": {"ip": ip, "port": port},
|
||||
}
|
||||
)
|
||||
device = device_func(info, config)
|
||||
except UnsupportedDeviceError as udex:
|
||||
_LOGGER.debug("Unsupported device found at %s << %s", ip, udex)
|
||||
self.unsupported_device_exceptions[ip] = udex
|
||||
@ -388,6 +431,7 @@ class Discover:
|
||||
*,
|
||||
target: str = "255.255.255.255",
|
||||
on_discovered: OnDiscoveredCallable | None = None,
|
||||
on_discovered_raw: OnDiscoveredRawCallable | None = None,
|
||||
discovery_timeout: int = 5,
|
||||
discovery_packets: int = 3,
|
||||
interface: str | None = None,
|
||||
@ -418,6 +462,8 @@ class Discover:
|
||||
:param target: The target address where to send the broadcast discovery
|
||||
queries if multi-homing (e.g. 192.168.xxx.255).
|
||||
:param on_discovered: coroutine to execute on discovery
|
||||
:param on_discovered_raw: Optional callback once discovered json is loaded
|
||||
before any attempt to deserialize it and create devices
|
||||
:param discovery_timeout: Seconds to wait for responses, defaults to 5
|
||||
:param discovery_packets: Number of discovery packets to broadcast
|
||||
:param interface: Bind to specific interface
|
||||
@ -440,6 +486,7 @@ class Discover:
|
||||
discovery_packets=discovery_packets,
|
||||
interface=interface,
|
||||
on_unsupported=on_unsupported,
|
||||
on_discovered_raw=on_discovered_raw,
|
||||
credentials=credentials,
|
||||
timeout=timeout,
|
||||
discovery_timeout=discovery_timeout,
|
||||
@ -452,7 +499,7 @@ class Discover:
|
||||
try:
|
||||
_LOGGER.debug("Waiting %s seconds for responses...", discovery_timeout)
|
||||
await protocol.wait_for_discovery_to_complete()
|
||||
except KasaException as ex:
|
||||
except (KasaException, asyncio.CancelledError) as ex:
|
||||
for device in protocol.discovered_devices.values():
|
||||
await device.protocol.close()
|
||||
raise ex
|
||||
@ -473,6 +520,7 @@ class Discover:
|
||||
credentials: Credentials | None = None,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
on_discovered_raw: OnDiscoveredRawCallable | None = None,
|
||||
on_unsupported: OnUnsupportedCallable | None = None,
|
||||
) -> Device | None:
|
||||
"""Discover a single device by the given IP address.
|
||||
@ -490,6 +538,9 @@ class Discover:
|
||||
username and password are ignored if provided.
|
||||
:param username: Username for devices that require authentication
|
||||
:param password: Password for devices that require authentication
|
||||
:param on_discovered_raw: Optional callback once discovered json is loaded
|
||||
before any attempt to deserialize it and create devices
|
||||
:param on_unsupported: Optional callback when unsupported devices are discovered
|
||||
:rtype: SmartDevice
|
||||
:return: Object for querying/controlling found device.
|
||||
"""
|
||||
@ -526,6 +577,7 @@ class Discover:
|
||||
credentials=credentials,
|
||||
timeout=timeout,
|
||||
discovery_timeout=discovery_timeout,
|
||||
on_discovered_raw=on_discovered_raw,
|
||||
),
|
||||
local_addr=("0.0.0.0", 0), # noqa: S104
|
||||
)
|
||||
@ -583,22 +635,26 @@ class Discover:
|
||||
Device.Family.SmartTapoPlug,
|
||||
Device.Family.IotSmartPlugSwitch,
|
||||
Device.Family.SmartIpCamera,
|
||||
Device.Family.SmartTapoRobovac,
|
||||
Device.Family.IotIpCamera,
|
||||
}
|
||||
candidates: dict[
|
||||
tuple[type[BaseProtocol], type[BaseTransport], type[Device]],
|
||||
tuple[type[BaseProtocol], type[BaseTransport], type[Device], bool],
|
||||
tuple[BaseProtocol, DeviceConfig],
|
||||
] = {
|
||||
(type(protocol), type(protocol._transport), device_class): (
|
||||
(type(protocol), type(protocol._transport), device_class, https): (
|
||||
protocol,
|
||||
config,
|
||||
)
|
||||
for encrypt in Device.EncryptionType
|
||||
for device_family in main_device_families
|
||||
for https in (True, False)
|
||||
for login_version in (None, 2)
|
||||
if (
|
||||
conn_params := DeviceConnectionParameters(
|
||||
device_family=device_family,
|
||||
encryption_type=encrypt,
|
||||
login_version=login_version,
|
||||
https=https,
|
||||
)
|
||||
)
|
||||
@ -610,10 +666,9 @@ class Discover:
|
||||
port_override=port,
|
||||
credentials=credentials,
|
||||
http_client=http_client,
|
||||
uses_http=encrypt is not Device.EncryptionType.Xor,
|
||||
)
|
||||
)
|
||||
and (protocol := get_protocol(config))
|
||||
and (protocol := get_protocol(config, strict=True))
|
||||
and (
|
||||
device_class := get_device_class_from_family(
|
||||
device_family.value, https=https, require_exact=True
|
||||
@ -623,9 +678,14 @@ class Discover:
|
||||
for key, val in candidates.items():
|
||||
try:
|
||||
prot, config = val
|
||||
_LOGGER.debug("Trying to connect with %s", prot.__class__.__name__)
|
||||
dev = await _connect(config, prot)
|
||||
except Exception:
|
||||
_LOGGER.debug("Unable to connect with %s", prot)
|
||||
except Exception as ex:
|
||||
_LOGGER.debug(
|
||||
"Unable to connect with %s: %s",
|
||||
prot.__class__.__name__,
|
||||
ex,
|
||||
)
|
||||
if on_attempt:
|
||||
ca = tuple.__new__(ConnectAttempt, key)
|
||||
on_attempt(ca, False)
|
||||
@ -633,6 +693,7 @@ class Discover:
|
||||
if on_attempt:
|
||||
ca = tuple.__new__(ConnectAttempt, key)
|
||||
on_attempt(ca, True)
|
||||
_LOGGER.debug("Found working protocol %s", prot.__class__.__name__)
|
||||
return dev
|
||||
finally:
|
||||
await prot.close()
|
||||
@ -643,7 +704,11 @@ class Discover:
|
||||
"""Find SmartDevice subclass for device described by passed data."""
|
||||
if "result" in info:
|
||||
discovery_result = DiscoveryResult.from_dict(info["result"])
|
||||
https = discovery_result.mgt_encrypt_schm.is_support_https
|
||||
https = (
|
||||
discovery_result.mgt_encrypt_schm.is_support_https
|
||||
if discovery_result.mgt_encrypt_schm
|
||||
else False
|
||||
)
|
||||
dev_class = get_device_class_from_family(
|
||||
discovery_result.device_type, https=https
|
||||
)
|
||||
@ -657,33 +722,43 @@ class Discover:
|
||||
return get_device_class_from_sys_info(info)
|
||||
|
||||
@staticmethod
|
||||
def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice:
|
||||
"""Get SmartDevice from legacy 9999 response."""
|
||||
def _get_discovery_json_legacy(data: bytes, ip: str) -> dict:
|
||||
"""Get discovery json from legacy 9999 response."""
|
||||
try:
|
||||
info = json_loads(XorEncryption.decrypt(data))
|
||||
except Exception as ex:
|
||||
raise KasaException(
|
||||
f"Unable to read response from device: {config.host}: {ex}"
|
||||
f"Unable to read response from device: {ip}: {ex}"
|
||||
) from ex
|
||||
return info
|
||||
|
||||
@staticmethod
|
||||
def _get_device_instance_legacy(info: dict, config: DeviceConfig) -> Device:
|
||||
"""Get IotDevice from legacy 9999 response."""
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
data = redact_data(info, IOT_REDACTORS) if Discover._redact_data else info
|
||||
_LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data))
|
||||
|
||||
device_class = cast(type[IotDevice], Discover._get_device_class(info))
|
||||
device = device_class(config.host, config=config)
|
||||
sys_info = info["system"]["get_sysinfo"]
|
||||
if device_type := sys_info.get("mic_type", sys_info.get("type")):
|
||||
config.connection_type = DeviceConnectionParameters.from_values(
|
||||
device_family=device_type,
|
||||
encryption_type=DeviceEncryptionType.Xor.value,
|
||||
)
|
||||
sys_info = _extract_sys_info(info)
|
||||
device_type = sys_info.get("mic_type", sys_info.get("type"))
|
||||
login_version = (
|
||||
sys_info.get("stream_version") if device_type == "IOT.IPCAMERA" else None
|
||||
)
|
||||
config.connection_type = DeviceConnectionParameters.from_values(
|
||||
device_family=device_type,
|
||||
encryption_type=DeviceEncryptionType.Xor.value,
|
||||
https=device_type == "IOT.IPCAMERA",
|
||||
login_version=login_version,
|
||||
)
|
||||
device.protocol = get_protocol(config) # type: ignore[assignment]
|
||||
device.update_from_discover_info(info)
|
||||
return device
|
||||
|
||||
@staticmethod
|
||||
def _decrypt_discovery_data(discovery_result: DiscoveryResult) -> None:
|
||||
debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)
|
||||
if TYPE_CHECKING:
|
||||
assert discovery_result.encrypt_info
|
||||
assert _AesDiscoveryQuery.keypair
|
||||
@ -699,22 +774,80 @@ class Discover:
|
||||
session = AesEncyptionSession(key, iv)
|
||||
decrypted_data = session.decrypt(encrypted_data)
|
||||
|
||||
discovery_result.decrypted_data = json_loads(decrypted_data)
|
||||
result = json_loads(decrypted_data)
|
||||
if debug_enabled:
|
||||
data = (
|
||||
redact_data(result, DECRYPTED_REDACTORS)
|
||||
if Discover._redact_data
|
||||
else result
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Decrypted encrypt_info for %s: %s",
|
||||
discovery_result.ip,
|
||||
pf(data),
|
||||
)
|
||||
discovery_result.decrypted_data = result
|
||||
|
||||
@staticmethod
|
||||
def _get_discovery_json(data: bytes, ip: str) -> dict:
|
||||
"""Get discovery json from the new 20002 response."""
|
||||
try:
|
||||
info = json_loads(data[16:])
|
||||
except Exception as ex:
|
||||
_LOGGER.debug("Got invalid response from device %s: %s", ip, data)
|
||||
raise KasaException(
|
||||
f"Unable to read response from device: {ip}: {ex}"
|
||||
) from ex
|
||||
return info
|
||||
|
||||
@staticmethod
|
||||
def _get_connection_parameters(
|
||||
discovery_result: DiscoveryResult,
|
||||
) -> DeviceConnectionParameters:
|
||||
"""Get connection parameters from the discovery result."""
|
||||
type_ = discovery_result.device_type
|
||||
if (encrypt_schm := discovery_result.mgt_encrypt_schm) is None:
|
||||
raise UnsupportedDeviceError(
|
||||
f"Unsupported device {discovery_result.ip} of type {type_} "
|
||||
"with no mgt_encrypt_schm",
|
||||
discovery_result=discovery_result.to_dict(),
|
||||
host=discovery_result.ip,
|
||||
)
|
||||
|
||||
if not (encrypt_type := encrypt_schm.encrypt_type) and (
|
||||
encrypt_info := discovery_result.encrypt_info
|
||||
):
|
||||
encrypt_type = encrypt_info.sym_schm
|
||||
|
||||
if not (login_version := encrypt_schm.lv) and (
|
||||
et := discovery_result.encrypt_type
|
||||
):
|
||||
# Known encrypt types are ["1","2"] and ["3"]
|
||||
# Reuse the login_version attribute to pass the max to transport
|
||||
login_version = max([int(i) for i in et])
|
||||
|
||||
if not encrypt_type:
|
||||
raise UnsupportedDeviceError(
|
||||
f"Unsupported device {discovery_result.ip} of type {type_} "
|
||||
+ "with no encryption type",
|
||||
discovery_result=discovery_result.to_dict(),
|
||||
host=discovery_result.ip,
|
||||
)
|
||||
return DeviceConnectionParameters.from_values(
|
||||
type_,
|
||||
encrypt_type,
|
||||
login_version=login_version,
|
||||
https=encrypt_schm.is_support_https,
|
||||
http_port=encrypt_schm.http_port,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_device_instance(
|
||||
data: bytes,
|
||||
info: dict,
|
||||
config: DeviceConfig,
|
||||
) -> Device:
|
||||
"""Get SmartDevice from the new 20002 response."""
|
||||
debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)
|
||||
try:
|
||||
info = json_loads(data[16:])
|
||||
except Exception as ex:
|
||||
_LOGGER.debug("Got invalid response from device %s: %s", config.host, data)
|
||||
raise KasaException(
|
||||
f"Unable to read response from device: {config.host}: {ex}"
|
||||
) from ex
|
||||
|
||||
try:
|
||||
discovery_result = DiscoveryResult.from_dict(info["result"])
|
||||
@ -743,43 +876,26 @@ class Discover:
|
||||
Discover._decrypt_discovery_data(discovery_result)
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Unable to decrypt discovery data %s: %s", config.host, data
|
||||
"Unable to decrypt discovery data %s: %s",
|
||||
config.host,
|
||||
redact_data(info, NEW_DISCOVERY_REDACTORS),
|
||||
)
|
||||
|
||||
type_ = discovery_result.device_type
|
||||
encrypt_schm = discovery_result.mgt_encrypt_schm
|
||||
|
||||
try:
|
||||
if not (encrypt_type := encrypt_schm.encrypt_type) and (
|
||||
encrypt_info := discovery_result.encrypt_info
|
||||
):
|
||||
encrypt_type = encrypt_info.sym_schm
|
||||
|
||||
if not encrypt_type:
|
||||
raise UnsupportedDeviceError(
|
||||
f"Unsupported device {config.host} of type {type_} "
|
||||
+ "with no encryption type",
|
||||
discovery_result=discovery_result.to_dict(),
|
||||
host=config.host,
|
||||
)
|
||||
config.connection_type = DeviceConnectionParameters.from_values(
|
||||
type_,
|
||||
encrypt_type,
|
||||
discovery_result.mgt_encrypt_schm.lv,
|
||||
discovery_result.mgt_encrypt_schm.is_support_https,
|
||||
)
|
||||
conn_params = Discover._get_connection_parameters(discovery_result)
|
||||
config.connection_type = conn_params
|
||||
except KasaException as ex:
|
||||
if isinstance(ex, UnsupportedDeviceError):
|
||||
raise
|
||||
raise UnsupportedDeviceError(
|
||||
f"Unsupported device {config.host} of type {type_} "
|
||||
+ f"with encrypt_type {discovery_result.mgt_encrypt_schm.encrypt_type}",
|
||||
+ f"with encrypt_scheme {discovery_result.mgt_encrypt_schm}",
|
||||
discovery_result=discovery_result.to_dict(),
|
||||
host=config.host,
|
||||
) from ex
|
||||
|
||||
if (
|
||||
device_class := get_device_class_from_family(
|
||||
type_, https=encrypt_schm.is_support_https
|
||||
)
|
||||
device_class := get_device_class_from_family(type_, https=conn_params.https)
|
||||
) is None:
|
||||
_LOGGER.debug("Got unsupported device type: %s", type_)
|
||||
raise UnsupportedDeviceError(
|
||||
@ -854,7 +970,7 @@ class DiscoveryResult(_DiscoveryBaseMixin):
|
||||
device_id: str
|
||||
ip: str
|
||||
mac: str
|
||||
mgt_encrypt_schm: EncryptionScheme
|
||||
mgt_encrypt_schm: EncryptionScheme | None = None
|
||||
device_name: str | None = None
|
||||
encrypt_info: EncryptionInfo | None = None
|
||||
encrypt_type: list[str] | None = None
|
||||
|
@ -127,11 +127,14 @@ class SmartErrorCode(IntEnum):
|
||||
DST_ERROR = -2301
|
||||
DST_SAVE_ERROR = -2302
|
||||
|
||||
VACUUM_BATTERY_LOW = -3001
|
||||
|
||||
SYSTEM_ERROR = -40101
|
||||
INVALID_ARGUMENTS = -40209
|
||||
|
||||
# Camera error codes
|
||||
SESSION_EXPIRED = -40401
|
||||
BAD_USERNAME = -40411 # determined from testing
|
||||
HOMEKIT_LOGIN_FAIL = -40412
|
||||
DEVICE_BLOCKED = -40404
|
||||
DEVICE_FACTORY = -40405
|
||||
|
@ -24,8 +24,8 @@ State (state): True
|
||||
Signal Level (signal_level): 2
|
||||
RSSI (rssi): -52
|
||||
SSID (ssid): #MASKED_SSID#
|
||||
Overheated (overheated): False
|
||||
Reboot (reboot): <Action>
|
||||
Device time (device_time): 2024-02-23 02:40:15+01:00
|
||||
Brightness (brightness): 100
|
||||
Cloud connection (cloud_connection): True
|
||||
HSV (hsv): HSV(hue=0, saturation=100, value=100)
|
||||
@ -39,7 +39,7 @@ 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
|
||||
Device time (device_time): 2024-02-23 02:40:15+01:00
|
||||
Overheated (overheated): False
|
||||
|
||||
To see whether a device supports a feature, check for the existence of it:
|
||||
|
||||
@ -76,6 +76,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .device import Device
|
||||
from .module import Module
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -142,7 +143,7 @@ class Feature:
|
||||
#: 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
|
||||
container: Device | Module | None = None
|
||||
#: Icon suggestion
|
||||
icon: str | None = None
|
||||
#: Attribute containing the name of the unit getter property.
|
||||
@ -255,7 +256,7 @@ class Feature:
|
||||
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"Unexpected value for {self.name}: '{value}'"
|
||||
f" - allowed: {self.choices}"
|
||||
)
|
||||
|
||||
@ -278,7 +279,18 @@ class Feature:
|
||||
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:
|
||||
if not isinstance(choices, list):
|
||||
_LOGGER.error(
|
||||
"Choices are not properly defined for %s (%s). Type: <%s> Value: %s", # noqa: E501
|
||||
self.name,
|
||||
self.id,
|
||||
type(choices),
|
||||
choices,
|
||||
)
|
||||
return f"{self.name} ({self.id}): improperly defined choice set."
|
||||
if (value not in choices) and (
|
||||
isinstance(value, Enum) and value.name not in choices
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"Invalid value for for choice %s (%s): %s not in %s",
|
||||
self.name,
|
||||
@ -290,14 +302,24 @@ class Feature:
|
||||
f"{self.name} ({self.id}): invalid value '{value}' not in {choices}"
|
||||
)
|
||||
value = " ".join(
|
||||
[f"*{choice}*" if choice == value else choice for choice in choices]
|
||||
[
|
||||
f"*{choice}*"
|
||||
if choice == value
|
||||
or (isinstance(value, Enum) and choice == value.name)
|
||||
else f"{choice}"
|
||||
for choice in choices
|
||||
]
|
||||
)
|
||||
if self.precision_hint is not None and isinstance(value, float):
|
||||
value = round(value, self.precision_hint)
|
||||
|
||||
if isinstance(value, Enum):
|
||||
value = repr(value)
|
||||
s = f"{self.name} ({self.id}): {value}"
|
||||
if self.unit is not None:
|
||||
s += f" {self.unit}"
|
||||
if (unit := self.unit) is not None:
|
||||
if isinstance(unit, Enum):
|
||||
unit = repr(unit)
|
||||
s += f" {unit}"
|
||||
|
||||
if self.type == Feature.Type.Number:
|
||||
s += f" (range: {self.minimum_value}-{self.maximum_value})"
|
||||
|
@ -113,10 +113,23 @@ class HttpClient:
|
||||
ssl=ssl,
|
||||
)
|
||||
async with resp:
|
||||
if resp.status == 200:
|
||||
response_data = await resp.read()
|
||||
if return_json:
|
||||
response_data = await resp.read()
|
||||
|
||||
if resp.status == 200:
|
||||
if return_json:
|
||||
response_data = json_loads(response_data.decode())
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Device %s received status code %s with response %s",
|
||||
self._config.host,
|
||||
resp.status,
|
||||
str(response_data),
|
||||
)
|
||||
if response_data and return_json:
|
||||
try:
|
||||
response_data = json_loads(response_data.decode())
|
||||
except Exception:
|
||||
_LOGGER.debug("Device %s response could not be parsed as json")
|
||||
|
||||
except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex:
|
||||
if not self._wait_between_requests:
|
||||
|
@ -1,5 +1,7 @@
|
||||
"""Package for interfaces."""
|
||||
|
||||
from .alarm import Alarm
|
||||
from .childsetup import ChildSetup
|
||||
from .energy import Energy
|
||||
from .fan import Fan
|
||||
from .led import Led
|
||||
@ -10,6 +12,8 @@ from .thermostat import Thermostat, ThermostatState
|
||||
from .time import Time
|
||||
|
||||
__all__ = [
|
||||
"Alarm",
|
||||
"ChildSetup",
|
||||
"Fan",
|
||||
"Energy",
|
||||
"Led",
|
||||
|
75
kasa/interfaces/alarm.py
Normal file
75
kasa/interfaces/alarm.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""Module for base alarm module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Annotated
|
||||
|
||||
from ..module import FeatureAttribute, Module
|
||||
|
||||
|
||||
class Alarm(Module, ABC):
|
||||
"""Base interface to represent an alarm module."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def alarm_sound(self) -> Annotated[str, FeatureAttribute()]:
|
||||
"""Return current alarm sound."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_alarm_sound(self, sound: str) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""Set alarm sound.
|
||||
|
||||
See *alarm_sounds* for list of available sounds.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def alarm_sounds(self) -> list[str]:
|
||||
"""Return list of available alarm sounds."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def alarm_volume(self) -> Annotated[int, FeatureAttribute()]:
|
||||
"""Return alarm volume."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_alarm_volume(
|
||||
self, volume: int
|
||||
) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""Set alarm volume."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def alarm_duration(self) -> Annotated[int, FeatureAttribute()]:
|
||||
"""Return alarm duration."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_alarm_duration(
|
||||
self, duration: int
|
||||
) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""Set alarm duration."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def active(self) -> bool:
|
||||
"""Return true if alarm is active."""
|
||||
|
||||
@abstractmethod
|
||||
async def play(
|
||||
self,
|
||||
*,
|
||||
duration: int | None = None,
|
||||
volume: int | None = None,
|
||||
sound: str | None = None,
|
||||
) -> dict:
|
||||
"""Play alarm.
|
||||
|
||||
The optional *duration*, *volume*, and *sound* to override the device settings.
|
||||
*duration* is in seconds.
|
||||
See *alarm_sounds* for the list of sounds available for the device.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def stop(self) -> dict:
|
||||
"""Stop alarm."""
|
70
kasa/interfaces/childsetup.py
Normal file
70
kasa/interfaces/childsetup.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""Module for childsetup interface.
|
||||
|
||||
The childsetup module allows pairing and unpairing of supported child device types to
|
||||
hubs.
|
||||
|
||||
>>> from kasa import Discover, Module, LightState
|
||||
>>>
|
||||
>>> dev = await Discover.discover_single(
|
||||
>>> "127.0.0.6",
|
||||
>>> username="user@example.com",
|
||||
>>> password="great_password"
|
||||
>>> )
|
||||
>>> await dev.update()
|
||||
>>> print(dev.alias)
|
||||
Tapo Hub
|
||||
|
||||
>>> childsetup = dev.modules[Module.ChildSetup]
|
||||
>>> childsetup.supported_categories
|
||||
['camera', 'subg.trv', 'subg.trigger', 'subg.plugswitch']
|
||||
|
||||
Put child devices in pairing mode.
|
||||
The hub will pair with all supported devices in pairing mode:
|
||||
|
||||
>>> added = await childsetup.pair()
|
||||
>>> added
|
||||
[{'device_id': 'SCRUBBED_CHILD_DEVICE_ID_5', 'category': 'subg.trigger.button', \
|
||||
'device_model': 'S200B', 'name': 'I01BU0tFRF9OQU1FIw===='}]
|
||||
|
||||
>>> for child in dev.children:
|
||||
>>> print(f"{child.device_id} - {child.model}")
|
||||
SCRUBBED_CHILD_DEVICE_ID_1 - T310
|
||||
SCRUBBED_CHILD_DEVICE_ID_2 - T315
|
||||
SCRUBBED_CHILD_DEVICE_ID_3 - T110
|
||||
SCRUBBED_CHILD_DEVICE_ID_4 - S200B
|
||||
SCRUBBED_CHILD_DEVICE_ID_5 - S200B
|
||||
|
||||
Unpair with the child `device_id`:
|
||||
|
||||
>>> await childsetup.unpair("SCRUBBED_CHILD_DEVICE_ID_4")
|
||||
>>> for child in dev.children:
|
||||
>>> print(f"{child.device_id} - {child.model}")
|
||||
SCRUBBED_CHILD_DEVICE_ID_1 - T310
|
||||
SCRUBBED_CHILD_DEVICE_ID_2 - T315
|
||||
SCRUBBED_CHILD_DEVICE_ID_3 - T110
|
||||
SCRUBBED_CHILD_DEVICE_ID_5 - S200B
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ..module import Module
|
||||
|
||||
|
||||
class ChildSetup(Module, ABC):
|
||||
"""Interface for child setup on hubs."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def supported_categories(self) -> list[str]:
|
||||
"""Supported child device categories."""
|
||||
|
||||
@abstractmethod
|
||||
async def pair(self, *, timeout: int = 10) -> list[dict]:
|
||||
"""Scan for new devices and pair them."""
|
||||
|
||||
@abstractmethod
|
||||
async def unpair(self, device_id: str) -> dict:
|
||||
"""Remove device from the hub."""
|
@ -28,7 +28,7 @@ class Energy(Module, ABC):
|
||||
|
||||
_supported: ModuleFeature = ModuleFeature(0)
|
||||
|
||||
def supports(self, module_feature: ModuleFeature) -> bool:
|
||||
def supports(self, module_feature: Energy.ModuleFeature) -> bool:
|
||||
"""Return True if module supports the feature."""
|
||||
return module_feature in self._supported
|
||||
|
||||
|
@ -23,13 +23,13 @@ Get the light module to interact:
|
||||
|
||||
>>> light = dev.modules[Module.Light]
|
||||
|
||||
You can use the ``is_``-prefixed properties to check for supported features:
|
||||
You can use the ``has_feature()`` method to check for supported features:
|
||||
|
||||
>>> light.is_dimmable
|
||||
>>> light.has_feature("brightness")
|
||||
True
|
||||
>>> light.is_color
|
||||
>>> light.has_feature("hsv")
|
||||
True
|
||||
>>> light.is_variable_color_temp
|
||||
>>> light.has_feature("color_temp")
|
||||
True
|
||||
|
||||
All known bulbs support changing the brightness:
|
||||
@ -43,8 +43,9 @@ All known bulbs support changing the brightness:
|
||||
|
||||
Bulbs supporting color temperature can be queried for the supported range:
|
||||
|
||||
>>> light.valid_temperature_range
|
||||
ColorTempRange(min=2500, max=6500)
|
||||
>>> 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
|
||||
@ -64,8 +65,10 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, NamedTuple
|
||||
from typing import TYPE_CHECKING, Annotated, Any, NamedTuple
|
||||
from warnings import warn
|
||||
|
||||
from ..exceptions import KasaException
|
||||
from ..module import FeatureAttribute, Module
|
||||
|
||||
|
||||
@ -99,34 +102,6 @@ class HSV(NamedTuple):
|
||||
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()]:
|
||||
@ -196,3 +171,44 @@ class Light(Module, ABC):
|
||||
@abstractmethod
|
||||
async def set_state(self, state: LightState) -> dict:
|
||||
"""Set the light state."""
|
||||
|
||||
def _deprecated_valid_temperature_range(self) -> ColorTempRange:
|
||||
if not (temp := self.get_feature("color_temp")):
|
||||
raise KasaException("Color temperature not supported")
|
||||
return ColorTempRange(temp.minimum_value, temp.maximum_value)
|
||||
|
||||
def _deprecated_attributes(self, dep_name: str) -> str | None:
|
||||
map: dict[str, str] = {
|
||||
"is_color": "hsv",
|
||||
"is_dimmable": "brightness",
|
||||
"is_variable_color_temp": "color_temp",
|
||||
}
|
||||
return map.get(dep_name)
|
||||
|
||||
if not TYPE_CHECKING:
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
if name == "valid_temperature_range":
|
||||
msg = (
|
||||
"valid_temperature_range is deprecated, use "
|
||||
'get_feature("color_temp") minimum_value '
|
||||
" and maximum_value instead"
|
||||
)
|
||||
warn(msg, DeprecationWarning, stacklevel=2)
|
||||
res = self._deprecated_valid_temperature_range()
|
||||
return res
|
||||
|
||||
if name == "has_effects":
|
||||
msg = (
|
||||
"has_effects is deprecated, check `Module.LightEffect "
|
||||
"in device.modules` instead"
|
||||
)
|
||||
warn(msg, DeprecationWarning, stacklevel=2)
|
||||
return Module.LightEffect in self._device.modules
|
||||
|
||||
if attr := self._deprecated_attributes(name):
|
||||
msg = f'{name} is deprecated, use has_feature("{attr}") instead'
|
||||
warn(msg, DeprecationWarning, stacklevel=2)
|
||||
return self.has_feature(attr)
|
||||
|
||||
raise AttributeError(f"Energy module has no attribute {name!r}")
|
||||
|
@ -13,8 +13,7 @@ Living Room Bulb
|
||||
|
||||
Light effects are accessed via the LightPreset module. To list available presets
|
||||
|
||||
>>> if dev.modules[Module.Light].has_effects:
|
||||
>>> light_effect = dev.modules[Module.LightEffect]
|
||||
>>> light_effect = dev.modules[Module.LightEffect]
|
||||
>>> light_effect.effect_list
|
||||
['Off', 'Party', 'Relax']
|
||||
|
||||
@ -52,6 +51,7 @@ class LightEffect(Module, ABC):
|
||||
"""Interface to represent a light effect module."""
|
||||
|
||||
LIGHT_EFFECTS_OFF = "Off"
|
||||
LIGHT_EFFECTS_UNNAMED_CUSTOM = "Custom"
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features."""
|
||||
@ -78,7 +78,7 @@ class LightEffect(Module, ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def effect(self) -> str:
|
||||
"""Return effect state or name."""
|
||||
"""Return effect name."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""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
|
||||
@ -15,4 +16,5 @@ __all__ = [
|
||||
"IotDimmer",
|
||||
"IotLightStrip",
|
||||
"IotWallSwitch",
|
||||
"IotCamera",
|
||||
]
|
||||
|
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
|
@ -22,7 +22,7 @@ from datetime import datetime, timedelta, tzinfo
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from warnings import warn
|
||||
|
||||
from ..device import Device, WifiNetwork, _DeviceInfo
|
||||
from ..device import Device, DeviceInfo, WifiNetwork
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..exceptions import KasaException
|
||||
@ -43,7 +43,7 @@ def requires_update(f: Callable) -> Any:
|
||||
@functools.wraps(f)
|
||||
async def wrapped(*args: Any, **kwargs: Any) -> Any:
|
||||
self = args[0]
|
||||
if self._last_update is None and (
|
||||
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")
|
||||
@ -54,7 +54,7 @@ def requires_update(f: Callable) -> Any:
|
||||
@functools.wraps(f)
|
||||
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
||||
self = args[0]
|
||||
if self._last_update is None and (
|
||||
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")
|
||||
@ -70,6 +70,16 @@ def _parse_features(features: str) -> set[str]:
|
||||
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.
|
||||
|
||||
@ -102,7 +112,7 @@ class IotDevice(Device):
|
||||
>>> dev.alias
|
||||
Bedroom Lamp Plug
|
||||
>>> dev.model
|
||||
HS110(EU)
|
||||
HS110
|
||||
>>> dev.rssi
|
||||
-71
|
||||
>>> dev.mac
|
||||
@ -300,18 +310,18 @@ class IotDevice(Device):
|
||||
# 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 self._last_update is None:
|
||||
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(response["system"]["get_sysinfo"])
|
||||
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(self._last_update["system"]["get_sysinfo"])
|
||||
self._set_sys_info(_extract_sys_info(self._last_update))
|
||||
for module in self._modules.values():
|
||||
await module._post_update_hook()
|
||||
|
||||
@ -442,7 +452,9 @@ class IotDevice(Device):
|
||||
# This allows setting of some info properties directly
|
||||
# from partial discovery info that will then be found
|
||||
# by the requires_update decorator
|
||||
self._set_sys_info(info)
|
||||
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."""
|
||||
@ -461,18 +473,13 @@ class IotDevice(Device):
|
||||
"""
|
||||
return self._sys_info # type: ignore
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def model(self) -> str:
|
||||
"""Return device model."""
|
||||
sys_info = self._sys_info
|
||||
return str(sys_info["model"])
|
||||
|
||||
@property
|
||||
@requires_update
|
||||
def _model_region(self) -> str:
|
||||
"""Return device full model name and region."""
|
||||
return self.model
|
||||
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:
|
||||
@ -705,10 +712,13 @@ class IotDevice(Device):
|
||||
@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] = info["system"]["get_sysinfo"]
|
||||
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!")
|
||||
@ -728,15 +738,16 @@ class IotDevice(Device):
|
||||
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:
|
||||
) -> DeviceInfo:
|
||||
"""Get model information for a device."""
|
||||
sys_info = info["system"]["get_sysinfo"]
|
||||
sys_info = _extract_sys_info(info)
|
||||
|
||||
# Get model and region info
|
||||
region = None
|
||||
@ -749,10 +760,13 @@ class IotDevice(Device):
|
||||
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)
|
||||
if " " in fw_version_full:
|
||||
firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1)
|
||||
else:
|
||||
firmware_version, firmware_build = fw_version_full, None
|
||||
auth = bool(discovery_info and ("mgt_encrypt_schm" in discovery_info))
|
||||
|
||||
return _DeviceInfo(
|
||||
return DeviceInfo(
|
||||
short_name=long_name,
|
||||
long_name=long_name,
|
||||
brand="kasa",
|
||||
|
@ -11,7 +11,7 @@ from ..module import Module
|
||||
from ..protocols import BaseProtocol
|
||||
from .iotdevice import KasaException, requires_update
|
||||
from .iotplug import IotPlug
|
||||
from .modules import AmbientLight, Light, Motion
|
||||
from .modules import AmbientLight, Dimmer, Light, Motion
|
||||
|
||||
|
||||
class ButtonAction(Enum):
|
||||
@ -87,6 +87,7 @@ class IotDimmer(IotPlug):
|
||||
# TODO: need to be figured out what's the best approach to detect support
|
||||
self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR"))
|
||||
self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS"))
|
||||
self.add_module(Module.IotDimmer, Dimmer(self, "smartlife.iot.dimmer"))
|
||||
self.add_module(Module.Light, Light(self, "light"))
|
||||
|
||||
@property # type: ignore
|
||||
@ -115,9 +116,7 @@ class IotDimmer(IotPlug):
|
||||
raise KasaException("Device is not dimmable.")
|
||||
|
||||
if not isinstance(brightness, int):
|
||||
raise ValueError(
|
||||
"Brightness must be integer, " "not of %s.", type(brightness)
|
||||
)
|
||||
raise ValueError("Brightness must be integer, not of %s.", type(brightness))
|
||||
|
||||
if not 0 <= brightness <= 100:
|
||||
raise ValueError(
|
||||
|
@ -161,11 +161,17 @@ class IotStrip(IotDevice):
|
||||
|
||||
async def turn_on(self, **kwargs) -> dict:
|
||||
"""Turn the strip on."""
|
||||
return await self._query_helper("system", "set_relay_state", {"state": 1})
|
||||
for plug in self.children:
|
||||
if plug.is_off:
|
||||
await plug.turn_on()
|
||||
return {}
|
||||
|
||||
async def turn_off(self, **kwargs) -> dict:
|
||||
"""Turn the strip off."""
|
||||
return await self._query_helper("system", "set_relay_state", {"state": 0})
|
||||
for plug in self.children:
|
||||
if plug.is_on:
|
||||
await plug.turn_off()
|
||||
return {}
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
|
@ -4,6 +4,7 @@ from .ambientlight import AmbientLight
|
||||
from .antitheft import Antitheft
|
||||
from .cloud import Cloud
|
||||
from .countdown import Countdown
|
||||
from .dimmer import Dimmer
|
||||
from .emeter import Emeter
|
||||
from .led import Led
|
||||
from .light import Light
|
||||
@ -20,6 +21,7 @@ __all__ = [
|
||||
"Antitheft",
|
||||
"Cloud",
|
||||
"Countdown",
|
||||
"Dimmer",
|
||||
"Emeter",
|
||||
"Led",
|
||||
"Light",
|
||||
|
270
kasa/iot/modules/dimmer.py
Normal file
270
kasa/iot/modules/dimmer.py
Normal file
@ -0,0 +1,270 @@
|
||||
"""Implementation of the dimmer config module found in dimmers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from ...exceptions import KasaException
|
||||
from ...feature import Feature
|
||||
from ..iotmodule import IotModule, merge
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _td_to_ms(td: timedelta) -> int:
|
||||
"""
|
||||
Convert timedelta to integer milliseconds.
|
||||
|
||||
Uses default float to integer rounding.
|
||||
"""
|
||||
return int(td / timedelta(milliseconds=1))
|
||||
|
||||
|
||||
class Dimmer(IotModule):
|
||||
"""Implements the dimmer config module."""
|
||||
|
||||
THRESHOLD_ABS_MIN: Final[int] = 0
|
||||
# Strange value, but verified against hardware (KS220).
|
||||
THRESHOLD_ABS_MAX: Final[int] = 51
|
||||
FADE_TIME_ABS_MIN: Final[timedelta] = timedelta(seconds=0)
|
||||
# Arbitrary, but set low intending GENTLE FADE for longer fades.
|
||||
FADE_TIME_ABS_MAX: Final[timedelta] = timedelta(seconds=10)
|
||||
GENTLE_TIME_ABS_MIN: Final[timedelta] = timedelta(seconds=0)
|
||||
# Arbitrary, but reasonable default.
|
||||
GENTLE_TIME_ABS_MAX: Final[timedelta] = timedelta(seconds=120)
|
||||
# Verified against KS220.
|
||||
RAMP_RATE_ABS_MIN: Final[int] = 10
|
||||
# Verified against KS220.
|
||||
RAMP_RATE_ABS_MAX: Final[int] = 50
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features after the initial update."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="dimmer_threshold_min",
|
||||
name="Minimum dimming level",
|
||||
icon="mdi:lightbulb-on-20",
|
||||
attribute_getter="threshold_min",
|
||||
attribute_setter="set_threshold_min",
|
||||
range_getter=lambda: (self.THRESHOLD_ABS_MIN, self.THRESHOLD_ABS_MAX),
|
||||
type=Feature.Type.Number,
|
||||
category=Feature.Category.Config,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="dimmer_fade_off_time",
|
||||
name="Dimmer fade off time",
|
||||
icon="mdi:clock-in",
|
||||
attribute_getter="fade_off_time",
|
||||
attribute_setter="set_fade_off_time",
|
||||
range_getter=lambda: (
|
||||
_td_to_ms(self.FADE_TIME_ABS_MIN),
|
||||
_td_to_ms(self.FADE_TIME_ABS_MAX),
|
||||
),
|
||||
type=Feature.Type.Number,
|
||||
category=Feature.Category.Config,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="dimmer_fade_on_time",
|
||||
name="Dimmer fade on time",
|
||||
icon="mdi:clock-out",
|
||||
attribute_getter="fade_on_time",
|
||||
attribute_setter="set_fade_on_time",
|
||||
range_getter=lambda: (
|
||||
_td_to_ms(self.FADE_TIME_ABS_MIN),
|
||||
_td_to_ms(self.FADE_TIME_ABS_MAX),
|
||||
),
|
||||
type=Feature.Type.Number,
|
||||
category=Feature.Category.Config,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="dimmer_gentle_off_time",
|
||||
name="Dimmer gentle off time",
|
||||
icon="mdi:clock-in",
|
||||
attribute_getter="gentle_off_time",
|
||||
attribute_setter="set_gentle_off_time",
|
||||
range_getter=lambda: (
|
||||
_td_to_ms(self.GENTLE_TIME_ABS_MIN),
|
||||
_td_to_ms(self.GENTLE_TIME_ABS_MAX),
|
||||
),
|
||||
type=Feature.Type.Number,
|
||||
category=Feature.Category.Config,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="dimmer_gentle_on_time",
|
||||
name="Dimmer gentle on time",
|
||||
icon="mdi:clock-out",
|
||||
attribute_getter="gentle_on_time",
|
||||
attribute_setter="set_gentle_on_time",
|
||||
range_getter=lambda: (
|
||||
_td_to_ms(self.GENTLE_TIME_ABS_MIN),
|
||||
_td_to_ms(self.GENTLE_TIME_ABS_MAX),
|
||||
),
|
||||
type=Feature.Type.Number,
|
||||
category=Feature.Category.Config,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="dimmer_ramp_rate",
|
||||
name="Dimmer ramp rate",
|
||||
icon="mdi:clock-fast",
|
||||
attribute_getter="ramp_rate",
|
||||
attribute_setter="set_ramp_rate",
|
||||
range_getter=lambda: (self.RAMP_RATE_ABS_MIN, self.RAMP_RATE_ABS_MAX),
|
||||
type=Feature.Type.Number,
|
||||
category=Feature.Category.Config,
|
||||
)
|
||||
)
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Request Dimming configuration."""
|
||||
req = merge(
|
||||
self.query_for_command("get_dimmer_parameters"),
|
||||
self.query_for_command("get_default_behavior"),
|
||||
)
|
||||
|
||||
return req
|
||||
|
||||
@property
|
||||
def config(self) -> dict[str, Any]:
|
||||
"""Return current configuration."""
|
||||
return self.data["get_dimmer_parameters"]
|
||||
|
||||
@property
|
||||
def threshold_min(self) -> int:
|
||||
"""Return the minimum dimming level for this dimmer."""
|
||||
return self.config["minThreshold"]
|
||||
|
||||
async def set_threshold_min(self, min: int) -> dict:
|
||||
"""Set the minimum dimming level for this dimmer.
|
||||
|
||||
The value will depend on the luminaries connected to the dimmer.
|
||||
|
||||
:param min: The minimum dimming level, in the range 0-51.
|
||||
"""
|
||||
if min < self.THRESHOLD_ABS_MIN or min > self.THRESHOLD_ABS_MAX:
|
||||
raise KasaException(
|
||||
"Minimum dimming threshold is outside the supported range: "
|
||||
f"{self.THRESHOLD_ABS_MIN}-{self.THRESHOLD_ABS_MAX}"
|
||||
)
|
||||
return await self.call("calibrate_brightness", {"minThreshold": min})
|
||||
|
||||
@property
|
||||
def fade_off_time(self) -> timedelta:
|
||||
"""Return the fade off animation duration."""
|
||||
return timedelta(milliseconds=cast(int, self.config["fadeOffTime"]))
|
||||
|
||||
async def set_fade_off_time(self, time: int | timedelta) -> dict:
|
||||
"""Set the duration of the fade off animation.
|
||||
|
||||
:param time: The animation duration, in ms.
|
||||
"""
|
||||
if isinstance(time, int):
|
||||
time = timedelta(milliseconds=time)
|
||||
if time < self.FADE_TIME_ABS_MIN or time > self.FADE_TIME_ABS_MAX:
|
||||
raise KasaException(
|
||||
"Fade time is outside the bounds of the supported range:"
|
||||
f"{self.FADE_TIME_ABS_MIN}-{self.FADE_TIME_ABS_MAX}"
|
||||
)
|
||||
return await self.call("set_fade_off_time", {"fadeTime": _td_to_ms(time)})
|
||||
|
||||
@property
|
||||
def fade_on_time(self) -> timedelta:
|
||||
"""Return the fade on animation duration."""
|
||||
return timedelta(milliseconds=cast(int, self.config["fadeOnTime"]))
|
||||
|
||||
async def set_fade_on_time(self, time: int | timedelta) -> dict:
|
||||
"""Set the duration of the fade on animation.
|
||||
|
||||
:param time: The animation duration, in ms.
|
||||
"""
|
||||
if isinstance(time, int):
|
||||
time = timedelta(milliseconds=time)
|
||||
if time < self.FADE_TIME_ABS_MIN or time > self.FADE_TIME_ABS_MAX:
|
||||
raise KasaException(
|
||||
"Fade time is outside the bounds of the supported range:"
|
||||
f"{self.FADE_TIME_ABS_MIN}-{self.FADE_TIME_ABS_MAX}"
|
||||
)
|
||||
return await self.call("set_fade_on_time", {"fadeTime": _td_to_ms(time)})
|
||||
|
||||
@property
|
||||
def gentle_off_time(self) -> timedelta:
|
||||
"""Return the gentle fade off animation duration."""
|
||||
return timedelta(milliseconds=cast(int, self.config["gentleOffTime"]))
|
||||
|
||||
async def set_gentle_off_time(self, time: int | timedelta) -> dict:
|
||||
"""Set the duration of the gentle fade off animation.
|
||||
|
||||
:param time: The animation duration, in ms.
|
||||
"""
|
||||
if isinstance(time, int):
|
||||
time = timedelta(milliseconds=time)
|
||||
if time < self.GENTLE_TIME_ABS_MIN or time > self.GENTLE_TIME_ABS_MAX:
|
||||
raise KasaException(
|
||||
"Gentle off time is outside the bounds of the supported range: "
|
||||
f"{self.GENTLE_TIME_ABS_MIN}-{self.GENTLE_TIME_ABS_MAX}."
|
||||
)
|
||||
return await self.call("set_gentle_off_time", {"duration": _td_to_ms(time)})
|
||||
|
||||
@property
|
||||
def gentle_on_time(self) -> timedelta:
|
||||
"""Return the gentle fade on animation duration."""
|
||||
return timedelta(milliseconds=cast(int, self.config["gentleOnTime"]))
|
||||
|
||||
async def set_gentle_on_time(self, time: int | timedelta) -> dict:
|
||||
"""Set the duration of the gentle fade on animation.
|
||||
|
||||
:param time: The animation duration, in ms.
|
||||
"""
|
||||
if isinstance(time, int):
|
||||
time = timedelta(milliseconds=time)
|
||||
if time < self.GENTLE_TIME_ABS_MIN or time > self.GENTLE_TIME_ABS_MAX:
|
||||
raise KasaException(
|
||||
"Gentle off time is outside the bounds of the supported range: "
|
||||
f"{self.GENTLE_TIME_ABS_MIN}-{self.GENTLE_TIME_ABS_MAX}."
|
||||
)
|
||||
return await self.call("set_gentle_on_time", {"duration": _td_to_ms(time)})
|
||||
|
||||
@property
|
||||
def ramp_rate(self) -> int:
|
||||
"""Return the rate that the dimmer buttons increment the dimmer level."""
|
||||
return self.config["rampRate"]
|
||||
|
||||
async def set_ramp_rate(self, rate: int) -> dict:
|
||||
"""Set how quickly to ramp the dimming level when using the dimmer buttons.
|
||||
|
||||
:param rate: The rate to increment the dimming level with each press.
|
||||
"""
|
||||
if rate < self.RAMP_RATE_ABS_MIN or rate > self.RAMP_RATE_ABS_MAX:
|
||||
raise KasaException(
|
||||
"Gentle off time is outside the bounds of the supported range:"
|
||||
f"{self.RAMP_RATE_ABS_MIN}-{self.RAMP_RATE_ABS_MAX}"
|
||||
)
|
||||
return await self.call("set_button_ramp_rate", {"rampRate": rate})
|
@ -3,13 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from typing import TYPE_CHECKING, Annotated, cast
|
||||
|
||||
from ...device_type import DeviceType
|
||||
from ...exceptions import KasaException
|
||||
from ...feature import Feature
|
||||
from ...interfaces.light import HSV, ColorTempRange, LightState
|
||||
from ...interfaces.light import HSV, LightState
|
||||
from ...interfaces.light import Light as LightInterface
|
||||
from ...module import FeatureAttribute
|
||||
from ..iotmodule import IotModule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -32,7 +33,7 @@ class Light(IotModule, LightInterface):
|
||||
super()._initialize_features()
|
||||
device = self._device
|
||||
|
||||
if self._device._is_dimmable:
|
||||
if device._is_dimmable:
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device,
|
||||
@ -46,7 +47,9 @@ class Light(IotModule, LightInterface):
|
||||
category=Feature.Category.Primary,
|
||||
)
|
||||
)
|
||||
if self._device._is_variable_color_temp:
|
||||
if device._is_variable_color_temp:
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(device, IotBulb)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=device,
|
||||
@ -55,12 +58,12 @@ class Light(IotModule, LightInterface):
|
||||
container=self,
|
||||
attribute_getter="color_temp",
|
||||
attribute_setter="set_color_temp",
|
||||
range_getter="valid_temperature_range",
|
||||
range_getter=lambda: device._valid_temperature_range,
|
||||
category=Feature.Category.Primary,
|
||||
type=Feature.Type.Number,
|
||||
)
|
||||
)
|
||||
if self._device._is_color:
|
||||
if device._is_color:
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=device,
|
||||
@ -90,18 +93,13 @@ class Light(IotModule, LightInterface):
|
||||
return None
|
||||
|
||||
@property # type: ignore
|
||||
def is_dimmable(self) -> int:
|
||||
"""Whether the bulb supports brightness changes."""
|
||||
return self._device._is_dimmable
|
||||
|
||||
@property # type: ignore
|
||||
def brightness(self) -> int:
|
||||
def brightness(self) -> Annotated[int, FeatureAttribute()]:
|
||||
"""Return the current brightness in percentage."""
|
||||
return self._device._brightness
|
||||
|
||||
async def set_brightness(
|
||||
self, brightness: int, *, transition: int | None = None
|
||||
) -> dict:
|
||||
) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""Set the brightness in percentage. A value of 0 will turn off the light.
|
||||
|
||||
:param int brightness: brightness in percent
|
||||
@ -112,28 +110,7 @@ class Light(IotModule, LightInterface):
|
||||
)
|
||||
|
||||
@property
|
||||
def is_color(self) -> bool:
|
||||
"""Whether the light supports color changes."""
|
||||
if (bulb := self._get_bulb_device()) is None:
|
||||
return False
|
||||
return bulb._is_color
|
||||
|
||||
@property
|
||||
def is_variable_color_temp(self) -> bool:
|
||||
"""Whether the bulb supports color temperature changes."""
|
||||
if (bulb := self._get_bulb_device()) is None:
|
||||
return False
|
||||
return bulb._is_variable_color_temp
|
||||
|
||||
@property
|
||||
def has_effects(self) -> bool:
|
||||
"""Return True if the device supports effects."""
|
||||
if (bulb := self._get_bulb_device()) is None:
|
||||
return False
|
||||
return bulb._has_effects
|
||||
|
||||
@property
|
||||
def hsv(self) -> HSV:
|
||||
def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
|
||||
"""Return the current HSV state of the bulb.
|
||||
|
||||
:return: hue, saturation and value (degrees, %, %)
|
||||
@ -149,7 +126,7 @@ class Light(IotModule, LightInterface):
|
||||
value: int | None = None,
|
||||
*,
|
||||
transition: int | None = None,
|
||||
) -> dict:
|
||||
) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""Set new HSV.
|
||||
|
||||
Note, transition is not supported and will be ignored.
|
||||
@ -164,19 +141,7 @@ class Light(IotModule, LightInterface):
|
||||
return await bulb._set_hsv(hue, saturation, value, transition=transition)
|
||||
|
||||
@property
|
||||
def valid_temperature_range(self) -> ColorTempRange:
|
||||
"""Return the device-specific white temperature range (in Kelvin).
|
||||
|
||||
:return: White temperature range in Kelvin (minimum, maximum)
|
||||
"""
|
||||
if (
|
||||
bulb := self._get_bulb_device()
|
||||
) is None or not bulb._is_variable_color_temp:
|
||||
raise KasaException("Light does not support colortemp.")
|
||||
return bulb._valid_temperature_range
|
||||
|
||||
@property
|
||||
def color_temp(self) -> int:
|
||||
def color_temp(self) -> Annotated[int, FeatureAttribute()]:
|
||||
"""Whether the bulb supports color temperature changes."""
|
||||
if (
|
||||
bulb := self._get_bulb_device()
|
||||
@ -186,7 +151,7 @@ class Light(IotModule, LightInterface):
|
||||
|
||||
async def set_color_temp(
|
||||
self, temp: int, *, brightness: int | None = None, transition: int | None = None
|
||||
) -> dict:
|
||||
) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""Set the color temperature of the device in kelvin.
|
||||
|
||||
Note, transition is not supported and will be ignored.
|
||||
@ -242,17 +207,18 @@ class Light(IotModule, LightInterface):
|
||||
return self._light_state
|
||||
|
||||
async def _post_update_hook(self) -> None:
|
||||
if self._device.is_on is False:
|
||||
device = self._device
|
||||
if device.is_on is False:
|
||||
state = LightState(light_on=False)
|
||||
else:
|
||||
state = LightState(light_on=True)
|
||||
if self.is_dimmable:
|
||||
if device._is_dimmable:
|
||||
state.brightness = self.brightness
|
||||
if self.is_color:
|
||||
if device._is_color:
|
||||
hsv = self.hsv
|
||||
state.hue = hsv.hue
|
||||
state.saturation = hsv.saturation
|
||||
if self.is_variable_color_temp:
|
||||
if device._is_variable_color_temp:
|
||||
state.color_temp = self.color_temp
|
||||
self._light_state = state
|
||||
|
||||
|
@ -12,20 +12,11 @@ class LightEffect(IotModule, LightEffectInterface):
|
||||
|
||||
@property
|
||||
def effect(self) -> str:
|
||||
"""Return effect state.
|
||||
|
||||
Example:
|
||||
{'brightness': 50,
|
||||
'custom': 0,
|
||||
'enable': 0,
|
||||
'id': '',
|
||||
'name': ''}
|
||||
"""
|
||||
"""Return effect name."""
|
||||
eff = self.data["lighting_effect_state"]
|
||||
name = eff["name"]
|
||||
if eff["enable"]:
|
||||
return name
|
||||
|
||||
return name or self.LIGHT_EFFECTS_UNNAMED_CUSTOM
|
||||
return self.LIGHT_EFFECTS_OFF
|
||||
|
||||
@property
|
||||
|
@ -54,7 +54,7 @@ class LightPreset(IotModule, LightPresetInterface):
|
||||
async def _post_update_hook(self) -> None:
|
||||
"""Update the internal presets."""
|
||||
self._presets = {
|
||||
f"Light preset {index+1}": IotLightPreset.from_dict(vals)
|
||||
f"Light preset {index + 1}": IotLightPreset.from_dict(vals)
|
||||
for index, vals in enumerate(self.data["preferred_state"])
|
||||
# Devices may list some light effects along with normal presets but these
|
||||
# are handled by the LightEffect module so exclude preferred states with id
|
||||
@ -85,17 +85,19 @@ class LightPreset(IotModule, LightPresetInterface):
|
||||
def preset(self) -> str:
|
||||
"""Return current preset name."""
|
||||
light = self._device.modules[Module.Light]
|
||||
is_color = light.has_feature("hsv")
|
||||
is_variable_color_temp = light.has_feature("color_temp")
|
||||
|
||||
brightness = light.brightness
|
||||
color_temp = light.color_temp if light.is_variable_color_temp else None
|
||||
h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None)
|
||||
color_temp = light.color_temp if is_variable_color_temp else None
|
||||
|
||||
h, s = (light.hsv.hue, light.hsv.saturation) if is_color else (None, None)
|
||||
for preset_name, preset in self._presets.items():
|
||||
if (
|
||||
preset.brightness == brightness
|
||||
and (
|
||||
preset.color_temp == color_temp or not light.is_variable_color_temp
|
||||
)
|
||||
and (preset.hue == h or not light.is_color)
|
||||
and (preset.saturation == s or not light.is_color)
|
||||
and (preset.color_temp == color_temp or not is_variable_color_temp)
|
||||
and (preset.hue == h or not is_color)
|
||||
and (preset.saturation == s or not is_color)
|
||||
):
|
||||
return preset_name
|
||||
return self.PRESET_NOT_SET
|
||||
@ -107,7 +109,7 @@ class LightPreset(IotModule, LightPresetInterface):
|
||||
"""Set a light preset for the device."""
|
||||
light = self._device.modules[Module.Light]
|
||||
if preset_name == self.PRESET_NOT_SET:
|
||||
if light.is_color:
|
||||
if light.has_feature("hsv"):
|
||||
preset = LightState(hue=0, saturation=0, brightness=100)
|
||||
else:
|
||||
preset = LightState(brightness=100)
|
||||
|
@ -3,11 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from ...exceptions import KasaException
|
||||
from ...feature import Feature
|
||||
from ..iotmodule import IotModule
|
||||
from ..iotmodule import IotModule, merge
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -20,6 +22,71 @@ class Range(Enum):
|
||||
Near = 2
|
||||
Custom = 3
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
@dataclass
|
||||
class PIRConfig:
|
||||
"""Dataclass representing a PIR sensor configuration."""
|
||||
|
||||
enabled: bool
|
||||
adc_min: int
|
||||
adc_max: int
|
||||
range: Range
|
||||
threshold: int
|
||||
|
||||
@property
|
||||
def adc_mid(self) -> int:
|
||||
"""Compute the ADC midpoint from the configured ADC Max and Min values."""
|
||||
return math.floor(abs(self.adc_max - self.adc_min) / 2)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PIRStatus:
|
||||
"""Dataclass representing the current trigger state of an ADC PIR sensor."""
|
||||
|
||||
pir_config: PIRConfig
|
||||
adc_value: int
|
||||
|
||||
@property
|
||||
def pir_value(self) -> int:
|
||||
"""
|
||||
Get the PIR status value in integer form.
|
||||
|
||||
Computes the PIR status value that this object represents,
|
||||
using the given PIR configuration.
|
||||
"""
|
||||
return self.pir_config.adc_mid - self.adc_value
|
||||
|
||||
@property
|
||||
def pir_percent(self) -> float:
|
||||
"""
|
||||
Get the PIR status value in percentile form.
|
||||
|
||||
Computes the PIR status percentage that this object represents,
|
||||
using the given PIR configuration.
|
||||
"""
|
||||
value = self.pir_value
|
||||
divisor = (
|
||||
(self.pir_config.adc_mid - self.pir_config.adc_min)
|
||||
if (value < 0)
|
||||
else (self.pir_config.adc_max - self.pir_config.adc_mid)
|
||||
)
|
||||
return (float(value) / divisor) * 100
|
||||
|
||||
@property
|
||||
def pir_triggered(self) -> bool:
|
||||
"""
|
||||
Get the PIR status trigger state.
|
||||
|
||||
Compute the PIR trigger state this object represents,
|
||||
using the given PIR configuration.
|
||||
"""
|
||||
return (self.pir_config.enabled) and (
|
||||
abs(self.pir_percent) > (100 - self.pir_config.threshold)
|
||||
)
|
||||
|
||||
|
||||
class Motion(IotModule):
|
||||
"""Implements the motion detection (PIR) module."""
|
||||
@ -30,6 +97,11 @@ class Motion(IotModule):
|
||||
if "get_config" not in self.data:
|
||||
return
|
||||
|
||||
# Require that ADC value is also present.
|
||||
if "get_adc_value" not in self.data:
|
||||
_LOGGER.warning("%r initialized, but no get_adc_value in response")
|
||||
return
|
||||
|
||||
if "enable" not in self.config:
|
||||
_LOGGER.warning("%r initialized, but no enable in response")
|
||||
return
|
||||
@ -48,9 +120,143 @@ class Motion(IotModule):
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="pir_range",
|
||||
name="Motion Sensor Range",
|
||||
icon="mdi:motion-sensor",
|
||||
attribute_getter="range",
|
||||
attribute_setter="_set_range_from_str",
|
||||
type=Feature.Type.Choice,
|
||||
choices_getter="ranges",
|
||||
category=Feature.Category.Config,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="pir_threshold",
|
||||
name="Motion Sensor Threshold",
|
||||
icon="mdi:motion-sensor",
|
||||
attribute_getter="threshold",
|
||||
attribute_setter="set_threshold",
|
||||
type=Feature.Type.Number,
|
||||
category=Feature.Category.Config,
|
||||
range_getter=lambda: (0, 100),
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="pir_triggered",
|
||||
name="PIR Triggered",
|
||||
icon="mdi:motion-sensor",
|
||||
attribute_getter="pir_triggered",
|
||||
attribute_setter=None,
|
||||
type=Feature.Type.Sensor,
|
||||
category=Feature.Category.Primary,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="pir_value",
|
||||
name="PIR Value",
|
||||
icon="mdi:motion-sensor",
|
||||
attribute_getter="pir_value",
|
||||
attribute_setter=None,
|
||||
type=Feature.Type.Sensor,
|
||||
category=Feature.Category.Info,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="pir_adc_value",
|
||||
name="PIR ADC Value",
|
||||
icon="mdi:motion-sensor",
|
||||
attribute_getter="adc_value",
|
||||
attribute_setter=None,
|
||||
type=Feature.Type.Sensor,
|
||||
category=Feature.Category.Debug,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="pir_adc_min",
|
||||
name="PIR ADC Min",
|
||||
icon="mdi:motion-sensor",
|
||||
attribute_getter="adc_min",
|
||||
attribute_setter=None,
|
||||
type=Feature.Type.Sensor,
|
||||
category=Feature.Category.Debug,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="pir_adc_mid",
|
||||
name="PIR ADC Mid",
|
||||
icon="mdi:motion-sensor",
|
||||
attribute_getter="adc_mid",
|
||||
attribute_setter=None,
|
||||
type=Feature.Type.Sensor,
|
||||
category=Feature.Category.Debug,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="pir_adc_max",
|
||||
name="PIR ADC Max",
|
||||
icon="mdi:motion-sensor",
|
||||
attribute_getter="adc_max",
|
||||
attribute_setter=None,
|
||||
type=Feature.Type.Sensor,
|
||||
category=Feature.Category.Debug,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
container=self,
|
||||
id="pir_percent",
|
||||
name="PIR Percentile",
|
||||
icon="mdi:motion-sensor",
|
||||
attribute_getter="pir_percent",
|
||||
attribute_setter=None,
|
||||
type=Feature.Type.Sensor,
|
||||
category=Feature.Category.Debug,
|
||||
unit_getter=lambda: "%",
|
||||
)
|
||||
)
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Request PIR configuration."""
|
||||
return self.query_for_command("get_config")
|
||||
req = merge(
|
||||
self.query_for_command("get_config"),
|
||||
self.query_for_command("get_adc_value"),
|
||||
)
|
||||
|
||||
return req
|
||||
|
||||
@property
|
||||
def config(self) -> dict:
|
||||
@ -58,34 +264,103 @@ class Motion(IotModule):
|
||||
return self.data["get_config"]
|
||||
|
||||
@property
|
||||
def range(self) -> Range:
|
||||
"""Return motion detection range."""
|
||||
return Range(self.config["trigger_index"])
|
||||
def pir_config(self) -> PIRConfig:
|
||||
"""Return PIR sensor configuration."""
|
||||
pir_range = Range(self.config["trigger_index"])
|
||||
return PIRConfig(
|
||||
enabled=bool(self.config["enable"]),
|
||||
adc_min=int(self.config["min_adc"]),
|
||||
adc_max=int(self.config["max_adc"]),
|
||||
range=pir_range,
|
||||
threshold=self.get_range_threshold(pir_range),
|
||||
)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Return True if module is enabled."""
|
||||
return bool(self.config["enable"])
|
||||
return self.pir_config.enabled
|
||||
|
||||
@property
|
||||
def adc_min(self) -> int:
|
||||
"""Return minimum ADC sensor value."""
|
||||
return self.pir_config.adc_min
|
||||
|
||||
@property
|
||||
def adc_max(self) -> int:
|
||||
"""Return maximum ADC sensor value."""
|
||||
return self.pir_config.adc_max
|
||||
|
||||
@property
|
||||
def adc_mid(self) -> int:
|
||||
"""
|
||||
Return the midpoint for the ADC.
|
||||
|
||||
The midpoint represents the zero point for the PIR sensor waveform.
|
||||
|
||||
Currently this is estimated by:
|
||||
math.floor(abs(adc_max - adc_min) / 2)
|
||||
"""
|
||||
return self.pir_config.adc_mid
|
||||
|
||||
async def set_enabled(self, state: bool) -> dict:
|
||||
"""Enable/disable PIR."""
|
||||
return await self.call("set_enable", {"enable": int(state)})
|
||||
|
||||
async def set_range(
|
||||
self, *, range: Range | None = None, custom_range: int | None = None
|
||||
) -> dict:
|
||||
"""Set the range for the sensor.
|
||||
@property
|
||||
def ranges(self) -> list[str]:
|
||||
"""Return set of supported range classes."""
|
||||
range_min = 0
|
||||
range_max = len(self.config["array"])
|
||||
valid_ranges = list()
|
||||
for r in Range:
|
||||
if (r.value >= range_min) and (r.value < range_max):
|
||||
valid_ranges.append(r.name)
|
||||
return valid_ranges
|
||||
|
||||
:param range: for using standard ranges
|
||||
:param custom_range: range in decimeters, overrides the range parameter
|
||||
@property
|
||||
def range(self) -> Range:
|
||||
"""Return motion detection Range."""
|
||||
return self.pir_config.range
|
||||
|
||||
async def set_range(self, range: Range) -> dict:
|
||||
"""Set the Range for the sensor.
|
||||
|
||||
:param Range: the range class to use.
|
||||
"""
|
||||
if custom_range is not None:
|
||||
payload = {"index": Range.Custom.value, "value": custom_range}
|
||||
elif range is not None:
|
||||
payload = {"index": range.value}
|
||||
else:
|
||||
raise KasaException("Either range or custom_range need to be defined")
|
||||
payload = {"index": range.value}
|
||||
return await self.call("set_trigger_sens", payload)
|
||||
|
||||
def _parse_range_value(self, value: str) -> Range:
|
||||
"""Attempt to parse a range value from the given string."""
|
||||
value = value.strip().capitalize()
|
||||
try:
|
||||
return Range[value]
|
||||
except KeyError:
|
||||
raise KasaException(
|
||||
f"Invalid range value: '{value}'."
|
||||
f" Valid options are: {Range._member_names_}"
|
||||
) from KeyError
|
||||
|
||||
async def _set_range_from_str(self, input: str) -> dict:
|
||||
value = self._parse_range_value(input)
|
||||
return await self.set_range(range=value)
|
||||
|
||||
def get_range_threshold(self, range_type: Range) -> int:
|
||||
"""Get the distance threshold at which the PIR sensor is will trigger."""
|
||||
if range_type.value < 0 or range_type.value >= len(self.config["array"]):
|
||||
raise KasaException(
|
||||
"Range type is outside the bounds of the configured device ranges."
|
||||
)
|
||||
return int(self.config["array"][range_type.value])
|
||||
|
||||
@property
|
||||
def threshold(self) -> int:
|
||||
"""Return motion detection Range."""
|
||||
return self.pir_config.threshold
|
||||
|
||||
async def set_threshold(self, value: int) -> dict:
|
||||
"""Set the distance threshold at which the PIR sensor is will trigger."""
|
||||
payload = {"index": Range.Custom.value, "value": value}
|
||||
return await self.call("set_trigger_sens", payload)
|
||||
|
||||
@property
|
||||
@ -100,3 +375,34 @@ class Motion(IotModule):
|
||||
to avoid reverting this back to 60 seconds after a period of time.
|
||||
"""
|
||||
return await self.call("set_cold_time", {"cold_time": timeout})
|
||||
|
||||
@property
|
||||
def pir_state(self) -> PIRStatus:
|
||||
"""Return cached PIR status."""
|
||||
return PIRStatus(self.pir_config, self.data["get_adc_value"]["value"])
|
||||
|
||||
async def get_pir_state(self) -> PIRStatus:
|
||||
"""Return real-time PIR status."""
|
||||
latest = await self.call("get_adc_value")
|
||||
self.data["get_adc_value"] = latest
|
||||
return PIRStatus(self.pir_config, latest["value"])
|
||||
|
||||
@property
|
||||
def adc_value(self) -> int:
|
||||
"""Return motion adc value."""
|
||||
return self.pir_state.adc_value
|
||||
|
||||
@property
|
||||
def pir_value(self) -> int:
|
||||
"""Return the computed PIR sensor value."""
|
||||
return self.pir_state.pir_value
|
||||
|
||||
@property
|
||||
def pir_percent(self) -> float:
|
||||
"""Return the computed PIR sensor value, in percentile form."""
|
||||
return self.pir_state.pir_percent
|
||||
|
||||
@property
|
||||
def pir_triggered(self) -> bool:
|
||||
"""Return if the motion sensor has been triggered."""
|
||||
return self.pir_state.pir_triggered
|
||||
|
14
kasa/json.py
14
kasa/json.py
@ -8,18 +8,24 @@ from typing import Any
|
||||
try:
|
||||
import orjson
|
||||
|
||||
def dumps(obj: Any, *, default: Callable | None = None) -> str:
|
||||
def dumps(
|
||||
obj: Any, *, default: Callable | None = None, indent: bool = False
|
||||
) -> str:
|
||||
"""Dump JSON."""
|
||||
return orjson.dumps(obj).decode()
|
||||
return orjson.dumps(
|
||||
obj, option=orjson.OPT_INDENT_2 if indent else None
|
||||
).decode()
|
||||
|
||||
loads = orjson.loads
|
||||
except ImportError:
|
||||
import json
|
||||
|
||||
def dumps(obj: Any, *, default: Callable | None = None) -> str:
|
||||
def dumps(
|
||||
obj: Any, *, default: Callable | None = None, indent: bool = False
|
||||
) -> str:
|
||||
"""Dump JSON."""
|
||||
# Separators specified for consistency with orjson
|
||||
return json.dumps(obj, separators=(",", ":"))
|
||||
return json.dumps(obj, separators=(",", ":"), indent=2 if indent else None)
|
||||
|
||||
loads = json.loads
|
||||
|
||||
|
@ -21,6 +21,9 @@ check for the existence of the module:
|
||||
>>> print(light.brightness)
|
||||
100
|
||||
|
||||
.. include:: ../featureattributes.md
|
||||
:parser: myst_parser.sphinx_
|
||||
|
||||
To see whether a device supports specific functionality, you can check whether the
|
||||
module has that feature:
|
||||
|
||||
@ -78,6 +81,9 @@ ModuleT = TypeVar("ModuleT", bound="Module")
|
||||
class FeatureAttribute:
|
||||
"""Class for annotating attributes bound to feature."""
|
||||
|
||||
def __init__(self, feature_name: str | None = None) -> None:
|
||||
self.feature_name = feature_name
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "FeatureAttribute"
|
||||
|
||||
@ -90,6 +96,8 @@ class Module(ABC):
|
||||
"""
|
||||
|
||||
# Common Modules
|
||||
Alarm: Final[ModuleName[interfaces.Alarm]] = ModuleName("Alarm")
|
||||
ChildSetup: Final[ModuleName[interfaces.ChildSetup]] = ModuleName("ChildSetup")
|
||||
Energy: Final[ModuleName[interfaces.Energy]] = ModuleName("Energy")
|
||||
Fan: Final[ModuleName[interfaces.Fan]] = ModuleName("Fan")
|
||||
LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect")
|
||||
@ -103,13 +111,13 @@ class Module(ABC):
|
||||
IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient")
|
||||
IotAntitheft: Final[ModuleName[iot.Antitheft]] = ModuleName("anti_theft")
|
||||
IotCountdown: Final[ModuleName[iot.Countdown]] = ModuleName("countdown")
|
||||
IotDimmer: Final[ModuleName[iot.Dimmer]] = ModuleName("dimmer")
|
||||
IotMotion: Final[ModuleName[iot.Motion]] = ModuleName("motion")
|
||||
IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule")
|
||||
IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage")
|
||||
IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud")
|
||||
|
||||
# SMART only Modules
|
||||
Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm")
|
||||
AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff")
|
||||
BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor")
|
||||
Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness")
|
||||
@ -149,16 +157,37 @@ class Module(ABC):
|
||||
ChildProtection: Final[ModuleName[smart.ChildProtection]] = ModuleName(
|
||||
"ChildProtection"
|
||||
)
|
||||
ChildLock: Final[ModuleName[smart.ChildLock]] = ModuleName("ChildLock")
|
||||
TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs")
|
||||
PowerProtection: Final[ModuleName[smart.PowerProtection]] = ModuleName(
|
||||
"PowerProtection"
|
||||
)
|
||||
|
||||
HomeKit: Final[ModuleName[smart.HomeKit]] = ModuleName("HomeKit")
|
||||
Matter: Final[ModuleName[smart.Matter]] = ModuleName("Matter")
|
||||
|
||||
# SMARTCAM only modules
|
||||
Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera")
|
||||
LensMask: Final[ModuleName[smartcam.LensMask]] = ModuleName("LensMask")
|
||||
|
||||
# Vacuum modules
|
||||
Clean: Final[ModuleName[smart.Clean]] = ModuleName("Clean")
|
||||
Consumables: Final[ModuleName[smart.Consumables]] = ModuleName("Consumables")
|
||||
Dustbin: Final[ModuleName[smart.Dustbin]] = ModuleName("Dustbin")
|
||||
Speaker: Final[ModuleName[smart.Speaker]] = ModuleName("Speaker")
|
||||
Mop: Final[ModuleName[smart.Mop]] = ModuleName("Mop")
|
||||
CleanRecords: Final[ModuleName[smart.CleanRecords]] = ModuleName("CleanRecords")
|
||||
|
||||
def __init__(self, device: Device, module: str) -> None:
|
||||
self._device = device
|
||||
self._module = module
|
||||
self._module_features: dict[str, Feature] = {}
|
||||
|
||||
@property
|
||||
def device(self) -> Device:
|
||||
"""Return the device exposing the module."""
|
||||
return self._device
|
||||
|
||||
@property
|
||||
def _all_features(self) -> dict[str, Feature]:
|
||||
"""Get the features for this module and any sub modules."""
|
||||
@ -217,7 +246,7 @@ class Module(ABC):
|
||||
)
|
||||
|
||||
|
||||
def _is_bound_feature(attribute: property | Callable) -> bool:
|
||||
def _get_feature_attribute(attribute: property | Callable) -> FeatureAttribute | None:
|
||||
"""Check if an attribute is bound to a feature with FeatureAttribute."""
|
||||
if isinstance(attribute, property):
|
||||
hints = get_type_hints(attribute.fget, include_extras=True)
|
||||
@ -228,9 +257,9 @@ def _is_bound_feature(attribute: property | Callable) -> bool:
|
||||
metadata = hints["return"].__metadata__
|
||||
for meta in metadata:
|
||||
if isinstance(meta, FeatureAttribute):
|
||||
return True
|
||||
return meta
|
||||
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
@cache
|
||||
@ -257,12 +286,17 @@ def _get_bound_feature(
|
||||
f"module {module.__class__.__name__}"
|
||||
)
|
||||
|
||||
if not _is_bound_feature(attribute_callable):
|
||||
if not (fa := _get_feature_attribute(attribute_callable)):
|
||||
raise KasaException(
|
||||
f"Attribute {attribute_name} of module {module.__class__.__name__}"
|
||||
" is not bound to a feature"
|
||||
)
|
||||
|
||||
# If a feature_name was passed to the FeatureAttribute use that to check
|
||||
# for the feature. Otherwise check the getters and setters in the features
|
||||
if fa.feature_name:
|
||||
return module._all_features.get(fa.feature_name)
|
||||
|
||||
check = {attribute_name, attribute_callable}
|
||||
for feature in module._all_features.values():
|
||||
if (getter := feature.attribute_getter) and getter in check:
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
from .iotprotocol import IotProtocol
|
||||
from .protocol import BaseProtocol
|
||||
from .smartcamprotocol import SmartCamProtocol
|
||||
from .smartprotocol import SmartErrorCode, SmartProtocol
|
||||
|
||||
__all__ = [
|
||||
@ -9,4 +10,5 @@ __all__ = [
|
||||
"IotProtocol",
|
||||
"SmartErrorCode",
|
||||
"SmartProtocol",
|
||||
"SmartCamProtocol",
|
||||
]
|
||||
|
@ -25,19 +25,35 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _mask_children(children: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
def mask_child(child: dict[str, Any], index: int) -> dict[str, Any]:
|
||||
result = {
|
||||
**child,
|
||||
"id": f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}",
|
||||
}
|
||||
# Will leave empty aliases as blank
|
||||
if child.get("alias"):
|
||||
result["alias"] = f"#MASKED_NAME# {index + 1}"
|
||||
return result
|
||||
|
||||
return [mask_child(child, index) for index, child in enumerate(children)]
|
||||
|
||||
|
||||
REDACTORS: dict[str, Callable[[Any], Any] | None] = {
|
||||
"latitude": lambda x: 0,
|
||||
"longitude": lambda x: 0,
|
||||
"latitude_i": lambda x: 0,
|
||||
"longitude_i": lambda x: 0,
|
||||
"deviceId": lambda x: "REDACTED_" + x[9::],
|
||||
"id": lambda x: "REDACTED_" + x[9::],
|
||||
"children": _mask_children,
|
||||
"alias": lambda x: "#MASKED_NAME#" if x else "",
|
||||
"mac": mask_mac,
|
||||
"mic_mac": mask_mac,
|
||||
"ssid": lambda x: "#MASKED_SSID#" if x else "",
|
||||
"oemId": lambda x: "REDACTED_" + x[9::],
|
||||
"username": lambda _: "user@example.com", # cnCloud
|
||||
"hwId": lambda x: "REDACTED_" + x[9::],
|
||||
}
|
||||
|
||||
|
||||
@ -82,12 +98,26 @@ class IotProtocol(BaseProtocol):
|
||||
)
|
||||
raise auex
|
||||
except _RetryableError as ex:
|
||||
if retry == 0:
|
||||
_LOGGER.debug(
|
||||
"Device %s got a retryable error, will retry %s times: %s",
|
||||
self._host,
|
||||
retry_count,
|
||||
ex,
|
||||
)
|
||||
await self._transport.reset()
|
||||
if retry >= retry_count:
|
||||
_LOGGER.debug("Giving up on %s after %s retries", self._host, retry)
|
||||
raise ex
|
||||
continue
|
||||
except TimeoutError as ex:
|
||||
if retry == 0:
|
||||
_LOGGER.debug(
|
||||
"Device %s got a timeout error, will retry %s times: %s",
|
||||
self._host,
|
||||
retry_count,
|
||||
ex,
|
||||
)
|
||||
await self._transport.reset()
|
||||
if retry >= retry_count:
|
||||
_LOGGER.debug("Giving up on %s after %s retries", self._host, retry)
|
||||
|
@ -66,6 +66,8 @@ def redact_data(data: _T, redactors: dict[str, Callable[[Any], Any] | None]) ->
|
||||
|
||||
def mask_mac(mac: str) -> str:
|
||||
"""Return mac address with last two octects blanked."""
|
||||
if len(mac) == 12:
|
||||
return f"{mac[:6]}000000"
|
||||
delim = ":" if ":" in mac else "-"
|
||||
rest = delim.join(format(s, "02x") for s in bytes.fromhex("000000"))
|
||||
return f"{mac[:8]}{delim}{rest}"
|
||||
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pprint import pformat as pf
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from ..exceptions import (
|
||||
AuthenticationError,
|
||||
@ -19,7 +19,7 @@ from ..transports.sslaestransport import (
|
||||
SMART_RETRYABLE_ERRORS,
|
||||
SmartErrorCode,
|
||||
)
|
||||
from . import SmartProtocol
|
||||
from .smartprotocol import SmartProtocol
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -49,10 +49,13 @@ class SingleRequest:
|
||||
class SmartCamProtocol(SmartProtocol):
|
||||
"""Class for SmartCam Protocol."""
|
||||
|
||||
async def _handle_response_lists(
|
||||
self, response_result: dict[str, Any], method: str, retry_count: int
|
||||
) -> None:
|
||||
pass
|
||||
def _get_list_request(
|
||||
self, method: str, params: dict | None, start_index: int
|
||||
) -> dict:
|
||||
# All smartcam requests have params
|
||||
params = cast(dict, params)
|
||||
module_name = next(iter(params))
|
||||
return {method: {module_name: {"start_index": start_index}}}
|
||||
|
||||
def _handle_response_error_code(
|
||||
self, resp_dict: dict, method: str, raise_on_error: bool = True
|
||||
@ -147,7 +150,9 @@ class SmartCamProtocol(SmartProtocol):
|
||||
if len(request) == 1 and method in {"get", "set", "do", "multipleRequest"}:
|
||||
single_request = self._get_smart_camera_single_request(request)
|
||||
else:
|
||||
return await self._execute_multiple_query(request, retry_count)
|
||||
return await self._execute_multiple_query(
|
||||
request, retry_count, iterate_list_pages
|
||||
)
|
||||
else:
|
||||
single_request = self._make_smart_camera_single_request(request)
|
||||
|
||||
@ -239,11 +244,15 @@ class _ChildCameraProtocolWrapper(SmartProtocol):
|
||||
|
||||
responses = response["multipleRequest"]["responses"]
|
||||
response_dict = {}
|
||||
|
||||
# Raise errors for single calls
|
||||
raise_on_error = len(requests) == 1
|
||||
|
||||
for index_id, response in enumerate(responses):
|
||||
response_data = response["result"]["response_data"]
|
||||
method = methods[index_id]
|
||||
self._handle_response_error_code(
|
||||
response_data, method, raise_on_error=False
|
||||
response_data, method, raise_on_error=raise_on_error
|
||||
)
|
||||
response_dict[method] = response_data.get("result")
|
||||
|
||||
|
@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
@ -35,6 +36,18 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _mask_area_list(area_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
def mask_area(area: dict[str, Any]) -> dict[str, Any]:
|
||||
result = {**area}
|
||||
# Will leave empty names as blank
|
||||
if area.get("name"):
|
||||
result["name"] = "I01BU0tFRF9OQU1FIw==" # #MASKED_NAME#
|
||||
return result
|
||||
|
||||
return [mask_area(area) for area in area_list]
|
||||
|
||||
|
||||
REDACTORS: dict[str, Callable[[Any], Any] | None] = {
|
||||
"latitude": lambda x: 0,
|
||||
"longitude": lambda x: 0,
|
||||
@ -45,15 +58,42 @@ REDACTORS: dict[str, Callable[[Any], Any] | None] = {
|
||||
"original_device_id": lambda x: "REDACTED_" + x[9::], # Strip children
|
||||
"nickname": lambda x: "I01BU0tFRF9OQU1FIw==" if x else "",
|
||||
"mac": mask_mac,
|
||||
"ssid": lambda x: "I01BU0tFRF9TU0lEIw=" if x else "",
|
||||
"ssid": lambda x: "I01BU0tFRF9TU0lEIw==" if x else "",
|
||||
"bssid": lambda _: "000000000000",
|
||||
"channel": lambda _: 0,
|
||||
"oem_id": lambda x: "REDACTED_" + x[9::],
|
||||
"setup_code": None, # matter
|
||||
"setup_payload": None, # matter
|
||||
"mfi_setup_code": None, # mfi_ for homekit
|
||||
"mfi_setup_id": None,
|
||||
"mfi_token_token": None,
|
||||
"mfi_token_uuid": None,
|
||||
"hw_id": lambda x: "REDACTED_" + x[9::],
|
||||
"fw_id": lambda x: "REDACTED_" + x[9::],
|
||||
"setup_code": lambda x: re.sub(r"\w", "0", x), # matter
|
||||
"setup_payload": lambda x: re.sub(r"\w", "0", x), # matter
|
||||
"mfi_setup_code": lambda x: re.sub(r"\w", "0", x), # mfi_ for homekit
|
||||
"mfi_setup_id": lambda x: re.sub(r"\w", "0", x),
|
||||
"mfi_token_token": lambda x: re.sub(r"\w", "0", x),
|
||||
"mfi_token_uuid": lambda x: re.sub(r"\w", "0", x),
|
||||
"ip": lambda x: x, # don't redact but keep listed here for dump_devinfo
|
||||
# smartcam
|
||||
"dev_id": lambda x: "REDACTED_" + x[9::],
|
||||
"ext_addr": lambda x: "REDACTED_" + x[9::],
|
||||
"device_name": lambda x: "#MASKED_NAME#" if x else "",
|
||||
"device_alias": lambda x: "#MASKED_NAME#" if x else "",
|
||||
"alias": lambda x: "#MASKED_NAME#" if x else "", # child info on parent uses alias
|
||||
"local_ip": lambda x: x, # don't redact but keep listed here for dump_devinfo
|
||||
# robovac
|
||||
"board_sn": lambda _: "000000000000",
|
||||
"custom_sn": lambda _: "000000000000",
|
||||
"location": lambda x: "#MASKED_NAME#" if x else "",
|
||||
"map_data": lambda x: "#SCRUBBED_MAPDATA#" if x else "",
|
||||
"map_name": lambda x: "I01BU0tFRF9OQU1FIw==", # #MASKED_NAME#
|
||||
"area_list": _mask_area_list,
|
||||
# unknown robovac binary blob in get_device_info
|
||||
"cd": lambda x: "I01BU0tFRF9CSU5BUlkj", # #MASKED_BINARY#
|
||||
}
|
||||
|
||||
# Queries that are known not to work properly when sent as a
|
||||
# multiRequest. They will not return the `method` key.
|
||||
FORCE_SINGLE_REQUEST = {
|
||||
"getConnectStatus",
|
||||
"scanApList",
|
||||
}
|
||||
|
||||
|
||||
@ -76,6 +116,7 @@ class SmartProtocol(BaseProtocol):
|
||||
self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE
|
||||
)
|
||||
self._redact_data = True
|
||||
self._method_missing_logged = False
|
||||
|
||||
def get_smart_request(self, method: str, params: dict | None = None) -> str:
|
||||
"""Get a request message as a string."""
|
||||
@ -157,22 +198,25 @@ class SmartProtocol(BaseProtocol):
|
||||
# make mypy happy, this should never be reached..
|
||||
raise KasaException("Query reached somehow to unreachable")
|
||||
|
||||
async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dict:
|
||||
async def _execute_multiple_query(
|
||||
self, requests: dict, retry_count: int, iterate_list_pages: bool
|
||||
) -> dict:
|
||||
debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)
|
||||
multi_result: dict[str, Any] = {}
|
||||
smart_method = "multipleRequest"
|
||||
|
||||
multi_requests = [
|
||||
{"method": method, "params": params} if params else {"method": method}
|
||||
for method, params in requests.items()
|
||||
]
|
||||
|
||||
end = len(multi_requests)
|
||||
end = len(requests)
|
||||
# The SmartCamProtocol sends requests with a length 1 as a
|
||||
# multipleRequest. The SmartProtocol doesn't so will never
|
||||
# raise_on_error
|
||||
raise_on_error = end == 1
|
||||
|
||||
multi_requests = [
|
||||
{"method": method, "params": params} if params else {"method": method}
|
||||
for method, params in requests.items()
|
||||
if method not in FORCE_SINGLE_REQUEST
|
||||
]
|
||||
|
||||
# Break the requests down as there can be a size limit
|
||||
step = self._multi_request_batch_size
|
||||
if step == 1:
|
||||
@ -192,7 +236,7 @@ class SmartProtocol(BaseProtocol):
|
||||
|
||||
smart_params = {"requests": requests_step}
|
||||
smart_request = self.get_smart_request(smart_method, smart_params)
|
||||
batch_name = f"multi-request-batch-{batch_num+1}-of-{int(end/step)+1}"
|
||||
batch_name = f"multi-request-batch-{batch_num + 1}-of-{int(end / step) + 1}"
|
||||
if debug_enabled:
|
||||
_LOGGER.debug(
|
||||
"%s %s >> %s",
|
||||
@ -233,22 +277,41 @@ class SmartProtocol(BaseProtocol):
|
||||
|
||||
responses = response_step["result"]["responses"]
|
||||
for response in responses:
|
||||
method = response["method"]
|
||||
# some smartcam devices calls do not populate the method key
|
||||
# these should be defined in DO_NOT_SEND_AS_MULTI_REQUEST.
|
||||
if not (method := response.get("method")):
|
||||
if not self._method_missing_logged:
|
||||
# Avoid spamming the logs
|
||||
self._method_missing_logged = True
|
||||
_LOGGER.error(
|
||||
"No method key in response for %s, skipping: %s",
|
||||
self._host,
|
||||
response_step,
|
||||
)
|
||||
# These will end up being queried individually
|
||||
continue
|
||||
|
||||
self._handle_response_error_code(
|
||||
response, method, raise_on_error=raise_on_error
|
||||
)
|
||||
result = response.get("result", None)
|
||||
await self._handle_response_lists(
|
||||
result, method, retry_count=retry_count
|
||||
)
|
||||
request_params = rp if (rp := requests.get(method)) else None
|
||||
if iterate_list_pages and result:
|
||||
await self._handle_response_lists(
|
||||
result, method, request_params, retry_count=retry_count
|
||||
)
|
||||
multi_result[method] = result
|
||||
# Multi requests don't continue after errors so requery any missing
|
||||
|
||||
# Multi requests don't continue after errors so requery any missing.
|
||||
# Will also query individually any DO_NOT_SEND_AS_MULTI_REQUEST.
|
||||
for method, params in requests.items():
|
||||
if method not in multi_result:
|
||||
resp = await self._transport.send(
|
||||
self.get_smart_request(method, params)
|
||||
)
|
||||
self._handle_response_error_code(resp, method, raise_on_error=False)
|
||||
self._handle_response_error_code(
|
||||
resp, method, raise_on_error=raise_on_error
|
||||
)
|
||||
multi_result[method] = resp.get("result")
|
||||
return multi_result
|
||||
|
||||
@ -262,7 +325,9 @@ class SmartProtocol(BaseProtocol):
|
||||
smart_method = next(iter(request))
|
||||
smart_params = request[smart_method]
|
||||
else:
|
||||
return await self._execute_multiple_query(request, retry_count)
|
||||
return await self._execute_multiple_query(
|
||||
request, retry_count, iterate_list_pages
|
||||
)
|
||||
else:
|
||||
smart_method = request
|
||||
smart_params = None
|
||||
@ -289,12 +354,21 @@ class SmartProtocol(BaseProtocol):
|
||||
result = response_data.get("result")
|
||||
if iterate_list_pages and result:
|
||||
await self._handle_response_lists(
|
||||
result, smart_method, retry_count=retry_count
|
||||
result, smart_method, smart_params, retry_count=retry_count
|
||||
)
|
||||
return {smart_method: result}
|
||||
|
||||
def _get_list_request(
|
||||
self, method: str, params: dict | None, start_index: int
|
||||
) -> dict:
|
||||
return {method: {"start_index": start_index}}
|
||||
|
||||
async def _handle_response_lists(
|
||||
self, response_result: dict[str, Any], method: str, retry_count: int
|
||||
self,
|
||||
response_result: dict[str, Any],
|
||||
method: str,
|
||||
params: dict | None,
|
||||
retry_count: int,
|
||||
) -> None:
|
||||
if (
|
||||
response_result is None
|
||||
@ -314,8 +388,9 @@ class SmartProtocol(BaseProtocol):
|
||||
)
|
||||
)
|
||||
while (list_length := len(response_result[response_list_name])) < list_sum:
|
||||
request = self._get_list_request(method, params, list_length)
|
||||
response = await self._execute_query(
|
||||
{method: {"start_index": list_length}},
|
||||
request,
|
||||
retry_count=retry_count,
|
||||
iterate_list_pages=False,
|
||||
)
|
||||
|
@ -6,16 +6,23 @@ from .autooff import AutoOff
|
||||
from .batterysensor import BatterySensor
|
||||
from .brightness import Brightness
|
||||
from .childdevice import ChildDevice
|
||||
from .childlock import ChildLock
|
||||
from .childprotection import ChildProtection
|
||||
from .childsetup import ChildSetup
|
||||
from .clean import Clean
|
||||
from .cleanrecords import CleanRecords
|
||||
from .cloud import Cloud
|
||||
from .color import Color
|
||||
from .colortemperature import ColorTemperature
|
||||
from .consumables import Consumables
|
||||
from .contactsensor import ContactSensor
|
||||
from .devicemodule import DeviceModule
|
||||
from .dustbin import Dustbin
|
||||
from .energy import Energy
|
||||
from .fan import Fan
|
||||
from .firmware import Firmware
|
||||
from .frostprotection import FrostProtection
|
||||
from .homekit import HomeKit
|
||||
from .humiditysensor import HumiditySensor
|
||||
from .led import Led
|
||||
from .light import Light
|
||||
@ -23,8 +30,13 @@ from .lighteffect import LightEffect
|
||||
from .lightpreset import LightPreset
|
||||
from .lightstripeffect import LightStripEffect
|
||||
from .lighttransition import LightTransition
|
||||
from .matter import Matter
|
||||
from .mop import Mop
|
||||
from .motionsensor import MotionSensor
|
||||
from .overheatprotection import OverheatProtection
|
||||
from .powerprotection import PowerProtection
|
||||
from .reportmode import ReportMode
|
||||
from .speaker import Speaker
|
||||
from .temperaturecontrol import TemperatureControl
|
||||
from .temperaturesensor import TemperatureSensor
|
||||
from .thermostat import Thermostat
|
||||
@ -38,6 +50,8 @@ __all__ = [
|
||||
"Energy",
|
||||
"DeviceModule",
|
||||
"ChildDevice",
|
||||
"ChildLock",
|
||||
"ChildSetup",
|
||||
"BatterySensor",
|
||||
"HumiditySensor",
|
||||
"TemperatureSensor",
|
||||
@ -63,5 +77,15 @@ __all__ = [
|
||||
"TriggerLogs",
|
||||
"FrostProtection",
|
||||
"Thermostat",
|
||||
"Clean",
|
||||
"Consumables",
|
||||
"CleanRecords",
|
||||
"SmartLightEffect",
|
||||
"PowerProtection",
|
||||
"OverheatProtection",
|
||||
"Speaker",
|
||||
"HomeKit",
|
||||
"Matter",
|
||||
"Dustbin",
|
||||
"Mop",
|
||||
]
|
||||
|
@ -2,13 +2,30 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from typing import TYPE_CHECKING, Annotated, Literal, TypeAlias
|
||||
|
||||
from ...feature import Feature
|
||||
from ...interfaces import Alarm as AlarmInterface
|
||||
from ...module import FeatureAttribute
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
DURATION_MAX = 10 * 60
|
||||
|
||||
class Alarm(SmartModule):
|
||||
VOLUME_INT_TO_STR = {
|
||||
0: "mute",
|
||||
1: "low",
|
||||
2: "normal",
|
||||
3: "high",
|
||||
}
|
||||
|
||||
VOLUME_STR_LIST = [v for v in VOLUME_INT_TO_STR.values()]
|
||||
VOLUME_INT_RANGE = (min(VOLUME_INT_TO_STR.keys()), max(VOLUME_INT_TO_STR.keys()))
|
||||
VOLUME_STR_TO_INT = {v: k for k, v in VOLUME_INT_TO_STR.items()}
|
||||
|
||||
AlarmVolume: TypeAlias = Literal["mute", "low", "normal", "high"]
|
||||
|
||||
|
||||
class Alarm(SmartModule, AlarmInterface):
|
||||
"""Implementation of alarm module."""
|
||||
|
||||
REQUIRED_COMPONENT = "alarm"
|
||||
@ -21,10 +38,7 @@ class Alarm(SmartModule):
|
||||
}
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features.
|
||||
|
||||
This is implemented as some features depend on device responses.
|
||||
"""
|
||||
"""Initialize features."""
|
||||
device = self._device
|
||||
self._add_feature(
|
||||
Feature(
|
||||
@ -67,11 +81,37 @@ class Alarm(SmartModule):
|
||||
id="alarm_volume",
|
||||
name="Alarm volume",
|
||||
container=self,
|
||||
attribute_getter="alarm_volume",
|
||||
attribute_getter="_alarm_volume_str",
|
||||
attribute_setter="set_alarm_volume",
|
||||
category=Feature.Category.Config,
|
||||
type=Feature.Type.Choice,
|
||||
choices_getter=lambda: ["low", "normal", "high"],
|
||||
choices_getter=lambda: VOLUME_STR_LIST,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device,
|
||||
id="alarm_volume_level",
|
||||
name="Alarm volume",
|
||||
container=self,
|
||||
attribute_getter="alarm_volume",
|
||||
attribute_setter="set_alarm_volume",
|
||||
category=Feature.Category.Config,
|
||||
type=Feature.Type.Number,
|
||||
range_getter=lambda: VOLUME_INT_RANGE,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device,
|
||||
id="alarm_duration",
|
||||
name="Alarm duration",
|
||||
container=self,
|
||||
attribute_getter="alarm_duration",
|
||||
attribute_setter="set_alarm_duration",
|
||||
category=Feature.Category.Config,
|
||||
type=Feature.Type.Number,
|
||||
range_getter=lambda: (1, DURATION_MAX),
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
@ -96,15 +136,16 @@ class Alarm(SmartModule):
|
||||
)
|
||||
|
||||
@property
|
||||
def alarm_sound(self) -> str:
|
||||
def alarm_sound(self) -> Annotated[str, FeatureAttribute()]:
|
||||
"""Return current alarm sound."""
|
||||
return self.data["get_alarm_configure"]["type"]
|
||||
|
||||
async def set_alarm_sound(self, sound: str) -> dict:
|
||||
async def set_alarm_sound(self, sound: str) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""Set alarm sound.
|
||||
|
||||
See *alarm_sounds* for list of available sounds.
|
||||
"""
|
||||
self._check_sound(sound)
|
||||
payload = self.data["get_alarm_configure"].copy()
|
||||
payload["type"] = sound
|
||||
return await self.call("set_alarm_configure", payload)
|
||||
@ -115,16 +156,40 @@ class Alarm(SmartModule):
|
||||
return self.data["get_support_alarm_type_list"]["alarm_type_list"]
|
||||
|
||||
@property
|
||||
def alarm_volume(self) -> Literal["low", "normal", "high"]:
|
||||
def alarm_volume(self) -> Annotated[int, FeatureAttribute("alarm_volume_level")]:
|
||||
"""Return alarm volume."""
|
||||
return VOLUME_STR_TO_INT[self._alarm_volume_str]
|
||||
|
||||
@property
|
||||
def _alarm_volume_str(
|
||||
self,
|
||||
) -> Annotated[AlarmVolume, FeatureAttribute("alarm_volume")]:
|
||||
"""Return alarm volume."""
|
||||
return self.data["get_alarm_configure"]["volume"]
|
||||
|
||||
async def set_alarm_volume(self, volume: Literal["low", "normal", "high"]) -> dict:
|
||||
async def set_alarm_volume(
|
||||
self, volume: AlarmVolume | int
|
||||
) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""Set alarm volume."""
|
||||
self._check_and_convert_volume(volume)
|
||||
payload = self.data["get_alarm_configure"].copy()
|
||||
payload["volume"] = volume
|
||||
return await self.call("set_alarm_configure", payload)
|
||||
|
||||
@property
|
||||
def alarm_duration(self) -> Annotated[int, FeatureAttribute()]:
|
||||
"""Return alarm duration."""
|
||||
return self.data["get_alarm_configure"]["duration"]
|
||||
|
||||
async def set_alarm_duration(
|
||||
self, duration: int
|
||||
) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""Set alarm duration."""
|
||||
self._check_duration(duration)
|
||||
payload = self.data["get_alarm_configure"].copy()
|
||||
payload["duration"] = duration
|
||||
return await self.call("set_alarm_configure", payload)
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
"""Return true if alarm is active."""
|
||||
@ -136,10 +201,62 @@ class Alarm(SmartModule):
|
||||
src = self._device.sys_info["in_alarm_source"]
|
||||
return src if src else None
|
||||
|
||||
async def play(self) -> dict:
|
||||
"""Play alarm."""
|
||||
return await self.call("play_alarm")
|
||||
async def play(
|
||||
self,
|
||||
*,
|
||||
duration: int | None = None,
|
||||
volume: int | AlarmVolume | None = None,
|
||||
sound: str | None = None,
|
||||
) -> dict:
|
||||
"""Play alarm.
|
||||
|
||||
The optional *duration*, *volume*, and *sound* to override the device settings.
|
||||
*volume* can be set to 'mute', 'low', 'normal', or 'high'.
|
||||
*duration* is in seconds.
|
||||
See *alarm_sounds* for the list of sounds available for the device.
|
||||
"""
|
||||
params: dict[str, str | int] = {}
|
||||
|
||||
if duration is not None:
|
||||
self._check_duration(duration)
|
||||
params["alarm_duration"] = duration
|
||||
|
||||
if volume is not None:
|
||||
target_volume = self._check_and_convert_volume(volume)
|
||||
params["alarm_volume"] = target_volume
|
||||
|
||||
if sound is not None:
|
||||
self._check_sound(sound)
|
||||
params["alarm_type"] = sound
|
||||
|
||||
return await self.call("play_alarm", params)
|
||||
|
||||
async def stop(self) -> dict:
|
||||
"""Stop alarm."""
|
||||
return await self.call("stop_alarm")
|
||||
|
||||
def _check_and_convert_volume(self, volume: str | int) -> str:
|
||||
"""Raise an exception on invalid volume."""
|
||||
if isinstance(volume, int):
|
||||
volume = VOLUME_INT_TO_STR.get(volume, "invalid")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(volume, str)
|
||||
|
||||
if volume not in VOLUME_INT_TO_STR.values():
|
||||
raise ValueError(
|
||||
f"Invalid volume {volume} "
|
||||
f"available: {VOLUME_INT_TO_STR.keys()}, {VOLUME_INT_TO_STR.values()}"
|
||||
)
|
||||
|
||||
return volume
|
||||
|
||||
def _check_duration(self, duration: int) -> None:
|
||||
"""Raise an exception on invalid duration."""
|
||||
if duration < 1 or duration > DURATION_MAX:
|
||||
raise ValueError(f"Invalid duration {duration} available: 1-600")
|
||||
|
||||
def _check_sound(self, sound: str) -> None:
|
||||
"""Raise an exception on invalid sound."""
|
||||
if sound not in self.alarm_sounds:
|
||||
raise ValueError(f"Invalid sound {sound} available: {self.alarm_sounds}")
|
||||
|
@ -2,7 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from ...exceptions import KasaException
|
||||
from ...feature import Feature
|
||||
from ...module import FeatureAttribute
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
|
||||
@ -14,18 +18,22 @@ class BatterySensor(SmartModule):
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
"battery_low",
|
||||
"Battery low",
|
||||
container=self,
|
||||
attribute_getter="battery_low",
|
||||
icon="mdi:alert",
|
||||
type=Feature.Type.BinarySensor,
|
||||
category=Feature.Category.Debug,
|
||||
if (
|
||||
"at_low_battery" in self._device.sys_info
|
||||
or "is_low" in self._device.sys_info
|
||||
):
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
"battery_low",
|
||||
"Battery low",
|
||||
container=self,
|
||||
attribute_getter="battery_low",
|
||||
icon="mdi:alert",
|
||||
type=Feature.Type.BinarySensor,
|
||||
category=Feature.Category.Debug,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Some devices, like T110 contact sensor do not report the battery percentage
|
||||
if "battery_percentage" in self._device.sys_info:
|
||||
@ -48,11 +56,17 @@ class BatterySensor(SmartModule):
|
||||
return {}
|
||||
|
||||
@property
|
||||
def battery(self) -> int:
|
||||
def battery(self) -> Annotated[int, FeatureAttribute()]:
|
||||
"""Return battery level."""
|
||||
return self._device.sys_info["battery_percentage"]
|
||||
|
||||
@property
|
||||
def battery_low(self) -> bool:
|
||||
def battery_low(self) -> Annotated[bool, FeatureAttribute()]:
|
||||
"""Return True if battery is low."""
|
||||
return self._device.sys_info["at_low_battery"]
|
||||
is_low = self._device.sys_info.get(
|
||||
"at_low_battery", self._device.sys_info.get("is_low")
|
||||
)
|
||||
if is_low is None:
|
||||
raise KasaException("Device does not report battery low status")
|
||||
|
||||
return is_low
|
||||
|
@ -38,6 +38,7 @@ Plug 3: False
|
||||
True
|
||||
"""
|
||||
|
||||
from ...device_type import DeviceType
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
|
||||
@ -46,3 +47,10 @@ class ChildDevice(SmartModule):
|
||||
|
||||
REQUIRED_COMPONENT = "child_device"
|
||||
QUERY_GETTER_NAME = "get_child_device_list"
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
q = super().query()
|
||||
if self._device.device_type is DeviceType.Hub:
|
||||
q["get_child_device_component_list"] = None
|
||||
return q
|
||||
|
37
kasa/smart/modules/childlock.py
Normal file
37
kasa/smart/modules/childlock.py
Normal file
@ -0,0 +1,37 @@
|
||||
"""Child lock module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ...feature import Feature
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
|
||||
class ChildLock(SmartModule):
|
||||
"""Implementation for child lock."""
|
||||
|
||||
REQUIRED_COMPONENT = "button_and_led"
|
||||
QUERY_GETTER_NAME = "getChildLockInfo"
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features after the initial update."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
id="child_lock",
|
||||
name="Child lock",
|
||||
container=self,
|
||||
attribute_getter="enabled",
|
||||
attribute_setter="set_enabled",
|
||||
type=Feature.Type.Switch,
|
||||
category=Feature.Category.Config,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Return True if child lock is enabled."""
|
||||
return self.data["child_lock_status"]
|
||||
|
||||
async def set_enabled(self, enabled: bool) -> dict:
|
||||
"""Set child lock."""
|
||||
return await self.call("setChildLockInfo", {"child_lock_status": enabled})
|
112
kasa/smart/modules/childsetup.py
Normal file
112
kasa/smart/modules/childsetup.py
Normal file
@ -0,0 +1,112 @@
|
||||
"""Implementation for child device setup.
|
||||
|
||||
This module allows pairing and disconnecting child devices.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from ...feature import Feature
|
||||
from ...interfaces.childsetup import ChildSetup as ChildSetupInterface
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChildSetup(SmartModule, ChildSetupInterface):
|
||||
"""Implementation for child device setup."""
|
||||
|
||||
REQUIRED_COMPONENT = "child_quick_setup"
|
||||
QUERY_GETTER_NAME = "get_support_child_device_category"
|
||||
_categories: list[str] = []
|
||||
|
||||
# Supported child device categories will hardly ever change
|
||||
MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="pair",
|
||||
name="Pair",
|
||||
container=self,
|
||||
attribute_setter="pair",
|
||||
category=Feature.Category.Config,
|
||||
type=Feature.Type.Action,
|
||||
)
|
||||
)
|
||||
|
||||
async def _post_update_hook(self) -> None:
|
||||
self._categories = [
|
||||
cat["category"] for cat in self.data["device_category_list"]
|
||||
]
|
||||
|
||||
@property
|
||||
def supported_categories(self) -> list[str]:
|
||||
"""Supported child device categories."""
|
||||
return self._categories
|
||||
|
||||
async def pair(self, *, timeout: int = 10) -> list[dict]:
|
||||
"""Scan for new devices and pair them."""
|
||||
await self.call("begin_scanning_child_device")
|
||||
|
||||
_LOGGER.info("Waiting %s seconds for discovering new devices", timeout)
|
||||
await asyncio.sleep(timeout)
|
||||
detected = await self._get_detected_devices()
|
||||
|
||||
if not detected["child_device_list"]:
|
||||
_LOGGER.warning(
|
||||
"No devices found, make sure to activate pairing "
|
||||
"mode on the devices to be added."
|
||||
)
|
||||
return []
|
||||
|
||||
_LOGGER.info(
|
||||
"Discovery done, found %s devices: %s",
|
||||
len(detected["child_device_list"]),
|
||||
detected,
|
||||
)
|
||||
|
||||
return await self._add_devices(detected)
|
||||
|
||||
async def unpair(self, device_id: str) -> dict:
|
||||
"""Remove device from the hub."""
|
||||
_LOGGER.info("Going to unpair %s from %s", device_id, self)
|
||||
|
||||
payload = {"child_device_list": [{"device_id": device_id}]}
|
||||
res = await self.call("remove_child_device_list", payload)
|
||||
await self._device.update()
|
||||
return res
|
||||
|
||||
async def _add_devices(self, devices: dict) -> list[dict]:
|
||||
"""Add devices based on get_detected_device response.
|
||||
|
||||
Pass the output from :ref:_get_detected_devices: as a parameter.
|
||||
"""
|
||||
await self.call("add_child_device_list", devices)
|
||||
|
||||
await self._device.update()
|
||||
|
||||
successes = []
|
||||
for detected in devices["child_device_list"]:
|
||||
device_id = detected["device_id"]
|
||||
|
||||
result = "not added"
|
||||
if device_id in self._device._children:
|
||||
result = "added"
|
||||
successes.append(detected)
|
||||
|
||||
msg = f"{detected['device_model']} - {device_id} - {result}"
|
||||
_LOGGER.info("Added child to %s: %s", self._device.host, msg)
|
||||
|
||||
return successes
|
||||
|
||||
async def _get_detected_devices(self) -> dict:
|
||||
"""Return list of devices detected during scanning."""
|
||||
param = {"scan_list": self.data["device_category_list"]}
|
||||
res = await self.call("get_scan_child_device_list", param)
|
||||
_LOGGER.debug("Scan status: %s", res)
|
||||
return res["get_scan_child_device_list"]
|
411
kasa/smart/modules/clean.py
Normal file
411
kasa/smart/modules/clean.py
Normal file
@ -0,0 +1,411 @@
|
||||
"""Implementation of vacuum clean module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from enum import IntEnum
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from ...feature import Feature
|
||||
from ...module import FeatureAttribute
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Status(IntEnum):
|
||||
"""Status of vacuum."""
|
||||
|
||||
Idle = 0
|
||||
Cleaning = 1
|
||||
Mapping = 2
|
||||
GoingHome = 4
|
||||
Charging = 5
|
||||
Charged = 6
|
||||
Paused = 7
|
||||
Undocked = 8
|
||||
Error = 100
|
||||
|
||||
UnknownInternal = -1000
|
||||
|
||||
|
||||
class ErrorCode(IntEnum):
|
||||
"""Error codes for vacuum."""
|
||||
|
||||
Ok = 0
|
||||
SideBrushStuck = 2
|
||||
MainBrushStuck = 3
|
||||
WheelBlocked = 4
|
||||
Trapped = 6
|
||||
TrappedCliff = 7
|
||||
DustBinRemoved = 14
|
||||
UnableToMove = 15
|
||||
LidarBlocked = 16
|
||||
UnableToFindDock = 21
|
||||
BatteryLow = 22
|
||||
|
||||
UnknownInternal = -1000
|
||||
|
||||
|
||||
class FanSpeed(IntEnum):
|
||||
"""Fan speed level."""
|
||||
|
||||
Quiet = 1
|
||||
Standard = 2
|
||||
Turbo = 3
|
||||
Max = 4
|
||||
Ultra = 5
|
||||
|
||||
|
||||
class AreaUnit(IntEnum):
|
||||
"""Area unit."""
|
||||
|
||||
#: Square meter
|
||||
Sqm = 0
|
||||
#: Square feet
|
||||
Sqft = 1
|
||||
#: Taiwanese unit: https://en.wikipedia.org/wiki/Taiwanese_units_of_measurement#Area
|
||||
Ping = 2
|
||||
|
||||
|
||||
class Clean(SmartModule):
|
||||
"""Implementation of vacuum clean module."""
|
||||
|
||||
REQUIRED_COMPONENT = "clean"
|
||||
_error_code = ErrorCode.Ok
|
||||
_logged_error_code_warnings: set | None = None
|
||||
_logged_status_code_warnings: set
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="vacuum_return_home",
|
||||
name="Return home",
|
||||
container=self,
|
||||
attribute_setter="return_home",
|
||||
category=Feature.Category.Primary,
|
||||
type=Feature.Action,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="vacuum_start",
|
||||
name="Start cleaning",
|
||||
container=self,
|
||||
attribute_setter="start",
|
||||
category=Feature.Category.Primary,
|
||||
type=Feature.Action,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="vacuum_pause",
|
||||
name="Pause",
|
||||
container=self,
|
||||
attribute_setter="pause",
|
||||
category=Feature.Category.Primary,
|
||||
type=Feature.Action,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="vacuum_status",
|
||||
name="Vacuum status",
|
||||
container=self,
|
||||
attribute_getter="status",
|
||||
category=Feature.Category.Primary,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="vacuum_error",
|
||||
name="Error",
|
||||
container=self,
|
||||
attribute_getter="error",
|
||||
category=Feature.Category.Info,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="battery_level",
|
||||
name="Battery level",
|
||||
container=self,
|
||||
attribute_getter="battery",
|
||||
icon="mdi:battery",
|
||||
unit_getter=lambda: "%",
|
||||
category=Feature.Category.Info,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="vacuum_fan_speed",
|
||||
name="Fan speed",
|
||||
container=self,
|
||||
attribute_getter="fan_speed_preset",
|
||||
attribute_setter="set_fan_speed_preset",
|
||||
icon="mdi:fan",
|
||||
choices_getter=lambda: list(FanSpeed.__members__),
|
||||
category=Feature.Category.Primary,
|
||||
type=Feature.Type.Choice,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="clean_count",
|
||||
name="Clean count",
|
||||
container=self,
|
||||
attribute_getter="clean_count",
|
||||
attribute_setter="set_clean_count",
|
||||
range_getter=lambda: (1, 3),
|
||||
category=Feature.Category.Config,
|
||||
type=Feature.Type.Number,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="carpet_boost",
|
||||
name="Carpet boost",
|
||||
container=self,
|
||||
attribute_getter="carpet_boost",
|
||||
attribute_setter="set_carpet_boost",
|
||||
icon="mdi:rug",
|
||||
category=Feature.Category.Config,
|
||||
type=Feature.Type.Switch,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="clean_area",
|
||||
name="Cleaning area",
|
||||
container=self,
|
||||
attribute_getter="clean_area",
|
||||
unit_getter="area_unit",
|
||||
category=Feature.Category.Info,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="clean_time",
|
||||
name="Cleaning time",
|
||||
container=self,
|
||||
attribute_getter="clean_time",
|
||||
category=Feature.Category.Info,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="clean_progress",
|
||||
name="Cleaning progress",
|
||||
container=self,
|
||||
attribute_getter="clean_progress",
|
||||
unit_getter=lambda: "%",
|
||||
category=Feature.Category.Info,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
|
||||
async def _post_update_hook(self) -> None:
|
||||
"""Set error code after update."""
|
||||
if self._logged_error_code_warnings is None:
|
||||
self._logged_error_code_warnings = set()
|
||||
self._logged_status_code_warnings = set()
|
||||
|
||||
errors = self._vac_status.get("err_status")
|
||||
if errors is None or not errors:
|
||||
self._error_code = ErrorCode.Ok
|
||||
return
|
||||
|
||||
if len(errors) > 1 and "multiple" not in self._logged_error_code_warnings:
|
||||
self._logged_error_code_warnings.add("multiple")
|
||||
_LOGGER.warning(
|
||||
"Multiple error codes, using the first one only: %s", errors
|
||||
)
|
||||
|
||||
error = errors.pop(0)
|
||||
try:
|
||||
self._error_code = ErrorCode(error)
|
||||
except ValueError:
|
||||
if error not in self._logged_error_code_warnings:
|
||||
self._logged_error_code_warnings.add(error)
|
||||
_LOGGER.warning(
|
||||
"Unknown error code, please create an issue "
|
||||
"describing the error: %s",
|
||||
error,
|
||||
)
|
||||
self._error_code = ErrorCode.UnknownInternal
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
return {
|
||||
"getVacStatus": {},
|
||||
"getCleanInfo": {},
|
||||
"getCarpetClean": {},
|
||||
"getAreaUnit": {},
|
||||
"getBatteryInfo": {},
|
||||
"getCleanStatus": {},
|
||||
"getCleanAttr": {"type": "global"},
|
||||
}
|
||||
|
||||
async def start(self) -> dict:
|
||||
"""Start cleaning."""
|
||||
# If we are paused, do not restart cleaning
|
||||
|
||||
if self.status is Status.Paused:
|
||||
return await self.resume()
|
||||
|
||||
return await self.call(
|
||||
"setSwitchClean",
|
||||
{
|
||||
"clean_mode": 0,
|
||||
"clean_on": True,
|
||||
"clean_order": True,
|
||||
"force_clean": False,
|
||||
},
|
||||
)
|
||||
|
||||
async def pause(self) -> dict:
|
||||
"""Pause cleaning."""
|
||||
if self.status is Status.GoingHome:
|
||||
return await self.set_return_home(False)
|
||||
|
||||
return await self.set_pause(True)
|
||||
|
||||
async def resume(self) -> dict:
|
||||
"""Resume cleaning."""
|
||||
return await self.set_pause(False)
|
||||
|
||||
async def set_pause(self, enabled: bool) -> dict:
|
||||
"""Pause or resume cleaning."""
|
||||
return await self.call("setRobotPause", {"pause": enabled})
|
||||
|
||||
async def return_home(self) -> dict:
|
||||
"""Return home."""
|
||||
return await self.set_return_home(True)
|
||||
|
||||
async def set_return_home(self, enabled: bool) -> dict:
|
||||
"""Return home / pause returning."""
|
||||
return await self.call("setSwitchCharge", {"switch_charge": enabled})
|
||||
|
||||
@property
|
||||
def error(self) -> ErrorCode:
|
||||
"""Return error."""
|
||||
return self._error_code
|
||||
|
||||
@property
|
||||
def fan_speed_preset(self) -> Annotated[str, FeatureAttribute()]:
|
||||
"""Return fan speed preset."""
|
||||
return FanSpeed(self._settings["suction"]).name
|
||||
|
||||
async def set_fan_speed_preset(
|
||||
self, speed: str
|
||||
) -> Annotated[dict, FeatureAttribute]:
|
||||
"""Set fan speed preset."""
|
||||
name_to_value = {x.name: x.value for x in FanSpeed}
|
||||
if speed not in name_to_value:
|
||||
raise ValueError("Invalid fan speed %s, available %s", speed, name_to_value)
|
||||
return await self._change_setting("suction", name_to_value[speed])
|
||||
|
||||
async def _change_setting(
|
||||
self, name: str, value: int, *, scope: Literal["global", "pose"] = "global"
|
||||
) -> dict:
|
||||
"""Change device setting."""
|
||||
params = {
|
||||
name: value,
|
||||
"type": scope,
|
||||
}
|
||||
return await self.call("setCleanAttr", params)
|
||||
|
||||
@property
|
||||
def battery(self) -> int:
|
||||
"""Return battery level."""
|
||||
return self.data["getBatteryInfo"]["battery_percentage"]
|
||||
|
||||
@property
|
||||
def _vac_status(self) -> dict:
|
||||
"""Return vac status container."""
|
||||
return self.data["getVacStatus"]
|
||||
|
||||
@property
|
||||
def _info(self) -> dict:
|
||||
"""Return current cleaning info."""
|
||||
return self.data["getCleanInfo"]
|
||||
|
||||
@property
|
||||
def _settings(self) -> dict:
|
||||
"""Return cleaning settings."""
|
||||
return self.data["getCleanAttr"]
|
||||
|
||||
@property
|
||||
def status(self) -> Status:
|
||||
"""Return current status."""
|
||||
if self._error_code is not ErrorCode.Ok:
|
||||
return Status.Error
|
||||
|
||||
status_code = self._vac_status["status"]
|
||||
try:
|
||||
return Status(status_code)
|
||||
except ValueError:
|
||||
if status_code not in self._logged_status_code_warnings:
|
||||
self._logged_status_code_warnings.add(status_code)
|
||||
_LOGGER.warning(
|
||||
"Got unknown status code: %s (%s)", status_code, self.data
|
||||
)
|
||||
return Status.UnknownInternal
|
||||
|
||||
@property
|
||||
def carpet_boost(self) -> bool:
|
||||
"""Return carpet boost mode."""
|
||||
return self.data["getCarpetClean"]["carpet_clean_prefer"] == "boost"
|
||||
|
||||
async def set_carpet_boost(self, on: bool) -> dict:
|
||||
"""Set carpet clean mode."""
|
||||
mode = "boost" if on else "normal"
|
||||
return await self.call("setCarpetClean", {"carpet_clean_prefer": mode})
|
||||
|
||||
@property
|
||||
def area_unit(self) -> AreaUnit:
|
||||
"""Return area unit."""
|
||||
return AreaUnit(self.data["getAreaUnit"]["area_unit"])
|
||||
|
||||
@property
|
||||
def clean_area(self) -> Annotated[int, FeatureAttribute()]:
|
||||
"""Return currently cleaned area."""
|
||||
return self._info["clean_area"]
|
||||
|
||||
@property
|
||||
def clean_time(self) -> timedelta:
|
||||
"""Return current cleaning time."""
|
||||
return timedelta(minutes=self._info["clean_time"])
|
||||
|
||||
@property
|
||||
def clean_progress(self) -> int:
|
||||
"""Return amount of currently cleaned area."""
|
||||
return self._info["clean_percent"]
|
||||
|
||||
@property
|
||||
def clean_count(self) -> Annotated[int, FeatureAttribute()]:
|
||||
"""Return number of times to clean."""
|
||||
return self._settings["clean_number"]
|
||||
|
||||
async def set_clean_count(self, count: int) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""Set number of times to clean."""
|
||||
return await self._change_setting("clean_number", count)
|
205
kasa/smart/modules/cleanrecords.py
Normal file
205
kasa/smart/modules/cleanrecords.py
Normal file
@ -0,0 +1,205 @@
|
||||
"""Implementation of vacuum cleaning records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, tzinfo
|
||||
from typing import Annotated, cast
|
||||
|
||||
from mashumaro import DataClassDictMixin, field_options
|
||||
from mashumaro.config import ADD_DIALECT_SUPPORT
|
||||
from mashumaro.dialect import Dialect
|
||||
from mashumaro.types import SerializationStrategy
|
||||
|
||||
from ...feature import Feature
|
||||
from ...module import FeatureAttribute
|
||||
from ..smartmodule import Module, SmartModule
|
||||
from .clean import AreaUnit, Clean
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Record(DataClassDictMixin):
|
||||
"""Historical cleanup result."""
|
||||
|
||||
class Config:
|
||||
"""Configuration class."""
|
||||
|
||||
code_generation_options = [ADD_DIALECT_SUPPORT]
|
||||
|
||||
#: Total time cleaned (in minutes)
|
||||
clean_time: timedelta = field(
|
||||
metadata=field_options(deserialize=lambda x: timedelta(minutes=x))
|
||||
)
|
||||
#: Total area cleaned
|
||||
clean_area: int
|
||||
dust_collection: bool
|
||||
timestamp: datetime
|
||||
|
||||
info_num: int | None = None
|
||||
message: int | None = None
|
||||
map_id: int | None = None
|
||||
start_type: int | None = None
|
||||
task_type: int | None = None
|
||||
record_index: int | None = None
|
||||
|
||||
#: Error code from cleaning
|
||||
error: int = field(default=0)
|
||||
|
||||
|
||||
class _DateTimeSerializationStrategy(SerializationStrategy):
|
||||
def __init__(self, tz: tzinfo) -> None:
|
||||
self.tz = tz
|
||||
|
||||
def deserialize(self, value: float) -> datetime:
|
||||
return datetime.fromtimestamp(value, self.tz)
|
||||
|
||||
|
||||
def _get_tz_strategy(tz: tzinfo) -> type[Dialect]:
|
||||
"""Return a timezone aware de-serialization strategy."""
|
||||
|
||||
class TimezoneDialect(Dialect):
|
||||
serialization_strategy = {datetime: _DateTimeSerializationStrategy(tz)}
|
||||
|
||||
return TimezoneDialect
|
||||
|
||||
|
||||
@dataclass
|
||||
class Records(DataClassDictMixin):
|
||||
"""Response payload for getCleanRecords."""
|
||||
|
||||
class Config:
|
||||
"""Configuration class."""
|
||||
|
||||
code_generation_options = [ADD_DIALECT_SUPPORT]
|
||||
|
||||
total_time: timedelta = field(
|
||||
metadata=field_options(deserialize=lambda x: timedelta(minutes=x))
|
||||
)
|
||||
total_area: int
|
||||
total_count: int = field(metadata=field_options(alias="total_number"))
|
||||
|
||||
records: list[Record] = field(metadata=field_options(alias="record_list"))
|
||||
last_clean: Record = field(metadata=field_options(alias="lastest_day_record"))
|
||||
|
||||
@classmethod
|
||||
def __pre_deserialize__(cls, d: dict) -> dict:
|
||||
if ldr := d.get("lastest_day_record"):
|
||||
d["lastest_day_record"] = {
|
||||
"timestamp": ldr[0],
|
||||
"clean_time": ldr[1],
|
||||
"clean_area": ldr[2],
|
||||
"dust_collection": ldr[3],
|
||||
}
|
||||
return d
|
||||
|
||||
|
||||
class CleanRecords(SmartModule):
|
||||
"""Implementation of vacuum cleaning records."""
|
||||
|
||||
REQUIRED_COMPONENT = "clean_percent"
|
||||
_parsed_data: Records
|
||||
|
||||
async def _post_update_hook(self) -> None:
|
||||
"""Cache parsed data after an update."""
|
||||
self._parsed_data = Records.from_dict(
|
||||
self.data, dialect=_get_tz_strategy(self._device.timezone)
|
||||
)
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features."""
|
||||
for type_ in ["total", "last"]:
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id=f"{type_}_clean_area",
|
||||
name=f"{type_.capitalize()} area cleaned",
|
||||
container=self,
|
||||
attribute_getter=f"{type_}_clean_area",
|
||||
unit_getter="area_unit",
|
||||
category=Feature.Category.Debug,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id=f"{type_}_clean_time",
|
||||
name=f"{type_.capitalize()} time cleaned",
|
||||
container=self,
|
||||
attribute_getter=f"{type_}_clean_time",
|
||||
category=Feature.Category.Debug,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="total_clean_count",
|
||||
name="Total clean count",
|
||||
container=self,
|
||||
attribute_getter="total_clean_count",
|
||||
category=Feature.Category.Debug,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="last_clean_timestamp",
|
||||
name="Last clean timestamp",
|
||||
container=self,
|
||||
attribute_getter="last_clean_timestamp",
|
||||
category=Feature.Category.Debug,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
return {
|
||||
"getCleanRecords": {},
|
||||
}
|
||||
|
||||
@property
|
||||
def total_clean_area(self) -> Annotated[int, FeatureAttribute()]:
|
||||
"""Return total cleaning area."""
|
||||
return self._parsed_data.total_area
|
||||
|
||||
@property
|
||||
def total_clean_time(self) -> timedelta:
|
||||
"""Return total cleaning time."""
|
||||
return self._parsed_data.total_time
|
||||
|
||||
@property
|
||||
def total_clean_count(self) -> int:
|
||||
"""Return total clean count."""
|
||||
return self._parsed_data.total_count
|
||||
|
||||
@property
|
||||
def last_clean_area(self) -> Annotated[int, FeatureAttribute()]:
|
||||
"""Return latest cleaning area."""
|
||||
return self._parsed_data.last_clean.clean_area
|
||||
|
||||
@property
|
||||
def last_clean_time(self) -> timedelta:
|
||||
"""Return total cleaning time."""
|
||||
return self._parsed_data.last_clean.clean_time
|
||||
|
||||
@property
|
||||
def last_clean_timestamp(self) -> datetime:
|
||||
"""Return latest cleaning timestamp."""
|
||||
return self._parsed_data.last_clean.timestamp
|
||||
|
||||
@property
|
||||
def area_unit(self) -> AreaUnit:
|
||||
"""Return area unit."""
|
||||
clean = cast(Clean, self._device.modules[Module.Clean])
|
||||
return clean.area_unit
|
||||
|
||||
@property
|
||||
def parsed_data(self) -> Records:
|
||||
"""Return parsed records data."""
|
||||
return self._parsed_data
|
170
kasa/smart/modules/consumables.py
Normal file
170
kasa/smart/modules/consumables.py
Normal file
@ -0,0 +1,170 @@
|
||||
"""Implementation of vacuum consumables."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from ...feature import Feature
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ConsumableMeta:
|
||||
"""Consumable meta container."""
|
||||
|
||||
#: Name of the consumable.
|
||||
name: str
|
||||
#: Internal id of the consumable
|
||||
id: str
|
||||
#: Data key in the device reported data
|
||||
data_key: str
|
||||
#: Lifetime
|
||||
lifetime: timedelta
|
||||
|
||||
|
||||
@dataclass
|
||||
class Consumable:
|
||||
"""Consumable container."""
|
||||
|
||||
#: Name of the consumable.
|
||||
name: str
|
||||
#: Id of the consumable
|
||||
id: str
|
||||
#: Lifetime
|
||||
lifetime: timedelta
|
||||
#: Used
|
||||
used: timedelta
|
||||
#: Remaining
|
||||
remaining: timedelta
|
||||
#: Device data key
|
||||
_data_key: str
|
||||
|
||||
|
||||
CONSUMABLE_METAS = [
|
||||
_ConsumableMeta(
|
||||
"Main brush",
|
||||
id="main_brush",
|
||||
data_key="roll_brush_time",
|
||||
lifetime=timedelta(hours=400),
|
||||
),
|
||||
_ConsumableMeta(
|
||||
"Side brush",
|
||||
id="side_brush",
|
||||
data_key="edge_brush_time",
|
||||
lifetime=timedelta(hours=200),
|
||||
),
|
||||
_ConsumableMeta(
|
||||
"Filter",
|
||||
id="filter",
|
||||
data_key="filter_time",
|
||||
lifetime=timedelta(hours=200),
|
||||
),
|
||||
_ConsumableMeta(
|
||||
"Sensor",
|
||||
id="sensor",
|
||||
data_key="sensor_time",
|
||||
lifetime=timedelta(hours=30),
|
||||
),
|
||||
_ConsumableMeta(
|
||||
"Charging contacts",
|
||||
id="charging_contacts",
|
||||
data_key="charge_contact_time",
|
||||
lifetime=timedelta(hours=30),
|
||||
),
|
||||
# Unknown keys: main_brush_lid_time, rag_time
|
||||
]
|
||||
|
||||
|
||||
class Consumables(SmartModule):
|
||||
"""Implementation of vacuum consumables."""
|
||||
|
||||
REQUIRED_COMPONENT = "consumables"
|
||||
QUERY_GETTER_NAME = "getConsumablesInfo"
|
||||
|
||||
_consumables: dict[str, Consumable] = {}
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features."""
|
||||
for c_meta in CONSUMABLE_METAS:
|
||||
if c_meta.data_key not in self.data:
|
||||
continue
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id=f"{c_meta.id}_used",
|
||||
name=f"{c_meta.name} used",
|
||||
container=self,
|
||||
attribute_getter=lambda _, c_id=c_meta.id: self._consumables[
|
||||
c_id
|
||||
].used,
|
||||
category=Feature.Category.Debug,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id=f"{c_meta.id}_remaining",
|
||||
name=f"{c_meta.name} remaining",
|
||||
container=self,
|
||||
attribute_getter=lambda _, c_id=c_meta.id: self._consumables[
|
||||
c_id
|
||||
].remaining,
|
||||
category=Feature.Category.Info,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id=f"{c_meta.id}_reset",
|
||||
name=f"Reset {c_meta.name.lower()} consumable",
|
||||
container=self,
|
||||
attribute_setter=lambda c_id=c_meta.id: self.reset_consumable(c_id),
|
||||
category=Feature.Category.Debug,
|
||||
type=Feature.Type.Action,
|
||||
)
|
||||
)
|
||||
|
||||
async def _post_update_hook(self) -> None:
|
||||
"""Update the consumables."""
|
||||
if not self._consumables:
|
||||
for consumable_meta in CONSUMABLE_METAS:
|
||||
if consumable_meta.data_key not in self.data:
|
||||
continue
|
||||
used = timedelta(minutes=self.data[consumable_meta.data_key])
|
||||
consumable = Consumable(
|
||||
id=consumable_meta.id,
|
||||
name=consumable_meta.name,
|
||||
lifetime=consumable_meta.lifetime,
|
||||
used=used,
|
||||
remaining=consumable_meta.lifetime - used,
|
||||
_data_key=consumable_meta.data_key,
|
||||
)
|
||||
self._consumables[consumable_meta.id] = consumable
|
||||
else:
|
||||
for consumable in self._consumables.values():
|
||||
consumable.used = timedelta(minutes=self.data[consumable._data_key])
|
||||
consumable.remaining = consumable.lifetime - consumable.used
|
||||
|
||||
async def reset_consumable(self, consumable_id: str) -> dict:
|
||||
"""Reset consumable stats."""
|
||||
consumable_name = self._consumables[consumable_id]._data_key.removesuffix(
|
||||
"_time"
|
||||
)
|
||||
return await self.call(
|
||||
"resetConsumablesTime", {"reset_list": [consumable_name]}
|
||||
)
|
||||
|
||||
@property
|
||||
def consumables(self) -> Mapping[str, Consumable]:
|
||||
"""Get list of consumables on the device."""
|
||||
return self._consumables
|
@ -10,7 +10,7 @@ class ContactSensor(SmartModule):
|
||||
"""Implementation of contact sensor module."""
|
||||
|
||||
REQUIRED_COMPONENT = None # we depend on availability of key
|
||||
REQUIRED_KEY_ON_PARENT = "open"
|
||||
SYSINFO_LOOKUP_KEYS = ["open"]
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features after the initial update."""
|
||||
|
@ -19,12 +19,15 @@ class DeviceModule(SmartModule):
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
if self._device._is_hub_child:
|
||||
# Child devices get their device info updated by the parent device.
|
||||
return {}
|
||||
query = {
|
||||
"get_device_info": None,
|
||||
}
|
||||
# Device usage is not available on older firmware versions
|
||||
# or child devices of hubs
|
||||
if self.supported_version >= 2 and not self._device._is_hub_child:
|
||||
if self.supported_version >= 2:
|
||||
query["get_device_usage"] = None
|
||||
|
||||
return query
|
||||
|
127
kasa/smart/modules/dustbin.py
Normal file
127
kasa/smart/modules/dustbin.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""Implementation of vacuum dustbin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from enum import IntEnum
|
||||
|
||||
from ...feature import Feature
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Mode(IntEnum):
|
||||
"""Dust collection modes."""
|
||||
|
||||
Smart = 0
|
||||
Light = 1
|
||||
Balanced = 2
|
||||
Max = 3
|
||||
|
||||
Off = -1_000
|
||||
|
||||
|
||||
class Dustbin(SmartModule):
|
||||
"""Implementation of vacuum dustbin."""
|
||||
|
||||
REQUIRED_COMPONENT = "dust_bucket"
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="dustbin_empty",
|
||||
name="Empty dustbin",
|
||||
container=self,
|
||||
attribute_setter="start_emptying",
|
||||
category=Feature.Category.Config,
|
||||
type=Feature.Action,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="dustbin_autocollection_enabled",
|
||||
name="Automatic emptying enabled",
|
||||
container=self,
|
||||
attribute_getter="auto_collection",
|
||||
attribute_setter="set_auto_collection",
|
||||
category=Feature.Category.Config,
|
||||
type=Feature.Switch,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="dustbin_mode",
|
||||
name="Automatic emptying mode",
|
||||
container=self,
|
||||
attribute_getter="mode",
|
||||
attribute_setter="set_mode",
|
||||
icon="mdi:fan",
|
||||
choices_getter=lambda: list(Mode.__members__),
|
||||
category=Feature.Category.Config,
|
||||
type=Feature.Type.Choice,
|
||||
)
|
||||
)
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
return {
|
||||
"getAutoDustCollection": {},
|
||||
"getDustCollectionInfo": {},
|
||||
}
|
||||
|
||||
async def start_emptying(self) -> dict:
|
||||
"""Start emptying the bin."""
|
||||
return await self.call(
|
||||
"setSwitchDustCollection",
|
||||
{
|
||||
"switch_dust_collection": True,
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
def _settings(self) -> dict:
|
||||
"""Return auto-empty settings."""
|
||||
return self.data["getDustCollectionInfo"]
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
"""Return auto-emptying mode."""
|
||||
if self.auto_collection is False:
|
||||
return Mode.Off.name
|
||||
return Mode(self._settings["dust_collection_mode"]).name
|
||||
|
||||
async def set_mode(self, mode: str) -> dict:
|
||||
"""Set auto-emptying mode."""
|
||||
name_to_value = {x.name: x.value for x in Mode}
|
||||
if mode not in name_to_value:
|
||||
raise ValueError(
|
||||
"Invalid auto/emptying mode speed %s, available %s", mode, name_to_value
|
||||
)
|
||||
|
||||
if mode == Mode.Off.name:
|
||||
return await self.set_auto_collection(False)
|
||||
|
||||
# Make a copy just in case, even when we are overriding both settings
|
||||
settings = self._settings.copy()
|
||||
settings["auto_dust_collection"] = True
|
||||
settings["dust_collection_mode"] = name_to_value[mode]
|
||||
|
||||
return await self.call("setDustCollectionInfo", settings)
|
||||
|
||||
@property
|
||||
def auto_collection(self) -> dict:
|
||||
"""Return auto-emptying config."""
|
||||
return self._settings["auto_dust_collection"]
|
||||
|
||||
async def set_auto_collection(self, on: bool) -> dict:
|
||||
"""Toggle auto-emptying."""
|
||||
settings = self._settings.copy()
|
||||
settings["auto_dust_collection"] = on
|
||||
return await self.call("setDustCollectionInfo", settings)
|
@ -2,10 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NoReturn
|
||||
from typing import Any, NoReturn
|
||||
|
||||
from ...emeterstatus import EmeterStatus
|
||||
from ...exceptions import KasaException
|
||||
from ...exceptions import DeviceError, KasaException
|
||||
from ...interfaces.energy import Energy as EnergyInterface
|
||||
from ..smartmodule import SmartModule, raise_if_update_error
|
||||
|
||||
@ -15,12 +15,39 @@ class Energy(SmartModule, EnergyInterface):
|
||||
|
||||
REQUIRED_COMPONENT = "energy_monitoring"
|
||||
|
||||
_energy: dict[str, Any]
|
||||
_current_consumption: float | None
|
||||
|
||||
async def _post_update_hook(self) -> None:
|
||||
if "voltage_mv" in self.data.get("get_emeter_data", {}):
|
||||
try:
|
||||
data = self.data
|
||||
except DeviceError as de:
|
||||
self._energy = {}
|
||||
self._current_consumption = None
|
||||
raise de
|
||||
|
||||
# If version is 1 then data is get_energy_usage
|
||||
self._energy = data.get("get_energy_usage", data)
|
||||
|
||||
if "voltage_mv" in data.get("get_emeter_data", {}):
|
||||
self._supported = (
|
||||
self._supported | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT
|
||||
)
|
||||
|
||||
if (power := self._energy.get("current_power")) is not None or (
|
||||
power := data.get("get_emeter_data", {}).get("power_mw")
|
||||
) is not None:
|
||||
self._current_consumption = power / 1_000
|
||||
# Fallback if get_energy_usage does not provide current_power,
|
||||
# which can happen on some newer devices (e.g. P304M).
|
||||
# This may not be valid scenario as it pre-dates trying get_emeter_data
|
||||
elif (
|
||||
power := self.data.get("get_current_power", {}).get("current_power")
|
||||
) is not None:
|
||||
self._current_consumption = power
|
||||
else:
|
||||
self._current_consumption = None
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
req = {
|
||||
@ -33,28 +60,21 @@ class Energy(SmartModule, EnergyInterface):
|
||||
return req
|
||||
|
||||
@property
|
||||
@raise_if_update_error
|
||||
def current_consumption(self) -> float | None:
|
||||
"""Current power in watts."""
|
||||
if (power := self.energy.get("current_power")) is not None or (
|
||||
power := self.data.get("get_emeter_data", {}).get("power_mw")
|
||||
) is not None:
|
||||
return power / 1_000
|
||||
# Fallback if get_energy_usage does not provide current_power,
|
||||
# which can happen on some newer devices (e.g. P304M).
|
||||
elif (
|
||||
power := self.data.get("get_current_power", {}).get("current_power")
|
||||
) is not None:
|
||||
return power
|
||||
return None
|
||||
def optional_response_keys(self) -> list[str]:
|
||||
"""Return optional response keys for the module."""
|
||||
if self.supported_version > 1:
|
||||
return ["get_energy_usage"]
|
||||
return []
|
||||
|
||||
@property
|
||||
def current_consumption(self) -> float | None:
|
||||
"""Current power in watts."""
|
||||
return self._current_consumption
|
||||
|
||||
@property
|
||||
@raise_if_update_error
|
||||
def energy(self) -> dict:
|
||||
"""Return get_energy_usage results."""
|
||||
if en := self.data.get("get_energy_usage"):
|
||||
return en
|
||||
return self.data
|
||||
return self._energy
|
||||
|
||||
def _get_status_from_energy(self, energy: dict) -> EmeterStatus:
|
||||
return EmeterStatus(
|
||||
@ -83,16 +103,18 @@ class Energy(SmartModule, EnergyInterface):
|
||||
return self._get_status_from_energy(res["get_energy_usage"])
|
||||
|
||||
@property
|
||||
@raise_if_update_error
|
||||
def consumption_this_month(self) -> float | None:
|
||||
"""Get the emeter value for this month in kWh."""
|
||||
return self.energy.get("month_energy", 0) / 1_000
|
||||
if (month := self.energy.get("month_energy")) is not None:
|
||||
return month / 1_000
|
||||
return None
|
||||
|
||||
@property
|
||||
@raise_if_update_error
|
||||
def consumption_today(self) -> float | None:
|
||||
"""Get the emeter value for today in kWh."""
|
||||
return self.energy.get("today_energy", 0) / 1_000
|
||||
if (today := self.energy.get("today_energy")) is not None:
|
||||
return today / 1_000
|
||||
return None
|
||||
|
||||
@property
|
||||
@raise_if_update_error
|
||||
@ -104,15 +126,17 @@ class Energy(SmartModule, EnergyInterface):
|
||||
@raise_if_update_error
|
||||
def current(self) -> float | None:
|
||||
"""Return the current in A."""
|
||||
ma = self.data.get("get_emeter_data", {}).get("current_ma")
|
||||
return ma / 1000 if ma else None
|
||||
if (ma := self.data.get("get_emeter_data", {}).get("current_ma")) is not None:
|
||||
return ma / 1_000
|
||||
return None
|
||||
|
||||
@property
|
||||
@raise_if_update_error
|
||||
def voltage(self) -> float | None:
|
||||
"""Get the current voltage in V."""
|
||||
mv = self.data.get("get_emeter_data", {}).get("voltage_mv")
|
||||
return mv / 1000 if mv else None
|
||||
if (mv := self.data.get("get_emeter_data", {}).get("voltage_mv")) is not None:
|
||||
return mv / 1_000
|
||||
return None
|
||||
|
||||
async def _deprecated_get_realtime(self) -> EmeterStatus:
|
||||
"""Retrieve current energy readings."""
|
||||
|
32
kasa/smart/modules/homekit.py
Normal file
32
kasa/smart/modules/homekit.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""Implementation of homekit module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ...feature import Feature
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
|
||||
class HomeKit(SmartModule):
|
||||
"""Implementation of homekit module."""
|
||||
|
||||
QUERY_GETTER_NAME: str = "get_homekit_info"
|
||||
REQUIRED_COMPONENT = "homekit"
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features after the initial update."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="homekit_setup_code",
|
||||
name="Homekit setup code",
|
||||
container=self,
|
||||
attribute_getter=lambda x: x.info["mfi_setup_code"],
|
||||
type=Feature.Type.Sensor,
|
||||
category=Feature.Category.Debug,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def info(self) -> dict[str, str]:
|
||||
"""Homekit mfi setup info."""
|
||||
return self.data
|
@ -7,7 +7,7 @@ from typing import Annotated
|
||||
|
||||
from ...exceptions import KasaException
|
||||
from ...feature import Feature
|
||||
from ...interfaces.light import HSV, ColorTempRange, LightState
|
||||
from ...interfaces.light import HSV, LightState
|
||||
from ...interfaces.light import Light as LightInterface
|
||||
from ...module import FeatureAttribute, Module
|
||||
from ..smartmodule import SmartModule
|
||||
@ -34,39 +34,13 @@ class Light(SmartModule, LightInterface):
|
||||
"""Query to execute during the update cycle."""
|
||||
return {}
|
||||
|
||||
@property
|
||||
def is_color(self) -> bool:
|
||||
"""Whether the bulb supports color changes."""
|
||||
return Module.Color in self._device.modules
|
||||
|
||||
@property
|
||||
def is_dimmable(self) -> bool:
|
||||
"""Whether the bulb supports brightness changes."""
|
||||
return Module.Brightness in self._device.modules
|
||||
|
||||
@property
|
||||
def is_variable_color_temp(self) -> bool:
|
||||
"""Whether the bulb supports color temperature changes."""
|
||||
return Module.ColorTemperature in self._device.modules
|
||||
|
||||
@property
|
||||
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 KasaException("Color temperature not supported")
|
||||
|
||||
return self._device.modules[Module.ColorTemperature].valid_temperature_range
|
||||
|
||||
@property
|
||||
def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
|
||||
"""Return the current HSV state of the bulb.
|
||||
|
||||
:return: hue, saturation and value (degrees, %, %)
|
||||
"""
|
||||
if not self.is_color:
|
||||
if Module.Color not in self._device.modules:
|
||||
raise KasaException("Bulb does not support color.")
|
||||
|
||||
return self._device.modules[Module.Color].hsv
|
||||
@ -74,7 +48,7 @@ class Light(SmartModule, LightInterface):
|
||||
@property
|
||||
def color_temp(self) -> Annotated[int, FeatureAttribute()]:
|
||||
"""Whether the bulb supports color temperature changes."""
|
||||
if not self.is_variable_color_temp:
|
||||
if Module.ColorTemperature not in self._device.modules:
|
||||
raise KasaException("Bulb does not support colortemp.")
|
||||
|
||||
return self._device.modules[Module.ColorTemperature].color_temp
|
||||
@ -82,7 +56,7 @@ class Light(SmartModule, LightInterface):
|
||||
@property
|
||||
def brightness(self) -> Annotated[int, FeatureAttribute()]:
|
||||
"""Return the current brightness in percentage."""
|
||||
if not self.is_dimmable: # pragma: no cover
|
||||
if Module.Brightness not in self._device.modules: # pragma: no cover
|
||||
raise KasaException("Bulb is not dimmable.")
|
||||
|
||||
return self._device.modules[Module.Brightness].brightness
|
||||
@ -104,7 +78,7 @@ class Light(SmartModule, LightInterface):
|
||||
:param int value: value between 1 and 100
|
||||
:param int transition: transition in milliseconds.
|
||||
"""
|
||||
if not self.is_color:
|
||||
if Module.Color not in self._device.modules:
|
||||
raise KasaException("Bulb does not support color.")
|
||||
|
||||
return await self._device.modules[Module.Color].set_hsv(hue, saturation, value)
|
||||
@ -119,7 +93,7 @@ class Light(SmartModule, LightInterface):
|
||||
:param int temp: The new color temperature, in Kelvin
|
||||
:param int transition: transition in milliseconds.
|
||||
"""
|
||||
if not self.is_variable_color_temp:
|
||||
if Module.ColorTemperature not in self._device.modules:
|
||||
raise KasaException("Bulb does not support colortemp.")
|
||||
return await self._device.modules[Module.ColorTemperature].set_color_temp(
|
||||
temp, brightness=brightness
|
||||
@ -135,16 +109,11 @@ class Light(SmartModule, LightInterface):
|
||||
:param int brightness: brightness in percent
|
||||
:param int transition: transition in milliseconds.
|
||||
"""
|
||||
if not self.is_dimmable: # pragma: no cover
|
||||
if Module.Brightness not in self._device.modules: # pragma: no cover
|
||||
raise KasaException("Bulb is not dimmable.")
|
||||
|
||||
return await self._device.modules[Module.Brightness].set_brightness(brightness)
|
||||
|
||||
@property
|
||||
def has_effects(self) -> bool:
|
||||
"""Return True if the device supports effects."""
|
||||
return Module.LightEffect in self._device.modules
|
||||
|
||||
async def set_state(self, state: LightState) -> dict:
|
||||
"""Set the light state."""
|
||||
state_dict = asdict(state)
|
||||
@ -167,16 +136,17 @@ class Light(SmartModule, LightInterface):
|
||||
return self._light_state
|
||||
|
||||
async def _post_update_hook(self) -> None:
|
||||
if self._device.is_on is False:
|
||||
device = self._device
|
||||
if device.is_on is False:
|
||||
state = LightState(light_on=False)
|
||||
else:
|
||||
state = LightState(light_on=True)
|
||||
if self.is_dimmable:
|
||||
if Module.Brightness in device.modules:
|
||||
state.brightness = self.brightness
|
||||
if self.is_color:
|
||||
if Module.Color in device.modules:
|
||||
hsv = self.hsv
|
||||
state.hue = hsv.hue
|
||||
state.saturation = hsv.saturation
|
||||
if self.is_variable_color_temp:
|
||||
if Module.ColorTemperature in device.modules:
|
||||
state.color_temp = self.color_temp
|
||||
self._light_state = state
|
||||
|
@ -96,13 +96,18 @@ class LightPreset(SmartModule, LightPresetInterface):
|
||||
"""Return current preset name."""
|
||||
light = self._device.modules[SmartModule.Light]
|
||||
brightness = light.brightness
|
||||
color_temp = light.color_temp if light.is_variable_color_temp else None
|
||||
h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None)
|
||||
color_temp = light.color_temp if light.has_feature("color_temp") else None
|
||||
h, s = (
|
||||
(light.hsv.hue, light.hsv.saturation)
|
||||
if light.has_feature("hsv")
|
||||
else (None, None)
|
||||
)
|
||||
for preset_name, preset in self._presets.items():
|
||||
if (
|
||||
preset.brightness == brightness
|
||||
and (
|
||||
preset.color_temp == color_temp or not light.is_variable_color_temp
|
||||
preset.color_temp == color_temp
|
||||
or not light.has_feature("color_temp")
|
||||
)
|
||||
and preset.hue == h
|
||||
and preset.saturation == s
|
||||
@ -117,7 +122,7 @@ class LightPreset(SmartModule, LightPresetInterface):
|
||||
"""Set a light preset for the device."""
|
||||
light = self._device.modules[SmartModule.Light]
|
||||
if preset_name == self.PRESET_NOT_SET:
|
||||
if light.is_color:
|
||||
if light.has_feature("hsv"):
|
||||
preset = LightState(hue=0, saturation=0, brightness=100)
|
||||
else:
|
||||
preset = LightState(brightness=100)
|
||||
|
@ -37,20 +37,14 @@ class LightStripEffect(SmartModule, SmartLightEffect):
|
||||
|
||||
@property
|
||||
def effect(self) -> str:
|
||||
"""Return effect state.
|
||||
|
||||
Example:
|
||||
{'brightness': 50,
|
||||
'custom': 0,
|
||||
'enable': 0,
|
||||
'id': '',
|
||||
'name': ''}
|
||||
"""
|
||||
"""Return effect name."""
|
||||
eff = self.data["lighting_effect"]
|
||||
name = eff["name"]
|
||||
# When devices are unpaired effect name is softAP which is not in our list
|
||||
if eff["enable"] and name in self._effect_list:
|
||||
return name
|
||||
if eff["enable"] and eff["custom"]:
|
||||
return name or self.LIGHT_EFFECTS_UNNAMED_CUSTOM
|
||||
return self.LIGHT_EFFECTS_OFF
|
||||
|
||||
@property
|
||||
|
43
kasa/smart/modules/matter.py
Normal file
43
kasa/smart/modules/matter.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""Implementation of matter module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ...feature import Feature
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
|
||||
class Matter(SmartModule):
|
||||
"""Implementation of matter module."""
|
||||
|
||||
QUERY_GETTER_NAME: str = "get_matter_setup_info"
|
||||
REQUIRED_COMPONENT = "matter"
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features after the initial update."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="matter_setup_code",
|
||||
name="Matter setup code",
|
||||
container=self,
|
||||
attribute_getter=lambda x: x.info["setup_code"],
|
||||
type=Feature.Type.Sensor,
|
||||
category=Feature.Category.Debug,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="matter_setup_payload",
|
||||
name="Matter setup payload",
|
||||
container=self,
|
||||
attribute_getter=lambda x: x.info["setup_payload"],
|
||||
type=Feature.Type.Sensor,
|
||||
category=Feature.Category.Debug,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def info(self) -> dict[str, str]:
|
||||
"""Matter setup info."""
|
||||
return self.data
|
90
kasa/smart/modules/mop.py
Normal file
90
kasa/smart/modules/mop.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""Implementation of vacuum mop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from enum import IntEnum
|
||||
from typing import Annotated
|
||||
|
||||
from ...feature import Feature
|
||||
from ...module import FeatureAttribute
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Waterlevel(IntEnum):
|
||||
"""Water level for mopping."""
|
||||
|
||||
Disable = 0
|
||||
Low = 1
|
||||
Medium = 2
|
||||
High = 3
|
||||
|
||||
|
||||
class Mop(SmartModule):
|
||||
"""Implementation of vacuum mop."""
|
||||
|
||||
REQUIRED_COMPONENT = "mop"
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="mop_attached",
|
||||
name="Mop attached",
|
||||
container=self,
|
||||
icon="mdi:square-rounded",
|
||||
attribute_getter="mop_attached",
|
||||
category=Feature.Category.Info,
|
||||
type=Feature.BinarySensor,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="mop_waterlevel",
|
||||
name="Mop water level",
|
||||
container=self,
|
||||
attribute_getter="waterlevel",
|
||||
attribute_setter="set_waterlevel",
|
||||
icon="mdi:water",
|
||||
choices_getter=lambda: list(Waterlevel.__members__),
|
||||
category=Feature.Category.Config,
|
||||
type=Feature.Type.Choice,
|
||||
)
|
||||
)
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
return {
|
||||
"getMopState": {},
|
||||
"getCleanAttr": {"type": "global"},
|
||||
}
|
||||
|
||||
@property
|
||||
def mop_attached(self) -> bool:
|
||||
"""Return True if mop is attached."""
|
||||
return self.data["getMopState"]["mop_state"]
|
||||
|
||||
@property
|
||||
def _settings(self) -> dict:
|
||||
"""Return settings settings."""
|
||||
return self.data["getCleanAttr"]
|
||||
|
||||
@property
|
||||
def waterlevel(self) -> Annotated[str, FeatureAttribute()]:
|
||||
"""Return water level."""
|
||||
return Waterlevel(int(self._settings["cistern"])).name
|
||||
|
||||
async def set_waterlevel(self, mode: str) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""Set waterlevel mode."""
|
||||
name_to_value = {x.name: x.value for x in Waterlevel}
|
||||
if mode not in name_to_value:
|
||||
raise ValueError("Invalid waterlevel %s, available %s", mode, name_to_value)
|
||||
|
||||
settings = self._settings.copy()
|
||||
settings["cistern"] = name_to_value[mode]
|
||||
return await self.call("setCleanAttr", settings)
|
41
kasa/smart/modules/overheatprotection.py
Normal file
41
kasa/smart/modules/overheatprotection.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""Overheat module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ...feature import Feature
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
|
||||
class OverheatProtection(SmartModule):
|
||||
"""Implementation for overheat_protection."""
|
||||
|
||||
SYSINFO_LOOKUP_KEYS = ["overheated", "overheat_status"]
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features after the initial update."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
container=self,
|
||||
id="overheated",
|
||||
name="Overheated",
|
||||
attribute_getter="overheated",
|
||||
icon="mdi:heat-wave",
|
||||
type=Feature.Type.BinarySensor,
|
||||
category=Feature.Category.Info,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def overheated(self) -> bool:
|
||||
"""Return True if device reports overheating."""
|
||||
if (value := self._device.sys_info.get("overheat_status")) is not None:
|
||||
# Value can be normal, cooldown, or overheated.
|
||||
# We report all but normal as overheated.
|
||||
return value != "normal"
|
||||
|
||||
return self._device.sys_info["overheated"]
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
return {}
|
124
kasa/smart/modules/powerprotection.py
Normal file
124
kasa/smart/modules/powerprotection.py
Normal file
@ -0,0 +1,124 @@
|
||||
"""Power protection module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from ...feature import Feature
|
||||
from ...module import FeatureAttribute
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
|
||||
class PowerProtection(SmartModule):
|
||||
"""Implementation for power_protection."""
|
||||
|
||||
REQUIRED_COMPONENT = "power_protection"
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features after the initial update."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
id="overloaded",
|
||||
name="Overloaded",
|
||||
container=self,
|
||||
attribute_getter="overloaded",
|
||||
type=Feature.Type.BinarySensor,
|
||||
category=Feature.Category.Info,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self._device,
|
||||
id="power_protection_threshold",
|
||||
name="Power protection threshold",
|
||||
container=self,
|
||||
attribute_getter="_threshold_or_zero",
|
||||
attribute_setter="_set_threshold_auto_enable",
|
||||
unit_getter=lambda: "W",
|
||||
type=Feature.Type.Number,
|
||||
range_getter=lambda: (0, self._max_power),
|
||||
category=Feature.Category.Config,
|
||||
)
|
||||
)
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
return {"get_protection_power": {}, "get_max_power": {}}
|
||||
|
||||
@property
|
||||
def overloaded(self) -> bool:
|
||||
"""Return True is power protection has been triggered.
|
||||
|
||||
This value remains True until the device is turned on again.
|
||||
"""
|
||||
return self._device.sys_info["power_protection_status"] == "overloaded"
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Return True if child protection is enabled."""
|
||||
return self.data["get_protection_power"]["enabled"]
|
||||
|
||||
async def set_enabled(self, enabled: bool, *, threshold: int | None = None) -> dict:
|
||||
"""Set power protection enabled.
|
||||
|
||||
If power protection has never been enabled before the threshold will
|
||||
be 0 so if threshold is not provided it will be set to half the max.
|
||||
"""
|
||||
if threshold is None and enabled and self.protection_threshold == 0:
|
||||
threshold = int(self._max_power / 2)
|
||||
|
||||
if threshold and (threshold < 0 or threshold > self._max_power):
|
||||
raise ValueError(
|
||||
"Threshold out of range: %s (%s)", threshold, self.protection_threshold
|
||||
)
|
||||
|
||||
params = {**self.data["get_protection_power"], "enabled": enabled}
|
||||
if threshold is not None:
|
||||
params["protection_power"] = threshold
|
||||
return await self.call("set_protection_power", params)
|
||||
|
||||
async def _set_threshold_auto_enable(self, threshold: int) -> dict:
|
||||
"""Set power protection and enable."""
|
||||
if threshold == 0:
|
||||
return await self.set_enabled(False)
|
||||
else:
|
||||
return await self.set_enabled(True, threshold=threshold)
|
||||
|
||||
@property
|
||||
def _threshold_or_zero(self) -> int:
|
||||
"""Get power protection threshold. 0 if not enabled."""
|
||||
return self.protection_threshold if self.enabled else 0
|
||||
|
||||
@property
|
||||
def _max_power(self) -> int:
|
||||
"""Return max power."""
|
||||
return self.data["get_max_power"]["max_power"]
|
||||
|
||||
@property
|
||||
def protection_threshold(
|
||||
self,
|
||||
) -> Annotated[int, FeatureAttribute("power_protection_threshold")]:
|
||||
"""Return protection threshold in watts."""
|
||||
# If never configured, there is no value set.
|
||||
return self.data["get_protection_power"].get("protection_power", 0)
|
||||
|
||||
async def set_protection_threshold(self, threshold: int) -> dict:
|
||||
"""Set protection threshold."""
|
||||
if threshold < 0 or threshold > self._max_power:
|
||||
raise ValueError(
|
||||
"Threshold out of range: %s (%s)", threshold, self.protection_threshold
|
||||
)
|
||||
|
||||
params = {
|
||||
**self.data["get_protection_power"],
|
||||
"protection_power": threshold,
|
||||
}
|
||||
return await self.call("set_protection_power", params)
|
||||
|
||||
async def _check_supported(self) -> bool:
|
||||
"""Return True if module is supported.
|
||||
|
||||
This is needed, as strips like P304M report the status only for children.
|
||||
"""
|
||||
return "power_protection_status" in self._device.sys_info
|
67
kasa/smart/modules/speaker.py
Normal file
67
kasa/smart/modules/speaker.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""Implementation of vacuum speaker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from ...feature import Feature
|
||||
from ...module import FeatureAttribute
|
||||
from ..smartmodule import SmartModule
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Speaker(SmartModule):
|
||||
"""Implementation of vacuum speaker."""
|
||||
|
||||
REQUIRED_COMPONENT = "speaker"
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="locate",
|
||||
name="Locate device",
|
||||
container=self,
|
||||
attribute_setter="locate",
|
||||
category=Feature.Category.Primary,
|
||||
type=Feature.Action,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="volume",
|
||||
name="Volume",
|
||||
container=self,
|
||||
attribute_getter="volume",
|
||||
attribute_setter="set_volume",
|
||||
range_getter=lambda: (0, 100),
|
||||
category=Feature.Category.Config,
|
||||
type=Feature.Type.Number,
|
||||
)
|
||||
)
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
return {
|
||||
"getVolume": None,
|
||||
}
|
||||
|
||||
@property
|
||||
def volume(self) -> Annotated[str, FeatureAttribute()]:
|
||||
"""Return volume."""
|
||||
return self.data["volume"]
|
||||
|
||||
async def set_volume(self, volume: int) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""Set volume."""
|
||||
if volume < 0 or volume > 100:
|
||||
raise ValueError("Volume must be between 0 and 100")
|
||||
|
||||
return await self.call("setVolume", {"volume": volume})
|
||||
|
||||
async def locate(self) -> dict:
|
||||
"""Play sound to locate the device."""
|
||||
return await self.call("playSelectAudio", {"audio_type": "seek_me"})
|
@ -6,10 +6,11 @@ import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..device import DeviceInfo
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper
|
||||
from .smartdevice import SmartDevice
|
||||
from .smartdevice import ComponentsRaw, SmartDevice
|
||||
from .smartmodule import SmartModule
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@ -23,6 +24,7 @@ class SmartChildDevice(SmartDevice):
|
||||
|
||||
CHILD_DEVICE_TYPE_MAP = {
|
||||
"plug.powerstrip.sub-plug": DeviceType.Plug,
|
||||
"subg.plugswitch.switch": DeviceType.WallSwitch,
|
||||
"subg.trigger.contact-sensor": DeviceType.Sensor,
|
||||
"subg.trigger.temp-hmdt-sensor": DeviceType.Sensor,
|
||||
"subg.trigger.water-leak-sensor": DeviceType.Sensor,
|
||||
@ -37,7 +39,7 @@ class SmartChildDevice(SmartDevice):
|
||||
self,
|
||||
parent: SmartDevice,
|
||||
info: dict,
|
||||
component_info: dict,
|
||||
component_info_raw: ComponentsRaw,
|
||||
*,
|
||||
config: DeviceConfig | None = None,
|
||||
protocol: SmartProtocol | None = None,
|
||||
@ -47,7 +49,24 @@ class SmartChildDevice(SmartDevice):
|
||||
super().__init__(parent.host, config=parent.config, protocol=_protocol)
|
||||
self._parent = parent
|
||||
self._update_internal_state(info)
|
||||
self._components = component_info
|
||||
self._components_raw = component_info_raw
|
||||
self._components = self._parse_components(self._components_raw)
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device info.
|
||||
|
||||
Child device does not have it info and components in _last_update so
|
||||
this overrides the base implementation to call _get_device_info with
|
||||
info and components combined as they would be in _last_update.
|
||||
"""
|
||||
return self._get_device_info(
|
||||
{
|
||||
"get_device_info": self._info,
|
||||
"component_nego": self._components_raw,
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
async def update(self, update_children: bool = True) -> None:
|
||||
"""Update child module info.
|
||||
@ -67,11 +86,22 @@ class SmartChildDevice(SmartDevice):
|
||||
module_queries: list[SmartModule] = []
|
||||
req: dict[str, Any] = {}
|
||||
for module in self.modules.values():
|
||||
if module.disabled is False and (mod_query := module.query()):
|
||||
if (
|
||||
module.disabled is False
|
||||
and (mod_query := module.query())
|
||||
and module._should_update(now)
|
||||
):
|
||||
module_queries.append(module)
|
||||
req.update(mod_query)
|
||||
if req:
|
||||
self._last_update = await self.protocol.query(req)
|
||||
first_update = self._last_update != {}
|
||||
try:
|
||||
resp = await self.protocol.query(req)
|
||||
except Exception as ex:
|
||||
resp = await self._handle_modular_update_error(
|
||||
ex, first_update, ", ".join(mod.name for mod in module_queries), req
|
||||
)
|
||||
self._last_update = resp
|
||||
|
||||
for module in self.modules.values():
|
||||
await self._handle_module_post_update(
|
||||
@ -79,12 +109,17 @@ class SmartChildDevice(SmartDevice):
|
||||
)
|
||||
self._last_update_time = now
|
||||
|
||||
# We can first initialize the features after the first update.
|
||||
# We make here an assumption that every device has at least a single feature.
|
||||
if not self._features:
|
||||
await self._initialize_features()
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
parent: SmartDevice,
|
||||
child_info: dict,
|
||||
child_components: dict,
|
||||
child_components_raw: ComponentsRaw,
|
||||
protocol: SmartProtocol | None = None,
|
||||
*,
|
||||
last_update: dict | None = None,
|
||||
@ -97,7 +132,7 @@ class SmartChildDevice(SmartDevice):
|
||||
derived from the parent.
|
||||
"""
|
||||
child: SmartChildDevice = cls(
|
||||
parent, child_info, child_components, protocol=protocol
|
||||
parent, child_info, child_components_raw, protocol=protocol
|
||||
)
|
||||
if last_update:
|
||||
child._last_update = last_update
|
||||
|
@ -5,11 +5,12 @@ from __future__ import annotations
|
||||
import base64
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime, timedelta, tzinfo
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import TYPE_CHECKING, Any, TypeAlias, cast
|
||||
|
||||
from ..device import Device, WifiNetwork, _DeviceInfo
|
||||
from ..device import Device, DeviceInfo, WifiNetwork
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
|
||||
@ -40,6 +41,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
# same issue, homekit perhaps?
|
||||
NON_HUB_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud]
|
||||
|
||||
ComponentsRaw: TypeAlias = dict[str, list[dict[str, int | str]]]
|
||||
|
||||
|
||||
# Device must go last as the other interfaces also inherit Device
|
||||
# and python needs a consistent method resolution order.
|
||||
@ -61,16 +64,18 @@ class SmartDevice(Device):
|
||||
)
|
||||
super().__init__(host=host, config=config, protocol=_protocol)
|
||||
self.protocol: SmartProtocol
|
||||
self._components_raw: dict[str, Any] | None = None
|
||||
self._components_raw: ComponentsRaw | None = None
|
||||
self._components: dict[str, int] = {}
|
||||
self._state_information: dict[str, Any] = {}
|
||||
self._modules: dict[str | ModuleName[Module], SmartModule] = {}
|
||||
self._modules: OrderedDict[str | ModuleName[Module], SmartModule] = (
|
||||
OrderedDict()
|
||||
)
|
||||
self._parent: SmartDevice | None = None
|
||||
self._children: Mapping[str, SmartDevice] = {}
|
||||
self._last_update = {}
|
||||
self._children: dict[str, SmartDevice] = {}
|
||||
self._last_update_time: float | None = None
|
||||
self._on_since: datetime | None = None
|
||||
self._info: dict[str, Any] = {}
|
||||
self._logged_missing_child_ids: set[str] = set()
|
||||
|
||||
async def _initialize_children(self) -> None:
|
||||
"""Initialize children for power strips."""
|
||||
@ -81,25 +86,86 @@ class SmartDevice(Device):
|
||||
resp = await self.protocol.query(child_info_query)
|
||||
self.internal_state.update(resp)
|
||||
|
||||
children = self.internal_state["get_child_device_list"]["child_device_list"]
|
||||
children_components = {
|
||||
child["device_id"]: {
|
||||
comp["id"]: int(comp["ver_code"]) for comp in child["component_list"]
|
||||
}
|
||||
for child in self.internal_state["get_child_device_component_list"][
|
||||
"child_component_list"
|
||||
]
|
||||
}
|
||||
async def _try_create_child(
|
||||
self, info: dict, child_components: dict
|
||||
) -> SmartDevice | None:
|
||||
from .smartchilddevice import SmartChildDevice
|
||||
|
||||
self._children = {
|
||||
child_info["device_id"]: await SmartChildDevice.create(
|
||||
parent=self,
|
||||
child_info=child_info,
|
||||
child_components=children_components[child_info["device_id"]],
|
||||
)
|
||||
for child_info in children
|
||||
return await SmartChildDevice.create(
|
||||
parent=self,
|
||||
child_info=info,
|
||||
child_components_raw=child_components,
|
||||
)
|
||||
|
||||
async def _create_delete_children(
|
||||
self,
|
||||
child_device_resp: dict[str, list],
|
||||
child_device_components_resp: dict[str, list],
|
||||
) -> bool:
|
||||
"""Create and delete children. Return True if children changed.
|
||||
|
||||
Adds newly found children and deletes children that are no longer
|
||||
reported by the device. It will only log once per child_id that
|
||||
can't be created to avoid spamming the logs on every update.
|
||||
"""
|
||||
changed = False
|
||||
smart_children_components = {
|
||||
child["device_id"]: child
|
||||
for child in child_device_components_resp["child_component_list"]
|
||||
}
|
||||
children = self._children
|
||||
child_ids: set[str] = set()
|
||||
existing_child_ids = set(self._children.keys())
|
||||
|
||||
for info in child_device_resp["child_device_list"]:
|
||||
if (child_id := info.get("device_id")) and (
|
||||
child_components := smart_children_components.get(child_id)
|
||||
):
|
||||
child_ids.add(child_id)
|
||||
|
||||
if child_id in existing_child_ids:
|
||||
continue
|
||||
|
||||
child = await self._try_create_child(info, child_components)
|
||||
if child:
|
||||
_LOGGER.debug("Created child device %s for %s", child, self.host)
|
||||
changed = True
|
||||
children[child_id] = child
|
||||
continue
|
||||
|
||||
if child_id not in self._logged_missing_child_ids:
|
||||
self._logged_missing_child_ids.add(child_id)
|
||||
_LOGGER.debug("Child device type not supported: %s", info)
|
||||
continue
|
||||
|
||||
if child_id:
|
||||
if child_id not in self._logged_missing_child_ids:
|
||||
self._logged_missing_child_ids.add(child_id)
|
||||
_LOGGER.debug(
|
||||
"Could not find child components for device %s, "
|
||||
"child_id %s, components: %s: ",
|
||||
self.host,
|
||||
child_id,
|
||||
smart_children_components,
|
||||
)
|
||||
continue
|
||||
|
||||
# If we couldn't get a child device id we still only want to
|
||||
# log once to avoid spamming the logs on every update cycle
|
||||
# so store it under an empty string
|
||||
if "" not in self._logged_missing_child_ids:
|
||||
self._logged_missing_child_ids.add("")
|
||||
_LOGGER.debug(
|
||||
"Could not find child id for device %s, info: %s", self.host, info
|
||||
)
|
||||
|
||||
removed_ids = existing_child_ids - child_ids
|
||||
for removed_id in removed_ids:
|
||||
changed = True
|
||||
removed = children.pop(removed_id)
|
||||
_LOGGER.debug("Removed child device %s from %s", removed, self.host)
|
||||
|
||||
return changed
|
||||
|
||||
@property
|
||||
def children(self) -> Sequence[SmartDevice]:
|
||||
@ -131,6 +197,13 @@ class SmartDevice(Device):
|
||||
f"{request} not found in {responses} for device {self.host}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_components(components_raw: ComponentsRaw) -> dict[str, int]:
|
||||
return {
|
||||
str(comp["id"]): int(comp["ver_code"])
|
||||
for comp in components_raw["component_list"]
|
||||
}
|
||||
|
||||
async def _negotiate(self) -> None:
|
||||
"""Perform initialization.
|
||||
|
||||
@ -151,29 +224,41 @@ class SmartDevice(Device):
|
||||
self._info = self._try_get_response(resp, "get_device_info")
|
||||
|
||||
# Create our internal presentation of available components
|
||||
self._components_raw = cast(dict, resp["component_nego"])
|
||||
self._components_raw = cast(ComponentsRaw, resp["component_nego"])
|
||||
|
||||
self._components = {
|
||||
comp["id"]: int(comp["ver_code"])
|
||||
for comp in self._components_raw["component_list"]
|
||||
}
|
||||
self._components = self._parse_components(self._components_raw)
|
||||
|
||||
if "child_device" in self._components and not self.children:
|
||||
await self._initialize_children()
|
||||
|
||||
def _update_children_info(self) -> None:
|
||||
"""Update the internal child device info from the parent info."""
|
||||
async def _update_children_info(self) -> bool:
|
||||
"""Update the internal child device info from the parent info.
|
||||
|
||||
Return true if children added or deleted.
|
||||
"""
|
||||
changed = False
|
||||
if child_info := self._try_get_response(
|
||||
self._last_update, "get_child_device_list", {}
|
||||
):
|
||||
changed = await self._create_delete_children(
|
||||
child_info, self._last_update["get_child_device_component_list"]
|
||||
)
|
||||
|
||||
for info in child_info["child_device_list"]:
|
||||
self._children[info["device_id"]]._update_internal_state(info)
|
||||
child_id = info.get("device_id")
|
||||
if child_id not in self._children:
|
||||
# _create_delete_children has already logged a message
|
||||
continue
|
||||
|
||||
self._children[child_id]._update_internal_state(info)
|
||||
|
||||
return changed
|
||||
|
||||
def _update_internal_info(self, info_resp: dict) -> None:
|
||||
"""Update the internal device info."""
|
||||
self._info = self._try_get_response(info_resp, "get_device_info")
|
||||
|
||||
async def update(self, update_children: bool = False) -> None:
|
||||
async def update(self, update_children: bool = True) -> None:
|
||||
"""Update the device."""
|
||||
if self.credentials is None and self.credentials_hash is None:
|
||||
raise AuthenticationError("Tapo plug requires authentication.")
|
||||
@ -191,13 +276,13 @@ class SmartDevice(Device):
|
||||
|
||||
resp = await self._modular_update(first_update, now)
|
||||
|
||||
self._update_children_info()
|
||||
children_changed = await self._update_children_info()
|
||||
# Call child update which will only update module calls, info is updated
|
||||
# from get_child_device_list. update_children only affects hub devices, other
|
||||
# devices will always update children to prevent errors on module access.
|
||||
# This needs to go after updating the internal state of the children so that
|
||||
# child modules have access to their sysinfo.
|
||||
if update_children or self.device_type != DeviceType.Hub:
|
||||
if children_changed or update_children or self.device_type != DeviceType.Hub:
|
||||
for child in self._children.values():
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(child, SmartChildDevice)
|
||||
@ -250,11 +335,7 @@ class SmartDevice(Device):
|
||||
if first_update and module.__class__ in self.FIRST_UPDATE_MODULES:
|
||||
module._last_update_time = update_time
|
||||
continue
|
||||
if (
|
||||
not module.update_interval
|
||||
or not module._last_update_time
|
||||
or (update_time - module._last_update_time) >= module.update_interval
|
||||
):
|
||||
if module._should_update(update_time):
|
||||
module_queries.append(module)
|
||||
req.update(query)
|
||||
|
||||
@ -342,9 +423,8 @@ class SmartDevice(Device):
|
||||
) or mod.__name__ in child_modules_to_skip:
|
||||
continue
|
||||
required_component = cast(str, mod.REQUIRED_COMPONENT)
|
||||
if required_component in self._components or (
|
||||
mod.REQUIRED_KEY_ON_PARENT
|
||||
and self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None
|
||||
if required_component in self._components or any(
|
||||
self.sys_info.get(key) is not None for key in mod.SYSINFO_LOOKUP_KEYS
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Device %s, found required %s, adding %s to modules.",
|
||||
@ -368,6 +448,11 @@ class SmartDevice(Device):
|
||||
):
|
||||
self._modules[Thermostat.__name__] = Thermostat(self, "thermostat")
|
||||
|
||||
# We move time to the beginning so other modules can access the
|
||||
# time and timezone after update if required. e.g. cleanrecords
|
||||
if Time.__name__ in self._modules:
|
||||
self._modules.move_to_end(Time.__name__, last=False)
|
||||
|
||||
async def _initialize_features(self) -> None:
|
||||
"""Initialize device features."""
|
||||
self._add_feature(
|
||||
@ -433,19 +518,6 @@ class SmartDevice(Device):
|
||||
)
|
||||
)
|
||||
|
||||
if "overheated" in self._info:
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self,
|
||||
id="overheated",
|
||||
name="Overheated",
|
||||
attribute_getter=lambda x: x._info["overheated"],
|
||||
icon="mdi:heat-wave",
|
||||
type=Feature.Type.BinarySensor,
|
||||
category=Feature.Category.Info,
|
||||
)
|
||||
)
|
||||
|
||||
# We check for the key available, and not for the property truthiness,
|
||||
# as the value is falsy when the device is off.
|
||||
if "on_time" in self._info:
|
||||
@ -473,12 +545,25 @@ class SmartDevice(Device):
|
||||
)
|
||||
)
|
||||
|
||||
if self.parent is not None and (
|
||||
cs := self.parent.modules.get(Module.ChildSetup)
|
||||
):
|
||||
self._add_feature(
|
||||
Feature(
|
||||
device=self,
|
||||
id="unpair",
|
||||
name="Unpair device",
|
||||
container=cs,
|
||||
attribute_setter=lambda: cs.unpair(self.device_id),
|
||||
category=Feature.Category.Debug,
|
||||
type=Feature.Type.Action,
|
||||
)
|
||||
)
|
||||
|
||||
for module in self.modules.values():
|
||||
module._initialize_features()
|
||||
for feat in module._module_features.values():
|
||||
self._add_feature(feat)
|
||||
for child in self._children.values():
|
||||
await child._initialize_features()
|
||||
|
||||
@property
|
||||
def _is_hub_child(self) -> bool:
|
||||
@ -500,18 +585,13 @@ class SmartDevice(Device):
|
||||
@property
|
||||
def model(self) -> str:
|
||||
"""Returns the device model."""
|
||||
return str(self._info.get("model"))
|
||||
# If update hasn't been called self._device_info can't be used
|
||||
if self._last_update:
|
||||
return self.device_info.short_name
|
||||
|
||||
@property
|
||||
def _model_region(self) -> str:
|
||||
"""Return device full model name and region."""
|
||||
if (disco := self._discovery_info) and (
|
||||
disco_model := disco.get("device_model")
|
||||
):
|
||||
return disco_model
|
||||
# Some devices have the region in the specs element.
|
||||
region = f"({specs})" if (specs := self._info.get("specs")) else ""
|
||||
return f"{self.model}{region}"
|
||||
disco_model = str(self._info.get("device_model"))
|
||||
long_name, _, _ = disco_model.partition("(")
|
||||
return long_name
|
||||
|
||||
@property
|
||||
def alias(self) -> str | None:
|
||||
@ -611,12 +691,8 @@ class SmartDevice(Device):
|
||||
"""
|
||||
self._info = info
|
||||
|
||||
async def _query_helper(
|
||||
self, method: str, params: dict | None = None, child_ids: None = None
|
||||
) -> dict:
|
||||
res = await self.protocol.query({method: params})
|
||||
|
||||
return res
|
||||
async def _query_helper(self, method: str, params: dict | None = None) -> dict:
|
||||
return await self.protocol.query({method: params})
|
||||
|
||||
@property
|
||||
def ssid(self) -> str:
|
||||
@ -765,10 +841,11 @@ class SmartDevice(Device):
|
||||
if self._device_type is not DeviceType.Unknown:
|
||||
return self._device_type
|
||||
|
||||
# Fallback to device_type (from disco info)
|
||||
type_str = self._info.get("type", self._info.get("device_type"))
|
||||
|
||||
if not type_str: # no update or discovery info
|
||||
if (
|
||||
not (type_str := self._info.get("type", self._info.get("device_type")))
|
||||
or not self._components
|
||||
):
|
||||
# no update or discovery info
|
||||
return self._device_type
|
||||
|
||||
self._device_type = self._get_device_type_from_components(
|
||||
@ -802,13 +879,17 @@ class SmartDevice(Device):
|
||||
return DeviceType.Sensor
|
||||
if "ENERGY" in device_type:
|
||||
return DeviceType.Thermostat
|
||||
if "ROBOVAC" in device_type:
|
||||
return DeviceType.Vacuum
|
||||
if "TAPOCHIME" in device_type:
|
||||
return DeviceType.Chime
|
||||
_LOGGER.warning("Unknown device type, falling back to plug")
|
||||
return DeviceType.Plug
|
||||
|
||||
@staticmethod
|
||||
def _get_device_info(
|
||||
info: dict[str, Any], discovery_info: dict[str, Any] | None
|
||||
) -> _DeviceInfo:
|
||||
) -> DeviceInfo:
|
||||
"""Get model information for a device."""
|
||||
di = info["get_device_info"]
|
||||
components = [comp["id"] for comp in info["component_nego"]["component_list"]]
|
||||
@ -832,12 +913,15 @@ class SmartDevice(Device):
|
||||
components, device_family
|
||||
)
|
||||
fw_version_full = di["fw_ver"]
|
||||
firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1)
|
||||
if " " in fw_version_full:
|
||||
firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1)
|
||||
else:
|
||||
firmware_version, firmware_build = fw_version_full, None
|
||||
_protocol, devicetype = device_family.split(".")
|
||||
# Brand inferred from SMART.KASAPLUG/SMART.TAPOPLUG etc.
|
||||
brand = devicetype[:4].lower()
|
||||
|
||||
return _DeviceInfo(
|
||||
return DeviceInfo(
|
||||
short_name=short_name,
|
||||
long_name=long_name,
|
||||
brand=brand,
|
||||
|
@ -3,7 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from collections.abc import Callable, Coroutine
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar
|
||||
|
||||
from ..exceptions import DeviceError, KasaException, SmartErrorCode
|
||||
@ -20,15 +21,16 @@ _R = TypeVar("_R")
|
||||
|
||||
|
||||
def allow_update_after(
|
||||
func: Callable[Concatenate[_T, _P], Awaitable[dict]],
|
||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, dict]]:
|
||||
func: Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]],
|
||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]:
|
||||
"""Define a wrapper to set _last_update_time to None.
|
||||
|
||||
This will ensure that a module is updated in the next update cycle after
|
||||
a value has been changed.
|
||||
"""
|
||||
|
||||
async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> dict:
|
||||
@wraps(func)
|
||||
async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||
try:
|
||||
return await func(self, *args, **kwargs)
|
||||
finally:
|
||||
@ -40,6 +42,7 @@ def allow_update_after(
|
||||
def raise_if_update_error(func: Callable[[_T], _R]) -> Callable[[_T], _R]:
|
||||
"""Define a wrapper to raise an error if the last module update was an error."""
|
||||
|
||||
@wraps(func)
|
||||
def _wrap(self: _T) -> _R:
|
||||
if err := self._last_update_error:
|
||||
raise err
|
||||
@ -54,14 +57,16 @@ class SmartModule(Module):
|
||||
NAME: str
|
||||
#: Module is initialized, if the given component is available
|
||||
REQUIRED_COMPONENT: str | None = None
|
||||
#: Module is initialized, if the given key available in the main sysinfo
|
||||
REQUIRED_KEY_ON_PARENT: str | None = None
|
||||
#: Module is initialized, if any of the given keys exists in the sysinfo
|
||||
SYSINFO_LOOKUP_KEYS: list[str] = []
|
||||
#: Query to execute during the main update cycle
|
||||
QUERY_GETTER_NAME: str
|
||||
QUERY_GETTER_NAME: str = ""
|
||||
|
||||
REGISTERED_MODULES: dict[str, type[SmartModule]] = {}
|
||||
|
||||
MINIMUM_UPDATE_INTERVAL_SECS = 0
|
||||
MINIMUM_HUB_CHILD_UPDATE_INTERVAL_SECS = 60 * 60 * 24
|
||||
|
||||
UPDATE_INTERVAL_AFTER_ERROR_SECS = 30
|
||||
|
||||
DISABLE_AFTER_ERROR_COUNT = 10
|
||||
@ -72,6 +77,7 @@ class SmartModule(Module):
|
||||
self._last_update_time: float | None = None
|
||||
self._last_update_error: KasaException | None = None
|
||||
self._error_count = 0
|
||||
self._logged_remove_keys: list[str] = []
|
||||
|
||||
def __init_subclass__(cls, **kwargs) -> None:
|
||||
# We only want to register submodules in a modules package so that
|
||||
@ -106,16 +112,27 @@ class SmartModule(Module):
|
||||
@property
|
||||
def update_interval(self) -> int:
|
||||
"""Time to wait between updates."""
|
||||
if self._last_update_error is None:
|
||||
return self.MINIMUM_UPDATE_INTERVAL_SECS
|
||||
if self._last_update_error:
|
||||
return self.UPDATE_INTERVAL_AFTER_ERROR_SECS * self._error_count
|
||||
|
||||
return self.UPDATE_INTERVAL_AFTER_ERROR_SECS * self._error_count
|
||||
if self._device._is_hub_child:
|
||||
return self.MINIMUM_HUB_CHILD_UPDATE_INTERVAL_SECS
|
||||
|
||||
return self.MINIMUM_UPDATE_INTERVAL_SECS
|
||||
|
||||
@property
|
||||
def disabled(self) -> bool:
|
||||
"""Return true if the module is disabled due to errors."""
|
||||
return self._error_count >= self.DISABLE_AFTER_ERROR_COUNT
|
||||
|
||||
def _should_update(self, update_time: float) -> bool:
|
||||
"""Return true if module should update based on delay parameters."""
|
||||
return (
|
||||
not self.update_interval
|
||||
or not self._last_update_time
|
||||
or (update_time - self._last_update_time) >= self.update_interval
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _module_name(cls) -> str:
|
||||
return getattr(cls, "NAME", cls.__name__)
|
||||
@ -138,7 +155,9 @@ class SmartModule(Module):
|
||||
|
||||
Default implementation uses the raw query getter w/o parameters.
|
||||
"""
|
||||
return {self.QUERY_GETTER_NAME: None}
|
||||
if self.QUERY_GETTER_NAME:
|
||||
return {self.QUERY_GETTER_NAME: None}
|
||||
return {}
|
||||
|
||||
async def call(self, method: str, params: dict | None = None) -> dict:
|
||||
"""Call a method.
|
||||
@ -147,6 +166,15 @@ class SmartModule(Module):
|
||||
"""
|
||||
return await self._device._query_helper(method, params)
|
||||
|
||||
@property
|
||||
def optional_response_keys(self) -> list[str]:
|
||||
"""Return optional response keys for the module.
|
||||
|
||||
Defaults to no keys. Overriding this and providing keys will remove
|
||||
instead of raise on error.
|
||||
"""
|
||||
return []
|
||||
|
||||
@property
|
||||
def data(self) -> dict[str, Any]:
|
||||
"""Return response data for the module.
|
||||
@ -179,12 +207,31 @@ class SmartModule(Module):
|
||||
|
||||
filtered_data = {k: v for k, v in dev._last_update.items() if k in q_keys}
|
||||
|
||||
remove_keys: list[str] = []
|
||||
for data_item in filtered_data:
|
||||
if isinstance(filtered_data[data_item], SmartErrorCode):
|
||||
raise DeviceError(
|
||||
f"{data_item} for {self.name}", error_code=filtered_data[data_item]
|
||||
if data_item in self.optional_response_keys:
|
||||
remove_keys.append(data_item)
|
||||
else:
|
||||
raise DeviceError(
|
||||
f"{data_item} for {self.name}",
|
||||
error_code=filtered_data[data_item],
|
||||
)
|
||||
|
||||
for key in remove_keys:
|
||||
if key not in self._logged_remove_keys:
|
||||
self._logged_remove_keys.append(key)
|
||||
_LOGGER.debug(
|
||||
"Removed key %s from response for device %s as it returned "
|
||||
"error: %s. This message will only be logged once per key.",
|
||||
key,
|
||||
self._device.host,
|
||||
filtered_data[key],
|
||||
)
|
||||
if len(filtered_data) == 1:
|
||||
|
||||
filtered_data.pop(key)
|
||||
|
||||
if len(filtered_data) == 1 and not remove_keys:
|
||||
return next(iter(filtered_data.values()))
|
||||
|
||||
return filtered_data
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""Package for supporting tapo-branded cameras."""
|
||||
|
||||
from .smartcamchild import SmartCamChild
|
||||
from .smartcamdevice import SmartCamDevice
|
||||
|
||||
__all__ = ["SmartCamDevice"]
|
||||
__all__ = ["SmartCamDevice", "SmartCamChild"]
|
||||
|
@ -1,19 +1,39 @@
|
||||
"""Modules for SMARTCAM devices."""
|
||||
|
||||
from .alarm import Alarm
|
||||
from .babycrydetection import BabyCryDetection
|
||||
from .battery import Battery
|
||||
from .camera import Camera
|
||||
from .childdevice import ChildDevice
|
||||
from .childsetup import ChildSetup
|
||||
from .device import DeviceModule
|
||||
from .homekit import HomeKit
|
||||
from .led import Led
|
||||
from .lensmask import LensMask
|
||||
from .matter import Matter
|
||||
from .motiondetection import MotionDetection
|
||||
from .pantilt import PanTilt
|
||||
from .persondetection import PersonDetection
|
||||
from .petdetection import PetDetection
|
||||
from .tamperdetection import TamperDetection
|
||||
from .time import Time
|
||||
|
||||
__all__ = [
|
||||
"Alarm",
|
||||
"BabyCryDetection",
|
||||
"Battery",
|
||||
"Camera",
|
||||
"ChildDevice",
|
||||
"ChildSetup",
|
||||
"DeviceModule",
|
||||
"Led",
|
||||
"PanTilt",
|
||||
"PersonDetection",
|
||||
"PetDetection",
|
||||
"Time",
|
||||
"HomeKit",
|
||||
"Matter",
|
||||
"MotionDetection",
|
||||
"LensMask",
|
||||
"TamperDetection",
|
||||
]
|
||||
|
@ -2,7 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from ...feature import Feature
|
||||
from ...interfaces import Alarm as AlarmInterface
|
||||
from ...module import FeatureAttribute
|
||||
from ...smart.smartmodule import allow_update_after
|
||||
from ..smartcammodule import SmartCamModule
|
||||
|
||||
DURATION_MIN = 0
|
||||
@ -12,12 +17,9 @@ VOLUME_MIN = 0
|
||||
VOLUME_MAX = 10
|
||||
|
||||
|
||||
class Alarm(SmartCamModule):
|
||||
class Alarm(SmartCamModule, AlarmInterface):
|
||||
"""Implementation of alarm module."""
|
||||
|
||||
# Needs a different name to avoid clashing with SmartAlarm
|
||||
NAME = "SmartCamAlarm"
|
||||
|
||||
REQUIRED_COMPONENT = "siren"
|
||||
QUERY_GETTER_NAME = "getSirenStatus"
|
||||
QUERY_MODULE_NAME = "siren"
|
||||
@ -106,20 +108,18 @@ class Alarm(SmartCamModule):
|
||||
)
|
||||
|
||||
@property
|
||||
def alarm_sound(self) -> str:
|
||||
def alarm_sound(self) -> Annotated[str, FeatureAttribute()]:
|
||||
"""Return current alarm sound."""
|
||||
return self.data["getSirenConfig"]["siren_type"]
|
||||
|
||||
async def set_alarm_sound(self, sound: str) -> dict:
|
||||
@allow_update_after
|
||||
async def set_alarm_sound(self, sound: str) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""Set alarm sound.
|
||||
|
||||
See *alarm_sounds* for list of available sounds.
|
||||
"""
|
||||
if sound not in self.alarm_sounds:
|
||||
raise ValueError(
|
||||
f"sound must be one of {', '.join(self.alarm_sounds)}: {sound}"
|
||||
)
|
||||
return await self.call("setSirenConfig", {"siren": {"siren_type": sound}})
|
||||
config = self._validate_and_get_config(sound=sound)
|
||||
return await self.call("setSirenConfig", {"siren": config})
|
||||
|
||||
@property
|
||||
def alarm_sounds(self) -> list[str]:
|
||||
@ -127,40 +127,90 @@ class Alarm(SmartCamModule):
|
||||
return self.data["getSirenTypeList"]["siren_type_list"]
|
||||
|
||||
@property
|
||||
def alarm_volume(self) -> int:
|
||||
def alarm_volume(self) -> Annotated[int, FeatureAttribute()]:
|
||||
"""Return alarm volume.
|
||||
|
||||
Unlike duration the device expects/returns a string for volume.
|
||||
"""
|
||||
return int(self.data["getSirenConfig"]["volume"])
|
||||
|
||||
async def set_alarm_volume(self, volume: int) -> dict:
|
||||
@allow_update_after
|
||||
async def set_alarm_volume(
|
||||
self, volume: int
|
||||
) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""Set alarm volume."""
|
||||
if volume < VOLUME_MIN or volume > VOLUME_MAX:
|
||||
raise ValueError(f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}")
|
||||
return await self.call("setSirenConfig", {"siren": {"volume": str(volume)}})
|
||||
config = self._validate_and_get_config(volume=volume)
|
||||
return await self.call("setSirenConfig", {"siren": config})
|
||||
|
||||
@property
|
||||
def alarm_duration(self) -> int:
|
||||
def alarm_duration(self) -> Annotated[int, FeatureAttribute()]:
|
||||
"""Return alarm duration."""
|
||||
return self.data["getSirenConfig"]["duration"]
|
||||
|
||||
async def set_alarm_duration(self, duration: int) -> dict:
|
||||
@allow_update_after
|
||||
async def set_alarm_duration(
|
||||
self, duration: int
|
||||
) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""Set alarm volume."""
|
||||
if duration < DURATION_MIN or duration > DURATION_MAX:
|
||||
msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}"
|
||||
raise ValueError(msg)
|
||||
return await self.call("setSirenConfig", {"siren": {"duration": duration}})
|
||||
config = self._validate_and_get_config(duration=duration)
|
||||
return await self.call("setSirenConfig", {"siren": config})
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
"""Return true if alarm is active."""
|
||||
return self.data["getSirenStatus"]["status"] != "off"
|
||||
|
||||
async def play(self) -> dict:
|
||||
"""Play alarm."""
|
||||
async def play(
|
||||
self,
|
||||
*,
|
||||
duration: int | None = None,
|
||||
volume: int | None = None,
|
||||
sound: str | None = None,
|
||||
) -> dict:
|
||||
"""Play alarm.
|
||||
|
||||
The optional *duration*, *volume*, and *sound* to override the device settings.
|
||||
*duration* is in seconds.
|
||||
See *alarm_sounds* for the list of sounds available for the device.
|
||||
"""
|
||||
if config := self._validate_and_get_config(
|
||||
duration=duration, volume=volume, sound=sound
|
||||
):
|
||||
await self.call("setSirenConfig", {"siren": config})
|
||||
|
||||
return await self.call("setSirenStatus", {"siren": {"status": "on"}})
|
||||
|
||||
async def stop(self) -> dict:
|
||||
"""Stop alarm."""
|
||||
return await self.call("setSirenStatus", {"siren": {"status": "off"}})
|
||||
|
||||
def _validate_and_get_config(
|
||||
self,
|
||||
*,
|
||||
duration: int | None = None,
|
||||
volume: int | None = None,
|
||||
sound: str | None = None,
|
||||
) -> dict:
|
||||
if sound and sound not in self.alarm_sounds:
|
||||
raise ValueError(
|
||||
f"sound must be one of {', '.join(self.alarm_sounds)}: {sound}"
|
||||
)
|
||||
|
||||
if duration is not None and (
|
||||
duration < DURATION_MIN or duration > DURATION_MAX
|
||||
):
|
||||
msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}"
|
||||
raise ValueError(msg)
|
||||
|
||||
if volume is not None and (volume < VOLUME_MIN or volume > VOLUME_MAX):
|
||||
raise ValueError(f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}")
|
||||
|
||||
config: dict[str, str | int] = {}
|
||||
if sound:
|
||||
config["siren_type"] = sound
|
||||
if duration is not None:
|
||||
config["duration"] = duration
|
||||
if volume is not None:
|
||||
config["volume"] = str(volume)
|
||||
|
||||
return config
|
||||
|
49
kasa/smartcam/modules/babycrydetection.py
Normal file
49
kasa/smartcam/modules/babycrydetection.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""Implementation of baby cry detection module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ...feature import Feature
|
||||
from ...smart.smartmodule import allow_update_after
|
||||
from ..smartcammodule import SmartCamModule
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BabyCryDetection(SmartCamModule):
|
||||
"""Implementation of baby cry detection module."""
|
||||
|
||||
REQUIRED_COMPONENT = "babyCryDetection"
|
||||
|
||||
QUERY_GETTER_NAME = "getBCDConfig"
|
||||
QUERY_MODULE_NAME = "sound_detection"
|
||||
QUERY_SECTION_NAMES = "bcd"
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features after the initial update."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="baby_cry_detection",
|
||||
name="Baby cry detection",
|
||||
container=self,
|
||||
attribute_getter="enabled",
|
||||
attribute_setter="set_enabled",
|
||||
type=Feature.Type.Switch,
|
||||
category=Feature.Category.Config,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Return the baby cry detection enabled state."""
|
||||
return self.data["bcd"]["enabled"] == "on"
|
||||
|
||||
@allow_update_after
|
||||
async def set_enabled(self, enable: bool) -> dict:
|
||||
"""Set the baby cry detection enabled state."""
|
||||
params = {"enabled": "on" if enable else "off"}
|
||||
return await self._device._query_setter_helper(
|
||||
"setBCDConfig", self.QUERY_MODULE_NAME, "bcd", params
|
||||
)
|
113
kasa/smartcam/modules/battery.py
Normal file
113
kasa/smartcam/modules/battery.py
Normal file
@ -0,0 +1,113 @@
|
||||
"""Implementation of baby cry detection module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ...feature import Feature
|
||||
from ..smartcammodule import SmartCamModule
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Battery(SmartCamModule):
|
||||
"""Implementation of a battery module."""
|
||||
|
||||
REQUIRED_COMPONENT = "battery"
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
"battery_low",
|
||||
"Battery low",
|
||||
container=self,
|
||||
attribute_getter="battery_low",
|
||||
icon="mdi:alert",
|
||||
type=Feature.Type.BinarySensor,
|
||||
category=Feature.Category.Debug,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
"battery_level",
|
||||
"Battery level",
|
||||
container=self,
|
||||
attribute_getter="battery_percent",
|
||||
icon="mdi:battery",
|
||||
unit_getter=lambda: "%",
|
||||
category=Feature.Category.Info,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
"battery_temperature",
|
||||
"Battery temperature",
|
||||
container=self,
|
||||
attribute_getter="battery_temperature",
|
||||
icon="mdi:battery",
|
||||
unit_getter=lambda: "celsius",
|
||||
category=Feature.Category.Debug,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
"battery_voltage",
|
||||
"Battery voltage",
|
||||
container=self,
|
||||
attribute_getter="battery_voltage",
|
||||
icon="mdi:battery",
|
||||
unit_getter=lambda: "V",
|
||||
category=Feature.Category.Debug,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
"battery_charging",
|
||||
"Battery charging",
|
||||
container=self,
|
||||
attribute_getter="battery_charging",
|
||||
icon="mdi:alert",
|
||||
type=Feature.Type.BinarySensor,
|
||||
category=Feature.Category.Debug,
|
||||
)
|
||||
)
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
return {}
|
||||
|
||||
@property
|
||||
def battery_percent(self) -> int:
|
||||
"""Return battery level."""
|
||||
return self._device.sys_info["battery_percent"]
|
||||
|
||||
@property
|
||||
def battery_low(self) -> bool:
|
||||
"""Return True if battery is low."""
|
||||
return self._device.sys_info["low_battery"]
|
||||
|
||||
@property
|
||||
def battery_temperature(self) -> bool:
|
||||
"""Return battery voltage in C."""
|
||||
return self._device.sys_info["battery_temperature"]
|
||||
|
||||
@property
|
||||
def battery_voltage(self) -> bool:
|
||||
"""Return battery voltage in V."""
|
||||
return self._device.sys_info["battery_voltage"] / 1_000
|
||||
|
||||
@property
|
||||
def battery_charging(self) -> bool:
|
||||
"""Return True if battery is charging."""
|
||||
return self._device.sys_info["battery_voltage"] != "NO"
|
@ -1,47 +1,69 @@
|
||||
"""Implementation of device module."""
|
||||
"""Implementation of camera module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from enum import StrEnum
|
||||
from typing import Annotated
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from ...credentials import Credentials
|
||||
from ...device_type import DeviceType
|
||||
from ...feature import Feature
|
||||
from ...json import loads as json_loads
|
||||
from ...module import FeatureAttribute, Module
|
||||
from ..smartcammodule import SmartCamModule
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
LOCAL_STREAMING_PORT = 554
|
||||
ONVIF_PORT = 2020
|
||||
|
||||
|
||||
class StreamResolution(StrEnum):
|
||||
"""Class for stream resolution."""
|
||||
|
||||
HD = "HD"
|
||||
SD = "SD"
|
||||
|
||||
|
||||
class Camera(SmartCamModule):
|
||||
"""Implementation of device module."""
|
||||
|
||||
QUERY_GETTER_NAME = "getLensMaskConfig"
|
||||
QUERY_MODULE_NAME = "lens_mask"
|
||||
QUERY_SECTION_NAMES = "lens_mask_info"
|
||||
REQUIRED_COMPONENT = "video"
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features after the initial update."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="state",
|
||||
name="State",
|
||||
attribute_getter="is_on",
|
||||
attribute_setter="set_state",
|
||||
type=Feature.Type.Switch,
|
||||
category=Feature.Category.Primary,
|
||||
if Module.LensMask in self._device.modules:
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="state",
|
||||
name="State",
|
||||
container=self,
|
||||
attribute_getter="is_on",
|
||||
attribute_setter="set_state",
|
||||
type=Feature.Type.Switch,
|
||||
category=Feature.Category.Primary,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return the device id."""
|
||||
return self.data["lens_mask_info"]["enabled"] == "off"
|
||||
"""Return the device on state."""
|
||||
if lens_mask := self._device.modules.get(Module.LensMask):
|
||||
return not lens_mask.enabled
|
||||
return True
|
||||
|
||||
async def set_state(self, on: bool) -> Annotated[dict, FeatureAttribute()]:
|
||||
"""Set the device on state.
|
||||
|
||||
If the device does not support setting state will do nothing.
|
||||
"""
|
||||
if lens_mask := self._device.modules.get(Module.LensMask):
|
||||
# Turning off enables the privacy mask which is why value is reversed.
|
||||
return await lens_mask.set_enabled(not on)
|
||||
return {}
|
||||
|
||||
def _get_credentials(self) -> Credentials | None:
|
||||
"""Get credentials from ."""
|
||||
@ -64,7 +86,12 @@ class Camera(SmartCamModule):
|
||||
|
||||
return None
|
||||
|
||||
def stream_rtsp_url(self, credentials: Credentials | None = None) -> str | None:
|
||||
def stream_rtsp_url(
|
||||
self,
|
||||
credentials: Credentials | None = None,
|
||||
*,
|
||||
stream_resolution: StreamResolution = StreamResolution.HD,
|
||||
) -> str | None:
|
||||
"""Return the local rtsp streaming url.
|
||||
|
||||
:param credentials: Credentials for camera account.
|
||||
@ -73,26 +100,30 @@ class Camera(SmartCamModule):
|
||||
:return: rtsp url with escaped credentials or None if no credentials or
|
||||
camera is off.
|
||||
"""
|
||||
if not self.is_on:
|
||||
if self._device._is_hub_child:
|
||||
return None
|
||||
dev = self._device
|
||||
|
||||
streams = {
|
||||
StreamResolution.HD: "stream1",
|
||||
StreamResolution.SD: "stream2",
|
||||
}
|
||||
if (stream := streams.get(stream_resolution)) is None:
|
||||
return None
|
||||
|
||||
if not credentials:
|
||||
credentials = self._get_credentials()
|
||||
|
||||
if not credentials or not credentials.username or not credentials.password:
|
||||
return None
|
||||
|
||||
username = quote_plus(credentials.username)
|
||||
password = quote_plus(credentials.password)
|
||||
return f"rtsp://{username}:{password}@{dev.host}:{LOCAL_STREAMING_PORT}/stream1"
|
||||
|
||||
async def set_state(self, on: bool) -> dict:
|
||||
"""Set the device state."""
|
||||
# Turning off enables the privacy mask which is why value is reversed.
|
||||
params = {"enabled": "off" if on else "on"}
|
||||
return await self._device._query_setter_helper(
|
||||
"setLensMaskConfig", self.QUERY_MODULE_NAME, "lens_mask_info", params
|
||||
)
|
||||
return f"rtsp://{username}:{password}@{self._device.host}:{LOCAL_STREAMING_PORT}/{stream}"
|
||||
|
||||
async def _check_supported(self) -> bool:
|
||||
"""Additional check to see if the module is supported by the device."""
|
||||
return self._device.device_type is DeviceType.Camera
|
||||
def onvif_url(self) -> str | None:
|
||||
"""Return the onvif url."""
|
||||
if self._device._is_hub_child:
|
||||
return None
|
||||
|
||||
return f"http://{self._device.host}:{ONVIF_PORT}/onvif/device_service"
|
||||
|
@ -19,7 +19,10 @@ class ChildDevice(SmartCamModule):
|
||||
|
||||
Default implementation uses the raw query getter w/o parameters.
|
||||
"""
|
||||
return {self.QUERY_GETTER_NAME: {"childControl": {"start_index": 0}}}
|
||||
q = {self.QUERY_GETTER_NAME: {"childControl": {"start_index": 0}}}
|
||||
if self._device.device_type is DeviceType.Hub:
|
||||
q["getChildDeviceComponentList"] = {"childControl": {"start_index": 0}}
|
||||
return q
|
||||
|
||||
async def _check_supported(self) -> bool:
|
||||
"""Additional check to see if the module is supported by the device."""
|
||||
|
112
kasa/smartcam/modules/childsetup.py
Normal file
112
kasa/smartcam/modules/childsetup.py
Normal file
@ -0,0 +1,112 @@
|
||||
"""Implementation for child device setup.
|
||||
|
||||
This module allows pairing and disconnecting child devices.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from ...feature import Feature
|
||||
from ...interfaces.childsetup import ChildSetup as ChildSetupInterface
|
||||
from ..smartcammodule import SmartCamModule
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChildSetup(SmartCamModule, ChildSetupInterface):
|
||||
"""Implementation for child device setup."""
|
||||
|
||||
REQUIRED_COMPONENT = "childQuickSetup"
|
||||
QUERY_GETTER_NAME = "getSupportChildDeviceCategory"
|
||||
QUERY_MODULE_NAME = "childControl"
|
||||
_categories: list[str] = []
|
||||
|
||||
# Supported child device categories will hardly ever change
|
||||
MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features."""
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
id="pair",
|
||||
name="Pair",
|
||||
container=self,
|
||||
attribute_setter="pair",
|
||||
category=Feature.Category.Config,
|
||||
type=Feature.Type.Action,
|
||||
)
|
||||
)
|
||||
|
||||
async def _post_update_hook(self) -> None:
|
||||
self._categories = [
|
||||
cat["category"].replace("ipcamera", "camera")
|
||||
for cat in self.data["device_category_list"]
|
||||
]
|
||||
|
||||
@property
|
||||
def supported_categories(self) -> list[str]:
|
||||
"""Supported child device categories."""
|
||||
return self._categories
|
||||
|
||||
async def pair(self, *, timeout: int = 10) -> list[dict]:
|
||||
"""Scan for new devices and pair them."""
|
||||
await self.call(
|
||||
"startScanChildDevice", {"childControl": {"category": self._categories}}
|
||||
)
|
||||
|
||||
_LOGGER.info("Waiting %s seconds for discovering new devices", timeout)
|
||||
|
||||
await asyncio.sleep(timeout)
|
||||
res = await self.call(
|
||||
"getScanChildDeviceList", {"childControl": {"category": self._categories}}
|
||||
)
|
||||
|
||||
detected_list = res["getScanChildDeviceList"]["child_device_list"]
|
||||
if not detected_list:
|
||||
_LOGGER.warning(
|
||||
"No devices found, make sure to activate pairing "
|
||||
"mode on the devices to be added."
|
||||
)
|
||||
return []
|
||||
|
||||
_LOGGER.info(
|
||||
"Discovery done, found %s devices: %s",
|
||||
len(detected_list),
|
||||
detected_list,
|
||||
)
|
||||
return await self._add_devices(detected_list)
|
||||
|
||||
async def _add_devices(self, detected_list: list[dict]) -> list[dict]:
|
||||
"""Add devices based on getScanChildDeviceList response."""
|
||||
await self.call(
|
||||
"addScanChildDeviceList",
|
||||
{"childControl": {"child_device_list": detected_list}},
|
||||
)
|
||||
|
||||
await self._device.update()
|
||||
|
||||
successes = []
|
||||
for detected in detected_list:
|
||||
device_id = detected["device_id"]
|
||||
|
||||
result = "not added"
|
||||
if device_id in self._device._children:
|
||||
result = "added"
|
||||
successes.append(detected)
|
||||
|
||||
msg = f"{detected['device_model']} - {device_id} - {result}"
|
||||
_LOGGER.info("Adding child to %s: %s", self._device.host, msg)
|
||||
|
||||
return successes
|
||||
|
||||
async def unpair(self, device_id: str) -> dict:
|
||||
"""Remove device from the hub."""
|
||||
_LOGGER.info("Going to unpair %s from %s", device_id, self)
|
||||
|
||||
payload = {"childControl": {"child_device_list": [{"device_id": device_id}]}}
|
||||
res = await self.call("removeChildDeviceList", payload)
|
||||
await self._device.update()
|
||||
return res
|
@ -14,6 +14,18 @@ class DeviceModule(SmartCamModule):
|
||||
QUERY_MODULE_NAME = "device_info"
|
||||
QUERY_SECTION_NAMES = ["basic_info", "info"]
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
if self._device._is_hub_child:
|
||||
# Child devices get their device info updated by the parent device.
|
||||
# and generally don't support connection type as they're not
|
||||
# connected to the network
|
||||
return {}
|
||||
q = super().query()
|
||||
q["getConnectionType"] = {"network": {"get_connection_type": []}}
|
||||
|
||||
return q
|
||||
|
||||
def _initialize_features(self) -> None:
|
||||
"""Initialize features after the initial update."""
|
||||
self._add_feature(
|
||||
@ -26,6 +38,32 @@ class DeviceModule(SmartCamModule):
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
if self.rssi is not None:
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
container=self,
|
||||
id="rssi",
|
||||
name="RSSI",
|
||||
attribute_getter="rssi",
|
||||
icon="mdi:signal",
|
||||
unit_getter=lambda: "dBm",
|
||||
category=Feature.Category.Debug,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
self._add_feature(
|
||||
Feature(
|
||||
self._device,
|
||||
container=self,
|
||||
id="signal_level",
|
||||
name="Signal Level",
|
||||
attribute_getter="signal_level",
|
||||
icon="mdi:signal",
|
||||
category=Feature.Category.Info,
|
||||
type=Feature.Type.Sensor,
|
||||
)
|
||||
)
|
||||
|
||||
async def _post_update_hook(self) -> None:
|
||||
"""Overriden to prevent module disabling.
|
||||
@ -37,4 +75,14 @@ class DeviceModule(SmartCamModule):
|
||||
@property
|
||||
def device_id(self) -> str:
|
||||
"""Return the device id."""
|
||||
return self.data["basic_info"]["dev_id"]
|
||||
return self._device._info["device_id"]
|
||||
|
||||
@property
|
||||
def rssi(self) -> int | None:
|
||||
"""Return the device id."""
|
||||
return self.data.get("getConnectionType", {}).get("rssiValue")
|
||||
|
||||
@property
|
||||
def signal_level(self) -> int | None:
|
||||
"""Return the device id."""
|
||||
return self.data.get("getConnectionType", {}).get("rssi")
|
||||
|
16
kasa/smartcam/modules/homekit.py
Normal file
16
kasa/smartcam/modules/homekit.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""Implementation of homekit module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..smartcammodule import SmartCamModule
|
||||
|
||||
|
||||
class HomeKit(SmartCamModule):
|
||||
"""Implementation of homekit module."""
|
||||
|
||||
REQUIRED_COMPONENT = "homekit"
|
||||
|
||||
@property
|
||||
def info(self) -> dict[str, str]:
|
||||
"""Not supported, return empty dict."""
|
||||
return {}
|
@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...interfaces.led import Led as LedInterface
|
||||
from ...smart.smartmodule import allow_update_after
|
||||
from ..smartcammodule import SmartCamModule
|
||||
|
||||
|
||||
@ -19,6 +20,7 @@ class Led(SmartCamModule, LedInterface):
|
||||
"""Return current led status."""
|
||||
return self.data["config"]["enabled"] == "on"
|
||||
|
||||
@allow_update_after
|
||||
async def set_led(self, enable: bool) -> dict:
|
||||
"""Set led.
|
||||
|
||||
|
33
kasa/smartcam/modules/lensmask.py
Normal file
33
kasa/smartcam/modules/lensmask.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""Implementation of lens mask privacy module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ...smart.smartmodule import allow_update_after
|
||||
from ..smartcammodule import SmartCamModule
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LensMask(SmartCamModule):
|
||||
"""Implementation of lens mask module."""
|
||||
|
||||
REQUIRED_COMPONENT = "lensMask"
|
||||
|
||||
QUERY_GETTER_NAME = "getLensMaskConfig"
|
||||
QUERY_MODULE_NAME = "lens_mask"
|
||||
QUERY_SECTION_NAMES = "lens_mask_info"
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Return the lens mask state."""
|
||||
return self.data["lens_mask_info"]["enabled"] == "on"
|
||||
|
||||
@allow_update_after
|
||||
async def set_enabled(self, enable: bool) -> dict:
|
||||
"""Set the lens mask state."""
|
||||
params = {"enabled": "on" if enable else "off"}
|
||||
return await self._device._query_setter_helper(
|
||||
"setLensMaskConfig", self.QUERY_MODULE_NAME, "lens_mask_info", params
|
||||
)
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user