Add WallSwitch device type and autogenerate supported devices docs (#758)

This commit is contained in:
Steven B 2024-03-01 18:32:45 +00:00 committed by GitHub
parent 0306e05fb9
commit fcad0d2344
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 714 additions and 211 deletions

View File

@ -26,6 +26,9 @@ jobs:
run: | run: |
python -m pip install --upgrade pip poetry python -m pip install --upgrade pip poetry
poetry install poetry install
- name: "Check supported device md files are up to date"
run: |
poetry run pre-commit run generate-supported --all-files
- name: "Linting and code formating (ruff)" - name: "Linting and code formating (ruff)"
run: | run: |
poetry run pre-commit run ruff --all-files poetry run pre-commit run ruff --all-files
@ -47,9 +50,6 @@ jobs:
- name: "Run check-ast" - name: "Run check-ast"
run: | run: |
poetry run pre-commit run check-ast --all-files poetry run pre-commit run check-ast --all-files
- name: "Check README for supported models"
run: |
poetry run python -m devtools.check_readme_vs_fixtures
tests: tests:

View File

@ -27,3 +27,14 @@ repos:
hooks: hooks:
- id: doc8 - id: doc8
additional_dependencies: [tomli] additional_dependencies: [tomli]
- repo: local
hooks:
- id: generate-supported
name: Generate supported devices
description: This hook generates the supported device sections of README.md and SUPPORTED.md
entry: devtools/generate_supported.py
language: system # Required or pre-commit creates a new venv
verbose: true # Show output on success
types: [json]
pass_filenames: false # passing filenames causes the hook to run in batches against all-files

128
README.md
View File

@ -220,120 +220,32 @@ Note, that this works currently only on kasa-branded devices which use port 9999
## Supported devices ## Supported devices
In principle, most kasa-branded devices that are locally controllable using the official Kasa mobile app work with this library. The following devices have been tested and confirmed as working. If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `python -m devtools.dump_devinfo` to generate one).
The following lists the devices that have been manually verified to work. <!--Do not edit text inside the SUPPORTED section below -->
**If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `python -m devtools.dump_devinfo` to generate one).** <!--SUPPORTED_START-->
### Supported Kasa devices
### Plugs - **Plugs**: EP10, EP25<sup>\*</sup>, HS100<sup>\*\*</sup>, HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M<sup>\*</sup>, KP401
- **Power Strips**: EP40, HS107, HS300, KP200, KP303, KP400
- **Wall Switches**: ES20M, HS200, HS210, HS220, KP405, KS200M, KS205<sup>\*</sup>, KS220M, KS225<sup>\*</sup>, KS230
- **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB100, LB110, LB120, LB130
- **Light Strips**: KL400L5, KL420L5, KL430
* HS100 ### Supported Tapo<sup>\*</sup> devices
* HS103
* HS105
* HS107
* HS110
* KP100
* KP105
* KP115
* KP125
* KP125M [See note below](#newer-kasa-branded-devices)
* KP401
* EP10
* EP25 [See note below](#newer-kasa-branded-devices)
### Power Strips - **Plugs**: P100, P110, P125M, P135, TP15
- **Power Strips**: P300, TP25
- **Wall Switches**: S500D, S505
- **Bulbs**: L510B, L510E, L530E
- **Light Strips**: L900-10, L900-5, L920-5, L930-5
- **Hubs**: H100
* EP40 <!--SUPPORTED_END-->
* HS300 <sup>*</sup>&nbsp; Model requires authentication<br>
* KP303 <sup>**</sup> Newer versions require authentication
* KP200 (in wall)
* KP400
* KP405 (dimmer)
### Wall switches See [supported devices in our documentation](SUPPORTED.md) for more detailed information about tested hardware and software versions.
* ES20M
* HS200
* HS210
* HS220
* KS200M (partial support, no motion, no daylight detection)
* KS220M (partial support, no motion, no daylight detection)
* KS230
### Bulbs
* LB100
* LB110
* LB120
* LB130
* LB230
* KL50
* KL60
* KL110
* KL120
* KL125
* KL130
* KL135
### Light strips
* KL400L5
* KL420L5
* KL430
### Tapo branded devices
The library has recently added a limited supported for devices that carry Tapo branding.
At the moment, the following devices have been confirmed to work:
#### Plugs
* Tapo P110
* Tapo P125M
* Tapo P135 (dimming not yet supported)
* Tapo TP15
#### Bulbs
* Tapo L510B
* Tapo L510E
* Tapo L530E
#### Light strips
* Tapo L900-5
* Tapo L900-10
* Tapo L920-5
* Tapo L930-5
#### Wall switches
* Tapo S500D
* Tapo S505
#### Power strips
* Tapo P300
* Tapo TP25
#### Hubs
* Tapo H100
### Newer Kasa branded devices
Some newer hardware versions of Kasa branded devices are now using the same protocol as
Tapo branded devices. Support for these devices is currently limited as per TAPO branded
devices:
* Kasa EP25 (plug) hw_version 2.6
* Kasa KP125M (plug)
* Kasa KS205 (Wifi/Matter Wall Switch)
* Kasa KS225 (Wifi/Matter Wall Dimmer Switch)
**If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `python -m devtools.dump_devinfo` to generate one).**
## Resources ## Resources

210
SUPPORTED.md Normal file
View File

@ -0,0 +1,210 @@
# Supported devices
The following devices have been tested and confirmed as working. If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `python -m devtools.dump_devinfo` to generate one).
<!--Do not edit text inside the SUPPORTED section below -->
<!--SUPPORTED_START-->
## Kasa devices
Some newer Kasa devices require authentication. These are marked with <sup>*</sup> in the list below.
### Plugs
- **EP10**
- Hardware: 1.0 (US) / Firmware: 1.0.2
- **EP25**
- Hardware: 2.6 (US) / Firmware: 1.0.1<sup>\*</sup>
- Hardware: 2.6 (US) / Firmware: 1.0.2<sup>\*</sup>
- **HS100**
- Hardware: 1.0 (UK) / Firmware: 1.2.6
- Hardware: 4.1 (UK) / Firmware: 1.1.0<sup>\*</sup>
- Hardware: 1.0 (US) / Firmware: 1.2.5
- Hardware: 2.0 (US) / Firmware: 1.5.6
- **HS103**
- Hardware: 1.0 (US) / Firmware: 1.5.7
- Hardware: 2.1 (US) / Firmware: 1.1.2
- Hardware: 2.1 (US) / Firmware: 1.1.4
- **HS105**
- Hardware: 1.0 (US) / Firmware: 1.2.9
- Hardware: 1.0 (US) / Firmware: 1.5.6
- **HS110**
- Hardware: 1.0 (EU) / Firmware: 1.2.5
- Hardware: 2.0 (EU) / Firmware: 1.5.2
- Hardware: 4.0 (EU) / Firmware: 1.0.4
- Hardware: 1.0 (US) / Firmware: 1.0.8
- **KP100**
- Hardware: 3.0 (US) / Firmware: 1.0.1
- **KP105**
- Hardware: 1.0 (UK) / Firmware: 1.0.5
- Hardware: 1.0 (UK) / Firmware: 1.0.7
- **KP115**
- Hardware: 1.0 (EU) / Firmware: 1.0.16
- Hardware: 1.0 (US) / Firmware: 1.0.17
- Hardware: 1.0 (US) / Firmware: 1.0.21
- **KP125**
- Hardware: 1.0 (US) / Firmware: 1.0.6
- **KP125M**
- Hardware: 1.0 (US) / Firmware: 1.1.3<sup>\*</sup>
- **KP401**
- Hardware: 1.0 (US) / Firmware: 1.0.0
### Power Strips
- **EP40**
- Hardware: 1.0 (US) / Firmware: 1.0.2
- **HS107**
- Hardware: 1.0 (US) / Firmware: 1.0.8
- **HS300**
- Hardware: 1.0 (US) / Firmware: 1.0.10
- Hardware: 2.0 (US) / Firmware: 1.0.12
- Hardware: 2.0 (US) / Firmware: 1.0.3
- **KP200**
- Hardware: 3.0 (US) / Firmware: 1.0.3
- **KP303**
- Hardware: 1.0 (UK) / Firmware: 1.0.3
- Hardware: 2.0 (US) / Firmware: 1.0.3
- **KP400**
- Hardware: 1.0 (US) / Firmware: 1.0.10
- Hardware: 2.0 (US) / Firmware: 1.0.6
### Wall Switches
- **ES20M**
- Hardware: 1.0 (US) / Firmware: 1.0.8
- **HS200**
- Hardware: 1.0 (US) / Firmware: 1.1.0
- Hardware: 2.0 (US) / Firmware: 1.5.7
- Hardware: 5.0 (US) / Firmware: 1.0.2
- **HS210**
- Hardware: 1.0 (US) / Firmware: 1.5.8
- **HS220**
- Hardware: 1.0 (US) / Firmware: 1.5.7
- Hardware: 1.0 (US) / Firmware: 1.5.7
- Hardware: 2.0 (US) / Firmware: 1.0.3
- **KP405**
- Hardware: 1.0 (US) / Firmware: 1.0.5
- **KS200M**
- Hardware: 1.0 (US) / Firmware: 1.0.8
- **KS205**
- Hardware: 1.0 (US) / Firmware: 1.0.2<sup>\*</sup>
- **KS220M**
- Hardware: 1.0 (US) / Firmware: 1.0.4
- **KS225**
- Hardware: 1.0 (US) / Firmware: 1.0.2<sup>\*</sup>
- **KS230**
- Hardware: 1.0 (US) / Firmware: 1.0.14
### Bulbs
- **KL110**
- Hardware: 1.0 (US) / Firmware: 1.8.11
- **KL120**
- Hardware: 1.0 (US) / Firmware: 1.8.6
- **KL125**
- Hardware: 1.20 (US) / Firmware: 1.0.5
- Hardware: 2.0 (US) / Firmware: 1.0.7
- Hardware: 4.0 (US) / Firmware: 1.0.5
- **KL130**
- Hardware: 1.0 (EU) / Firmware: 1.8.8
- Hardware: 1.0 (US) / Firmware: 1.8.11
- **KL135**
- Hardware: 1.0 (US) / Firmware: 1.0.6
- **KL50**
- Hardware: 1.0 (US) / Firmware: 1.1.13
- **KL60**
- Hardware: 1.0 (UN) / Firmware: 1.1.4
- Hardware: 1.0 (US) / Firmware: 1.1.13
- **LB100**
- Hardware: 1.0 (US) / Firmware: 1.4.3
- **LB110**
- Hardware: 1.0 (US) / Firmware: 1.8.11
- **LB120**
- Hardware: 1.0 (US) / Firmware: 1.1.0
- **LB130**
- Hardware: 1.0 (US) / Firmware: 1.6.0
### Light Strips
- **KL400L5**
- Hardware: 1.0 (US) / Firmware: 1.0.5
- Hardware: 1.0 (US) / Firmware: 1.0.8
- **KL420L5**
- Hardware: 1.0 (US) / Firmware: 1.0.2
- **KL430**
- Hardware: 2.0 (UN) / Firmware: 1.0.8
- Hardware: 1.0 (US) / Firmware: 1.0.10
- Hardware: 2.0 (US) / Firmware: 1.0.11
- Hardware: 2.0 (US) / Firmware: 1.0.8
- Hardware: 2.0 (US) / Firmware: 1.0.9
## Tapo devices
All Tapo devices require authentication.
### Plugs
- **P100**
- Hardware: 1.0.0 / Firmware: 1.1.3
- Hardware: 1.0.0 / Firmware: 1.3.7
- **P110**
- Hardware: 1.0 (EU) / Firmware: 1.0.7
- Hardware: 1.0 (EU) / Firmware: 1.2.3
- Hardware: 1.0 (UK) / Firmware: 1.3.0
- **P125M**
- Hardware: 1.0 (US) / Firmware: 1.1.0
- **P135**
- Hardware: 1.0 (US) / Firmware: 1.0.5
- **TP15**
- Hardware: 1.0 (US) / Firmware: 1.0.3
### Power Strips
- **P300**
- Hardware: 1.0 (EU) / Firmware: 1.0.13
- Hardware: 1.0 (EU) / Firmware: 1.0.7
- **TP25**
- Hardware: 1.0 (US) / Firmware: 1.0.2
### Wall Switches
- **S500D**
- Hardware: 1.0 (US) / Firmware: 1.0.5
- **S505**
- Hardware: 1.0 (US) / Firmware: 1.0.2
### Bulbs
- **L510B**
- Hardware: 3.0 (EU) / Firmware: 1.0.5
- **L510E**
- Hardware: 3.0 (US) / Firmware: 1.0.5
- Hardware: 3.0 (US) / Firmware: 1.1.2
- **L530E**
- Hardware: 3.0 (EU) / Firmware: 1.0.6
- Hardware: 3.0 (EU) / Firmware: 1.1.0
- Hardware: 3.0 (EU) / Firmware: 1.1.6
- Hardware: 2.0 (US) / Firmware: 1.1.0
### Light Strips
- **L900-10**
- Hardware: 1.0 (EU) / Firmware: 1.0.17
- Hardware: 1.0 (US) / Firmware: 1.0.11
- **L900-5**
- Hardware: 1.0 (EU) / Firmware: 1.0.17
- Hardware: 1.0 (EU) / Firmware: 1.1.0
- **L920-5**
- Hardware: 1.0 (US) / Firmware: 1.1.0
- Hardware: 1.0 (US) / Firmware: 1.1.3
- **L930-5**
- Hardware: 1.0 (US) / Firmware: 1.1.2
### Hubs
- **H100**
- Hardware: 1.0 (EU) / Firmware: 1.2.3
- Hardware: 1.0 (EU) / Firmware: 1.5.5
<!--SUPPORTED_END-->

