Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cc6bcf6
store MotionSensitivity enum which makes communication to HA easier
dirixmjm Aug 18, 2025
6bd7013
fixup: mdi_scan Python code reformatted using Ruff
Aug 18, 2025
9a147d5
CR: Catched missing update
dirixmjm Aug 18, 2025
247d762
reference to self-functions which return default values of config-obj…
dirixmjm Aug 18, 2025
722734a
fixup: mdi_scan Python code reformatted using Ruff
Aug 18, 2025
96307d0
print wrong value of level in de log
dirixmjm Aug 18, 2025
9b13066
fixup: mdi_scan Python code reformatted using Ruff
Aug 18, 2025
22b21d3
CR: update node.py as well
dirixmjm Aug 18, 2025
b5a44c2
release 0.44.12a2
dirixmjm Aug 18, 2025
96e4df5
CR: Nitpick on formatting
dirixmjm Aug 19, 2025
f536631
expose scheduling of light calibration
dirixmjm Aug 19, 2025
32f66ff
Revert api/set_motion_sensitivity_level() typing
bouwew Aug 22, 2025
b797755
Revert node/set_motion_sensitivity_level() typing
bouwew Aug 22, 2025
3516508
Assure proper input-output
bouwew Aug 22, 2025
68ab733
Fix set_motion_sensitivity_level tests
bouwew Aug 22, 2025
7e010a5
Fix sensitivity_level property
bouwew Aug 22, 2025
1b64119
convert to value just before call to ScanConfigureRequest
dirixmjm Aug 24, 2025
1d56d5f
cherry-pick updates to test_usb.py
dirixmjm Aug 24, 2025
1696031
return type change to indicate already or newly activated
dirixmjm Aug 24, 2025
01d1db4
CR: remove unwanted exception
dirixmjm Aug 24, 2025
46dfac2
CR: Nitpick, cachewrite already stringifies internally
dirixmjm Aug 24, 2025
b37383a
update CHANGELOG
dirixmjm Aug 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Changelog

## Ongoing
## v0.44.12 - 2025-08-24

