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 <tpr@iki.fi>
This commit is contained in:
ZeliardM
2025-10-10 10:45:16 -04:00
committed by GitHub
parent 2b881cfd7b
commit 0f6fc9c4d1
13 changed files with 221 additions and 5 deletions

View File

@@ -186,7 +186,7 @@ The following devices have been tested and confirmed as working. If your device
<!--SUPPORTED_START-->
### 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

View File

@@ -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**

View File

@@ -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 = []

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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",
]

View File

@@ -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,
)
)

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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}

View File

@@ -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
}
}
}

View File

@@ -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