View File

@ -1,43 +0,0 @@
"""Script that checks if README.md is missing devices that have fixtures."""
import re
import sys
from kasa.tests.conftest import (
ALL_DEVICES,
BULBS,
DIMMERS,
LIGHT_STRIPS,
PLUGS,
STRIPS,
)
with open("README.md") as f:
readme = f.read()
typemap = {
"light strips": LIGHT_STRIPS,
"bulbs": BULBS,
"plugs": PLUGS,
"strips": STRIPS,
"dimmers": DIMMERS,
}
def _get_device_type(dev, typemap):
for typename, devs in typemap.items():
if dev in devs:
return typename
else:
return "Unknown type"
found_unlisted = False
for dev in ALL_DEVICES:
regex = rf"^\*.*\s{dev}"
match = re.search(regex, readme, re.MULTILINE)
if match is None:
print(f"{dev} not listed in {_get_device_type(dev, typemap)}")
found_unlisted = True
if found_unlisted:
sys.exit(-1)

241
devtools/generate_supported.py Executable file
View File

@ -0,0 +1,241 @@
#!/usr/bin/env python
"""Script that checks supported devices and updates README.md and SUPPORTED.md."""
import json
import os
import sys
from pathlib import Path
from string import Template
from typing import NamedTuple
from kasa.device_factory import _get_device_type_from_sys_info
from kasa.device_type import DeviceType
from kasa.smart.smartdevice import SmartDevice
class SupportedVersion(NamedTuple):
"""Supported version."""
region: str
hw: str
fw: str
auth: bool
# The order of devices in this dict drives the display order
DEVICE_TYPE_TO_PRODUCT_GROUP = {
DeviceType.Plug: "Plugs",
DeviceType.Strip: "Power Strips",
DeviceType.StripSocket: "Power Strips",
DeviceType.Dimmer: "Wall Switches",
DeviceType.WallSwitch: "Wall Switches",
DeviceType.Bulb: "Bulbs",
DeviceType.LightStrip: "Light Strips",
DeviceType.Hub: "Hubs",
DeviceType.Sensor: "Sensors",
}
SUPPORTED_FILENAME = "SUPPORTED.md"
README_FILENAME = "README.md"
IOT_FOLDER = "kasa/tests/fixtures/"
SMART_FOLDER = "kasa/tests/fixtures/smart/"
def generate_supported(args):
"""Generate the SUPPORTED.md from the fixtures."""
print_diffs = "--print-diffs" in args
running_in_ci = "CI" in os.environ
print("Generating supported devices")
if running_in_ci:
print_diffs = True
print("Detected running in CI")
supported = {"kasa": {}, "tapo": {}}
_get_iot_supported(supported)
_get_smart_supported(supported)
readme_updated = _update_supported_file(
README_FILENAME, _supported_summary(supported), print_diffs
)
supported_updated = _update_supported_file(
SUPPORTED_FILENAME, _supported_detail(supported), print_diffs
)
if not readme_updated and not supported_updated:
print("Supported devices unchanged.")
def _update_supported_file(filename, supported_text, print_diffs) -> bool:
with open(filename) as f:
contents = f.readlines()
start_index = end_index = None
for index, line in enumerate(contents):
if line == "<!--SUPPORTED_START-->\n":
start_index = index + 1
if line == "<!--SUPPORTED_END-->\n":
end_index = index
current_text = "".join(contents[start_index:end_index])
if current_text != supported_text:
print(
f"{filename} has been modified with updated "
+ "supported devices, add file to commit."
)
if print_diffs:
print("##CURRENT##")
print(current_text)
print("##NEW##")
print(supported_text)
new_contents = contents[:start_index]
end_contents = contents[end_index:]
new_contents.append(supported_text)
new_contents.extend(end_contents)
with open(filename, "w") as f:
new_contents_text = "".join(new_contents)
f.write(new_contents_text)
return True
return False
def _supported_summary(supported):
return _supported_text(
supported,
"### Supported $brand$auth devices\n\n$types\n",
"- **$type_**: $models\n",
)
def _supported_detail(supported):
return _supported_text(
supported,
"## $brand devices\n\n$preamble\n\n$types\n",
"### $type_\n\n$models\n",
"- **$model**\n$versions",
" - Hardware: $hw$region / Firmware: $fw$auth_flag\n",
)
def _supported_text(
supported, brand_template, types_template, model_template="", version_template=""
):
brandt = Template(brand_template)
typest = Template(types_template)
modelt = Template(model_template)
versst = Template(version_template)
brands = ""
version: SupportedVersion
for brand, types in supported.items():
preamble_text = (
"Some newer Kasa devices require authentication. "
+ "These are marked with <sup>*</sup> in the list below."
if brand == "kasa"
else "All Tapo devices require authentication."
)
brand_text = brand.capitalize()
brand_auth = r"<sup>\*</sup>" if brand == "tapo" else ""
types_text = ""
for supported_type, models in sorted(
# Sort by device type order in the enum
types.items(),
key=lambda st: list(DEVICE_TYPE_TO_PRODUCT_GROUP.values()).index(st[0]),
):
models_list = []
models_text = ""
for model, versions in sorted(models.items()):
auth_count = 0
versions_text = ""
for version in sorted(versions):
region_text = f" ({version.region})" if version.region else ""
auth_count += 1 if version.auth else 0
vauth_flag = (
r"<sup>\*</sup>" if version.auth and brand == "kasa" else ""
)
if version_template:
versions_text += versst.substitute(
hw=version.hw,
fw=version.fw,
region=region_text,
auth_flag=vauth_flag,
)
if brand == "kasa" and auth_count > 0:
auth_flag = (
r"<sup>\*</sup>"
if auth_count == len(versions)
else r"<sup>\*\*</sup>"
)
else:
auth_flag = ""
if model_template:
models_text += modelt.substitute(
model=model, versions=versions_text, auth_flag=auth_flag
)
else:
models_list.append(f"{model}{auth_flag}")
models_text = models_text if models_text else ", ".join(models_list)
types_text += typest.substitute(type_=supported_type, models=models_text)
brands += brandt.substitute(
brand=brand_text, types=types_text, auth=brand_auth, preamble=preamble_text
)
return brands
def _get_smart_supported(supported):
for file in Path(SMART_FOLDER).glob("*.json"):
with file.open() as f:
fixture_data = json.load(f)
model, _, region = fixture_data["discovery_result"]["device_model"].partition(
"("
)
# P100 doesn't have region HW
region = region.replace(")", "") if region else ""
device_type = fixture_data["discovery_result"]["device_type"]
_protocol, devicetype = device_type.split(".")
brand = devicetype[:4].lower()
components = [
component["id"]
for component in fixture_data["component_nego"]["component_list"]
]
dt = SmartDevice._get_device_type_from_components(components, device_type)
supported_type = DEVICE_TYPE_TO_PRODUCT_GROUP[dt]
hw_version = fixture_data["get_device_info"]["hw_ver"]
fw_version = fixture_data["get_device_info"]["fw_ver"]
fw_version = fw_version.split(" ", maxsplit=1)[0]
stype = supported[brand].setdefault(supported_type, {})
smodel = stype.setdefault(model, [])
smodel.append(
SupportedVersion(region=region, hw=hw_version, fw=fw_version, auth=True)
)
def _get_iot_supported(supported):
for file in Path(IOT_FOLDER).glob("*.json"):
with file.open() as f:
fixture_data = json.load(f)
sysinfo = fixture_data["system"]["get_sysinfo"]
dt = _get_device_type_from_sys_info(fixture_data)
supported_type = DEVICE_TYPE_TO_PRODUCT_GROUP[dt]
model, _, region = sysinfo["model"][:-1].partition("(")
auth = "discovery_result" in fixture_data
stype = supported["kasa"].setdefault(supported_type, {})
smodel = stype.setdefault(model, [])
fw = sysinfo["sw_ver"].split(" ", maxsplit=1)[0]
smodel.append(
SupportedVersion(region=region, hw=sysinfo["hw_ver"], fw=fw, auth=auth)
)
def main():
"""Entry point to module."""
generate_supported(sys.argv[1:])
if __name__ == "__main__":
generate_supported(sys.argv[1:])

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

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

