8000 Add Async ADC PIR Support · python-kasa/python-kasa@2cac441 · GitHub
[go: up one dir, main page]

Skip to content

Commit 2cac441

Browse files
committed
Add Async ADC PIR Support
- Add: Async function to get the current state of the ADC PIR sensor. - Fix: Move ADC PIR state and config into proper dataclasses. - Add: Add CLI test for passing bad quotes to feature set.
1 parent 45abfec commit 2cac441

File tree

3 files changed

+140
-18
lines changed

3 files changed

+140
-18
lines changed

kasa/iot/modules/motion.py

Lines changed: 93 additions & 18 deletions
57AE
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import logging
66
import math
7+
from dataclasses import dataclass
78
from enum import Enum
89

910
from ...exceptions import KasaException
@@ -25,6 +26,64 @@ def __str__(self) -> str:
2526
return self.name
2627

2728

29+
@dataclass
30+
class PIRConfig:
31+
"""Dataclass representing a PIR sensor configuration."""
32+
33+
enabled: bool
34+
adc_min: int
35+
adc_max: int
36+
range: Range
37+
threshold: int
38+
39+
@property
40+
def adc_mid(self) -> int:
41+
"""Compute the ADC midpoint from the configured ADC Max and Min values."""
42+
return math.floor(abs(self.adc_max - self.adc_min) / 2)
43+
44+
45+
@dataclass
46+
class PIRStatus:
47+
"""Dataclass representing the current trigger state of an ADC PIR sensor."""
48+
49+
adc_value: int
50+
51+
def get_pir_value(self, config: PIRConfig) -> int:
52+
"""
53+
Get the PIR status value in integer form.
54+
55+
Computes the PIR status value that this object represents,
56+
using the given PIR configuration.
57+
"""
58+
return config.adc_mid - self.adc_value
59+
60+
def get_pir_percent(self, config: PIRConfig) -> float:
61+
"""
62+
Get the PIR status value in percentile form.
63+
64+
Computes the PIR status percentage that this object represents,
65+
using the given PIR configuration.
66+
"""
67+
value = self.get_pir_value(config)
68+
divisor = (
69+
(config.adc_mid - config.adc_min)
70+
if (value < 0)
71+
else (config.adc_max - config.adc_mid)
72+
)
73+
return (float(value) / divisor) * 100
74+
75+
def get_pir_triggered(self, config: PIRConfig) -> bool:
76+
"""
77+
Get the PIR status trigger state.
78+
79+
Compute the PIR trigger state this object represents,
80+
using the given PIR configuration.
81+
"""
82+
return (config.enabled) and (
83+
abs(self.get_pir_percent(config)) > (100 - config.threshold)
84+
)
85+
86+
2887
class Motion(IotModule):
2988
"""Implements the motion detection (PIR) module."""
3089

@@ -150,7 +209,7 @@ def _initialize_features(self) -> None:
150209
id="pir_adc_mid",
151210
name="PIR ADC Mid",
152211
icon="mdi:motion-sensor",
153-
attribute_getter="adc_midpoint",
212+
attribute_getter="adc_mid",
154213
attribute_setter=None,
155214
type=Feature.Type.Sensor,
156215
category=Feature.Category.Debug,
@@ -200,23 +259,35 @@ def config(self) -> dict:
200259
"""Return current configuration."""
201260
return self.data["get_config"]
202261

262+
@property
263+
def pir_config(self) -> PIRConfig:
264+
"""Return PIR sensor configuration."""
265+
pir_range = Range(self.config["trigger_index"])
266+
return PIRConfig(
267+
enabled=bool(self.config["enable"]),
268+
adc_min=int(self.config["min_adc"]),
269+
adc_max=int(self.config["max_adc"]),
270+
range=pir_range,
271+
threshold=self.get_range_threshold(pir_range),
272+
)
273+
203274
@property
204275
def enabled(self) -> bool:
205276
"""Return True if module is enabled."""
206-
return bool(self.config["enable"])
277+
return self.pir_config.enabled
207278

208279
@property
209280
def adc_min(self) -> int:
210281
"""Return minimum ADC sensor value."""
211-
return int(self.config["min_adc"])
282+
return self.pir_config.adc_min
212283

213284
@property
214285
def adc_max(self) -> int:
215286
"""Return maximum ADC sensor value."""
216-
return int(self.config["max_adc"])
287+
return self.pir_config.adc_max
217288

218289
@property
219-
def adc_midpoint(self) -> int:
290+
def adc_mid(self) -> int:
220291
"""
221292
Return the midpoint for the ADC.
222293
@@ -225,7 +296,7 @@ def adc_midpoint(self) -> int:
225296
Currently this is estimated by:
226297
math.floor(abs(adc_max - adc_min) / 2)
227298
"""
228-
return math.floor(abs(self.adc_max - self.adc_min) / 2)
299+
return self.pir_config.adc_mid
229300

