From fcad0d2344deab5cd92d80f2d4be49fd7dab873c Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 1 Mar 2024 18:32:45 +0000 Subject: [PATCH] Add WallSwitch device type and autogenerate supported devices docs (#758) --- .github/workflows/ci.yml | 6 +- .pre-commit-config.yaml | 11 ++ README.md | 128 +++----------- SUPPORTED.md | 210 +++++++++++++++++++++++ devtools/check_readme_vs_fixtures.py | 43 ----- devtools/generate_supported.py | 241 +++++++++++++++++++++++++++ docs/source/SUPPORTED.md | 3 + docs/source/index.rst | 1 + kasa/cli.py | 12 +- kasa/device.py | 5 + kasa/device_factory.py | 39 ++++- kasa/device_type.py | 1 + kasa/iot/__init__.py | 3 +- kasa/iot/iotplug.py | 16 +- kasa/smart/smartdevice.py | 47 +++--- kasa/tests/device_fixtures.py | 51 +++++- kasa/tests/test_device_factory.py | 20 ++- kasa/tests/test_discovery.py | 12 +- kasa/tests/test_plug.py | 44 ++++- poetry.lock | 30 +++- pyproject.toml | 2 +- 21 files changed, 714 insertions(+), 211 deletions(-) create mode 100644 SUPPORTED.md delete mode 100644 devtools/check_readme_vs_fixtures.py create mode 100755 devtools/generate_supported.py create mode 100644 docs/source/SUPPORTED.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 779f6b19..110d452e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,9 @@ jobs: run: | python -m pip install --upgrade pip poetry 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)" run: | poetry run pre-commit run ruff --all-files @@ -47,9 +50,6 @@ jobs: - name: "Run check-ast" run: | 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: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4bbfd8c5..4d1f0a4c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,3 +27,14 @@ repos: hooks: - id: doc8 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 diff --git a/README.md b/README.md index 4b45c822..7ffda4c7 100644 --- a/README.md +++ b/README.md @@ -220,120 +220,32 @@ Note, that this works currently only on kasa-branded devices which use port 9999 ## 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. -**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 Kasa devices -### Plugs +- **Plugs**: EP10, EP25\*, HS100\*\*, HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M\*, KP401 +- **Power Strips**: EP40, HS107, HS300, KP200, KP303, KP400 +- **Wall Switches**: ES20M, HS200, HS210, HS220, KP405, KS200M, KS205\*, KS220M, KS225\*, KS230 +- **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB100, LB110, LB120, LB130 +- **Light Strips**: KL400L5, KL420L5, KL430 -* HS100 -* 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) +### Supported Tapo\* 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 -* HS300 -* KP303 -* KP200 (in wall) -* KP400 -* KP405 (dimmer) + +*  Model requires authentication
+** Newer versions require authentication -### Wall switches - -* 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).** +See [supported devices in our documentation](SUPPORTED.md) for more detailed information about tested hardware and software versions. ## Resources diff --git a/SUPPORTED.md b/SUPPORTED.md new file mode 100644 index 00000000..9a740d6a --- /dev/null +++ b/SUPPORTED.md @@ -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). + + + +## Kasa devices + +Some newer Kasa devices require authentication. These are marked with * in the list below. + +### Plugs + +- **EP10** + - Hardware: 1.0 (US) / Firmware: 1.0.2 +- **EP25** + - Hardware: 2.6 (US) / Firmware: 1.0.1\* + - Hardware: 2.6 (US) / Firmware: 1.0.2\* +- **HS100** + - Hardware: 1.0 (UK) / Firmware: 1.2.6 + - Hardware: 4.1 (UK) / Firmware: 1.1.0\* + - 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\* +- **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\* +- **KS220M** + - Hardware: 1.0 (US) / Firmware: 1.0.4 +- **KS225** + - Hardware: 1.0 (US) / Firmware: 1.0.2\* +- **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 + + + diff --git a/devtools/check_readme_vs_fixtures.py b/devtools/check_readme_vs_fixtures.py deleted file mode 100644 index 88663621..00000000 --- a/devtools/check_readme_vs_fixtures.py +++ /dev/null @@ -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) diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py new file mode 100755 index 00000000..85dc3992 --- /dev/null +++ b/devtools/generate_supported.py @@ -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 == "\n": + start_index = index + 1 + if line == "\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 * in the list below." + if brand == "kasa" + else "All Tapo devices require authentication." + ) + brand_text = brand.capitalize() + brand_auth = r"\*" 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"\*" 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"\*" + if auth_count == len(versions) + else r"\*\*" + ) + 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:]) diff --git a/docs/source/SUPPORTED.md b/docs/source/SUPPORTED.md new file mode 100644 index 00000000..3ebfbeb2 --- /dev/null +++ b/docs/source/SUPPORTED.md @@ -0,0 +1,3 @@ +```{include} ../../SUPPORTED.md +:relative-docs: doc/source +``` diff --git a/docs/source/index.rst b/docs/source/index.rst index 346c53d0..9dc648a9 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -15,3 +15,4 @@ smartdimmer smartstrip smartlightstrip + SUPPORTED diff --git a/kasa/cli.py b/kasa/cli.py index 83980ec2..78553ebf 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -26,7 +26,15 @@ from kasa import ( UnsupportedDeviceError, ) 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 try: @@ -63,11 +71,13 @@ echo = _do_echo TYPE_TO_CLASS = { "plug": IotPlug, + "switch": IotWallSwitch, "bulb": IotBulb, "dimmer": IotDimmer, "strip": IotStrip, "lightstrip": IotLightStrip, "iot.plug": IotPlug, + "iot.switch": IotWallSwitch, "iot.bulb": IotBulb, "iot.dimmer": IotDimmer, "iot.strip": IotStrip, diff --git a/kasa/device.py b/kasa/device.py index 72967ee2..cebec582 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -212,6 +212,11 @@ class Device(ABC): """Return True if the device is a 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 def is_strip(self) -> bool: """Return True if the device is a strip.""" diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 2e8ba0c9..d35df09c 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -5,9 +5,18 @@ from typing import Any, Dict, Optional, Tuple, Type from .aestransport import AesTransport from .device import Device +from .device_type import DeviceType from .deviceconfig import DeviceConfig 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 .klaptransport import KlapTransport, KlapTransportV2 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.""" if "system" not in info or "get_sysinfo" not in info["system"]: 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!") if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: - return IotDimmer + return DeviceType.Dimmer if "smartplug" in type_.lower(): if "children" in sysinfo: - return IotStrip - - return IotPlug + return DeviceType.Strip + if (dev_name := sysinfo.get("dev_name")) and "light" in dev_name.lower(): + return DeviceType.WallSwitch + return DeviceType.Plug if "smartbulb" in type_.lower(): if "length" in sysinfo: # strips have length - return IotLightStrip + return DeviceType.LightStrip - return IotBulb + return DeviceType.Bulb 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]]: """Return the device class from the type name.""" supported_device_types: Dict[str, Type[Device]] = { diff --git a/kasa/device_type.py b/kasa/device_type.py index a44efffa..80a81644 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -11,6 +11,7 @@ class DeviceType(Enum): Plug = "plug" Bulb = "bulb" Strip = "strip" + WallSwitch = "wallswitch" StripSocket = "stripsocket" Dimmer = "dimmer" LightStrip = "lightstrip" diff --git a/kasa/iot/__init__.py b/kasa/iot/__init__.py index 2ee03d69..e1e4b576 100644 --- a/kasa/iot/__init__.py +++ b/kasa/iot/__init__.py @@ -3,7 +3,7 @@ from .iotbulb import IotBulb from .iotdevice import IotDevice from .iotdimmer import IotDimmer from .iotlightstrip import IotLightStrip -from .iotplug import IotPlug +from .iotplug import IotPlug, IotWallSwitch from .iotstrip import IotStrip __all__ = [ @@ -13,4 +13,5 @@ __all__ = [ "IotStrip", "IotDimmer", "IotLightStrip", + "IotWallSwitch", ] diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index e408bb3c..3f776b98 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -13,7 +13,7 @@ _LOGGER = logging.getLogger(__name__) 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. This will allow accessing the properties using the exposed properties. @@ -101,3 +101,17 @@ class IotPlug(IotDevice): def state_information(self) -> Dict[str, Any]: """Return switch-specific state information.""" 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 diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 66db2c58..8b0236c3 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -63,12 +63,6 @@ class SmartDevice(Device): ) 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 def children(self) -> Sequence["SmartDevice"]: @@ -519,21 +513,30 @@ class SmartDevice(Device): if self._device_type is not DeviceType.Unknown: return self._device_type - if self.children: - if "SMART.TAPOHUB" in self.sys_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 + self._device_type = self._get_device_type_from_components( + list(self._components.keys()), self._info["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 diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index e4f513ff..73d171d2 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -7,7 +7,7 @@ from kasa import ( Device, 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 .fakeprotocol_iot import FakeIotProtocol @@ -60,15 +60,12 @@ PLUGS_IOT = { "HS103", "HS105", "HS110", - "HS200", - "HS210", "EP10", "KP100", "KP105", "KP115", "KP125", "KP401", - "KS200M", } # P135 supports dimming, but its not currently support # by the library @@ -77,15 +74,25 @@ PLUGS_SMART = { "P110", "KP125M", "EP25", - "KS205", "P125M", - "S505", "TP15", } PLUGS = { *PLUGS_IOT, *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_SMART = {"P300", "TP25"} STRIPS = {*STRIPS_IOT, *STRIPS_SMART} @@ -105,12 +112,15 @@ WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} 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 = ( BULBS_SMART.union(PLUGS_SMART) .union(STRIPS_SMART) .union(DIMMERS_SMART) .union(HUBS_SMART) + .union(SWITCHES_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"}) -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"}) dimmer = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) lightstrip = parametrize( @@ -213,6 +230,9 @@ strip_smart = parametrize( plug_smart = parametrize( "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 devices smart", model_filter=BULBS_SMART, protocol_filter={"SMART"} ) @@ -239,8 +259,8 @@ def check_categories(): + strip.args[1] + plug.args[1] + bulb.args[1] + + wallswitch.args[1] + lightstrip.args[1] - + plug_smart.args[1] + bulb_smart.args[1] + dimmers_smart.args[1] + hubs_smart.args[1] @@ -263,6 +283,9 @@ def device_for_fixture_name(model, protocol): for d in PLUGS_SMART: if d in model: return SmartDevice + for d in SWITCHES_SMART: + if d in model: + return SmartDevice for d in BULBS_SMART: if d in model: return SmartBulb @@ -283,6 +306,9 @@ def device_for_fixture_name(model, protocol): for d in PLUGS_IOT: if d in model: 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 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) else: 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) return d diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index 1519ca5f..dc514485 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -10,7 +10,11 @@ from kasa import ( Discover, 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 ( ConnectionType, DeviceConfig, @@ -18,6 +22,7 @@ from kasa.deviceconfig import ( EncryptType, ) from kasa.discover import DiscoveryResult +from kasa.smart.smartdevice import SmartDevice 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 await dev.disconnect() 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 diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 897d91d8..eb039144 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -29,8 +29,9 @@ from .conftest import ( dimmer, lightstrip, new_discovery, - plug, + plug_iot, strip_iot, + wallswitch_iot, ) 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): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_plug diff --git a/kasa/tests/test_plug.py b/kasa/tests/test_plug.py index 64c420f9..9ccf3d04 100644 --- a/kasa/tests/test_plug.py +++ b/kasa/tests/test_plug.py @@ -1,6 +1,6 @@ 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 # 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. -@plug +@plug_iot async def test_plug_sysinfo(dev): assert dev.sys_info is not None SYSINFO_SCHEMA(dev.sys_info) @@ -19,8 +19,34 @@ async def test_plug_sysinfo(dev): assert dev.is_plug or dev.is_strip -@plug -async def test_led(dev): +@wallswitch_iot +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 await dev.set_led(False) @@ -40,3 +66,13 @@ async def test_plug_device_info(dev): assert dev.model is not None 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 + ) diff --git a/poetry.lock b/poetry.lock index 6195a6c5..eafa0b29 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1726,20 +1726,22 @@ test = ["cython", "html5lib", "pytest (>=4.6)", "typed_ast"] [[package]] name = "sphinx-rtd-theme" -version = "0.5.1" +version = "2.0.0" description = "Read the Docs theme for Sphinx" optional = true -python-versions = "*" +python-versions = ">=3.6" files = [ - {file = "sphinx_rtd_theme-0.5.1-py2.py3-none-any.whl", hash = "sha256:fa6bebd5ab9a73da8e102509a86f3fcc36dec04a0b52ea80e5a033b2aba00113"}, - {file = "sphinx_rtd_theme-0.5.1.tar.gz", hash = "sha256:eda689eda0c7301a80cf122dad28b1861e5605cbf455558f3775e1e8200e83a5"}, + {file = "sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586"}, + {file = "sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b"}, ] [package.dependencies] -sphinx = "*" +docutils = "<0.21" +sphinx = ">=5,<8" +sphinxcontrib-jquery = ">=4,<5" [package.extras] -dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client"] +dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"] [[package]] name = "sphinxcontrib-applehelp" @@ -1786,6 +1788,20 @@ files = [ lint = ["docutils-stubs", "flake8", "mypy"] 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]] name = "sphinxcontrib-jsmath" version = "1.0.1" @@ -2130,4 +2146,4 @@ speedups = ["kasa-crypt", "orjson"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "aadbdc97219e5282f614f834c1318bbf8430fe769030f0a262e1922c5d7523b8" +content-hash = "fecc8870f967cc6da9d6e1fde0e9a9acd261d28c4ba57476250d17234dc2c876" diff --git a/pyproject.toml b/pyproject.toml index a35f4b90..f3fa470e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ kasa-crypt = { "version" = ">=0.2.0", optional = true } # required only for docs 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 } myst-parser = { version = "*", optional = true } docutils = { version = ">=0.17", optional = true }