Skip to content

Commit eb0047b

Browse files
committed
Add ADC Value to PIR Enabled Switches
- Add: ADC value reporting to PIR enabled switches, so that it may be used in polling automations. - Add: ADC trigger state value reporting, for simpler automations. - Add: Ability for features to use custom value parsers.
1 parent 5fe75ca commit eb0047b

File tree

8 files changed

+272
-28
lines changed

8 files changed

+272
-28
lines changed

devtools/dump_devinfo.py

+3
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,10 @@ async def get_legacy_fixture(protocol, *, discovery_info):
401401
),
402402
Call(module="smartlife.iot.LAS", method="get_config"),
403403
Call(module="smartlife.iot.LAS", method="get_current_brt"),
404+
Call(module="smartlife.iot.LAS", method="get_dark_status"),
405+
Call(module="smartlife.iot.LAS", method="get_adc_value"),
404406
Call(module="smartlife.iot.PIR", method="get_config"),
407+
Call(module="smartlife.iot.PIR", method="get_adc_value"),
405408
]
406409

407410
successes = []

kasa/cli/feature.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ async def feature(
127127
echo(f"{feat.name} ({name}): {feat.value}{unit}")
128128
return feat.value
129129

130-
value = ast.literal_eval(value)
130+
value = feat.parse_value(value, ast.literal_eval)
131131
echo(f"Changing {name} from {feat.value} to {value}")
132132
response = await dev.features[name].set_value(value)
133133
await dev.update()

kasa/feature.py

+42-2
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,9 @@ class Category(Enum):
162162
#: If set, this property will be used to get *choices*.
163163
choices_getter: str | Callable[[], list[str]] | None = None
164164

165+
#: Value converter, for when working with complex types.
166+
value_parser: str | None = None
167+
165168
def __post_init__(self) -> None:
166169
"""Handle late-binding of members."""
167170
# Populate minimum & maximum values, if range_getter is given
@@ -264,6 +267,26 @@ async def set_value(self, value: int | float | bool | str | Enum | None) -> Any:
264267

265268
return await getattr(container, self.attribute_setter)(value)
266269

270+
def parse_value(
271+
self, value: str, fallback: Callable[[str], Any | None] = lambda x: None
272+
) -> Any | None:
273+
"""Attempt to parse a given string into a value accepted by this feature."""
274+
parser = self._get_property_value(self.value_parser)
275+
parser = parser if parser else fallback
276+
allowed = f"{self.choices}" if self.choices else "Unknown"
277+
try:
278+
parsed = parser(value)
279+
if parsed is None:
280+
raise ValueError(
281+
f"Unexpected value for {self.name}: {value}"
282+
f" - allowed: {allowed}"
283+
)
284+
return parsed
285+
except SyntaxError as se:
286+
raise ValueError(
287+
f"{se.msg} for {self.name}: {value}" f" - allowed: {allowed}",
288+
) from se
289+
267290
def __repr__(self) -> str:
268291
try:
269292
value = self.value
@@ -272,7 +295,18 @@ def __repr__(self) -> str:
272295
return f"Unable to read value ({self.id}): {ex}"
273296

274297
if self.type == Feature.Type.Choice:
275-
if not isinstance(choices, list) or value not in choices:
298+
if not isinstance(choices, list):
299+
_LOGGER.critical(
300+
"Choices are not properly defined for %s (%s). Type: <%s> Value: %s", # noqa: E501
301+
self.name,
302+
self.id,
303+
type(choices),
304+
choices,
305+
)
306+
return f"{self.name} ({self.id}): improperly defined choice set."
307+
if (value not in choices) and (
308+
isinstance(value, Enum) and value.name not in choices
309+
):
276310
_LOGGER.warning(
277311
"Invalid value for for choice %s (%s): %s not in %s",
278312
self.name,
@@ -284,7 +318,13 @@ def __repr__(self) -> str:
284318
f"{self.name} ({self.id}): invalid value '{value}' not in {choices}"
285319
)
286320
value = " ".join(
287-
[f"*{choice}*" if choice == value else choice for choice in choices]
321+
[
322+
f"*{choice}*"
323+
if choice == value
324+
or (isinstance(value, Enum) and choice == value.name)
325+
else f"{choice}"
326+
for choice in choices
327+
]
288328
)
289329
if self.precision_hint is not None and isinstance(value, float):
290330
value = round(value, self.precision_hint)

kasa/iot/modules/motion.py

+167-14
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44

55
import logging
66
from enum import Enum
7+
from typing import Literal, overload
78

89
from ...exceptions import KasaException
910
from ...feature import Feature
10-
from ..iotmodule import IotModule
11+
from ..iotmodule import IotModule, merge
1112

1213
_LOGGER = logging.getLogger(__name__)
1314

@@ -20,6 +21,9 @@ class Range(Enum):
2021
Near = 2
2122
Custom = 3
2223

24+
def __str__(self) -> str:
25+
return self.name
26+
2327

2428
class Motion(IotModule):
2529
"""Implements the motion detection (PIR) module."""
@@ -30,6 +34,11 @@ def _initialize_features(self) -> None:
3034
if "get_config" not in self.data:
3135
return
3236

37+
# Require that ADC value is also present.
38+
if "get_adc_value" not in self.data:
39+
_LOGGER.warning("%r initialized, but no get_adc_value in response")
40+
return
41+
3342
if "enable" not in self.config:
3443
_LOGGER.warning("%r initialized, but no enable in response")
3544
return
@@ -48,20 +57,78 @@ def _initialize_features(self) -> None:
4857
)
4958
)
5059

