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)