8000 Merge branch 'master' into dev-adc · python-kasa/python-kasa@d31b3ce · GitHub
[go: up one dir, main page]

Skip to content
Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit d31b3ce

Browse files
authored
Merge branch 'master' into dev-adc
2 parents 786cd93 + c5830a4 commit d31b3ce

File tree

8 files changed

+361
-41
lines changed

8 files changed

+361
-41
lines changed

devtools/dump_devinfo.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,10 @@ async def get_legacy_fixture(
425425
Call(module="smartlife.iot.lightStrip", method="get_light_details"),
426426
Call(module="smartlife.iot.LAS", method="get_config"),
427427
Call(module="smartlife.iot.LAS", method="get_current_brt"),
428+
Call(module="smartlife.iot.LAS", method="get_dark_status"),
429+
Call(module="smartlife.iot.LAS", method="get_adc_value"),
428430
Call(module="smartlife.iot.PIR", method="get_config"),
431+
Call(module="smartlife.iot. 8000 PIR", method="get_adc_value"),
429432
]
430433

431434
successes = []

kasa/module.py

Lines changed: 72 additions & 0 deletions
< A93C tr class="diff-line-row">
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,13 @@
4242

4343
import logging
4444
from abc import ABC, abstractmethod
45+
from collections.abc import Callable
46+
from functools import cache
4547
from typing import (
4648
TYPE_CHECKING,
4749
Final,
4850
TypeVar,
51+
get_type_hints,
4952
)
5053

5154
from .exceptions import KasaException
@@ -64,6 +67,10 @@
6467
ModuleT = TypeVar("ModuleT", bound="Module")
6568

6669

70+
class FeatureAttribute:
71+
"""Class for annotating attributes bound to feature."""
72+
73+
6774
class Module(ABC):
6875
"""Base class implemention for all modules.
6976
@@ -140,6 +147,14 @@ def __init__(self, device: Device, module: str) -> None:
140147
self._module = module
141148
self._module_features: dict[str, Feature] = {}
142149

150+
def has_feature(self, attribute: str | property | Callable) -> bool:
151+
"""Return True if the module attribute feature is supported."""
152+
return bool(self.get_feature(attribute))
153+
154+
def get_feature(self, attribute: str | property | Callable) -> Feature | None:
155+
"""Get Feature for a module attribute or None if not supported."""
156+
return _get_bound_feature(self, attribute)
157+
143158
@abstractmethod
144159
def query(self) -> dict:
145160
"""Query to execute during the update cycle.
@@ -183,3 +198,60 @@ def __repr__(self) -> str:
183198
f"<Module {self.__class__.__name__} ({self._module})"
184199
f" for {self._device.host}>"
185200
)
201+
202+
203+
def _is_bound_feature(attribute: property | Callable) -> bool:
204+
"""Check if an attribute is bound to a feature with FeatureAttribute."""
205+
if isinstance(attribute, property):
206+
hints = get_type_hints(attribute.fget, include_extras=True)
207+
else:
208+
hints = get_type_hints(attribute, include_extras=True)
209+
210+
if (return_hints := hints.get("return")) and hasattr(return_hints, "__metadata__"):
211+
metadata = hints["return"].__metadata__
212+
for meta in metadata:
213+
if isinstance(meta, FeatureAttribute):
214+
return True
215+
216+
return False
217+
218+
219+
@cache
220+
def _get_bound_feature(
221+
module: Module, attribute: str | property | Callable
222+
) -> Feature | None:
223+
"""Get Feature for a bound property or None if not supported."""
224+
if not isinstance(attribute, str):
225+
if isinstance(attribute, property):
226+
# Properties have __name__ in 3.13 so this could be simplified
227+
# when only 3.13 supported
228+
attribute_name = attribute.fget.__name__ # type: ignore[union-attr]
229+
else:
230+
attribute_name = attribute.__name__
231+
attribute_callable = attribute
232+
else:
233+
if TYPE_CHECKING:
234+
assert isinstance(attribute, str)
235+
attribute_name = attribute
236+
attribute_callable = getattr(module.__class__, attribute, None) # type: ignore[assignment]
237+
if not attribute_callable:
238+
raise KasaException(
239+
f"No attribute named {attribute_name} in "
240+
f"module {module.__class__.__name__}"
241+
)
242+
243+
if not _is_bound_feature(attribute_callable):
244+
raise KasaException(
245+
f"Attribute {attribute_name} of module {module.__class__.__name__}"
246+
" is not bound to a feature"
247+
)
248+
249+
check = {attribute_name, attribute_callable}
250+
for feature in module._module_features.values():
251+
if (getter := feature.attribute_getter) and getter in check:
252+
return feature
253+
254+
if (setter := feature.attribute_setter) and setter in check:
255+
return feature
256+
257+
return None

kasa/smart/modules/fan.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
from __future__ import annotations
44

5+
from typing import Annotated
6+
57
from ...feature import Feature
68
from ...interfaces.fan import Fan as FanInterface
9+
from ...module import FeatureAttribute
710
from ..smartmodule import SmartModule
811

912

@@ -46,11 +49,13 @@ def query(self) -> dict:
4649
return {}
4750

