Add support for contact sensor (T110) (#877)

Initial support for T110 contact sensor & T110 fixture by courtesy of @ngaertner.
This commit is contained in:
Teemu R 2024-05-07 20:58:18 +02:00 committed by GitHub
parent 7f98acd477
commit 353e84438c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 621 additions and 8 deletions

View File

@ -242,7 +242,7 @@ The following devices have been tested and confirmed as working. If your device
- **Bulbs**: L510B, L510E, L530E
- **Light Strips**: L900-10, L900-5, L920-5, L930-5
- **Hubs**: H100
- **Hub-Connected Devices<sup>\*\*\*</sup>**: T300, T310, T315
- **Hub-Connected Devices<sup>\*\*\*</sup>**: T110, T300, T310, T315
<!--SUPPORTED_END-->
<sup>\*</sup>&nbsp;&nbsp; Model requires authentication<br>

View File

@ -214,6 +214,8 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
### Hub-Connected Devices
- **T110**
- Hardware: 1.0 (EU) / Firmware: 1.8.0
- **T300**
- Hardware: 1.0 (EU) / Firmware: 1.7.0
- **T310**

View File

@ -8,6 +8,7 @@ from .childdevicemodule import ChildDeviceModule
from .cloudmodule import CloudModule
from .colormodule import ColorModule
from .colortemp import ColorTemperatureModule
from .contact import ContactSensor
from .devicemodule import DeviceModule
from .energymodule import EnergyModule
from .fanmodule import FanModule
@ -45,5 +46,6 @@ __all__ = [
"ColorTemperatureModule",
"ColorModule",
"WaterleakSensor",
"ContactSensor",
"FrostProtectionModule",
]

View File

@ -0,0 +1,42 @@
"""Implementation of contact sensor module."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ...feature import Feature
from ..smartmodule import SmartModule
if TYPE_CHECKING:
from ..smartdevice import SmartDevice
class ContactSensor(SmartModule):
"""Implementation of contact sensor module."""
REQUIRED_COMPONENT = None # we depend on availability of key
REQUIRED_KEY_ON_PARENT = "open"
def __init__(self, device: SmartDevice, module: str):
super().__init__(device, module)
self._add_feature(
Feature(
device,
id="is_open",
name="Open",
container=self,
attribute_getter="is_open",
icon="mdi:door",
category=Feature.Category.Primary,
type=Feature.Type.BinarySensor,
)
)
def query(self) -> dict:
"""Query to execute during the update cycle."""
return {}
@property
def is_open(self):
"""Return True if the contact sensor is open."""
return self._device.sys_info["open"]

View File

@ -49,6 +49,7 @@ class SmartChildDevice(SmartDevice):
"""Return child device type."""
child_device_map = {
"plug.powerstrip.sub-plug": DeviceType.Plug,
"subg.trigger.contact-sensor": DeviceType.Sensor,
"subg.trigger.temp-hmdt-sensor": DeviceType.Sensor,
"subg.trigger.water-leak-sensor": DeviceType.Sensor,
"kasa.switch.outlet.sub-fan": DeviceType.Fan,

View File

@ -210,7 +210,10 @@ class SmartDevice(Bulb, Fan, Device):
skip_parent_only_modules and mod in WALL_SWITCH_PARENT_ONLY_MODULES
) or mod.__name__ in child_modules_to_skip:
continue
if mod.REQUIRED_COMPONENT in self._components:
if (
mod.REQUIRED_COMPONENT in self._components
or self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None
):
_LOGGER.debug(
"Found required %s, adding %s to modules.",
mod.REQUIRED_COMPONENT,

View File

@ -18,8 +18,13 @@ class SmartModule(Module):
"""Base class for SMART modules."""
NAME: str
REQUIRED_COMPONENT: str
#: Module is initialized, if the given component is available
REQUIRED_COMPONENT: str | None = None
#: Module is initialized, if the given key available in the main sysinfo
REQUIRED_KEY_ON_PARENT: str | None = None
#: Query to execute during the main update cycle
QUERY_GETTER_NAME: str
REGISTERED_MODULES: dict[str, type[SmartModule]] = {}
def __init__(self, device: SmartDevice, module: str):
@ -27,8 +32,6 @@ class SmartModule(Module):
super().__init__(device, module)
def __init_subclass__(cls, **kwargs):
assert cls.REQUIRED_COMPONENT is not None # noqa: S101
name = getattr(cls, "NAME", cls.__name__)
_LOGGER.debug("Registering %s" % cls)
cls.REGISTERED_MODULES[name] = cls
@ -91,8 +94,13 @@ class SmartModule(Module):
@property
def supported_version(self) -> int:
"""Return version supported by the device."""
"""Return version supported by the device.
If the module has no required component, this will return -1.
"""
if self.REQUIRED_COMPONENT is not None:
return self._device._components[self.REQUIRED_COMPONENT]
return -1
async def _check_supported(self) -> bool:
"""Additional check to see if the module is supported by the device.

View File

@ -109,7 +109,7 @@ DIMMERS = {
}
HUBS_SMART = {"H100", "KH100"}
SENSORS_SMART = {"T310", "T315", "T300"}
SENSORS_SMART = {"T310", "T315", "T300", "T110"}
THERMOSTATS_SMART = {"KE100"}
WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT}

