GeezerVison is showing github.com — Improve feature setter robustness (#870) · pillarsdotnet/python-kasa@fc734b1
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: kasa/feature.py +6-1Lines changed: 6 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -172,9 +172,14 @@ async def set_value(self, value): | 172 | 172 | return await getattr(container, self.attribute_setter)(value) | 173 | 173 |
| 174 | 174 | def __repr__(self): | 175 | | - value = self.value | | 175 | + try: | | 176 | + value = self.value | | 177 | + except Exception as ex: | | 178 | + return f"Unable to read value ({self.id}): {ex}" | | 179 | + | 176 | 180 | if self.precision_hint is not None and value is not None: | 177 | 181 | value = round(self.value, self.precision_hint) | | 182 | + | 178 | 183 | s = f"{self.name} ({self.id}): {value}" | 179 | 184 | if self.unit is not None: | 180 | 185 | s += f" {self.unit}" | |
Collapse file Copy file name to clipboardExpand all lines: kasa/iot/iotbulb.py +1Lines changed: 1 addition & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -233,6 +233,7 @@ async def _initialize_features(self): | 233 | 233 | attribute_setter="set_color_temp", | 234 | 234 | range_getter="valid_temperature_range", | 235 | 235 | category=Feature.Category.Primary, | | 236 | + type=Feature.Type.Number, | 236 | 237 | ) | 237 | 238 | ) | 238 | 239 |
| |
Collapse file Copy file name to clipboardExpand all lines: kasa/smart/modules/autooffmodule.py +4-4Lines changed: 4 additions & 4 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -55,9 +55,9 @@ def enabled(self) -> bool: | 55 | 55 | """Return True if enabled.""" | 56 | 56 | return self.data["enable"] | 57 | 57 |
| 58 | | - def set_enabled(self, enable: bool): | | 58 | + async def set_enabled(self, enable: bool): | 59 | 59 | """Enable/disable auto off.""" | 60 | | - return self.call( | | 60 | + return await self.call( | 61 | 61 | "set_auto_off_config", | 62 | 62 | {"enable": enable, "delay_min": self.data["delay_min"]}, | 63 | 63 | ) | @@ -67,9 +67,9 @@ def delay(self) -> int: | 67 | 67 | """Return time until auto off.""" | 68 | 68 | return self.data["delay_min"] | 69 | 69 |
| 70 | | - def set_delay(self, delay: int): | | 70 | + async def set_delay(self, delay: int): | 71 | 71 | """Set time until auto off.""" | 72 | | - return self.call( | | 72 | + return await self.call( | 73 | 73 | "set_auto_off_config", {"delay_min": delay, "enable": self.data["enable"]} | 74 | 74 | ) | 75 | 75 |
| |
Collapse file Copy file name to clipboardExpand all lines: kasa/smart/modules/colortemp.py +1Lines changed: 1 addition & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -34,6 +34,7 @@ def __init__(self, device: SmartDevice, module: str): | 34 | 34 | attribute_setter="set_color_temp", | 35 | 35 | range_getter="valid_temperature_range", | 36 | 36 | category=Feature.Category.Primary, | | 37 | + type=Feature.Type.Number, | 37 | 38 | ) | 38 | 39 | ) | 39 | 40 |
| |
Collapse file Copy file name to clipboardExpand all lines: kasa/smart/modules/lighttransitionmodule.py +2-2Lines changed: 2 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -88,9 +88,9 @@ def _turn_off(self): | 88 | 88 |
| 89 | 89 | return self.data["off_state"] | 90 | 90 |
| 91 | | - def set_enabled_v1(self, enable: bool): | | 91 | + async def set_enabled_v1(self, enable: bool): | 92 | 92 | """Enable gradual on/off.""" | 93 | | - return self.call("set_on_off_gradually_info", {"enable": enable}) | | 93 | + return await self.call("set_on_off_gradually_info", {"enable": enable}) | 94 | 94 |
| 95 | 95 | @property | 96 | 96 | def enabled_v1(self) -> bool: | |
Collapse file Copy file name to clipboardExpand all lines: kasa/smart/modules/temperature.py +1Lines changed: 1 addition & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -48,6 +48,7 @@ def __init__(self, device: SmartDevice, module: str): | 48 | 48 | attribute_getter="temperature_unit", | 49 | 49 | attribute_setter="set_temperature_unit", | 50 | 50 | type=Feature.Type.Choice, | | 51 | + choices=["celsius", "fahrenheit"], | 51 | 52 | ) | 52 | 53 | ) | 53 | 54 | # TODO: use temperature_unit for feature creation | |
Collapse file Copy file name to clipboardExpand all lines: kasa/tests/device_fixtures.py +10-8Lines changed: 10 additions & 8 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | from __future__ import annotations | 2 | 2 |
| | 3 | +from typing import AsyncGenerator | | 4 | + | 3 | 5 | import pytest | 4 | 6 |
| 5 | 7 | from kasa import ( | @@ -346,13 +348,13 @@ def device_for_fixture_name(model, protocol): | 346 | 348 | raise Exception("Unable to find type for %s", model) | 347 | 349 |
| 348 | 350 |
| 349 | | -async def _update_and_close(d): | | 351 | +async def _update_and_close(d) -> Device: | 350 | 352 | await d.update() | 351 | 353 | await d.protocol.close() | 352 | 354 | return d | 353 | 355 |
| 354 | 356 |
| 355 | | -async def _discover_update_and_close(ip, username, password): | | 357 | +async def _discover_update_and_close(ip, username, password) -> Device: | 356 | 358 | if username and password: | 357 | 359 | credentials = Credentials(username=username, password=password) | 358 | 360 | else: | @@ -361,7 +363,7 @@ async def _discover_update_and_close(ip, username, password): | 361 | 363 | return await _update_and_close(d) | 362 | 364 |
| 363 | 365 |
| 364 | | -async def get_device_for_fixture(fixture_data: FixtureInfo): | | 366 | +async def get_device_for_fixture(fixture_data: FixtureInfo) -> Device: | 365 | 367 | # if the wanted file is not an absolute path, prepend the fixtures directory | 366 | 368 |
| 367 | 369 | d = device_for_fixture_name(fixture_data.name, fixture_data.protocol)( | @@ -395,13 +397,14 @@ async def get_device_for_fixture_protocol(fixture, protocol): | 395 | 397 |
| 396 | 398 |
| 397 | 399 | @pytest.fixture(params=filter_fixtures("main devices"), ids=idgenerator) | 398 | | -async def dev(request): | | 400 | +async def dev(request) -> AsyncGenerator[Device, None]: | 399 | 401 | """Device fixture. | 400 | 402 |
| 401 | 403 | Provides a device (given --ip) or parametrized fixture for the supported devices. | 402 | 404 | The initial update is called automatically before returning the device. | 403 | 405 | """ | 404 | 406 | fixture_data: FixtureInfo = request.param | | 407 | + dev: Device | 405 | 408 |
| 406 | 409 | ip = request.config.getoption("--ip") | 407 | 410 | username = request.config.getoption("--username") | @@ -412,13 +415,12 @@ async def dev(request): | 412 | 415 | if not model: | 413 | 416 | d = await _discover_update_and_close(ip, username, password) | 414 | 417 | IP_MODEL_CACHE[ip] = model = d.model | | 418 | + | 415 | 419 | if model not in fixture_data.name: | 416 | 420 | pytest.skip(f"skipping file {fixture_data.name}") | 417 | | - dev: Device = ( | 418 | | - d if d else await _discover_update_and_close(ip, username, password) | 419 | | - ) | | 421 | + dev = d if d else await _discover_update_and_close(ip, username, password) | 420 | 422 | else: | 421 | | - dev: Device = await get_device_for_fixture(fixture_data) | | 423 | + dev = await get_device_for_fixture(fixture_data) | 422 | 424 |
| 423 | 425 | yield dev | 424 | 426 |
| |
Collapse file Copy file name to clipboardExpand all lines: kasa/tests/test_feature.py +65-3Lines changed: 65 additions & 3 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +import logging | | 2 | +import sys | | 3 | + | 1 | 4 | import pytest | 2 | | -from pytest_mock import MockFixture | | 5 | +from pytest_mock import MockerFixture | | 6 | + | | 7 | +from kasa import Device, Feature, KasaException | 3 | 8 |
| 4 | | -from kasa import Feature | | 9 | +_LOGGER = logging.getLogger(__name__) | 5 | 10 |
| 6 | 11 |
| 7 | 12 | class DummyDevice: | @@ -111,7 +116,7 @@ async def test_feature_action(mocker): | 111 | 116 | mock_call_action.assert_called() | 112 | 117 |
| 113 | 118 |
| 114 | | -async def test_feature_choice_list(dummy_feature, caplog, mocker: MockFixture): | | 119 | +async def test_feature_choice_list(dummy_feature, caplog, mocker: MockerFixture): | 115 | 120 | """Test the choice feature type.""" | 116 | 121 | dummy_feature.type = Feature.Type.Choice | 117 | 122 | dummy_feature.choices = ["first", "second"] | @@ -138,3 +143,60 @@ async def test_precision_hint(dummy_feature, precision_hint): | 138 | 143 | dummy_feature.attribute_getter = lambda x: dummy_value | 139 | 144 | assert dummy_feature.value == dummy_value | 140 | 145 | assert f"{round(dummy_value, precision_hint)} dummyunit" in repr(dummy_feature) | | 146 | + | | 147 | + | | 148 | +@pytest.mark.skipif( | | 149 | + sys.version_info < (3, 11), | | 150 | + reason="exceptiongroup requires python3.11+", | | 151 | +) | | 152 | +async def test_feature_setters(dev: Device, mocker: MockerFixture): | | 153 | + """Test that all feature setters query something.""" | | 154 | + | | 155 | + async def _test_feature(feat, query_mock): | | 156 | + if feat.attribute_setter is None: | | 157 | + return | | 158 | + | | 159 | + expecting_call = True | | 160 | + | | 161 | + if feat.type == Feature.Type.Number: | | 162 | + await feat.set_value(feat.minimum_value) | | 163 | + elif feat.type == Feature.Type.Switch: | | 164 | + await feat.set_value(True) | | 165 | + elif feat.type == Feature.Type.Action: | | 166 | + await feat.set_value("dummyvalue") | | 167 | + elif feat.type == Feature.Type.Choice: | | 168 | + await feat.set_value(feat.choices[0]) | | 169 | + elif feat.type == Feature.Type.Unknown: | | 170 | + _LOGGER.warning("Feature '%s' has no type, cannot test the setter", feat) | | 171 | + expecting_call = False | | 172 | + else: | | 173 | + raise NotImplementedError(f"set_value not implemented for {feat.type}") | | 174 | + | | 175 | + if expecting_call: | | 176 | + query_mock.assert_called() | | 177 | + | | 178 | + async def _test_features(dev): | | 179 | + exceptions = [] | | 180 | + query = mocker.patch.object(dev.protocol, "query") | | 181 | + for feat in dev.features.values(): | | 182 | + query.reset_mock() | | 183 | + try: | | 184 | + await _test_feature(feat, query) | | 185 | + # we allow our own exceptions to avoid mocking valid responses | | 186 | + except KasaException: | | 187 | + pass | | 188 | + except Exception as ex: | | 189 | + ex.add_note(f"Exception when trying to set {feat} on {dev}") | | 190 | + exceptions.append(ex) | | 191 | + | | 192 | + return exceptions | | 193 | + | | 194 | + exceptions = await _test_features(dev) | | 195 | + | | 196 | + for child in dev.children: | | 197 | + exceptions.extend(await _test_features(child)) | | 198 | + | | 199 | + if exceptions: | | 200 | + raise ExceptionGroup( | | 201 | + "Got exceptions while testing attribute_setters", exceptions | | 202 | + ) |
|