From 9a11ae179dff02b862d896b91e1562ab95198c47 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 3 Dec 2024 15:38:09 +0100 Subject: [PATCH 1/4] Improve overheat reporting --- kasa/smart/modules/__init__.py | 2 ++ kasa/smart/modules/overheatprotection.py | 41 ++++++++++++++++++++++++ kasa/smart/smartdevice.py | 29 ++++++++--------- kasa/smart/smartmodule.py | 4 +-- 4 files changed, 58 insertions(+), 18 deletions(-) create mode 100644 kasa/smart/modules/overheatprotection.py diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 99820cfaf..367548019 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -24,6 +24,7 @@ from .lightstripeffect import LightStripEffect from .lighttransition import LightTransition from .motionsensor import MotionSensor +from .overheatprotection import OverheatProtection from .reportmode import ReportMode from .temperaturecontrol import TemperatureControl from .temperaturesensor import TemperatureSensor @@ -64,4 +65,5 @@ "FrostProtection", "Thermostat", "SmartLightEffect", + "OverheatProtection", ] diff --git a/kasa/smart/modules/overheatprotection.py b/kasa/smart/modules/overheatprotection.py new file mode 100644 index 000000000..1aee66373 --- /dev/null +++ b/kasa/smart/modules/overheatprotection.py @@ -0,0 +1,41 @@ +"""Overheat module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class OverheatProtection(SmartModule): + """Implementation for overheat_protection.""" + + REQUIRED_KEY_ON_PARENT = ["overheated", "overheat_status"] + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + container=self, + id="overheated", + name="Overheated", + attribute_getter="overheated", + icon="mdi:heat-wave", + type=Feature.Type.BinarySensor, + category=Feature.Category.Info, + ) + ) + + @property + def overheated(self) -> bool: + """Return True if device reports overheating.""" + if (value := self._device.sys_info.get("overheat_status")) is not None: + # Value can be normal, cooldown, or overheated. + # We report all but normal as overheated. + return value != "normal" + + return self._device.sys_info["overheated"] + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index adb4829d5..84131c2dd 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -320,6 +320,17 @@ async def _handle_modular_update_error( responses[meth] = SmartErrorCode.INTERNAL_QUERY_ERROR return responses + def _required_key_on_parent(self, mod: type[SmartModule]) -> bool: + """Return True if the device sysinfo contains a required key.""" + required_key = mod.REQUIRED_KEY_ON_PARENT + if required_key is None: + return False + + if isinstance(required_key, str): + required_key = [required_key] + + return any(self.sys_info.get(key) is not None for key in required_key) + async def _initialize_modules(self) -> None: """Initialize modules based on component negotiation response.""" from .smartmodule import SmartModule @@ -342,9 +353,8 @@ async def _initialize_modules(self) -> None: ) or mod.__name__ in child_modules_to_skip: continue required_component = cast(str, mod.REQUIRED_COMPONENT) - if required_component in self._components or ( - mod.REQUIRED_KEY_ON_PARENT - and self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None + if required_component in self._components or self._required_key_on_parent( + mod ): _LOGGER.debug( "Device %s, found required %s, adding %s to modules.", @@ -433,19 +443,6 @@ async def _initialize_features(self) -> None: ) ) - if "overheated" in self._info: - self._add_feature( - Feature( - self, - id="overheated", - name="Overheated", - attribute_getter=lambda x: x._info["overheated"], - icon="mdi:heat-wave", - type=Feature.Type.BinarySensor, - category=Feature.Category.Info, - ) - ) - # We check for the key available, and not for the property truthiness, # as the value is falsy when the device is off. if "on_time" in self._info: diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index c56970438..d7a3adb55 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -54,8 +54,8 @@ class SmartModule(Module): NAME: 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 + #: Module is initialized, if any of the given keys is available in the main sysinfo + REQUIRED_KEY_ON_PARENT: str | list[str] | None = None #: Query to execute during the main update cycle QUERY_GETTER_NAME: str From 1e6745f22c050d881774de02ddec26fd82414719 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 3 Dec 2024 15:51:10 +0100 Subject: [PATCH 2/4] Fix tests --- docs/tutorial.py | 2 +- kasa/feature.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tutorial.py b/docs/tutorial.py index 8d0a14354..f5cb9dea6 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -91,5 +91,5 @@ True >>> for feat in dev.features.values(): >>> print(f"{feat.name}: {feat.value}") -Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nReboot: \nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: None\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: None\nCheck latest firmware: \nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nDevice time: 2024-02-23 02:40:15+01:00 +Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nReboot: \nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: None\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: None\nCheck latest firmware: \nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nOverheated: False\nDevice time: 2024-02-23 02:40:15+01:00 """ diff --git a/kasa/feature.py b/kasa/feature.py index d747338da..ff19baf97 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -24,7 +24,6 @@ Signal Level (signal_level): 2 RSSI (rssi): -52 SSID (ssid): #MASKED_SSID# -Overheated (overheated): False Reboot (reboot): Brightness (brightness): 100 Cloud connection (cloud_connection): True @@ -39,6 +38,7 @@ Light preset (light_preset): Not set Smooth transition on (smooth_transition_on): 2 Smooth transition off (smooth_transition_off): 2 +Overheated (overheated): False Device time (device_time): 2024-02-23 02:40:15+01:00 To see whether a device supports a feature, check for the existence of it: From 7e68bc5e364a47af67184757911c1d473d7529c7 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 10 Dec 2024 22:52:02 +0100 Subject: [PATCH 3/4] Adjust based on review feedback --- kasa/smart/modules/contactsensor.py | 2 +- kasa/smart/modules/overheatprotection.py | 2 +- kasa/smart/smartdevice.py | 15 ++------------- kasa/smart/smartmodule.py | 4 ++-- 4 files changed, 6 insertions(+), 17 deletions(-) diff --git a/kasa/smart/modules/contactsensor.py b/kasa/smart/modules/contactsensor.py index f388b781d..d0bebb077 100644 --- a/kasa/smart/modules/contactsensor.py +++ b/kasa/smart/modules/contactsensor.py @@ -10,7 +10,7 @@ class ContactSensor(SmartModule): """Implementation of contact sensor module.""" REQUIRED_COMPONENT = None # we depend on availability of key - REQUIRED_KEY_ON_PARENT = "open" + SYSINFO_LOOKUP_KEYS = ["open"] def _initialize_features(self) -> None: """Initialize features after the initial update.""" diff --git a/kasa/smart/modules/overheatprotection.py b/kasa/smart/modules/overheatprotection.py index 1aee66373..cdaba4e82 100644 --- a/kasa/smart/modules/overheatprotection.py +++ b/kasa/smart/modules/overheatprotection.py @@ -9,7 +9,7 @@ class OverheatProtection(SmartModule): """Implementation for overheat_protection.""" - REQUIRED_KEY_ON_PARENT = ["overheated", "overheat_status"] + SYSINFO_LOOKUP_KEYS = ["overheated", "overheat_status"] def _initialize_features(self) -> None: """Initialize features after the initial update.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 84131c2dd..1e58c0cd7 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -320,17 +320,6 @@ async def _handle_modular_update_error( responses[meth] = SmartErrorCode.INTERNAL_QUERY_ERROR return responses - def _required_key_on_parent(self, mod: type[SmartModule]) -> bool: - """Return True if the device sysinfo contains a required key.""" - required_key = mod.REQUIRED_KEY_ON_PARENT - if required_key is None: - return False - - if isinstance(required_key, str): - required_key = [required_key] - - return any(self.sys_info.get(key) is not None for key in required_key) - async def _initialize_modules(self) -> None: """Initialize modules based on component negotiation response.""" from .smartmodule import SmartModule @@ -353,8 +342,8 @@ async def _initialize_modules(self) -> None: ) or mod.__name__ in child_modules_to_skip: continue required_component = cast(str, mod.REQUIRED_COMPONENT) - if required_component in self._components or self._required_key_on_parent( - mod + if required_component in self._components or any( + self.sys_info.get(key) is not None for key in mod.SYSINFO_LOOKUP_KEYS ): _LOGGER.debug( "Device %s, found required %s, adding %s to modules.", diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index d7a3adb55..ab6ae667d 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -54,8 +54,8 @@ class SmartModule(Module): NAME: str #: Module is initialized, if the given component is available REQUIRED_COMPONENT: str | None = None - #: Module is initialized, if any of the given keys is available in the main sysinfo - REQUIRED_KEY_ON_PARENT: str | list[str] | None = None + #: Module is initialized, if any of the given keys exists in the sysinfo + SYSINFO_LOOKUP_KEYS: list[str] = [] #: Query to execute during the main update cycle QUERY_GETTER_NAME: str From 7d0bbd25b26cf694d9a44e5cfa3c0249b7a7259a Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 10 Dec 2024 23:21:53 +0100 Subject: [PATCH 4/4] Add tests for initialize_modules Adds tests for REQUIRED_COMPONENT and SYSINFO_LOOKUP_KEYS initialization --- tests/smart/test_smartdevice.py | 58 +++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index c53193a32..a3ff12898 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -442,3 +442,61 @@ async def test_smart_temp_range(dev: Device): light = dev.modules.get(Module.Light) assert light assert light.valid_temperature_range + + +@device_smart +async def test_initialize_modules_sysinfo_lookup_keys( + dev: SmartDevice, mocker: MockerFixture +): + """Test that matching modules using SYSINFO_LOOKUP_KEYS are initialized correctly.""" + + class AvailableKey(SmartModule): + SYSINFO_LOOKUP_KEYS = ["device_id"] + + class NonExistingKey(SmartModule): + SYSINFO_LOOKUP_KEYS = ["this_does_not_exist"] + + # The __init_subclass__ hook in smartmodule checks the path, + # so we have to manually add these for testing. + mocker.patch.dict( + "kasa.smart.smartmodule.SmartModule.REGISTERED_MODULES", + { + AvailableKey._module_name(): AvailableKey, + NonExistingKey._module_name(): NonExistingKey, + }, + ) + + # We have an already initialized device, so we try to initialize the modules again + await dev._initialize_modules() + + assert "AvailableKey" in dev.modules + assert "NonExistingKey" not in dev.modules + + +@device_smart +async def test_initialize_modules_required_component( + dev: SmartDevice, mocker: MockerFixture +): + """Test that matching modules using REQUIRED_COMPONENT are initialized correctly.""" + + class AvailableComponent(SmartModule): + REQUIRED_COMPONENT = "device" + + class NonExistingComponent(SmartModule): + REQUIRED_COMPONENT = "this_does_not_exist" + + # The __init_subclass__ hook in smartmodule checks the path, + # so we have to manually add these for testing. + mocker.patch.dict( + "kasa.smart.smartmodule.SmartModule.REGISTERED_MODULES", + { + AvailableComponent._module_name(): AvailableComponent, + NonExistingComponent._module_name(): NonExistingComponent, + }, + ) + + # We have an already initialized device, so we try to initialize the modules again + await dev._initialize_modules() + + assert "AvailableComponent" in dev.modules + assert "NonExistingComponent" not in dev.modules