230301
async def set_enabled(self, state: bool) -> dict:
231302
"""Enable/disable PIR."""
@@ -245,7 +316,7 @@ def ranges(self) -> list[str]:
245316
@property
246317
def range(self) -> Range:
247318
"""Return motion detection Range."""
248-
return Range(self.config["trigger_index"])
319+
return self.pir_config.range
249320

250321
async def set_range(self, range: Range) -> dict:
251322
"""Set the Range for the sensor.
@@ -280,7 +351,7 @@ def get_range_threshold(self, range_type: Range) -> int:
280351
@property
281352
def threshold(self) -> int:
282353
"""Return motion detection Range."""
283-
return self.get_range_threshold(self.range)
354+
return self.pir_config.threshold
284355

285356
async def set_threshold(self, value: int) -> dict:
286357
"""Set the distance threshold at which the PIR sensor is will trigger."""
@@ -300,28 +371,32 @@ async def set_inactivity_timeout(self, timeout: int) -> dict:
300371
"""
301372
return await self.call("set_cold_time", {"cold_time": timeout})
302373

374+
@property
375+
def pir_state(self) -> PIRStatus:
376+
"""Return cached PIR status."""
377+
return PIRStatus(self.data["get_adc_value"]["value"])
378+
379+
async def get_pir_state(self) -> PIRStatus:
380+
"""Return real-time PIR status."""
381+
current = await self.call("get_adc_value")
382+
return PIRStatus(current["value"])
383+
303384
@property
304385
def adc_value(self) -> int:
305386
"""Return motion adc value."""
306-
return self.data["get_adc_value"]["value"]
387+
return self.pir_state.adc_value
307388

308389
@property
309390
def pir_value(self) -> int:
310391
"""Return the computed PIR sensor value."""
311-
return self.adc_midpoint - self.adc_value
392+
return self.pir_state.get_pir_value(self.pir_config)
312393

313394
@property
314395
def pir_percent(self) -> float:
315396
"""Return the computed PIR sensor value, in percentile form."""
316-
amp = self.pir_value
317-
per: float
318-
if amp < 0:
319-
per = (float(amp) / (self.adc_midpoint - self.adc_min)) * 100
320-
else:
321-
per = (float(amp) / (self.adc_max - self.adc_midpoint)) * 100
322-
return per
397+
return self.pir_state.get_pir_percent(self.pir_config)
323398

324399
@property
325400
def pir_triggered(self) -> bool:
326401
"""Return if the motion sensor has been triggered."""
327-
return (self.enabled) and (abs(self.pir_percent) > (100 - self.threshold))
402+
return self.pir_state.get_pir_triggered(self.pir_config)

tests/iot/modules/test_motion.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@ async def test_motion_threshold(dev: IotDimmer, mocker: MockerFixture):
6868
)
6969

7070

71+
@dimmer_iot
72+
async def test_motion_realtime(dev: IotDimmer, mocker: MockerFixture):
73+
motion: Motion = dev.modules[Module.IotMotion]
74+
query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper")
75+
76+
await motion.get_pir_state()
77+
query_helper.assert_called_with("smartlife.iot.PIR", "get_adc_value", None)
78+
79+
7180
@dimmer_iot
7281
def test_motion_feature(dev: IotDimmer):
7382
assert Module.IotMotion in dev.modules

tests/test_cli.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1110,6 +1110,44 @@ async def test_feature_set_child(mocker, runner):
11101110
assert res.exit_code == 0
11111111

11121112

1113+
async def test_feature_set_unquoted(mocker, runner):
1114+
"""Test feature command's set value."""
1115+
dummy_device = await get_device_for_fixture_protocol(
1116+
"ES20M(US)_1.0_1.0.11.json", "IOT"
1117+
)
1118+
range_setter = mocker.patch("kasa.iot.modules.motion.Motion._set_range_cli")
1119+
mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device)
1120+
1121+
res = await runner.invoke(
1122+
cli,
1123+
["--host", "127.0.0.123", "--debug", "feature", "pir_range", "Far"],
1124+
catch_exceptions=False,
1125+
)
1126+
1127+
range_setter.assert_not_called()
1128+
assert "Error: Invalid value: " in res.output
1129+
assert res.exit_code != 0
1130+
1131+
1132+
async def test_feature_set_badquoted(mocker, runner):
1133+
"""Test feature command's set value."""
1134+
dummy_device = await get_device_for_fixture_protocol(
1135+
"ES20M(US)_1.0_1.0.11.json", "IOT"
1136+
)
1137+
range_setter = mocker.patch("kasa.iot.modules.motion.Motion._set_range_cli")
1138+
mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device)
1139+
1140+
res = await runner.invoke(
1141+
cli,
1142+
["--host", "127.0.0.123", "--debug", "feature", "pir_range", "`Far"],
1143+
catch_exceptions=False,
1144+
)
1145+
1146+
range_setter.assert_not_called()
1147+
assert "Error: Invalid value: " in res.output
1148+
assert res.exit_code != 0
1149+
1150+
11131151
async def test_cli_child_commands(
11141152
dev: Device, runner: CliRunner, mocker: MockerFixture
11151153
):

0 commit comments

Comments
 (0)
0