View File

@ -15,3 +15,4 @@
smartdimmer smartdimmer
smartstrip smartstrip
smartlightstrip smartlightstrip
SUPPORTED

View File

@ -26,7 +26,15 @@ from kasa import (
UnsupportedDeviceError, UnsupportedDeviceError,
) )
from kasa.discover import DiscoveryResult from kasa.discover import DiscoveryResult
from kasa.iot import IotBulb, IotDevice, IotDimmer, IotLightStrip, IotPlug, IotStrip from kasa.iot import (
IotBulb,
IotDevice,
IotDimmer,
IotLightStrip,
IotPlug,
IotStrip,
IotWallSwitch,
)
from kasa.smart import SmartBulb, SmartDevice from kasa.smart import SmartBulb, SmartDevice
try: try:
@ -63,11 +71,13 @@ echo = _do_echo
TYPE_TO_CLASS = { TYPE_TO_CLASS = {
"plug": IotPlug, "plug": IotPlug,
"switch": IotWallSwitch,
"bulb": IotBulb, "bulb": IotBulb,
"dimmer": IotDimmer, "dimmer": IotDimmer,
"strip": IotStrip, "strip": IotStrip,
"lightstrip": IotLightStrip, "lightstrip": IotLightStrip,
"iot.plug": IotPlug, "iot.plug": IotPlug,
"iot.switch": IotWallSwitch,
"iot.bulb": IotBulb, "iot.bulb": IotBulb,
"iot.dimmer": IotDimmer, "iot.dimmer": IotDimmer,
"iot.strip": IotStrip, "iot.strip": IotStrip,

View File

@ -212,6 +212,11 @@ class Device(ABC):
"""Return True if the device is a plug.""" """Return True if the device is a plug."""
return self.device_type == DeviceType.Plug return self.device_type == DeviceType.Plug
@property
def is_wallswitch(self) -> bool:
"""Return True if the device is a switch."""
return self.device_type == DeviceType.WallSwitch
@property @property
def is_strip(self) -> bool: def is_strip(self) -> bool:
"""Return True if the device is a strip.""" """Return True if the device is a strip."""

View File

@ -5,9 +5,18 @@ from typing import Any, Dict, Optional, Tuple, Type
from .aestransport import AesTransport from .aestransport import AesTransport
from .device import Device from .device import Device
from .device_type import DeviceType
from .deviceconfig import DeviceConfig from .deviceconfig import DeviceConfig
from .exceptions import KasaException, UnsupportedDeviceError from .exceptions import KasaException, UnsupportedDeviceError
from .iot import IotBulb, IotDevice, IotDimmer, IotLightStrip, IotPlug, IotStrip from .iot import (
IotBulb,
IotDevice,
IotDimmer,
IotLightStrip,
IotPlug,
IotStrip,
IotWallSwitch,
)
from .iotprotocol import IotProtocol from .iotprotocol import IotProtocol
from .klaptransport import KlapTransport, KlapTransportV2 from .klaptransport import KlapTransport, KlapTransportV2
from .protocol import ( from .protocol import (
@ -105,7 +114,7 @@ async def _connect(config: DeviceConfig, protocol: BaseProtocol) -> "Device":
) )
def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[IotDevice]: def _get_device_type_from_sys_info(info: Dict[str, Any]) -> DeviceType:
"""Find SmartDevice subclass for device described by passed data.""" """Find SmartDevice subclass for device described by passed data."""
if "system" not in info or "get_sysinfo" not in info["system"]: if "system" not in info or "get_sysinfo" not in info["system"]:
raise KasaException("No 'system' or 'get_sysinfo' in response") raise KasaException("No 'system' or 'get_sysinfo' in response")
@ -116,22 +125,36 @@ def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[IotDevice]:
raise KasaException("Unable to find the device type field!") raise KasaException("Unable to find the device type field!")
if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]:
return IotDimmer return DeviceType.Dimmer
if "smartplug" in type_.lower(): if "smartplug" in type_.lower():
if "children" in sysinfo: if "children" in sysinfo:
return IotStrip return DeviceType.Strip
if (dev_name := sysinfo.get("dev_name")) and "light" in dev_name.lower():
return IotPlug return DeviceType.WallSwitch
return DeviceType.Plug
if "smartbulb" in type_.lower(): if "smartbulb" in type_.lower():
if "length" in sysinfo: # strips have length if "length" in sysinfo: # strips have length
return IotLightStrip return DeviceType.LightStrip
return IotBulb return DeviceType.Bulb
raise UnsupportedDeviceError("Unknown device type: %s" % type_) raise UnsupportedDeviceError("Unknown device type: %s" % type_)
def get_device_class_from_sys_info(sysinfo: Dict[str, Any]) -> Type[IotDevice]:
"""Find SmartDevice subclass for device described by passed data."""
TYPE_TO_CLASS = {
DeviceType.Bulb: IotBulb,
DeviceType.Plug: IotPlug,
DeviceType.Dimmer: IotDimmer,
DeviceType.Strip: IotStrip,
DeviceType.WallSwitch: IotWallSwitch,
DeviceType.LightStrip: IotLightStrip,
}
return TYPE_TO_CLASS[_get_device_type_from_sys_info(sysinfo)]
def get_device_class_from_family(device_type: str) -> Optional[Type[Device]]: def get_device_class_from_family(device_type: str) -> Optional[Type[Device]]:
"""Return the device class from the type name.""" """Return the device class from the type name."""
supported_device_types: Dict[str, Type[Device]] = { supported_device_types: Dict[str, Type[Device]] = {

View File

@ -11,6 +11,7 @@ class DeviceType(Enum):
Plug = "plug" Plug = "plug"
Bulb = "bulb" Bulb = "bulb"
Strip = "strip" Strip = "strip"
WallSwitch = "wallswitch"
StripSocket = "stripsocket" StripSocket = "stripsocket"
Dimmer = "dimmer" Dimmer = "dimmer"
LightStrip = "lightstrip" LightStrip = "lightstrip"

View File

@ -3,7 +3,7 @@ from .iotbulb import IotBulb
from .iotdevice import IotDevice from .iotdevice import IotDevice
from .iotdimmer import IotDimmer from .iotdimmer import IotDimmer
from .iotlightstrip import IotLightStrip from .iotlightstrip import IotLightStrip
from .iotplug import IotPlug from .iotplug import IotPlug, IotWallSwitch
from .iotstrip import IotStrip from .iotstrip import IotStrip
__all__ = [ __all__ = [
@ -13,4 +13,5 @@ __all__ = [
"IotStrip", "IotStrip",
"IotDimmer", "IotDimmer",
"IotLightStrip", "IotLightStrip",
"IotWallSwitch",
] ]

View File

@ -13,7 +13,7 @@ _LOGGER = logging.getLogger(__name__)
class IotPlug(IotDevice): class IotPlug(IotDevice):
r"""Representation of a TP-Link Smart Switch. r"""Representation of a TP-Link Smart Plug.
To initialize, you have to await :func:`update()` at least once. To initialize, you have to await :func:`update()` at least once.
This will allow accessing the properties using the exposed properties. This will allow accessing the properties using the exposed properties.
@ -101,3 +101,17 @@ class IotPlug(IotDevice):
def state_information(self) -> Dict[str, Any]: def state_information(self) -> Dict[str, Any]:
"""Return switch-specific state information.""" """Return switch-specific state information."""
return {} return {}
class IotWallSwitch(IotPlug):
"""Representation of a TP-Link Smart Wall Switch."""
def __init__(
self,
host: str,
*,
config: Optional[DeviceConfig] = None,
protocol: Optional[BaseProtocol] = None,
) -> None:
super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.WallSwitch

View File

@ -63,12 +63,6 @@ class SmartDevice(Device):
) )
for child_info in children for child_info in children
} }
# TODO: This may not be the best approach, but it allows distinguishing
# between power strips and hubs for the time being.
if all(child.is_plug for child in self._children.values()):
self._device_type = DeviceType.Strip
else:
self._device_type = DeviceType.Hub
@property @property
def children(self) -> Sequence["SmartDevice"]: def children(self) -> Sequence["SmartDevice"]:
@ -519,21 +513,30 @@ class SmartDevice(Device):
if self._device_type is not DeviceType.Unknown: if self._device_type is not DeviceType.Unknown:
return self._device_type return self._device_type
if self.children: self._device_type = self._get_device_type_from_components(
if "SMART.TAPOHUB" in self.sys_info["type"]: list(self._components.keys()), self._info["type"]
self._device_type = DeviceType.Hub )
else:
self._device_type = DeviceType.Strip
elif "light_strip" in self._components:
self._device_type = DeviceType.LightStrip
elif "dimmer_calibration" in self._components:
self._device_type = DeviceType.Dimmer
elif "brightness" in self._components:
self._device_type = DeviceType.Bulb
elif "PLUG" in self.sys_info["type"]:
self._device_type = DeviceType.Plug
else:
_LOGGER.warning("Unknown device type, falling back to plug")
self._device_type = DeviceType.Plug
return self._device_type return self._device_type
@staticmethod
def _get_device_type_from_components(
components: List[str], device_type: str
) -> DeviceType:
"""Find type to be displayed as a supported device category."""
if "HUB" in device_type:
return DeviceType.Hub
if "PLUG" in device_type:
if "child_device" in components:
return DeviceType.Strip
return DeviceType.Plug
if "light_strip" in components:
return DeviceType.LightStrip
if "dimmer_calibration" in components:
return DeviceType.Dimmer
if "brightness" in components:
return DeviceType.Bulb
if "SWITCH" in device_type:
return DeviceType.WallSwitch
_LOGGER.warning("Unknown device type, falling back to plug")
return DeviceType.Plug