60+
self._add_feature(
61+
Feature(
62+
device=self._device,
63+
container=self,
64+
id="pir_range",
65+
name="Motion Sensor Range",
66+
icon="mdi:motion-sensor",
67+
attribute_getter="range",
68+
attribute_setter="_set_range_cli",
69+
type=Feature.Type.Choice,
70+
choices_getter="ranges",
71+
value_parser="parse_range_value",
72+
category=Feature.Category.Config,
73+
)
74+
)
75+
76+
self._add_feature(
77+
Feature(
78+
device=self._device,
79+
container=self,
80+
id="pir_threshold",
81+
name="Motion Sensor Threshold",
82+
icon="mdi:motion-sensor",
83+
attribute_getter="threshold",
84+
attribute_setter="set_threshold",
85+
type=Feature.Type.Number,
86+
category=Feature.Category.Config,
87+
)
88+
)
89+
90+
self._add_feature(
91+
Feature(
92+
device=self._device,
93+
container=self,
94+
id="pir_adc_value",
95+
name="PIR ADC Value",
96+
icon="mdi:motion-sensor",
97+
attribute_getter="adc_value",
98+
attribute_setter=None,
99+
type=Feature.Type.Sensor,
100+
category=Feature.Category.Primary,
101+
)
102+
)
103+
104+
self._add_feature(
105+
Feature(
106+
device=self._device,
107+
container=self,
108+
id="pir_triggered",
109+
name="PIR Triggered",
110+
icon="mdi:motion-sensor",
111+
attribute_getter="is_triggered",
112+
attribute_setter=None,
113+
type=Feature.Type.Sensor,
114+
category=Feature.Category.Primary,
115+
)
116+
)
117+
51118
def query(self) -> dict:
52119
"""Request PIR configuration."""
53-
return self.query_for_command("get_config")
120+
req = merge(
121+
self.query_for_command("get_config"),
122+
self.query_for_command("get_adc_value"),
123+
)
124+
125+
return req
54126

55127
@property
56128
def config(self) -> dict:
57129
"""Return current configuration."""
58130
return self.data["get_config"]
59131

60-
@property
61-
def range(self) -> Range:
62-
"""Return motion detection range."""
63-
return Range(self.config["trigger_index"])
64-
65132
@property
66133
def enabled(self) -> bool:
67134
"""Return True if module is enabled."""
@@ -71,23 +138,99 @@ async def set_enabled(self, state: bool) -> dict:
71138
"""Enable/disable PIR."""
72139
return await self.call("set_enable", {"enable": int(state)})
73140