View File

@ -0,0 +1,526 @@
{
"component_nego": {
"component_list": [
{
"id": "device",
"ver_code": 2
},
{
"id": "quick_setup",
"ver_code": 3
},
{
"id": "trigger_log",
"ver_code": 1
},
{
"id": "time",
"ver_code": 1
},
{
"id": "device_local_time",
"ver_code": 1
},
{
"id": "account",
"ver_code": 1
},
{
"id": "synchronize",
"ver_code": 1
},
{
"id": "cloud_connect",
"ver_code": 1
},
{
"id": "iot_cloud",
"ver_code": 1
},
{
"id": "firmware",
"ver_code": 1
},
{
"id": "localSmart",
"ver_code": 1
},
{
"id": "battery_detect",
"ver_code": 1
}
]
},
"get_connect_cloud_state": {
"status": 0
},
"get_device_info": {
"at_low_battery": false,
"avatar": "sensor_t110",
"bind_count": 1,
"category": "subg.trigger.contact-sensor",
"device_id": "SCRUBBED_CHILD_DEVICE_ID_2",
"fw_ver": "1.8.0 Build 220728 Rel.160024",
"hw_id": "00000000000000000000000000000000",
"hw_ver": "1.0",
"jamming_rssi": -113,
"jamming_signal_level": 1,
"lastOnboardingTimestamp": 1714661626,
"mac": "E4FAC4000000",
"model": "T110",
"nickname": "I01BU0tFRF9OQU1FIw==",
"oem_id": "00000000000000000000000000000000",
"open": false,
"parent_device_id": "0000000000000000000000000000000000000000",
"region": "Europe/Berlin",
"report_interval": 16,
"rssi": -54,
"signal_level": 3,
"specs": "EU",
"status": "online",
"status_follow_edge": false,
"type": "SMART.TAPOSENSOR"
},
"get_fw_download_state": {
"cloud_cache_seconds": 1,
"download_progress": 30,
"reboot_time": 5,
"status": 4,
"upgrade_time": 5
},
"get_latest_fw": {
"fw_ver": "1.9.0 Build 230704 Rel.154531",
"hw_id": "00000000000000000000000000000000",
"need_to_upgrade": true,
"oem_id": "00000000000000000000000000000000",
"release_date": "2023-10-30",
"release_note": "Modifications and Bug Fixes:\n1. Reduced power consumption.\n2. Fixed some minor bugs.",
"type": 2
},
"get_temp_humidity_records": {
"local_time": 1714681046,
"past24h_humidity": [
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000
],
"past24h_humidity_exception": [
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000
],
"past24h_temp": [
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000
],
"past24h_temp_exception": [
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000,
-1000
],
"temp_unit": "celsius"
},
"get_trigger_logs": {
"logs": [
{
"event": "close",
"eventId": "8140289c-c66b-bdd6-63b9-542299442299",
"id": 4,
"timestamp": 1714661714
},
{
"event": "open",
"eventId": "fb4e1439-2f2c-a5e1-c35a-9e7c0d35a1e3",
"id": 3,
"timestamp": 1714661710
},
{
"event": "close",
"eventId": "ddee7733-1180-48ac-56a3-512018048ac5",
"id": 2,
"timestamp": 1714661657
},
{
"event": "open",
"eventId": "ab80951f-da38-49f9-21c5-bf025c7b606d",
"id": 1,
"timestamp": 1714661638
}
],
"start_id": 4,
"sum": 4
}
}

View File

@ -0,0 +1,29 @@
import pytest
from kasa import SmartDevice
from kasa.smart.modules import ContactSensor
from kasa.tests.device_fixtures import parametrize
contact = parametrize(
"is contact sensor", model_filter="T110", protocol_filter={"SMART.CHILD"}
)
@contact
@pytest.mark.parametrize(
"feature, type",
[
("is_open", bool),
],
)
async def test_contact_features(dev: SmartDevice, feature, type):
"""Test that features are registered and work as expected."""
contact = dev.get_module(ContactSensor)
assert contact is not None
prop = getattr(contact, feature)
assert isinstance(prop, type)
feat = contact._module_features[feature]
assert feat.value == prop
assert isinstance(feat.value, type)