View File

@ -7,7 +7,7 @@ from kasa import (
Device, Device,
Discover, Discover,
) )
from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip, IotWallSwitch
from kasa.smart import SmartBulb, SmartDevice from kasa.smart import SmartBulb, SmartDevice
from .fakeprotocol_iot import FakeIotProtocol from .fakeprotocol_iot import FakeIotProtocol
@ -60,15 +60,12 @@ PLUGS_IOT = {
"HS103", "HS103",
"HS105", "HS105",
"HS110", "HS110",
"HS200",
"HS210",
"EP10", "EP10",
"KP100", "KP100",
"KP105", "KP105",
"KP115", "KP115",
"KP125", "KP125",
"KP401", "KP401",
"KS200M",
} }
# P135 supports dimming, but its not currently support # P135 supports dimming, but its not currently support
# by the library # by the library
@ -77,15 +74,25 @@ PLUGS_SMART = {
"P110", "P110",
"KP125M", "KP125M",
"EP25", "EP25",
"KS205",
"P125M", "P125M",
"S505",
"TP15", "TP15",
} }
PLUGS = { PLUGS = {
*PLUGS_IOT, *PLUGS_IOT,
*PLUGS_SMART, *PLUGS_SMART,
} }
SWITCHES_IOT = {
"HS200",
"HS210",
"KS200M",
}
SWITCHES_SMART = {
"KS205",
"KS225",
"S500D",
"S505",
}
SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART}
STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"}
STRIPS_SMART = {"P300", "TP25"} STRIPS_SMART = {"P300", "TP25"}
STRIPS = {*STRIPS_IOT, *STRIPS_SMART} STRIPS = {*STRIPS_IOT, *STRIPS_SMART}
@ -105,12 +112,15 @@ WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART}
DIMMABLE = {*BULBS, *DIMMERS} DIMMABLE = {*BULBS, *DIMMERS}
ALL_DEVICES_IOT = BULBS_IOT.union(PLUGS_IOT).union(STRIPS_IOT).union(DIMMERS_IOT) ALL_DEVICES_IOT = (
BULBS_IOT.union(PLUGS_IOT).union(STRIPS_IOT).union(DIMMERS_IOT).union(SWITCHES_IOT)
)
ALL_DEVICES_SMART = ( ALL_DEVICES_SMART = (
BULBS_SMART.union(PLUGS_SMART) BULBS_SMART.union(PLUGS_SMART)
.union(STRIPS_SMART) .union(STRIPS_SMART)
.union(DIMMERS_SMART) .union(DIMMERS_SMART)
.union(HUBS_SMART) .union(HUBS_SMART)
.union(SWITCHES_SMART)
) )
ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART)
@ -160,7 +170,14 @@ no_emeter_iot = parametrize(
) )
bulb = parametrize("bulbs", model_filter=BULBS, protocol_filter={"SMART", "IOT"}) bulb = parametrize("bulbs", model_filter=BULBS, protocol_filter={"SMART", "IOT"})
plug = parametrize("plugs", model_filter=PLUGS, protocol_filter={"IOT"}) plug = parametrize("plugs", model_filter=PLUGS, protocol_filter={"IOT", "SMART"})
plug_iot = parametrize("plugs iot", model_filter=PLUGS, protocol_filter={"IOT"})
wallswitch = parametrize(
"wall switches", model_filter=SWITCHES, protocol_filter={"IOT", "SMART"}
)
wallswitch_iot = parametrize(
"wall switches iot", model_filter=SWITCHES, protocol_filter={"IOT"}
)
strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"}) strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"})
dimmer = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) dimmer = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"})
lightstrip = parametrize( lightstrip = parametrize(
@ -213,6 +230,9 @@ strip_smart = parametrize(
plug_smart = parametrize( plug_smart = parametrize(
"plug devices smart", model_filter=PLUGS_SMART, protocol_filter={"SMART"} "plug devices smart", model_filter=PLUGS_SMART, protocol_filter={"SMART"}
) )
switch_smart = parametrize(
"switch devices smart", model_filter=SWITCHES_SMART, protocol_filter={"SMART"}
)
bulb_smart = parametrize( bulb_smart = parametrize(
"bulb devices smart", model_filter=BULBS_SMART, protocol_filter={"SMART"} "bulb devices smart", model_filter=BULBS_SMART, protocol_filter={"SMART"}
) )
@ -239,8 +259,8 @@ def check_categories():
+ strip.args[1] + strip.args[1]
+ plug.args[1] + plug.args[1]
+ bulb.args[1] + bulb.args[1]
+ wallswitch.args[1]
+ lightstrip.args[1] + lightstrip.args[1]
+ plug_smart.args[1]
+ bulb_smart.args[1] + bulb_smart.args[1]
+ dimmers_smart.args[1] + dimmers_smart.args[1]
+ hubs_smart.args[1] + hubs_smart.args[1]
@ -263,6 +283,9 @@ def device_for_fixture_name(model, protocol):
for d in PLUGS_SMART: for d in PLUGS_SMART:
if d in model: if d in model:
return SmartDevice return SmartDevice
for d in SWITCHES_SMART:
if d in model:
return SmartDevice
for d in BULBS_SMART: for d in BULBS_SMART:
if d in model: if d in model:
return SmartBulb return SmartBulb
@ -283,6 +306,9 @@ def device_for_fixture_name(model, protocol):
for d in PLUGS_IOT: for d in PLUGS_IOT:
if d in model: if d in model:
return IotPlug return IotPlug
for d in SWITCHES_IOT:
if d in model:
return IotWallSwitch
# Light strips are recognized also as bulbs, so this has to go first # Light strips are recognized also as bulbs, so this has to go first
for d in BULBS_IOT_LIGHT_STRIP: for d in BULBS_IOT_LIGHT_STRIP:
@ -325,6 +351,13 @@ async def get_device_for_fixture(fixture_data: FixtureInfo):
d.protocol = FakeSmartProtocol(fixture_data.data, fixture_data.name) d.protocol = FakeSmartProtocol(fixture_data.data, fixture_data.name)
else: else:
d.protocol = FakeIotProtocol(fixture_data.data) d.protocol = FakeIotProtocol(fixture_data.data)
if "discovery_result" in fixture_data.data:
discovery_data = {"result": fixture_data.data["discovery_result"]}
else:
discovery_data = {
"system": {"get_sysinfo": fixture_data.data["system"]["get_sysinfo"]}
}
d.update_from_discover_info(discovery_data)
await _update_and_close(d) await _update_and_close(d)
return d return d

View File

@ -10,7 +10,11 @@ from kasa import (
Discover, Discover,
KasaException, KasaException,
) )
from kasa.device_factory import connect, get_protocol from kasa.device_factory import (
_get_device_type_from_sys_info,
connect,
get_protocol,
)
from kasa.deviceconfig import ( from kasa.deviceconfig import (
ConnectionType, ConnectionType,
DeviceConfig, DeviceConfig,
@ -18,6 +22,7 @@ from kasa.deviceconfig import (
EncryptType, EncryptType,
) )
from kasa.discover import DiscoveryResult from kasa.discover import DiscoveryResult
from kasa.smart.smartdevice import SmartDevice
def _get_connection_type_device_class(discovery_info): def _get_connection_type_device_class(discovery_info):
@ -146,3 +151,16 @@ async def test_connect_http_client(discovery_data, mocker):
assert dev.protocol._transport._http_client.client == http_client assert dev.protocol._transport._http_client.client == http_client
await dev.disconnect() await dev.disconnect()
await http_client.close() await http_client.close()
async def test_device_types(dev: Device):
await dev.update()
if isinstance(dev, SmartDevice):
device_type = dev._discovery_info["result"]["device_type"]
res = SmartDevice._get_device_type_from_components(
dev._components.keys(), device_type
)
else:
res = _get_device_type_from_sys_info(dev._last_update)
assert dev.device_type == res

View File

@ -29,8 +29,9 @@ from .conftest import (
dimmer, dimmer,
lightstrip, lightstrip,
new_discovery, new_discovery,
plug, plug_iot,
strip_iot, strip_iot,
wallswitch_iot,
) )
UNSUPPORTED = { UNSUPPORTED = {
@ -55,7 +56,14 @@ UNSUPPORTED = {
} }
@plug @wallswitch_iot
async def test_type_detection_switch(dev: Device):
d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_wallswitch
assert d.device_type == DeviceType.WallSwitch
@plug_iot
async def test_type_detection_plug(dev: Device): async def test_type_detection_plug(dev: Device):
d = Discover._get_device_class(dev._last_update)("localhost") d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_plug assert d.is_plug

View File

@ -1,6 +1,6 @@
from kasa import DeviceType from kasa import DeviceType
from .conftest import plug, plug_smart from .conftest import plug_iot, plug_smart, switch_smart, wallswitch_iot
from .test_smartdevice import SYSINFO_SCHEMA from .test_smartdevice import SYSINFO_SCHEMA
# these schemas should go to the mainlib as # these schemas should go to the mainlib as
@ -8,7 +8,7 @@ from .test_smartdevice import SYSINFO_SCHEMA
# as well as to check that faked devices are operating properly. # as well as to check that faked devices are operating properly.
@plug @plug_iot
async def test_plug_sysinfo(dev): async def test_plug_sysinfo(dev):
assert dev.sys_info is not None assert dev.sys_info is not None
SYSINFO_SCHEMA(dev.sys_info) SYSINFO_SCHEMA(dev.sys_info)
@ -19,8 +19,34 @@ async def test_plug_sysinfo(dev):
assert dev.is_plug or dev.is_strip assert dev.is_plug or dev.is_strip
@plug @wallswitch_iot
async def test_led(dev): async def test_switch_sysinfo(dev):
assert dev.sys_info is not None
SYSINFO_SCHEMA(dev.sys_info)
assert dev.model is not None
assert dev.device_type == DeviceType.WallSwitch
assert dev.is_wallswitch
@plug_iot
async def test_plug_led(dev):
original = dev.led
await dev.set_led(False)
await dev.update()
assert not dev.led
await dev.set_led(True)
await dev.update()
assert dev.led
await dev.set_led(original)
@wallswitch_iot
async def test_switch_led(dev):
original = dev.led original = dev.led
await dev.set_led(False) await dev.set_led(False)
@ -40,3 +66,13 @@ async def test_plug_device_info(dev):
assert dev.model is not None assert dev.model is not None
assert dev.device_type == DeviceType.Plug or dev.device_type == DeviceType.Strip assert dev.device_type == DeviceType.Plug or dev.device_type == DeviceType.Strip
@switch_smart
async def test_switch_device_info(dev):
assert dev._info is not None
assert dev.model is not None
assert (
dev.device_type == DeviceType.WallSwitch or dev.device_type == DeviceType.Dimmer
)

30
poetry.lock generated
View File

@ -1726,20 +1726,22 @@ test = ["cython", "html5lib", "pytest (>=4.6)", "typed_ast"]
[[package]] [[package]]
name = "sphinx-rtd-theme" name = "sphinx-rtd-theme"
version = "0.5.1" version = "2.0.0"
description = "Read the Docs theme for Sphinx" description = "Read the Docs theme for Sphinx"
optional = true optional = true
python-versions = "*" python-versions = ">=3.6"
files = [ files = [
{file = "sphinx_rtd_theme-0.5.1-py2.py3-none-any.whl", hash = "sha256:fa6bebd5ab9a73da8e102509a86f3fcc36dec04a0b52ea80e5a033b2aba00113"}, {file = "sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586"},
{file = "sphinx_rtd_theme-0.5.1.tar.gz", hash = "sha256:eda689eda0c7301a80cf122dad28b1861e5605cbf455558f3775e1e8200e83a5"}, {file = "sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b"},
] ]
[package.dependencies] [package.dependencies]
sphinx = "*" docutils = "<0.21"
sphinx = ">=5,<8"
sphinxcontrib-jquery = ">=4,<5"
[package.extras] [package.extras]
dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client"] dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"]
[[package]] [[package]]
name = "sphinxcontrib-applehelp" name = "sphinxcontrib-applehelp"
@ -1786,6 +1788,20 @@ files = [
lint = ["docutils-stubs", "flake8", "mypy"] lint = ["docutils-stubs", "flake8", "mypy"]
test = ["html5lib", "pytest"] test = ["html5lib", "pytest"]
[[package]]
name = "sphinxcontrib-jquery"
version = "4.1"
description = "Extension to include jQuery on newer Sphinx releases"
optional = true
python-versions = ">=2.7"
files = [
{file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"},
{file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"},
]
[package.dependencies]
Sphinx = ">=1.8"
[[package]] [[package]]
name = "sphinxcontrib-jsmath" name = "sphinxcontrib-jsmath"
version = "1.0.1" version = "1.0.1"
@ -2130,4 +2146,4 @@ speedups = ["kasa-crypt", "orjson"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.8" python-versions = "^3.8"
content-hash = "aadbdc97219e5282f614f834c1318bbf8430fe769030f0a262e1922c5d7523b8" content-hash = "fecc8870f967cc6da9d6e1fde0e9a9acd261d28c4ba57476250d17234dc2c876"

View File

@ -35,7 +35,7 @@ kasa-crypt = { "version" = ">=0.2.0", optional = true }
# required only for docs # required only for docs
sphinx = { version = "^5", optional = true } sphinx = { version = "^5", optional = true }
sphinx_rtd_theme = { version = "^0", optional = true } sphinx_rtd_theme = { version = "^2", optional = true }
sphinxcontrib-programoutput = { version = "^0", optional = true } sphinxcontrib-programoutput = { version = "^0", optional = true }
myst-parser = { version = "*", optional = true } myst-parser = { version = "*", optional = true }
docutils = { version = ">=0.17", optional = true } docutils = { version = ">=0.17", optional = true }