141+
def _parse_range_value(self, value: str) -> int | Range | None:
142+
"""Attempt to parse a range value from the given string."""
143+
_LOGGER.debug("Parse Range Value: %s", value)
144+
parsed: int | Range | None = None
145+
try:
146+
parsed = int(value)
147+
_LOGGER.debug("Parse Range Value: %s is an integer.", value)
148+
return parsed
149+
except ValueError:
150+
_LOGGER.debug("Parse Range Value: %s is not an integer.", value)
151+
value = value.strip().upper()
152+
if value in Range._member_names_:
153+
_LOGGER.debug("Parse Range Value: %s is an enumeration.", value)
154+
parsed = Range[value]
155+
return parsed
156+
_LOGGER.debug("Parse Range Value: %s is not a Range Value.", value)
157+
return None
158+
159+
@property
160+
def ranges(self) -> list[Range]:
161+
"""Return set of supported range classes."""
162+
range_min = 0
163+
range_max = len(self.config["array"])
164+
valid_ranges = list()
165+
for r in Range:
166+
if (r.value >= range_min) and (r.value < range_max):
167+
valid_ranges.append(r)
168+
return valid_ranges
169+
170+
@property
171+
def range(self) -> Range:
172+
"""Return motion detection Range."""
173+
return Range(self.config["trigger_index"])
174+
175+
@overload
176+
async def set_range(self, *, range: Range) -> dict: ...
177+
178+
@overload
179+
async def set_range(self, *, range: Literal[Range.Custom], value: int) -> dict: ...
180+
181+
@overload
182+
async def set_range(self, *, value: int) -> dict: ...
183+
74184
async def set_range(
75-
self, *, range: Range | None = None, custom_range: int | None = None
185+
self, *, range: Range | None = None, value: int | None = None
76186
) -> dict:
77-
"""Set the range for the sensor.
187+
"""Set the Range for the sensor.
78188
79-
:param range: for using standard ranges
80-
:param custom_range: range in decimeters, overrides the range parameter
189+
:param Range: for using standard Ranges
190+
:param custom_Range: Range in decimeters, overrides the Range parameter
81191
"""
82-
if custom_range is not None:
83-
payload = {"index": Range.Custom.value, "value": custom_range}
192+
if value is not None:
193+
if range is not None and range is not Range.Custom:
194+
raise KasaException(
195+
"Refusing to set non-custom range %s to value %d." % (range, value)
196+
)
197+
elif value is None:
198+
raise KasaException("Custom range threshold may not be set to None.")
199+
payload = {"index": Range.Custom.value, "value": value}
84200
elif range is not None:
85201
payload = {"index": range.value}
86202
else:
87-
raise KasaException("Either range or custom_range need to be defined")
203+
raise KasaException("Either range or value needs to be defined")
88204

89205
return await self.call("set_trigger_sens", payload)
90206

207+
async def _set_range_cli(self, input: Range | int) -> dict:
208+
if isinstance(input, Range):
209+
return await self.set_range(range=input)
210+
elif isinstance(input, int):
211+
return await self.set_range(value=input)
212+
else:
213+
raise KasaException(
214+
"Invalid type: %s given to cli motion set." % (type(input))
215+
)
216+
217+
def get_range_threshold(self, range_type: Range) -> int:
218+
"""Get the distance threshold at which the PIR sensor is will trigger."""
219+
if range_type.value < 0 or range_type.value >= len(self.config["array"]):
220+
raise KasaException(
221+
"Range type is outside the bounds of the configured device ranges."
222+
)
223+
return int(self.config["array"][range_type.value])
224+
225+
@property
226+
def threshold(self) -> int:
227+
"""Return motion detection Range."""
228+
return self.get_range_threshold(self.range)
229+
230+
async def set_threshold(self, value: int) -> dict:
231+
"""Set the distance threshold at which the PIR sensor is will trigger."""
232+
return await self.set_range(value=value)
233+
91234
@property
92235
def inactivity_timeout(self) -> int:
93236
"""Return inactivity timeout in milliseconds."""
@@ -100,3 +243,13 @@ async def set_inactivity_timeout(self, timeout: int) -> dict:
100243
to avoid reverting this back to 60 seconds after a period of time.
101244
"""
102245
return await self.call("set_cold_time", {"cold_time": timeout})
246+
247+
@property
248+
def adc_value(self) -> int:
249+
"""Return motion adc value."""
250+
return int(self.data["get_adc_value"]["value"])
251+
252+
@property
253+
def is_triggered(self) -> bool:
254+
"""Return if the motion sensor has been triggered."""
255+
return (self.enabled) and (self.adc_value < self.range.value)

tests/fakeprotocol_iot.py

+1
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ def success(res):
163163

164164

165165
MOTION_MODULE = {
166+
"get_adc_value": {"value": 50, "err_code": 0},
166167
"get_config": {
167168
"enable": 0,
168169
"version": "1.0",

0 commit comments

Comments
 (0)