diff --git a/CHANGELOG.md b/CHANGELOG.md index f34f0e54d..bc4fb4d4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index 0350fdadb..97873d328 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -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 @@ -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. diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index fc3e84c46..248d6b6c9 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -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, @@ -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 ) @@ -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( @@ -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 @@ -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 @@ -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, @@ -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( @@ -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, @@ -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]: diff --git a/pyproject.toml b/pyproject.toml index 6b79f94dd..a90631a75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/tests/test_usb.py b/tests/test_usb.py index 2c0917bc6..7b20edc5e 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -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 ( @@ -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) @@ -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) @@ -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) @@ -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( @@ -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