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 }