diff --git a/README.md b/README.md
index 85fc6982..42ecaaa8 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 451efe68..f3c505e4 100644
--- a/SUPPORTED.md
+++ b/SUPPORTED.md
@@ -214,6 +214,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**
diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py
index 64722079..b0956b80 100644
--- a/kasa/smart/modules/__init__.py
+++ b/kasa/smart/modules/__init__.py
@@ -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",
]
diff --git a/kasa/smart/modules/contact.py b/kasa/smart/modules/contact.py
new file mode 100644
index 00000000..7932a081
--- /dev/null
+++ b/kasa/smart/modules/contact.py
@@ -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"]
diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py
index 7f747b84..d841d2d9 100644
--- a/kasa/smart/smartchilddevice.py
+++ b/kasa/smart/smartchilddevice.py
@@ -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,
diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py
index 89813387..68b08902 100644
--- a/kasa/smart/smartdevice.py
+++ b/kasa/smart/smartdevice.py
@@ -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,
diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py
index 9169b752..e78f4393 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 @@ 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 self._device._components[self.REQUIRED_COMPONENT]
+ """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.
diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py
index 92a86b6f..826465e5 100644
--- a/kasa/tests/device_fixtures.py
+++ b/kasa/tests/device_fixtures.py
@@ -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}
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 00000000..acf7ae88
--- /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
+ }
+}
diff --git a/kasa/tests/smart/modules/test_contact.py b/kasa/tests/smart/modules/test_contact.py
new file mode 100644
index 00000000..fc337545
--- /dev/null
+++ b/kasa/tests/smart/modules/test_contact.py
@@ -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)