Compare commits

...

136 Commits

Author SHA1 Message Date
ZeliardM
579fd5aa2a
Add LB100(US)_1.0_1.8.11 fixture file (#1515)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python 3.11 on macos-latest (push) Has been cancelled
CI / Python 3.12 on macos-latest (push) Has been cancelled
CI / Python 3.13 on macos-latest (push) Has been cancelled
CI / Python 3.11 on ubuntu-latest (push) Has been cancelled
CI / Python 3.12 on ubuntu-latest (push) Has been cancelled
CI / Python 3.13 on ubuntu-latest (push) Has been cancelled
CI / Python 3.11 on windows-latest (push) Has been cancelled
CI / Python 3.12 on windows-latest (push) Has been cancelled
CI / Python 3.13 on windows-latest (push) Has been cancelled
Stale / stale (push) Has been cancelled
The LB100 was already in the device_fixtures.py for tests, but was not
listed in the supported devices nor did it have a fixture file.
2025-03-04 08:16:47 +01:00
Teemu R.
8501390c61
Add a note to emeter guide being kasa-only (#1512)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python 3.11 on macos-latest (push) Has been cancelled
CI / Python 3.12 on macos-latest (push) Has been cancelled
CI / Python 3.13 on macos-latest (push) Has been cancelled
CI / Python 3.11 on ubuntu-latest (push) Has been cancelled
CI / Python 3.12 on ubuntu-latest (push) Has been cancelled
CI / Python 3.13 on ubuntu-latest (push) Has been cancelled
CI / Python 3.11 on windows-latest (push) Has been cancelled
CI / Python 3.12 on windows-latest (push) Has been cancelled
CI / Python 3.13 on windows-latest (push) Has been cancelled
Related to #1511
2025-02-25 22:44:44 +00:00
ZeliardM
f0abc2800d
Add KS225(US)_1.0_1.1.1 and L930-5(EU)_1.0_1.2.5 (#1509)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python 3.11 on macos-latest (push) Blocked by required conditions
CI / Python 3.12 on macos-latest (push) Blocked by required conditions
CI / Python 3.13 on macos-latest (push) Blocked by required conditions
CI / Python 3.11 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.12 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.13 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.11 on windows-latest (push) Blocked by required conditions
CI / Python 3.12 on windows-latest (push) Blocked by required conditions
CI / Python 3.13 on windows-latest (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Added fixture files, one device was added directly into HomeKit, and one
device was added through Matter from the obd_src. I'm not sure if that
makes a difference for you, but the devices are working correctly
through my plug-in with the latest python-kasa 0.10.2.
2025-02-24 20:42:12 +01:00
Steven B.
f488492c7d
Prepare 0.10.2 (#1505)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Has been cancelled
Stale / stale (push) Has been cancelled
## [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)
2025-02-12 19:29:44 +00:00
Alex Thomson
29195fa639
Add fixtures for new versions of H100, P110, and T100 devices (#1501)
Some checks failed
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Has been cancelled
2025-02-12 11:45:53 +00:00
Steven B.
8b138698b8
Add C110(EU) 2.0 1.4.3 fixture (#1503) 2025-02-12 11:41:16 +00:00
Steven B.
ad8a0eebec
Add L530B(EU) 3.0 1.1.9 fixture (#1502) 2025-02-12 11:38:39 +00:00
Teemu R.
668e32d3a5
Do not crash on missing build number in fw version (#1500)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Has been cancelled
Stale / stale (push) Has been cancelled
Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com>
2025-02-10 11:13:01 +00:00
EdwardWu
d5187dc6f1
Add L530E(TW) 2.0 1.1.1 fixture (#1497)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Has been cancelled
2025-02-07 08:02:21 +00:00
Steven B.
cbab40a59e
Prepare 0.10.1 (#1494)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Has been cancelled
## [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)
2025-02-02 14:02:19 +00:00
Ryan Nitcher
bff5409d22
Add Dimmer Configuration Support (#1484) 2025-02-02 13:48:34 +00:00
Teemu R.
8259d28b12
dustbin_mode: add 'off' mode for cleaner downstream impl (#1488)
Adds a new artificial "Off" mode for dustbin_mode,
which will allow avoiding the need to expose both a toggle and a select
in homeassistant.

This changes the behavior of the existing mode selection, as it is not
anymore possible to change the mode without activating the auto
collection.

* Mode is Off, if auto collection has been disabled
* When setting mode to "Off", this will disable the auto collection
* When setting mode to anything else than "Off", the auto collection
will be automatically enabled.
2025-02-02 14:00:49 +01:00
Steven B.
44c561b04d
Add FeatureAttributes to smartcam Alarm (#1489)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Has been cancelled
Co-authored-by: Teemu R. <tpr@iki.fi>
2025-01-29 19:32:01 +00:00
Steven B.
ebd370da74
Add module.device to the public api (#1478) 2025-01-29 19:49:38 +01:00
Steven B.
82fbe1226e
Do not return empty string for custom light effect name (#1491) 2025-01-29 18:49:06 +00:00
Steven B.
09e73faca3
Prepare 0.10.0 (#1473)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Has been cancelled
## [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 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)
- Add smartcam pet detection toggle module [\#1465](https://github.com/python-kasa/python-kasa/pull/1465) (@DawidPietrykowski)
- Only log one warning per unknown clean error code and status [\#1462](https://github.com/python-kasa/python-kasa/pull/1462) (@rytilahti)
- 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)
- Add support for doorbells and chimes [\#1435](https://github.com/python-kasa/python-kasa/pull/1435) (@steveredden)
- Implement vacuum dustbin module \(dust\_bucket\) [\#1423](https://github.com/python-kasa/python-kasa/pull/1423) (@rytilahti)
- Allow https for klaptransport [\#1415](https://github.com/python-kasa/python-kasa/pull/1415) (@rytilahti)
- Add smartcam child device support for smartcam hubs [\#1413](https://github.com/python-kasa/python-kasa/pull/1413) (@sdb9696)
- Add powerprotection module [\#1337](https://github.com/python-kasa/python-kasa/pull/1337) (@rytilahti)
- 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)

**Fixed bugs:**

- TP-Link HS300 Wi-Fi Power-Strip - "Parent On/Off" not functioning. [\#637](https://github.com/python-kasa/python-kasa/issues/637)
- 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)
- Report 0 for instead of None for zero current and voltage [\#1483](https://github.com/python-kasa/python-kasa/pull/1483) (@ryenitcher)
- Disable iot camera creation until more complete [\#1480](https://github.com/python-kasa/python-kasa/pull/1480) (@sdb9696)
- 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)
- 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 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)
- 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)

**Project maintenance:**

- 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 error code 7 for clean module [\#1474](https://github.com/python-kasa/python-kasa/pull/1474) (@rytilahti)
- 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)
- 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)
2025-01-26 17:15:00 +00:00
Teemu R.
781d07f6a2
Convert carpet_clean_mode to carpet_boost switch (#1486) 2025-01-26 16:16:24 +00:00
Teemu R.
1df05af208
Change category for empty dustbin feature from Primary to Config (#1485) 2025-01-26 16:14:45 +00:00
Steven B.
656c88771a
Add common alarm interface (#1479)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Add a common interface for the `alarm` module across `smart` and `smartcam` devices.
2025-01-26 13:33:13 +00:00
Teemu R.
d857cc68bb
Allow passing alarm parameter overrides (#1340)
Allows specifying alarm parameters duration, volume and sound.
Adds new feature: `alarm_duration`.
Breaking change to `alarm_volume' on the `smart.Alarm` module is changed from `str` to `int`
Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com>
2025-01-26 13:13:09 +00:00
Teemu R.
62c1dd87dc
Add powerprotection module (#1337)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Implements power protection on supported devices.
If the power usage is above the given threshold and the feature is
enabled, the device will be turned off.

Adds the following features:
* `overloaded` binary sensor
* `power_protection_threshold` number, setting this to `0` turns the
feature off.

---------

Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com>
2025-01-26 01:43:02 +01:00
Steven B.
ba6d6560f4
Disable iot camera creation until more complete (#1480)
Should address [HA Issue
135648](https://github.com/home-assistant/core/issues/https://github.com/home-assistant/core/issues/135648)
2025-01-26 00:19:29 +01:00
Ryan Nitcher
7f2a1be392
Add ADC Value to PIR Enabled Switches (#1263)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2025-01-25 10:45:48 +00:00
Ryan Nitcher
0aa1242a00
Report 0 for instead of None for zero current and voltage (#1483)
- Report `0` instead of `None` for current when current is zero.
- Report `0` instead of `None` for voltage when voltage is zero
2025-01-25 10:22:00 +01:00
Steven B.
5b9b89769a
Cancel in progress CI workflows after new pushes (#1481)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Create a concurreny group which will cancel in progress workflows after
new pushes to pull requests or python-kasa branches.
2025-01-24 18:45:14 +00:00
Steven B.
9b7bf367ae
Update ruff to 0.9 (#1482)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Ruff 0.9 contains a number of formatter changes for the 2025 style guide.
Update to `ruff>=0.9.0` and apply the formatter fixes.
https://astral.sh/blog/ruff-v0.9.0
2025-01-24 10:53:27 +00:00
Steven B.
09fce3f426
Add common childsetup interface (#1470)
Add a common interface for the `childsetup` module across `smart` and `smartcam` hubs.
Co-authored-by: Teemu R. <tpr@iki.fi>
2025-01-24 08:08:04 +00:00
Nathan Wreggit
b701441215
Fix iot strip turn on and off from parent (#639)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com>
2025-01-23 15:05:38 +00:00
Teemu R.
b6a584971a
Add error code 7 for clean module (#1474)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2025-01-23 11:43:02 +00:00
Steven B.
988eb96bd1
Update test framework to support smartcam device discovery. (#1477)
Update test framework to support `smartcam` device discovery:
- Add `SMARTCAM` to the default `discovery_mock` filter
- Make connection parameter derivation a self contained static method in `Discover`
- Introduce a queue to the `discovery_mock` to ensure the discovery callbacks
  complete in the same order that they started.
- Patch `Discover._decrypt_discovery_data` in `discovery_mock`
  so it doesn't error trying to decrypt empty fixture data
2025-01-23 11:26:55 +00:00
Steven B.
5e57f8bd6c
Add childsetup module to smartcam hubs (#1469)
Add the `childsetup` module for `smartcam` hubs to allow pairing and unpairing child devices.
2025-01-23 09:42:37 +00:00
Steven B.
bd43e0f7d2
Add D130(US) 1.0 1.1.9 fixture (#1476) 2025-01-23 09:35:54 +00:00
Steven B.
57c4ffa8a3
Add D100C(US) 1.0 1.1.3 fixture (#1475) 2025-01-23 09:29:25 +00:00
steveredden
54bb53899e
Add support for doorbells and chimes (#1435)
Add support for `smart` chimes and `smartcam` doorbells that are not hub child devices.
Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com>
2025-01-23 09:22:41 +00:00
Steven B.
acc0e9a80a
Enable CI workflow on PRs to feat/ fix/ and janitor/ (#1471)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
This will enable for PRs that we create to other branches.
2025-01-22 22:41:52 +01:00
Teemu R.
307173487a
Only log one warning per unknown clean error code and status (#1462)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com>
2025-01-22 16:58:04 +00:00
Teemu R.
7b1b14d1e6
Allow https for klaptransport (#1415)
Some checks failed
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Has been cancelled
Later firmware versions on robovacs use `KLAP` over https instead of ssltransport (reported as AES)
2025-01-22 10:54:32 +00:00
Steven B.
fa0f7157c6
Deprecate legacy light module is_capability checks (#1297)
Deprecate the `is_color`, `is_dimmable`, `is_variable_color_temp`, `valid_temperate_range`,
and `has_effects` attributes from the `Light` module, as 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.
2025-01-22 10:26:37 +00:00
Teemu R.
a03a4b1d63
Add consumables module for vacuums (#1327)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Has been cancelled
Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com>
2025-01-20 12:50:39 +00:00
Teemu R.
05085462d3
Add support for cleaning records (#945)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com>
2025-01-20 11:41:56 +00:00
Teemu R.
bca5576425
Add support for pairing devices with hubs (#859)
Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com>
2025-01-20 10:36:06 +00:00
DawidPietrykowski
2d26f91981
Add C220(EU) 1.0 1.2.2 camera fixture (#1466)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Has been cancelled
2025-01-18 13:22:53 +00:00
DawidPietrykowski
fd6067e5a0
Add smartcam pet detection toggle module (#1465) 2025-01-18 12:58:26 +00:00
Teemu R.
980f6a38ca
Add childlock module for vacuums (#1461)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Add new configuration feature:
```
Child lock (child_lock): False
```
---------

Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com>
2025-01-17 12:15:51 +00:00
Teemu R.
773801cad5
Add setting to change carpet clean mode (#1458)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Has been cancelled
Add new setting to control carpet clean mode:
```
== Configuration ==
Carpet clean mode (carpet_clean_mode): Normal *Boost*
```
2025-01-15 19:35:41 +00:00
Teemu R.
d27697c50f
Add ultra mode (fanspeed = 5) for vacuums (#1459) 2025-01-15 19:11:10 +00:00
Steven B.
b23019e748
Enable dynamic hub child creation and deletion on update (#1454) 2025-01-15 19:10:32 +00:00
Teemu R.
17356c10f1
Add mop module (#1456)
Adds the following new features: a setting to control water level and a sensor if the mop is attached:
```
Mop water level (mop_waterlevel): *Disable* Low Medium High
Mop attached (mop_attached): True
```
2025-01-15 19:12:33 +01:00
Teemu R.
bc97c0794a
Add setting to change clean count (#1457)
Adds a setting to change the number of times to clean:
```
== Configuration ==
Clean count (clean_count): 1 (range: 1-3)
```
2025-01-15 19:11:33 +01:00
Teemu R.
0f185f1905
Add commit-hook to prettify JSON files (#1455)
Some checks failed
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Has been cancelled
2025-01-15 16:06:52 +01:00
Teemu R.
2ab42f59b3
Fallback to is_low for batterysensor's battery_low (#1420)
Fallback to `is_low` if `at_low_battery` is not available.
2025-01-15 14:33:05 +01:00
Teemu R.
1355e85f8e
Expose current cleaning information (#1453)
Add new sensors to show the current cleaning state:
```
Cleaning area (clean_area): 0 0
Cleaning time (clean_time): 0:00:00
Cleaning progress (clean_progress): 100 %
```
2025-01-15 14:20:19 +01:00
Steven B.
4e7e18cef1
Add battery module to smartcam devices (#1452)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2025-01-14 21:57:35 +00:00
Teemu R.
2542516009
Add vacuum speaker controls (#1332)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Implements `speaker` and adds the following features:
* `volume`  to control the speaker volume
* `locate` to play "I'm here sound"
2025-01-14 16:48:34 +00:00
Teemu R.
3c98efb015
Implement vacuum dustbin module (dust_bucket) (#1423)
Initial implementation for dustbin auto-emptying.

New features:
- `dustbin_empty` action to empty the dustbin immediately
- `dustbin_autocollection_enabled` to toggle the auto collection
- `dustbin_mode` to choose how often the auto collection is performed
2025-01-14 17:30:18 +01:00
Steven B.
68f50aa763
Allow update of camera modules after setting values (#1450) 2025-01-14 15:11:12 +00:00
Steven B.
d03f535568
Fix discover cli command with host (#1437) 2025-01-14 14:47:52 +00:00
Teemu R.
1be87674bf
Initial support for vacuums (clean module) (#944)
Adds support for clean module:
- Show current vacuum state
- Start cleaning (all rooms)
- Return to dock
- Pausing & unpausing
- Controlling the fan speed

---------

Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com>
2025-01-14 15:35:09 +01:00
Steven B.
be34dbd387
Make uses_http a readonly property of device config (#1449)
`uses_http` will no longer be included in `DeviceConfig.to_dict()`
2025-01-14 14:20:53 +00:00
Steven B.
57f6c4138a
Add D230(EU) 1.20 1.1.19 fixture (#1448)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2025-01-14 08:46:29 +00:00
Steven B.
589d15091a
Add smartcam child device support for smartcam hubs (#1413) 2025-01-14 08:38:04 +00:00
Steven B.
a211cc0af5
Update hub children on first update and delay subsequent updates (#1438)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2025-01-13 17:19:40 +00:00
Teemu R.
333a36bf42
Add required sphinx.configuration (#1446)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2025-01-13 15:55:52 +00:00
Teemu R.
6420d76351
ssltransport: use debug logger for sending requests (#1443)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2025-01-12 17:06:48 +01:00
Steven B.
660b9f81de
Add more redactors for smartcams (#1439)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Has been cancelled
`alias` and `ext_addr` are new fields found on `smartcam` child devices
2025-01-10 18:34:11 +00:00
Steven B.
2e3b1bc376
Add tests for dump_devinfo parent/child smartcam fixture generation (#1428)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Has been cancelled
Currently the dump_devinfo fixture generation tests do not test
generation for hub and their children.

This PR enables tests for `smartcam` hubs and their child fixtures. It
does not enable support for `smart` hub fixtures as not all the fixtures
currently have the required info. This can be addressed in a subsequent
PR.
2025-01-08 22:51:35 +01:00
steveredden
debcff9f9b
Add fixture for C720 camera (#1433) 2025-01-08 22:22:26 +01:00
ZeliardM
3c038fc13b
Add KS230(US) 2.0 1.0.11 IOT Fixture (#1430)
Some checks failed
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Has been cancelled
2025-01-07 15:40:37 +00:00
Steven B.
7b3dde9aa0
Raise errors on single smartcam child requests (#1427)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2025-01-06 16:11:43 +00:00
Steven B.
40886ef24d
Prepare 0.9.1 (#1426)
## [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)
- Backoff after xor timeout and improve error reporting [\#1424](https://github.com/python-kasa/python-kasa/pull/1424) (@bdraco)
- Fix incorrect obd src echo [\#1412](https://github.com/python-kasa/python-kasa/pull/1412) (@rytilahti)
- 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 C210 2.0 1.3.11 fixture [\#1406](https://github.com/python-kasa/python-kasa/pull/1406) (@sdb9696)
- Add HS210\(US\) 3.0 1.0.10 IOT Fixture [\#1405](https://github.com/python-kasa/python-kasa/pull/1405) (@ZeliardM)
- Change smartcam detection features to category config [\#1402](https://github.com/python-kasa/python-kasa/pull/1402) (@sdb9696)
2025-01-06 14:24:54 +00:00
J. Nick Koston
7d508b5092
Backoff after xor timeout and improve error reporting (#1424) 2025-01-06 14:00:23 +00:00
Teemu R.
48a07a2970
Use repr() for enum values in Feature.__repr__ (#1414)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Instead of simply displaying the enum value, use repr to get a nicer
output for the cli.
Was: `Error (vacuum_error): 14`
Now: `Error (vacuum_error): <ErrorCode.DustBinRemoved: 14>`
2025-01-06 13:23:02 +01:00
Steven B.
6aa019280b
Handle smartcam partial list responses (#1411) 2025-01-06 09:23:46 +00:00
Teemu R.
1f45f425a0
Add S210 fixture (#1418)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Has been cancelled
2025-01-04 19:09:58 +00:00
Teemu R.
08639a3a7b
Add S220 fixture (#1419)
Add S220 (hub-connected) fixture, thanks to @chrisnewmanuk.

Drafted as requires adding `subg.plugswitch.switch` as a supported child
device category.

ref
https://github.com/home-assistant/core/issues/133973#issuecomment-2569967648
2025-01-04 19:47:12 +01:00
Steven B.
6e0be2ea1f
Add support for Tapo hub-attached switch devices (#1421)
Required for #1419 and #1418
2025-01-04 14:20:06 +01:00
Teemu R.
e097b45984
Improve exception messages on credential mismatches (#1417)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2025-01-04 10:06:26 +00:00
Steven B.
0a95a41ab6
Update SslAesTransport for older firmware versions (#1362)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Older firmware versions do not encrypt the payload.

Tested to work with C110 hw 2.0 fw 1.3.7 Build 230823 Rel.57279n(5553)

---------

Co-authored-by: Teemu R. <tpr@iki.fi>
2025-01-03 21:00:57 +01:00
Teemu R.
883d52209e
Fix incorrect obd src echo (#1412)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2025-01-03 18:07:46 +00:00
Steven B.
361697a239
Change smartcam detection features to category config (#1402)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2025-01-03 07:08:23 +00:00
Steven B.
5d49623d5d
Add C210 2.0 1.3.11 fixture (#1406) 2025-01-03 06:55:55 +00:00
ZeliardM
d0aba68e7a
Add HS210(US) 3.0 1.0.10 IOT Fixture (#1405)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Has been cancelled
Stale / stale (push) Has been cancelled
2024-12-24 15:56:14 +00:00
Steven B.
63f4f82791
Prepare 0.9.0 (#1401)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Has been cancelled
## [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 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)
- 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)
- Return raw discovery result in cli discover raw [\#1342](https://github.com/python-kasa/python-kasa/pull/1342) (@sdb9696)
- cli: print model, https, and lv for discover list [\#1339](https://github.com/python-kasa/python-kasa/pull/1339) (@rytilahti)
- 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)
- 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)

**Fixed bugs:**

- Tapo H200 Hub does not work with python-kasa [\#1149](https://github.com/python-kasa/python-kasa/issues/1149)
- Treat smartcam 500 errors after handshake as retryable [\#1395](https://github.com/python-kasa/python-kasa/pull/1395) (@sdb9696)
- 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)
- 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 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)
- 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)

**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)
- Update docs for new FeatureAttribute behaviour [\#1365](https://github.com/python-kasa/python-kasa/pull/1365) (@sdb9696)
- Add link to related homeassistant-tapo-control [\#1333](https://github.com/python-kasa/python-kasa/pull/1333) (@rytilahti)

**Project maintenance:**

- Add P135 1.0 1.2.0 fixture [\#1397](https://github.com/python-kasa/python-kasa/pull/1397) (@sdb9696)
- 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)
- 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)
- 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)
- Force single for some smartcam requests [\#1374](https://github.com/python-kasa/python-kasa/pull/1374) (@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)
- 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)
- Update dump\_devinfo for raw discovery json and common redactors [\#1358](https://github.com/python-kasa/python-kasa/pull/1358) (@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)
2024-12-21 16:47:46 +00:00
Bipolar Chemist
9b1be1c0b2
Add P306(US) 1.0 1.1.2 fixture (#1396)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2024-12-21 09:36:57 +00:00
Steven B.
d81cf1b3b6
Add P210M(US) 1.0 1.0.3 fixture (#1399) 2024-12-21 09:20:12 +00:00
Steven B.
cef0e571a0
Add C225(US) 2.0 1.0.11 fixture (#1398) 2024-12-21 09:17:50 +00:00
Steven B.
522c78350e
Add P135 1.0 1.2.0 fixture (#1397) 2024-12-21 09:17:00 +00:00
Steven B.
8418ba3eef
Treat smartcam 500 errors after handshake as retryable (#1395)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
`smartcam` devices can respond with 500 if another session is created from the same host
2024-12-20 19:23:18 +00:00
Steven B.
93ca3ad2e1
Handle smartcam device blocked response (#1393)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Devices that have failed authentication multiple times due to bad credentials go into a blocked state for 30 mins.
Handle that as a different error type instead of treating it as a normal `AuthenticationError`.
2024-12-20 14:55:15 +00:00
Steven B.
296af3192e
Handle KeyboardInterrupts in the cli better (#1391)
Addresses an issue with how `asyncclick` deals with `KeyboardInterrupt`
errors. Instead of the `click.main` receiving `KeyboardInterrupt` it
receives `CancelledError` because it's a task running inside the loop.

Also ensures that discovery catches the `CancelledError` and closes the
http clients.
2024-12-20 14:21:38 +01:00
Teemu R.
fe88b52e19
Fallback to other module data on get_energy_usage errors (#1245)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
- The `get_energy_usage` query can fail if the device time is not set because the response includes the device time.
- Make `get_energy_usage` an optional query response so the energy module can fall back to getting the power from `get_emeter_data` or `get_current_power` on error.
- Devices on `energy_monitoring` version 1 still fail as they have no additional queries to fall back to.
2024-12-20 08:53:07 +00:00
Steven B.
83eb73cc7f
Add rssi and signal_level to smartcam (#1392) 2024-12-20 06:16:18 +00:00
Steven B.
d890b0a3ac
Add smartcam detection modules (#1389)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
- Motion detection
- Person detection
- Tamper detection
- Baby Cry Detection
2024-12-20 00:22:08 +01:00
Steven B.
b5f49a3c8a
Fix lens mask required component and state (#1386)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Fixes a few issues with the lens mask since migrating it into its own
module:

- The module didn't provide itself as the container and hence the
feature was accessing the same properties on the device.
- `enabled` getter on the module incorrect but not picked up due to the
previous issue.
- No `REQUIRED_COMPONENT` set to ensure the module only created if
available.

Also changes attribute names to `enabled` from `state` to avoid
confusion with device states.
2024-12-19 14:52:25 +01:00
Steven B.
b78e09caa0
Add TC70 3.0 1.3.11 fixture (#1390)
Many thanks to @allanbeth for the fixture!
2024-12-19 14:48:03 +01:00
Steven B.
47934dbf96
Add C325WB(EU) 1.0 1.1.17 Fixture (#1379)
Some checks failed
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Has been cancelled
2024-12-18 11:43:20 +00:00
Steven B.
ba273f308e
Add LensMask module to smartcam (#1385)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Ensures no error with devices that do not have the `lens_mask` component.
2024-12-17 20:15:42 +00:00
Teemu R.
37ef7b0463
cli: print model, https, and lv for discover list (#1339)
```
kasa --target 192.168.xx.xx discover list

HOST            MODEL     DEVICE FAMILY        ENCRYPT HTTPS LV  ALIAS
192.168.xxx.xxx KP115(EU) IOT.SMARTPLUGSWITCH  XOR     0     -   Fridge
192.168.xxx.xxx L900-5    SMART.TAPOBULB       KLAP    0     2   L900
192.168.xxx.xxx P115      SMART.TAPOPLUG       AES     0     2   Nightdesk
192.168.xxx.xxx TC65      SMART.IPCAMERA       AES     1     2   Tapo_TC65_B593

```

Also handles `TimeoutError` and `Exception` during `update()`

---------

Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com>
2024-12-17 21:09:17 +01:00
Steven B.
14d5629de1
Update C520WS fixture with new methods (#1384)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2024-12-17 10:59:57 +00:00
Steven B.
c6c4490a49
Add C100 4.0 1.3.14 Fixture (#1378) 2024-12-17 10:59:24 +00:00
Steven B.
fe072657b4
Simplify get_protocol to prevent clashes with smartcam and robovac (#1377)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2024-12-17 07:39:17 +00:00
Steven B.
5918e4daa7
Enable saving of fixture files without git clone (#1375)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Allows `dump_devinfo` to be run without fixture subfolders present from cloned repository
2024-12-16 13:42:42 +00:00
Steven B.
d03a387a74
Add new methods to dump_devinfo (#1373)
Adds `getMatterSetupInfo`, `getConnectStatus` and `scanApList`
2024-12-16 13:06:26 +00:00
Steven B.
e206d9b4df
Miscellaneous minor fixes to dump_devinfo (#1382)
Fixes:
 - Decrypted discovery data saved under `discovery_result` instead of `result`
 - `smart` child data not redacted
 - `smartcam` child component list `device_id` not `SCRUBBED`
2024-12-16 13:00:28 +00:00
Steven B.
62345be916
Add timeout parameter to dump_devinfo (#1381) 2024-12-16 13:48:27 +01:00
Steven B.
e9109447a7
Add smartcam modules to package inits (#1376) 2024-12-16 13:20:26 +01:00
Steven B.
031ebcd97f
Update docs for Tapo Lab Third-Party compatibility (#1380) 2024-12-16 13:19:25 +01:00
Steven B.
f8503e4df6
Force single for some smartcam requests (#1374)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
`onboarding` requests do not return the method key and need to be sent as single requests.
2024-12-15 16:03:12 +00:00
Steven B.
c439530f93
Add bare bones homekit modules smart and smartcam devices (#1370)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Has been cancelled
2024-12-14 13:34:58 +00:00
Steven B.
59e5073509
Update docs for new FeatureAttribute behaviour (#1365)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Co-authored-by: Teemu R. <tpr@iki.fi>
2024-12-13 22:23:58 +01:00
Steven B.
2ca6d3ebe9
Add bare-bones matter modules to smart and smartcam devices (#1371) 2024-12-13 19:45:38 +00:00
Steven B.
223f3318ea
Use DeviceInfo consistently across devices (#1338)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
- Make model exclude region for `iot` devices. This is consistent with `smart` and `smartcam` devices.
- Make region it's own attribute on `Device`.
- Ensure that devices consistently use `_get_device_info` static methods for all information relating to device models.
- Fix issue with firmware and hardware being the wrong way round for `smartcam` devices.
2024-12-13 12:37:13 +00:00
Teemu R.
5f84c69774
Add homebridge-kasa-python link to README (#1367)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2024-12-12 09:51:45 +00:00
Steven B.
7709bb967f
Update cli, light modules, and docs to use FeatureAttributes (#1364)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2024-12-11 15:53:35 +00:00
Steven B.
f8a46f74cd
Pass raw components to SmartChildDevice init (#1363)
Clean up and consolidate the processing of raw component query responses and simplify the code paths for creating smartcam child devices when supported.
2024-12-11 14:38:38 +00:00
Steven B.
8cb5c2e180
Update dump_devinfo for raw discovery json and common redactors (#1358)
Some checks failed
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Has been cancelled
This PR does a few related things to dump_devinfo:

- Store the raw discovery result in the fixture.
- Consolidate redaction logic so it's not duplicated in dump_devinfo.
- Update existing fixtures to:
  - Store raw discovery result under `result`
  - Use `SCRUBBED_CHILD_DEVICE_ID` everywhere
  - Have correct values as per the consolidated redactors.
2024-12-11 14:18:44 +01:00
Teemu R.
032cd5d2cc
Improve overheat reporting (#1335)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Different devices and different firmwares report overheated status in
different ways.
Some devices indicate support for `overheat_protect` component, but
there are devices that report `overheat_status` even when it is not
listed.
Some other devices use `overheated` boolean that was already previously
supported, but this PR adds support for much more devices that use
`overheat_status` for reporting.

The "overheated" feature is moved into its own module, and uses either
of the ways to report this information.
This will also rename `REQUIRED_KEY_ON_PARENT` to `SYSINFO_LOOKUP_KEYS`
and change its logic to check if any of the keys in the list are found
in the sysinfo.

```
tpr@lumipyry ~/c/p/tests (fix/overheated)> ag 'overheat_protect' -c|wc -l
15
tpr@lumipyry ~/c/p/tests (fix/overheated)> ag 'overheated' -c|wc -l
38
tpr@lumipyry ~/c/p/tests (fix/overheated)> ag 'overheat_status' -c|wc -l
20
```

---------

Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com>
2024-12-11 01:01:36 +01:00
Steven B.
bf8f0adabe
Return raw discovery result in cli discover raw (#1342)
Add `on_discovered_raw` callback to Discover and adds a cli command `discover raw` which returns the raw json before serializing to a `DiscoveryResult` and attempting to create a device class.
2024-12-10 22:42:14 +00:00
Steven B.
464683e09b
Tweak RELEASING.md instructions for patch releases (#1347)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2024-12-10 22:23:04 +01:00
Steven B.
ed0481918c
Fix line endings in device_fixtures.py (#1361)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
2024-12-10 08:37:57 +00:00
ZeliardM
2f87ccd201
Add KS200 (US) IOT Fixture and P115 (US) Smart Fixture (#1355) 2024-12-10 06:14:17 +00:00
Happy-Cadaver
fd74b07e2c
Add C520WS camera fixture (#1352)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run
Adding the C520WS fixture file

---------

Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com>
2024-12-10 00:24:27 +01:00
Puxtril
cb89342be1
Add LinkieTransportV2 and basic IOT.IPCAMERA support (#1270)
Add LinkieTransportV2 transport used by kasa cameras and a basic
implementation for IOT.IPCAMERA (kasacam) devices.

---------

Co-authored-by: Zach Price <pricezt@ornl.gov>
Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com>
Co-authored-by: Teemu Rytilahti <tpr@iki.fi>
2024-12-07 00:06:58 +01:00
Steven B.
6d9b4421fe
Merge patch into master (#1346) 2024-12-06 12:01:24 +00:00
Steven B
7b9fe7f693
Merge patch into local master 2024-12-06 11:51:26 +00:00
Steven B.
be8b7139b8
Fix update errors on hubs with unsupported children (#1344) 2024-12-06 11:01:44 +00:00
Steven B.
1c9ee4d537
Fix smartcam missing device id (#1343) 2024-12-06 09:40:44 +00:00
Steven B.
8814d94989
Provide alternative camera urls (#1316) 2024-12-05 16:49:35 +00:00
Steven B.
4eed945e00
Do not error when accessing smart device_type before update (#1319) 2024-12-05 09:14:45 +00:00
Teemu R.
123ea107b1
Add link to related homeassistant-tapo-control (#1333) 2024-12-02 16:38:20 +01:00
Teemu R.
74b59d7f98
Scrub more vacuum keys (#1328) 2024-12-01 18:07:05 +01:00
Teemu R.
9966c6094a
Add ssltransport for robovacs (#943)
This PR implements a clear-text, token-based transport protocol seen on
RV30 Plus (#937).

- Client sends `{"username": "email@example.com", "password":
md5(password)}` and gets back a token in the response
- Rest of the communications are done with POST at `/app?token=<token>`

---------

Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com>
2024-12-01 18:06:48 +01:00
Teemu R.
9a52056522
Remove unnecessary check for python <3.10 (#1326) 2024-11-30 15:35:38 +00:00
Teemu R.
d122b48788
Add vacuum component queries to dump_devinfo (#1320) 2024-11-29 19:02:04 +00:00
Steven B.
5ef8f21b4d
Handle missing mgt_encryption_schm in discovery (#1318) 2024-11-29 15:23:16 +00:00
Teemu R.
fcb604e435
Follow main package structure for tests (#1317)
* Transport tests under tests/transports/
* Protocol tests under tests/protocols/
* IOT tests under iot/
* Plus some minor cleanups, most code changes are related to splitting
up smart & iot tests
2024-11-28 17:56:20 +01:00
347 changed files with 36202 additions and 4487 deletions

View File

@ -2,11 +2,22 @@ name: CI
on: on:
push: push:
branches: ["master", "patch"] branches:
- master
- patch
pull_request: pull_request:
branches: ["master", "patch"] branches:
- master
- patch
- 'feat/**'
- 'fix/**'
- 'janitor/**'
workflow_dispatch: # to allow manual re-runs workflow_dispatch: # to allow manual re-runs
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env: env:
UV_VERSION: 0.4.16 UV_VERSION: 0.4.16

View File

@ -2,12 +2,23 @@ name: "CodeQL checks"
on: on:
push: push:
branches: [ "master", "patch" ] branches:
- master
- patch
pull_request: pull_request:
branches: [ master, "patch" ] branches:
- master
- patch
- 'feat/**'
- 'fix/**'
- 'janitor/**'
schedule: schedule:
- cron: '44 17 * * 3' - cron: '44 17 * * 3'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs: jobs:
analyze: analyze:
name: Analyze name: Analyze

View File

@ -2,13 +2,13 @@ repos:
- repo: https://github.com/astral-sh/uv-pre-commit - repo: https://github.com/astral-sh/uv-pre-commit
# uv version. # uv version.
rev: 0.4.16 rev: 0.5.30
hooks: hooks:
# Update the uv lockfile # Update the uv lockfile
- id: uv-lock - id: uv-lock
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0 rev: v5.0.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
- id: end-of-file-fixer - id: end-of-file-fixer
@ -16,16 +16,20 @@ repos:
- id: check-yaml - id: check-yaml
- id: debug-statements - id: debug-statements
- id: check-ast - id: check-ast
- id: pretty-format-json
args:
- "--autofix"
- "--indent=4"
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.4 rev: v0.9.6
hooks: hooks:
- id: ruff - id: ruff
args: [--fix, --exit-non-zero-on-fix] args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format - id: ruff-format
- repo: https://github.com/PyCQA/doc8 - repo: https://github.com/PyCQA/doc8
rev: 'v1.1.1' rev: 'v1.1.2'
hooks: hooks:
- id: doc8 - id: doc8
additional_dependencies: [tomli] additional_dependencies: [tomli]

View File

@ -2,6 +2,10 @@ version: 2
formats: all formats: all
sphinx:
configuration: docs/source/conf.py
build: build:
os: ubuntu-22.04 os: ubuntu-22.04
tools: tools:

View File

@ -1,8 +1,265 @@
# Changelog # Changelog
## [0.8.1](https://github.com/python-kasa/python-kasa/tree/0.8.1) (2024-12-06) ## [0.10.2](https://github.com/python-kasa/python-kasa/tree/0.10.2) (2025-02-12)
This patch release fixes some issues with newly supported smartcam devices. [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) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.8.0...0.8.1)
@ -46,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) - 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 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) - 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) - 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) - 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) - 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) - Add common Thermostat module [\#977](https://github.com/python-kasa/python-kasa/pull/977) (@sdb9696)
**Fixed bugs:** **Fixed bugs:**
- TP-Link Tapo S505D cannot disable gradual on/off [\#1309](https://github.com/python-kasa/python-kasa/issues/1309) - 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) - 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) - 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) - 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) - 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) - 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) - Inconsistent emeter information between features and emeter cli [\#1308](https://github.com/python-kasa/python-kasa/issues/1308)
- Expose energy command to cli [\#1307](https://github.com/python-kasa/python-kasa/pull/1307) (@rytilahti)
- Make discovery on unsupported devices less noisy [\#1291](https://github.com/python-kasa/python-kasa/pull/1291) (@rytilahti) - 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 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) - 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) - 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:** **Added support for devices:**
@ -81,13 +338,11 @@ Special thanks to @ryenitcher and @Puxtril for their new contributions to the im
**Documentation updates:** **Documentation updates:**
- Use markdown footnotes in supported.md [\#1310](https://github.com/python-kasa/python-kasa/pull/1310) (@sdb9696) - 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) - 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:** **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) - 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) - 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) - Rename smartcamera to smartcam [\#1300](https://github.com/python-kasa/python-kasa/pull/1300) (@sdb9696)
@ -117,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) - 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) - 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) - 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) - 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) - 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) - 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) - 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) - 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:** **Closed issues:**

View File

@ -178,14 +178,18 @@ The following devices have been tested and confirmed as working. If your device
> [!NOTE] > [!NOTE]
> The hub attached Tapo buttons S200B and S200D do not currently support alerting when the button is pressed. > 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 --> <!--Do not edit text inside the SUPPORTED section below -->
<!--SUPPORTED_START--> <!--SUPPORTED_START-->
### Supported Kasa devices ### Supported Kasa devices
- **Plugs**: EP10, EP25[^1], HS100[^2], HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M[^1], KP401 - **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 - **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] - **Wall Switches**: ES20M, HS200[^2], HS210, HS220[^2], KP405, KS200, KS200M, KS205[^1], KS220, KS220M, KS225[^1], KS230, KS240[^1]
- **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110 - **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB100, LB110
- **Light Strips**: KL400L5, KL420L5, KL430 - **Light Strips**: KL400L5, KL420L5, KL430
- **Hubs**: KH100[^1] - **Hubs**: KH100[^1]
- **Hub-Connected Devices[^3]**: KE100[^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 ### Supported Tapo[^1] devices
- **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15 - **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15
- **Power Strips**: P300, P304M, TP25 - **Power Strips**: P210M, P300, P304M, P306, TP25
- **Wall Switches**: S500D, S505, S505D - **Wall Switches**: S210, S220, S500D, S505, S505D
- **Bulbs**: L510B, L510E, L530E, L630 - **Bulbs**: L510B, L510E, L530B, L530E, L630
- **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **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 - **Hubs**: H100, H200
- **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 - **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/) * [Home Assistant](https://www.home-assistant.io/integrations/tplink/)
* [MQTT access to TP-Link devices, using python-kasa](https://github.com/flavio-fernandes/mqtt2kasa) * [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 ### Other related projects
* [PyTapo - Python library for communication with Tapo Cameras](https://github.com/JurajNyiri/pytapo) * [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) * [Tapo P100 (Tapo plugs, Tapo bulbs)](https://github.com/fishbigger/TapoP100)
* [Home Assistant integration](https://github.com/fishbigger/HomeAssistant-Tapo-P100-Control) * [Home Assistant integration](https://github.com/fishbigger/HomeAssistant-Tapo-P100-Control)
* [plugp100, another tapo library](https://github.com/petretiandrea/plugp100) * [plugp100, another tapo library](https://github.com/petretiandrea/plugp100)

View File

@ -44,9 +44,10 @@ uv lock --upgrade
uv sync --all-extras uv sync --all-extras
``` ```
### Run pre-commit and tests ### Update and run pre-commit and tests
```bash ```bash
pre-commit autoupdate
uv run pre-commit run --all-files uv run pre-commit run --all-files
uv run pytest -n auto 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 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 #### Merge the PR once the CI passes
Create a squash commit and add the markdown from the PR description to the commit description. 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 checkout -b janitor/merge_patch
git fetch upstream patch git fetch upstream patch
git merge upstream/patch --no-commit 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 git diff --name-only --diff-filter=U | xargs git checkout upstream/master
# Check the diff is as expected
git diff --staged 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 # 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]` # If there are any other unexpected diffs `git checkout upstream/master [thefilename]`
git commit -m "Merge patch into local master" -S git commit -m "Merge patch into local master" -S

View File

@ -5,6 +5,9 @@ The following devices have been tested and confirmed as working. If your device
> [!NOTE] > [!NOTE]
> The hub attached Tapo buttons S200B and S200D do not currently support alerting when the button is pressed. > 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 --> <!--Do not edit text inside the SUPPORTED section below -->
<!--SUPPORTED_START--> <!--SUPPORTED_START-->
@ -90,6 +93,7 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th
- **HS210** - **HS210**
- Hardware: 1.0 (US) / Firmware: 1.5.8 - Hardware: 1.0 (US) / Firmware: 1.5.8
- Hardware: 2.0 (US) / Firmware: 1.1.5 - Hardware: 2.0 (US) / Firmware: 1.1.5
- Hardware: 3.0 (US) / Firmware: 1.0.10
- **HS220** - **HS220**
- Hardware: 1.0 (US) / Firmware: 1.5.7 - Hardware: 1.0 (US) / Firmware: 1.5.7
- Hardware: 2.0 (US) / Firmware: 1.0.3 - 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** - **KP405**
- Hardware: 1.0 (US) / Firmware: 1.0.5 - Hardware: 1.0 (US) / Firmware: 1.0.5
- Hardware: 1.0 (US) / Firmware: 1.0.6 - Hardware: 1.0 (US) / Firmware: 1.0.6
- **KS200**
- Hardware: 1.0 (US) / Firmware: 1.0.8
- **KS200M** - **KS200M**
- Hardware: 1.0 (US) / Firmware: 1.0.10 - Hardware: 1.0 (US) / Firmware: 1.0.10
- Hardware: 1.0 (US) / Firmware: 1.0.11 - 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** - **KS225**
- Hardware: 1.0 (US) / Firmware: 1.0.2[^1] - 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.0[^1]
- Hardware: 1.0 (US) / Firmware: 1.1.1[^1]
- **KS230** - **KS230**
- Hardware: 1.0 (US) / Firmware: 1.0.14 - Hardware: 1.0 (US) / Firmware: 1.0.14
- Hardware: 2.0 (US) / Firmware: 1.0.11
- **KS240** - **KS240**
- Hardware: 1.0 (US) / Firmware: 1.0.4[^1] - Hardware: 1.0 (US) / Firmware: 1.0.4[^1]
- Hardware: 1.0 (US) / Firmware: 1.0.5[^1] - Hardware: 1.0 (US) / Firmware: 1.0.5[^1]
@ -141,6 +149,8 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th
- **KL60** - **KL60**
- Hardware: 1.0 (UN) / Firmware: 1.1.4 - Hardware: 1.0 (UN) / Firmware: 1.1.4
- Hardware: 1.0 (US) / Firmware: 1.1.13 - Hardware: 1.0 (US) / Firmware: 1.1.13
- **LB100**
- Hardware: 1.0 (US) / Firmware: 1.8.11
- **LB110** - **LB110**
- Hardware: 1.0 (US) / Firmware: 1.8.11 - 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.3.7
- Hardware: 1.0.0 (US) / Firmware: 1.4.0 - Hardware: 1.0.0 (US) / Firmware: 1.4.0
- **P110** - **P110**
- Hardware: 1.0 (AU) / Firmware: 1.3.1
- Hardware: 1.0 (EU) / Firmware: 1.0.7 - Hardware: 1.0 (EU) / Firmware: 1.0.7
- Hardware: 1.0 (EU) / Firmware: 1.2.3 - Hardware: 1.0 (EU) / Firmware: 1.2.3
- Hardware: 1.0 (UK) / Firmware: 1.3.0 - 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 - Hardware: 1.0 (EU) / Firmware: 1.2.3
- **P115** - **P115**
- Hardware: 1.0 (EU) / Firmware: 1.2.3 - Hardware: 1.0 (EU) / Firmware: 1.2.3
- Hardware: 1.0 (US) / Firmware: 1.1.3
- **P125M** - **P125M**
- Hardware: 1.0 (US) / Firmware: 1.1.0 - Hardware: 1.0 (US) / Firmware: 1.1.0
- **P135** - **P135**
- Hardware: 1.0 (US) / Firmware: 1.0.5 - Hardware: 1.0 (US) / Firmware: 1.0.5
- Hardware: 1.0 (US) / Firmware: 1.2.0
- **TP15** - **TP15**
- Hardware: 1.0 (US) / Firmware: 1.0.3 - Hardware: 1.0 (US) / Firmware: 1.0.3
### Power Strips ### Power Strips
- **P210M**
- Hardware: 1.0 (US) / Firmware: 1.0.3
- **P300** - **P300**
- Hardware: 1.0 (EU) / Firmware: 1.0.13 - Hardware: 1.0 (EU) / Firmware: 1.0.13
- Hardware: 1.0 (EU) / Firmware: 1.0.15 - Hardware: 1.0 (EU) / Firmware: 1.0.15
- Hardware: 1.0 (EU) / Firmware: 1.0.7 - Hardware: 1.0 (EU) / Firmware: 1.0.7
- **P304M** - **P304M**
- Hardware: 1.0 (UK) / Firmware: 1.0.3 - Hardware: 1.0 (UK) / Firmware: 1.0.3
- **P306**
- Hardware: 1.0 (US) / Firmware: 1.1.2
- **TP25** - **TP25**
- Hardware: 1.0 (US) / Firmware: 1.0.2 - Hardware: 1.0 (US) / Firmware: 1.0.2
### Wall Switches ### Wall Switches
- **S210**
- Hardware: 1.0 (EU) / Firmware: 1.9.0
- **S220**
- Hardware: 1.0 (EU) / Firmware: 1.9.0
- **S500D** - **S500D**
- Hardware: 1.0 (US) / Firmware: 1.0.5 - Hardware: 1.0 (US) / Firmware: 1.0.5
- **S505** - **S505**
@ -226,10 +247,13 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
- **L510E** - **L510E**
- Hardware: 3.0 (US) / Firmware: 1.0.5 - Hardware: 3.0 (US) / Firmware: 1.0.5
- Hardware: 3.0 (US) / Firmware: 1.1.2 - Hardware: 3.0 (US) / Firmware: 1.1.2
- **L530B**
- Hardware: 3.0 (EU) / Firmware: 1.1.9
- **L530E** - **L530E**
- Hardware: 3.0 (EU) / Firmware: 1.0.6 - Hardware: 3.0 (EU) / Firmware: 1.0.6
- Hardware: 3.0 (EU) / Firmware: 1.1.0 - Hardware: 3.0 (EU) / Firmware: 1.1.0
- Hardware: 3.0 (EU) / Firmware: 1.1.6 - Hardware: 3.0 (EU) / Firmware: 1.1.6
- Hardware: 2.0 (TW) / Firmware: 1.1.1
- Hardware: 2.0 (US) / Firmware: 1.1.0 - Hardware: 2.0 (US) / Firmware: 1.1.0
- **L630** - **L630**
- Hardware: 1.0 (EU) / Firmware: 1.1.2 - 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.0
- Hardware: 1.0 (US) / Firmware: 1.1.3 - Hardware: 1.0 (US) / Firmware: 1.1.3
- **L930-5** - **L930-5**
- Hardware: 1.0 (EU) / Firmware: 1.2.5
- Hardware: 1.0 (US) / Firmware: 1.1.2 - Hardware: 1.0 (US) / Firmware: 1.1.2
### Cameras ### Cameras
- **C100**
- Hardware: 4.0 / Firmware: 1.3.14
- **C110**
- Hardware: 2.0 (EU) / Firmware: 1.4.3
- **C210** - **C210**
- Hardware: 2.0 / Firmware: 1.3.11
- Hardware: 2.0 (EU) / Firmware: 1.4.2 - Hardware: 2.0 (EU) / Firmware: 1.4.2
- Hardware: 2.0 (EU) / Firmware: 1.4.3 - 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** - **TC65**
- Hardware: 1.0 / Firmware: 1.3.9 - 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 ### Hubs
- **H100** - **H100**
- Hardware: 1.0 (AU) / Firmware: 1.5.23
- Hardware: 1.0 (EU) / Firmware: 1.2.3 - Hardware: 1.0 (EU) / Firmware: 1.2.3
- Hardware: 1.0 (EU) / Firmware: 1.5.10 - Hardware: 1.0 (EU) / Firmware: 1.5.10
- Hardware: 1.0 (EU) / Firmware: 1.5.5 - Hardware: 1.0 (EU) / Firmware: 1.5.5
- **H200** - **H200**
- Hardware: 1.0 (EU) / Firmware: 1.3.2 - Hardware: 1.0 (EU) / Firmware: 1.3.2
- Hardware: 1.0 (EU) / Firmware: 1.3.6
- Hardware: 1.0 (US) / Firmware: 1.3.6 - Hardware: 1.0 (US) / Firmware: 1.3.6
### Hub-Connected Devices ### 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 - Hardware: 1.0 (EU) / Firmware: 1.12.0
- **T100** - **T100**
- Hardware: 1.0 (EU) / Firmware: 1.12.0 - Hardware: 1.0 (EU) / Firmware: 1.12.0
- Hardware: 1.0 (US) / Firmware: 1.12.0
- **T110** - **T110**
- Hardware: 1.0 (EU) / Firmware: 1.8.0 - Hardware: 1.0 (EU) / Firmware: 1.8.0
- Hardware: 1.0 (EU) / Firmware: 1.9.0 - Hardware: 1.0 (EU) / Firmware: 1.9.0

View File

@ -10,8 +10,6 @@ and finally execute a query to query all of them at once.
from __future__ import annotations from __future__ import annotations
import base64
import collections.abc
import dataclasses import dataclasses
import json import json
import logging import logging
@ -19,6 +17,7 @@ import re
import sys import sys
import traceback import traceback
from collections import defaultdict, namedtuple from collections import defaultdict, namedtuple
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from pprint import pprint from pprint import pprint
from typing import Any from typing import Any
@ -39,30 +38,83 @@ from kasa import (
) )
from kasa.device_factory import get_protocol from kasa.device_factory import get_protocol
from kasa.deviceconfig import DeviceEncryptionType, DeviceFamily 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.exceptions import SmartErrorCode
from kasa.protocols import IotProtocol 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 ( from kasa.protocols.smartcamprotocol import (
SmartCamProtocol, SmartCamProtocol,
_ChildCameraProtocolWrapper, _ChildCameraProtocolWrapper,
) )
from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS
from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper
from kasa.smart import SmartChildDevice, SmartDevice 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") Call = namedtuple("Call", "module method")
FixtureResult = namedtuple("FixtureResult", "filename, folder, data") FixtureResult = namedtuple("FixtureResult", "filename, folder, data, protocol_suffix")
SMART_FOLDER = "tests/fixtures/smart/" SMART_FOLDER = "tests/fixtures/smart/"
SMARTCAM_FOLDER = "tests/fixtures/smartcam/" SMARTCAM_FOLDER = "tests/fixtures/smartcam/"
SMART_CHILD_FOLDER = "tests/fixtures/smart/child/" SMART_CHILD_FOLDER = "tests/fixtures/smart/child/"
SMARTCAM_CHILD_FOLDER = "tests/fixtures/smartcam/child/"
IOT_FOLDER = "tests/fixtures/iot/" 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] ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType]
_LOGGER = logging.getLogger(__name__) _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 @dataclasses.dataclass
class SmartCall: class SmartCall:
"""Class for smart and smartcam calls.""" """Class for smart and smartcam calls."""
@ -74,103 +126,6 @@ class SmartCall:
supports_multiple: bool = True 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): def default_to_regular(d):
"""Convert nested defaultdicts to regular ones. """Convert nested defaultdicts to regular ones.
@ -195,9 +150,19 @@ async def handle_device(
] ]
for fixture_result in fixture_results: 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: if autosave:
save = "y" save = "y"
else: else:
@ -288,6 +253,12 @@ async def handle_device(
type=bool, type=bool,
help="Set flag if the device encryption uses https.", 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) @click.option("--port", help="Port override", type=int)
async def cli( async def cli(
host, host,
@ -305,6 +276,7 @@ async def cli(
device_family, device_family,
login_version, login_version,
port, port,
timeout,
): ):
"""Generate devinfo files for devices. """Generate devinfo files for devices.
@ -313,6 +285,11 @@ async def cli(
if debug: if debug:
logging.basicConfig(level=logging.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) credentials = Credentials(username=username, password=password)
if host is not None: if host is not None:
if discovery_info: if discovery_info:
@ -323,13 +300,16 @@ async def cli(
connection_type = DeviceConnectionParameters.from_values( connection_type = DeviceConnectionParameters.from_values(
dr.device_type, dr.device_type,
dr.mgt_encrypt_schm.encrypt_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( dc = DeviceConfig(
host=host, host=host,
connection_type=connection_type, connection_type=connection_type,
port_override=port, port_override=port,
credentials=credentials, credentials=credentials,
timeout=timeout,
) )
device = await Device.connect(config=dc) device = await Device.connect(config=dc)
await handle_device( await handle_device(
@ -351,6 +331,7 @@ async def cli(
port_override=port, port_override=port,
credentials=credentials, credentials=credentials,
connection_type=ctype, connection_type=ctype,
timeout=timeout,
) )
if protocol := get_protocol(config): if protocol := get_protocol(config):
await handle_device(basedir, autosave, protocol, batch_size=batch_size) await handle_device(basedir, autosave, protocol, batch_size=batch_size)
@ -365,12 +346,17 @@ async def cli(
credentials=credentials, credentials=credentials,
port=port, port=port,
discovery_timeout=discovery_timeout, 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( await handle_device(
basedir, basedir,
autosave, autosave,
device.protocol, device.protocol,
discovery_info=device._discovery_info, discovery_info=discovery_info,
batch_size=batch_size, batch_size=batch_size,
) )
else: else:
@ -379,21 +365,29 @@ async def cli(
f" {target}. Use --target to override." f" {target}. Use --target to override."
) )
devices = await Discover.discover( 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") click.echo(f"Detected {len(devices)} devices")
for dev in devices.values(): 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( await handle_device(
basedir, basedir,
autosave, autosave,
dev.protocol, dev.protocol,
discovery_info=dev._discovery_info, discovery_info=discovery_info,
batch_size=batch_size, batch_size=batch_size,
) )
async def get_legacy_fixture( async def get_legacy_fixture(
protocol: IotProtocol, *, discovery_info: dict[str, Any] | None protocol: IotProtocol, *, discovery_info: dict[str, dict[str, Any]] | None
) -> FixtureResult: ) -> FixtureResult:
"""Get fixture for legacy IOT style protocol.""" """Get fixture for legacy IOT style protocol."""
items = [ items = [
@ -463,11 +457,21 @@ async def get_legacy_fixture(
_echo_error(f"Unable to query all successes at once: {ex}") _echo_error(f"Unable to query all successes at once: {ex}")
finally: finally:
await protocol.close() 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"): if discovery_info and not discovery_info.get("system"):
# Need to recreate a DiscoverResult here because we don't want the aliases final["discovery_result"] = redact_data(
# in the fixture, we want the actual field names as returned by the device. discovery_info, _wrap_redactors(NEW_DISCOVERY_REDACTORS)
dr = DiscoveryResult.from_dict(discovery_info) )
final["discovery_result"] = dr.to_dict()
click.echo(f"Got {len(successes)} successes") click.echo(f"Got {len(successes)} successes")
click.echo(click.style("## device info file ##", bold=True)) click.echo(click.style("## device info file ##", bold=True))
@ -477,9 +481,14 @@ async def get_legacy_fixture(
hw_version = sysinfo["hw_ver"] hw_version = sysinfo["hw_ver"]
sw_version = sysinfo["sw_ver"] sw_version = sysinfo["sw_ver"]
sw_version = sw_version.split(" ", maxsplit=1)[0] 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 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): def _echo_error(msg: str):
@ -716,15 +725,6 @@ async def get_smart_test_calls(protocol: SmartProtocol):
successes = [] successes = []
child_device_components = {} 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) click.echo("Testing component_nego call ..", nl=False)
responses = await _make_requests_or_exit( responses = await _make_requests_or_exit(
protocol, protocol,
@ -803,8 +803,6 @@ async def get_smart_test_calls(protocol: SmartProtocol):
click.echo(f"Skipping {component_id}..", nl=False) click.echo(f"Skipping {component_id}..", nl=False)
click.echo(click.style("UNSUPPORTED", fg="yellow")) click.echo(click.style("UNSUPPORTED", fg="yellow"))
test_calls.extend(extra_test_calls)
# Child component calls # Child component calls
for child_device_id, child_components in child_device_components.items(): for child_device_id, child_components in child_device_components.items():
test_calls.append( test_calls.append(
@ -830,32 +828,87 @@ async def get_smart_test_calls(protocol: SmartProtocol):
else: else:
click.echo(f"Skipping {component_id}..", nl=False) click.echo(f"Skipping {component_id}..", nl=False)
click.echo(click.style("UNSUPPORTED", fg="yellow")) 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 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.""" """Get a seperate fixture for the child device."""
model_info = SmartDevice._get_device_info(response, None)
hw_version = model_info.hardware_version hw_version = model_info.hardware_version
fw_version = model_info.firmware_version fw_version = model_info.firmware_version
model = model_info.long_name model = model_info.long_name
if model_info.region is not None: if model_info.region is not None:
model = f"{model}({model_info.region})" 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( 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( 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]: ) -> list[FixtureResult]:
"""Get fixture for new TAPO style protocol.""" """Get fixture for new TAPO style protocol."""
if isinstance(protocol, SmartCamProtocol): if isinstance(protocol, SmartCamProtocol):
@ -907,21 +960,19 @@ async def get_smart_fixtures(
finally: finally:
await protocol.close() await protocol.close()
# Put all the successes into a dict[child_device_id or "", successes[]]
device_requests: dict[str, list[SmartCall]] = {} device_requests: dict[str, list[SmartCall]] = {}
for success in successes: for success in successes:
device_request = device_requests.setdefault(success.child_device_id, []) device_request = device_requests.setdefault(success.child_device_id, [])
device_request.append(success) 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( final = await _make_final_calls(
protocol, device_requests[""], "All successes", batch_size, child_device_id="" protocol, device_requests[""], "All successes", batch_size, child_device_id=""
) )
fixture_results = [] fixture_results = []
# Make the final child calls
child_responses = {}
for child_device_id, requests in device_requests.items(): for child_device_id, requests in device_requests.items():
if child_device_id == "": if child_device_id == "":
continue continue
@ -932,77 +983,118 @@ async def get_smart_fixtures(
batch_size, batch_size,
child_device_id=child_device_id, child_device_id=child_device_id,
) )
child_responses[child_device_id] = response
scrubbed = scrubbed_device_ids[child_device_id] # scrub the child ids
if "get_device_info" in response and "device_id" in response["get_device_info"]: scrubbed_child_id_map = scrub_child_device_ids(final, child_responses)
response["get_device_info"]["device_id"] = scrubbed
# If the child is a different model to the parent create a seperate fixture # Redact data from the main device response. _wrap_redactors ensure we do
if "get_device_info" in final: # not redact the scrubbed child device ids and replaces REDACTED_partial_id
parent_model = final["get_device_info"]["model"] # with zeros
elif "getDeviceInfo" in final: final = redact_data(final, _wrap_redactors(SMART_REDACTORS))
parent_model = final["getDeviceInfo"]["device_info"]["basic_info"][
"device_model" # 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: else:
raise KasaException("Cannot determine parent device model.") parent_model = None
_LOGGER.error("Cannot determine parent device model.")
# different model smart child device
if ( if (
"component_nego" in response (child_model := response.get("get_device_info", {}).get("model"))
and "get_device_info" in response and parent_model
and (child_model := response["get_device_info"].get("model"))
and child_model != 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: else:
cd = final.setdefault("child_devices", {}) cd = final.setdefault("child_devices", {})
cd[scrubbed] = response cd[scrubbed_child_id] = response
# Scrub the device ids in the parent for smart protocol discovery_result = None
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.
if discovery_info: if discovery_info:
dr = DiscoveryResult.from_dict(discovery_info) # type: ignore final["discovery_result"] = redact_data(
final["discovery_result"] = dr.to_dict() discovery_info, _wrap_redactors(NEW_DISCOVERY_REDACTORS)
)
discovery_result = discovery_info["result"]
click.echo(f"Got {len(successes)} successes") click.echo(f"Got {len(successes)} successes")
click.echo(click.style("## device info file ##", bold=True)) click.echo(click.style("## device info file ##", bold=True))
if "get_device_info" in final: if "get_device_info" in final:
# smart protocol # smart protocol
model_info = SmartDevice._get_device_info(final, discovery_info) model_info = SmartDevice._get_device_info(final, discovery_result)
copy_folder = SMART_FOLDER copy_folder = SMART_FOLDER
protocol_suffix = SMART_PROTOCOL_SUFFIX
else: else:
# smart camera protocol # 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 copy_folder = SMARTCAM_FOLDER
protocol_suffix = SMARTCAM_SUFFIX
hw_version = model_info.hardware_version hw_version = model_info.hardware_version
sw_version = model_info.firmware_version sw_version = model_info.firmware_version
model = model_info.long_name model = model_info.long_name
if model_info.region is not None: if model_info.region is not None:
model = f"{model}({model_info.region})" 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( 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 return fixture_results

View File

@ -13,7 +13,7 @@ from typing import Any, NamedTuple
from kasa.device_type import DeviceType from kasa.device_type import DeviceType
from kasa.iot import IotDevice from kasa.iot import IotDevice
from kasa.smart import SmartDevice from kasa.smart import SmartDevice
from kasa.smartcam import SmartCamDevice from kasa.smartcam import SmartCamChild, SmartCamDevice
class SupportedVersion(NamedTuple): class SupportedVersion(NamedTuple):
@ -36,6 +36,9 @@ DEVICE_TYPE_TO_PRODUCT_GROUP = {
DeviceType.Bulb: "Bulbs", DeviceType.Bulb: "Bulbs",
DeviceType.LightStrip: "Light Strips", DeviceType.LightStrip: "Light Strips",
DeviceType.Camera: "Cameras", DeviceType.Camera: "Cameras",
DeviceType.Doorbell: "Doorbells and chimes",
DeviceType.Chime: "Doorbells and chimes",
DeviceType.Vacuum: "Vacuums",
DeviceType.Hub: "Hubs", DeviceType.Hub: "Hubs",
DeviceType.Sensor: "Hub-Connected Devices", DeviceType.Sensor: "Hub-Connected Devices",
DeviceType.Thermostat: "Hub-Connected Devices", DeviceType.Thermostat: "Hub-Connected Devices",
@ -49,6 +52,7 @@ IOT_FOLDER = "tests/fixtures/iot/"
SMART_FOLDER = "tests/fixtures/smart/" SMART_FOLDER = "tests/fixtures/smart/"
SMART_CHILD_FOLDER = "tests/fixtures/smart/child" SMART_CHILD_FOLDER = "tests/fixtures/smart/child"
SMARTCAM_FOLDER = "tests/fixtures/smartcam/" SMARTCAM_FOLDER = "tests/fixtures/smartcam/"
SMARTCAM_CHILD_FOLDER = "tests/fixtures/smartcam/child"
def generate_supported(args): def generate_supported(args):
@ -66,6 +70,7 @@ def generate_supported(args):
_get_supported_devices(supported, SMART_FOLDER, SmartDevice) _get_supported_devices(supported, SMART_FOLDER, SmartDevice)
_get_supported_devices(supported, SMART_CHILD_FOLDER, SmartDevice) _get_supported_devices(supported, SMART_CHILD_FOLDER, SmartDevice)
_get_supported_devices(supported, SMARTCAM_FOLDER, SmartCamDevice) _get_supported_devices(supported, SMARTCAM_FOLDER, SmartCamDevice)
_get_supported_devices(supported, SMARTCAM_CHILD_FOLDER, SmartCamChild)
readme_updated = _update_supported_file( readme_updated = _update_supported_file(
README_FILENAME, _supported_summary(supported), print_diffs README_FILENAME, _supported_summary(supported), print_diffs
@ -205,7 +210,7 @@ def _get_supported_devices(
fixture_data = json.load(f) fixture_data = json.load(f)
model_info = device_cls._get_device_info( 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] 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 = stype.setdefault(model_info.long_name, [])
smodel.append( smodel.append(
SupportedVersion( SupportedVersion(
region=model_info.region, region=model_info.region if model_info.region else "",
hw=model_info.hardware_version, hw=model_info.hardware_version,
fw=model_info.firmware_version, fw=model_info.firmware_version,
auth=model_info.requires_auth, auth=model_info.requires_auth,

View File

@ -60,4 +60,7 @@ SMARTCAM_REQUESTS: list[dict] = [
{"get": {"motor": {"name": ["capability"]}}}, {"get": {"motor": {"name": ["capability"]}}},
{"get": {"audio_capability": {"name": ["device_speaker", "device_microphone"]}}}, {"get": {"audio_capability": {"name": ["device_speaker", "device_microphone"]}}},
{"get": {"audio_config": {"name": ["speaker", "microphone"]}}}, {"get": {"audio_config": {"name": ["speaker", "microphone"]}}},
{"getMatterSetupInfo": {"matter": {}}},
{"getConnectStatus": {"onboarding": {"get_connect_status": {}}}},
{"scanApList": {"onboarding": {"scan": {}}}},
] ]

View File

@ -118,6 +118,16 @@ class SmartRequest:
enable: bool enable: bool
id: str | None = None 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 @staticmethod
def get_raw_request( def get_raw_request(
method: str, params: SmartRequestParams | None = None method: str, params: SmartRequestParams | None = None
@ -415,6 +425,7 @@ COMPONENT_REQUESTS = {
"get_trigger_logs", SmartRequest.GetTriggerLogsParams() "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")], "double_click": [SmartRequest.get_raw_request("get_double_click_info")],
"child_device": [ "child_device": [
SmartRequest.get_raw_request("get_child_device_list"), SmartRequest.get_raw_request("get_child_device_list"),
@ -425,4 +436,37 @@ COMPONENT_REQUESTS = {
"dimmer_calibration": [], "dimmer_calibration": [],
"fan_control": [], "fan_control": [],
"overheat_protection": [], "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": [],
} }

View File

@ -286,8 +286,7 @@ def main(
operator.local_seed = message operator.local_seed = message
response = None response = None
print( print(
f"got handshake1 in {packet_number}, " f"got handshake1 in {packet_number}, looking for the response"
f"looking for the response"
) )
while ( while (
True True

128
devtools/update_fixtures.py Normal file
View File

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

View File

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

View File

@ -1,6 +1,10 @@
# Get Energy Consumption and Usage Statistics # Get Energy Consumption and Usage Statistics
:::{note}
The documentation on this page applies only to KASA-branded devices.
:::
:::{note} :::{note}
In order to use the helper methods to calculate the statistics correctly, your devices need to have correct time set. 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. The devices use NTP (123/UDP) and public servers from [NTP Pool Project](https://www.ntppool.org/) to synchronize their time.

View File

@ -8,3 +8,10 @@
.. automodule:: kasa.smart.modules.childdevice .. automodule:: kasa.smart.modules.childdevice
:noindex: :noindex:
``` ```
## Pairing and unpairing
```{eval-rst}
.. automodule:: kasa.interfaces.childsetup
:noindex:
```

View File

@ -13,11 +13,13 @@
## Device ## Device
% N.B. Credentials clashes with autodoc
```{eval-rst} ```{eval-rst}
.. autoclass:: Device .. autoclass:: Device
:members: :members:
:undoc-members: :undoc-members:
:exclude-members: Credentials
``` ```
@ -28,7 +30,6 @@
.. autoclass:: Credentials .. autoclass:: Credentials
:members: :members:
:undoc-members: :undoc-members:
:noindex:
``` ```
@ -61,15 +62,11 @@
```{eval-rst} ```{eval-rst}
.. autoclass:: Module .. autoclass:: Module
:noindex:
:members: :members:
:inherited-members:
:undoc-members:
``` ```
```{eval-rst} ```{eval-rst}
.. autoclass:: Feature .. autoclass:: Feature
:noindex:
:members: :members:
:inherited-members: :inherited-members:
:undoc-members: :undoc-members:
@ -77,7 +74,6 @@
```{eval-rst} ```{eval-rst}
.. automodule:: kasa.interfaces .. automodule:: kasa.interfaces
:noindex:
:members: :members:
:inherited-members: :inherited-members:
:undoc-members: :undoc-members:
@ -85,64 +81,29 @@
## Protocols and transports ## Protocols and transports
```{eval-rst} ```{eval-rst}
.. autoclass:: kasa.protocols.BaseProtocol .. automodule:: kasa.protocols
:members: :members:
:inherited-members: :imported-members:
:undoc-members: :undoc-members:
:exclude-members: SmartErrorCode
:no-index:
``` ```
```{eval-rst} ```{eval-rst}
.. autoclass:: kasa.protocols.IotProtocol .. automodule:: kasa.transports
:members: :members:
:inherited-members: :imported-members:
:undoc-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 ## Errors and exceptions
```{eval-rst} ```{eval-rst}
.. autoclass:: kasa.exceptions.KasaException .. autoclass:: kasa.exceptions.KasaException
:members: :members:
@ -171,3 +132,4 @@
.. autoclass:: kasa.exceptions.TimeoutError .. autoclass:: kasa.exceptions.TimeoutError
:members: :members:
:undoc-members: :undoc-members:
```

View File

@ -80,14 +80,17 @@ This can be done using the {attr}`~kasa.Device.internal_state` property.
## Modules and Features ## Modules and Features
The functionality provided by all {class}`~kasa.Device` instances is (mostly) done inside separate modules. 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, While the device class provides easy access for most device related attributes,
you can also access individual modules through {attr}`kasa.Device.modules`. for components like `light` and `camera` you can access the module through {attr}`kasa.Device.modules`.
You can get the list of supported modules for a given device instance using {attr}`~kasa.Device.supported_modules`. The module names are handily available as constants on {class}`~kasa.Module` and will return type aware values from the collection.
```{note} Features represent individual pieces of functionality within a module like brightness, hsv and temperature within a light module.
If you only need some module-specific information, They allow for instrospection and can be accessed through {attr}`kasa.Device.features`.
you can call the wanted method on the module to avoid using {meth}`~kasa.Device.update`. 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)= (topics-protocols-and-transports)=
## 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 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>`. - 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. - 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:
-->

View File

@ -13,6 +13,7 @@
127.0.0.3 127.0.0.3
127.0.0.4 127.0.0.4
127.0.0.5 127.0.0.5
127.0.0.6
:meth:`~kasa.Discover.discover_single` returns a single device by hostname: :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`. 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. 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 >>> from kasa import Module
>>> Module.Light in dev.modules >>> Module.Light in dev.modules
@ -52,9 +53,9 @@ True
>>> await dev.update() >>> await dev.update()
>>> light.brightness >>> light.brightness
50 50
>>> light.is_color >>> light.has_feature("hsv")
True True
>>> if light.is_color: >>> if light.has_feature("hsv"):
>>> print(light.hsv) >>> print(light.hsv)
HSV(hue=0, saturation=100, value=50) HSV(hue=0, saturation=100, value=50)
@ -91,5 +92,5 @@ False
True True
>>> for feat in dev.features.values(): >>> for feat in dev.features.values():
>>> print(f"{feat.name}: {feat.value}") >>> 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
""" """

View File

@ -38,8 +38,9 @@ from kasa.feature import Feature
from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState
from kasa.interfaces.thermostat import Thermostat, ThermostatState from kasa.interfaces.thermostat import Thermostat, ThermostatState
from kasa.module import Module 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.protocols.iotprotocol import _deprecated_TPLinkSmartHomeProtocol # noqa: F401
from kasa.smartcam.modules.camera import StreamResolution
from kasa.transports import BaseTransport from kasa.transports import BaseTransport
__version__ = version("python-kasa") __version__ = version("python-kasa")
@ -51,6 +52,7 @@ __all__ = [
"BaseTransport", "BaseTransport",
"IotProtocol", "IotProtocol",
"SmartProtocol", "SmartProtocol",
"SmartCamProtocol",
"LightState", "LightState",
"TurnOnBehaviors", "TurnOnBehaviors",
"TurnOnBehavior", "TurnOnBehavior",
@ -75,6 +77,7 @@ __all__ = [
"DeviceFamily", "DeviceFamily",
"ThermostatState", "ThermostatState",
"Thermostat", "Thermostat",
"StreamResolution",
] ]
from . import iot from . import iot

View File

@ -2,13 +2,15 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import re import re
import sys import sys
from collections.abc import Callable from collections.abc import Callable
from contextlib import contextmanager from contextlib import contextmanager
from functools import singledispatch, update_wrapper, wraps 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 import asyncclick as click
@ -55,7 +57,7 @@ def echo(*args, **kwargs) -> None:
_echo(*args, **kwargs) _echo(*args, **kwargs)
def error(msg: str) -> None: def error(msg: str) -> NoReturn:
"""Print an error and exit.""" """Print an error and exit."""
echo(f"[bold red]{msg}[/bold red]") echo(f"[bold red]{msg}[/bold red]")
sys.exit(1) sys.exit(1)
@ -66,6 +68,16 @@ def json_formatter_cb(result: Any, **kwargs) -> None:
if not kwargs.get("json"): if not kwargs.get("json"):
return 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 @singledispatch
def to_serializable(val): def to_serializable(val):
"""Regular obj-to-string for json serialization. """Regular obj-to-string for json serialization.
@ -83,6 +95,25 @@ def json_formatter_cb(result: Any, **kwargs) -> None:
print(json_content) 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: def pass_dev_or_child(wrapped_function: Callable) -> Callable:
"""Pass the device or child to the click command based on the child options.""" """Pass the device or child to the click command based on the child options."""
child_help = ( child_help = (
@ -238,4 +269,19 @@ def CatchAllExceptions(cls):
except Exception as exc: except Exception as exc:
_handle_exception(self._debug, 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 return _CommandCls

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from pprint import pformat as pf from pprint import pformat as pf
from typing import TYPE_CHECKING
import asyncclick as click import asyncclick as click
@ -41,8 +42,14 @@ async def state(ctx, dev: Device):
echo(f"Device state: {dev.is_on}") echo(f"Device state: {dev.is_on}")
echo(f"Time: {dev.time} (tz: {dev.timezone})") echo(f"Time: {dev.time} (tz: {dev.timezone})")
echo(f"Hardware: {dev.hw_info['hw_ver']}") echo(
echo(f"Software: {dev.hw_info['sw_ver']}") 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})") echo(f"MAC (rssi): {dev.mac} ({dev.rssi})")
if verbose: if verbose:
echo(f"Location: {dev.location}") echo(f"Location: {dev.location}")
@ -76,6 +83,8 @@ async def state(ctx, dev: Device):
echo() echo()
from .discover import _echo_discovery_info from .discover import _echo_discovery_info
if TYPE_CHECKING:
assert dev._discovery_info
_echo_discovery_info(dev._discovery_info) _echo_discovery_info(dev._discovery_info)
return dev.internal_state return dev.internal_state

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from pprint import pformat as pf from pprint import pformat as pf
from typing import TYPE_CHECKING, cast
import asyncclick as click import asyncclick as click
@ -14,22 +15,53 @@ from kasa import (
Discover, Discover,
UnsupportedDeviceError, 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 from .common import echo, error
@click.group(invoke_without_command=True) @click.group(invoke_without_command=True)
@click.pass_context @click.pass_context
async def discover(ctx): async def discover(ctx: click.Context):
"""Discover devices in the network.""" """Discover devices in the network."""
if ctx.invoked_subcommand is None: if ctx.invoked_subcommand is None:
return await ctx.invoke(detail) 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() @discover.command()
@click.pass_context @click.pass_context
async def detail(ctx): async def detail(ctx: click.Context) -> DeviceDict:
"""Discover devices in the network using udp broadcasts.""" """Discover devices in the network using udp broadcasts."""
unsupported = [] unsupported = []
auth_failed = [] auth_failed = []
@ -50,10 +82,14 @@ async def detail(ctx):
from .device import state from .device import state
async def print_discovered(dev: Device) -> None: async def print_discovered(dev: Device) -> None:
if TYPE_CHECKING:
assert ctx.parent
async with sem: async with sem:
try: try:
await dev.update() await dev.update()
except AuthenticationError: except AuthenticationError:
if TYPE_CHECKING:
assert dev._discovery_info
auth_failed.append(dev._discovery_info) auth_failed.append(dev._discovery_info)
echo("== Authentication failed for device ==") echo("== Authentication failed for device ==")
_echo_discovery_info(dev._discovery_info) _echo_discovery_info(dev._discovery_info)
@ -63,8 +99,12 @@ async def detail(ctx):
await ctx.parent.invoke(state) await ctx.parent.invoke(state)
echo() echo()
discovered = await _discover(ctx, print_discovered, print_unsupported) discovered = await _discover(
if ctx.parent.parent.params["host"]: 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 return discovered
echo(f"Found {len(discovered)} devices") echo(f"Found {len(discovered)} devices")
@ -77,22 +117,54 @@ async def detail(ctx):
@discover.command() @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 @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.""" """List devices in the network in a table using udp broadcasts."""
sem = asyncio.Semaphore() sem = asyncio.Semaphore()
async def print_discovered(dev: Device): async def print_discovered(dev: Device):
cparams = dev.config.connection_type cparams = dev.config.connection_type
infostr = ( infostr = (
f"{dev.host:<15} {cparams.device_family.value:<20} " f"{dev.host:<15} {dev.model:<9} {cparams.device_family.value:<20} "
f"{cparams.encryption_type.value:<7}" f"{cparams.encryption_type.value:<7} {cparams.https:<5} "
f"{cparams.login_version or '-':<3}"
) )
async with sem: async with sem:
try: try:
await dev.update() await dev.update()
except AuthenticationError: except AuthenticationError:
echo(f"{infostr} - Authentication failed") echo(f"{infostr} - Authentication failed")
except TimeoutError:
echo(f"{infostr} - Timed out")
except Exception as ex:
echo(f"{infostr} - Error: {ex}")
else: else:
echo(f"{infostr} {dev.alias}") echo(f"{infostr} {dev.alias}")
@ -100,12 +172,28 @@ async def list(ctx):
if host := unsupported_exception.host: if host := unsupported_exception.host:
echo(f"{host:<15} UNSUPPORTED DEVICE") echo(f"{host:<15} UNSUPPORTED DEVICE")
echo(f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}") echo(
return await _discover(ctx, print_discovered, print_unsupported, do_echo=False) 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): async def _discover(
params = ctx.parent.parent.params 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"] target = params["target"]
username = params["username"] username = params["username"]
password = params["password"] 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 credentials = Credentials(username, password) if username and password else None
if host: if host:
host = cast(str, host)
echo(f"Discovering device {host} for {discovery_timeout} seconds") echo(f"Discovering device {host} for {discovery_timeout} seconds")
return await Discover.discover_single( dev = await Discover.discover_single(
host, host,
port=port, port=port,
credentials=credentials, credentials=credentials,
timeout=timeout, timeout=timeout,
discovery_timeout=discovery_timeout, discovery_timeout=discovery_timeout,
on_unsupported=print_unsupported, 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: if do_echo:
echo(f"Discovering devices on {target} for {discovery_timeout} seconds") echo(f"Discovering devices on {target} for {discovery_timeout} seconds")
discovered_devices = await Discover.discover( discovered_devices = await Discover.discover(
@ -136,23 +232,21 @@ async def _discover(ctx, print_discovered, print_unsupported, *, do_echo=True):
port=port, port=port,
timeout=timeout, timeout=timeout,
credentials=credentials, credentials=credentials,
on_discovered_raw=print_raw,
) )
for device in discovered_devices.values():
await device.protocol.close()
return discovered_devices return discovered_devices
@discover.command() @discover.command()
@click.pass_context @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. """Bypass udp discovery and try to show connection config for a device.
Bypasses udp discovery and shows the parameters required to connect Bypasses udp discovery and shows the parameters required to connect
directly to the device. directly to the device.
""" """
params = ctx.parent.parent.params params = ctx.find_root().params
username = params["username"] username = params["username"]
password = params["password"] password = params["password"]
timeout = params["timeout"] timeout = params["timeout"]
@ -167,8 +261,11 @@ async def config(ctx):
host_port = host + (f":{port}" if port else "") host_port = host + (f":{port}" if port else "")
def on_attempt(connect_attempt: ConnectAttempt, success: bool) -> None: def on_attempt(connect_attempt: ConnectAttempt, success: bool) -> None:
prot, tran, dev = connect_attempt prot, tran, dev, https = connect_attempt
key_str = f"{prot.__name__} + {tran.__name__} + {dev.__name__}" key_str = (
f"{prot.__name__} + {tran.__name__} + {dev.__name__}"
f" + {'https' if https else 'http'}"
)
result = "succeeded" if success else "failed" result = "succeeded" if success else "failed"
msg = f"Attempt to connect to {host_port} with {key_str} {result}" msg = f"Attempt to connect to {host_port} with {key_str} {result}"
echo(msg) echo(msg)
@ -184,6 +281,7 @@ async def config(ctx):
f"--encrypt-type {cparams.encryption_type.value} " f"--encrypt-type {cparams.encryption_type.value} "
f"{'--https' if cparams.https else '--no-https'}" f"{'--https' if cparams.https else '--no-https'}"
) )
return {host: dev}
else: else:
error(f"Unable to connect to {host}") 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}") 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 # We don't have discovery info when all connection params are passed manually
if discovery_info is None: if discovery_info is None:
return return
if "system" in discovery_info and "get_sysinfo" in discovery_info["system"]: if sysinfo := _extract_sys_info(discovery_info):
_echo_dictionary(discovery_info["system"]["get_sysinfo"]) _echo_dictionary(sysinfo)
return return
try: try:
@ -228,12 +326,14 @@ def _echo_discovery_info(discovery_info) -> None:
_conditional_echo("HW Ver", dr.hw_ver) _conditional_echo("HW Ver", dr.hw_ver)
_conditional_echo("HW Ver", dr.hardware_version) _conditional_echo("HW Ver", dr.hardware_version)
_conditional_echo("Supports IOT Cloud", dr.is_support_iot_cloud) _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("Factory Default", dr.factory_default)
_conditional_echo("Encrypt Type", dr.mgt_encrypt_schm.encrypt_type)
_conditional_echo("Encrypt Type", dr.encrypt_type) _conditional_echo("Encrypt Type", dr.encrypt_type)
_conditional_echo("Supports HTTPS", dr.mgt_encrypt_schm.is_support_https) if mgt_encrypt_schm := dr.mgt_encrypt_schm:
_conditional_echo("HTTP Port", dr.mgt_encrypt_schm.http_port) _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("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) _conditional_echo("Decrypted", pf(dr.decrypted_data) if dr.decrypted_data else None)

View File

@ -6,10 +6,7 @@ import ast
import asyncclick as click import asyncclick as click
from kasa import ( from kasa import Device, Feature
Device,
Feature,
)
from .common import ( from .common import (
echo, echo,
@ -133,7 +130,22 @@ async def feature(
echo(f"{feat.name} ({name}): {feat.value}{unit}") echo(f"{feat.name} ({name}): {feat.value}{unit}")
return feat.value 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}") echo(f"Changing {name} from {feat.value} to {value}")
response = await dev.features[name].set_value(value) response = await dev.features[name].set_value(value)
await dev.update() await dev.update()

95
kasa/cli/hub.py Normal file
View 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

View File

@ -66,7 +66,6 @@ class LazyGroup(click.Group):
# check the result to make debugging easier # check the result to make debugging easier
if not isinstance(cmd_object, click.BaseCommand): if not isinstance(cmd_object, click.BaseCommand):
raise ValueError( raise ValueError(
f"Lazy loading of {cmd_name} failed by returning " f"Lazy loading of {cmd_name} failed by returning a non-command object"
"a non-command object"
) )
return cmd_object return cmd_object

View File

@ -25,7 +25,9 @@ def light(dev) -> None:
@pass_dev_or_child @pass_dev_or_child
async def brightness(dev: Device, brightness: int, transition: int): async def brightness(dev: Device, brightness: int, transition: int):
"""Get or set brightness.""" """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.") error("This device does not support brightness.")
return return
@ -45,13 +47,15 @@ async def brightness(dev: Device, brightness: int, transition: int):
@pass_dev_or_child @pass_dev_or_child
async def temperature(dev: Device, temperature: int, transition: int): async def temperature(dev: Device, temperature: int, transition: int):
"""Get or set color temperature.""" """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") error("Device does not support color temperature")
return return
if temperature is None: if temperature is None:
echo(f"Color temperature: {light.color_temp}") 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): if valid_temperature_range != (0, 0):
echo("(min: {}, max: {})".format(*valid_temperature_range)) echo("(min: {}, max: {})".format(*valid_temperature_range))
else: else:
@ -59,7 +63,7 @@ async def temperature(dev: Device, temperature: int, transition: int):
"Temperature range unknown, please open a github issue" "Temperature range unknown, please open a github issue"
f" or a pull request for model '{dev.model}'" f" or a pull request for model '{dev.model}'"
) )
return light.valid_temperature_range return color_temp_feat.range
else: else:
echo(f"Setting color temperature to {temperature}") echo(f"Setting color temperature to {temperature}")
return await light.set_color_temp(temperature, transition=transition) return await light.set_color_temp(temperature, transition=transition)
@ -99,7 +103,7 @@ async def effect(dev: Device, ctx, effect):
@pass_dev_or_child @pass_dev_or_child
async def hsv(dev: Device, ctx, h, s, v, transition): async def hsv(dev: Device, ctx, h, s, v, transition):
"""Get or set color in HSV.""" """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") error("Device does not support colors")
return return

View File

@ -22,6 +22,7 @@ from .common import (
CatchAllExceptions, CatchAllExceptions,
echo, echo,
error, error,
invoke_subcommand,
json_formatter_cb, json_formatter_cb,
pass_dev_or_child, pass_dev_or_child,
) )
@ -92,6 +93,8 @@ def _legacy_type_to_class(_type: str) -> Any:
"hsv": "light", "hsv": "light",
"temperature": "light", "temperature": "light",
"effect": "light", "effect": "light",
"vacuum": "vacuum",
"hub": "hub",
}, },
result_callback=json_formatter_cb, result_callback=json_formatter_cb,
) )
@ -295,9 +298,10 @@ async def cli(
echo("No host name given, trying discovery..") echo("No host name given, trying discovery..")
from .discover import discover from .discover import discover
return await ctx.invoke(discover) return await invoke_subcommand(discover, ctx)
device_updated = False device_updated = False
device_discovered = False
if type is not None and type not in {"smart", "camera"}: if type is not None and type not in {"smart", "camera"}:
from kasa.deviceconfig import DeviceConfig from kasa.deviceconfig import DeviceConfig
@ -308,6 +312,7 @@ async def cli(
if type == "camera": if type == "camera":
encrypt_type = "AES" encrypt_type = "AES"
https = True https = True
login_version = 2
device_family = "SMART.IPCAMERA" device_family = "SMART.IPCAMERA"
from kasa.device import Device from kasa.device import Device
@ -350,12 +355,14 @@ async def cli(
return return
echo(f"Found hostname by alias: {dev.host}") echo(f"Found hostname by alias: {dev.host}")
device_updated = True device_updated = True
else: else: # host will be set
from .discover import discover from .discover import discover
dev = await ctx.invoke(discover) discovered = await invoke_subcommand(discover, ctx)
if not dev: if not discovered:
error(f"Unable to create device for {host}") error(f"Unable to create device for {host}")
dev = discovered[host]
device_discovered = True
# Skip update on specific commands, or if device factory, # Skip update on specific commands, or if device factory,
# that performs an update was used for the device. # 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)) 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 from .device import state
return await ctx.invoke(state) return await ctx.invoke(state)
return dev
@cli.command() @cli.command()
@pass_dev_or_child @pass_dev_or_child

84
kasa/cli/vacuum.py Normal file
View 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")

View File

@ -25,6 +25,7 @@ def get_default_credentials(tuple: tuple[str, str]) -> Credentials:
DEFAULT_CREDENTIALS = { DEFAULT_CREDENTIALS = {
"KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"), "KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"),
"KASACAMERA": ("YWRtaW4=", "MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM="),
"TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="), "TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="),
"TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="), "TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="),
} }

View File

@ -29,7 +29,7 @@ All devices provide several informational properties:
>>> dev.alias >>> dev.alias
Bedroom Lamp Plug Bedroom Lamp Plug
>>> dev.model >>> dev.model
HS110(EU) HS110
>>> dev.rssi >>> dev.rssi
-71 -71
>>> dev.mac >>> dev.mac
@ -107,7 +107,7 @@ from __future__ import annotations
import logging import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, tzinfo from datetime import datetime, tzinfo
from typing import TYPE_CHECKING, Any, TypeAlias from typing import TYPE_CHECKING, Any, TypeAlias
@ -151,7 +151,7 @@ _LOGGER = logging.getLogger(__name__)
@dataclass @dataclass
class _DeviceInfo: class DeviceInfo:
"""Device Model Information.""" """Device Model Information."""
short_name: str short_name: str
@ -161,7 +161,7 @@ class _DeviceInfo:
device_type: DeviceType device_type: DeviceType
hardware_version: str hardware_version: str
firmware_version: str firmware_version: str
firmware_build: str firmware_build: str | None
requires_auth: bool requires_auth: bool
region: str | None region: str | None
@ -208,7 +208,7 @@ class Device(ABC):
self.protocol: BaseProtocol = protocol or IotProtocol( self.protocol: BaseProtocol = protocol or IotProtocol(
transport=XorTransport(config=config or DeviceConfig(host=host)), 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)) _LOGGER.debug("Initializing %s of type %s", host, type(self))
self._device_type = DeviceType.Unknown self._device_type = DeviceType.Unknown
# TODO: typing Any is just as using dict | None would require separate # TODO: typing Any is just as using dict | None would require separate
@ -334,9 +334,21 @@ class Device(ABC):
"""Returns the device model.""" """Returns the device model."""
@property @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 @abstractmethod
def _model_region(self) -> str: def _get_device_info(
"""Return device full model name and region.""" info: dict[str, Any], discovery_info: dict[str, Any] | None
) -> DeviceInfo:
"""Get device info."""
@property @property
@abstractmethod @abstractmethod
@ -525,19 +537,52 @@ class Device(ABC):
return None 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 = { _deprecated_other_attributes = {
# light 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"]), "brightness": (Module.Light, ["brightness"]),
"set_brightness": (Module.Light, ["set_brightness"]), "set_brightness": (Module.Light, ["set_brightness"]),
"hsv": (Module.Light, ["hsv"]), "hsv": (Module.Light, ["hsv"]),
"set_hsv": (Module.Light, ["set_hsv"]), "set_hsv": (Module.Light, ["set_hsv"]),
"color_temp": (Module.Light, ["color_temp"]), "color_temp": (Module.Light, ["color_temp"]),
"set_color_temp": (Module.Light, ["set_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"]), "_deprecated_set_light_state": (Module.Light, ["has_effects"]),
# led attributes # led attributes
"led": (Module.Led, ["led"]), "led": (Module.Led, ["led"]),
@ -576,6 +621,9 @@ class Device(ABC):
msg = f"{name} is deprecated, use device_type property instead" msg = f"{name} is deprecated, use device_type property instead"
warn(msg, DeprecationWarning, stacklevel=2) warn(msg, DeprecationWarning, stacklevel=2)
return self.device_type == dep_device_type_attr[1] 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 # Other deprecated attributes
if (dep_attr := self._deprecated_other_attributes.get(name)) and ( if (dep_attr := self._deprecated_other_attributes.get(name)) and (
(replacing_attr := self._get_replacing_attr(dep_attr[0], *dep_attr[1])) (replacing_attr := self._get_replacing_attr(dep_attr[0], *dep_attr[1]))

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

@ -8,7 +8,7 @@ from typing import Any
from .device import Device from .device import Device
from .device_type import DeviceType from .device_type import DeviceType
from .deviceconfig import DeviceConfig from .deviceconfig import DeviceConfig, DeviceEncryptionType, DeviceFamily
from .exceptions import KasaException, UnsupportedDeviceError from .exceptions import KasaException, UnsupportedDeviceError
from .iot import ( from .iot import (
IotBulb, IotBulb,
@ -32,6 +32,8 @@ from .transports import (
BaseTransport, BaseTransport,
KlapTransport, KlapTransport,
KlapTransportV2, KlapTransportV2,
LinkieTransportV2,
SslTransport,
XorTransport, XorTransport,
) )
from .transports.sslaestransport import SslAesTransport 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.Strip: IotStrip,
DeviceType.WallSwitch: IotWallSwitch, DeviceType.WallSwitch: IotWallSwitch,
DeviceType.LightStrip: IotLightStrip, DeviceType.LightStrip: IotLightStrip,
# Disabled until properly implemented
# DeviceType.Camera: IotCamera,
} }
return TYPE_TO_CLASS[IotDevice._get_device_type_from_sys_info(sysinfo)] 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.KASAHUB": SmartDevice,
"SMART.KASASWITCH": SmartDevice, "SMART.KASASWITCH": SmartDevice,
"SMART.IPCAMERA.HTTPS": SmartCamDevice, "SMART.IPCAMERA.HTTPS": SmartCamDevice,
"SMART.TAPODOORBELL.HTTPS": SmartCamDevice,
"SMART.TAPOROBOVAC.HTTPS": SmartDevice,
"IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTPLUGSWITCH": IotPlug,
"IOT.SMARTBULB": IotBulb, "IOT.SMARTBULB": IotBulb,
# Disabled until properly implemented
# "IOT.IPCAMERA": IotCamera,
} }
lookup_key = f"{device_type}{'.HTTPS' if https else ''}" lookup_key = f"{device_type}{'.HTTPS' if https else ''}"
if ( if (
@ -167,21 +175,55 @@ def get_device_class_from_family(
_LOGGER.debug("Unknown SMART device with %s, using SmartDevice", device_type) _LOGGER.debug("Unknown SMART device with %s, using SmartDevice", device_type)
cls = SmartDevice cls = SmartDevice
if cls is not None:
_LOGGER.debug("Using %s for %s", cls.__name__, device_type)
return cls return cls
def get_protocol( def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol | None:
config: DeviceConfig, """Return the protocol from the device config.
) -> BaseProtocol | None:
"""Return the protocol from the connection name.""" For cameras and vacuums the device family is a simple mapping to
protocol_name = config.connection_type.device_family.value.split(".")[0] 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 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_transport_key = (
protocol_name protocol_name
+ "." + "."
+ ctype.encryption_type.value + ctype.encryption_type.value
+ (".HTTPS" if ctype.https else "") + (".HTTPS" if ctype.https else "")
) )
_LOGGER.debug("Finding transport for %s", protocol_transport_key)
supported_device_protocols: dict[ supported_device_protocols: dict[
str, tuple[type[BaseProtocol], type[BaseTransport]] str, tuple[type[BaseProtocol], type[BaseTransport]]
] = { ] = {
@ -189,6 +231,9 @@ def get_protocol(
"IOT.KLAP": (IotProtocol, KlapTransport), "IOT.KLAP": (IotProtocol, KlapTransport),
"SMART.AES": (SmartProtocol, AesTransport), "SMART.AES": (SmartProtocol, AesTransport),
"SMART.KLAP": (SmartProtocol, KlapTransportV2), "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), "SMART.AES.HTTPS": (SmartCamProtocol, SslAesTransport),
} }
if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)): if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)):

View File

@ -21,6 +21,9 @@ class DeviceType(Enum):
Hub = "hub" Hub = "hub"
Fan = "fan" Fan = "fan"
Thermostat = "thermostat" Thermostat = "thermostat"
Vacuum = "vacuum"
Chime = "chime"
Doorbell = "doorbell"
Unknown = "unknown" Unknown = "unknown"
@staticmethod @staticmethod

View File

@ -20,7 +20,7 @@ None
{'host': '127.0.0.3', 'timeout': 5, 'credentials': {'username': 'user@example.com', \ {'host': '127.0.0.3', 'timeout': 5, 'credentials': {'username': 'user@example.com', \
'password': 'great_password'}, 'connection_type'\ 'password': 'great_password'}, 'connection_type'\
: {'device_family': 'SMART.TAPOBULB', 'encryption_type': 'KLAP', 'login_version': 2, \ : {'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)) >>> later_device = await Device.connect(config=Device.Config.from_dict(config_dict))
>>> print(later_device.alias) # Alias is available as connect() calls update() >>> print(later_device.alias) # Alias is available as connect() calls update()
@ -69,6 +69,7 @@ class DeviceFamily(Enum):
IotSmartPlugSwitch = "IOT.SMARTPLUGSWITCH" IotSmartPlugSwitch = "IOT.SMARTPLUGSWITCH"
IotSmartBulb = "IOT.SMARTBULB" IotSmartBulb = "IOT.SMARTBULB"
IotIpCamera = "IOT.IPCAMERA"
SmartKasaPlug = "SMART.KASAPLUG" SmartKasaPlug = "SMART.KASAPLUG"
SmartKasaSwitch = "SMART.KASASWITCH" SmartKasaSwitch = "SMART.KASASWITCH"
SmartTapoPlug = "SMART.TAPOPLUG" SmartTapoPlug = "SMART.TAPOPLUG"
@ -77,6 +78,9 @@ class DeviceFamily(Enum):
SmartTapoHub = "SMART.TAPOHUB" SmartTapoHub = "SMART.TAPOHUB"
SmartKasaHub = "SMART.KASAHUB" SmartKasaHub = "SMART.KASAHUB"
SmartIpCamera = "SMART.IPCAMERA" SmartIpCamera = "SMART.IPCAMERA"
SmartTapoRobovac = "SMART.TAPOROBOVAC"
SmartTapoChime = "SMART.TAPOCHIME"
SmartTapoDoorbell = "SMART.TAPODOORBELL"
class _DeviceConfigBaseMixin(DataClassJSONMixin): class _DeviceConfigBaseMixin(DataClassJSONMixin):
@ -96,13 +100,16 @@ class DeviceConnectionParameters(_DeviceConfigBaseMixin):
encryption_type: DeviceEncryptionType encryption_type: DeviceEncryptionType
login_version: int | None = None login_version: int | None = None
https: bool = False https: bool = False
http_port: int | None = None
@staticmethod @staticmethod
def from_values( def from_values(
device_family: str, device_family: str,
encryption_type: str, encryption_type: str,
*,
login_version: int | None = None, login_version: int | None = None,
https: bool | None = None, https: bool | None = None,
http_port: int | None = None,
) -> DeviceConnectionParameters: ) -> DeviceConnectionParameters:
"""Return connection parameters from string values.""" """Return connection parameters from string values."""
try: try:
@ -113,6 +120,7 @@ class DeviceConnectionParameters(_DeviceConfigBaseMixin):
DeviceEncryptionType(encryption_type), DeviceEncryptionType(encryption_type),
login_version, login_version,
https, https,
http_port=http_port,
) )
except (ValueError, TypeError) as ex: except (ValueError, TypeError) as ex:
raise KasaException( raise KasaException(
@ -146,9 +154,12 @@ class DeviceConfig(_DeviceConfigBaseMixin):
DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor 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. @property
uses_http: bool = False 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. #: Set a custom http_client for the device to use.
http_client: ClientSession | None = field( http_client: ClientSession | None = field(

View File

@ -22,7 +22,7 @@ Discovery returns a dict of {ip: discovered devices}:
>>> >>>
>>> found_devices = await Discover.discover() >>> found_devices = await Discover.discover()
>>> [dev.model for dev in found_devices.values()] >>> [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 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", >>> password="great_password",
>>> ) >>> )
>>> print(len(devices)) >>> print(len(devices))
5 6
You can also pass a :class:`kasa.Credentials` You can also pass a :class:`kasa.Credentials`
>>> creds = Credentials("user@example.com", "great_password") >>> creds = Credentials("user@example.com", "great_password")
>>> devices = await Discover.discover(credentials=creds) >>> devices = await Discover.discover(credentials=creds)
>>> print(len(devices)) >>> print(len(devices))
5 6
Discovery can also be targeted to a specific broadcast address instead of Discovery can also be targeted to a specific broadcast address instead of
the default 255.255.255.255: the default 255.255.255.255:
>>> found_devices = await Discover.discover(target="127.0.0.255", credentials=creds) >>> found_devices = await Discover.discover(target="127.0.0.255", credentials=creds)
>>> print(len(found_devices)) >>> print(len(found_devices))
5 6
Basic information is available on the device from the discovery broadcast response 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 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})") >>> print(f"Discovered {dev.alias} (model: {dev.model})")
>>> >>>
>>> devices = await Discover.discover(on_discovered=print_dev_info, credentials=creds) >>> devices = await Discover.discover(on_discovered=print_dev_info, credentials=creds)
Discovered Bedroom Power Strip (model: KP303(UK)) Discovered Bedroom Power Strip (model: KP303)
Discovered Bedroom Lamp Plug (model: HS110(EU)) Discovered Bedroom Lamp Plug (model: HS110)
Discovered Living Room Bulb (model: L530) Discovered Living Room Bulb (model: L530)
Discovered Bedroom Lightstrip (model: KL430(US)) Discovered Bedroom Lightstrip (model: KL430)
Discovered Living Room Dimmer Switch (model: HS220(US)) Discovered Living Room Dimmer Switch (model: HS220)
Discovered Tapo Hub (model: H200)
Discovering a single device returns a kasa.Device object. Discovering a single device returns a kasa.Device object.
>>> device = await Discover.discover_single("127.0.0.1", credentials=creds) >>> device = await Discover.discover_single("127.0.0.1", credentials=creds)
>>> device.model >>> device.model
'KP303(UK)' 'KP303'
""" """
@ -99,6 +100,7 @@ from typing import (
Annotated, Annotated,
Any, Any,
NamedTuple, NamedTuple,
TypedDict,
cast, cast,
) )
@ -123,7 +125,7 @@ from kasa.exceptions import (
TimeoutError, TimeoutError,
UnsupportedDeviceError, 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 DataClassJSONMixin
from kasa.json import dumps as json_dumps from kasa.json import dumps as json_dumps
from kasa.json import loads as json_loads from kasa.json import loads as json_loads
@ -145,17 +147,46 @@ class ConnectAttempt(NamedTuple):
protocol: type protocol: type
transport: type transport: type
device: 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] OnDiscoveredCallable = Callable[[Device], Coroutine]
OnDiscoveredRawCallable = Callable[[DiscoveredRaw], None]
OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Coroutine] OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Coroutine]
OnConnectAttemptCallable = Callable[[ConnectAttempt, bool], None] OnConnectAttemptCallable = Callable[[ConnectAttempt, bool], None]
DeviceDict = dict[str, Device] 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::], "device_id": lambda x: "REDACTED_" + x[9::],
"owner": 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, "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, self,
*, *,
on_discovered: OnDiscoveredCallable | None = None, on_discovered: OnDiscoveredCallable | None = None,
on_discovered_raw: OnDiscoveredRawCallable | None = None,
target: str = "255.255.255.255", target: str = "255.255.255.255",
discovery_packets: int = 3, discovery_packets: int = 3,
discovery_timeout: int = 5, discovery_timeout: int = 5,
@ -237,6 +269,7 @@ class _DiscoverProtocol(asyncio.DatagramProtocol):
self.unsupported_device_exceptions: dict = {} self.unsupported_device_exceptions: dict = {}
self.invalid_device_exceptions: dict = {} self.invalid_device_exceptions: dict = {}
self.on_unsupported = on_unsupported self.on_unsupported = on_unsupported
self.on_discovered_raw = on_discovered_raw
self.credentials = credentials self.credentials = credentials
self.timeout = timeout self.timeout = timeout
self.discovery_timeout = discovery_timeout self.discovery_timeout = discovery_timeout
@ -326,12 +359,22 @@ class _DiscoverProtocol(asyncio.DatagramProtocol):
config.timeout = self.timeout config.timeout = self.timeout
try: try:
if port == self.discovery_port: 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: elif port == Discover.DISCOVERY_PORT_2:
config.uses_http = True json_func = Discover._get_discovery_json
device = Discover._get_device_instance(data, config) device_func = Discover._get_device_instance
else: else:
return 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: except UnsupportedDeviceError as udex:
_LOGGER.debug("Unsupported device found at %s << %s", ip, udex) _LOGGER.debug("Unsupported device found at %s << %s", ip, udex)
self.unsupported_device_exceptions[ip] = udex self.unsupported_device_exceptions[ip] = udex
@ -388,6 +431,7 @@ class Discover:
*, *,
target: str = "255.255.255.255", target: str = "255.255.255.255",
on_discovered: OnDiscoveredCallable | None = None, on_discovered: OnDiscoveredCallable | None = None,
on_discovered_raw: OnDiscoveredRawCallable | None = None,
discovery_timeout: int = 5, discovery_timeout: int = 5,
discovery_packets: int = 3, discovery_packets: int = 3,
interface: str | None = None, interface: str | None = None,
@ -418,6 +462,8 @@ class Discover:
:param target: The target address where to send the broadcast discovery :param target: The target address where to send the broadcast discovery
queries if multi-homing (e.g. 192.168.xxx.255). queries if multi-homing (e.g. 192.168.xxx.255).
:param on_discovered: coroutine to execute on discovery :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_timeout: Seconds to wait for responses, defaults to 5
:param discovery_packets: Number of discovery packets to broadcast :param discovery_packets: Number of discovery packets to broadcast
:param interface: Bind to specific interface :param interface: Bind to specific interface
@ -440,6 +486,7 @@ class Discover:
discovery_packets=discovery_packets, discovery_packets=discovery_packets,
interface=interface, interface=interface,
on_unsupported=on_unsupported, on_unsupported=on_unsupported,
on_discovered_raw=on_discovered_raw,
credentials=credentials, credentials=credentials,
timeout=timeout, timeout=timeout,
discovery_timeout=discovery_timeout, discovery_timeout=discovery_timeout,
@ -452,7 +499,7 @@ class Discover:
try: try:
_LOGGER.debug("Waiting %s seconds for responses...", discovery_timeout) _LOGGER.debug("Waiting %s seconds for responses...", discovery_timeout)
await protocol.wait_for_discovery_to_complete() await protocol.wait_for_discovery_to_complete()
except KasaException as ex: except (KasaException, asyncio.CancelledError) as ex:
for device in protocol.discovered_devices.values(): for device in protocol.discovered_devices.values():
await device.protocol.close() await device.protocol.close()
raise ex raise ex
@ -473,6 +520,7 @@ class Discover:
credentials: Credentials | None = None, credentials: Credentials | None = None,
username: str | None = None, username: str | None = None,
password: str | None = None, password: str | None = None,
on_discovered_raw: OnDiscoveredRawCallable | None = None,
on_unsupported: OnUnsupportedCallable | None = None, on_unsupported: OnUnsupportedCallable | None = None,
) -> Device | None: ) -> Device | None:
"""Discover a single device by the given IP address. """Discover a single device by the given IP address.
@ -490,6 +538,9 @@ class Discover:
username and password are ignored if provided. username and password are ignored if provided.
:param username: Username for devices that require authentication :param username: Username for devices that require authentication
:param password: Password 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 :rtype: SmartDevice
:return: Object for querying/controlling found device. :return: Object for querying/controlling found device.
""" """
@ -526,6 +577,7 @@ class Discover:
credentials=credentials, credentials=credentials,
timeout=timeout, timeout=timeout,
discovery_timeout=discovery_timeout, discovery_timeout=discovery_timeout,
on_discovered_raw=on_discovered_raw,
), ),
local_addr=("0.0.0.0", 0), # noqa: S104 local_addr=("0.0.0.0", 0), # noqa: S104
) )
@ -583,22 +635,26 @@ class Discover:
Device.Family.SmartTapoPlug, Device.Family.SmartTapoPlug,
Device.Family.IotSmartPlugSwitch, Device.Family.IotSmartPlugSwitch,
Device.Family.SmartIpCamera, Device.Family.SmartIpCamera,
Device.Family.SmartTapoRobovac,
Device.Family.IotIpCamera,
} }
candidates: dict[ candidates: dict[
tuple[type[BaseProtocol], type[BaseTransport], type[Device]], tuple[type[BaseProtocol], type[BaseTransport], type[Device], bool],
tuple[BaseProtocol, DeviceConfig], tuple[BaseProtocol, DeviceConfig],
] = { ] = {
(type(protocol), type(protocol._transport), device_class): ( (type(protocol), type(protocol._transport), device_class, https): (
protocol, protocol,
config, config,
) )
for encrypt in Device.EncryptionType for encrypt in Device.EncryptionType
for device_family in main_device_families for device_family in main_device_families
for https in (True, False) for https in (True, False)
for login_version in (None, 2)
if ( if (
conn_params := DeviceConnectionParameters( conn_params := DeviceConnectionParameters(
device_family=device_family, device_family=device_family,
encryption_type=encrypt, encryption_type=encrypt,
login_version=login_version,
https=https, https=https,
) )
) )
@ -610,10 +666,9 @@ class Discover:
port_override=port, port_override=port,
credentials=credentials, credentials=credentials,
http_client=http_client, 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 ( and (
device_class := get_device_class_from_family( device_class := get_device_class_from_family(
device_family.value, https=https, require_exact=True device_family.value, https=https, require_exact=True
@ -623,9 +678,14 @@ class Discover:
for key, val in candidates.items(): for key, val in candidates.items():
try: try:
prot, config = val prot, config = val
_LOGGER.debug("Trying to connect with %s", prot.__class__.__name__)
dev = await _connect(config, prot) dev = await _connect(config, prot)
except Exception: except Exception as ex:
_LOGGER.debug("Unable to connect with %s", prot) _LOGGER.debug(
"Unable to connect with %s: %s",
prot.__class__.__name__,
ex,
)
if on_attempt: if on_attempt:
ca = tuple.__new__(ConnectAttempt, key) ca = tuple.__new__(ConnectAttempt, key)
on_attempt(ca, False) on_attempt(ca, False)
@ -633,6 +693,7 @@ class Discover:
if on_attempt: if on_attempt:
ca = tuple.__new__(ConnectAttempt, key) ca = tuple.__new__(ConnectAttempt, key)
on_attempt(ca, True) on_attempt(ca, True)
_LOGGER.debug("Found working protocol %s", prot.__class__.__name__)
return dev return dev
finally: finally:
await prot.close() await prot.close()
@ -643,7 +704,11 @@ class Discover:
"""Find SmartDevice subclass for device described by passed data.""" """Find SmartDevice subclass for device described by passed data."""
if "result" in info: if "result" in info:
discovery_result = DiscoveryResult.from_dict(info["result"]) 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( dev_class = get_device_class_from_family(
discovery_result.device_type, https=https discovery_result.device_type, https=https
) )
@ -657,33 +722,43 @@ class Discover:
return get_device_class_from_sys_info(info) return get_device_class_from_sys_info(info)
@staticmethod @staticmethod
def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice: def _get_discovery_json_legacy(data: bytes, ip: str) -> dict:
"""Get SmartDevice from legacy 9999 response.""" """Get discovery json from legacy 9999 response."""
try: try:
info = json_loads(XorEncryption.decrypt(data)) info = json_loads(XorEncryption.decrypt(data))
except Exception as ex: except Exception as ex:
raise KasaException( raise KasaException(
f"Unable to read response from device: {config.host}: {ex}" f"Unable to read response from device: {ip}: {ex}"
) from 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): if _LOGGER.isEnabledFor(logging.DEBUG):
data = redact_data(info, IOT_REDACTORS) if Discover._redact_data else info data = redact_data(info, IOT_REDACTORS) if Discover._redact_data else info
_LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data))
device_class = cast(type[IotDevice], Discover._get_device_class(info)) device_class = cast(type[IotDevice], Discover._get_device_class(info))
device = device_class(config.host, config=config) device = device_class(config.host, config=config)
sys_info = info["system"]["get_sysinfo"] sys_info = _extract_sys_info(info)
if device_type := sys_info.get("mic_type", sys_info.get("type")): device_type = sys_info.get("mic_type", sys_info.get("type"))
config.connection_type = DeviceConnectionParameters.from_values( login_version = (
device_family=device_type, sys_info.get("stream_version") if device_type == "IOT.IPCAMERA" else None
encryption_type=DeviceEncryptionType.Xor.value, )
) 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.protocol = get_protocol(config) # type: ignore[assignment]
device.update_from_discover_info(info) device.update_from_discover_info(info)
return device return device
@staticmethod @staticmethod
def _decrypt_discovery_data(discovery_result: DiscoveryResult) -> None: def _decrypt_discovery_data(discovery_result: DiscoveryResult) -> None:
debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)
if TYPE_CHECKING: if TYPE_CHECKING:
assert discovery_result.encrypt_info assert discovery_result.encrypt_info
assert _AesDiscoveryQuery.keypair assert _AesDiscoveryQuery.keypair
@ -699,22 +774,80 @@ class Discover:
session = AesEncyptionSession(key, iv) session = AesEncyptionSession(key, iv)
decrypted_data = session.decrypt(encrypted_data) 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 @staticmethod
def _get_device_instance( def _get_device_instance(
data: bytes, info: dict,
config: DeviceConfig, config: DeviceConfig,
) -> Device: ) -> Device:
"""Get SmartDevice from the new 20002 response.""" """Get SmartDevice from the new 20002 response."""
debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) 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: try:
discovery_result = DiscoveryResult.from_dict(info["result"]) discovery_result = DiscoveryResult.from_dict(info["result"])
@ -743,43 +876,26 @@ class Discover:
Discover._decrypt_discovery_data(discovery_result) Discover._decrypt_discovery_data(discovery_result)
except Exception: except Exception:
_LOGGER.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 type_ = discovery_result.device_type
encrypt_schm = discovery_result.mgt_encrypt_schm
try: try:
if not (encrypt_type := encrypt_schm.encrypt_type) and ( conn_params = Discover._get_connection_parameters(discovery_result)
encrypt_info := discovery_result.encrypt_info config.connection_type = conn_params
):
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,
)
except KasaException as ex: except KasaException as ex:
if isinstance(ex, UnsupportedDeviceError):
raise
raise UnsupportedDeviceError( raise UnsupportedDeviceError(
f"Unsupported device {config.host} of type {type_} " 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(), discovery_result=discovery_result.to_dict(),
host=config.host, host=config.host,
) from ex ) from ex
if ( if (
device_class := get_device_class_from_family( device_class := get_device_class_from_family(type_, https=conn_params.https)
type_, https=encrypt_schm.is_support_https
)
) is None: ) is None:
_LOGGER.debug("Got unsupported device type: %s", type_) _LOGGER.debug("Got unsupported device type: %s", type_)
raise UnsupportedDeviceError( raise UnsupportedDeviceError(
@ -854,7 +970,7 @@ class DiscoveryResult(_DiscoveryBaseMixin):
device_id: str device_id: str
ip: str ip: str
mac: str mac: str
mgt_encrypt_schm: EncryptionScheme mgt_encrypt_schm: EncryptionScheme | None = None
device_name: str | None = None device_name: str | None = None
encrypt_info: EncryptionInfo | None = None encrypt_info: EncryptionInfo | None = None
encrypt_type: list[str] | None = None encrypt_type: list[str] | None = None

View File

@ -127,11 +127,14 @@ class SmartErrorCode(IntEnum):
DST_ERROR = -2301 DST_ERROR = -2301
DST_SAVE_ERROR = -2302 DST_SAVE_ERROR = -2302
VACUUM_BATTERY_LOW = -3001
SYSTEM_ERROR = -40101 SYSTEM_ERROR = -40101
INVALID_ARGUMENTS = -40209 INVALID_ARGUMENTS = -40209
# Camera error codes # Camera error codes
SESSION_EXPIRED = -40401 SESSION_EXPIRED = -40401
BAD_USERNAME = -40411 # determined from testing
HOMEKIT_LOGIN_FAIL = -40412 HOMEKIT_LOGIN_FAIL = -40412
DEVICE_BLOCKED = -40404 DEVICE_BLOCKED = -40404
DEVICE_FACTORY = -40405 DEVICE_FACTORY = -40405

View File

@ -24,8 +24,8 @@ State (state): True
Signal Level (signal_level): 2 Signal Level (signal_level): 2
RSSI (rssi): -52 RSSI (rssi): -52
SSID (ssid): #MASKED_SSID# SSID (ssid): #MASKED_SSID#
Overheated (overheated): False
Reboot (reboot): <Action> Reboot (reboot): <Action>
Device time (device_time): 2024-02-23 02:40:15+01:00
Brightness (brightness): 100 Brightness (brightness): 100
Cloud connection (cloud_connection): True Cloud connection (cloud_connection): True
HSV (hsv): HSV(hue=0, saturation=100, value=100) HSV (hsv): HSV(hue=0, saturation=100, value=100)
@ -39,7 +39,7 @@ Light effect (light_effect): Off
Light preset (light_preset): Not set Light preset (light_preset): Not set
Smooth transition on (smooth_transition_on): 2 Smooth transition on (smooth_transition_on): 2
Smooth transition off (smooth_transition_off): 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: 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: if TYPE_CHECKING:
from .device import Device from .device import Device
from .module import Module
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -142,7 +143,7 @@ class Feature:
#: Callable coroutine or name of the method that allows changing the value #: Callable coroutine or name of the method that allows changing the value
attribute_setter: str | Callable[..., Coroutine[Any, Any, Any]] | None = None attribute_setter: str | Callable[..., Coroutine[Any, Any, Any]] | None = None
#: Container storing the data, this overrides 'device' for getters #: Container storing the data, this overrides 'device' for getters
container: Any = None container: Device | Module | None = None
#: Icon suggestion #: Icon suggestion
icon: str | None = None icon: str | None = None
#: Attribute containing the name of the unit getter property. #: Attribute containing the name of the unit getter property.
@ -255,7 +256,7 @@ class Feature:
elif self.type == Feature.Type.Choice: # noqa: SIM102 elif self.type == Feature.Type.Choice: # noqa: SIM102
if not self.choices or value not in self.choices: if not self.choices or value not in self.choices:
raise ValueError( raise ValueError(
f"Unexpected value for {self.name}: {value}" f"Unexpected value for {self.name}: '{value}'"
f" - allowed: {self.choices}" f" - allowed: {self.choices}"
) )
@ -278,7 +279,18 @@ class Feature:
return f"Unable to read value ({self.id}): {ex}" return f"Unable to read value ({self.id}): {ex}"
if self.type == Feature.Type.Choice: 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( _LOGGER.warning(
"Invalid value for for choice %s (%s): %s not in %s", "Invalid value for for choice %s (%s): %s not in %s",
self.name, self.name,
@ -290,14 +302,24 @@ class Feature:
f"{self.name} ({self.id}): invalid value '{value}' not in {choices}" f"{self.name} ({self.id}): invalid value '{value}' not in {choices}"
) )
value = " ".join( 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): if self.precision_hint is not None and isinstance(value, float):
value = round(value, self.precision_hint) value = round(value, self.precision_hint)
if isinstance(value, Enum):
value = repr(value)
s = f"{self.name} ({self.id}): {value}" s = f"{self.name} ({self.id}): {value}"
if self.unit is not None: if (unit := self.unit) is not None:
s += f" {self.unit}" if isinstance(unit, Enum):
unit = repr(unit)
s += f" {unit}"
if self.type == Feature.Type.Number: if self.type == Feature.Type.Number:
s += f" (range: {self.minimum_value}-{self.maximum_value})" s += f" (range: {self.minimum_value}-{self.maximum_value})"

View File

@ -113,10 +113,23 @@ class HttpClient:
ssl=ssl, ssl=ssl,
) )
async with resp: async with resp:
if resp.status == 200: response_data = await resp.read()
response_data = await resp.read()
if return_json: 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()) 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: except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex:
if not self._wait_between_requests: if not self._wait_between_requests:

View File

@ -1,5 +1,7 @@
"""Package for interfaces.""" """Package for interfaces."""
from .alarm import Alarm
from .childsetup import ChildSetup
from .energy import Energy from .energy import Energy
from .fan import Fan from .fan import Fan
from .led import Led from .led import Led
@ -10,6 +12,8 @@ from .thermostat import Thermostat, ThermostatState
from .time import Time from .time import Time
__all__ = [ __all__ = [
"Alarm",
"ChildSetup",
"Fan", "Fan",
"Energy", "Energy",
"Led", "Led",

75
kasa/interfaces/alarm.py Normal file
View 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."""

View 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."""

View File

@ -28,7 +28,7 @@ class Energy(Module, ABC):
_supported: ModuleFeature = ModuleFeature(0) _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 True if module supports the feature."""
return module_feature in self._supported return module_feature in self._supported

View File

@ -23,13 +23,13 @@ Get the light module to interact:
>>> light = dev.modules[Module.Light] >>> 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 True
>>> light.is_color >>> light.has_feature("hsv")
True True
>>> light.is_variable_color_temp >>> light.has_feature("color_temp")
True True
All known bulbs support changing the brightness: 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: Bulbs supporting color temperature can be queried for the supported range:
>>> light.valid_temperature_range >>> if color_temp_feature := light.get_feature("color_temp"):
ColorTempRange(min=2500, max=6500) >>> print(f"{color_temp_feature.minimum_value}, {color_temp_feature.maximum_value}")
2500, 6500
>>> await light.set_color_temp(3000) >>> await light.set_color_temp(3000)
>>> await dev.update() >>> await dev.update()
>>> light.color_temp >>> light.color_temp
@ -64,8 +65,10 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass 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 from ..module import FeatureAttribute, Module
@ -99,34 +102,6 @@ class HSV(NamedTuple):
class Light(Module, ABC): class Light(Module, ABC):
"""Base class for TP-Link Light.""" """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 @property
@abstractmethod @abstractmethod
def hsv(self) -> Annotated[HSV, FeatureAttribute()]: def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
@ -196,3 +171,44 @@ class Light(Module, ABC):
@abstractmethod @abstractmethod
async def set_state(self, state: LightState) -> dict: async def set_state(self, state: LightState) -> dict:
"""Set the light state.""" """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}")

View File

@ -13,8 +13,7 @@ Living Room Bulb
Light effects are accessed via the LightPreset module. To list available presets 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 >>> light_effect.effect_list
['Off', 'Party', 'Relax'] ['Off', 'Party', 'Relax']
@ -52,6 +51,7 @@ class LightEffect(Module, ABC):
"""Interface to represent a light effect module.""" """Interface to represent a light effect module."""
LIGHT_EFFECTS_OFF = "Off" LIGHT_EFFECTS_OFF = "Off"
LIGHT_EFFECTS_UNNAMED_CUSTOM = "Custom"
def _initialize_features(self) -> None: def _initialize_features(self) -> None:
"""Initialize features.""" """Initialize features."""
@ -78,7 +78,7 @@ class LightEffect(Module, ABC):
@property @property
@abstractmethod @abstractmethod
def effect(self) -> str: def effect(self) -> str:
"""Return effect state or name.""" """Return effect name."""
@property @property
@abstractmethod @abstractmethod

View File

@ -1,6 +1,7 @@
"""Package for supporting legacy kasa devices.""" """Package for supporting legacy kasa devices."""
from .iotbulb import IotBulb from .iotbulb import IotBulb
from .iotcamera import IotCamera
from .iotdevice import IotDevice from .iotdevice import IotDevice
from .iotdimmer import IotDimmer from .iotdimmer import IotDimmer
from .iotlightstrip import IotLightStrip from .iotlightstrip import IotLightStrip
@ -15,4 +16,5 @@ __all__ = [
"IotDimmer", "IotDimmer",
"IotLightStrip", "IotLightStrip",
"IotWallSwitch", "IotWallSwitch",
"IotCamera",
] ]

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

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

View File

@ -22,7 +22,7 @@ from datetime import datetime, timedelta, tzinfo
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from warnings import warn from warnings import warn
from ..device import Device, WifiNetwork, _DeviceInfo from ..device import Device, DeviceInfo, WifiNetwork
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..exceptions import KasaException from ..exceptions import KasaException
@ -43,7 +43,7 @@ def requires_update(f: Callable) -> Any:
@functools.wraps(f) @functools.wraps(f)
async def wrapped(*args: Any, **kwargs: Any) -> Any: async def wrapped(*args: Any, **kwargs: Any) -> Any:
self = args[0] 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 self._sys_info is None or f.__name__ not in self._sys_info
): ):
raise KasaException("You need to await update() to access the data") raise KasaException("You need to await update() to access the data")
@ -54,7 +54,7 @@ def requires_update(f: Callable) -> Any:
@functools.wraps(f) @functools.wraps(f)
def wrapped(*args: Any, **kwargs: Any) -> Any: def wrapped(*args: Any, **kwargs: Any) -> Any:
self = args[0] 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 self._sys_info is None or f.__name__ not in self._sys_info
): ):
raise KasaException("You need to await update() to access the data") 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(":")) 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): class IotDevice(Device):
"""Base class for all supported device types. """Base class for all supported device types.
@ -102,7 +112,7 @@ class IotDevice(Device):
>>> dev.alias >>> dev.alias
Bedroom Lamp Plug Bedroom Lamp Plug
>>> dev.model >>> dev.model
HS110(EU) HS110
>>> dev.rssi >>> dev.rssi
-71 -71
>>> dev.mac >>> dev.mac
@ -300,18 +310,18 @@ class IotDevice(Device):
# If this is the initial update, check only for the sysinfo # If this is the initial update, check only for the sysinfo
# This is necessary as some devices crash on unexpected modules # This is necessary as some devices crash on unexpected modules
# See #105, #120, #161 # See #105, #120, #161
if self._last_update is None: if not self._last_update:
_LOGGER.debug("Performing the initial update to obtain sysinfo") _LOGGER.debug("Performing the initial update to obtain sysinfo")
response = await self.protocol.query(req) response = await self.protocol.query(req)
self._last_update = response self._last_update = response
self._set_sys_info(response["system"]["get_sysinfo"]) self._set_sys_info(_extract_sys_info(response))
if not self._modules: if not self._modules:
await self._initialize_modules() await self._initialize_modules()
await self._modular_update(req) 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(): for module in self._modules.values():
await module._post_update_hook() await module._post_update_hook()
@ -442,7 +452,9 @@ class IotDevice(Device):
# This allows setting of some info properties directly # This allows setting of some info properties directly
# from partial discovery info that will then be found # from partial discovery info that will then be found
# by the requires_update decorator # 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: def _set_sys_info(self, sys_info: dict[str, Any]) -> None:
"""Set sys_info.""" """Set sys_info."""
@ -461,18 +473,13 @@ class IotDevice(Device):
""" """
return self._sys_info # type: ignore 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 @property
@requires_update @requires_update
def _model_region(self) -> str: def model(self) -> str:
"""Return device full model name and region.""" """Returns the device model."""
return self.model if self._last_update:
return self.device_info.short_name
return self._sys_info["model"]
@property # type: ignore @property # type: ignore
def alias(self) -> str | None: def alias(self) -> str | None:
@ -705,10 +712,13 @@ class IotDevice(Device):
@staticmethod @staticmethod
def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType: def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType:
"""Find SmartDevice subclass for device described by passed data.""" """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"]: if "system" not in info or "get_sysinfo" not in info["system"]:
raise KasaException("No 'system' or 'get_sysinfo' in response") 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")) type_: str | None = sysinfo.get("type", sysinfo.get("mic_type"))
if type_ is None: if type_ is None:
raise KasaException("Unable to find the device type field!") raise KasaException("Unable to find the device type field!")
@ -728,15 +738,16 @@ class IotDevice(Device):
return DeviceType.LightStrip return DeviceType.LightStrip
return DeviceType.Bulb return DeviceType.Bulb
_LOGGER.warning("Unknown device type %s, falling back to plug", type_) _LOGGER.warning("Unknown device type %s, falling back to plug", type_)
return DeviceType.Plug return DeviceType.Plug
@staticmethod @staticmethod
def _get_device_info( def _get_device_info(
info: dict[str, Any], discovery_info: dict[str, Any] | None info: dict[str, Any], discovery_info: dict[str, Any] | None
) -> _DeviceInfo: ) -> DeviceInfo:
"""Get model information for a device.""" """Get model information for a device."""
sys_info = info["system"]["get_sysinfo"] sys_info = _extract_sys_info(info)
# Get model and region info # Get model and region info
region = None region = None
@ -749,10 +760,13 @@ class IotDevice(Device):
device_family = sys_info.get("type", sys_info.get("mic_type")) device_family = sys_info.get("type", sys_info.get("mic_type"))
device_type = IotDevice._get_device_type_from_sys_info(info) device_type = IotDevice._get_device_type_from_sys_info(info)
fw_version_full = sys_info["sw_ver"] 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)) auth = bool(discovery_info and ("mgt_encrypt_schm" in discovery_info))
return _DeviceInfo( return DeviceInfo(
short_name=long_name, short_name=long_name,
long_name=long_name, long_name=long_name,
brand="kasa", brand="kasa",

View File

@ -11,7 +11,7 @@ from ..module import Module
from ..protocols import BaseProtocol from ..protocols import BaseProtocol
from .iotdevice import KasaException, requires_update from .iotdevice import KasaException, requires_update
from .iotplug import IotPlug from .iotplug import IotPlug
from .modules import AmbientLight, Light, Motion from .modules import AmbientLight, Dimmer, Light, Motion
class ButtonAction(Enum): class ButtonAction(Enum):
@ -87,6 +87,7 @@ class IotDimmer(IotPlug):
# TODO: need to be figured out what's the best approach to detect support # 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.IotMotion, Motion(self, "smartlife.iot.PIR"))
self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS")) 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")) self.add_module(Module.Light, Light(self, "light"))
@property # type: ignore @property # type: ignore
@ -115,9 +116,7 @@ class IotDimmer(IotPlug):
raise KasaException("Device is not dimmable.") raise KasaException("Device is not dimmable.")
if not isinstance(brightness, int): if not isinstance(brightness, int):
raise ValueError( raise ValueError("Brightness must be integer, not of %s.", type(brightness))
"Brightness must be integer, " "not of %s.", type(brightness)
)
if not 0 <= brightness <= 100: if not 0 <= brightness <= 100:
raise ValueError( raise ValueError(

View File

@ -161,11 +161,17 @@ class IotStrip(IotDevice):
async def turn_on(self, **kwargs) -> dict: async def turn_on(self, **kwargs) -> dict:
"""Turn the strip on.""" """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: async def turn_off(self, **kwargs) -> dict:
"""Turn the strip off.""" """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 @property # type: ignore
@requires_update @requires_update

View File

@ -4,6 +4,7 @@ from .ambientlight import AmbientLight
from .antitheft import Antitheft from .antitheft import Antitheft
from .cloud import Cloud from .cloud import Cloud
from .countdown import Countdown from .countdown import Countdown
from .dimmer import Dimmer
from .emeter import Emeter from .emeter import Emeter
from .led import Led from .led import Led
from .light import Light from .light import Light
@ -20,6 +21,7 @@ __all__ = [
"Antitheft", "Antitheft",
"Cloud", "Cloud",
"Countdown", "Countdown",
"Dimmer",
"Emeter", "Emeter",
"Led", "Led",
"Light", "Light",

270
kasa/iot/modules/dimmer.py Normal file
View 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})

View File

@ -3,13 +3,14 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import asdict from dataclasses import asdict
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, Annotated, cast
from ...device_type import DeviceType from ...device_type import DeviceType
from ...exceptions import KasaException from ...exceptions import KasaException
from ...feature import Feature 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 ...interfaces.light import Light as LightInterface
from ...module import FeatureAttribute
from ..iotmodule import IotModule from ..iotmodule import IotModule
if TYPE_CHECKING: if TYPE_CHECKING:
@ -32,7 +33,7 @@ class Light(IotModule, LightInterface):
super()._initialize_features() super()._initialize_features()
device = self._device device = self._device
if self._device._is_dimmable: if device._is_dimmable:
self._add_feature( self._add_feature(
Feature( Feature(
device, device,
@ -46,7 +47,9 @@ class Light(IotModule, LightInterface):
category=Feature.Category.Primary, 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( self._add_feature(
Feature( Feature(
device=device, device=device,
@ -55,12 +58,12 @@ class Light(IotModule, LightInterface):
container=self, container=self,
attribute_getter="color_temp", attribute_getter="color_temp",
attribute_setter="set_color_temp", attribute_setter="set_color_temp",
range_getter="valid_temperature_range", range_getter=lambda: device._valid_temperature_range,
category=Feature.Category.Primary, category=Feature.Category.Primary,
type=Feature.Type.Number, type=Feature.Type.Number,
) )
) )
if self._device._is_color: if device._is_color:
self._add_feature( self._add_feature(
Feature( Feature(
device=device, device=device,
@ -90,18 +93,13 @@ class Light(IotModule, LightInterface):
return None return None
@property # type: ignore @property # type: ignore
def is_dimmable(self) -> int: def brightness(self) -> Annotated[int, FeatureAttribute()]:
"""Whether the bulb supports brightness changes."""
return self._device._is_dimmable
@property # type: ignore
def brightness(self) -> int:
"""Return the current brightness in percentage.""" """Return the current brightness in percentage."""
return self._device._brightness return self._device._brightness
async def set_brightness( async def set_brightness(
self, brightness: int, *, transition: int | None = None 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. """Set the brightness in percentage. A value of 0 will turn off the light.
:param int brightness: brightness in percent :param int brightness: brightness in percent
@ -112,28 +110,7 @@ class Light(IotModule, LightInterface):
) )
@property @property
def is_color(self) -> bool: def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
"""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:
"""Return the current HSV state of the bulb. """Return the current HSV state of the bulb.
:return: hue, saturation and value (degrees, %, %) :return: hue, saturation and value (degrees, %, %)
@ -149,7 +126,7 @@ class Light(IotModule, LightInterface):
value: int | None = None, value: int | None = None,
*, *,
transition: int | None = None, transition: int | None = None,
) -> dict: ) -> Annotated[dict, FeatureAttribute()]:
"""Set new HSV. """Set new HSV.
Note, transition is not supported and will be ignored. 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) return await bulb._set_hsv(hue, saturation, value, transition=transition)
@property @property
def valid_temperature_range(self) -> ColorTempRange: def color_temp(self) -> Annotated[int, FeatureAttribute()]:
"""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:
"""Whether the bulb supports color temperature changes.""" """Whether the bulb supports color temperature changes."""
if ( if (
bulb := self._get_bulb_device() bulb := self._get_bulb_device()
@ -186,7 +151,7 @@ class Light(IotModule, LightInterface):
async def set_color_temp( async def set_color_temp(
self, temp: int, *, brightness: int | None = None, transition: int | None = None self, temp: int, *, brightness: int | None = None, transition: int | None = None
) -> dict: ) -> Annotated[dict, FeatureAttribute()]:
"""Set the color temperature of the device in kelvin. """Set the color temperature of the device in kelvin.
Note, transition is not supported and will be ignored. Note, transition is not supported and will be ignored.
@ -242,17 +207,18 @@ class Light(IotModule, LightInterface):
return self._light_state return self._light_state
async def _post_update_hook(self) -> None: 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) state = LightState(light_on=False)
else: else:
state = LightState(light_on=True) state = LightState(light_on=True)
if self.is_dimmable: if device._is_dimmable:
state.brightness = self.brightness state.brightness = self.brightness
if self.is_color: if device._is_color:
hsv = self.hsv hsv = self.hsv
state.hue = hsv.hue state.hue = hsv.hue
state.saturation = hsv.saturation state.saturation = hsv.saturation
if self.is_variable_color_temp: if device._is_variable_color_temp:
state.color_temp = self.color_temp state.color_temp = self.color_temp
self._light_state = state self._light_state = state

View File

@ -12,20 +12,11 @@ class LightEffect(IotModule, LightEffectInterface):
@property @property
def effect(self) -> str: def effect(self) -> str:
"""Return effect state. """Return effect name."""
Example:
{'brightness': 50,
'custom': 0,
'enable': 0,
'id': '',
'name': ''}
"""
eff = self.data["lighting_effect_state"] eff = self.data["lighting_effect_state"]
name = eff["name"] name = eff["name"]
if eff["enable"]: if eff["enable"]:
return name return name or self.LIGHT_EFFECTS_UNNAMED_CUSTOM
return self.LIGHT_EFFECTS_OFF return self.LIGHT_EFFECTS_OFF
@property @property

View File

@ -54,7 +54,7 @@ class LightPreset(IotModule, LightPresetInterface):
async def _post_update_hook(self) -> None: async def _post_update_hook(self) -> None:
"""Update the internal presets.""" """Update the internal presets."""
self._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"]) for index, vals in enumerate(self.data["preferred_state"])
# Devices may list some light effects along with normal presets but these # Devices may list some light effects along with normal presets but these
# are handled by the LightEffect module so exclude preferred states with id # are handled by the LightEffect module so exclude preferred states with id
@ -85,17 +85,19 @@ class LightPreset(IotModule, LightPresetInterface):
def preset(self) -> str: def preset(self) -> str:
"""Return current preset name.""" """Return current preset name."""
light = self._device.modules[Module.Light] light = self._device.modules[Module.Light]
is_color = light.has_feature("hsv")
is_variable_color_temp = light.has_feature("color_temp")
brightness = light.brightness brightness = light.brightness
color_temp = light.color_temp if light.is_variable_color_temp else None color_temp = light.color_temp if is_variable_color_temp else None
h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None)
h, s = (light.hsv.hue, light.hsv.saturation) if is_color else (None, None)
for preset_name, preset in self._presets.items(): for preset_name, preset in self._presets.items():
if ( if (
preset.brightness == brightness preset.brightness == brightness
and ( and (preset.color_temp == color_temp or not is_variable_color_temp)
preset.color_temp == color_temp or not light.is_variable_color_temp and (preset.hue == h or not is_color)
) and (preset.saturation == s or not is_color)
and (preset.hue == h or not light.is_color)
and (preset.saturation == s or not light.is_color)
): ):
return preset_name return preset_name
return self.PRESET_NOT_SET return self.PRESET_NOT_SET
@ -107,7 +109,7 @@ class LightPreset(IotModule, LightPresetInterface):
"""Set a light preset for the device.""" """Set a light preset for the device."""
light = self._device.modules[Module.Light] light = self._device.modules[Module.Light]
if preset_name == self.PRESET_NOT_SET: if preset_name == self.PRESET_NOT_SET:
if light.is_color: if light.has_feature("hsv"):
preset = LightState(hue=0, saturation=0, brightness=100) preset = LightState(hue=0, saturation=0, brightness=100)
else: else:
preset = LightState(brightness=100) preset = LightState(brightness=100)

View File

@ -3,11 +3,13 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
from dataclasses import dataclass
from enum import Enum from enum import Enum
from ...exceptions import KasaException from ...exceptions import KasaException
from ...feature import Feature from ...feature import Feature
from ..iotmodule import IotModule from ..iotmodule import IotModule, merge
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -20,6 +22,71 @@ class Range(Enum):
Near = 2 Near = 2
Custom = 3 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): class Motion(IotModule):
"""Implements the motion detection (PIR) module.""" """Implements the motion detection (PIR) module."""
@ -30,6 +97,11 @@ class Motion(IotModule):
if "get_config" not in self.data: if "get_config" not in self.data:
return 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: if "enable" not in self.config:
_LOGGER.warning("%r initialized, but no enable in response") _LOGGER.warning("%r initialized, but no enable in response")
return 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: def query(self) -> dict:
"""Request PIR configuration.""" """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 @property
def config(self) -> dict: def config(self) -> dict:
@ -58,34 +264,103 @@ class Motion(IotModule):
return self.data["get_config"] return self.data["get_config"]
@property @property
def range(self) -> Range: def pir_config(self) -> PIRConfig:
"""Return motion detection range.""" """Return PIR sensor configuration."""
return Range(self.config["trigger_index"]) 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 @property
def enabled(self) -> bool: def enabled(self) -> bool:
"""Return True if module is enabled.""" """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: async def set_enabled(self, state: bool) -> dict:
"""Enable/disable PIR.""" """Enable/disable PIR."""
return await self.call("set_enable", {"enable": int(state)}) return await self.call("set_enable", {"enable": int(state)})
async def set_range( @property
self, *, range: Range | None = None, custom_range: int | None = None def ranges(self) -> list[str]:
) -> dict: """Return set of supported range classes."""
"""Set the range for the sensor. 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 @property
:param custom_range: range in decimeters, overrides the range parameter 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.value}
payload = {"index": Range.Custom.value, "value": custom_range} return await self.call("set_trigger_sens", payload)
elif range is not None:
payload = {"index": range.value}
else:
raise KasaException("Either range or custom_range need to be defined")
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) return await self.call("set_trigger_sens", payload)
@property @property
@ -100,3 +375,34 @@ class Motion(IotModule):
to avoid reverting this back to 60 seconds after a period of time. to avoid reverting this back to 60 seconds after a period of time.
""" """
return await self.call("set_cold_time", {"cold_time": timeout}) 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

View File

@ -8,18 +8,24 @@ from typing import Any
try: try:
import orjson 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.""" """Dump JSON."""
return orjson.dumps(obj).decode() return orjson.dumps(
obj, option=orjson.OPT_INDENT_2 if indent else None
).decode()
loads = orjson.loads loads = orjson.loads
except ImportError: except ImportError:
import json 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.""" """Dump JSON."""
# Separators specified for consistency with orjson # 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 loads = json.loads

View File

@ -21,6 +21,9 @@ check for the existence of the module:
>>> print(light.brightness) >>> print(light.brightness)
100 100
.. include:: ../featureattributes.md
:parser: myst_parser.sphinx_
To see whether a device supports specific functionality, you can check whether the To see whether a device supports specific functionality, you can check whether the
module has that feature: module has that feature:
@ -78,6 +81,9 @@ ModuleT = TypeVar("ModuleT", bound="Module")
class FeatureAttribute: class FeatureAttribute:
"""Class for annotating attributes bound to feature.""" """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: def __repr__(self) -> str:
return "FeatureAttribute" return "FeatureAttribute"
@ -90,6 +96,8 @@ class Module(ABC):
""" """
# Common Modules # Common Modules
Alarm: Final[ModuleName[interfaces.Alarm]] = ModuleName("Alarm")
ChildSetup: Final[ModuleName[interfaces.ChildSetup]] = ModuleName("ChildSetup")
Energy: Final[ModuleName[interfaces.Energy]] = ModuleName("Energy") Energy: Final[ModuleName[interfaces.Energy]] = ModuleName("Energy")
Fan: Final[ModuleName[interfaces.Fan]] = ModuleName("Fan") Fan: Final[ModuleName[interfaces.Fan]] = ModuleName("Fan")
LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect") LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect")
@ -103,13 +111,13 @@ class Module(ABC):
IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient")
IotAntitheft: Final[ModuleName[iot.Antitheft]] = ModuleName("anti_theft") IotAntitheft: Final[ModuleName[iot.Antitheft]] = ModuleName("anti_theft")
IotCountdown: Final[ModuleName[iot.Countdown]] = ModuleName("countdown") IotCountdown: Final[ModuleName[iot.Countdown]] = ModuleName("countdown")
IotDimmer: Final[ModuleName[iot.Dimmer]] = ModuleName("dimmer")
IotMotion: Final[ModuleName[iot.Motion]] = ModuleName("motion") IotMotion: Final[ModuleName[iot.Motion]] = ModuleName("motion")
IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule") IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule")
IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage") IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage")
IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud") IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud")
# SMART only Modules # SMART only Modules
Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm")
AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff") AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff")
BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor") BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor")
Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness") Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness")
@ -149,16 +157,37 @@ class Module(ABC):
ChildProtection: Final[ModuleName[smart.ChildProtection]] = ModuleName( ChildProtection: Final[ModuleName[smart.ChildProtection]] = ModuleName(
"ChildProtection" "ChildProtection"
) )
ChildLock: Final[ModuleName[smart.ChildLock]] = ModuleName("ChildLock")
TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs") 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 # SMARTCAM only modules
Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera") 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: def __init__(self, device: Device, module: str) -> None:
self._device = device self._device = device
self._module = module self._module = module
self._module_features: dict[str, Feature] = {} self._module_features: dict[str, Feature] = {}
@property
def device(self) -> Device:
"""Return the device exposing the module."""
return self._device
@property @property
def _all_features(self) -> dict[str, Feature]: def _all_features(self) -> dict[str, Feature]:
"""Get the features for this module and any sub modules.""" """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.""" """Check if an attribute is bound to a feature with FeatureAttribute."""
if isinstance(attribute, property): if isinstance(attribute, property):
hints = get_type_hints(attribute.fget, include_extras=True) 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__ metadata = hints["return"].__metadata__
for meta in metadata: for meta in metadata:
if isinstance(meta, FeatureAttribute): if isinstance(meta, FeatureAttribute):
return True return meta
return False return None
@cache @cache
@ -257,12 +286,17 @@ def _get_bound_feature(
f"module {module.__class__.__name__}" f"module {module.__class__.__name__}"
) )
if not _is_bound_feature(attribute_callable): if not (fa := _get_feature_attribute(attribute_callable)):
raise KasaException( raise KasaException(
f"Attribute {attribute_name} of module {module.__class__.__name__}" f"Attribute {attribute_name} of module {module.__class__.__name__}"
" is not bound to a feature" " 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} check = {attribute_name, attribute_callable}
for feature in module._all_features.values(): for feature in module._all_features.values():
if (getter := feature.attribute_getter) and getter in check: if (getter := feature.attribute_getter) and getter in check:

View File

@ -2,6 +2,7 @@
from .iotprotocol import IotProtocol from .iotprotocol import IotProtocol
from .protocol import BaseProtocol from .protocol import BaseProtocol
from .smartcamprotocol import SmartCamProtocol
from .smartprotocol import SmartErrorCode, SmartProtocol from .smartprotocol import SmartErrorCode, SmartProtocol
__all__ = [ __all__ = [
@ -9,4 +10,5 @@ __all__ = [
"IotProtocol", "IotProtocol",
"SmartErrorCode", "SmartErrorCode",
"SmartProtocol", "SmartProtocol",
"SmartCamProtocol",
] ]

View File

@ -25,19 +25,35 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__) _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] = { REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"latitude": lambda x: 0, "latitude": lambda x: 0,
"longitude": lambda x: 0, "longitude": lambda x: 0,
"latitude_i": lambda x: 0, "latitude_i": lambda x: 0,
"longitude_i": lambda x: 0, "longitude_i": lambda x: 0,
"deviceId": lambda x: "REDACTED_" + x[9::], "deviceId": lambda x: "REDACTED_" + x[9::],
"id": lambda x: "REDACTED_" + x[9::], "children": _mask_children,
"alias": lambda x: "#MASKED_NAME#" if x else "", "alias": lambda x: "#MASKED_NAME#" if x else "",
"mac": mask_mac, "mac": mask_mac,
"mic_mac": mask_mac, "mic_mac": mask_mac,
"ssid": lambda x: "#MASKED_SSID#" if x else "", "ssid": lambda x: "#MASKED_SSID#" if x else "",
"oemId": lambda x: "REDACTED_" + x[9::], "oemId": lambda x: "REDACTED_" + x[9::],
"username": lambda _: "user@example.com", # cnCloud "username": lambda _: "user@example.com", # cnCloud
"hwId": lambda x: "REDACTED_" + x[9::],
} }
@ -82,12 +98,26 @@ class IotProtocol(BaseProtocol):
) )
raise auex raise auex
except _RetryableError as ex: 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() await self._transport.reset()
if retry >= retry_count: if retry >= retry_count:
_LOGGER.debug("Giving up on %s after %s retries", self._host, retry) _LOGGER.debug("Giving up on %s after %s retries", self._host, retry)
raise ex raise ex
continue continue
except TimeoutError as ex: 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() await self._transport.reset()
if retry >= retry_count: if retry >= retry_count:
_LOGGER.debug("Giving up on %s after %s retries", self._host, retry) _LOGGER.debug("Giving up on %s after %s retries", self._host, retry)

View File

@ -66,6 +66,8 @@ def redact_data(data: _T, redactors: dict[str, Callable[[Any], Any] | None]) ->
def mask_mac(mac: str) -> str: def mask_mac(mac: str) -> str:
"""Return mac address with last two octects blanked.""" """Return mac address with last two octects blanked."""
if len(mac) == 12:
return f"{mac[:6]}000000"
delim = ":" if ":" in mac else "-" delim = ":" if ":" in mac else "-"
rest = delim.join(format(s, "02x") for s in bytes.fromhex("000000")) rest = delim.join(format(s, "02x") for s in bytes.fromhex("000000"))
return f"{mac[:8]}{delim}{rest}" return f"{mac[:8]}{delim}{rest}"

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from pprint import pformat as pf from pprint import pformat as pf
from typing import Any from typing import Any, cast
from ..exceptions import ( from ..exceptions import (
AuthenticationError, AuthenticationError,
@ -19,7 +19,7 @@ from ..transports.sslaestransport import (
SMART_RETRYABLE_ERRORS, SMART_RETRYABLE_ERRORS,
SmartErrorCode, SmartErrorCode,
) )
from . import SmartProtocol from .smartprotocol import SmartProtocol
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -49,10 +49,13 @@ class SingleRequest:
class SmartCamProtocol(SmartProtocol): class SmartCamProtocol(SmartProtocol):
"""Class for SmartCam Protocol.""" """Class for SmartCam Protocol."""
async def _handle_response_lists( def _get_list_request(
self, response_result: dict[str, Any], method: str, retry_count: int self, method: str, params: dict | None, start_index: int
) -> None: ) -> dict:
pass # 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( def _handle_response_error_code(
self, resp_dict: dict, method: str, raise_on_error: bool = True 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"}: if len(request) == 1 and method in {"get", "set", "do", "multipleRequest"}:
single_request = self._get_smart_camera_single_request(request) single_request = self._get_smart_camera_single_request(request)
else: else:
return await self._execute_multiple_query(request, retry_count) return await self._execute_multiple_query(
request, retry_count, iterate_list_pages
)
else: else:
single_request = self._make_smart_camera_single_request(request) single_request = self._make_smart_camera_single_request(request)
@ -239,11 +244,15 @@ class _ChildCameraProtocolWrapper(SmartProtocol):
responses = response["multipleRequest"]["responses"] responses = response["multipleRequest"]["responses"]
response_dict = {} response_dict = {}
# Raise errors for single calls
raise_on_error = len(requests) == 1
for index_id, response in enumerate(responses): for index_id, response in enumerate(responses):
response_data = response["result"]["response_data"] response_data = response["result"]["response_data"]
method = methods[index_id] method = methods[index_id]
self._handle_response_error_code( 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") response_dict[method] = response_data.get("result")

View File

@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import logging import logging
import re
import time import time
import uuid import uuid
from collections.abc import Callable from collections.abc import Callable
@ -35,6 +36,18 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__) _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] = { REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"latitude": lambda x: 0, "latitude": lambda x: 0,
"longitude": 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 "original_device_id": lambda x: "REDACTED_" + x[9::], # Strip children
"nickname": lambda x: "I01BU0tFRF9OQU1FIw==" if x else "", "nickname": lambda x: "I01BU0tFRF9OQU1FIw==" if x else "",
"mac": mask_mac, "mac": mask_mac,
"ssid": lambda x: "I01BU0tFRF9TU0lEIw=" if x else "", "ssid": lambda x: "I01BU0tFRF9TU0lEIw==" if x else "",
"bssid": lambda _: "000000000000", "bssid": lambda _: "000000000000",
"channel": lambda _: 0,
"oem_id": lambda x: "REDACTED_" + x[9::], "oem_id": lambda x: "REDACTED_" + x[9::],
"setup_code": None, # matter "hw_id": lambda x: "REDACTED_" + x[9::],
"setup_payload": None, # matter "fw_id": lambda x: "REDACTED_" + x[9::],
"mfi_setup_code": None, # mfi_ for homekit "setup_code": lambda x: re.sub(r"\w", "0", x), # matter
"mfi_setup_id": None, "setup_payload": lambda x: re.sub(r"\w", "0", x), # matter
"mfi_token_token": None, "mfi_setup_code": lambda x: re.sub(r"\w", "0", x), # mfi_ for homekit
"mfi_token_uuid": None, "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._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE
) )
self._redact_data = True self._redact_data = True
self._method_missing_logged = False
def get_smart_request(self, method: str, params: dict | None = None) -> str: def get_smart_request(self, method: str, params: dict | None = None) -> str:
"""Get a request message as a string.""" """Get a request message as a string."""
@ -157,22 +198,25 @@ class SmartProtocol(BaseProtocol):
# make mypy happy, this should never be reached.. # make mypy happy, this should never be reached..
raise KasaException("Query reached somehow to unreachable") 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) debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)
multi_result: dict[str, Any] = {} multi_result: dict[str, Any] = {}
smart_method = "multipleRequest" smart_method = "multipleRequest"
multi_requests = [ end = len(requests)
{"method": method, "params": params} if params else {"method": method}
for method, params in requests.items()
]
end = len(multi_requests)
# The SmartCamProtocol sends requests with a length 1 as a # The SmartCamProtocol sends requests with a length 1 as a
# multipleRequest. The SmartProtocol doesn't so will never # multipleRequest. The SmartProtocol doesn't so will never
# raise_on_error # raise_on_error
raise_on_error = end == 1 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 # Break the requests down as there can be a size limit
step = self._multi_request_batch_size step = self._multi_request_batch_size
if step == 1: if step == 1:
@ -192,7 +236,7 @@ class SmartProtocol(BaseProtocol):
smart_params = {"requests": requests_step} smart_params = {"requests": requests_step}
smart_request = self.get_smart_request(smart_method, smart_params) 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: if debug_enabled:
_LOGGER.debug( _LOGGER.debug(
"%s %s >> %s", "%s %s >> %s",
@ -233,22 +277,41 @@ class SmartProtocol(BaseProtocol):
responses = response_step["result"]["responses"] responses = response_step["result"]["responses"]
for response in 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( self._handle_response_error_code(
response, method, raise_on_error=raise_on_error response, method, raise_on_error=raise_on_error
) )
result = response.get("result", None) result = response.get("result", None)
await self._handle_response_lists( request_params = rp if (rp := requests.get(method)) else None
result, method, retry_count=retry_count if iterate_list_pages and result:
) await self._handle_response_lists(
result, method, request_params, retry_count=retry_count
)
multi_result[method] = result 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(): for method, params in requests.items():
if method not in multi_result: if method not in multi_result:
resp = await self._transport.send( resp = await self._transport.send(
self.get_smart_request(method, params) 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") multi_result[method] = resp.get("result")
return multi_result return multi_result
@ -262,7 +325,9 @@ class SmartProtocol(BaseProtocol):
smart_method = next(iter(request)) smart_method = next(iter(request))
smart_params = request[smart_method] smart_params = request[smart_method]
else: else:
return await self._execute_multiple_query(request, retry_count) return await self._execute_multiple_query(
request, retry_count, iterate_list_pages
)
else: else:
smart_method = request smart_method = request
smart_params = None smart_params = None
@ -289,12 +354,21 @@ class SmartProtocol(BaseProtocol):
result = response_data.get("result") result = response_data.get("result")
if iterate_list_pages and result: if iterate_list_pages and result:
await self._handle_response_lists( 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} 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( 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: ) -> None:
if ( if (
response_result is None response_result is None
@ -314,8 +388,9 @@ class SmartProtocol(BaseProtocol):
) )
) )
while (list_length := len(response_result[response_list_name])) < list_sum: 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( response = await self._execute_query(
{method: {"start_index": list_length}}, request,
retry_count=retry_count, retry_count=retry_count,
iterate_list_pages=False, iterate_list_pages=False,
) )

View File

@ -6,16 +6,23 @@ from .autooff import AutoOff
from .batterysensor import BatterySensor from .batterysensor import BatterySensor
from .brightness import Brightness from .brightness import Brightness
from .childdevice import ChildDevice from .childdevice import ChildDevice
from .childlock import ChildLock
from .childprotection import ChildProtection from .childprotection import ChildProtection
from .childsetup import ChildSetup
from .clean import Clean
from .cleanrecords import CleanRecords
from .cloud import Cloud from .cloud import Cloud
from .color import Color from .color import Color
from .colortemperature import ColorTemperature from .colortemperature import ColorTemperature
from .consumables import Consumables
from .contactsensor import ContactSensor from .contactsensor import ContactSensor
from .devicemodule import DeviceModule from .devicemodule import DeviceModule
from .dustbin import Dustbin
from .energy import Energy from .energy import Energy
from .fan import Fan from .fan import Fan
from .firmware import Firmware from .firmware import Firmware
from .frostprotection import FrostProtection from .frostprotection import FrostProtection
from .homekit import HomeKit
from .humiditysensor import HumiditySensor from .humiditysensor import HumiditySensor
from .led import Led from .led import Led
from .light import Light from .light import Light
@ -23,8 +30,13 @@ from .lighteffect import LightEffect
from .lightpreset import LightPreset from .lightpreset import LightPreset
from .lightstripeffect import LightStripEffect from .lightstripeffect import LightStripEffect
from .lighttransition import LightTransition from .lighttransition import LightTransition
from .matter import Matter
from .mop import Mop
from .motionsensor import MotionSensor from .motionsensor import MotionSensor
from .overheatprotection import OverheatProtection
from .powerprotection import PowerProtection
from .reportmode import ReportMode from .reportmode import ReportMode
from .speaker import Speaker
from .temperaturecontrol import TemperatureControl from .temperaturecontrol import TemperatureControl
from .temperaturesensor import TemperatureSensor from .temperaturesensor import TemperatureSensor
from .thermostat import Thermostat from .thermostat import Thermostat
@ -38,6 +50,8 @@ __all__ = [
"Energy", "Energy",
"DeviceModule", "DeviceModule",
"ChildDevice", "ChildDevice",
"ChildLock",
"ChildSetup",
"BatterySensor", "BatterySensor",
"HumiditySensor", "HumiditySensor",
"TemperatureSensor", "TemperatureSensor",
@ -63,5 +77,15 @@ __all__ = [
"TriggerLogs", "TriggerLogs",
"FrostProtection", "FrostProtection",
"Thermostat", "Thermostat",
"Clean",
"Consumables",
"CleanRecords",
"SmartLightEffect", "SmartLightEffect",
"PowerProtection",
"OverheatProtection",
"Speaker",
"HomeKit",
"Matter",
"Dustbin",
"Mop",
] ]

View File

@ -2,13 +2,30 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal from typing import TYPE_CHECKING, Annotated, Literal, TypeAlias
from ...feature import Feature from ...feature import Feature
from ...interfaces import Alarm as AlarmInterface
from ...module import FeatureAttribute
from ..smartmodule import SmartModule 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.""" """Implementation of alarm module."""
REQUIRED_COMPONENT = "alarm" REQUIRED_COMPONENT = "alarm"
@ -21,10 +38,7 @@ class Alarm(SmartModule):
} }
def _initialize_features(self) -> None: def _initialize_features(self) -> None:
"""Initialize features. """Initialize features."""
This is implemented as some features depend on device responses.
"""
device = self._device device = self._device
self._add_feature( self._add_feature(
Feature( Feature(
@ -67,11 +81,37 @@ class Alarm(SmartModule):
id="alarm_volume", id="alarm_volume",
name="Alarm volume", name="Alarm volume",
container=self, container=self,
attribute_getter="alarm_volume", attribute_getter="_alarm_volume_str",
attribute_setter="set_alarm_volume", attribute_setter="set_alarm_volume",
category=Feature.Category.Config, category=Feature.Category.Config,
type=Feature.Type.Choice, 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( self._add_feature(
@ -96,15 +136,16 @@ class Alarm(SmartModule):
) )
@property @property
def alarm_sound(self) -> str: def alarm_sound(self) -> Annotated[str, FeatureAttribute()]:
"""Return current alarm sound.""" """Return current alarm sound."""
return self.data["get_alarm_configure"]["type"] 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. """Set alarm sound.
See *alarm_sounds* for list of available sounds. See *alarm_sounds* for list of available sounds.
""" """
self._check_sound(sound)
payload = self.data["get_alarm_configure"].copy() payload = self.data["get_alarm_configure"].copy()
payload["type"] = sound payload["type"] = sound
return await self.call("set_alarm_configure", payload) 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"] return self.data["get_support_alarm_type_list"]["alarm_type_list"]
@property @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 alarm volume."""
return self.data["get_alarm_configure"]["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.""" """Set alarm volume."""
self._check_and_convert_volume(volume)
payload = self.data["get_alarm_configure"].copy() payload = self.data["get_alarm_configure"].copy()
payload["volume"] = volume payload["volume"] = volume
return await self.call("set_alarm_configure", payload) 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 @property
def active(self) -> bool: def active(self) -> bool:
"""Return true if alarm is active.""" """Return true if alarm is active."""
@ -136,10 +201,62 @@ class Alarm(SmartModule):
src = self._device.sys_info["in_alarm_source"] src = self._device.sys_info["in_alarm_source"]
return src if src else None return src if src else None
async def play(self) -> dict: async def play(
"""Play alarm.""" self,
return await self.call("play_alarm") *,
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: async def stop(self) -> dict:
"""Stop alarm.""" """Stop alarm."""
return await self.call("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}")

View File

@ -2,7 +2,11 @@
from __future__ import annotations from __future__ import annotations
from typing import Annotated
from ...exceptions import KasaException
from ...feature import Feature from ...feature import Feature
from ...module import FeatureAttribute
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
@ -14,18 +18,22 @@ class BatterySensor(SmartModule):
def _initialize_features(self) -> None: def _initialize_features(self) -> None:
"""Initialize features.""" """Initialize features."""
self._add_feature( if (
Feature( "at_low_battery" in self._device.sys_info
self._device, or "is_low" in self._device.sys_info
"battery_low", ):
"Battery low", self._add_feature(
container=self, Feature(
attribute_getter="battery_low", self._device,
icon="mdi:alert", "battery_low",
type=Feature.Type.BinarySensor, "Battery low",
category=Feature.Category.Debug, 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 # Some devices, like T110 contact sensor do not report the battery percentage
if "battery_percentage" in self._device.sys_info: if "battery_percentage" in self._device.sys_info:
@ -48,11 +56,17 @@ class BatterySensor(SmartModule):
return {} return {}
@property @property
def battery(self) -> int: def battery(self) -> Annotated[int, FeatureAttribute()]:
"""Return battery level.""" """Return battery level."""
return self._device.sys_info["battery_percentage"] return self._device.sys_info["battery_percentage"]
@property @property
def battery_low(self) -> bool: def battery_low(self) -> Annotated[bool, FeatureAttribute()]:
"""Return True if battery is low.""" """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

View File

@ -38,6 +38,7 @@ Plug 3: False
True True
""" """
from ...device_type import DeviceType
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
@ -46,3 +47,10 @@ class ChildDevice(SmartModule):
REQUIRED_COMPONENT = "child_device" REQUIRED_COMPONENT = "child_device"
QUERY_GETTER_NAME = "get_child_device_list" 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

View 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})

View 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
View 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)

View 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

View 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

View File

@ -10,7 +10,7 @@ class ContactSensor(SmartModule):
"""Implementation of contact sensor module.""" """Implementation of contact sensor module."""
REQUIRED_COMPONENT = None # we depend on availability of key REQUIRED_COMPONENT = None # we depend on availability of key
REQUIRED_KEY_ON_PARENT = "open" SYSINFO_LOOKUP_KEYS = ["open"]
def _initialize_features(self) -> None: def _initialize_features(self) -> None:
"""Initialize features after the initial update.""" """Initialize features after the initial update."""

View File

@ -19,12 +19,15 @@ class DeviceModule(SmartModule):
def query(self) -> dict: def query(self) -> dict:
"""Query to execute during the update cycle.""" """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 = { query = {
"get_device_info": None, "get_device_info": None,
} }
# Device usage is not available on older firmware versions # Device usage is not available on older firmware versions
# or child devices of hubs # 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 query["get_device_usage"] = None
return query return query

View 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)

View File

@ -2,10 +2,10 @@
from __future__ import annotations from __future__ import annotations
from typing import NoReturn from typing import Any, NoReturn
from ...emeterstatus import EmeterStatus from ...emeterstatus import EmeterStatus
from ...exceptions import KasaException from ...exceptions import DeviceError, KasaException
from ...interfaces.energy import Energy as EnergyInterface from ...interfaces.energy import Energy as EnergyInterface
from ..smartmodule import SmartModule, raise_if_update_error from ..smartmodule import SmartModule, raise_if_update_error
@ -15,12 +15,39 @@ class Energy(SmartModule, EnergyInterface):
REQUIRED_COMPONENT = "energy_monitoring" REQUIRED_COMPONENT = "energy_monitoring"
_energy: dict[str, Any]
_current_consumption: float | None
async def _post_update_hook(self) -> 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 = (
self._supported | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT 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: def query(self) -> dict:
"""Query to execute during the update cycle.""" """Query to execute during the update cycle."""
req = { req = {
@ -33,28 +60,21 @@ class Energy(SmartModule, EnergyInterface):
return req return req
@property @property
@raise_if_update_error def optional_response_keys(self) -> list[str]:
def current_consumption(self) -> float | None: """Return optional response keys for the module."""
"""Current power in watts.""" if self.supported_version > 1:
if (power := self.energy.get("current_power")) is not None or ( return ["get_energy_usage"]
power := self.data.get("get_emeter_data", {}).get("power_mw") return []
) is not None:
return power / 1_000 @property
# Fallback if get_energy_usage does not provide current_power, def current_consumption(self) -> float | None:
# which can happen on some newer devices (e.g. P304M). """Current power in watts."""
elif ( return self._current_consumption
power := self.data.get("get_current_power", {}).get("current_power")
) is not None:
return power
return None
@property @property
@raise_if_update_error
def energy(self) -> dict: def energy(self) -> dict:
"""Return get_energy_usage results.""" """Return get_energy_usage results."""
if en := self.data.get("get_energy_usage"): return self._energy
return en
return self.data
def _get_status_from_energy(self, energy: dict) -> EmeterStatus: def _get_status_from_energy(self, energy: dict) -> EmeterStatus:
return EmeterStatus( return EmeterStatus(
@ -83,16 +103,18 @@ class Energy(SmartModule, EnergyInterface):
return self._get_status_from_energy(res["get_energy_usage"]) return self._get_status_from_energy(res["get_energy_usage"])
@property @property
@raise_if_update_error
def consumption_this_month(self) -> float | None: def consumption_this_month(self) -> float | None:
"""Get the emeter value for this month in kWh.""" """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 @property
@raise_if_update_error
def consumption_today(self) -> float | None: def consumption_today(self) -> float | None:
"""Get the emeter value for today in kWh.""" """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 @property
@raise_if_update_error @raise_if_update_error
@ -104,15 +126,17 @@ class Energy(SmartModule, EnergyInterface):
@raise_if_update_error @raise_if_update_error
def current(self) -> float | None: def current(self) -> float | None:
"""Return the current in A.""" """Return the current in A."""
ma = self.data.get("get_emeter_data", {}).get("current_ma") if (ma := self.data.get("get_emeter_data", {}).get("current_ma")) is not None:
return ma / 1000 if ma else None return ma / 1_000
return None
@property @property
@raise_if_update_error @raise_if_update_error
def voltage(self) -> float | None: def voltage(self) -> float | None:
"""Get the current voltage in V.""" """Get the current voltage in V."""
mv = self.data.get("get_emeter_data", {}).get("voltage_mv") if (mv := self.data.get("get_emeter_data", {}).get("voltage_mv")) is not None:
return mv / 1000 if mv else None return mv / 1_000
return None
async def _deprecated_get_realtime(self) -> EmeterStatus: async def _deprecated_get_realtime(self) -> EmeterStatus:
"""Retrieve current energy readings.""" """Retrieve current energy readings."""

View 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

View File

@ -7,7 +7,7 @@ from typing import Annotated
from ...exceptions import KasaException from ...exceptions import KasaException
from ...feature import Feature 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 ...interfaces.light import Light as LightInterface
from ...module import FeatureAttribute, Module from ...module import FeatureAttribute, Module
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
@ -34,39 +34,13 @@ class Light(SmartModule, LightInterface):
"""Query to execute during the update cycle.""" """Query to execute during the update cycle."""
return {} 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 @property
def hsv(self) -> Annotated[HSV, FeatureAttribute()]: def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
"""Return the current HSV state of the bulb. """Return the current HSV state of the bulb.
:return: hue, saturation and value (degrees, %, %) :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.") raise KasaException("Bulb does not support color.")
return self._device.modules[Module.Color].hsv return self._device.modules[Module.Color].hsv
@ -74,7 +48,7 @@ class Light(SmartModule, LightInterface):
@property @property
def color_temp(self) -> Annotated[int, FeatureAttribute()]: def color_temp(self) -> Annotated[int, FeatureAttribute()]:
"""Whether the bulb supports color temperature changes.""" """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.") raise KasaException("Bulb does not support colortemp.")
return self._device.modules[Module.ColorTemperature].color_temp return self._device.modules[Module.ColorTemperature].color_temp
@ -82,7 +56,7 @@ class Light(SmartModule, LightInterface):
@property @property
def brightness(self) -> Annotated[int, FeatureAttribute()]: def brightness(self) -> Annotated[int, FeatureAttribute()]:
"""Return the current brightness in percentage.""" """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.") raise KasaException("Bulb is not dimmable.")
return self._device.modules[Module.Brightness].brightness 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 value: value between 1 and 100
:param int transition: transition in milliseconds. :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.") raise KasaException("Bulb does not support color.")
return await self._device.modules[Module.Color].set_hsv(hue, saturation, value) 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 temp: The new color temperature, in Kelvin
:param int transition: transition in milliseconds. :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.") raise KasaException("Bulb does not support colortemp.")
return await self._device.modules[Module.ColorTemperature].set_color_temp( return await self._device.modules[Module.ColorTemperature].set_color_temp(
temp, brightness=brightness temp, brightness=brightness
@ -135,16 +109,11 @@ class Light(SmartModule, LightInterface):
:param int brightness: brightness in percent :param int brightness: brightness in percent
:param int transition: transition in milliseconds. :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.") raise KasaException("Bulb is not dimmable.")
return await self._device.modules[Module.Brightness].set_brightness(brightness) 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: async def set_state(self, state: LightState) -> dict:
"""Set the light state.""" """Set the light state."""
state_dict = asdict(state) state_dict = asdict(state)
@ -167,16 +136,17 @@ class Light(SmartModule, LightInterface):
return self._light_state return self._light_state
async def _post_update_hook(self) -> None: 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) state = LightState(light_on=False)
else: else:
state = LightState(light_on=True) state = LightState(light_on=True)
if self.is_dimmable: if Module.Brightness in device.modules:
state.brightness = self.brightness state.brightness = self.brightness
if self.is_color: if Module.Color in device.modules:
hsv = self.hsv hsv = self.hsv
state.hue = hsv.hue state.hue = hsv.hue
state.saturation = hsv.saturation state.saturation = hsv.saturation
if self.is_variable_color_temp: if Module.ColorTemperature in device.modules:
state.color_temp = self.color_temp state.color_temp = self.color_temp
self._light_state = state self._light_state = state

View File

@ -96,13 +96,18 @@ class LightPreset(SmartModule, LightPresetInterface):
"""Return current preset name.""" """Return current preset name."""
light = self._device.modules[SmartModule.Light] light = self._device.modules[SmartModule.Light]
brightness = light.brightness brightness = light.brightness
color_temp = light.color_temp if light.is_variable_color_temp else None color_temp = light.color_temp if light.has_feature("color_temp") else None
h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, 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(): for preset_name, preset in self._presets.items():
if ( if (
preset.brightness == brightness preset.brightness == brightness
and ( 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.hue == h
and preset.saturation == s and preset.saturation == s
@ -117,7 +122,7 @@ class LightPreset(SmartModule, LightPresetInterface):
"""Set a light preset for the device.""" """Set a light preset for the device."""
light = self._device.modules[SmartModule.Light] light = self._device.modules[SmartModule.Light]
if preset_name == self.PRESET_NOT_SET: if preset_name == self.PRESET_NOT_SET:
if light.is_color: if light.has_feature("hsv"):
preset = LightState(hue=0, saturation=0, brightness=100) preset = LightState(hue=0, saturation=0, brightness=100)
else: else:
preset = LightState(brightness=100) preset = LightState(brightness=100)

View File

@ -37,20 +37,14 @@ class LightStripEffect(SmartModule, SmartLightEffect):
@property @property
def effect(self) -> str: def effect(self) -> str:
"""Return effect state. """Return effect name."""
Example:
{'brightness': 50,
'custom': 0,
'enable': 0,
'id': '',
'name': ''}
"""
eff = self.data["lighting_effect"] eff = self.data["lighting_effect"]
name = eff["name"] name = eff["name"]
# When devices are unpaired effect name is softAP which is not in our list # When devices are unpaired effect name is softAP which is not in our list
if eff["enable"] and name in self._effect_list: if eff["enable"] and name in self._effect_list:
return name return name
if eff["enable"] and eff["custom"]:
return name or self.LIGHT_EFFECTS_UNNAMED_CUSTOM
return self.LIGHT_EFFECTS_OFF return self.LIGHT_EFFECTS_OFF
@property @property

View 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
View 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)

View 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 {}

View 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

View 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"})

View File

@ -6,10 +6,11 @@ import logging
import time import time
from typing import Any from typing import Any
from ..device import DeviceInfo
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from ..protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper
from .smartdevice import SmartDevice from .smartdevice import ComponentsRaw, SmartDevice
from .smartmodule import SmartModule from .smartmodule import SmartModule
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -23,6 +24,7 @@ class SmartChildDevice(SmartDevice):
CHILD_DEVICE_TYPE_MAP = { CHILD_DEVICE_TYPE_MAP = {
"plug.powerstrip.sub-plug": DeviceType.Plug, "plug.powerstrip.sub-plug": DeviceType.Plug,
"subg.plugswitch.switch": DeviceType.WallSwitch,
"subg.trigger.contact-sensor": DeviceType.Sensor, "subg.trigger.contact-sensor": DeviceType.Sensor,
"subg.trigger.temp-hmdt-sensor": DeviceType.Sensor, "subg.trigger.temp-hmdt-sensor": DeviceType.Sensor,
"subg.trigger.water-leak-sensor": DeviceType.Sensor, "subg.trigger.water-leak-sensor": DeviceType.Sensor,
@ -37,7 +39,7 @@ class SmartChildDevice(SmartDevice):
self, self,
parent: SmartDevice, parent: SmartDevice,
info: dict, info: dict,
component_info: dict, component_info_raw: ComponentsRaw,
*, *,
config: DeviceConfig | None = None, config: DeviceConfig | None = None,
protocol: SmartProtocol | None = None, protocol: SmartProtocol | None = None,
@ -47,7 +49,24 @@ class SmartChildDevice(SmartDevice):
super().__init__(parent.host, config=parent.config, protocol=_protocol) super().__init__(parent.host, config=parent.config, protocol=_protocol)
self._parent = parent self._parent = parent
self._update_internal_state(info) 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: async def update(self, update_children: bool = True) -> None:
"""Update child module info. """Update child module info.
@ -67,11 +86,22 @@ class SmartChildDevice(SmartDevice):
module_queries: list[SmartModule] = [] module_queries: list[SmartModule] = []
req: dict[str, Any] = {} req: dict[str, Any] = {}
for module in self.modules.values(): 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) module_queries.append(module)
req.update(mod_query) req.update(mod_query)
if req: 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(): for module in self.modules.values():
await self._handle_module_post_update( await self._handle_module_post_update(
@ -79,12 +109,17 @@ class SmartChildDevice(SmartDevice):
) )
self._last_update_time = now 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 @classmethod
async def create( async def create(
cls, cls,
parent: SmartDevice, parent: SmartDevice,
child_info: dict, child_info: dict,
child_components: dict, child_components_raw: ComponentsRaw,
protocol: SmartProtocol | None = None, protocol: SmartProtocol | None = None,
*, *,
last_update: dict | None = None, last_update: dict | None = None,
@ -97,7 +132,7 @@ class SmartChildDevice(SmartDevice):
derived from the parent. derived from the parent.
""" """
child: SmartChildDevice = cls( child: SmartChildDevice = cls(
parent, child_info, child_components, protocol=protocol parent, child_info, child_components_raw, protocol=protocol
) )
if last_update: if last_update:
child._last_update = last_update child._last_update = last_update

View File

@ -5,11 +5,12 @@ from __future__ import annotations
import base64 import base64
import logging import logging
import time 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 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 ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
@ -40,6 +41,8 @@ _LOGGER = logging.getLogger(__name__)
# same issue, homekit perhaps? # same issue, homekit perhaps?
NON_HUB_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] 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 # Device must go last as the other interfaces also inherit Device
# and python needs a consistent method resolution order. # and python needs a consistent method resolution order.
@ -61,16 +64,18 @@ class SmartDevice(Device):
) )
super().__init__(host=host, config=config, protocol=_protocol) super().__init__(host=host, config=config, protocol=_protocol)
self.protocol: SmartProtocol self.protocol: SmartProtocol
self._components_raw: dict[str, Any] | None = None self._components_raw: ComponentsRaw | None = None
self._components: dict[str, int] = {} self._components: dict[str, int] = {}
self._state_information: dict[str, Any] = {} 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._parent: SmartDevice | None = None
self._children: Mapping[str, SmartDevice] = {} self._children: dict[str, SmartDevice] = {}
self._last_update = {}
self._last_update_time: float | None = None self._last_update_time: float | None = None
self._on_since: datetime | None = None self._on_since: datetime | None = None
self._info: dict[str, Any] = {} self._info: dict[str, Any] = {}
self._logged_missing_child_ids: set[str] = set()
async def _initialize_children(self) -> None: async def _initialize_children(self) -> None:
"""Initialize children for power strips.""" """Initialize children for power strips."""
@ -81,25 +86,86 @@ class SmartDevice(Device):
resp = await self.protocol.query(child_info_query) resp = await self.protocol.query(child_info_query)
self.internal_state.update(resp) self.internal_state.update(resp)
children = self.internal_state["get_child_device_list"]["child_device_list"] async def _try_create_child(
children_components = { self, info: dict, child_components: dict
child["device_id"]: { ) -> SmartDevice | None:
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"
]
}
from .smartchilddevice import SmartChildDevice from .smartchilddevice import SmartChildDevice
self._children = { return await SmartChildDevice.create(
child_info["device_id"]: await SmartChildDevice.create( parent=self,
parent=self, child_info=info,
child_info=child_info, child_components_raw=child_components,
child_components=children_components[child_info["device_id"]], )
)
for child_info in children 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 @property
def children(self) -> Sequence[SmartDevice]: def children(self) -> Sequence[SmartDevice]:
@ -131,6 +197,13 @@ class SmartDevice(Device):
f"{request} not found in {responses} for device {self.host}" 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: async def _negotiate(self) -> None:
"""Perform initialization. """Perform initialization.
@ -151,36 +224,41 @@ class SmartDevice(Device):
self._info = self._try_get_response(resp, "get_device_info") self._info = self._try_get_response(resp, "get_device_info")
# Create our internal presentation of available components # 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 = { self._components = self._parse_components(self._components_raw)
comp["id"]: int(comp["ver_code"])
for comp in self._components_raw["component_list"]
}
if "child_device" in self._components and not self.children: if "child_device" in self._components and not self.children:
await self._initialize_children() await self._initialize_children()
def _update_children_info(self) -> None: async def _update_children_info(self) -> bool:
"""Update the internal child device info from the parent info.""" """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( if child_info := self._try_get_response(
self._last_update, "get_child_device_list", {} 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"]: for info in child_info["child_device_list"]:
child_id = info["device_id"] child_id = info.get("device_id")
if child_id not in self._children: if child_id not in self._children:
_LOGGER.debug( # _create_delete_children has already logged a message
"Skipping child update for %s, probably unsupported device",
child_id,
)
continue continue
self._children[child_id]._update_internal_state(info) self._children[child_id]._update_internal_state(info)
return changed
def _update_internal_info(self, info_resp: dict) -> None: def _update_internal_info(self, info_resp: dict) -> None:
"""Update the internal device info.""" """Update the internal device info."""
self._info = self._try_get_response(info_resp, "get_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.""" """Update the device."""
if self.credentials is None and self.credentials_hash is None: if self.credentials is None and self.credentials_hash is None:
raise AuthenticationError("Tapo plug requires authentication.") raise AuthenticationError("Tapo plug requires authentication.")
@ -198,13 +276,13 @@ class SmartDevice(Device):
resp = await self._modular_update(first_update, now) 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 # Call child update which will only update module calls, info is updated
# from get_child_device_list. update_children only affects hub devices, other # from get_child_device_list. update_children only affects hub devices, other
# devices will always update children to prevent errors on module access. # 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 # This needs to go after updating the internal state of the children so that
# child modules have access to their sysinfo. # 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(): for child in self._children.values():
if TYPE_CHECKING: if TYPE_CHECKING:
assert isinstance(child, SmartChildDevice) assert isinstance(child, SmartChildDevice)
@ -257,11 +335,7 @@ class SmartDevice(Device):
if first_update and module.__class__ in self.FIRST_UPDATE_MODULES: if first_update and module.__class__ in self.FIRST_UPDATE_MODULES:
module._last_update_time = update_time module._last_update_time = update_time
continue continue
if ( if module._should_update(update_time):
not module.update_interval
or not module._last_update_time
or (update_time - module._last_update_time) >= module.update_interval
):
module_queries.append(module) module_queries.append(module)
req.update(query) req.update(query)
@ -349,9 +423,8 @@ class SmartDevice(Device):
) or mod.__name__ in child_modules_to_skip: ) or mod.__name__ in child_modules_to_skip:
continue continue
required_component = cast(str, mod.REQUIRED_COMPONENT) required_component = cast(str, mod.REQUIRED_COMPONENT)
if required_component in self._components or ( if required_component in self._components or any(
mod.REQUIRED_KEY_ON_PARENT self.sys_info.get(key) is not None for key in mod.SYSINFO_LOOKUP_KEYS
and self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None
): ):
_LOGGER.debug( _LOGGER.debug(
"Device %s, found required %s, adding %s to modules.", "Device %s, found required %s, adding %s to modules.",
@ -375,6 +448,11 @@ class SmartDevice(Device):
): ):
self._modules[Thermostat.__name__] = Thermostat(self, "thermostat") 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: async def _initialize_features(self) -> None:
"""Initialize device features.""" """Initialize device features."""
self._add_feature( self._add_feature(
@ -440,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, # We check for the key available, and not for the property truthiness,
# as the value is falsy when the device is off. # as the value is falsy when the device is off.
if "on_time" in self._info: if "on_time" in self._info:
@ -480,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(): for module in self.modules.values():
module._initialize_features() module._initialize_features()
for feat in module._module_features.values(): for feat in module._module_features.values():
self._add_feature(feat) self._add_feature(feat)
for child in self._children.values():
await child._initialize_features()
@property @property
def _is_hub_child(self) -> bool: def _is_hub_child(self) -> bool:
@ -507,18 +585,13 @@ class SmartDevice(Device):
@property @property
def model(self) -> str: def model(self) -> str:
"""Returns the device model.""" """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 disco_model = str(self._info.get("device_model"))
def _model_region(self) -> str: long_name, _, _ = disco_model.partition("(")
"""Return device full model name and region.""" return long_name
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}"
@property @property
def alias(self) -> str | None: def alias(self) -> str | None:
@ -618,12 +691,8 @@ class SmartDevice(Device):
""" """
self._info = info self._info = info
async def _query_helper( async def _query_helper(self, method: str, params: dict | None = None) -> dict:
self, method: str, params: dict | None = None, child_ids: None = None return await self.protocol.query({method: params})
) -> dict:
res = await self.protocol.query({method: params})
return res
@property @property
def ssid(self) -> str: def ssid(self) -> str:
@ -772,10 +841,11 @@ class SmartDevice(Device):
if self._device_type is not DeviceType.Unknown: if self._device_type is not DeviceType.Unknown:
return self._device_type return self._device_type
# Fallback to device_type (from disco info) if (
type_str = self._info.get("type", self._info.get("device_type")) not (type_str := self._info.get("type", self._info.get("device_type")))
or not self._components
if not type_str: # no update or discovery info ):
# no update or discovery info
return self._device_type return self._device_type
self._device_type = self._get_device_type_from_components( self._device_type = self._get_device_type_from_components(
@ -809,13 +879,17 @@ class SmartDevice(Device):
return DeviceType.Sensor return DeviceType.Sensor
if "ENERGY" in device_type: if "ENERGY" in device_type:
return DeviceType.Thermostat 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") _LOGGER.warning("Unknown device type, falling back to plug")
return DeviceType.Plug return DeviceType.Plug
@staticmethod @staticmethod
def _get_device_info( def _get_device_info(
info: dict[str, Any], discovery_info: dict[str, Any] | None info: dict[str, Any], discovery_info: dict[str, Any] | None
) -> _DeviceInfo: ) -> DeviceInfo:
"""Get model information for a device.""" """Get model information for a device."""
di = info["get_device_info"] di = info["get_device_info"]
components = [comp["id"] for comp in info["component_nego"]["component_list"]] components = [comp["id"] for comp in info["component_nego"]["component_list"]]
@ -839,12 +913,15 @@ class SmartDevice(Device):
components, device_family components, device_family
) )
fw_version_full = di["fw_ver"] 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(".") _protocol, devicetype = device_family.split(".")
# Brand inferred from SMART.KASAPLUG/SMART.TAPOPLUG etc. # Brand inferred from SMART.KASAPLUG/SMART.TAPOPLUG etc.
brand = devicetype[:4].lower() brand = devicetype[:4].lower()
return _DeviceInfo( return DeviceInfo(
short_name=short_name, short_name=short_name,
long_name=long_name, long_name=long_name,
brand=brand, brand=brand,

View File

@ -3,7 +3,8 @@
from __future__ import annotations from __future__ import annotations
import logging 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 typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar
from ..exceptions import DeviceError, KasaException, SmartErrorCode from ..exceptions import DeviceError, KasaException, SmartErrorCode
@ -20,15 +21,16 @@ _R = TypeVar("_R")
def allow_update_after( def allow_update_after(
func: Callable[Concatenate[_T, _P], Awaitable[dict]], func: Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]],
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, dict]]: ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]:
"""Define a wrapper to set _last_update_time to None. """Define a wrapper to set _last_update_time to None.
This will ensure that a module is updated in the next update cycle after This will ensure that a module is updated in the next update cycle after
a value has been changed. 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: try:
return await func(self, *args, **kwargs) return await func(self, *args, **kwargs)
finally: finally:
@ -40,6 +42,7 @@ def allow_update_after(
def raise_if_update_error(func: Callable[[_T], _R]) -> Callable[[_T], _R]: 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.""" """Define a wrapper to raise an error if the last module update was an error."""
@wraps(func)
def _wrap(self: _T) -> _R: def _wrap(self: _T) -> _R:
if err := self._last_update_error: if err := self._last_update_error:
raise err raise err
@ -54,14 +57,16 @@ class SmartModule(Module):
NAME: str NAME: str
#: Module is initialized, if the given component is available #: Module is initialized, if the given component is available
REQUIRED_COMPONENT: str | None = None REQUIRED_COMPONENT: str | None = None
#: Module is initialized, if the given key available in the main sysinfo #: Module is initialized, if any of the given keys exists in the sysinfo
REQUIRED_KEY_ON_PARENT: str | None = None SYSINFO_LOOKUP_KEYS: list[str] = []
#: Query to execute during the main update cycle #: Query to execute during the main update cycle
QUERY_GETTER_NAME: str QUERY_GETTER_NAME: str = ""
REGISTERED_MODULES: dict[str, type[SmartModule]] = {} REGISTERED_MODULES: dict[str, type[SmartModule]] = {}
MINIMUM_UPDATE_INTERVAL_SECS = 0 MINIMUM_UPDATE_INTERVAL_SECS = 0
MINIMUM_HUB_CHILD_UPDATE_INTERVAL_SECS = 60 * 60 * 24
UPDATE_INTERVAL_AFTER_ERROR_SECS = 30 UPDATE_INTERVAL_AFTER_ERROR_SECS = 30
DISABLE_AFTER_ERROR_COUNT = 10 DISABLE_AFTER_ERROR_COUNT = 10
@ -72,6 +77,7 @@ class SmartModule(Module):
self._last_update_time: float | None = None self._last_update_time: float | None = None
self._last_update_error: KasaException | None = None self._last_update_error: KasaException | None = None
self._error_count = 0 self._error_count = 0
self._logged_remove_keys: list[str] = []
def __init_subclass__(cls, **kwargs) -> None: def __init_subclass__(cls, **kwargs) -> None:
# We only want to register submodules in a modules package so that # We only want to register submodules in a modules package so that
@ -106,16 +112,27 @@ class SmartModule(Module):
@property @property
def update_interval(self) -> int: def update_interval(self) -> int:
"""Time to wait between updates.""" """Time to wait between updates."""
if self._last_update_error is None: if self._last_update_error:
return self.MINIMUM_UPDATE_INTERVAL_SECS 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 @property
def disabled(self) -> bool: def disabled(self) -> bool:
"""Return true if the module is disabled due to errors.""" """Return true if the module is disabled due to errors."""
return self._error_count >= self.DISABLE_AFTER_ERROR_COUNT 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 @classmethod
def _module_name(cls) -> str: def _module_name(cls) -> str:
return getattr(cls, "NAME", cls.__name__) return getattr(cls, "NAME", cls.__name__)
@ -138,7 +155,9 @@ class SmartModule(Module):
Default implementation uses the raw query getter w/o parameters. 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: async def call(self, method: str, params: dict | None = None) -> dict:
"""Call a method. """Call a method.
@ -147,6 +166,15 @@ class SmartModule(Module):
""" """
return await self._device._query_helper(method, params) 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 @property
def data(self) -> dict[str, Any]: def data(self) -> dict[str, Any]:
"""Return response data for the module. """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} 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: for data_item in filtered_data:
if isinstance(filtered_data[data_item], SmartErrorCode): if isinstance(filtered_data[data_item], SmartErrorCode):
raise DeviceError( if data_item in self.optional_response_keys:
f"{data_item} for {self.name}", error_code=filtered_data[data_item] 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 next(iter(filtered_data.values()))
return filtered_data return filtered_data

View File

@ -1,5 +1,6 @@
"""Package for supporting tapo-branded cameras.""" """Package for supporting tapo-branded cameras."""
from .smartcamchild import SmartCamChild
from .smartcamdevice import SmartCamDevice from .smartcamdevice import SmartCamDevice
__all__ = ["SmartCamDevice"] __all__ = ["SmartCamDevice", "SmartCamChild"]

View File

@ -1,19 +1,39 @@
"""Modules for SMARTCAM devices.""" """Modules for SMARTCAM devices."""
from .alarm import Alarm from .alarm import Alarm
from .babycrydetection import BabyCryDetection
from .battery import Battery
from .camera import Camera from .camera import Camera
from .childdevice import ChildDevice from .childdevice import ChildDevice
from .childsetup import ChildSetup
from .device import DeviceModule from .device import DeviceModule
from .homekit import HomeKit
from .led import Led from .led import Led
from .lensmask import LensMask
from .matter import Matter
from .motiondetection import MotionDetection
from .pantilt import PanTilt from .pantilt import PanTilt
from .persondetection import PersonDetection
from .petdetection import PetDetection
from .tamperdetection import TamperDetection
from .time import Time from .time import Time
__all__ = [ __all__ = [
"Alarm", "Alarm",
"BabyCryDetection",
"Battery",
"Camera", "Camera",
"ChildDevice", "ChildDevice",
"ChildSetup",
"DeviceModule", "DeviceModule",
"Led", "Led",
"PanTilt", "PanTilt",
"PersonDetection",
"PetDetection",
"Time", "Time",
"HomeKit",
"Matter",
"MotionDetection",
"LensMask",
"TamperDetection",
] ]

View File

@ -2,7 +2,12 @@
from __future__ import annotations from __future__ import annotations
from typing import Annotated
from ...feature import Feature 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 from ..smartcammodule import SmartCamModule
DURATION_MIN = 0 DURATION_MIN = 0
@ -12,12 +17,9 @@ VOLUME_MIN = 0
VOLUME_MAX = 10 VOLUME_MAX = 10
class Alarm(SmartCamModule): class Alarm(SmartCamModule, AlarmInterface):
"""Implementation of alarm module.""" """Implementation of alarm module."""
# Needs a different name to avoid clashing with SmartAlarm
NAME = "SmartCamAlarm"
REQUIRED_COMPONENT = "siren" REQUIRED_COMPONENT = "siren"
QUERY_GETTER_NAME = "getSirenStatus" QUERY_GETTER_NAME = "getSirenStatus"
QUERY_MODULE_NAME = "siren" QUERY_MODULE_NAME = "siren"
@ -106,20 +108,18 @@ class Alarm(SmartCamModule):
) )
@property @property
def alarm_sound(self) -> str: def alarm_sound(self) -> Annotated[str, FeatureAttribute()]:
"""Return current alarm sound.""" """Return current alarm sound."""
return self.data["getSirenConfig"]["siren_type"] 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. """Set alarm sound.
See *alarm_sounds* for list of available sounds. See *alarm_sounds* for list of available sounds.
""" """
if sound not in self.alarm_sounds: config = self._validate_and_get_config(sound=sound)
raise ValueError( return await self.call("setSirenConfig", {"siren": config})
f"sound must be one of {', '.join(self.alarm_sounds)}: {sound}"
)
return await self.call("setSirenConfig", {"siren": {"siren_type": sound}})
@property @property
def alarm_sounds(self) -> list[str]: def alarm_sounds(self) -> list[str]:
@ -127,40 +127,90 @@ class Alarm(SmartCamModule):
return self.data["getSirenTypeList"]["siren_type_list"] return self.data["getSirenTypeList"]["siren_type_list"]
@property @property
def alarm_volume(self) -> int: def alarm_volume(self) -> Annotated[int, FeatureAttribute()]:
"""Return alarm volume. """Return alarm volume.
Unlike duration the device expects/returns a string for volume. Unlike duration the device expects/returns a string for volume.
""" """
return int(self.data["getSirenConfig"]["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.""" """Set alarm volume."""
if volume < VOLUME_MIN or volume > VOLUME_MAX: config = self._validate_and_get_config(volume=volume)
raise ValueError(f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}") return await self.call("setSirenConfig", {"siren": config})
return await self.call("setSirenConfig", {"siren": {"volume": str(volume)}})
@property @property
def alarm_duration(self) -> int: def alarm_duration(self) -> Annotated[int, FeatureAttribute()]:
"""Return alarm duration.""" """Return alarm duration."""
return self.data["getSirenConfig"]["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.""" """Set alarm volume."""
if duration < DURATION_MIN or duration > DURATION_MAX: config = self._validate_and_get_config(duration=duration)
msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}" return await self.call("setSirenConfig", {"siren": config})
raise ValueError(msg)
return await self.call("setSirenConfig", {"siren": {"duration": duration}})
@property @property
def active(self) -> bool: def active(self) -> bool:
"""Return true if alarm is active.""" """Return true if alarm is active."""
return self.data["getSirenStatus"]["status"] != "off" return self.data["getSirenStatus"]["status"] != "off"
async def play(self) -> dict: async def play(
"""Play alarm.""" 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"}}) return await self.call("setSirenStatus", {"siren": {"status": "on"}})
async def stop(self) -> dict: async def stop(self) -> dict:
"""Stop alarm.""" """Stop alarm."""
return await self.call("setSirenStatus", {"siren": {"status": "off"}}) 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

View 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
)

View 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"

View File

@ -1,47 +1,69 @@
"""Implementation of device module.""" """Implementation of camera module."""
from __future__ import annotations from __future__ import annotations
import base64 import base64
import logging import logging
from enum import StrEnum
from typing import Annotated
from urllib.parse import quote_plus from urllib.parse import quote_plus
from ...credentials import Credentials from ...credentials import Credentials
from ...device_type import DeviceType
from ...feature import Feature from ...feature import Feature
from ...json import loads as json_loads from ...json import loads as json_loads
from ...module import FeatureAttribute, Module
from ..smartcammodule import SmartCamModule from ..smartcammodule import SmartCamModule
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
LOCAL_STREAMING_PORT = 554 LOCAL_STREAMING_PORT = 554
ONVIF_PORT = 2020
class StreamResolution(StrEnum):
"""Class for stream resolution."""
HD = "HD"
SD = "SD"
class Camera(SmartCamModule): class Camera(SmartCamModule):
"""Implementation of device module.""" """Implementation of device module."""
QUERY_GETTER_NAME = "getLensMaskConfig" REQUIRED_COMPONENT = "video"
QUERY_MODULE_NAME = "lens_mask"
QUERY_SECTION_NAMES = "lens_mask_info"
def _initialize_features(self) -> None: def _initialize_features(self) -> None:
"""Initialize features after the initial update.""" """Initialize features after the initial update."""
self._add_feature( if Module.LensMask in self._device.modules:
Feature( self._add_feature(
self._device, Feature(
id="state", self._device,
name="State", id="state",
attribute_getter="is_on", name="State",
attribute_setter="set_state", container=self,
type=Feature.Type.Switch, attribute_getter="is_on",
category=Feature.Category.Primary, attribute_setter="set_state",
type=Feature.Type.Switch,
category=Feature.Category.Primary,
)
) )
)
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return the device id.""" """Return the device on state."""
return self.data["lens_mask_info"]["enabled"] == "off" 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: def _get_credentials(self) -> Credentials | None:
"""Get credentials from .""" """Get credentials from ."""
@ -64,7 +86,12 @@ class Camera(SmartCamModule):
return None 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. """Return the local rtsp streaming url.
:param credentials: Credentials for camera account. :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 :return: rtsp url with escaped credentials or None if no credentials or
camera is off. camera is off.
""" """
if not self.is_on: if self._device._is_hub_child:
return None 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: if not credentials:
credentials = self._get_credentials() credentials = self._get_credentials()
if not credentials or not credentials.username or not credentials.password: if not credentials or not credentials.username or not credentials.password:
return None return None
username = quote_plus(credentials.username) username = quote_plus(credentials.username)
password = quote_plus(credentials.password) password = quote_plus(credentials.password)
return f"rtsp://{username}:{password}@{dev.host}:{LOCAL_STREAMING_PORT}/stream1"
async def set_state(self, on: bool) -> dict: return f"rtsp://{username}:{password}@{self._device.host}:{LOCAL_STREAMING_PORT}/{stream}"
"""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
)
async def _check_supported(self) -> bool: def onvif_url(self) -> str | None:
"""Additional check to see if the module is supported by the device.""" """Return the onvif url."""
return self._device.device_type is DeviceType.Camera if self._device._is_hub_child:
return None
return f"http://{self._device.host}:{ONVIF_PORT}/onvif/device_service"

View File

@ -19,7 +19,10 @@ class ChildDevice(SmartCamModule):
Default implementation uses the raw query getter w/o parameters. 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: async def _check_supported(self) -> bool:
"""Additional check to see if the module is supported by the device.""" """Additional check to see if the module is supported by the device."""

View 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

View File

@ -14,6 +14,18 @@ class DeviceModule(SmartCamModule):
QUERY_MODULE_NAME = "device_info" QUERY_MODULE_NAME = "device_info"
QUERY_SECTION_NAMES = ["basic_info", "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: def _initialize_features(self) -> None:
"""Initialize features after the initial update.""" """Initialize features after the initial update."""
self._add_feature( self._add_feature(
@ -26,6 +38,32 @@ class DeviceModule(SmartCamModule):
type=Feature.Type.Sensor, 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: async def _post_update_hook(self) -> None:
"""Overriden to prevent module disabling. """Overriden to prevent module disabling.
@ -37,4 +75,14 @@ class DeviceModule(SmartCamModule):
@property @property
def device_id(self) -> str: def device_id(self) -> str:
"""Return the device id.""" """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")

View 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 {}

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from ...interfaces.led import Led as LedInterface from ...interfaces.led import Led as LedInterface
from ...smart.smartmodule import allow_update_after
from ..smartcammodule import SmartCamModule from ..smartcammodule import SmartCamModule
@ -19,6 +20,7 @@ class Led(SmartCamModule, LedInterface):
"""Return current led status.""" """Return current led status."""
return self.data["config"]["enabled"] == "on" return self.data["config"]["enabled"] == "on"
@allow_update_after
async def set_led(self, enable: bool) -> dict: async def set_led(self, enable: bool) -> dict:
"""Set led. """Set led.

View 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