Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
Comment on lines +236 to 244
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Type contract drift: MotionConfig.sensitivity_level vs implementation

You’ve changed MotionConfig.sensitivity_level to MotionSensitivity | None, which is a good direction. However, PlugwiseScan currently stores and exposes an int for sensitivity_level (see nodes/scan.py line 168 and property at line 276), leading to a mismatch with this public type. This will trip strict type-checking and confuse downstream consumers.

Recommend making the node consistently use MotionSensitivity end-to-end (store the enum in _motion_config, property returns MotionSensitivity) and only convert to the numeric value at the request boundary and when writing to cache. See suggested fixes in scan.py comments.

🤖 Prompt for AI Agents
In plugwise_usb/api.py around lines 236 to 244, MotionConfig.sensitivity_level
is now typed as MotionSensitivity | None but the node implementation stores and
exposes an int; update the node to store the MotionSensitivity enum in its
internal _motion_config and make the public property return MotionSensitivity
(or None) instead of int; when persisting to cache or preparing network requests
convert the enum to its numeric value (e.g., enum.value) and when reading from
cache or responses convert numeric values back into MotionSensitivity using
MotionSensitivity(value) to maintain a consistent enum-based contract
end-to-end.



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.
"""

Comment on lines +681 to +687
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

API contract mismatch: scan_calibrate_light return type

In this Protocol, scan_calibrate_light() returns bool with semantics “True if successful.” In PlugwiseScan (nodes/scan.py lines 493-496), the method now schedules calibration and returns None, with execution moved to _scan_calibrate_light() during awake tasks.

This is a breaking contract between the public API (Protocol and base class) and the concrete implementation. Please align the API to the new scheduling model. Suggested change: make the public method “schedule-only” and return None consistently across API, base class, and implementations; document that actual success/failure will be surfaced via logs/events.

Proposed diff for this file:

-    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 scan_calibrate_light(self) -> None:
+        """Schedule light sensitivity calibration of a Scan device.
+
+        Description:
+            Schedules a light sensitivity calibration request to be performed
+            when the device is awake for maintenance. No return value.
+        """

Also update nodes/node.py to match (see scan.py review for a code block).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 scan_calibrate_light(self) -> None:
"""Schedule light sensitivity calibration of a Scan device.
Description:
Schedules a light sensitivity calibration request to be performed
when the device is awake for maintenance. No return value.
"""

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

Expand Down
5 changes: 3 additions & 2 deletions plugwise_usb/messages/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import logging
from typing import Any

from ..api import MotionSensitivity
from ..constants import (
DAY_IN_MINUTES,
HOUR_IN_MINUTES,
Expand Down Expand Up @@ -1376,14 +1377,14 @@ def __init__(
send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]],
mac: bytes,
reset_timer: int,
sensitivity: int,
sensitivity: MotionSensitivity,
light: bool,
):
"""Initialize ScanConfigureRequest message object."""
super().__init__(send_fn, mac)
reset_timer_value = Int(reset_timer, length=2)
# Sensitivity: HIGH(0x14), MEDIUM(0x1E), OFF(0xFF)
sensitivity_value = Int(sensitivity, length=2)
sensitivity_value = Int(sensitivity.value, length=2)
light_temp = 1 if light else 0
light_value = Int(light_temp, length=2)
self._args += [
Expand Down
39 changes: 21 additions & 18 deletions plugwise_usb/nodes/scan.py
Original file line number Diff line number Diff line change
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._motion_config.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,
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, str(self.reset_timer))
self._set_cache(
CACHE_SCAN_CONFIG_SENSITIVITY,
str(MotionSensitivity(self._motion_config.sensitivity_level).name),
)
self._set_cache(
CACHE_SCAN_CONFIG_DAYLIGHT_MODE, str(self._motion_config.daylight_mode)
self._motion_config.sensitivity_level.name,
)
self._set_cache(CACHE_SCAN_CONFIG_DIRTY, str(self._motion_config.dirty))
self._set_cache(CACHE_SCAN_CONFIG_DAYLIGHT_MODE, str(self.daylight_mode))
self._set_cache(CACHE_SCAN_CONFIG_DIRTY, str(self.dirty))
await gather(
self.publish_feature_update_to_subscribers(
NodeFeature.MOTION_CONFIG,
Expand All @@ -492,14 +490,19 @@ async def _scan_configure_update(self) -> None:
self.save_cache(),
)

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

Comment on lines +493 to +496
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Public API semantics changed; propagate to base class and Protocol

This method now schedules calibration and returns None. Please update:

  • PlugwiseNode Protocol (api.py) to -> None with a scheduling docstring (see api.py comment).
  • PlugwiseBaseNode (nodes/node.py) signature and NotImplementedError docstring accordingly.

You can keep _scan_calibrate_light() as the boolean-returning executor.

🤖 Prompt for AI Agents
In plugwise_usb/api.py and plugwise_usb/nodes/node.py (update the PlugwiseNode
Protocol and PlugwiseBaseNode method definitions respectively), change the
signature of scan_calibrate_light to async def scan_calibrate_light(self) ->
None and update the docstring to state this method schedules light sensitivity
calibration (does not run it) and returns None; in PlugwiseBaseNode adjust the
NotImplementedError text to instruct implementers to schedule calibration and
return None (keep the existing _scan_calibrate_light executor that returns a
boolean unchanged).

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
):
self._scan_calibrate_light_scheduled = False
return True
return False
raise NodeTimeout(
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.12a2"
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