From fb0934fea3c29f0c8dbbec79edde5a4dd608ca24 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 27 Apr 2024 15:55:54 +0200 Subject: [PATCH 1/5] Add support for contact sensor (T110) --- kasa/smart/modules/__init__.py | 2 ++ kasa/smart/modules/contact.py | 39 ++++++++++++++++++++++++++++++++++ kasa/smart/smartchilddevice.py | 1 + 3 files changed, 42 insertions(+) create mode 100644 kasa/smart/modules/contact.py diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 647220791..b0956b80e 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -8,6 +8,7 @@ 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 @@ "ColorTemperatureModule", "ColorModule", "WaterleakSensor", + "ContactSensor", "FrostProtectionModule", ] diff --git a/kasa/smart/modules/contact.py b/kasa/smart/modules/contact.py new file mode 100644 index 000000000..b51793c5d --- /dev/null +++ b/kasa/smart/modules/contact.py @@ -0,0 +1,39 @@ +"""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 = "contact" + + def __init__(self, device: SmartDevice, module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "Open", + container=self, + attribute_getter="is_open", + icon="mdi:door", + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # Brightness is contained in the main device info response. + return {} + + @property + def is_open(self): + """Return True if the contact sensor is open.""" + return self._device.sys_info["open"] diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 7f747b846..d841d2d9d 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -49,6 +49,7 @@ def device_type(self) -> DeviceType: """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, From b787d32a7f50831a6b1dc2d8e39b5547eb45a245 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 7 May 2024 03:02:55 +0200 Subject: [PATCH 2/5] Add T110 fixture --- .../smart/child/T110(EU)_1.0_1.8.0.json | 526 ++++++++++++++++++ 1 file changed, 526 insertions(+) create mode 100644 kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json diff --git a/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json b/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json new file mode 100644 index 000000000..acf7ae889 --- /dev/null +++ b/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json @@ -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 + } +} From cd297c65e39d49e40f89f3d6542f63a84cace3e7 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 7 May 2024 03:07:19 +0200 Subject: [PATCH 3/5] Allow module to depend on a key on parent, add tests for contact sensor --- kasa/smart/modules/contact.py | 6 ++++-- kasa/smart/smartdevice.py | 5 ++++- kasa/smart/smartmodule.py | 13 ++++++++---- kasa/tests/device_fixtures.py | 2 +- kasa/tests/smart/modules/test_contact.py | 27 ++++++++++++++++++++++++ 5 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 kasa/tests/smart/modules/test_contact.py diff --git a/kasa/smart/modules/contact.py b/kasa/smart/modules/contact.py index b51793c5d..2bada6272 100644 --- a/kasa/smart/modules/contact.py +++ b/kasa/smart/modules/contact.py @@ -14,14 +14,16 @@ class ContactSensor(SmartModule): """Implementation of contact sensor module.""" - REQUIRED_COMPONENT = "contact" + 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, - "Open", + id="is_open", + name="Is Open", # TODO: this hack is required until #904 gets merged container=self, attribute_getter="is_open", icon="mdi:door", diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 898133878..68b08902e 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -210,7 +210,10 @@ async def _initialize_modules(self): 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, diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 9169b752a..0e7af6090 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -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 @@ def __init__(self, device: SmartDevice, module: str): 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 @@ -92,7 +95,9 @@ def data(self): @property def supported_version(self) -> int: """Return version supported by the device.""" - return self._device._components[self.REQUIRED_COMPONENT] + 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. diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 92a86b6f0..826465e5e 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -109,7 +109,7 @@ } 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} diff --git a/kasa/tests/smart/modules/test_contact.py b/kasa/tests/smart/modules/test_contact.py new file mode 100644 index 000000000..5acc2b942 --- /dev/null +++ b/kasa/tests/smart/modules/test_contact.py @@ -0,0 +1,27 @@ +import pytest + +from kasa.smart.modules import ContactSensor +from kasa.tests.device_fixtures import parametrize + +contact = parametrize( + "has humidity", model_filter="T110", protocol_filter={"SMART.CHILD"} +) + + +@contact +@pytest.mark.parametrize( + "feature, type", + [ + ("is_open", bool), + ], +) +async def test_contact_features(dev, feature, type): + """Test that features are registered and work as expected.""" + contact: ContactSensor = dev.modules["ContactSensor"] + + prop = getattr(contact, feature) + assert isinstance(prop, type) + + feat = contact._module_features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) From 59bc522a40474ef9d683c6773675a02fe1ea5bab Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 7 May 2024 15:53:58 +0200 Subject: [PATCH 4/5] Improvements based on code review --- kasa/smart/modules/contact.py | 5 +++-- kasa/smart/smartmodule.py | 5 ++++- kasa/tests/smart/modules/test_contact.py | 8 +++++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/kasa/smart/modules/contact.py b/kasa/smart/modules/contact.py index 2bada6272..7932a081d 100644 --- a/kasa/smart/modules/contact.py +++ b/kasa/smart/modules/contact.py @@ -23,16 +23,17 @@ def __init__(self, device: SmartDevice, module: str): Feature( device, id="is_open", - name="Is Open", # TODO: this hack is required until #904 gets merged + 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.""" - # Brightness is contained in the main device info response. return {} @property diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 0e7af6090..e78f43933 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -94,7 +94,10 @@ def data(self): @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 diff --git a/kasa/tests/smart/modules/test_contact.py b/kasa/tests/smart/modules/test_contact.py index 5acc2b942..fc3375450 100644 --- a/kasa/tests/smart/modules/test_contact.py +++ b/kasa/tests/smart/modules/test_contact.py @@ -1,10 +1,11 @@ import pytest +from kasa import SmartDevice from kasa.smart.modules import ContactSensor from kasa.tests.device_fixtures import parametrize contact = parametrize( - "has humidity", model_filter="T110", protocol_filter={"SMART.CHILD"} + "is contact sensor", model_filter="T110", protocol_filter={"SMART.CHILD"} ) @@ -15,9 +16,10 @@ ("is_open", bool), ], ) -async def test_contact_features(dev, feature, type): +async def test_contact_features(dev: SmartDevice, feature, type): """Test that features are registered and work as expected.""" - contact: ContactSensor = dev.modules["ContactSensor"] + contact = dev.get_module(ContactSensor) + assert contact is not None prop = getattr(contact, feature) assert isinstance(prop, type) From 2cead3c94e2e3a34d5e85f6b95338c2a665c1766 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 7 May 2024 16:28:28 +0200 Subject: [PATCH 5/5] Update supported devices --- README.md | 2 +- SUPPORTED.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 85fc6982b..42ecaaa8a 100644 --- a/README.md +++ b/README.md @@ -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\*\*\***: T300, T310, T315 +- **Hub-Connected Devices\*\*\***: T110, T300, T310, T315 \*   Model requires authentication
diff --git a/SUPPORTED.md b/SUPPORTED.md index e52697635..bb35116d0 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -212,6 +212,8 @@ All Tapo devices require authentication.
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**