Skip to content

Commit 656c999

Browse files
authored
Merge pull request #323 from plugwise/mdi_scan
store MotionSensitivity enum which makes communication to HA easier
2 parents 6372799 + b37383a commit 656c999

File tree

5 files changed

+74
-41
lines changed

5 files changed

+74
-41
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# Changelog
22

3-
## Ongoing
3+
## v0.44.12 - 2025-08-24
44

5+
- PR [323](https://github.com/plugwise/python-plugwise-usb/pull/323): Motion Sensitivity to use named levels (Off/Medium/High) instead of numeric values, add light sensitivity calibration on wake-up for scan devices.
6+
- PR [322](https://github.com/plugwise/python-plugwise-usb/pull/322): Improve Circle+ load function to align to Circle load function
57
- PR [321](https://github.com/plugwise/python-plugwise-usb/pull/321): Catch error reported in Issue [#312](https://github.com/plugwise/plugwise_usb-beta/issues/312)
68
- PR [319](https://github.com/plugwise/python-plugwise-usb/pull/319): Replace unclear warning message when a node is not online, also various small improvements suggested by CRAI.
79
- PR [312](https://github.com/plugwise/python-plugwise-usb/pull/312): properly propagate configuration changes and initialize to available on first node wakeup

plugwise_usb/api.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,14 +233,14 @@ class MotionConfig:
233233
Attributes:
234234
reset_timer: int | None: Motion reset timer in minutes before the motion detection is switched off.
235235
daylight_mode: bool | None: Motion detection when light level is below threshold.
236-
sensitivity_level: int | None: Motion sensitivity level.
236+
sensitivity_level: MotionSensitivity | None: Motion sensitivity level.
237237
dirty: bool: Settings changed, device update pending
238238
239239
"""
240240

241241
daylight_mode: bool | None = None
242242
reset_timer: int | None = None
243-
sensitivity_level: int | None = None
243+
sensitivity_level: MotionSensitivity | None = None
244244
dirty: bool = False
245245

246246

@@ -678,6 +678,13 @@ async def set_motion_sensitivity_level(self, level: MotionSensitivity) -> bool:
678678
679679
"""
680680

681+
async def scan_calibrate_light(self) -> bool:
682+
"""Request to calibration light sensitivity of Scan device.
683+
684+
Description:
685+
Request to calibration light sensitivity of Scan device.
686+
"""
687+
681688
async def set_relay_init(self, state: bool) -> bool:
682689
"""Change the initial state of the relay.
683690

plugwise_usb/nodes/scan.py

Lines changed: 43 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
)
2020
from ..connection import StickController
2121
from ..constants import MAX_UINT_2
22-
from ..exceptions import MessageError, NodeError, NodeTimeout
22+
from ..exceptions import MessageError, NodeError
2323
from ..messages.requests import ScanConfigureRequest, ScanLightCalibrateRequest
2424
from ..messages.responses import (
2525
NODE_SWITCH_GROUP_ID,
@@ -86,7 +86,7 @@ def __init__(
8686

8787
self._motion_state = MotionState()
8888
self._motion_config = MotionConfig()
89-
89+
self._scan_calibrate_light_scheduled = False
9090
self._configure_daylight_mode_task: Task[Coroutine[Any, Any, None]] | None = (
9191
None
9292
)
@@ -198,7 +198,7 @@ def _reset_timer_from_cache(self) -> int | None:
198198
return int(reset_timer)
199199
return None
200200

201-
def _sensitivity_level_from_cache(self) -> int | None:
201+
def _sensitivity_level_from_cache(self) -> MotionSensitivity | None:
202202
"""Load sensitivity level from cache."""
203203
if (
204204
sensitivity_level := self._get_cache(
@@ -274,7 +274,7 @@ def reset_timer(self) -> int:
274274
return DEFAULT_RESET_TIMER
275275

276276
@property
277-
def sensitivity_level(self) -> int:
277+
def sensitivity_level(self) -> MotionSensitivity:
278278
"""Sensitivity level of motion sensor."""
279279
if self._motion_config.sensitivity_level is not None:
280280
return self._motion_config.sensitivity_level
@@ -326,13 +326,13 @@ async def set_motion_reset_timer(self, minutes: int) -> bool:
326326
await self._scan_configure_update()
327327
return True
328328

329-
async def set_motion_sensitivity_level(self, level: int) -> bool:
329+
async def set_motion_sensitivity_level(self, level: MotionSensitivity) -> bool:
330330
"""Configure the motion sensitivity level."""
331331
_LOGGER.debug(
332332
"set_motion_sensitivity_level | Device %s | %s -> %s",
333333
self.name,
334-
self._motion_config.sensitivity_level,
335-
level,
334+
self.sensitivity_level.name,
335+
level.name,
336336
)
337337
if self._motion_config.sensitivity_level == level:
338338
return False
@@ -426,6 +426,8 @@ async def _run_awake_tasks(self) -> None:
426426
await super()._run_awake_tasks()
427427
if self._motion_config.dirty:
428428
await self._configure_scan_task()
429+
if self._scan_calibrate_light_scheduled:
430+
await self._scan_calibrate_light()
429431
await self.publish_feature_update_to_subscribers(
430432
NodeFeature.MOTION_CONFIG,
431433
self._motion_config,
@@ -446,9 +448,9 @@ async def scan_configure(self) -> bool:
446448
request = ScanConfigureRequest(
447449
self._send,
448450
self._mac_in_bytes,
449-
self._motion_config.reset_timer,
450-
self._motion_config.sensitivity_level,
451-
self._motion_config.daylight_mode,
451+
self.reset_timer,
452+
self.sensitivity_level.value,
453+
self.daylight_mode,
452454
)
453455
if (response := await request.send()) is None:
454456
_LOGGER.warning(
@@ -473,17 +475,13 @@ async def scan_configure(self) -> bool:
473475

474476
async def _scan_configure_update(self) -> None:
475477
"""Push scan configuration update to cache."""
476-
self._set_cache(
477-
CACHE_SCAN_CONFIG_RESET_TIMER, str(self._motion_config.reset_timer)
478-
)
478+
self._set_cache(CACHE_SCAN_CONFIG_RESET_TIMER, self.reset_timer)
479479
self._set_cache(
480480
CACHE_SCAN_CONFIG_SENSITIVITY,
481-
str(MotionSensitivity(self._motion_config.sensitivity_level).name),
481+
self._motion_config.sensitivity_level.name,
482482
)
483-
self._set_cache(
484-
CACHE_SCAN_CONFIG_DAYLIGHT_MODE, str(self._motion_config.daylight_mode)
485-
)
486-
self._set_cache(CACHE_SCAN_CONFIG_DIRTY, str(self._motion_config.dirty))
483+
self._set_cache(CACHE_SCAN_CONFIG_DAYLIGHT_MODE, self.daylight_mode)
484+
self._set_cache(CACHE_SCAN_CONFIG_DIRTY, self.dirty)
487485
await gather(
488486
self.publish_feature_update_to_subscribers(
489487
NodeFeature.MOTION_CONFIG,
@@ -493,18 +491,36 @@ async def _scan_configure_update(self) -> None:
493491
)
494492

495493
async def scan_calibrate_light(self) -> bool:
494+
"""Schedule light sensitivity calibration of Scan device.
495+
496+
Returns True when scheduling was newly activated;
497+
False if it was already scheduled.
498+
"""
499+
if self._scan_calibrate_light_scheduled:
500+
return False
501+
self._scan_calibrate_light_scheduled = True
502+
return True
503+
504+
async def _scan_calibrate_light(self) -> bool:
496505
"""Request to calibration light sensitivity of Scan device."""
497506
request = ScanLightCalibrateRequest(self._send, self._mac_in_bytes)
498-
if (response := await request.send()) is not None:
499-
if (
500-
response.node_ack_type
501-
== NodeAckResponseType.SCAN_LIGHT_CALIBRATION_ACCEPTED
502-
):
503-
return True
507+
response = await request.send()
508+
if response is None:
509+
_LOGGER.warning(
510+
"No response from %s to light calibration request",
511+
self.name,
512+
)
504513
return False
505-
raise NodeTimeout(
506-
f"No response from Scan device {self.mac} "
507-
+ "to light calibration request."
514+
if (
515+
response.node_ack_type
516+
== NodeAckResponseType.SCAN_LIGHT_CALIBRATION_ACCEPTED
517+
):
518+
self._scan_calibrate_light_scheduled = False
519+
return True
520+
_LOGGER.warning(
521+
"Unexpected ack type %s for light calibration on %s",
522+
response.node_ack_type,
523+
self.name,
508524
)
509525

510526
async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "plugwise_usb"
7-
version = "0.44.12a1"
7+
version = "0.44.12"
88
license = "MIT"
99
keywords = ["home", "automation", "plugwise", "module", "usb"]
1010
classifiers = [

tests/test_usb.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1494,7 +1494,7 @@ async def test_creating_request_messages(self) -> None:
14941494
self.dummy_fn,
14951495
b"1111222233334444",
14961496
5, # Delay in minutes when signal is send when no motion is detected
1497-
30, # Sensitivity of Motion sensor (High, Medium, Off)
1497+
pw_api.MotionSensitivity.MEDIUM, # Sensitivity of Motion sensor (High, Medium, Off)
14981498
False, # Daylight override to only report motion when lightlevel is below calibrated level
14991499
)
15001500
assert (
@@ -1834,7 +1834,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign
18341834
await test_node.set_motion_daylight_mode(True)
18351835

18361836
with pytest.raises(pw_exceptions.NodeError):
1837-
await test_node.set_motion_sensitivity_level(20)
1837+
await test_node.set_motion_sensitivity_level(pw_api.MotionSensitivity.HIGH)
18381838

18391839
with pytest.raises(pw_exceptions.NodeError):
18401840
await test_node.set_motion_reset_timer(5)
@@ -1865,7 +1865,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign
18651865
await test_node.set_motion_daylight_mode(True)
18661866

18671867
with pytest.raises(pw_exceptions.FeatureError):
1868-
await test_node.set_motion_sensitivity_level(20)
1868+
await test_node.set_motion_sensitivity_level(pw_api.MotionSensitivity.HIGH)
18691869

18701870
with pytest.raises(pw_exceptions.FeatureError):
18711871
await test_node.set_motion_reset_timer(5)
@@ -1892,7 +1892,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign
18921892
with pytest.raises(NotImplementedError):
18931893
await test_node.set_motion_daylight_mode(True)
18941894
with pytest.raises(NotImplementedError):
1895-
await test_node.set_motion_sensitivity_level(20)
1895+
await test_node.set_motion_sensitivity_level(pw_api.MotionSensitivity.HIGH)
18961896
with pytest.raises(NotImplementedError):
18971897
await test_node.set_motion_reset_timer(5)
18981898

@@ -2240,12 +2240,18 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign
22402240
assert test_scan.motion_config.daylight_mode
22412241

22422242
# test motion sensitivity level
2243-
assert test_scan.sensitivity_level == 30
2244-
assert test_scan.motion_config.sensitivity_level == 30
2245-
assert not await test_scan.set_motion_sensitivity_level(30)
2243+
assert test_scan.sensitivity_level == pw_api.MotionSensitivity.MEDIUM
2244+
assert (
2245+
test_scan.motion_config.sensitivity_level == pw_api.MotionSensitivity.MEDIUM
2246+
)
2247+
assert not await test_scan.set_motion_sensitivity_level(
2248+
pw_api.MotionSensitivity.MEDIUM
2249+
)
22462250

22472251
assert not test_scan.motion_config.dirty
2248-
assert await test_scan.set_motion_sensitivity_level(20)
2252+
assert await test_scan.set_motion_sensitivity_level(
2253+
pw_api.MotionSensitivity.HIGH
2254+
)
22492255
assert test_scan.motion_config.dirty
22502256
awake_response4 = pw_responses.NodeAwakeResponse()
22512257
awake_response4.deserialize(
@@ -2257,8 +2263,10 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign
22572263
await test_scan._awake_response(awake_response4) # pylint: disable=protected-access
22582264
await asyncio.sleep(0.001) # Ensure time for task to be executed
22592265
assert not test_scan.motion_config.dirty
2260-
assert test_scan.sensitivity_level == 20
2261-
assert test_scan.motion_config.sensitivity_level == 20
2266+
assert test_scan.sensitivity_level == pw_api.MotionSensitivity.HIGH
2267+
assert (
2268+
test_scan.motion_config.sensitivity_level == pw_api.MotionSensitivity.HIGH
2269+
)
22622270

22632271
# scan with cache enabled
22642272
mock_stick_controller.send_response = None

0 commit comments

Comments
 (0)