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_START-->
### Supported Kasa devices ### 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 - **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] - **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 - **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** - **EP10**
- Hardware: 1.0 (US) / Firmware: 1.0.2 - Hardware: 1.0 (US) / Firmware: 1.0.2
- **EP25** - **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.1[^1]
- Hardware: 2.6 (US) / Firmware: 1.0.2[^1] - Hardware: 2.6 (US) / Firmware: 1.0.2[^1]
- **HS100** - **HS100**

View File

@@ -423,6 +423,7 @@ async def get_legacy_fixture(
Call(module="smartlife.iot.LAS", method="get_adc_value"), Call(module="smartlife.iot.LAS", method="get_adc_value"),
Call(module="smartlife.iot.PIR", method="get_config"), Call(module="smartlife.iot.PIR", method="get_config"),
Call(module="smartlife.iot.PIR", method="get_adc_value"), Call(module="smartlife.iot.PIR", method="get_adc_value"),
Call(module="smartlife.iot.homekit", method="setup_info_get"),
] ]
successes = [] successes = []

View File

@@ -47,6 +47,7 @@ Devices support different functionality that are exposed via
>>> for module_name in dev.modules: >>> for module_name in dev.modules:
>>> print(module_name) >>> print(module_name)
homekit
Energy Energy
schedule schedule
usage usage
@@ -85,6 +86,7 @@ state
rssi rssi
on_since on_since
reboot reboot
...
current_consumption current_consumption
consumption_today consumption_today
consumption_this_month consumption_this_month

View File

@@ -31,7 +31,7 @@ from ..module import Module
from ..modulemapping import ModuleMapping, ModuleName from ..modulemapping import ModuleMapping, ModuleName
from ..protocols import BaseProtocol from ..protocols import BaseProtocol
from .iotmodule import IotModule, merge from .iotmodule import IotModule, merge
from .modules import Emeter from .modules import Emeter, HomeKit
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -330,6 +330,8 @@ class IotDevice(Device):
async def _initialize_modules(self) -> None: async def _initialize_modules(self) -> None:
"""Initialize modules not added in init.""" """Initialize modules not added in init."""
self.add_module(Module.IotHomeKit, HomeKit(self, "smartlife.iot.homekit"))
if self.has_emeter: if self.has_emeter:
_LOGGER.debug( _LOGGER.debug(
"The device has emeter, querying its information along sysinfo" "The device has emeter, querying its information along sysinfo"

View File

@@ -21,7 +21,17 @@ from .iotdevice import (
) )
from .iotmodule import IotModule from .iotmodule import IotModule
from .iotplug import IotPlug 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__) _LOGGER = logging.getLogger(__name__)
@@ -109,6 +119,7 @@ class IotStrip(IotDevice):
self.add_module(Module.IotCountdown, Countdown(self, "countdown")) self.add_module(Module.IotCountdown, Countdown(self, "countdown"))
self.add_module(Module.Led, Led(self, "system")) self.add_module(Module.Led, Led(self, "system"))
self.add_module(Module.IotCloud, Cloud(self, "cnCloud")) self.add_module(Module.IotCloud, Cloud(self, "cnCloud"))
self.add_module(Module.IotHomeKit, HomeKit(self, "smartlife.iot.homekit"))
if self.has_emeter: if self.has_emeter:
_LOGGER.debug( _LOGGER.debug(
"The device has emeter, querying its information along sysinfo" "The device has emeter, querying its information along sysinfo"

View File

@@ -6,6 +6,7 @@ from .cloud import Cloud
from .countdown import Countdown from .countdown import Countdown
from .dimmer import Dimmer from .dimmer import Dimmer
from .emeter import Emeter from .emeter import Emeter
from .homekit import HomeKit
from .led import Led from .led import Led
from .light import Light from .light import Light
from .lighteffect import LightEffect from .lighteffect import LightEffect
@@ -34,4 +35,5 @@ __all__ = [
"Schedule", "Schedule",
"Time", "Time",
"Usage", "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") IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule")
IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage") IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage")
IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud") IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud")
IotHomeKit: Final[ModuleName[iot.HomeKit]] = ModuleName("homekit")
# SMART only Modules # SMART only Modules
AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff") AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff")

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import re
from collections.abc import Callable from collections.abc import Callable
from pprint import pformat as pf from pprint import pformat as pf
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -54,6 +55,8 @@ REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"oemId": lambda x: "REDACTED_" + x[9::], "oemId": lambda x: "REDACTED_" + x[9::],
"username": lambda _: "user@example.com", # cnCloud "username": lambda _: "user@example.com", # cnCloud
"hwId": lambda x: "REDACTED_" + x[9::], "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", "HS105",
"HS110", "HS110",
"EP10", "EP10",
"EP25",
"KP100", "KP100",
"KP105", "KP105",
"KP115", "KP115",
@@ -139,7 +140,7 @@ THERMOSTATS_SMART = {"KE100"}
VACUUMS_SMART = {"RV20"} 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_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M"}
WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} 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