4851
@property
49-
def fan_speed_level(self) -> int:
52+
def fan_speed_level(self) -> Annotated[int, FeatureAttribute()]:
5053
"""Return fan speed level."""
5154
return 0 if self.data["device_on"] is False else self.data["fan_speed_level"]
5255

53-
async def set_fan_speed_level(self, level: int) -> dict:
56+
async def set_fan_speed_level(
57+
self, level: int
58+
) -> Annotated[dict, FeatureAttribute()]:
5459
"""Set fan speed level, 0 for off, 1-4 for on."""
5560
if level < 0 or level > 4:
5661
raise ValueError("Invalid level, should be in range 0-4.")
@@ -61,11 +66,11 @@ async def set_fan_speed_level(self, level: int) -> dict:
6166
)
6267

6368
@property
64-
def sleep_mode(self) -> bool:
69+
def sleep_mode(self) -> Annotated[bool, FeatureAttribute()]:
6570
"""Return sleep mode status."""
6671
return self.data["fan_sleep_mode_on"]
6772

68-
async def set_sleep_mode(self, on: bool) -> dict:
73+
async def set_sleep_mode(self, on: bool) -> Annotated[dict, FeatureAttribute()]:
6974
"""Set sleep mode."""
7075
return await self.call("set_device_info", {"fan_sleep_mode_on": on})
7176

tests/fixtures/ES20M(US)_1.0_1.0.11.json

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,41 @@
11
{
2+
"cnCloud": {
3+
"get_info": {
4+
"binded": 1,
5+
"cld_connection": 1,
6+
"err_code": 0,
7+
"fwDlPage": "",
8+
"fwNotifyType": -1,
9+
"illegalType": 0,
10+
"server": "n-devs.tplinkcloud.com",
11+
"stopConnect": 0,
12+
"tcspInfo": "",
13+
"tcspStatus": 1,
14+
"username": "#MASKED_NAME#"
15+
},
16+
"get_intl_fw_list": {
17+
"err_code": 0,
18+
"fw_list": []
19+
}
20+
},
21+
"schedule": {
22+
"get_next_action": {
23+
"err_code": 0,
24+
"type": -1
25+
},
26+
"get_rules": {
27+
"enable": 1,
28+
"err_code": 0,
29+
"rule_list": [],
30+
"version": 2
31+
}
32+
},
233
"smartlife.iot.LAS": {
34+
"get_adc_value": {
35+
"err_code": 0,
36+
"type": 2,
37+
"value": 0
38+
},
339
"get_config": {
440
"devs": [
541
{
@@ -47,10 +83,18 @@
4783
},
4884
"get_current_brt": {
4985
"err_code": 0,
50-
"value": 16
86+
"value": 0
87+
},
88+
"get_dark_status": {
89+
"bDark": 1,
90+
"err_code": 0
5191
}
5292
},
5393
"smartlife.iot.PIR": {
94+
"get_adc_value": {
95+
"err_code": 0,
96+
"value": 2107
97+
},
5498
"get_config": {
5599
"array": [
56100
80,
@@ -59,23 +103,32 @@
59103
0
60104
],
61105
"cold_time": 120000,
62-
"enable": 1,
106+
"enable": 0,
63107
"err_code": 0,
64108
"max_adc": 4095,
65109
"min_adc": 0,
66-
"trigger_index": 0,
110+
"trigger_index": 1,
67111
"version": "1.0"
68112
}
69113
},
70114
"smartlife.iot.dimmer": {
115+
"get_default_behavior": {
116+
"double_click": {
117+
"mode": "unknown"
118+
},
119+
"err_code": 0,
120+
"long_press": {
121+
"mode": "unknown"
122+
}
123+
},
71124
"get_dimmer_parameters": {
72125
"bulb_type": 1,
73126
"err_code": 0,
74127
"fadeOffTime": 0,
75128
"fadeOnTime": 0,
76129
"gentleOffTime": 10000,
77130
"gentleOnTime": 3000,
78-
"minThreshold": 17,
131+
"minThreshold": 5,
79132
"rampRate": 30
80133
}
81134
},
@@ -92,17 +145,17 @@
92145
"hw_ver": "1.0",
93146
"icon_hash": "",
94147
"latitude_i": 0,
95-
"led_off": 1,
148+
"led_off": 0,
96149
"longitude_i": 0,
97-
"mac": "B0:A7:B9:00:00:00",
150+
"mac": "28:87:BA:00:00:00",
98151
"mic_type": "IOT.SMARTPLUGSWITCH",
99152
"model": "ES20M(US)",
100153
"next_action": {
101154
"type": -1
102155
},
103156
"obd_src": "tplink",
104157
"oemId": "00000000000000000000000000000000",
105-
"on_time": 6,
158+
"on_time": 0,
106159
"preferred_state": [
107160
{
108161
"brightness": 100,
@@ -121,8 +174,8 @@
121174
"index": 3
122175
}
123176
],
124-
"relay_state": 1,
125-
"rssi": -40,
177+
"relay_state": 0,
178+
"rssi": -57,
126179
"status": "new",
127180
"sw_ver": "1.0.11 Build 240514 Rel.110351",
128181
"updating": 0

0 commit comments

Comments
 (0)
0