Merge remote-tracking branch 'upstream/master' into feat/light_module_feats

This commit is contained in:
Steven B 2024-12-23 09:46:11 +00:00
commit a7192b8a92
No known key found for this signature in database
GPG Key ID: 6D5B46B3679F2A43
216 changed files with 11945 additions and 2156 deletions

View File

@ -1,8 +1,89 @@
# Changelog
## [0.8.1](https://github.com/python-kasa/python-kasa/tree/0.8.1) (2024-12-06)
## [0.9.0](https://github.com/python-kasa/python-kasa/tree/0.9.0) (2024-12-21)
This patch release fixes some issues with newly supported smartcam devices.
[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)
## [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)
@ -46,28 +127,28 @@ Special thanks to @ryenitcher and @Puxtril for their new contributions to the im
- Update cli feature command for actions not to require a value [\#1264](https://github.com/python-kasa/python-kasa/pull/1264) (@sdb9696)
- Add pan tilt camera module [\#1261](https://github.com/python-kasa/python-kasa/pull/1261) (@sdb9696)
- Add alarm module for smartcamera hubs [\#1258](https://github.com/python-kasa/python-kasa/pull/1258) (@sdb9696)
- Move TAPO smartcamera out of experimental package [\#1255](https://github.com/python-kasa/python-kasa/pull/1255) (@sdb9696)
- Add SmartCamera Led Module [\#1249](https://github.com/python-kasa/python-kasa/pull/1249) (@sdb9696)
- Use component queries to select smartcamera modules [\#1248](https://github.com/python-kasa/python-kasa/pull/1248) (@sdb9696)
- Print formatting for IotLightPreset [\#1216](https://github.com/python-kasa/python-kasa/pull/1216) (@Puxtril)
- Allow getting Annotated features from modules [\#1018](https://github.com/python-kasa/python-kasa/pull/1018) (@sdb9696)
- Move TAPO smartcamera out of experimental package [\#1255](https://github.com/python-kasa/python-kasa/pull/1255) (@sdb9696)
- Use component queries to select smartcamera modules [\#1248](https://github.com/python-kasa/python-kasa/pull/1248) (@sdb9696)
- Add common Thermostat module [\#977](https://github.com/python-kasa/python-kasa/pull/977) (@sdb9696)
**Fixed bugs:**
- TP-Link Tapo S505D cannot disable gradual on/off [\#1309](https://github.com/python-kasa/python-kasa/issues/1309)
- Inconsistent emeter information between features and emeter cli [\#1308](https://github.com/python-kasa/python-kasa/issues/1308)
- How to dump power usage after latest updates? [\#1306](https://github.com/python-kasa/python-kasa/issues/1306)
- kasa.discover: Got unsupported connection type: 'device\_family': 'SMART.IPCAMERA' [\#1267](https://github.com/python-kasa/python-kasa/issues/1267)
- device \_\_repr\_\_ fails if no sys\_info [\#1262](https://github.com/python-kasa/python-kasa/issues/1262)
- Tapo P110M: Error processing Energy for device, module will be unavailable: get\_energy\_usage for Energy [\#1243](https://github.com/python-kasa/python-kasa/issues/1243)
- Listing light presets throws error [\#1201](https://github.com/python-kasa/python-kasa/issues/1201)
- Include duration when disabling smooth transition on/off [\#1313](https://github.com/python-kasa/python-kasa/pull/1313) (@rytilahti)
- Expose energy command to cli [\#1307](https://github.com/python-kasa/python-kasa/pull/1307) (@rytilahti)
- Inconsistent emeter information between features and emeter cli [\#1308](https://github.com/python-kasa/python-kasa/issues/1308)
- Make discovery on unsupported devices less noisy [\#1291](https://github.com/python-kasa/python-kasa/pull/1291) (@rytilahti)
- Fix repr for device created with no sysinfo or discovery info" [\#1266](https://github.com/python-kasa/python-kasa/pull/1266) (@sdb9696)
- Fix discovery by alias for smart devices [\#1260](https://github.com/python-kasa/python-kasa/pull/1260) (@sdb9696)
- Make \_\_repr\_\_ work on discovery info [\#1233](https://github.com/python-kasa/python-kasa/pull/1233) (@rytilahti)
- Include duration when disabling smooth transition on/off [\#1313](https://github.com/python-kasa/python-kasa/pull/1313) (@rytilahti)
- Expose energy command to cli [\#1307](https://github.com/python-kasa/python-kasa/pull/1307) (@rytilahti)
**Added support for devices:**
@ -81,13 +162,11 @@ Special thanks to @ryenitcher and @Puxtril for their new contributions to the im
**Documentation updates:**
- Use markdown footnotes in supported.md [\#1310](https://github.com/python-kasa/python-kasa/pull/1310) (@sdb9696)
- Update docs for the new module attributes has/get feature [\#1301](https://github.com/python-kasa/python-kasa/pull/1301) (@sdb9696)
- Fixup contributing.md for running test against a real device [\#1236](https://github.com/python-kasa/python-kasa/pull/1236) (@sdb9696)
- Update docs for the new module attributes has/get feature [\#1301](https://github.com/python-kasa/python-kasa/pull/1301) (@sdb9696)
**Project maintenance:**
- Rename tests/smartcamera to tests/smartcam [\#1315](https://github.com/python-kasa/python-kasa/pull/1315) (@sdb9696)
- Do not error on smartcam hub attached smartcam child devices [\#1314](https://github.com/python-kasa/python-kasa/pull/1314) (@sdb9696)
- Add P110M\(EU\) fixture [\#1305](https://github.com/python-kasa/python-kasa/pull/1305) (@sdb9696)
- Run tests with caplog in a single worker [\#1304](https://github.com/python-kasa/python-kasa/pull/1304) (@sdb9696)
- Rename smartcamera to smartcam [\#1300](https://github.com/python-kasa/python-kasa/pull/1300) (@sdb9696)
@ -117,15 +196,17 @@ Special thanks to @ryenitcher and @Puxtril for their new contributions to the im
- Add linkcheck to readthedocs CI [\#1253](https://github.com/python-kasa/python-kasa/pull/1253) (@rytilahti)
- Update cli energy command to use energy module [\#1252](https://github.com/python-kasa/python-kasa/pull/1252) (@sdb9696)
- Consolidate warnings for fixtures missing child devices [\#1251](https://github.com/python-kasa/python-kasa/pull/1251) (@sdb9696)
- Update smartcamera fixtures with components [\#1250](https://github.com/python-kasa/python-kasa/pull/1250) (@sdb9696)
- Move transports into their own package [\#1247](https://github.com/python-kasa/python-kasa/pull/1247) (@rytilahti)
- Fix warnings in our test suite [\#1246](https://github.com/python-kasa/python-kasa/pull/1246) (@rytilahti)
- Move tests folder to top level of project [\#1242](https://github.com/python-kasa/python-kasa/pull/1242) (@sdb9696)
- Fix test framework running against real devices [\#1235](https://github.com/python-kasa/python-kasa/pull/1235) (@sdb9696)
- Add Additional Firmware Test Fixures [\#1234](https://github.com/python-kasa/python-kasa/pull/1234) (@ryenitcher)
- Update DiscoveryResult to use Mashumaro instead of pydantic [\#1231](https://github.com/python-kasa/python-kasa/pull/1231) (@sdb9696)
- Update fixture for ES20M 1.0.11 [\#1215](https://github.com/python-kasa/python-kasa/pull/1215) (@rytilahti)
- Enable ruff check for ANN [\#1139](https://github.com/python-kasa/python-kasa/pull/1139) (@rytilahti)
- Rename tests/smartcamera to tests/smartcam [\#1315](https://github.com/python-kasa/python-kasa/pull/1315) (@sdb9696)
- Do not error on smartcam hub attached smartcam child devices [\#1314](https://github.com/python-kasa/python-kasa/pull/1314) (@sdb9696)
- Update smartcamera fixtures with components [\#1250](https://github.com/python-kasa/python-kasa/pull/1250) (@sdb9696)
- Fix warnings in our test suite [\#1246](https://github.com/python-kasa/python-kasa/pull/1246) (@rytilahti)
- Fix test framework running against real devices [\#1235](https://github.com/python-kasa/python-kasa/pull/1235) (@sdb9696)
- Update DiscoveryResult to use Mashumaro instead of pydantic [\#1231](https://github.com/python-kasa/python-kasa/pull/1231) (@sdb9696)
**Closed issues:**

View File

@ -178,6 +178,10 @@ The following devices have been tested and confirmed as working. If your device
> [!NOTE]
> The hub attached Tapo buttons S200B and S200D do not currently support alerting when the button is pressed.
> [!NOTE]
> Some firmware versions of Tapo Cameras will not authenticate unless you enable "Tapo Lab" > "Third-Party Compatibility" in the native Tapo app.
> Alternatively, you can factory reset and then prevent the device from accessing the internet.
<!--Do not edit text inside the SUPPORTED section below -->
<!--SUPPORTED_START-->
### Supported Kasa devices
@ -193,11 +197,11 @@ The following devices have been tested and confirmed as working. If your device
### Supported Tapo[^1] devices
- **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15
- **Power Strips**: P300, P304M, TP25
- **Power Strips**: P210M, P300, P304M, P306, TP25
- **Wall Switches**: S500D, S505, S505D
- **Bulbs**: L510B, L510E, L530E, L630
- **Light Strips**: L900-10, L900-5, L920-5, L930-5
- **Cameras**: C210, C520WS, TC65
- **Cameras**: C100, C210, C225, C325WB, C520WS, TC65, TC70
- **Hubs**: H100, H200
- **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315
@ -223,6 +227,7 @@ See [supported devices in our documentation](SUPPORTED.md) for more detailed inf
* [Home Assistant](https://www.home-assistant.io/integrations/tplink/)
* [MQTT access to TP-Link devices, using python-kasa](https://github.com/flavio-fernandes/mqtt2kasa)
* [Homebridge Kasa Python Plug-In](https://github.com/ZeliardM/homebridge-kasa-python)
### Other related projects

View File

@ -5,6 +5,9 @@ The following devices have been tested and confirmed as working. If your device
> [!NOTE]
> The hub attached Tapo buttons S200B and S200D do not currently support alerting when the button is pressed.
> [!NOTE]
> Some firmware versions of Tapo Cameras will not authenticate unless you enable "Tapo Lab" > "Third-Party Compatibility" in the native Tapo app.
> Alternatively, you can factory reset and then prevent the device from accessing the internet.
<!--Do not edit text inside the SUPPORTED section below -->
<!--SUPPORTED_START-->
@ -199,17 +202,22 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
- Hardware: 1.0 (US) / Firmware: 1.1.0
- **P135**
- Hardware: 1.0 (US) / Firmware: 1.0.5
- Hardware: 1.0 (US) / Firmware: 1.2.0
- **TP15**
- Hardware: 1.0 (US) / Firmware: 1.0.3
### Power Strips
- **P210M**
- Hardware: 1.0 (US) / Firmware: 1.0.3
- **P300**
- Hardware: 1.0 (EU) / Firmware: 1.0.13
- Hardware: 1.0 (EU) / Firmware: 1.0.15
- Hardware: 1.0 (EU) / Firmware: 1.0.7
- **P304M**
- Hardware: 1.0 (UK) / Firmware: 1.0.3
- **P306**
- Hardware: 1.0 (US) / Firmware: 1.1.2
- **TP25**
- Hardware: 1.0 (US) / Firmware: 1.0.2
@ -255,13 +263,21 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
### Cameras
- **C100**
- Hardware: 4.0 / Firmware: 1.3.14
- **C210**
- Hardware: 2.0 (EU) / Firmware: 1.4.2
- Hardware: 2.0 (EU) / Firmware: 1.4.3
- **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
- **TC65**
- Hardware: 1.0 / Firmware: 1.3.9
- **TC70**
- Hardware: 3.0 / Firmware: 1.3.11
### Hubs

View File

@ -10,8 +10,6 @@ and finally execute a query to query all of them at once.
from __future__ import annotations
import base64
import collections.abc
import dataclasses
import json
import logging
@ -19,6 +17,7 @@ import re
import sys
import traceback
from collections import defaultdict, namedtuple
from collections.abc import Callable
from pathlib import Path
from pprint import pprint
from typing import Any
@ -39,30 +38,80 @@ from kasa import (
)
from kasa.device_factory import get_protocol
from kasa.deviceconfig import DeviceEncryptionType, DeviceFamily
from kasa.discover import DiscoveryResult
from kasa.discover import (
NEW_DISCOVERY_REDACTORS,
DiscoveredRaw,
DiscoveryResult,
)
from kasa.exceptions import SmartErrorCode
from kasa.protocols import IotProtocol
from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS
from kasa.protocols.protocol import redact_data
from kasa.protocols.smartcamprotocol import (
SmartCamProtocol,
_ChildCameraProtocolWrapper,
)
from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS
from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper
from kasa.smart import SmartChildDevice, SmartDevice
from kasa.smartcam import SmartCamDevice
Call = namedtuple("Call", "module method")
FixtureResult = namedtuple("FixtureResult", "filename, folder, data")
FixtureResult = namedtuple("FixtureResult", "filename, folder, data, protocol_suffix")
SMART_FOLDER = "tests/fixtures/smart/"
SMARTCAM_FOLDER = "tests/fixtures/smartcam/"
SMART_CHILD_FOLDER = "tests/fixtures/smart/child/"
IOT_FOLDER = "tests/fixtures/iot/"
SMART_PROTOCOL_SUFFIX = "SMART"
SMARTCAM_SUFFIX = "SMARTCAM"
SMART_CHILD_SUFFIX = "SMART.CHILD"
IOT_SUFFIX = "IOT"
NO_GIT_FIXTURE_FOLDER = "kasa-fixtures"
ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType]
_LOGGER = logging.getLogger(__name__)
def _wrap_redactors(redactors: dict[str, Callable[[Any], Any] | None]):
"""Wrap the redactors for dump_devinfo.
Will replace all partial REDACT_ values with zeros.
If the data item is already scrubbed by dump_devinfo will leave as-is.
"""
def _wrap(key: str) -> Any:
def _wrapped(redactor: Callable[[Any], Any] | None) -> Any | None:
if redactor is None:
return lambda x: "**SCRUBBED**"
def _redact_to_zeros(x: Any) -> Any:
if isinstance(x, str) and "REDACT" in x:
return re.sub(r"\w", "0", x)
if isinstance(x, dict):
for k, v in x.items():
x[k] = _redact_to_zeros(v)
return x
def _scrub(x: Any) -> Any:
if key in {"ip", "local_ip"}:
return "127.0.0.123"
# Already scrubbed by dump_devinfo
if isinstance(x, str) and "SCRUBBED" in x:
return x
default = redactor(x)
return _redact_to_zeros(default)
return _scrub
return _wrapped(redactors[key])
return {key: _wrap(key) for key in redactors}
@dataclasses.dataclass
class SmartCall:
"""Class for smart and smartcam calls."""
@ -74,115 +123,6 @@ class SmartCall:
supports_multiple: bool = True
def scrub(res):
"""Remove identifiers from the given dict."""
keys_to_scrub = [
"deviceId",
"fwId",
"hwId",
"oemId",
"mac",
"mic_mac",
"latitude_i",
"longitude_i",
"latitude",
"longitude",
"la", # lat on ks240
"lo", # lon on ks240
"owner",
"device_id",
"ip",
"ssid",
"hw_id",
"fw_id",
"oem_id",
"nickname",
"alias",
"bssid",
"channel",
"original_device_id", # for child devices on strips
"parent_device_id", # for hub children
"setup_code", # matter
"setup_payload", # matter
"mfi_setup_code", # mfi_ for homekit
"mfi_setup_id",
"mfi_token_token",
"mfi_token_uuid",
"dev_id",
"device_name",
"device_alias",
"connect_ssid",
"encrypt_info",
"local_ip",
"username",
# vacuum
"board_sn",
"custom_sn",
"location",
]
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",
"location",
]:
v = "#MASKED_NAME#"
elif isinstance(res[k], int):
v = 0
elif k in ["map_data"]: #
v = "#SCRUBBED_MAPDATA#"
elif k in ["device_id", "dev_id"] and "SCRUBBED" in v:
pass # already scrubbed
elif k == ["device_id", "dev_id"] and len(v) > 40:
# retain the last two chars when scrubbing child ids
end = v[-2:]
v = re.sub(r"\w", "0", v)
v = v[:40] + end
else:
v = re.sub(r"\w", "0", v)
res[k] = v
return res
def default_to_regular(d):
"""Convert nested defaultdicts to regular ones.
@ -207,9 +147,19 @@ async def handle_device(
]
for fixture_result in fixture_results:
save_filename = Path(basedir) / fixture_result.folder / fixture_result.filename
save_folder = Path(basedir) / fixture_result.folder
if save_folder.exists():
save_filename = save_folder / f"{fixture_result.filename}.json"
else:
# If being run without git clone
save_folder = Path(basedir) / NO_GIT_FIXTURE_FOLDER
save_folder.mkdir(exist_ok=True)
save_filename = (
save_folder
/ f"{fixture_result.filename}-{fixture_result.protocol_suffix}.json"
)
pprint(scrub(fixture_result.data))
pprint(fixture_result.data)
if autosave:
save = "y"
else:
@ -300,6 +250,12 @@ async def handle_device(
type=bool,
help="Set flag if the device encryption uses https.",
)
@click.option(
"--timeout",
required=False,
default=15,
help="Timeout for queries.",
)
@click.option("--port", help="Port override", type=int)
async def cli(
host,
@ -317,6 +273,7 @@ async def cli(
device_family,
login_version,
port,
timeout,
):
"""Generate devinfo files for devices.
@ -325,6 +282,11 @@ async def cli(
if debug:
logging.basicConfig(level=logging.DEBUG)
raw_discovery = {}
def capture_raw(discovered: DiscoveredRaw):
raw_discovery[discovered["meta"]["ip"]] = discovered["discovery_response"]
credentials = Credentials(username=username, password=password)
if host is not None:
if discovery_info:
@ -342,6 +304,7 @@ async def cli(
connection_type=connection_type,
port_override=port,
credentials=credentials,
timeout=timeout,
)
device = await Device.connect(config=dc)
await handle_device(
@ -363,6 +326,7 @@ async def cli(
port_override=port,
credentials=credentials,
connection_type=ctype,
timeout=timeout,
)
if protocol := get_protocol(config):
await handle_device(basedir, autosave, protocol, batch_size=batch_size)
@ -377,12 +341,17 @@ async def cli(
credentials=credentials,
port=port,
discovery_timeout=discovery_timeout,
timeout=timeout,
on_discovered_raw=capture_raw,
)
discovery_info = raw_discovery[device.host]
if decrypted_data := device._discovery_info.get("decrypted_data"):
discovery_info["result"]["decrypted_data"] = decrypted_data
await handle_device(
basedir,
autosave,
device.protocol,
discovery_info=device._discovery_info,
discovery_info=discovery_info,
batch_size=batch_size,
)
else:
@ -391,21 +360,29 @@ async def cli(
f" {target}. Use --target to override."
)
devices = await Discover.discover(
target=target, credentials=credentials, discovery_timeout=discovery_timeout
target=target,
credentials=credentials,
discovery_timeout=discovery_timeout,
timeout=timeout,
on_discovered_raw=capture_raw,
)
click.echo(f"Detected {len(devices)} devices")
for dev in devices.values():
discovery_info = raw_discovery[dev.host]
if decrypted_data := dev._discovery_info.get("decrypted_data"):
discovery_info["result"]["decrypted_data"] = decrypted_data
await handle_device(
basedir,
autosave,
dev.protocol,
discovery_info=dev._discovery_info,
discovery_info=discovery_info,
batch_size=batch_size,
)
async def get_legacy_fixture(
protocol: IotProtocol, *, discovery_info: dict[str, Any] | None
protocol: IotProtocol, *, discovery_info: dict[str, dict[str, Any]] | None
) -> FixtureResult:
"""Get fixture for legacy IOT style protocol."""
items = [
@ -475,11 +452,21 @@ async def get_legacy_fixture(
_echo_error(f"Unable to query all successes at once: {ex}")
finally:
await protocol.close()
final = redact_data(final, _wrap_redactors(IOT_REDACTORS))
# Scrub the child device ids
if children := final.get("system", {}).get("get_sysinfo", {}).get("children"):
for index, child in enumerate(children):
if "id" not in child:
_LOGGER.error("Could not find a device for the child device: %s", child)
else:
child["id"] = f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}"
if discovery_info and not discovery_info.get("system"):
# Need to recreate a DiscoverResult here because we don't want the aliases
# in the fixture, we want the actual field names as returned by the device.
dr = DiscoveryResult.from_dict(discovery_info)
final["discovery_result"] = dr.to_dict()
final["discovery_result"] = redact_data(
discovery_info, _wrap_redactors(NEW_DISCOVERY_REDACTORS)
)
click.echo(f"Got {len(successes)} successes")
click.echo(click.style("## device info file ##", bold=True))
@ -489,9 +476,14 @@ async def get_legacy_fixture(
hw_version = sysinfo["hw_ver"]
sw_version = sysinfo["sw_ver"]
sw_version = sw_version.split(" ", maxsplit=1)[0]
save_filename = f"{model}_{hw_version}_{sw_version}.json"
save_filename = f"{model}_{hw_version}_{sw_version}"
copy_folder = IOT_FOLDER
return FixtureResult(filename=save_filename, folder=copy_folder, data=final)
return FixtureResult(
filename=save_filename,
folder=copy_folder,
data=final,
protocol_suffix=IOT_SUFFIX,
)
def _echo_error(msg: str):
@ -860,14 +852,20 @@ def get_smart_child_fixture(response):
model = model_info.long_name
if model_info.region is not None:
model = f"{model}({model_info.region})"
save_filename = f"{model}_{hw_version}_{fw_version}.json"
save_filename = f"{model}_{hw_version}_{fw_version}"
return FixtureResult(
filename=save_filename, folder=SMART_CHILD_FOLDER, data=response
filename=save_filename,
folder=SMART_CHILD_FOLDER,
data=response,
protocol_suffix=SMART_CHILD_SUFFIX,
)
async def get_smart_fixtures(
protocol: SmartProtocol, *, discovery_info: dict[str, Any] | None, batch_size: int
protocol: SmartProtocol,
*,
discovery_info: dict[str, dict[str, Any]] | None,
batch_size: int,
) -> list[FixtureResult]:
"""Get fixture for new TAPO style protocol."""
if isinstance(protocol, SmartCamProtocol):
@ -963,6 +961,7 @@ async def get_smart_fixtures(
and (child_model := response["get_device_info"].get("model"))
and child_model != parent_model
):
response = redact_data(response, _wrap_redactors(SMART_REDACTORS))
fixture_results.append(get_smart_child_fixture(response))
else:
cd = final.setdefault("child_devices", {})
@ -978,43 +977,56 @@ async def get_smart_fixtures(
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 gc := final.get("getChildDeviceComponentList"):
for child in gc["child_component_list"]:
device_id = child["device_id"]
child["device_id"] = scrubbed_device_ids[device_id]
for child in final["getChildDeviceList"]["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]
elif dev_id := child.get("dev_id"):
child["dev_id"] = scrubbed_device_ids[dev_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.
final = redact_data(final, _wrap_redactors(SMART_REDACTORS))
discovery_result = None
if discovery_info:
dr = DiscoveryResult.from_dict(discovery_info) # type: ignore
final["discovery_result"] = dr.to_dict()
final["discovery_result"] = redact_data(
discovery_info, _wrap_redactors(NEW_DISCOVERY_REDACTORS)
)
discovery_result = discovery_info["result"]
click.echo(f"Got {len(successes)} successes")
click.echo(click.style("## device info file ##", bold=True))
if "get_device_info" in final:
# smart protocol
model_info = SmartDevice._get_device_info(final, discovery_info)
model_info = SmartDevice._get_device_info(final, discovery_result)
copy_folder = SMART_FOLDER
protocol_suffix = SMART_PROTOCOL_SUFFIX
else:
# smart camera protocol
model_info = SmartCamDevice._get_device_info(final, discovery_info)
model_info = SmartCamDevice._get_device_info(final, discovery_result)
copy_folder = SMARTCAM_FOLDER
protocol_suffix = SMARTCAM_SUFFIX
hw_version = model_info.hardware_version
sw_version = model_info.firmware_version
model = model_info.long_name
if model_info.region is not None:
model = f"{model}({model_info.region})"
save_filename = f"{model}_{hw_version}_{sw_version}.json"
save_filename = f"{model}_{hw_version}_{sw_version}"
fixture_results.insert(
0, FixtureResult(filename=save_filename, folder=copy_folder, data=final)
0,
FixtureResult(
filename=save_filename,
folder=copy_folder,
data=final,
protocol_suffix=protocol_suffix,
),
)
return fixture_results

View File

@ -205,7 +205,7 @@ def _get_supported_devices(
fixture_data = json.load(f)
model_info = device_cls._get_device_info(
fixture_data, fixture_data.get("discovery_result")
fixture_data, fixture_data.get("discovery_result", {}).get("result")
)
supported_type = DEVICE_TYPE_TO_PRODUCT_GROUP[model_info.device_type]

View File

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

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

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

View File

@ -80,14 +80,17 @@ This can be done using the {attr}`~kasa.Device.internal_state` property.
## Modules and Features
The functionality provided by all {class}`~kasa.Device` instances is (mostly) done inside separate modules.
While the individual device-type specific classes provide an easy access for the most import features,
you can also access individual modules through {attr}`kasa.Device.modules`.
You can get the list of supported modules for a given device instance using {attr}`~kasa.Device.supported_modules`.
While the device class provides easy access for most device related attributes,
for components like `light` and `camera` you can access the module through {attr}`kasa.Device.modules`.
The module names are handily available as constants on {class}`~kasa.Module` and will return type aware values from the collection.
```{note}
If you only need some module-specific information,
you can call the wanted method on the module to avoid using {meth}`~kasa.Device.update`.
```
Features represent individual pieces of functionality within a module like brightness, hsv and temperature within a light module.
They allow for instrospection and can be accessed through {attr}`kasa.Device.features`.
Attributes can be accessed via a `Feature` or a module attribute depending on the use case.
Modules tend to provide richer functionality but using the features does not require an understanding of the module api.
:::{include} featureattributes.md
:::
(topics-protocols-and-transports)=
## Protocols and Transports
@ -137,96 +140,3 @@ The base exception for all library errors is {class}`KasaException <kasa.excepti
- If the library encounters and unsupported deviceit raises an {class}`UnsupportedDeviceError <kasa.exceptions.UnsupportedDeviceError>`.
- If the device fails to respond within a timeout the library raises a {class}`TimeoutError <kasa.exceptions.TimeoutError>`.
- All other failures will raise the base {class}`KasaException <kasa.exceptions.KasaException>` class.
<!-- Commenting out this section keeps git seeing the change as a rename.
API documentation for modules and features
******************************************
.. autoclass:: kasa.Module
:noindex:
:members:
:inherited-members:
:undoc-members:
.. automodule:: kasa.interfaces
:noindex:
:members:
:inherited-members:
:undoc-members:
.. autoclass:: kasa.Feature
:noindex:
:members:
:inherited-members:
:undoc-members:
API documentation for protocols and transports
**********************************************
.. autoclass:: kasa.protocols.BaseProtocol
:members:
:inherited-members:
:undoc-members:
.. autoclass:: kasa.protocols.IotProtocol
:members:
:inherited-members:
:undoc-members:
.. autoclass:: kasa.protocols.SmartProtocol
:members:
:inherited-members:
:undoc-members:
.. autoclass:: kasa.transports.BaseTransport
:members:
:inherited-members:
:undoc-members:
.. autoclass:: kasa.transports.XorTransport
:members:
:inherited-members:
:undoc-members:
.. autoclass:: kasa.transports.KlapTransport
:members:
:inherited-members:
:undoc-members:
.. autoclass:: kasa.transports.KlapTransportV2
:members:
:inherited-members:
:undoc-members:
.. autoclass:: kasa.transports.AesTransport
:members:
:inherited-members:
:undoc-members:
API documentation for errors and exceptions
*******************************************
.. autoclass:: kasa.exceptions.KasaException
:members:
:undoc-members:
.. autoclass:: kasa.exceptions.DeviceError
:members:
:undoc-members:
.. autoclass:: kasa.exceptions.AuthenticationError
:members:
:undoc-members:
.. autoclass:: kasa.exceptions.UnsupportedDeviceError
:members:
:undoc-members:
.. autoclass:: kasa.exceptions.TimeoutError
:members:
:undoc-members:
-->

View File

@ -40,7 +40,7 @@ Different groups of functionality are supported by modules which you can access
key from :class:`~kasa.Module`.
Modules will only be available on the device if they are supported but some individual features of a module may not be available for your device.
You can check the availability using ``is_``-prefixed properties like `is_color`.
You can check the availability using ``has_feature()`` method.
>>> from kasa import Module
>>> Module.Light in dev.modules

View File

@ -38,7 +38,7 @@ from kasa.feature import Feature
from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState
from kasa.interfaces.thermostat import Thermostat, ThermostatState
from kasa.module import Module
from kasa.protocols import BaseProtocol, IotProtocol, SmartProtocol
from kasa.protocols import BaseProtocol, IotProtocol, SmartCamProtocol, SmartProtocol
from kasa.protocols.iotprotocol import _deprecated_TPLinkSmartHomeProtocol # noqa: F401
from kasa.smartcam.modules.camera import StreamResolution
from kasa.transports import BaseTransport
@ -52,6 +52,7 @@ __all__ = [
"BaseTransport",
"IotProtocol",
"SmartProtocol",
"SmartCamProtocol",
"LightState",
"TurnOnBehaviors",
"TurnOnBehavior",

View File

@ -2,12 +2,14 @@
from __future__ import annotations
import asyncio
import json
import re
import sys
from collections.abc import Callable
from contextlib import contextmanager
from functools import singledispatch, update_wrapper, wraps
from gettext import gettext
from typing import TYPE_CHECKING, Any, Final
import asyncclick as click
@ -238,4 +240,19 @@ def CatchAllExceptions(cls):
except Exception as exc:
_handle_exception(self._debug, exc)
def __call__(self, *args, **kwargs):
"""Run the coroutine in the event loop and print any exceptions.
python click catches KeyboardInterrupt in main, raises Abort()
and does sys.exit. asyncclick doesn't properly handle a coroutine
receiving CancelledError on a KeyboardInterrupt, so we catch the
KeyboardInterrupt here once asyncio.run has re-raised it. This
avoids large stacktraces when a user presses Ctrl-C.
"""
try:
asyncio.run(self.main(*args, **kwargs))
except KeyboardInterrupt:
click.echo(gettext("\nAborted!"), file=sys.stderr)
sys.exit(1)
return _CommandCls

View File

@ -41,8 +41,14 @@ async def state(ctx, dev: Device):
echo(f"Device state: {dev.is_on}")
echo(f"Time: {dev.time} (tz: {dev.timezone})")
echo(f"Hardware: {dev.hw_info['hw_ver']}")
echo(f"Software: {dev.hw_info['sw_ver']}")
echo(
f"Hardware: {dev.device_info.hardware_version}"
f"{' (' + dev.region + ')' if dev.region else ''}"
)
echo(
f"Firmware: {dev.device_info.firmware_version}"
f" {dev.device_info.firmware_build}"
)
echo(f"MAC (rssi): {dev.mac} ({dev.rssi})")
if verbose:
echo(f"Location: {dev.location}")

View File

@ -123,14 +123,19 @@ async def list(ctx):
async def print_discovered(dev: Device):
cparams = dev.config.connection_type
infostr = (
f"{dev.host:<15} {cparams.device_family.value:<20} "
f"{cparams.encryption_type.value:<7}"
f"{dev.host:<15} {dev.model:<9} {cparams.device_family.value:<20} "
f"{cparams.encryption_type.value:<7} {cparams.https:<5} "
f"{cparams.login_version or '-':<3}"
)
async with sem:
try:
await dev.update()
except AuthenticationError:
echo(f"{infostr} - Authentication failed")
except TimeoutError:
echo(f"{infostr} - Timed out")
except Exception as ex:
echo(f"{infostr} - Error: {ex}")
else:
echo(f"{infostr} {dev.alias}")
@ -138,7 +143,10 @@ async def list(ctx):
if host := unsupported_exception.host:
echo(f"{host:<15} UNSUPPORTED DEVICE")
echo(f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}")
echo(
f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} "
f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}"
)
return await _discover(
ctx,
print_discovered=print_discovered,

View File

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

View File

@ -29,7 +29,7 @@ All devices provide several informational properties:
>>> dev.alias
Bedroom Lamp Plug
>>> dev.model
HS110(EU)
HS110
>>> dev.rssi
-71
>>> dev.mac
@ -151,7 +151,7 @@ _LOGGER = logging.getLogger(__name__)
@dataclass
class _DeviceInfo:
class DeviceInfo:
"""Device Model Information."""
short_name: str
@ -208,7 +208,7 @@ class Device(ABC):
self.protocol: BaseProtocol = protocol or IotProtocol(
transport=XorTransport(config=config or DeviceConfig(host=host)),
)
self._last_update: Any = None
self._last_update: dict[str, Any] = {}
_LOGGER.debug("Initializing %s of type %s", host, type(self))
self._device_type = DeviceType.Unknown
# TODO: typing Any is just as using dict | None would require separate
@ -334,9 +334,21 @@ class Device(ABC):
"""Returns the device model."""
@property
def region(self) -> str | None:
"""Returns the device region."""
return self.device_info.region
@property
def device_info(self) -> DeviceInfo:
"""Return device info."""
return self._get_device_info(self._last_update, self._discovery_info)
@staticmethod
@abstractmethod
def _model_region(self) -> str:
"""Return device full model name and region."""
def _get_device_info(
info: dict[str, Any], discovery_info: dict[str, Any] | None
) -> DeviceInfo:
"""Get device info."""
@property
@abstractmethod

View File

@ -8,7 +8,7 @@ from typing import Any
from .device import Device
from .device_type import DeviceType
from .deviceconfig import DeviceConfig
from .deviceconfig import DeviceConfig, DeviceFamily
from .exceptions import KasaException, UnsupportedDeviceError
from .iot import (
IotBulb,
@ -179,20 +179,29 @@ def get_device_class_from_family(
def get_protocol(
config: DeviceConfig,
) -> BaseProtocol | None:
"""Return the protocol from the connection name."""
protocol_name = config.connection_type.device_family.value.split(".")[0]
"""Return the protocol from the connection name.
For cameras and vacuums the device family is a simple mapping to
the protocol/transport. For other device types the transport varies
based on the discovery information.
"""
ctype = config.connection_type
protocol_name = ctype.device_family.value.split(".")[0]
if ctype.device_family is DeviceFamily.SmartIpCamera:
return SmartCamProtocol(transport=SslAesTransport(config=config))
if ctype.device_family is DeviceFamily.IotIpCamera:
return IotProtocol(transport=LinkieTransportV2(config=config))
if ctype.device_family is DeviceFamily.SmartTapoRobovac:
return SmartProtocol(transport=SslTransport(config=config))
protocol_transport_key = (
protocol_name
+ "."
+ ctype.encryption_type.value
+ (".HTTPS" if ctype.https else "")
+ (
f".{ctype.login_version}"
if ctype.login_version and ctype.login_version > 1
else ""
)
)
_LOGGER.debug("Finding transport for %s", protocol_transport_key)
@ -201,12 +210,11 @@ def get_protocol(
] = {
"IOT.XOR": (IotProtocol, XorTransport),
"IOT.KLAP": (IotProtocol, KlapTransport),
"IOT.XOR.HTTPS.2": (IotProtocol, LinkieTransportV2),
"SMART.AES": (SmartProtocol, AesTransport),
"SMART.AES.2": (SmartProtocol, AesTransport),
"SMART.KLAP.2": (SmartProtocol, KlapTransportV2),
"SMART.AES.HTTPS.2": (SmartCamProtocol, SslAesTransport),
"SMART.AES.HTTPS": (SmartProtocol, SslTransport),
"SMART.KLAP": (SmartProtocol, KlapTransportV2),
# H200 is device family SMART.TAPOHUB and uses SmartCamProtocol so use
# https to distuingish from SmartProtocol devices
"SMART.AES.HTTPS": (SmartCamProtocol, SslAesTransport),
}
if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)):
return None

View File

@ -22,7 +22,7 @@ Discovery returns a dict of {ip: discovered devices}:
>>>
>>> found_devices = await Discover.discover()
>>> [dev.model for dev in found_devices.values()]
['KP303(UK)', 'HS110(EU)', 'L530E', 'KL430(US)', 'HS220(US)']
['KP303', 'HS110', 'L530E', 'KL430', 'HS220']
You can pass username and password for devices requiring authentication
@ -65,17 +65,17 @@ It is also possible to pass a coroutine to be executed for each found device:
>>> print(f"Discovered {dev.alias} (model: {dev.model})")
>>>
>>> devices = await Discover.discover(on_discovered=print_dev_info, credentials=creds)
Discovered Bedroom Power Strip (model: KP303(UK))
Discovered Bedroom Lamp Plug (model: HS110(EU))
Discovered Bedroom Power Strip (model: KP303)
Discovered Bedroom Lamp Plug (model: HS110)
Discovered Living Room Bulb (model: L530)
Discovered Bedroom Lightstrip (model: KL430(US))
Discovered Living Room Dimmer Switch (model: HS220(US))
Discovered Bedroom Lightstrip (model: KL430)
Discovered Living Room Dimmer Switch (model: HS220)
Discovering a single device returns a kasa.Device object.
>>> device = await Discover.discover_single("127.0.0.1", credentials=creds)
>>> device.model
'KP303(UK)'
'KP303'
"""
@ -168,6 +168,12 @@ OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Coroutine]
OnConnectAttemptCallable = Callable[[ConnectAttempt, bool], None]
DeviceDict = dict[str, Device]
DECRYPTED_REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"connect_ssid": lambda x: "#MASKED_SSID#" if x else "",
"device_id": lambda x: "REDACTED_" + x[9::],
"owner": lambda x: "REDACTED_" + x[9::],
}
NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"device_id": lambda x: "REDACTED_" + x[9::],
"device_name": lambda x: "#MASKED_NAME#" if x else "",
@ -177,6 +183,8 @@ NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"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),
}
@ -490,7 +498,7 @@ class Discover:
try:
_LOGGER.debug("Waiting %s seconds for responses...", discovery_timeout)
await protocol.wait_for_discovery_to_complete()
except KasaException as ex:
except (KasaException, asyncio.CancelledError) as ex:
for device in protocol.discovered_devices.values():
await device.protocol.close()
raise ex
@ -742,6 +750,7 @@ class Discover:
@staticmethod
def _decrypt_discovery_data(discovery_result: DiscoveryResult) -> None:
debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)
if TYPE_CHECKING:
assert discovery_result.encrypt_info
assert _AesDiscoveryQuery.keypair
@ -757,7 +766,19 @@ class Discover:
session = AesEncyptionSession(key, iv)
decrypted_data = session.decrypt(encrypted_data)
discovery_result.decrypted_data = json_loads(decrypted_data)
result = json_loads(decrypted_data)
if debug_enabled:
data = (
redact_data(result, DECRYPTED_REDACTORS)
if Discover._redact_data
else result
)
_LOGGER.debug(
"Decrypted encrypt_info for %s: %s",
discovery_result.ip,
pf(data),
)
discovery_result.decrypted_data = result
@staticmethod
def _get_discovery_json(data: bytes, ip: str) -> dict:
@ -826,12 +847,12 @@ class Discover:
):
encrypt_type = encrypt_info.sym_schm
if (
not (login_version := encrypt_schm.lv)
and (et := discovery_result.encrypt_type)
and et == ["3"]
if not (login_version := encrypt_schm.lv) and (
et := discovery_result.encrypt_type
):
login_version = 2
# 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(

View File

@ -113,10 +113,23 @@ class HttpClient:
ssl=ssl,
)
async with resp:
if resp.status == 200:
response_data = await resp.read()
if return_json:
response_data = await resp.read()
if resp.status == 200:
if return_json:
response_data = json_loads(response_data.decode())
else:
_LOGGER.debug(
"Device %s received status code %s with response %s",
self._config.host,
resp.status,
str(response_data),
)
if response_data and return_json:
try:
response_data = json_loads(response_data.decode())
except Exception:
_LOGGER.debug("Device %s response could not be parsed as json")
except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex:
if not self._wait_between_requests:

View File

@ -23,7 +23,7 @@ Get the light module to interact:
>>> light = dev.modules[Module.Light]
You can use the ``is_``-prefixed properties to check for supported features:
You can use the ``has_feature()`` method to check for supported features:
>>> light.has_feature("brightness")
True

View File

@ -22,7 +22,7 @@ from datetime import datetime, timedelta, tzinfo
from typing import TYPE_CHECKING, Any, cast
from warnings import warn
from ..device import Device, WifiNetwork, _DeviceInfo
from ..device import Device, DeviceInfo, WifiNetwork
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..exceptions import KasaException
@ -43,7 +43,7 @@ def requires_update(f: Callable) -> Any:
@functools.wraps(f)
async def wrapped(*args: Any, **kwargs: Any) -> Any:
self = args[0]
if self._last_update is None and (
if not self._last_update and (
self._sys_info is None or f.__name__ not in self._sys_info
):
raise KasaException("You need to await update() to access the data")
@ -54,7 +54,7 @@ def requires_update(f: Callable) -> Any:
@functools.wraps(f)
def wrapped(*args: Any, **kwargs: Any) -> Any:
self = args[0]
if self._last_update is None and (
if not self._last_update and (
self._sys_info is None or f.__name__ not in self._sys_info
):
raise KasaException("You need to await update() to access the data")
@ -112,7 +112,7 @@ class IotDevice(Device):
>>> dev.alias
Bedroom Lamp Plug
>>> dev.model
HS110(EU)
HS110
>>> dev.rssi
-71
>>> dev.mac
@ -310,7 +310,7 @@ class IotDevice(Device):
# If this is the initial update, check only for the sysinfo
# This is necessary as some devices crash on unexpected modules
# See #105, #120, #161
if self._last_update is None:
if not self._last_update:
_LOGGER.debug("Performing the initial update to obtain sysinfo")
response = await self.protocol.query(req)
self._last_update = response
@ -452,7 +452,9 @@ class IotDevice(Device):
# This allows setting of some info properties directly
# from partial discovery info that will then be found
# by the requires_update decorator
self._set_sys_info(info)
discovery_model = info["device_model"]
no_region_model, _, _ = discovery_model.partition("(")
self._set_sys_info({**info, "model": no_region_model})
def _set_sys_info(self, sys_info: dict[str, Any]) -> None:
"""Set sys_info."""
@ -471,18 +473,13 @@ class IotDevice(Device):
"""
return self._sys_info # type: ignore
@property # type: ignore
@requires_update
def model(self) -> str:
"""Return device model."""
sys_info = self._sys_info
return str(sys_info["model"])
@property
@requires_update
def _model_region(self) -> str:
"""Return device full model name and region."""
return self.model
def model(self) -> str:
"""Returns the device model."""
if self._last_update:
return self.device_info.short_name
return self._sys_info["model"]
@property # type: ignore
def alias(self) -> str | None:
@ -748,7 +745,7 @@ class IotDevice(Device):
@staticmethod
def _get_device_info(
info: dict[str, Any], discovery_info: dict[str, Any] | None
) -> _DeviceInfo:
) -> DeviceInfo:
"""Get model information for a device."""
sys_info = _extract_sys_info(info)
@ -766,7 +763,7 @@ class IotDevice(Device):
firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1)
auth = bool(discovery_info and ("mgt_encrypt_schm" in discovery_info))
return _DeviceInfo(
return DeviceInfo(
short_name=long_name,
long_name=long_name,
brand="kasa",

View File

@ -207,17 +207,18 @@ class Light(IotModule, LightInterface):
return self._light_state
async def _post_update_hook(self) -> None:
if self._device.is_on is False:
device = self._device
if device.is_on is False:
state = LightState(light_on=False)
else:
state = LightState(light_on=True)
if self._device._is_dimmable:
if device._is_dimmable:
state.brightness = self.brightness
if self._device._is_color:
if device._is_color:
hsv = self.hsv
state.hue = hsv.hue
state.saturation = hsv.saturation
if self._device._is_variable_color_temp:
if device._is_variable_color_temp:
state.color_temp = self.color_temp
self._light_state = state

View File

@ -85,17 +85,19 @@ class LightPreset(IotModule, LightPresetInterface):
def preset(self) -> str:
"""Return current preset name."""
light = self._device.modules[Module.Light]
is_color = light.has_feature("hsv")
is_variable_color_temp = light.has_feature("color_temp")
brightness = light.brightness
color_temp = light.color_temp if light.is_variable_color_temp else None
h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None)
color_temp = light.color_temp if is_variable_color_temp else None
h, s = (light.hsv.hue, light.hsv.saturation) if is_color else (None, None)
for preset_name, preset in self._presets.items():
if (
preset.brightness == brightness
and (
preset.color_temp == color_temp or not light.is_variable_color_temp
)
and (preset.hue == h or not light.is_color)
and (preset.saturation == s or not light.is_color)
and (preset.color_temp == color_temp or not is_variable_color_temp)
and (preset.hue == h or not is_color)
and (preset.saturation == s or not is_color)
):
return preset_name
return self.PRESET_NOT_SET
@ -107,7 +109,7 @@ class LightPreset(IotModule, LightPresetInterface):
"""Set a light preset for the device."""
light = self._device.modules[Module.Light]
if preset_name == self.PRESET_NOT_SET:
if light.is_color:
if light.has_feature("hsv"):
preset = LightState(hue=0, saturation=0, brightness=100)
else:
preset = LightState(brightness=100)

View File

@ -21,6 +21,9 @@ check for the existence of the module:
>>> print(light.brightness)
100
.. include:: ../featureattributes.md
:parser: myst_parser.sphinx_
To see whether a device supports specific functionality, you can check whether the
module has that feature:
@ -151,8 +154,12 @@ class Module(ABC):
)
TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs")
HomeKit: Final[ModuleName[smart.HomeKit]] = ModuleName("HomeKit")
Matter: Final[ModuleName[smart.Matter]] = ModuleName("Matter")
# SMARTCAM only modules
Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera")
LensMask: Final[ModuleName[smartcam.LensMask]] = ModuleName("LensMask")
def __init__(self, device: Device, module: str) -> None:
self._device = device

View File

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

View File

@ -25,19 +25,35 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
def _mask_children(children: list[dict[str, Any]]) -> list[dict[str, Any]]:
def mask_child(child: dict[str, Any], index: int) -> dict[str, Any]:
result = {
**child,
"id": f"SCRUBBED_CHILD_DEVICE_ID_{index+1}",
}
# Will leave empty aliases as blank
if child.get("alias"):
result["alias"] = f"#MASKED_NAME# {index + 1}"
return result
return [mask_child(child, index) for index, child in enumerate(children)]
REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"latitude": lambda x: 0,
"longitude": lambda x: 0,
"latitude_i": lambda x: 0,
"longitude_i": lambda x: 0,
"deviceId": lambda x: "REDACTED_" + x[9::],
"id": lambda x: "REDACTED_" + x[9::],
"children": _mask_children,
"alias": lambda x: "#MASKED_NAME#" if x else "",
"mac": mask_mac,
"mic_mac": mask_mac,
"ssid": lambda x: "#MASKED_SSID#" if x else "",
"oemId": lambda x: "REDACTED_" + x[9::],
"username": lambda _: "user@example.com", # cnCloud
"hwId": lambda x: "REDACTED_" + x[9::],
}

View File

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

View File

@ -19,7 +19,7 @@ from ..transports.sslaestransport import (
SMART_RETRYABLE_ERRORS,
SmartErrorCode,
)
from . import SmartProtocol
from .smartprotocol import SmartProtocol
_LOGGER = logging.getLogger(__name__)

View File

@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio
import base64
import logging
import re
import time
import uuid
from collections.abc import Callable
@ -45,15 +46,36 @@ REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"original_device_id": lambda x: "REDACTED_" + x[9::], # Strip children
"nickname": lambda x: "I01BU0tFRF9OQU1FIw==" if x else "",
"mac": mask_mac,
"ssid": lambda x: "I01BU0tFRF9TU0lEIw=" if x else "",
"ssid": lambda x: "I01BU0tFRF9TU0lEIw==" if x else "",
"bssid": lambda _: "000000000000",
"channel": lambda _: 0,
"oem_id": lambda x: "REDACTED_" + x[9::],
"setup_code": None, # matter
"setup_payload": None, # matter
"mfi_setup_code": None, # mfi_ for homekit
"mfi_setup_id": None,
"mfi_token_token": None,
"mfi_token_uuid": None,
"hw_id": lambda x: "REDACTED_" + x[9::],
"fw_id": lambda x: "REDACTED_" + x[9::],
"setup_code": lambda x: re.sub(r"\w", "0", x), # matter
"setup_payload": lambda x: re.sub(r"\w", "0", x), # matter
"mfi_setup_code": lambda x: re.sub(r"\w", "0", x), # mfi_ for homekit
"mfi_setup_id": lambda x: re.sub(r"\w", "0", x),
"mfi_token_token": lambda x: re.sub(r"\w", "0", x),
"mfi_token_uuid": lambda x: re.sub(r"\w", "0", x),
"ip": lambda x: x, # don't redact but keep listed here for dump_devinfo
# smartcam
"dev_id": lambda x: "REDACTED_" + x[9::],
"device_name": lambda x: "#MASKED_NAME#" if x else "",
"device_alias": lambda x: "#MASKED_NAME#" if x else "",
"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 "",
}
# 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 +98,7 @@ class SmartProtocol(BaseProtocol):
self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE
)
self._redact_data = True
self._method_missing_logged = False
def get_smart_request(self, method: str, params: dict | None = None) -> str:
"""Get a request message as a string."""
@ -162,17 +185,18 @@ class SmartProtocol(BaseProtocol):
multi_result: dict[str, Any] = {}
smart_method = "multipleRequest"
multi_requests = [
{"method": method, "params": params} if params else {"method": method}
for method, params in requests.items()
]
end = len(multi_requests)
end = len(requests)
# The SmartCamProtocol sends requests with a length 1 as a
# multipleRequest. The SmartProtocol doesn't so will never
# raise_on_error
raise_on_error = end == 1
multi_requests = [
{"method": method, "params": params} if params else {"method": method}
for method, params in requests.items()
if method not in FORCE_SINGLE_REQUEST
]
# Break the requests down as there can be a size limit
step = self._multi_request_batch_size
if step == 1:
@ -233,7 +257,20 @@ class SmartProtocol(BaseProtocol):
responses = response_step["result"]["responses"]
for response in responses:
method = response["method"]
# some smartcam devices calls do not populate the method key
# these should be defined in DO_NOT_SEND_AS_MULTI_REQUEST.
if not (method := response.get("method")):
if not self._method_missing_logged:
# Avoid spamming the logs
self._method_missing_logged = True
_LOGGER.error(
"No method key in response for %s, skipping: %s",
self._host,
response_step,
)
# These will end up being queried individually
continue
self._handle_response_error_code(
response, method, raise_on_error=raise_on_error
)
@ -242,13 +279,17 @@ class SmartProtocol(BaseProtocol):
result, method, retry_count=retry_count
)
multi_result[method] = result
# Multi requests don't continue after errors so requery any missing
# Multi requests don't continue after errors so requery any missing.
# Will also query individually any DO_NOT_SEND_AS_MULTI_REQUEST.
for method, params in requests.items():
if method not in multi_result:
resp = await self._transport.send(
self.get_smart_request(method, params)
)
self._handle_response_error_code(resp, method, raise_on_error=False)
self._handle_response_error_code(
resp, method, raise_on_error=raise_on_error
)
multi_result[method] = resp.get("result")
return multi_result

View File

@ -16,6 +16,7 @@ from .energy import Energy
from .fan import Fan
from .firmware import Firmware
from .frostprotection import FrostProtection
from .homekit import HomeKit
from .humiditysensor import HumiditySensor
from .led import Led
from .light import Light
@ -23,6 +24,7 @@ from .lighteffect import LightEffect
from .lightpreset import LightPreset
from .lightstripeffect import LightStripEffect
from .lighttransition import LightTransition
from .matter import Matter
from .motionsensor import MotionSensor
from .overheatprotection import OverheatProtection
from .reportmode import ReportMode
@ -66,4 +68,6 @@ __all__ = [
"Thermostat",
"SmartLightEffect",
"OverheatProtection",
"HomeKit",
"Matter",
]

View File

@ -2,10 +2,10 @@
from __future__ import annotations
from typing import NoReturn
from typing import Any, NoReturn
from ...emeterstatus import EmeterStatus
from ...exceptions import KasaException
from ...exceptions import DeviceError, KasaException
from ...interfaces.energy import Energy as EnergyInterface
from ..smartmodule import SmartModule, raise_if_update_error
@ -15,12 +15,39 @@ class Energy(SmartModule, EnergyInterface):
REQUIRED_COMPONENT = "energy_monitoring"
_energy: dict[str, Any]
_current_consumption: float | None
async def _post_update_hook(self) -> None:
if "voltage_mv" in self.data.get("get_emeter_data", {}):
try:
data = self.data
except DeviceError as de:
self._energy = {}
self._current_consumption = None
raise de
# If version is 1 then data is get_energy_usage
self._energy = data.get("get_energy_usage", data)
if "voltage_mv" in data.get("get_emeter_data", {}):
self._supported = (
self._supported | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT
)
if (power := self._energy.get("current_power")) is not None or (
power := data.get("get_emeter_data", {}).get("power_mw")
) is not None:
self._current_consumption = power / 1_000
# Fallback if get_energy_usage does not provide current_power,
# which can happen on some newer devices (e.g. P304M).
# This may not be valid scenario as it pre-dates trying get_emeter_data
elif (
power := self.data.get("get_current_power", {}).get("current_power")
) is not None:
self._current_consumption = power
else:
self._current_consumption = None
def query(self) -> dict:
"""Query to execute during the update cycle."""
req = {
@ -33,28 +60,21 @@ class Energy(SmartModule, EnergyInterface):
return req
@property
@raise_if_update_error
def current_consumption(self) -> float | None:
"""Current power in watts."""
if (power := self.energy.get("current_power")) is not None or (
power := self.data.get("get_emeter_data", {}).get("power_mw")
) is not None:
return power / 1_000
# Fallback if get_energy_usage does not provide current_power,
# which can happen on some newer devices (e.g. P304M).
elif (
power := self.data.get("get_current_power", {}).get("current_power")
) is not None:
return power
return None
def optional_response_keys(self) -> list[str]:
"""Return optional response keys for the module."""
if self.supported_version > 1:
return ["get_energy_usage"]
return []
@property
def current_consumption(self) -> float | None:
"""Current power in watts."""
return self._current_consumption
@property
@raise_if_update_error
def energy(self) -> dict:
"""Return get_energy_usage results."""
if en := self.data.get("get_energy_usage"):
return en
return self.data
return self._energy
def _get_status_from_energy(self, energy: dict) -> EmeterStatus:
return EmeterStatus(
@ -83,16 +103,18 @@ class Energy(SmartModule, EnergyInterface):
return self._get_status_from_energy(res["get_energy_usage"])
@property
@raise_if_update_error
def consumption_this_month(self) -> float | None:
"""Get the emeter value for this month in kWh."""
return self.energy.get("month_energy", 0) / 1_000
if (month := self.energy.get("month_energy")) is not None:
return month / 1_000
return None
@property
@raise_if_update_error
def consumption_today(self) -> float | None:
"""Get the emeter value for today in kWh."""
return self.energy.get("today_energy", 0) / 1_000
if (today := self.energy.get("today_energy")) is not None:
return today / 1_000
return None
@property
@raise_if_update_error

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

@ -136,16 +136,17 @@ class Light(SmartModule, LightInterface):
return self._light_state
async def _post_update_hook(self) -> None:
if self._device.is_on is False:
device = self._device
if device.is_on is False:
state = LightState(light_on=False)
else:
state = LightState(light_on=True)
if Module.Brightness in self._device.modules:
if Module.Brightness in device.modules:
state.brightness = self.brightness
if Module.Color in self._device.modules:
if Module.Color in device.modules:
hsv = self.hsv
state.hue = hsv.hue
state.saturation = hsv.saturation
if Module.ColorTemperature in self._device.modules:
if Module.ColorTemperature in device.modules:
state.color_temp = self.color_temp
self._light_state = state

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

View File

@ -6,10 +6,11 @@ import logging
import time
from typing import Any
from ..device import DeviceInfo
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper
from .smartdevice import SmartDevice
from .smartdevice import ComponentsRaw, SmartDevice
from .smartmodule import SmartModule
_LOGGER = logging.getLogger(__name__)
@ -37,7 +38,7 @@ class SmartChildDevice(SmartDevice):
self,
parent: SmartDevice,
info: dict,
component_info: dict,
component_info_raw: ComponentsRaw,
*,
config: DeviceConfig | None = None,
protocol: SmartProtocol | None = None,
@ -47,7 +48,24 @@ class SmartChildDevice(SmartDevice):
super().__init__(parent.host, config=parent.config, protocol=_protocol)
self._parent = parent
self._update_internal_state(info)
self._components = component_info
self._components_raw = component_info_raw
self._components = self._parse_components(self._components_raw)
@property
def device_info(self) -> DeviceInfo:
"""Return device info.
Child device does not have it info and components in _last_update so
this overrides the base implementation to call _get_device_info with
info and components combined as they would be in _last_update.
"""
return self._get_device_info(
{
"get_device_info": self._info,
"component_nego": self._components_raw,
},
None,
)
async def update(self, update_children: bool = True) -> None:
"""Update child module info.
@ -84,7 +102,7 @@ class SmartChildDevice(SmartDevice):
cls,
parent: SmartDevice,
child_info: dict,
child_components: dict,
child_components_raw: ComponentsRaw,
protocol: SmartProtocol | None = None,
*,
last_update: dict | None = None,
@ -97,7 +115,7 @@ class SmartChildDevice(SmartDevice):
derived from the parent.
"""
child: SmartChildDevice = cls(
parent, child_info, child_components, protocol=protocol
parent, child_info, child_components_raw, protocol=protocol
)
if last_update:
child._last_update = last_update

View File

@ -7,9 +7,9 @@ import logging
import time
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime, timedelta, tzinfo
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any, TypeAlias, cast
from ..device import Device, WifiNetwork, _DeviceInfo
from ..device import Device, DeviceInfo, WifiNetwork
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
@ -40,6 +40,8 @@ _LOGGER = logging.getLogger(__name__)
# same issue, homekit perhaps?
NON_HUB_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud]
ComponentsRaw: TypeAlias = dict[str, list[dict[str, int | str]]]
# Device must go last as the other interfaces also inherit Device
# and python needs a consistent method resolution order.
@ -61,13 +63,12 @@ class SmartDevice(Device):
)
super().__init__(host=host, config=config, protocol=_protocol)
self.protocol: SmartProtocol
self._components_raw: dict[str, Any] | None = None
self._components_raw: ComponentsRaw | None = None
self._components: dict[str, int] = {}
self._state_information: dict[str, Any] = {}
self._modules: dict[str | ModuleName[Module], SmartModule] = {}
self._parent: SmartDevice | None = None
self._children: Mapping[str, SmartDevice] = {}
self._last_update = {}
self._last_update_time: float | None = None
self._on_since: datetime | None = None
self._info: dict[str, Any] = {}
@ -82,10 +83,8 @@ class SmartDevice(Device):
self.internal_state.update(resp)
children = self.internal_state["get_child_device_list"]["child_device_list"]
children_components = {
child["device_id"]: {
comp["id"]: int(comp["ver_code"]) for comp in child["component_list"]
}
children_components_raw = {
child["device_id"]: child
for child in self.internal_state["get_child_device_component_list"][
"child_component_list"
]
@ -96,7 +95,7 @@ class SmartDevice(Device):
child_info["device_id"]: await SmartChildDevice.create(
parent=self,
child_info=child_info,
child_components=children_components[child_info["device_id"]],
child_components_raw=children_components_raw[child_info["device_id"]],
)
for child_info in children
}
@ -131,6 +130,13 @@ class SmartDevice(Device):
f"{request} not found in {responses} for device {self.host}"
)
@staticmethod
def _parse_components(components_raw: ComponentsRaw) -> dict[str, int]:
return {
str(comp["id"]): int(comp["ver_code"])
for comp in components_raw["component_list"]
}
async def _negotiate(self) -> None:
"""Perform initialization.
@ -151,12 +157,9 @@ class SmartDevice(Device):
self._info = self._try_get_response(resp, "get_device_info")
# Create our internal presentation of available components
self._components_raw = cast(dict, resp["component_nego"])
self._components_raw = cast(ComponentsRaw, resp["component_nego"])
self._components = {
comp["id"]: int(comp["ver_code"])
for comp in self._components_raw["component_list"]
}
self._components = self._parse_components(self._components_raw)
if "child_device" in self._components and not self.children:
await self._initialize_children()
@ -493,18 +496,13 @@ class SmartDevice(Device):
@property
def model(self) -> str:
"""Returns the device model."""
return str(self._info.get("model"))
# If update hasn't been called self._device_info can't be used
if self._last_update:
return self.device_info.short_name
@property
def _model_region(self) -> str:
"""Return device full model name and region."""
if (disco := self._discovery_info) and (
disco_model := disco.get("device_model")
):
return disco_model
# Some devices have the region in the specs element.
region = f"({specs})" if (specs := self._info.get("specs")) else ""
return f"{self.model}{region}"
disco_model = str(self._info.get("device_model"))
long_name, _, _ = disco_model.partition("(")
return long_name
@property
def alias(self) -> str | None:
@ -804,7 +802,7 @@ class SmartDevice(Device):
@staticmethod
def _get_device_info(
info: dict[str, Any], discovery_info: dict[str, Any] | None
) -> _DeviceInfo:
) -> DeviceInfo:
"""Get model information for a device."""
di = info["get_device_info"]
components = [comp["id"] for comp in info["component_nego"]["component_list"]]
@ -833,7 +831,7 @@ class SmartDevice(Device):
# Brand inferred from SMART.KASAPLUG/SMART.TAPOPLUG etc.
brand = devicetype[:4].lower()
return _DeviceInfo(
return DeviceInfo(
short_name=short_name,
long_name=long_name,
brand=brand,

View File

@ -57,7 +57,7 @@ class SmartModule(Module):
#: Module is initialized, if any of the given keys exists in the sysinfo
SYSINFO_LOOKUP_KEYS: list[str] = []
#: Query to execute during the main update cycle
QUERY_GETTER_NAME: str
QUERY_GETTER_NAME: str = ""
REGISTERED_MODULES: dict[str, type[SmartModule]] = {}
@ -72,6 +72,7 @@ class SmartModule(Module):
self._last_update_time: float | None = None
self._last_update_error: KasaException | None = None
self._error_count = 0
self._logged_remove_keys: list[str] = []
def __init_subclass__(cls, **kwargs) -> None:
# We only want to register submodules in a modules package so that
@ -138,7 +139,9 @@ class SmartModule(Module):
Default implementation uses the raw query getter w/o parameters.
"""
return {self.QUERY_GETTER_NAME: None}
if self.QUERY_GETTER_NAME:
return {self.QUERY_GETTER_NAME: None}
return {}
async def call(self, method: str, params: dict | None = None) -> dict:
"""Call a method.
@ -147,6 +150,15 @@ class SmartModule(Module):
"""
return await self._device._query_helper(method, params)
@property
def optional_response_keys(self) -> list[str]:
"""Return optional response keys for the module.
Defaults to no keys. Overriding this and providing keys will remove
instead of raise on error.
"""
return []
@property
def data(self) -> dict[str, Any]:
"""Return response data for the module.
@ -179,12 +191,31 @@ class SmartModule(Module):
filtered_data = {k: v for k, v in dev._last_update.items() if k in q_keys}
remove_keys: list[str] = []
for data_item in filtered_data:
if isinstance(filtered_data[data_item], SmartErrorCode):
raise DeviceError(
f"{data_item} for {self.name}", error_code=filtered_data[data_item]
if data_item in self.optional_response_keys:
remove_keys.append(data_item)
else:
raise DeviceError(
f"{data_item} for {self.name}",
error_code=filtered_data[data_item],
)
for key in remove_keys:
if key not in self._logged_remove_keys:
self._logged_remove_keys.append(key)
_LOGGER.debug(
"Removed key %s from response for device %s as it returned "
"error: %s. This message will only be logged once per key.",
key,
self._device.host,
filtered_data[key],
)
if len(filtered_data) == 1:
filtered_data.pop(key)
if len(filtered_data) == 1 and not remove_keys:
return next(iter(filtered_data.values()))
return filtered_data

View File

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

View File

@ -0,0 +1,47 @@
"""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 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.Primary,
)
)
@property
def enabled(self) -> bool:
"""Return the baby cry detection enabled state."""
return self.data["bcd"]["enabled"] == "on"
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

@ -1,16 +1,18 @@
"""Implementation of device module."""
"""Implementation of camera module."""
from __future__ import annotations
import base64
import logging
from enum import StrEnum
from typing import Annotated
from urllib.parse import quote_plus
from ...credentials import Credentials
from ...device_type import DeviceType
from ...feature import Feature
from ...json import loads as json_loads
from ...module import FeatureAttribute, Module
from ..smartcammodule import SmartCamModule
_LOGGER = logging.getLogger(__name__)
@ -29,28 +31,38 @@ class StreamResolution(StrEnum):
class Camera(SmartCamModule):
"""Implementation of device module."""
QUERY_GETTER_NAME = "getLensMaskConfig"
QUERY_MODULE_NAME = "lens_mask"
QUERY_SECTION_NAMES = "lens_mask_info"
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
self._device,
id="state",
name="State",
attribute_getter="is_on",
attribute_setter="set_state",
type=Feature.Type.Switch,
category=Feature.Category.Primary,
if Module.LensMask in self._device.modules:
self._add_feature(
Feature(
self._device,
id="state",
name="State",
container=self,
attribute_getter="is_on",
attribute_setter="set_state",
type=Feature.Type.Switch,
category=Feature.Category.Primary,
)
)
)
@property
def is_on(self) -> bool:
"""Return the device id."""
return self.data["lens_mask_info"]["enabled"] == "off"
"""Return the device on state."""
if lens_mask := self._device.modules.get(Module.LensMask):
return not lens_mask.enabled
return True
async def set_state(self, on: bool) -> Annotated[dict, FeatureAttribute()]:
"""Set the device on state.
If the device does not support setting state will do nothing.
"""
if lens_mask := self._device.modules.get(Module.LensMask):
# Turning off enables the privacy mask which is why value is reversed.
return await lens_mask.set_enabled(not on)
return {}
def _get_credentials(self) -> Credentials | None:
"""Get credentials from ."""
@ -109,14 +121,6 @@ class Camera(SmartCamModule):
"""Return the onvif url."""
return f"http://{self._device.host}:{ONVIF_PORT}/onvif/device_service"
async def set_state(self, on: bool) -> dict:
"""Set the device state."""
# Turning off enables the privacy mask which is why value is reversed.
params = {"enabled": "off" if on else "on"}
return await self._device._query_setter_helper(
"setLensMaskConfig", self.QUERY_MODULE_NAME, "lens_mask_info", params
)
async def _check_supported(self) -> bool:
"""Additional check to see if the module is supported by the device."""
return self._device.device_type is DeviceType.Camera

View File

@ -14,6 +14,13 @@ class DeviceModule(SmartCamModule):
QUERY_MODULE_NAME = "device_info"
QUERY_SECTION_NAMES = ["basic_info", "info"]
def query(self) -> dict:
"""Query to execute during the update cycle."""
q = super().query()
q["getConnectionType"] = {"network": {"get_connection_type": []}}
return q
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
@ -26,6 +33,32 @@ class DeviceModule(SmartCamModule):
type=Feature.Type.Sensor,
)
)
if self.rssi is not None:
self._add_feature(
Feature(
self._device,
container=self,
id="rssi",
name="RSSI",
attribute_getter="rssi",
icon="mdi:signal",
unit_getter=lambda: "dBm",
category=Feature.Category.Debug,
type=Feature.Type.Sensor,
)
)
self._add_feature(
Feature(
self._device,
container=self,
id="signal_level",
name="Signal Level",
attribute_getter="signal_level",
icon="mdi:signal",
category=Feature.Category.Info,
type=Feature.Type.Sensor,
)
)
async def _post_update_hook(self) -> None:
"""Overriden to prevent module disabling.
@ -37,4 +70,14 @@ class DeviceModule(SmartCamModule):
@property
def device_id(self) -> str:
"""Return the device id."""
return self.data["basic_info"]["dev_id"]
return self.data[self.QUERY_GETTER_NAME]["basic_info"]["dev_id"]
@property
def rssi(self) -> int | None:
"""Return the device id."""
return self.data["getConnectionType"].get("rssiValue")
@property
def signal_level(self) -> int | None:
"""Return the device id."""
return self.data["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

@ -0,0 +1,31 @@
"""Implementation of lens mask privacy module."""
from __future__ import annotations
import logging
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"
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
)

View File

@ -0,0 +1,44 @@
"""Implementation of matter module."""
from __future__ import annotations
from ...feature import Feature
from ..smartcammodule import SmartCamModule
class Matter(SmartCamModule):
"""Implementation of matter module."""
QUERY_GETTER_NAME = "getMatterSetupInfo"
QUERY_MODULE_NAME = "matter"
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

View File

@ -0,0 +1,47 @@
"""Implementation of motion detection module."""
from __future__ import annotations
import logging
from ...feature import Feature
from ..smartcammodule import SmartCamModule
_LOGGER = logging.getLogger(__name__)
class MotionDetection(SmartCamModule):
"""Implementation of motion detection module."""
REQUIRED_COMPONENT = "detection"
QUERY_GETTER_NAME = "getDetectionConfig"
QUERY_MODULE_NAME = "motion_detection"
QUERY_SECTION_NAMES = "motion_det"
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
self._device,
id="motion_detection",
name="Motion detection",
container=self,
attribute_getter="enabled",
attribute_setter="set_enabled",
type=Feature.Type.Switch,
category=Feature.Category.Primary,
)
)
@property
def enabled(self) -> bool:
"""Return the motion detection enabled state."""
return self.data["motion_det"]["enabled"] == "on"
async def set_enabled(self, enable: bool) -> dict:
"""Set the motion detection enabled state."""
params = {"enabled": "on" if enable else "off"}
return await self._device._query_setter_helper(
"setDetectionConfig", self.QUERY_MODULE_NAME, "motion_det", params
)

View File

@ -0,0 +1,47 @@
"""Implementation of person detection module."""
from __future__ import annotations
import logging
from ...feature import Feature
from ..smartcammodule import SmartCamModule
_LOGGER = logging.getLogger(__name__)
class PersonDetection(SmartCamModule):
"""Implementation of person detection module."""
REQUIRED_COMPONENT = "personDetection"
QUERY_GETTER_NAME = "getPersonDetectionConfig"
QUERY_MODULE_NAME = "people_detection"
QUERY_SECTION_NAMES = "detection"
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
self._device,
id="person_detection",
name="Person detection",
container=self,
attribute_getter="enabled",
attribute_setter="set_enabled",
type=Feature.Type.Switch,
category=Feature.Category.Primary,
)
)
@property
def enabled(self) -> bool:
"""Return the person detection enabled state."""
return self.data["detection"]["enabled"] == "on"
async def set_enabled(self, enable: bool) -> dict:
"""Set the person detection enabled state."""
params = {"enabled": "on" if enable else "off"}
return await self._device._query_setter_helper(
"setPersonDetectionConfig", self.QUERY_MODULE_NAME, "detection", params
)

View File

@ -0,0 +1,47 @@
"""Implementation of tamper detection module."""
from __future__ import annotations
import logging
from ...feature import Feature
from ..smartcammodule import SmartCamModule
_LOGGER = logging.getLogger(__name__)
class TamperDetection(SmartCamModule):
"""Implementation of tamper detection module."""
REQUIRED_COMPONENT = "tamperDetection"
QUERY_GETTER_NAME = "getTamperDetectionConfig"
QUERY_MODULE_NAME = "tamper_detection"
QUERY_SECTION_NAMES = "tamper_det"
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
self._device,
id="tamper_detection",
name="Tamper detection",
container=self,
attribute_getter="enabled",
attribute_setter="set_enabled",
type=Feature.Type.Switch,
category=Feature.Category.Primary,
)
)
@property
def enabled(self) -> bool:
"""Return the tamper detection enabled state."""
return self.data["tamper_det"]["enabled"] == "on"
async def set_enabled(self, enable: bool) -> dict:
"""Set the tamper detection enabled state."""
params = {"enabled": "on" if enable else "off"}
return await self._device._query_setter_helper(
"setTamperDetectionConfig", self.QUERY_MODULE_NAME, "tamper_det", params
)

View File

@ -3,13 +3,14 @@
from __future__ import annotations
import logging
from typing import Any
from typing import Any, cast
from ..device import _DeviceInfo
from ..device import DeviceInfo
from ..device_type import DeviceType
from ..module import Module
from ..protocols.smartcamprotocol import _ChildCameraProtocolWrapper
from ..smart import SmartChildDevice, SmartDevice
from ..smart.smartdevice import ComponentsRaw
from .modules import ChildDevice, DeviceModule
from .smartcammodule import SmartCamModule
@ -36,7 +37,7 @@ class SmartCamDevice(SmartDevice):
@staticmethod
def _get_device_info(
info: dict[str, Any], discovery_info: dict[str, Any] | None
) -> _DeviceInfo:
) -> DeviceInfo:
"""Get model information for a device."""
basic_info = info["getDeviceInfo"]["device_info"]["basic_info"]
short_name = basic_info["device_model"]
@ -44,7 +45,7 @@ class SmartCamDevice(SmartDevice):
device_type = SmartCamDevice._get_device_type_from_sysinfo(basic_info)
fw_version_full = basic_info["sw_version"]
firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1)
return _DeviceInfo(
return DeviceInfo(
short_name=basic_info["device_model"],
long_name=long_name,
brand="tapo",
@ -78,7 +79,7 @@ class SmartCamDevice(SmartDevice):
self._children[child_id]._update_internal_state(info)
async def _initialize_smart_child(
self, info: dict, child_components: dict
self, info: dict, child_components_raw: ComponentsRaw
) -> SmartDevice:
"""Initialize a smart child device attached to a smartcam device."""
child_id = info["device_id"]
@ -93,7 +94,7 @@ class SmartCamDevice(SmartDevice):
return await SmartChildDevice.create(
parent=self,
child_info=info,
child_components=child_components,
child_components_raw=child_components_raw,
protocol=child_protocol,
last_update=initial_response,
)
@ -108,17 +109,8 @@ class SmartCamDevice(SmartDevice):
self.internal_state.update(resp)
smart_children_components = {
child["device_id"]: {
comp["id"]: int(comp["ver_code"]) for comp in component_list
}
child["device_id"]: child
for child in resp["getChildDeviceComponentList"]["child_component_list"]
if (component_list := child.get("component_list"))
# Child camera devices will have a different component schema so only
# extract smart values.
and (first_comp := next(iter(component_list), None))
and isinstance(first_comp, dict)
and "id" in first_comp
and "ver_code" in first_comp
}
children = {}
for info in resp["getChildDeviceList"]["child_device_list"]:
@ -142,6 +134,11 @@ class SmartCamDevice(SmartDevice):
if (
mod.REQUIRED_COMPONENT
and mod.REQUIRED_COMPONENT not in self._components
# Always add Camera module to cameras
and (
mod._module_name() != Module.Camera
or self._device_type is not DeviceType.Camera
)
):
continue
module = mod(self, mod._module_name())
@ -172,6 +169,13 @@ class SmartCamDevice(SmartDevice):
return res
@staticmethod
def _parse_components(components_raw: ComponentsRaw) -> dict[str, int]:
return {
str(comp["name"]): int(comp["version"])
for comp in components_raw["app_component_list"]
}
async def _negotiate(self) -> None:
"""Perform initialization.
@ -181,17 +185,16 @@ class SmartCamDevice(SmartDevice):
initial_query = {
"getDeviceInfo": {"device_info": {"name": ["basic_info", "info"]}},
"getAppComponentList": {"app_component": {"name": "app_component_list"}},
"getConnectionType": {"network": {"get_connection_type": {}}},
}
resp = await self.protocol.query(initial_query)
self._last_update.update(resp)
self._update_internal_info(resp)
self._components = {
comp["name"]: int(comp["version"])
for comp in resp["getAppComponentList"]["app_component"][
"app_component_list"
]
}
self._components_raw = cast(
ComponentsRaw, resp["getAppComponentList"]["app_component"]
)
self._components = self._parse_components(self._components_raw)
if "childControl" in self._components and not self.children:
await self._initialize_children()
@ -251,11 +254,16 @@ class SmartCamDevice(SmartDevice):
def hw_info(self) -> dict:
"""Return hardware info for the device."""
return {
"sw_ver": self._info.get("hw_ver"),
"hw_ver": self._info.get("fw_ver"),
"sw_ver": self._info.get("fw_ver"),
"hw_ver": self._info.get("hw_ver"),
"mac": self._info.get("mac"),
"type": self._info.get("type"),
"hwId": self._info.get("hwId"),
"dev_name": self.alias,
"oemId": self._info.get("oem_id"),
}
@property
def rssi(self) -> int | None:
"""Return the device id."""
return self.modules[SmartCamModule.SmartCamDeviceModule].rssi

View File

@ -20,9 +20,23 @@ class SmartCamModule(SmartModule):
"""Base class for SMARTCAM modules."""
SmartCamAlarm: Final[ModuleName[modules.Alarm]] = ModuleName("SmartCamAlarm")
SmartCamMotionDetection: Final[ModuleName[modules.MotionDetection]] = ModuleName(
"MotionDetection"
)
SmartCamPersonDetection: Final[ModuleName[modules.PersonDetection]] = ModuleName(
"PersonDetection"
)
SmartCamTamperDetection: Final[ModuleName[modules.TamperDetection]] = ModuleName(
"TamperDetection"
)
SmartCamBabyCryDetection: Final[ModuleName[modules.BabyCryDetection]] = ModuleName(
"BabyCryDetection"
)
SmartCamDeviceModule: Final[ModuleName[modules.DeviceModule]] = ModuleName(
"devicemodule"
)
#: Query to execute during the main update cycle
QUERY_GETTER_NAME: str
#: Module name to be queried
QUERY_MODULE_NAME: str
#: Section name or names to be queried
@ -37,6 +51,8 @@ class SmartCamModule(SmartModule):
Default implementation uses the raw query getter w/o parameters.
"""
if not self.QUERY_GETTER_NAME:
return {}
section_names = (
{"name": self.QUERY_SECTION_NAMES} if self.QUERY_SECTION_NAMES else {}
)
@ -86,7 +102,8 @@ class SmartCamModule(SmartModule):
f" for '{self._module}'"
)
return query_resp.get(self.QUERY_MODULE_NAME)
# Some calls return the data under the module, others not
return query_resp.get(self.QUERY_MODULE_NAME, query_resp)
else:
found = {key: val for key, val in dev._last_update.items() if key in q}
for key in q:

View File

@ -4,6 +4,7 @@ from .aestransport import AesEncyptionSession, AesTransport
from .basetransport import BaseTransport
from .klaptransport import KlapTransport, KlapTransportV2
from .linkietransport import LinkieTransportV2
from .sslaestransport import SslAesTransport
from .ssltransport import SslTransport
from .xortransport import XorEncryption, XorTransport
@ -11,6 +12,7 @@ __all__ = [
"AesTransport",
"AesEncyptionSession",
"SslTransport",
"SslAesTransport",
"BaseTransport",
"KlapTransport",
"KlapTransportV2",

View File

@ -8,6 +8,7 @@ import hashlib
import logging
import secrets
import ssl
from contextlib import suppress
from enum import Enum, auto
from typing import TYPE_CHECKING, Any, cast
@ -160,6 +161,19 @@ class SslAesTransport(BaseTransport):
error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR
return error_code
def _get_response_inner_error(self, resp_dict: Any) -> SmartErrorCode | None:
error_code_raw = resp_dict.get("data", {}).get("code")
if error_code_raw is None:
return None
try:
error_code = SmartErrorCode.from_int(error_code_raw)
except ValueError:
_LOGGER.warning(
"Device %s received unknown error code: %s", self._host, error_code_raw
)
error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR
return error_code
def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None:
error_code = self._get_response_error(resp_dict)
if error_code is SmartErrorCode.SUCCESS:
@ -216,6 +230,31 @@ class SslAesTransport(BaseTransport):
ssl=await self._get_ssl_context(),
)
if TYPE_CHECKING:
assert self._encryption_session is not None
# Devices can respond with 500 if another session is created from
# the same host. Decryption may not succeed after that
if status_code == 500:
msg = (
f"Device {self._host} replied with status 500 after handshake, "
f"response: "
)
decrypted = None
if isinstance(resp_dict, dict) and (
response := resp_dict.get("result", {}).get("response")
):
with suppress(Exception):
decrypted = self._encryption_session.decrypt(response.encode())
if decrypted:
msg += decrypted
else:
msg += str(resp_dict)
_LOGGER.debug(msg)
raise _RetryableError(msg)
if status_code != 200:
raise KasaException(
f"{self._host} responded with an unexpected "
@ -228,7 +267,6 @@ class SslAesTransport(BaseTransport):
if TYPE_CHECKING:
resp_dict = cast(dict[str, Any], resp_dict)
assert self._encryption_session is not None
if "result" in resp_dict and "response" in resp_dict["result"]:
raw_response: str = resp_dict["result"]["response"]
@ -383,13 +421,29 @@ class SslAesTransport(BaseTransport):
error_code = default_error_code
resp_dict = default_resp_dict
# If the default login worked it's ok not to provide credentials but if
# it didn't raise auth error here.
if not self._username:
raise AuthenticationError(
f"Credentials must be supplied to connect to {self._host}"
)
# Device responds with INVALID_NONCE and a "nonce" to indicate ready
# for secure login. Otherwise error.
if error_code is not SmartErrorCode.INVALID_NONCE or (
resp_dict and "nonce" not in resp_dict["result"].get("data", {})
resp_dict and "nonce" not in resp_dict.get("result", {}).get("data", {})
):
if (
resp_dict
and self._get_response_inner_error(resp_dict)
is SmartErrorCode.DEVICE_BLOCKED
):
sec_left = resp_dict.get("data", {}).get("sec_left")
msg = "Device blocked" + (
f" for {sec_left} seconds" if sec_left else ""
)
raise DeviceError(msg, error_code=SmartErrorCode.DEVICE_BLOCKED)
raise AuthenticationError(f"Error trying handshake1: {resp_dict}")
if TYPE_CHECKING:

View File

@ -1,6 +1,6 @@
[project]
name = "python-kasa"
version = "0.8.1"
version = "0.9.0"
description = "Python API for TP-Link Kasa and Tapo devices"
license = {text = "GPL-3.0-or-later"}
authors = [ { name = "python-kasa developers" }]

View File

@ -79,8 +79,6 @@ PLUGS_IOT = {
"KP125",
"KP401",
}
# P135 supports dimming, but its not currently support
# by the library
PLUGS_SMART = {
"P100",
"P110",
@ -112,7 +110,7 @@ SWITCHES_SMART = {
}
SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART}
STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"}
STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M"}
STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M", "P210M", "P306"}
STRIPS = {*STRIPS_IOT, *STRIPS_SMART}
DIMMERS_IOT = {"ES20M", "HS220", "KS220", "KS220M", "KS230", "KP405"}
@ -435,7 +433,7 @@ async def get_device_for_fixture(
discovery_data = None
if "discovery_result" in fixture_data.data:
discovery_data = fixture_data.data["discovery_result"]
discovery_data = fixture_data.data["discovery_result"]["result"]
elif "system" in fixture_data.data:
discovery_data = {
"system": {"get_sysinfo": fixture_data.data["system"]["get_sysinfo"]}
@ -473,8 +471,12 @@ def get_nearest_fixture_to_ip(dev):
assert protocol_fixtures, "Unknown device type"
# This will get the best fixture with a match on model region
if model_region_fixtures := filter_fixtures(
"", model_filter={dev._model_region}, fixture_list=protocol_fixtures
if (di := dev.device_info) and (
model_region_fixtures := filter_fixtures(
"",
model_filter={di.long_name + (f"({di.region})" if di.region else "")},
fixture_list=protocol_fixtures,
)
):
return next(iter(model_region_fixtures))

View File

@ -139,7 +139,8 @@ smart_discovery = parametrize_discovery("smart discovery", protocol_filter={"SMA
)
async def discovery_mock(request, mocker):
"""Mock discovery and patch protocol queries to use Fake protocols."""
fixture_info: FixtureInfo = request.param
fi: FixtureInfo = request.param
fixture_info = FixtureInfo(fi.name, fi.protocol, copy.deepcopy(fi.data))
return patch_discovery({DISCOVERY_MOCK_IP: fixture_info}, mocker)
@ -159,6 +160,17 @@ def create_discovery_mock(ip: str, fixture_data: dict):
login_version: int | None = None
port_override: int | None = None
@property
def model(self) -> str:
dd = self.discovery_data
model_region = (
dd["result"]["device_model"]
if self.discovery_port == 20002
else dd["system"]["get_sysinfo"]["model"]
)
model, _, _ = model_region.partition("(")
return model
@property
def _datagram(self) -> bytes:
if self.default_port == 9999:
@ -170,14 +182,17 @@ def create_discovery_mock(ip: str, fixture_data: dict):
)
if "discovery_result" in fixture_data:
discovery_data = {"result": fixture_data["discovery_result"].copy()}
discovery_result = fixture_data["discovery_result"]
discovery_data = fixture_data["discovery_result"].copy()
discovery_result = fixture_data["discovery_result"]["result"]
device_type = discovery_result["device_type"]
encrypt_type = discovery_result["mgt_encrypt_schm"].get(
"encrypt_type", discovery_result.get("encrypt_info", {}).get("sym_schm")
)
login_version = discovery_result["mgt_encrypt_schm"].get("lv")
if not (login_version := discovery_result["mgt_encrypt_schm"].get("lv")) and (
et := discovery_result.get("encrypt_type")
):
login_version = max([int(i) for i in et])
https = discovery_result["mgt_encrypt_schm"]["is_support_https"]
dm = _DiscoveryMock(
ip,
@ -305,7 +320,7 @@ def discovery_data(request, mocker):
mocker.patch("kasa.IotProtocol.query", return_value=fixture_data)
mocker.patch("kasa.SmartProtocol.query", return_value=fixture_data)
if "discovery_result" in fixture_data:
return {"result": fixture_data["discovery_result"]}
return fixture_data["discovery_result"].copy()
else:
return {"system": {"get_sysinfo": fixture_data["system"]["get_sysinfo"]}}

View File

@ -114,6 +114,15 @@ class FakeSmartTransport(BaseTransport):
"type": 0,
},
),
"get_homekit_info": (
"homekit",
{
"mfi_setup_code": "000-00-000",
"mfi_setup_id": "0000",
"mfi_token_token": "000000000000000000000000000000000",
"mfi_token_uuid": "00000000-0000-0000-0000-000000000000",
},
),
"get_auto_update_info": (
"firmware",
{"enable": True, "random_range": 120, "time": 180},
@ -151,6 +160,13 @@ class FakeSmartTransport(BaseTransport):
"energy_monitoring",
{"igain": 10861, "vgain": 118657},
),
"get_matter_setup_info": (
"matter",
{
"setup_code": "00000000000",
"setup_payload": "00:0000000-0000.00.000",
},
),
}
async def send(self, request: str):

View File

@ -34,6 +34,7 @@ class FakeSmartCamTransport(BaseTransport):
list_return_size=10,
is_child=False,
verbatim=False,
components_not_included=False,
):
super().__init__(
config=DeviceConfig(
@ -44,6 +45,7 @@ class FakeSmartCamTransport(BaseTransport):
),
),
)
self.fixture_name = fixture_name
# When True verbatim will bypass any extra processing of missing
# methods and is used to test the fixture creation itself.
@ -58,6 +60,17 @@ class FakeSmartCamTransport(BaseTransport):
# self.child_protocols = self._get_child_protocols()
self.list_return_size = list_return_size
# Setting this flag allows tests to create dummy transports without
# full fixture info for testing specific cases like list handling etc
self.components_not_included = (components_not_included,)
if not components_not_included:
self.components = {
comp["name"]: comp["version"]
for comp in self.info["getAppComponentList"]["app_component"][
"app_component_list"
]
}
@property
def default_port(self):
"""Default port for the transport."""
@ -112,6 +125,15 @@ class FakeSmartCamTransport(BaseTransport):
info = info[key]
info[set_keys[-1]] = value
FIXTURE_MISSING_MAP = {
"getMatterSetupInfo": (
"matter",
{
"setup_code": "00000000000",
"setup_payload": "00:0000000-0000.00.000",
},
)
}
# Setters for when there's not a simple mapping of setters to getters
SETTERS = {
("system", "sys", "dev_alias"): [
@ -199,26 +221,38 @@ class FakeSmartCamTransport(BaseTransport):
return {**result, "error_code": 0}
else:
return {"error_code": -1}
elif method[:3] == "get":
params = request_dict.get("params")
if method in info:
result = copy.deepcopy(info[method])
if "start_index" in result and "sum" in result:
list_key = next(
iter([key for key in result if isinstance(result[key], list)])
)
start_index = (
start_index
if (params and (start_index := params.get("start_index")))
else 0
)
result[list_key] = result[list_key][
start_index : start_index + self.list_return_size
]
return {"result": result, "error_code": 0}
else:
return {"error_code": -1}
if method in info:
params = request_dict.get("params")
result = copy.deepcopy(info[method])
if "start_index" in result and "sum" in result:
list_key = next(
iter([key for key in result if isinstance(result[key], list)])
)
start_index = (
start_index
if (params and (start_index := params.get("start_index")))
else 0
)
result[list_key] = result[list_key][
start_index : start_index + self.list_return_size
]
return {"result": result, "error_code": 0}
if self.verbatim:
return {"error_code": -1}
if (
# FIXTURE_MISSING is for service calls not in place when
# SMART fixtures started to be generated
missing_result := self.FIXTURE_MISSING_MAP.get(method)
) and missing_result[0] in self.components:
# Copy to info so it will work with update methods
info[method] = copy.deepcopy(missing_result[1])
result = copy.deepcopy(info[method])
return {"result": result, "error_code": 0}
return {"error_code": -1}
async def close(self) -> None:

View File

@ -145,12 +145,21 @@ def filter_fixtures(
def _component_match(
fixture_data: FixtureInfo, component_filter: str | ComponentFilter
):
if (component_nego := fixture_data.data.get("component_nego")) is None:
components = {}
if component_nego := fixture_data.data.get("component_nego"):
components = {
component["id"]: component["ver_code"]
for component in component_nego["component_list"]
}
if get_app_component_list := fixture_data.data.get("getAppComponentList"):
components = {
component["name"]: component["version"]
for component in get_app_component_list["app_component"][
"app_component_list"
]
}
if not components:
return False
components = {
component["id"]: component["ver_code"]
for component in component_nego["component_list"]
}
if isinstance(component_filter, str):
return component_filter in components
else:

View File

@ -2,7 +2,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "167 lamp",
"alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Plug Mini",
"deviceId": "0000000000000000000000000000000000000000",
"err_code": 0,

View File

@ -1,12 +1,12 @@
{
"system": {
"get_sysinfo": {
"alias": "TP-LINK_Smart Plug_004F",
"alias": "#MASKED_NAME#",
"child_num": 2,
"children": [
{
"alias": "Zombie",
"id": "8006231E1499BAC4D4BC7EFCD4B075181E6393F200",
"alias": "#MASKED_NAME# 1",
"id": "SCRUBBED_CHILD_DEVICE_ID_1",
"next_action": {
"type": -1
},
@ -14,8 +14,8 @@
"state": 0
},
{
"alias": "Magic",
"id": "8006231E1499BAC4D4BC7EFCD4B075181E6393F201",
"alias": "#MASKED_NAME# 2",
"id": "SCRUBBED_CHILD_DEVICE_ID_2",
"next_action": {
"type": -1
},

View File

@ -11,7 +11,7 @@
"stopConnect": 0,
"tcspInfo": "",
"tcspStatus": 1,
"username": "#MASKED_NAME#"
"username": "user@example.com"
},
"get_intl_fw_list": {
"err_code": 0,

View File

@ -78,7 +78,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "Test ES20M",
"alias": "#MASKED_NAME#",
"brightness": 35,
"dev_name": "Wi-Fi Smart Dimmer with sensor",
"deviceId": "0000000000000000000000000000000000000000",

View File

@ -1,18 +1,21 @@
{
"discovery_result": {
"device_id": "00000000000000000000000000000000",
"device_model": "HS100(UK)",
"device_type": "IOT.SMARTPLUGSWITCH",
"factory_default": true,
"hw_ver": "4.1",
"ip": "127.0.0.123",
"mac": "CC-32-E5-00-00-00",
"mgt_encrypt_schm": {
"encrypt_type": "KLAP",
"http_port": 80,
"is_support_https": false
},
"owner": "00000000000000000000000000000000"
"error_code": 0,
"result": {
"device_id": "00000000000000000000000000000000",
"device_model": "HS100(UK)",
"device_type": "IOT.SMARTPLUGSWITCH",
"factory_default": true,
"hw_ver": "4.1",
"ip": "127.0.0.123",
"mac": "CC-32-E5-00-00-00",
"mgt_encrypt_schm": {
"encrypt_type": "KLAP",
"http_port": 80,
"is_support_https": false
},
"owner": "00000000000000000000000000000000"
}
},
"system": {
"get_sysinfo": {

View File

@ -18,7 +18,7 @@
"system": {
"get_sysinfo": {
"active_mode": "schedule",
"alias": "Unused 3",
"alias": "#MASKED_NAME#",
"dev_name": "Wi-Fi Smart Plug",
"deviceId": "0000000000000000000000000000000000000000",
"err_code": 0,

View File

@ -20,7 +20,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "3D Printer",
"alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Plug",
"deviceId": "0000000000000000000000000000000000000000",
"err_code": 0,

View File

@ -20,7 +20,7 @@
"system": {
"get_sysinfo": {
"active_mode": "schedule",
"alias": "Night lite",
"alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Plug Lite",
"deviceId": "0000000000000000000000000000000000000000",
"err_code": 0,

View File

@ -18,7 +18,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "Corner",
"alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Plug Lite",
"deviceId": "0000000000000000000000000000000000000000",
"err_code": 0,

View File

@ -2,7 +2,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "Plug",
"alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Plug Lite",
"deviceId": "0000000000000000000000000000000000000000",
"err_code": 0,

View File

@ -20,7 +20,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "Unused 1",
"alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Plug Mini",
"deviceId": "0000000000000000000000000000000000000000",
"err_code": 0,

View File

@ -17,12 +17,12 @@
},
"system": {
"get_sysinfo": {
"alias": "TP-LINK_Smart Plug_D310",
"alias": "#MASKED_NAME#",
"child_num": 2,
"children": [
{
"alias": "Garage Charger 1",
"id": "00",
"alias": "#MASKED_NAME# 1",
"id": "SCRUBBED_CHILD_DEVICE_ID_1",
"next_action": {
"type": -1
},
@ -30,8 +30,8 @@
"state": 0
},
{
"alias": "Garage Charger 2",
"id": "01",
"alias": "#MASKED_NAME# 2",
"id": "SCRUBBED_CHILD_DEVICE_ID_2",
"next_action": {
"type": -1
},
@ -46,7 +46,7 @@
"hw_ver": "1.0",
"latitude_i": 0,
"led_off": 0,
"longitude_i": -0,
"longitude_i": 0,
"mac": "00:00:00:00:00:00",
"mic_type": "IOT.SMARTPLUGSWITCH",
"model": "HS107(US)",

View File

@ -11,7 +11,7 @@
"system": {
"get_sysinfo": {
"active_mode": "schedule",
"alias": "Bedroom Lamp Plug",
"alias": "#MASKED_NAME#",
"dev_name": "Wi-Fi Smart Plug With Energy Monitoring",
"deviceId": "0000000000000000000000000000000000000000",
"err_code": 0,

View File

@ -11,7 +11,7 @@
"system": {
"get_sysinfo": {
"active_mode": "schedule",
"alias": "Home Google WiFi HS110",
"alias": "#MASKED_NAME#",
"dev_name": "Wi-Fi Smart Plug With Energy Monitoring",
"deviceId": "0000000000000000000000000000000000000000",
"err_code": 0,

View File

@ -20,7 +20,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "Master Bedroom Fan",
"alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Light Switch",
"deviceId": "0000000000000000000000000000000000000000",
"err_code": 0,

View File

@ -2,7 +2,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "House Fan",
"alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Light Switch",
"deviceId": "0000000000000000000000000000000000000000",
"err_code": 0,

View File

@ -21,7 +21,7 @@
"get_sysinfo": {
"abnormal_detect": 1,
"active_mode": "none",
"alias": "Garage Light",
"alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi 3-Way Light Switch",
"deviceId": "0000000000000000000000000000000000000000",
"err_code": 0,

View File

@ -28,7 +28,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "Living Room Dimmer Switch",
"alias": "#MASKED_NAME#",
"brightness": 25,
"dev_name": "Smart Wi-Fi Dimmer",
"deviceId": "000000000000000000000000000000000000000",
@ -38,9 +38,9 @@
"hwId": "00000000000000000000000000000000",
"hw_ver": "1.0",
"icon_hash": "",
"latitude_i": 11.6210,
"latitude_i": 0,
"led_off": 0,
"longitude_i": 42.2074,
"longitude_i": 0,
"mac": "00:00:00:00:00:00",
"mic_type": "IOT.SMARTPLUGSWITCH",
"model": "HS220(US)",

View File

@ -17,7 +17,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "Living Room Dimmer Switch",
"alias": "#MASKED_NAME#",
"brightness": 100,
"dev_name": "Wi-Fi Smart Dimmer",
"deviceId": "0000000000000000000000000000000000000000",

View File

@ -22,12 +22,12 @@
},
"system": {
"get_sysinfo": {
"alias": "TP-LINK_Power Strip_DAE1",
"alias": "#MASKED_NAME#",
"child_num": 6,
"children": [
{
"alias": "Office Monitor 1",
"id": "00",
"alias": "#MASKED_NAME# 1",
"id": "SCRUBBED_CHILD_DEVICE_ID_1",
"next_action": {
"type": -1
},
@ -35,8 +35,8 @@
"state": 0
},
{
"alias": "Office Monitor 2",
"id": "01",
"alias": "#MASKED_NAME# 2",
"id": "SCRUBBED_CHILD_DEVICE_ID_2",
"next_action": {
"type": -1
},
@ -44,8 +44,8 @@
"state": 0
},
{
"alias": "Office Monitor 3",
"id": "02",
"alias": "#MASKED_NAME# 3",
"id": "SCRUBBED_CHILD_DEVICE_ID_3",
"next_action": {
"type": -1
},
@ -53,8 +53,8 @@
"state": 0
},
{
"alias": "Office Laptop Dock",
"id": "03",
"alias": "#MASKED_NAME# 4",
"id": "SCRUBBED_CHILD_DEVICE_ID_4",
"next_action": {
"type": -1
},
@ -62,8 +62,8 @@
"state": 0
},
{
"alias": "Office Desk Light",
"id": "04",
"alias": "#MASKED_NAME# 5",
"id": "SCRUBBED_CHILD_DEVICE_ID_5",
"next_action": {
"type": -1
},
@ -71,8 +71,8 @@
"state": 0
},
{
"alias": "Laptop",
"id": "05",
"alias": "#MASKED_NAME# 6",
"id": "SCRUBBED_CHILD_DEVICE_ID_6",
"next_action": {
"type": -1
},
@ -87,7 +87,7 @@
"hw_ver": "1.0",
"latitude_i": 0,
"led_off": 0,
"longitude_i": -0,
"longitude_i": 0,
"mac": "00:00:00:00:00:00",
"mic_type": "IOT.SMARTPLUGSWITCH",
"model": "HS300(US)",

View File

@ -10,12 +10,12 @@
},
"system": {
"get_sysinfo": {
"alias": "TP-LINK_Power Strip_2CA9",
"alias": "#MASKED_NAME#",
"child_num": 6,
"children": [
{
"alias": "Home CameraPC",
"id": "800623145DFF1AA096363EFD161C2E661A9D8DED00",
"alias": "#MASKED_NAME# 1",
"id": "SCRUBBED_CHILD_DEVICE_ID_1",
"next_action": {
"type": -1
},
@ -23,8 +23,8 @@
"state": 1
},
{
"alias": "Home Firewalla",
"id": "800623145DFF1AA096363EFD161C2E661A9D8DED01",
"alias": "#MASKED_NAME# 2",
"id": "SCRUBBED_CHILD_DEVICE_ID_2",
"next_action": {
"type": -1
},
@ -32,8 +32,8 @@
"state": 1
},
{
"alias": "Home Cox modem",
"id": "800623145DFF1AA096363EFD161C2E661A9D8DED02",
"alias": "#MASKED_NAME# 3",
"id": "SCRUBBED_CHILD_DEVICE_ID_3",
"next_action": {
"type": -1
},
@ -41,8 +41,8 @@
"state": 1
},
{
"alias": "Home rpi3-2",
"id": "800623145DFF1AA096363EFD161C2E661A9D8DED03",
"alias": "#MASKED_NAME# 4",
"id": "SCRUBBED_CHILD_DEVICE_ID_4",
"next_action": {
"type": -1
},
@ -50,8 +50,8 @@
"state": 1
},
{
"alias": "Home Camera Switch",
"id": "800623145DFF1AA096363EFD161C2E661A9D8DED05",
"alias": "#MASKED_NAME# 5",
"id": "SCRUBBED_CHILD_DEVICE_ID_5",
"next_action": {
"type": -1
},
@ -59,8 +59,8 @@
"state": 1
},
{
"alias": "Home Network Switch",
"id": "800623145DFF1AA096363EFD161C2E661A9D8DED04",
"alias": "#MASKED_NAME# 6",
"id": "SCRUBBED_CHILD_DEVICE_ID_6",
"next_action": {
"type": -1
},

View File

@ -15,8 +15,8 @@
"child_num": 6,
"children": [
{
"alias": "#MASKED_NAME#",
"id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D00",
"alias": "#MASKED_NAME# 1",
"id": "SCRUBBED_CHILD_DEVICE_ID_1",
"next_action": {
"type": -1
},
@ -24,8 +24,8 @@
"state": 1
},
{
"alias": "#MASKED_NAME#",
"id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D01",
"alias": "#MASKED_NAME# 2",
"id": "SCRUBBED_CHILD_DEVICE_ID_2",
"next_action": {
"type": -1
},
@ -33,8 +33,8 @@
"state": 1
},
{
"alias": "#MASKED_NAME#",
"id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D02",
"alias": "#MASKED_NAME# 3",
"id": "SCRUBBED_CHILD_DEVICE_ID_3",
"next_action": {
"type": -1
},
@ -42,8 +42,8 @@
"state": 1
},
{
"alias": "#MASKED_NAME#",
"id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D03",
"alias": "#MASKED_NAME# 4",
"id": "SCRUBBED_CHILD_DEVICE_ID_4",
"next_action": {
"type": -1
},
@ -51,8 +51,8 @@
"state": 1
},
{
"alias": "#MASKED_NAME#",
"id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D04",
"alias": "#MASKED_NAME# 5",
"id": "SCRUBBED_CHILD_DEVICE_ID_5",
"next_action": {
"type": -1
},
@ -60,8 +60,8 @@
"state": 1
},
{
"alias": "#MASKED_NAME#",
"id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D05",
"alias": "#MASKED_NAME# 6",
"id": "SCRUBBED_CHILD_DEVICE_ID_6",
"next_action": {
"type": -1
},

View File

@ -11,12 +11,12 @@
},
"system": {
"get_sysinfo": {
"alias": "TP-LINK_Power Strip_5C33",
"alias": "#MASKED_NAME#",
"child_num": 6,
"children": [
{
"alias": "Plug 1",
"id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031900",
"alias": "#MASKED_NAME# 1",
"id": "SCRUBBED_CHILD_DEVICE_ID_1",
"next_action": {
"type": -1
},
@ -24,8 +24,8 @@
"state": 0
},
{
"alias": "Plug 2",
"id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031901",
"alias": "#MASKED_NAME# 2",
"id": "SCRUBBED_CHILD_DEVICE_ID_2",
"next_action": {
"type": -1
},
@ -33,8 +33,8 @@
"state": 0
},
{
"alias": "Plug 3",
"id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031902",
"alias": "#MASKED_NAME# 3",
"id": "SCRUBBED_CHILD_DEVICE_ID_3",
"next_action": {
"type": -1
},
@ -42,8 +42,8 @@
"state": 0
},
{
"alias": "Plug 4",
"id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031903",
"alias": "#MASKED_NAME# 4",
"id": "SCRUBBED_CHILD_DEVICE_ID_4",
"next_action": {
"type": -1
},
@ -51,8 +51,8 @@
"state": 0
},
{
"alias": "Plug 5",
"id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031904",
"alias": "#MASKED_NAME# 5",
"id": "SCRUBBED_CHILD_DEVICE_ID_5",
"next_action": {
"type": -1
},
@ -60,8 +60,8 @@
"state": 0
},
{
"alias": "Plug 6",
"id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031905",
"alias": "#MASKED_NAME# 6",
"id": "SCRUBBED_CHILD_DEVICE_ID_6",
"next_action": {
"type": -1
},

View File

@ -21,7 +21,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "Bulb3",
"alias": "#MASKED_NAME#",
"ctrl_protocols": {
"name": "Linkie",
"version": "1.0"

View File

@ -19,7 +19,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "Home Family Room Table",
"alias": "#MASKED_NAME#",
"ctrl_protocols": {
"name": "Linkie",
"version": "1.0"

View File

@ -34,11 +34,11 @@
},
"description": "Smart Wi-Fi LED Bulb with Tunable White Light",
"dev_state": "normal",
"deviceId": "801200814AD69370AC59DE5501319C051AF409C3",
"deviceId": "0000000000000000000000000000000000000000",
"disco_ver": "1.0",
"err_code": 0,
"heapsize": 290784,
"hwId": "111E35908497A05512E259BB76801E10",
"hwId": "00000000000000000000000000000000",
"hw_ver": "1.0",
"is_color": 0,
"is_dimmable": 1,
@ -52,10 +52,10 @@
"on_off": 1,
"saturation": 0
},
"mic_mac": "D80D17150474",
"mic_mac": "D80D17000000",
"mic_type": "IOT.SMARTBULB",
"model": "KL120(US)",
"oemId": "1210657CD7FBDC72895644388EEFAE8B",
"oemId": "00000000000000000000000000000000",
"preferred_state": [
{
"brightness": 100,

View File

@ -20,7 +20,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "kasa-bc01",
"alias": "#MASKED_NAME#",
"ctrl_protocols": {
"name": "Linkie",
"version": "1.0"

View File

@ -22,7 +22,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "Test bulb 6",
"alias": "#MASKED_NAME#",
"ctrl_protocols": {
"name": "Linkie",
"version": "1.0"

View File

@ -11,7 +11,7 @@
"stopConnect": 0,
"tcspInfo": "",
"tcspStatus": 1,
"username": "#MASKED_NAME#"
"username": "user@example.com"
},
"get_intl_fw_list": {
"err_code": 0,

View File

@ -21,7 +21,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "Bulb2",
"alias": "#MASKED_NAME#",
"ctrl_protocols": {
"name": "Linkie",
"version": "1.0"

View File

@ -11,7 +11,7 @@
"stopConnect": 0,
"tcspInfo": "",
"tcspStatus": 1,
"username": "#MASKED_NAME#"
"username": "user@example.com"
},
"get_intl_fw_list": {
"err_code": 0,

View File

@ -20,7 +20,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "KL135 Bulb",
"alias": "#MASKED_NAME#",
"ctrl_protocols": {
"name": "Linkie",
"version": "1.0"

View File

@ -10,7 +10,7 @@
"get_sysinfo": {
"LEF": 0,
"active_mode": "none",
"alias": "Kl400",
"alias": "#MASKED_NAME#",
"ctrl_protocols": {
"name": "Linkie",
"version": "1.0"

View File

@ -10,7 +10,7 @@
"get_sysinfo": {
"LEF": 0,
"active_mode": "none",
"alias": "Kl400",
"alias": "#MASKED_NAME#",
"ctrl_protocols": {
"name": "Linkie",
"version": "1.0"

View File

@ -10,7 +10,7 @@
"get_sysinfo": {
"LEF": 1,
"active_mode": "none",
"alias": "Kl420 test",
"alias": "#MASKED_NAME#",
"ctrl_protocols": {
"name": "Linkie",
"version": "1.0"

View File

@ -10,7 +10,7 @@
"get_sysinfo": {
"LEF": 1,
"active_mode": "none",
"alias": "Bedroom light strip",
"alias": "#MASKED_NAME#",
"ctrl_protocols": {
"name": "Linkie",
"version": "1.0"

View File

@ -23,7 +23,7 @@
"system": {
"get_sysinfo": {
"active_mode": "schedule",
"alias": "Bedroom Lightstrip",
"alias": "#MASKED_NAME#",
"ctrl_protocols": {
"name": "Linkie",
"version": "1.0"

View File

@ -11,7 +11,7 @@
"stopConnect": 0,
"tcspInfo": "",
"tcspStatus": 1,
"username": "#MASKED_NAME#"
"username": "user@example.com"
},
"get_intl_fw_list": {
"err_code": 0,

View File

@ -10,7 +10,7 @@
"get_sysinfo": {
"LEF": 1,
"active_mode": "none",
"alias": "89 strip",
"alias": "#MASKED_NAME#",
"ctrl_protocols": {
"name": "Linkie",
"version": "1.0"

View File

@ -10,7 +10,7 @@
"get_sysinfo": {
"LEF": 1,
"active_mode": "none",
"alias": "kl430 updated",
"alias": "#MASKED_NAME#",
"ctrl_protocols": {
"name": "Linkie",
"version": "1.0"

View File

@ -22,7 +22,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "Kl50",
"alias": "#MASKED_NAME#",
"ctrl_protocols": {
"name": "Linkie",
"version": "1.0"

View File

@ -32,7 +32,7 @@
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "TP-LINK_Smart Bulb_9179",
"alias": "#MASKED_NAME#",
"ctrl_protocols": {
"name": "Linkie",
"version": "1.0"
@ -60,7 +60,7 @@
"on_off": 0
},
"longitude_i": 0,
"mic_mac": "74DA88C89179",
"mic_mac": "74DA88000000",
"mic_type": "IOT.SMARTBULB",
"model": "KL60(UN)",
"oemId": "00000000000000000000000000000000",

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