From 0f6fc9c4d1f96bde0e0bc7e15437877b0bbfca2d Mon Sep 17 00:00:00 2001 From: ZeliardM <140266236+ZeliardM@users.noreply.github.com> Date: Fri, 10 Oct 2025 10:45:16 -0400 Subject: [PATCH] Add bare bones homekit module for iot devices (#1566) Based on the existing smart HomeKit module, this has been tested with a real device that supports this module. --------- Co-authored-by: Teemu Rytilahti --- README.md | 2 +- SUPPORTED.md | 1 + devtools/dump_devinfo.py | 3 +- kasa/device.py | 2 + kasa/iot/iotdevice.py | 4 +- kasa/iot/iotstrip.py | 13 +++- kasa/iot/modules/__init__.py | 2 + kasa/iot/modules/homekit.py | 53 ++++++++++++++ kasa/module.py | 1 + kasa/protocols/iotprotocol.py | 3 + tests/device_fixtures.py | 3 +- tests/fixtures/iot/EP25(US)_1.0_1.0.14.json | 80 +++++++++++++++++++++ tests/iot/modules/test_homekit.py | 59 +++++++++++++++ 13 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 kasa/iot/modules/homekit.py create mode 100644 tests/fixtures/iot/EP25(US)_1.0_1.0.14.json create mode 100644 tests/iot/modules/test_homekit.py diff --git a/README.md b/README.md index 39c2d409..aca294e2 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ The following devices have been tested and confirmed as working. If your device ### Supported Kasa devices -- **Plugs**: EP10, EP25[^1], HS100[^2], HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M[^1], KP401 +- **Plugs**: EP10, EP25[^2], HS100[^2], HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M[^1], KP401 - **Power Strips**: EP40, EP40M[^1], HS107, HS300, KP200, KP303, KP400 - **Wall Switches**: ES20M, HS200[^2], HS210, HS220[^2], KP405, KS200, KS200M, KS205[^1], KS220, KS220M, KS225[^1], KS230, KS240[^1] - **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB100, LB110 diff --git a/SUPPORTED.md b/SUPPORTED.md index 9abbefed..8f4c1c10 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -20,6 +20,7 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th - **EP10** - Hardware: 1.0 (US) / Firmware: 1.0.2 - **EP25** + - Hardware: 1.0 (US) / Firmware: 1.0.14 - Hardware: 2.6 (US) / Firmware: 1.0.1[^1] - Hardware: 2.6 (US) / Firmware: 1.0.2[^1] - **HS100** diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index bbe1e813..027b28bd 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -80,7 +80,7 @@ _LOGGER = logging.getLogger(__name__) def _wrap_redactors(redactors: dict[str, Callable[[Any], Any] | None]): - """Wrap the redactors for dump_devinfo. + """Wrap the redactors for dump_devinfo. Will replace all partial REDACT_ values with zeros. If the data item is already scrubbed by dump_devinfo will leave as-is. @@ -423,6 +423,7 @@ async def get_legacy_fixture( Call(module="smartlife.iot.LAS", method="get_adc_value"), Call(module="smartlife.iot.PIR", method="get_config"), Call(module="smartlife.iot.PIR", method="get_adc_value"), + Call(module="smartlife.iot.homekit", method="setup_info_get"), ] successes = [] diff --git a/kasa/device.py b/kasa/device.py index c4ea41e2..45763db3 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -47,6 +47,7 @@ Devices support different functionality that are exposed via >>> for module_name in dev.modules: >>> print(module_name) +homekit Energy schedule usage @@ -85,6 +86,7 @@ state rssi on_since reboot +... current_consumption consumption_today consumption_this_month diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index d1de7f9e..36aba3e5 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -31,7 +31,7 @@ from ..module import Module from ..modulemapping import ModuleMapping, ModuleName from ..protocols import BaseProtocol from .iotmodule import IotModule, merge -from .modules import Emeter +from .modules import Emeter, HomeKit _LOGGER = logging.getLogger(__name__) @@ -330,6 +330,8 @@ class IotDevice(Device): async def _initialize_modules(self) -> None: """Initialize modules not added in init.""" + self.add_module(Module.IotHomeKit, HomeKit(self, "smartlife.iot.homekit")) + if self.has_emeter: _LOGGER.debug( "The device has emeter, querying its information along sysinfo" diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index a63b3e17..64ddf0a0 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -21,7 +21,17 @@ from .iotdevice import ( ) from .iotmodule import IotModule from .iotplug import IotPlug -from .modules import Antitheft, Cloud, Countdown, Emeter, Led, Schedule, Time, Usage +from .modules import ( + Antitheft, + Cloud, + Countdown, + Emeter, + HomeKit, + Led, + Schedule, + Time, + Usage, +) _LOGGER = logging.getLogger(__name__) @@ -109,6 +119,7 @@ class IotStrip(IotDevice): self.add_module(Module.IotCountdown, Countdown(self, "countdown")) self.add_module(Module.Led, Led(self, "system")) self.add_module(Module.IotCloud, Cloud(self, "cnCloud")) + self.add_module(Module.IotHomeKit, HomeKit(self, "smartlife.iot.homekit")) if self.has_emeter: _LOGGER.debug( "The device has emeter, querying its information along sysinfo" diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index ef7adf68..207839e4 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -6,6 +6,7 @@ from .cloud import Cloud from .countdown import Countdown from .dimmer import Dimmer from .emeter import Emeter +from .homekit import HomeKit from .led import Led from .light import Light from .lighteffect import LightEffect @@ -34,4 +35,5 @@ __all__ = [ "Schedule", "Time", "Usage", + "HomeKit", ] diff --git a/kasa/iot/modules/homekit.py b/kasa/iot/modules/homekit.py new file mode 100644 index 00000000..935f87f9 --- /dev/null +++ b/kasa/iot/modules/homekit.py @@ -0,0 +1,53 @@ +"""Implementation of HomeKit module for IOT devices that natively support HomeKit.""" + +from __future__ import annotations + +from typing import Any + +from ...feature import Feature +from ..iotmodule import IotModule + + +class HomeKit(IotModule): + """Implementation of HomeKit module for IOT devices.""" + + def query(self) -> dict: + """Request HomeKit setup info.""" + return {"smartlife.iot.homekit": {"setup_info_get": {}}} + + @property + def info(self) -> dict[str, Any]: + """Return the HomeKit setup info.""" + # Only return info if the module has data + if self._module not in self._device._last_update: + return {} + return self.data.get("setup_info_get", {}) + + @property + def setup_code(self) -> str: + """Return the HomeKit setup code.""" + return self.info["setup_code"] + + @property + def setup_payload(self) -> str: + """Return the HomeKit setup payload.""" + return self.info["setup_payload"] + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + # Only add features if the device supports the module + data = self._device._last_update.get(self._module, {}) + if not data or "setup_info_get" not in data: + return + + self._add_feature( + Feature( + self._device, + container=self, + id="homekit_setup_code", + name="HomeKit setup code", + attribute_getter="setup_code", + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) diff --git a/kasa/module.py b/kasa/module.py index afd1e127..57ee321f 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -116,6 +116,7 @@ class Module(ABC): IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule") IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage") IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud") + IotHomeKit: Final[ModuleName[iot.HomeKit]] = ModuleName("homekit") # SMART only Modules AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff") diff --git a/kasa/protocols/iotprotocol.py b/kasa/protocols/iotprotocol.py index 7ca02e0c..8d85733e 100755 --- a/kasa/protocols/iotprotocol.py +++ b/kasa/protocols/iotprotocol.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import logging +import re from collections.abc import Callable from pprint import pformat as pf from typing import TYPE_CHECKING, Any @@ -54,6 +55,8 @@ REDACTORS: dict[str, Callable[[Any], Any] | None] = { "oemId": lambda x: "REDACTED_" + x[9::], "username": lambda _: "user@example.com", # cnCloud "hwId": lambda x: "REDACTED_" + x[9::], + "setup_code": lambda x: re.sub(r"\w", "0", x), # homekit + "setup_payload": lambda x: re.sub(r"\w", "0", x), # homekit } diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 8b53446f..a6cef66c 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -73,6 +73,7 @@ PLUGS_IOT = { "HS105", "HS110", "EP10", + "EP25", "KP100", "KP105", "KP115", @@ -139,7 +140,7 @@ THERMOSTATS_SMART = {"KE100"} VACUUMS_SMART = {"RV20"} -WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} +WITH_EMETER_IOT = {"EP25", "HS110", "HS300", "KP115", "KP125", *BULBS_IOT} WITH_EMETER_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M"} WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} diff --git a/tests/fixtures/iot/EP25(US)_1.0_1.0.14.json b/tests/fixtures/iot/EP25(US)_1.0_1.0.14.json new file mode 100644 index 00000000..9b592fbb --- /dev/null +++ b/tests/fixtures/iot/EP25(US)_1.0_1.0.14.json @@ -0,0 +1,80 @@ +{ + "cnCloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "user@example.com" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 0, + "total_wh": 50, + "voltage_mv": 125403 + } + }, + "schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 0, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "smartlife.iot.homekit": { + "setup_info_get": { + "err_code": 0, + "setup_code": "000-00-000", + "setup_payload": "0-00://0000000000000" + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Plug Mini", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "AC:15:A2:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "EP25(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "apple", + "oemId": "00000000000000000000000000000000", + "on_time": 495961, + "relay_state": 1, + "rssi": -37, + "status": "configured", + "sw_ver": "1.0.14 Build 240424 Rel.094105", + "updating": 0 + } + } +} diff --git a/tests/iot/modules/test_homekit.py b/tests/iot/modules/test_homekit.py new file mode 100644 index 00000000..29436785 --- /dev/null +++ b/tests/iot/modules/test_homekit.py @@ -0,0 +1,59 @@ +from unittest.mock import PropertyMock, patch + +import pytest + +from kasa import Module +from kasa.iot import IotDevice +from kasa.iot.modules.homekit import HomeKit + +from ...device_fixtures import device_iot + + +@device_iot +def test_homekit_getters(dev: IotDevice): + # HomeKit can be present on any IOT device + if Module.IotHomeKit not in dev.modules: + pytest.skip("HomeKit module not present on this device") + homekit: HomeKit = dev.modules[Module.IotHomeKit] + info = homekit.info + if not info: + pytest.skip("No HomeKit data present for this fixture") + assert "setup_code" in info + assert "setup_payload" in info + assert "err_code" in info + # Check that the setup_code and setup_payload are strings + assert isinstance(info["setup_code"], str) + assert isinstance(info["setup_payload"], str) + assert isinstance(info["err_code"], int) + # Check that the HomeKit module properties match + assert info["setup_code"] == homekit.setup_code + assert info["setup_payload"] == homekit.setup_payload + + +@device_iot +def test_homekit_feature(dev: IotDevice): + if Module.IotHomeKit not in dev.modules: + pytest.skip("HomeKit module not present on this device") + homekit: HomeKit = dev.modules[Module.IotHomeKit] + if not homekit.info: + pytest.skip("No HomeKit data present for this device") + feature = homekit._all_features.get("homekit_setup_code") + assert feature is not None + assert isinstance(feature.attribute_getter, str) + value = getattr(homekit, feature.attribute_getter) + assert value == homekit.setup_code + + +@device_iot +def test_initialize_features_skips_when_no_data(dev: IotDevice): + if Module.IotHomeKit not in dev.modules: + pytest.skip("HomeKit module not present on this device") + homekit: HomeKit = dev.modules[Module.IotHomeKit] + if "homekit_setup_code" in homekit._all_features: + pytest.skip("HomeKit feature already present on this device") + # Patch .data so it looks like no homekit data is present + with patch.object(HomeKit, "data", new_callable=PropertyMock) as mock_data: + mock_data.return_value = {} + homekit._initialize_features() + # Since there was no data, no features should be added + assert "homekit_setup_code" not in homekit._all_features