- 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.
- PR [322](https://github.com/plugwise/python-plugwise-usb/pull/322): Improve Circle+ load function to align to Circle load function
- 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)
- 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.
- PR [312](https://github.com/plugwise/python-plugwise-usb/pull/312): properly propagate configuration changes and initialize to available on first node wakeup
Expand Down
11 changes: 9 additions & 2 deletions plugwise_usb/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,14 +233,14 @@ class MotionConfig:
Attributes:
reset_timer: int | None: Motion reset timer in minutes before the motion detection is switched off.
daylight_mode: bool | None: Motion detection when light level is below threshold.
sensitivity_level: int | None: Motion sensitivity level.
sensitivity_level: MotionSensitivity | None: Motion sensitivity level.
dirty: bool: Settings changed, device update pending

"""

daylight_mode: bool | None = None
reset_timer: int | None = None
sensitivity_level: int | None = None
sensitivity_level: MotionSensitivity | None = None
dirty: bool = False


Expand Down Expand Up @@ -678,6 +678,13 @@ async def set_motion_sensitivity_level(self, level: MotionSensitivity) -> bool:

"""

async def scan_calibrate_light(self) -> bool:
"""Request to calibration light sensitivity of Scan device.

Description:
Request to calibration light sensitivity of Scan device.
"""

async def set_relay_init(self, state: bool) -> bool:
"""Change the initial state of the relay.

Expand Down
70 changes: 43 additions & 27 deletions plugwise_usb/nodes/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
)
from ..connection import StickController
from ..constants import MAX_UINT_2
from ..exceptions import MessageError, NodeError, NodeTimeout
from ..exceptions import MessageError, NodeError
from ..messages.requests import ScanConfigureRequest, ScanLightCalibrateRequest
from ..messages.responses import (
NODE_SWITCH_GROUP_ID,
Expand Down Expand Up @@ -86,7 +86,7 @@ def __init__(

self._motion_state = MotionState()
self._motion_config = MotionConfig()

self._scan_calibrate_light_scheduled = False
self._configure_daylight_mode_task: Task[Coroutine[Any, Any, None]] | None = (
None
)
Expand Down Expand Up @@ -198,7 +198,7 @@ def _reset_timer_from_cache(self) -> int | None:
return int(reset_timer)
return None

def _sensitivity_level_from_cache(self) -> int | None:
def _sensitivity_level_from_cache(self) -> MotionSensitivity | None:
"""Load sensitivity level from cache."""
if (
sensitivity_level := self._get_cache(
Expand Down Expand Up @@ -274,7 +274,7 @@ def reset_timer(self) -> int:
return DEFAULT_RESET_TIMER

@property
def sensitivity_level(self) -> int:
def sensitivity_level(self) -> MotionSensitivity:
"""Sensitivity level of motion sensor."""
if self._motion_config.sensitivity_level is not None:
return self._motion_config.sensitivity_level
Expand Down Expand Up @@ -326,13 +326,13 @@ async def set_motion_reset_timer(self, minutes: int) -> bool:
await self._scan_configure_update()
return True

async def set_motion_sensitivity_level(self, level: int) -> bool:
async def set_motion_sensitivity_level(self, level: MotionSensitivity) -> bool:
"""Configure the motion sensitivity level."""
_LOGGER.debug(
"set_motion_sensitivity_level | Device %s | %s -> %s",
self.name,
self._motion_config.sensitivity_level,
level,
self.sensitivity_level.name,
level.name,
)
if self._motion_config.sensitivity_level == level:
return False
Expand Down Expand Up @@ -426,6 +426,8 @@ async def _run_awake_tasks(self) -> None:
await super()._run_awake_tasks()
if self._motion_config.dirty:
await self._configure_scan_task()
if self._scan_calibrate_light_scheduled:
await self._scan_calibrate_light()
await self.publish_feature_update_to_subscribers(
NodeFeature.MOTION_CONFIG,
self._motion_config,
Expand All @@ -446,9 +448,9 @@ async def scan_configure(self) -> bool:
request = ScanConfigureRequest(
self._send,
self._mac_in_bytes,
self._motion_config.reset_timer,
self._motion_config.sensitivity_level,
self._motion_config.daylight_mode,
self.reset_timer,
self.sensitivity_level.value,
self.daylight_mode,
)
if (response := await request.send()) is None:
_LOGGER.warning(
Expand All @@ -473,17 +475,13 @@ async def scan_configure(self) -> bool:

async def _scan_configure_update(self) -> None:
"""Push scan configuration update to cache."""
self._set_cache(
CACHE_SCAN_CONFIG_RESET_TIMER, str(self._motion_config.reset_timer)
)
self._set_cache(CACHE_SCAN_CONFIG_RESET_TIMER, self.reset_timer)
self._set_cache(
CACHE_SCAN_CONFIG_SENSITIVITY,
str(MotionSensitivity(self._motion_config.sensitivity_level).name),
self._motion_config.sensitivity_level.name,
)
self._set_cache(
CACHE_SCAN_CONFIG_DAYLIGHT_MODE, str(self._motion_config.daylight_mode)
)
self._set_cache(CACHE_SCAN_CONFIG_DIRTY, str(self._motion_config.dirty))
self._set_cache(CACHE_SCAN_CONFIG_DAYLIGHT_MODE, self.daylight_mode)
self._set_cache(CACHE_SCAN_CONFIG_DIRTY, self.dirty)
await gather(
self.publish_feature_update_to_subscribers(
NodeFeature.MOTION_CONFIG,
Expand All @@ -493,18 +491,36 @@ async def _scan_configure_update(self) -> None:
)

async def scan_calibrate_light(self) -> bool:
"""Schedule light sensitivity calibration of Scan device.

Returns True when scheduling was newly activated;
False if it was already scheduled.
"""
if self._scan_calibrate_light_scheduled:
return False
self._scan_calibrate_light_scheduled = True
return True

async def _scan_calibrate_light(self) -> bool:
"""Request to calibration light sensitivity of Scan device."""
request = ScanLightCalibrateRequest(self._send, self._mac_in_bytes)
if (response := await request.send()) is not None:
if (
response.node_ack_type
== NodeAckResponseType.SCAN_LIGHT_CALIBRATION_ACCEPTED
):
return True
response = await request.send()
if response is None:
_LOGGER.warning(
"No response from %s to light calibration request",
self.name,
)
return False
raise NodeTimeout(
f"No response from Scan device {self.mac} "
+ "to light calibration request."
if (
response.node_ack_type
== NodeAckResponseType.SCAN_LIGHT_CALIBRATION_ACCEPTED
):
self._scan_calibrate_light_scheduled = False
return True
_LOGGER.warning(
"Unexpected ack type %s for light calibration on %s",
response.node_ack_type,
self.name,
)

async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "plugwise_usb"
version = "0.44.12a1"
version = "0.44.12"
license = "MIT"
keywords = ["home", "automation", "plugwise", "module", "usb"]
classifiers = [
Expand Down
28 changes: 18 additions & 10 deletions tests/test_usb.py
Original file line number Diff line number Diff line change
Expand Up @@ -1494,7 +1494,7 @@ async def test_creating_request_messages(self) -> None:
self.dummy_fn,
b"1111222233334444",
5, # Delay in minutes when signal is send when no motion is detected
30, # Sensitivity of Motion sensor (High, Medium, Off)
pw_api.MotionSensitivity.MEDIUM, # Sensitivity of Motion sensor (High, Medium, Off)
False, # Daylight override to only report motion when lightlevel is below calibrated level
)
assert (
Expand Down Expand Up @@ -1834,7 +1834,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign
await test_node.set_motion_daylight_mode(True)

with pytest.raises(pw_exceptions.NodeError):
await test_node.set_motion_sensitivity_level(20)
await test_node.set_motion_sensitivity_level(pw_api.MotionSensitivity.HIGH)

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

with pytest.raises(pw_exceptions.FeatureError):
await test_node.set_motion_sensitivity_level(20)
await test_node.set_motion_sensitivity_level(pw_api.MotionSensitivity.HIGH)

with pytest.raises(pw_exceptions.FeatureError):
await test_node.set_motion_reset_timer(5)
Expand All @@ -1892,7 +1892,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign
with pytest.raises(NotImplementedError):
await test_node.set_motion_daylight_mode(True)
with pytest.raises(NotImplementedError):
await test_node.set_motion_sensitivity_level(20)
await test_node.set_motion_sensitivity_level(pw_api.MotionSensitivity.HIGH)
with pytest.raises(NotImplementedError):
await test_node.set_motion_reset_timer(5)

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

# test motion sensitivity level
assert test_scan.sensitivity_level == 30
assert test_scan.motion_config.sensitivity_level == 30
assert not await test_scan.set_motion_sensitivity_level(30)
assert test_scan.sensitivity_level == pw_api.MotionSensitivity.MEDIUM
assert (
test_scan.motion_config.sensitivity_level == pw_api.MotionSensitivity.MEDIUM
)
assert not await test_scan.set_motion_sensitivity_level(
pw_api.MotionSensitivity.MEDIUM
)

assert not test_scan.motion_config.dirty
assert await test_scan.set_motion_sensitivity_level(20)
assert await test_scan.set_motion_sensitivity_level(
pw_api.MotionSensitivity.HIGH
)
assert test_scan.motion_config.dirty
awake_response4 = pw_responses.NodeAwakeResponse()
awake_response4.deserialize(
Expand All @@ -2257,8 +2263,10 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign
await test_scan._awake_response(awake_response4) # pylint: disable=protected-access
await asyncio.sleep(0.001) # Ensure time for task to be executed
assert not test_scan.motion_config.dirty
assert test_scan.sensitivity_level == 20
assert test_scan.motion_config.sensitivity_level == 20
assert test_scan.sensitivity_level == pw_api.MotionSensitivity.HIGH
assert (
test_scan.motion_config.sensitivity_level == pw_api.MotionSensitivity.HIGH
)

# scan with cache enabled
mock_stick_controller.send_response = None
Expand Down