From 7cddfea6d30f24443760635a76b73bdae73d36a3 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Sat, 30 Aug 2025 08:54:18 +0200 Subject: [PATCH 01/33] add sense hysteresis request --- plugwise_usb/messages/requests.py | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 39e07a46b..f81cb3cc7 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -1404,6 +1404,55 @@ async def send(self) -> NodeAckResponse | None: ) +class SenseConfigureHysteresisRequest(PlugwiseRequest): + """Configure a Sense Hysteresis Switching Setting. + + temp_hum : configure temperature True or humidity False + lower_bound : lower bound of the hysteresis + upper_bound : upper bound of the hysteresis + direction : Switch active high or active low + + Response message: NodeAckResponse + """ + + _identifier = b"0104" + _reply_identifier = b"0100" + + # pylint: disable=too-many-arguments + def __init__( # noqa: PLR0913 + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + temp_hum: bool, + lower_bound: int, + upper_bound: int, + direction: bool, + ): + """Initialize ScanConfigureRequest message object.""" + super().__init__(send_fn, mac) + temp_hum_value = 1 if temp_hum else 0 + lower_bound_value = Int(lower_bound, length=4) + upper_bound_value = Int(upper_bound, length=4) + direction_value = 1 if direction else 0 + self._args += [ + temp_hum_value, + lower_bound_value, + upper_bound_value, + direction_value, + ] + + async def send(self) -> NodeAckResponse | None: + """Send request.""" + result = await self._send_request() + if isinstance(result, NodeAckResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeAckResponse" + ) + + class ScanLightCalibrateRequest(PlugwiseRequest): """Calibrate light sensitivity. From 1fa588058936c63b1d77d6cea4839b28da3101a5 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Sat, 30 Aug 2025 15:33:45 +0200 Subject: [PATCH 02/33] generate api class for humidity/temperature hysteresis --- plugwise_usb/api.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index 97873d328..e6e5ef761 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -268,6 +268,37 @@ class SenseStatistics: humidity: float | None = None +@dataclass(frozen=True) +class SenseHysteresisConfig: + """Configuration of sense hysteresis switch. + + Description: Configuration settings for sense hysteresis. + When value is scheduled to be changed the returned value is the optimistic value + + Attributes: + humidity_enabled: bool | None: enable humidity hysteresis + humidity_upper_bound: int | None: upper humidity switching value + humidity_lower_bound: int | None: lower humidity switching value + humidity_direction: bool | None: True switch on on increasing humidity, False switch off on increasing humidity + temperature_enabled: bool | None: enable temperature hysteresis + temperature_upper_bound: int | None: upper temperature switching value + temperature_lower_bound: int | None: lower temperature switching value + temperature_direction: bool | None: True switch on on increasing temperature, False switch off on increasing temperature + dirty: bool: Settings changed, device update pending + + """ + + humidity_enabled: bool | None = None + humidity_upper_bound: int | None = None + humidity_lower_bound: int | None = None + humidity_direction: bool | None = None + temperature_enabled: bool | None = None + temperature_upper_bound: int | None = None + temperature_lower_bound: int | None = None + temperature_direction: bool | None = None + dirty: bool = False + + class PlugwiseNode(Protocol): """Protocol definition of a Plugwise device node.""" From a3d3a56c31703c77e21370734e8dcc7f19c849ff Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Sun, 31 Aug 2025 12:50:38 +0200 Subject: [PATCH 03/33] reduce function calls return motion property on get_state --- plugwise_usb/nodes/scan.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 3607f870a..f88860a50 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -437,14 +437,6 @@ async def _configure_scan_task(self) -> bool: """Configure Scan device settings. Returns True if successful.""" if not self._motion_config.dirty: return True - if not await self.scan_configure(): - _LOGGER.debug("Motion Configuration for %s failed", self._mac_in_str) - return False - return True - - async def scan_configure(self) -> bool: - """Configure Scan device settings. Returns True if successful.""" - # Default to medium request = ScanConfigureRequest( self._send, self._mac_in_bytes, @@ -542,7 +534,7 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any case NodeFeature.MOTION: states[NodeFeature.MOTION] = self._motion_state case NodeFeature.MOTION_CONFIG: - states[NodeFeature.MOTION_CONFIG] = self._motion_config + states[NodeFeature.MOTION_CONFIG] = self.motion_config case _: state_result = await super().get_state((feature,)) if feature in state_result: From 639c83cc5cecb45622c8a104b070f8f0d100aa6a Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Sun, 31 Aug 2025 14:07:33 +0200 Subject: [PATCH 04/33] expand sense with hysteresis configuration and switch action --- plugwise_usb/api.py | 28 +- plugwise_usb/messages/requests.py | 6 +- plugwise_usb/nodes/helpers/firmware.py | 1 + plugwise_usb/nodes/sense.py | 627 ++++++++++++++++++++++++- 4 files changed, 645 insertions(+), 17 deletions(-) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index e6e5ef761..4f245f348 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -55,6 +55,7 @@ class NodeFeature(str, Enum): RELAY_LOCK = "relay_lock" SWITCH = "switch" SENSE = "sense" + SENSE_HYSTERESIS = "sense_hysteresis" TEMPERATURE = "temperature" @@ -260,14 +261,6 @@ class EnergyStatistics: day_production_reset: datetime | None = None -@dataclass -class SenseStatistics: - """Sense statistics collection.""" - - temperature: float | None = None - humidity: float | None = None - - @dataclass(frozen=True) class SenseHysteresisConfig: """Configuration of sense hysteresis switch. @@ -289,16 +282,27 @@ class SenseHysteresisConfig: """ humidity_enabled: bool | None = None - humidity_upper_bound: int | None = None - humidity_lower_bound: int | None = None + humidity_upper_bound: float | None = None + humidity_lower_bound: float | None = None humidity_direction: bool | None = None temperature_enabled: bool | None = None - temperature_upper_bound: int | None = None - temperature_lower_bound: int | None = None + temperature_upper_bound: float | None = None + temperature_lower_bound: float | None = None temperature_direction: bool | None = None dirty: bool = False +@dataclass +class SenseStatistics: + """Sense statistics collection.""" + + temperature: float | None = None + humidity: float | None = None + temperature_state: bool | None = None + temperature_state: bool | None = None + humidity_state: bool | None = None + + class PlugwiseNode(Protocol): """Protocol definition of a Plugwise device node.""" diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index f81cb3cc7..30fdbf0aa 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -1433,12 +1433,14 @@ def __init__( # noqa: PLR0913 temp_hum_value = 1 if temp_hum else 0 lower_bound_value = Int(lower_bound, length=4) upper_bound_value = Int(upper_bound, length=4) - direction_value = 1 if direction else 0 + direction_value_1 = 0 if direction else 1 + direction_value_2 = 1 if direction else 0 self._args += [ temp_hum_value, lower_bound_value, + direction_value_1, upper_bound_value, - direction_value, + direction_value_2, ] async def send(self) -> NodeAckResponse | None: diff --git a/plugwise_usb/nodes/helpers/firmware.py b/plugwise_usb/nodes/helpers/firmware.py index 1379b04c6..506a39703 100644 --- a/plugwise_usb/nodes/helpers/firmware.py +++ b/plugwise_usb/nodes/helpers/firmware.py @@ -159,6 +159,7 @@ class SupportedVersions(NamedTuple): NodeFeature.CIRCLEPLUS: 2.0, NodeFeature.INFO: 2.0, NodeFeature.SENSE: 2.0, + NodeFeature.SENSE_HYSTERESIS: 2.0, NodeFeature.TEMPERATURE: 2.0, NodeFeature.HUMIDITY: 2.0, NodeFeature.ENERGY: 2.0, diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index a7f7c431b..c5c72e57d 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -2,15 +2,31 @@ from __future__ import annotations +from asyncio import gather from collections.abc import Awaitable, Callable +from dataclasses import replace from datetime import UTC, datetime import logging from typing import Any, Final -from ..api import NodeEvent, NodeFeature, NodeType, SenseStatistics +from ..api import ( + NodeEvent, + NodeFeature, + NodeType, + SenseHysteresisConfig, + SenseStatistics, +) from ..connection import StickController from ..exceptions import MessageError, NodeError -from ..messages.responses import SENSE_REPORT_ID, PlugwiseResponse, SenseReportResponse +from ..messages.requests import SenseConfigureHysteresisRequest +from ..messages.responses import ( + NODE_SWITCH_GROUP_ID, + SENSE_REPORT_ID, + NodeAckResponseType, + NodeSwitchGroupResponse, + PlugwiseResponse, + SenseReportResponse, +) from ..nodes.sed import NodeSED from .helpers import raise_not_loaded from .helpers.firmware import SENSE_FIRMWARE_SUPPORT @@ -29,11 +45,32 @@ SENSE_FEATURES: Final = ( NodeFeature.INFO, NodeFeature.SENSE, + NodeFeature.SENSE_HYSTERESIS, + NodeFeature.SWITCH, ) # Default firmware if not known DEFAULT_FIRMWARE: Final = datetime(2010, 12, 3, 10, 17, 7, tzinfo=UTC) +CACHE_SENSE_HYSTERESIS_HUMIDITY_ENABLED = "humidity_enabled" +CACHE_SENSE_HYSTERESIS_HUMIDITY_UPPER_BOUND = "humidity_upper_bound" +CACHE_SENSE_HYSTERESIS_HUMIDITY_LOWER_BOUND = "humidity_lower_bound" +CACHE_SENSE_HYSTERESIS_HUMIDITY_DIRECTION = "humidity_direction" +CACHE_SENSE_HYSTERESIS_TEMPERATURE_ENABLED = "temperature_enabled" +CACHE_SENSE_HYSTERESIS_TEMPERATURE_UPPER_BOUND = "temperature_upper_bound" +CACHE_SENSE_HYSTERESIS_TEMPERATURE_LOWER_BOUND = "temperature_lower_bound" +CACHE_SENSE_HYSTERESIS_TEMPERATURE_DIRECTION = "temperature_direction" +CACHE_SENSE_HYSTERESIS_DIRTY = "sense_hysteresis_dirty" + +DEFAULT_SENSE_HYSTERESIS_HUMIDITY_ENABLED: Final = False +DEFAULT_SENSE_HYSTERESIS_HUMIDITY_UPPER_BOUND: Final = 24.0 +DEFAULT_SENSE_HYSTERESIS_HUMIDITY_LOWER_BOUND: Final = 24.0 +DEFAULT_SENSE_HYSTERESIS_HUMIDITY_DIRECTION: Final = True +DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_ENABLED: Final = False +DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_UPPER_BOUND: Final = 50.0 +DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_LOWER_BOUND: Final = 50.0 +DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_DIRECTION: Final = True + class PlugwiseSense(NodeSED): """Plugwise Sense node.""" @@ -49,8 +86,9 @@ def __init__( super().__init__(mac, node_type, controller, loaded_callback) self._sense_statistics = SenseStatistics() - + self._hysteresis_config = SenseHysteresisConfig() self._sense_subscription: Callable[[], None] | None = None + self._unsubscribe_switch_group: Callable[[], None] | None = None async def load(self) -> None: """Load and activate Sense node features.""" @@ -75,6 +113,11 @@ async def initialize(self) -> None: self._mac_in_bytes, (SENSE_REPORT_ID,), ) + self._unsubscribe_switch_group = await self._message_subscribe( + self._switch_group, + self._mac_in_bytes, + (NODE_SWITCH_GROUP_ID,), + ) await super().initialize() async def unload(self) -> None: @@ -82,6 +125,8 @@ async def unload(self) -> None: self._loaded = False if self._sense_subscription is not None: self._sense_subscription() + if self._unsubscribe_switch_group is not None: + self._unsubscribe_switch_group() await super().unload() # region Caching @@ -102,6 +147,119 @@ async def _load_defaults(self) -> None: self._node_info.firmware = DEFAULT_FIRMWARE self._sed_node_info_update_task_scheduled = True + async def _load_from_cache(self) -> bool: + """Load states from previous cached information. Returns True if successful.""" + super_load_success = True + if not await super()._load_from_cache(): + super_load_success = False + dirty = False + if (humidity_enabled := self._humidity_enabled_from_cache()) is None: + dirty = True + humidity_enabled = DEFAULT_SENSE_HYSTERESIS_HUMIDITY_ENABLED + if (humidity_upper_bound := self._humidity_upper_bound_from_cache()) is None: + dirty = True + humidity_upper_bound = DEFAULT_SENSE_HYSTERESIS_HUMIDITY_UPPER_BOUND + if (humidity_lower_bound := self._humidity_lower_bound_from_cache()) is None: + dirty = True + humidity_lower_bound = DEFAULT_SENSE_HYSTERESIS_HUMIDITY_LOWER_BOUND + if (humidity_direction := self._humidity_direction_from_cache()) is None: + dirty = True + humidity_direction = DEFAULT_SENSE_HYSTERESIS_HUMIDITY_DIRECTION + if (temperature_enabled := self._temperature_enabled_from_cache()) is None: + dirty = True + temperature_enabled = DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_ENABLED + if ( + temperature_upper_bound := self._temperature_upper_bound_from_cache() + ) is None: + dirty = True + temperature_upper_bound = DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_UPPER_BOUND + if ( + temperature_lower_bound := self._temperature_lower_bound_from_cache() + ) is None: + dirty = True + temperature_lower_bound = DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_LOWER_BOUND + if (temperature_direction := self._temperature_direction_from_cache()) is None: + dirty = True + temperature_direction = DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_DIRECTION + dirty |= self._sense_hysteresis_dirty_from_cache() + + self._hysteresis_config = SenseHysteresisConfig( + humidity_enabled=humidity_enabled, + humidity_upper_bound=humidity_upper_bound, + humidity_lower_bound=humidity_lower_bound, + humidity_direction=humidity_direction, + temperature_enabled=temperature_enabled, + temperature_upper_bound=temperature_upper_bound, + temperature_lower_bound=temperature_lower_bound, + temperature_direction=temperature_direction, + dirty=dirty, + ) + if dirty: + await self._sense_configure_update() + return super_load_success + + def _humidity_enabled_from_cache(self) -> bool | None: + """Load humidity hysteresis enabled from cache.""" + return self._get_cache_as_bool(CACHE_SENSE_HYSTERESIS_HUMIDITY_ENABLED) + + def _humidity_upper_bound_from_cache(self) -> float | None: + """Load humidity upper bound from cache.""" + if ( + humidity_upper_bound := self._get_cache( + CACHE_SENSE_HYSTERESIS_HUMIDITY_UPPER_BOUND + ) + ) is not None: + return float(humidity_upper_bound) + return None + + def _humidity_lower_bound_from_cache(self) -> float | None: + """Load humidity lower bound from cache.""" + if ( + humidity_lower_bound := self._get_cache( + CACHE_SENSE_HYSTERESIS_HUMIDITY_LOWER_BOUND + ) + ) is not None: + return float(humidity_lower_bound) + return None + + def _humidity_direction_from_cache(self) -> bool | None: + """Load humidity hysteresis switch direction from cache.""" + return self._get_cache_as_bool(CACHE_SENSE_HYSTERESIS_HUMIDITY_DIRECTION) + + def _temperature_enabled_from_cache(self) -> bool | None: + """Load temperature hysteresis enabled from cache.""" + return self._get_cache_as_bool(CACHE_SENSE_HYSTERESIS_TEMPERATURE_ENABLED) + + def _temperature_upper_bound_from_cache(self) -> float | None: + """Load temperature upper bound from cache.""" + if ( + temperature_upper_bound := self._get_cache( + CACHE_SENSE_HYSTERESIS_TEMPERATURE_UPPER_BOUND + ) + ) is not None: + return float(temperature_upper_bound) + return None + + def _temperature_lower_bound_from_cache(self) -> float | None: + """Load temperature lower bound from cache.""" + if ( + temperature_lower_bound := self._get_cache( + CACHE_SENSE_HYSTERESIS_TEMPERATURE_LOWER_BOUND + ) + ) is not None: + return float(temperature_lower_bound) + return None + + def _temperature_direction_from_cache(self) -> bool | None: + """Load Temperature hysteresis switch direction from cache.""" + return self._get_cache_as_bool(CACHE_SENSE_HYSTERESIS_TEMPERATURE_DIRECTION) + + def _sense_hysteresis_dirty_from_cache(self) -> bool: + """Load sense hysteresis dirty from cache.""" + if (dirty := self._get_cache_as_bool(CACHE_SENSE_HYSTERESIS_DIRTY)) is not None: + return dirty + return True + # endregion # region properties @@ -112,8 +270,317 @@ def sense_statistics(self) -> SenseStatistics: """Sense Statistics.""" return self._sense_statistics + @property + def hysteresis_config(self) -> SenseHysteresisConfig: + """Sense Hysteresis Configuration.""" + return SenseHysteresisConfig( + humidity_enabled=self.humidity_enabled, + humidity_upper_bound=self.humidity_upper_bound, + humidity_lower_bound=self.humidity_lower_bound, + humidity_direction=self.humidity_direction, + temperature_enabled=self.temperature_enabled, + temperature_upper_bound=self.temperature_upper_bound, + temperature_lower_bound=self.temperature_lower_bound, + temperature_direction=self.temperature_direction, + dirty=self.dirty, + ) + + @property + def humidity_enabled(self) -> bool: + """Humidity hysteresis enabled flag.""" + if self._hysteresis_config.humidity_enabled is not None: + return self._hysteresis_config.humidity_enabled + return DEFAULT_SENSE_HYSTERESIS_HUMIDITY_ENABLED + + @property + def humidity_upper_bound(self) -> float: + """Humidity upper bound value.""" + if self._hysteresis_config.humidity_upper_bound is not None: + return self._hysteresis_config.humidity_upper_bound + return DEFAULT_SENSE_HYSTERESIS_HUMIDITY_UPPER_BOUND + + @property + def humidity_lower_bound(self) -> float: + """Humidity lower bound value.""" + if self._hysteresis_config.humidity_lower_bound is not None: + return self._hysteresis_config.humidity_lower_bound + return DEFAULT_SENSE_HYSTERESIS_HUMIDITY_LOWER_BOUND + + @property + def humidity_direction(self) -> bool: + """Humidity hysteresis switch direction.""" + if self._hysteresis_config.humidity_direction is not None: + return self._hysteresis_config.humidity_direction + return DEFAULT_SENSE_HYSTERESIS_HUMIDITY_DIRECTION + + @property + def temperature_enabled(self) -> bool: + """Temperature hysteresis enabled flag.""" + if self._hysteresis_config.temperature_enabled is not None: + return self._hysteresis_config.temperature_enabled + return DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_ENABLED + + @property + def temperature_upper_bound(self) -> float: + """Temperature upper bound value.""" + if self._hysteresis_config.temperature_upper_bound is not None: + return self._hysteresis_config.temperature_upper_bound + return DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_UPPER_BOUND + + @property + def temperature_lower_bound(self) -> float: + """Temperature lower bound value.""" + if self._hysteresis_config.temperature_lower_bound is not None: + return self._hysteresis_config.temperature_lower_bound + return DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_LOWER_BOUND + + @property + def temperature_direction(self) -> bool: + """Temperature hysteresis switch direction.""" + if self._hysteresis_config.temperature_direction is not None: + return self._hysteresis_config.temperature_direction + return DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_DIRECTION + + # end region + + # region Configuration actions + + async def set_hysteresis_humidity_enabled(self, state: bool) -> bool: + """Configure if humidity hysteresis should be enabled or not. + + Configuration request will be queued and will be applied the next time when node is awake for maintenance. + """ + _LOGGER.debug( + "set_hysteresis_humidity_enabled | Device %s | %s -> %s", + self.name, + self._hysteresis_config.humidity_enabled, + state, + ) + if self._hysteresis_config.humidity_enabled == state: + return False + self._hysteresis_config = replace( + self._hysteresis_config, + humidity_enabled=state, + dirty=True, + ) + await self._sense_configure_update() + return True + + async def set_hysteresis_humidity_upper_bound(self, upper_bound: float) -> bool: + """Configure humidity hysteresis upper bound. + + Configuration request will be queued and will be applied the next time when node is awake for maintenance. + """ + _LOGGER.debug( + "set_hysteresis_humidity_upper_bound | Device %s | %s -> %s", + self.name, + self._hysteresis_config.humidity_upper_bound, + upper_bound, + ) + if upper_bound < 1 or upper_bound > 128: + raise ValueError( + "Invalid humidity upper bound {upper_bound}. It must be between 1 and 128 percent." + ) + if upper_bound < self._hysteresis_config.humidity_lower_bound: + raise ValueError( + "Invalid humidity upper bound {upper_bound}. It must be equal or above the lower bound {self._hysteresis_config.humidity_lower_bound}." + ) + if self._hysteresis_config.humidity_upper_bound == upper_bound: + return False + self._hysteresis_config = replace( + self._hysteresis_config, + upper_bound=upper_bound, + dirty=True, + ) + await self._sense_configure_update() + return True + + async def set_hysteresis_humidity_lower_bound(self, lower_bound: float) -> bool: + """Configure humidity hysteresis lower bound. + + Configuration request will be queued and will be applied the next time when node is awake for maintenance. + """ + _LOGGER.debug( + "set_hysteresis_humidity_lower_bound | Device %s | %s -> %s", + self.name, + self._hysteresis_config.humidity_lower_bound, + lower_bound, + ) + if lower_bound < 1 or lower_bound > 128: + raise ValueError( + "Invalid humidity lower bound {lower_bound}. It must be between 1 and 128 percent." + ) + if lower_bound > self._hysteresis_config.humidity_upper_bound: + raise ValueError( + "Invalid humidity lower bound {lower_bound}. It must be equal or above the lower bound {self._hysteresis_config.humidity_lower_bound}." + ) + if self._hysteresis_config.humidity_lower_bound == lower_bound: + return False + self._hysteresis_config = replace( + self._hysteresis_config, + lower_bound=lower_bound, + dirty=True, + ) + await self._sense_configure_update() + return True + + async def set_hysteresis_humidity_direction(self, state: bool) -> bool: + """Configure humitidy hysteresis to switch on or off on increase or decreasing direction. + + Configuration request will be queued and will be applied the next time when node is awake for maintenance. + """ + _LOGGER.debug( + "set_hysteresis_humidity_direction | Device %s | %s -> %s", + self.name, + self._hysteresis_config.humidity_direction, + state, + ) + if self._hysteresis_config.humidity_direction == state: + return False + self._hysteresis_config = replace( + self._hysteresis_config, + humidity_direction=state, + dirty=True, + ) + await self._sense_configure_update() + return True + + async def set_hysteresis_temperature_enabled(self, state: bool) -> bool: + """Configure if temperature hysteresis should be enabled or not. + + Configuration request will be queued and will be applied the next time when node is awake for maintenance. + """ + _LOGGER.debug( + "set_hysteresis_temperature_enabled | Device %s | %s -> %s", + self.name, + self._hysteresis_config.temperature_enabled, + state, + ) + if self._hysteresis_config.temperature_enabled == state: + return False + self._hysteresis_config = replace( + self._hysteresis_config, + temperature_enabled=state, + dirty=True, + ) + await self._sense_configure_update() + return True + + async def set_hysteresis_temperature_upper_bound(self, upper_bound: float) -> bool: + """Configure temperature hysteresis upper bound. + + Configuration request will be queued and will be applied the next time when node is awake for maintenance. + """ + _LOGGER.debug( + "set_hysteresis_temperature_upper_bound | Device %s | %s -> %s", + self.name, + self._hysteresis_config.temperature_upper_bound, + upper_bound, + ) + if upper_bound < 1 or upper_bound > 128: + raise ValueError( + "Invalid temperature upper bound {upper_bound}. It must be between 1 and 128 percent." + ) + if upper_bound < self._hysteresis_config.temperature_lower_bound: + raise ValueError( + "Invalid temperature upper bound {upper_bound}. It must be equal or above the lower bound {self._hysteresis_config.temperature_lower_bound}." + ) + if self._hysteresis_config.temperature_upper_bound == upper_bound: + return False + self._hysteresis_config = replace( + self._hysteresis_config, + upper_bound=upper_bound, + dirty=True, + ) + await self._sense_configure_update() + return True + + async def set_hysteresis_temperature_lower_bound(self, lower_bound: float) -> bool: + """Configure temperature hysteresis lower bound. + + Configuration request will be queued and will be applied the next time when node is awake for maintenance. + """ + _LOGGER.debug( + "set_hysteresis_temperature_lower_bound | Device %s | %s -> %s", + self.name, + self._hysteresis_config.temperature_lower_bound, + lower_bound, + ) + if lower_bound < 1 or lower_bound > 128: + raise ValueError( + "Invalid temperature lower bound {lower_bound}. It must be between 1 and 128 percent." + ) + if lower_bound > self._hysteresis_config.temperature_upper_bound: + raise ValueError( + "Invalid temperature lower bound {lower_bound}. It must be equal or below the upper bound {self._hysteresis_config.temperature_upper_bound}." + ) + if self._hysteresis_config.temperature_lower_bound == lower_bound: + return False + self._hysteresis_config = replace( + self._hysteresis_config, + lower_bound=lower_bound, + dirty=True, + ) + await self._sense_configure_update() + return True + + async def set_hysteresis_temperature_direction(self, state: bool) -> bool: + """Configure humitidy hysteresis to switch on or off on increase or decreasing direction. + + Configuration request will be queued and will be applied the next time when node is awake for maintenance. + """ + _LOGGER.debug( + "set_hysteresis_temperature_direction | Device %s | %s -> %s", + self.name, + self._hysteresis_config.temperature_direction, + state, + ) + if self._hysteresis_config.temperature_direction == state: + return False + self._hysteresis_config = replace( + self._hysteresis_config, + temperature_direction=state, + dirty=True, + ) + await self._sense_configure_update() + return True + # end region + async def _switch_group(self, response: PlugwiseResponse) -> bool: + """Switch group request from Sense. + + turn on/off based on hysteresis and direction. + """ + if not isinstance(response, NodeSwitchGroupResponse): + raise MessageError( + f"Invalid response message type ({response.__class__.__name__}) received, expected NodeSwitchGroupResponse" + ) + _LOGGER.warning("%s received %s", self.name, response) + await gather( + self._available_update_state(True, response.timestamp), + self._hysteresis_state_update(response.switch_state, response.timestamp), + ) + return True + + async def _hysteresis_state_update( + self, switch_state: bool, switch_group: int, timestamp: datetime + ) -> None: + """Process hysteresis state update.""" + _LOGGER.debug( + "_hysteresis_state_update for %s: %s", + self.name, + switch_state, + ) + if switch_group == 1: + self._sense_statistics.temperature_state = switch_state + if switch_group == 2: + self._sense_statistics.humidity_state = switch_state + + await self.publish_feature_update_to_subscribers( + NodeFeature.SENSE, self._sense_statistics + ) + async def _sense_report(self, response: PlugwiseResponse) -> bool: """Process sense report message to extract current temperature and humidity values.""" if not isinstance(response, SenseReportResponse): @@ -143,6 +610,158 @@ async def _sense_report(self, response: PlugwiseResponse) -> bool: return report_received + async def _run_awake_tasks(self) -> None: + """Execute all awake tasks.""" + await super()._run_awake_tasks() + if self._hysteresis_config.dirty: + await self._configure_sense_humidity_task() + await self._configure_sense_temperature_task() + + async def _configure_sense_humidity_task(self) -> bool: + """Configure Sense humidity hysteresis device settings. Returns True if successful.""" + if not self._hysteresis_config.dirty: + return True + # Set value to -1 for disabled + humidity_lower_bound = 2621 + humidity_upper_bound = 2621 + if self.humidity_enabled: + if self.humidity_lower_bound > self.humidity_upper_bound: + raise ValueError( + "Invalid humidity lower bound {self.humidity_lower_bound}. It must be equal or below the upper bound {self.humidity_upper_bound}." + ) + humidity_lower_bound = int( + (self.humidity_lower_bound + SENSE_HUMIDITY_OFFSET) + * 65536 + / SENSE_HUMIDITY_MULTIPLIER + ) + humidity_upper_bound = int( + (self.humidity_upper_bound + SENSE_HUMIDITY_OFFSET) + * 65536 + / SENSE_HUMIDITY_MULTIPLIER + ) + request = SenseConfigureHysteresisRequest( + self._send, + self._mac_in_bytes, + False, + humidity_lower_bound, + humidity_upper_bound, + self.humidity_direction, + ) + if (response := await request.send()) is None: + _LOGGER.warning( + "No response from %s to configure humidity hysteresis settings request", + self.name, + ) + return False + if response.node_ack_type == NodeAckResponseType.SENSE_BOUNDARIES_FAILED: + _LOGGER.warning( + "Failed to configure humidity hysteresis settings for %s", self.name + ) + return False + if response.node_ack_type == NodeAckResponseType.SENSE_BOUNDARIES_ACCEPTED: + _LOGGER.debug( + "Successful configure humidity hysteresis settings for %s", self.name + ) + self._hysteresis_config = replace(self._hysteresis_config, dirty=False) + await self._sense_configure_update() + return True + + _LOGGER.warning( + "Unexpected response ack type %s for %s", + response.node_ack_type, + self.name, + ) + return False + + async def _configure_sense_temperature_task(self) -> bool: + """Configure Sense temperature hysteresis device settings. Returns True if successful.""" + if not self._hysteresis_config.dirty: + return True + # Set value to -1 for disabled + temperature_lower_bound = 17099 + temperature_upper_bound = 17099 + if self.temperature_enabled: + if self.temperature_lower_bound > self.temperature_upper_bound: + raise ValueError( + "Invalid temperature lower bound {self.temperature_lower_bound}. It must be equal or below the upper bound {self.temperature_upper_bound}." + ) + temperature_lower_bound = int( + (self.temperature_lower_bound + SENSE_TEMPERATURE_OFFSET) + * 65536 + / SENSE_TEMPERATURE_MULTIPLIER + ) + temperature_upper_bound = int( + (self.temperature_upper_bound + SENSE_TEMPERATURE_OFFSET) + * 65536 + / SENSE_TEMPERATURE_MULTIPLIER + ) + request = SenseConfigureHysteresisRequest( + self._send, + self._mac_in_bytes, + False, + temperature_lower_bound, + temperature_upper_bound, + self.temperature_direction, + ) + if (response := await request.send()) is None: + _LOGGER.warning( + "No response from %s to configure temperature hysteresis settings request", + self.name, + ) + return False + if response.node_ack_type == NodeAckResponseType.SENSE_BOUNDARIES_FAILED: + _LOGGER.warning( + "Failed to configure temperature hysteresis settings for %s", self.name + ) + return False + if response.node_ack_type == NodeAckResponseType.SENSE_BOUNDARIES_ACCEPTED: + _LOGGER.debug( + "Successful configure temperature hysteresis settings for %s", self.name + ) + self._hysteresis_config = replace(self._hysteresis_config, dirty=False) + await self._sense_configure_update() + return True + + _LOGGER.warning( + "Unexpected response ack type %s for %s", + response.node_ack_type, + self.name, + ) + return False + + async def _sense_configure_update(self) -> None: + """Push sense configuration update to cache.""" + self._set_cache(CACHE_SENSE_HYSTERESIS_HUMIDITY_ENABLED, self.humidity_enabled) + self._set_cache( + CACHE_SENSE_HYSTERESIS_HUMIDITY_UPPER_BOUND, self.humidity_upper_bound + ) + self._set_cache( + CACHE_SENSE_HYSTERESIS_HUMIDITY_LOWER_BOUND, self.humidity_lower_bound + ) + self._set_cache( + CACHE_SENSE_HYSTERESIS_HUMIDITY_DIRECTION, self.humidity_direction + ) + self._set_cache( + CACHE_SENSE_HYSTERESIS_TEMPERATURE_ENABLED, self.temperature_enabled + ) + self._set_cache( + CACHE_SENSE_HYSTERESIS_TEMPERATURE_UPPER_BOUND, self.temperature_upper_bound + ) + self._set_cache( + CACHE_SENSE_HYSTERESIS_TEMPERATURE_LOWER_BOUND, self.temperature_lower_bound + ) + self._set_cache( + CACHE_SENSE_HYSTERESIS_TEMPERATURE_DIRECTION, self.temperature_direction + ) + self._set_cache(CACHE_SENSE_HYSTERESIS_DIRTY, self.dirty) + await gather( + self.publish_feature_update_to_subscribers( + NodeFeature.SENSE_HYSTERESIS, + self._hysteresis_config, + ), + self.save_cache(), + ) + @raise_not_loaded async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" @@ -163,6 +782,8 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any states[NodeFeature.PING] = await self.ping_update() case NodeFeature.SENSE: states[NodeFeature.SENSE] = self._sense_statistics + case NodeFeature.SENSE_HYSTERESIS: + states[NodeFeature.SENSE_HYSTERESIS] = self.hysteresis_config case _: state_result = await super().get_state((feature,)) if feature in state_result: From ab8a2c19eed85e63e4707eaff2f99cfe1110c1aa Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Sun, 31 Aug 2025 14:38:11 +0200 Subject: [PATCH 05/33] CR: implement most of the proposed fixes --- plugwise_usb/api.py | 1 - plugwise_usb/messages/requests.py | 6 +-- plugwise_usb/nodes/sense.py | 65 ++++++++++++++++++------------- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index 4f245f348..14518b9b4 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -299,7 +299,6 @@ class SenseStatistics: temperature: float | None = None humidity: float | None = None temperature_state: bool | None = None - temperature_state: bool | None = None humidity_state: bool | None = None diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 30fdbf0aa..1a60b271d 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -1430,11 +1430,11 @@ def __init__( # noqa: PLR0913 ): """Initialize ScanConfigureRequest message object.""" super().__init__(send_fn, mac) - temp_hum_value = 1 if temp_hum else 0 + temp_hum_value = Int(1 if temp_hum else 0, length=2) lower_bound_value = Int(lower_bound, length=4) upper_bound_value = Int(upper_bound, length=4) - direction_value_1 = 0 if direction else 1 - direction_value_2 = 1 if direction else 0 + direction_value_1 = Int(0 if direction else 1, length=2) + direction_value_2 = Int(1 if direction else 0, length=2) self._args += [ temp_hum_value, lower_bound_value, diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index c5c72e57d..e00eb12c3 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -377,19 +377,22 @@ async def set_hysteresis_humidity_upper_bound(self, upper_bound: float) -> bool: self._hysteresis_config.humidity_upper_bound, upper_bound, ) - if upper_bound < 1 or upper_bound > 128: + if upper_bound < 1 or upper_bound > 99: raise ValueError( - "Invalid humidity upper bound {upper_bound}. It must be between 1 and 128 percent." + f"Invalid humidity upper bound {upper_bound}. It must be between 1 and 99 percent." ) - if upper_bound < self._hysteresis_config.humidity_lower_bound: + if ( + self._hysteresis_config.humidity_lower_bound is not None + and upper_bound < self._hysteresis_config.humidity_lower_bound + ): raise ValueError( - "Invalid humidity upper bound {upper_bound}. It must be equal or above the lower bound {self._hysteresis_config.humidity_lower_bound}." + f"Invalid humidity upper bound {upper_bound}. It must be equal or above the lower bound {self._hysteresis_config.humidity_lower_bound}." ) if self._hysteresis_config.humidity_upper_bound == upper_bound: return False self._hysteresis_config = replace( self._hysteresis_config, - upper_bound=upper_bound, + humidity_upper_bound=upper_bound, dirty=True, ) await self._sense_configure_update() @@ -406,19 +409,22 @@ async def set_hysteresis_humidity_lower_bound(self, lower_bound: float) -> bool: self._hysteresis_config.humidity_lower_bound, lower_bound, ) - if lower_bound < 1 or lower_bound > 128: + if lower_bound < 1 or lower_bound > 99: raise ValueError( - "Invalid humidity lower bound {lower_bound}. It must be between 1 and 128 percent." + f"Invalid humidity lower bound {lower_bound}. It must be between 1 and 99 percent." ) - if lower_bound > self._hysteresis_config.humidity_upper_bound: + if ( + self._hysteresis_config.humidity_upper_bound is not None + and lower_bound > self._hysteresis_config.humidity_upper_bound + ): raise ValueError( - "Invalid humidity lower bound {lower_bound}. It must be equal or above the lower bound {self._hysteresis_config.humidity_lower_bound}." + f"Invalid humidity lower bound {lower_bound}. It must be equal or above the lower bound {self._hysteresis_config.humidity_lower_bound}." ) if self._hysteresis_config.humidity_lower_bound == lower_bound: return False self._hysteresis_config = replace( self._hysteresis_config, - lower_bound=lower_bound, + humidity_lower_bound=lower_bound, dirty=True, ) await self._sense_configure_update() @@ -477,19 +483,22 @@ async def set_hysteresis_temperature_upper_bound(self, upper_bound: float) -> bo self._hysteresis_config.temperature_upper_bound, upper_bound, ) - if upper_bound < 1 or upper_bound > 128: + if upper_bound < 1 or upper_bound > 60: raise ValueError( - "Invalid temperature upper bound {upper_bound}. It must be between 1 and 128 percent." + f"Invalid temperature upper bound {upper_bound}. It must be between 1 and 60 degrees." ) - if upper_bound < self._hysteresis_config.temperature_lower_bound: + if ( + self._hysteresis_config.temperature_lower_bound is not None + and upper_bound < self._hysteresis_config.temperature_lower_bound + ): raise ValueError( - "Invalid temperature upper bound {upper_bound}. It must be equal or above the lower bound {self._hysteresis_config.temperature_lower_bound}." + f"Invalid temperature upper bound {upper_bound}. It must be equal or above the lower bound {self._hysteresis_config.temperature_lower_bound}." ) if self._hysteresis_config.temperature_upper_bound == upper_bound: return False self._hysteresis_config = replace( self._hysteresis_config, - upper_bound=upper_bound, + temperature_upper_bound=upper_bound, dirty=True, ) await self._sense_configure_update() @@ -506,19 +515,22 @@ async def set_hysteresis_temperature_lower_bound(self, lower_bound: float) -> bo self._hysteresis_config.temperature_lower_bound, lower_bound, ) - if lower_bound < 1 or lower_bound > 128: + if lower_bound < 1 or lower_bound > 60: raise ValueError( - "Invalid temperature lower bound {lower_bound}. It must be between 1 and 128 percent." + f"Invalid temperature lower bound {lower_bound}. It must be between 1 and 60 degrees." ) - if lower_bound > self._hysteresis_config.temperature_upper_bound: + if ( + self._hysteresis_config.temperature_upper_bound is not None + and lower_bound > self._hysteresis_config.temperature_upper_bound + ): raise ValueError( - "Invalid temperature lower bound {lower_bound}. It must be equal or below the upper bound {self._hysteresis_config.temperature_upper_bound}." + f"Invalid temperature lower bound {lower_bound}. It must be equal or below the upper bound {self._hysteresis_config.temperature_upper_bound}." ) if self._hysteresis_config.temperature_lower_bound == lower_bound: return False self._hysteresis_config = replace( self._hysteresis_config, - lower_bound=lower_bound, + temperature_lower_bound=lower_bound, dirty=True, ) await self._sense_configure_update() @@ -614,8 +626,13 @@ async def _run_awake_tasks(self) -> None: """Execute all awake tasks.""" await super()._run_awake_tasks() if self._hysteresis_config.dirty: - await self._configure_sense_humidity_task() - await self._configure_sense_temperature_task() + configure_result = await gather( + self._configure_sense_humidity_task(), + self._configure_sense_temperature_task(), + ) + if not all(configure_result): + self._hysteresis_config = replace(self._hysteresis_config, dirty=False) + await self._sense_configure_update() async def _configure_sense_humidity_task(self) -> bool: """Configure Sense humidity hysteresis device settings. Returns True if successful.""" @@ -662,8 +679,6 @@ async def _configure_sense_humidity_task(self) -> bool: _LOGGER.debug( "Successful configure humidity hysteresis settings for %s", self.name ) - self._hysteresis_config = replace(self._hysteresis_config, dirty=False) - await self._sense_configure_update() return True _LOGGER.warning( @@ -718,8 +733,6 @@ async def _configure_sense_temperature_task(self) -> bool: _LOGGER.debug( "Successful configure temperature hysteresis settings for %s", self.name ) - self._hysteresis_config = replace(self._hysteresis_config, dirty=False) - await self._sense_configure_update() return True _LOGGER.warning( From 04043d2439f1d862443d1a317d4a7f7753c17a93 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Sun, 31 Aug 2025 14:44:32 +0200 Subject: [PATCH 06/33] missed one fix --- plugwise_usb/nodes/sense.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index e00eb12c3..705b992d1 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -571,7 +571,11 @@ async def _switch_group(self, response: PlugwiseResponse) -> bool: _LOGGER.warning("%s received %s", self.name, response) await gather( self._available_update_state(True, response.timestamp), - self._hysteresis_state_update(response.switch_state, response.timestamp), + self._hysteresis_state_update( + response.switch_state, + response.switch_group, + response.timestamp, + ), ) return True From eae7fa766d46289ba6c8682e3f1cd36ccaef63dd Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Sun, 31 Aug 2025 16:33:42 +0200 Subject: [PATCH 07/33] further CR fixes --- plugwise_usb/nodes/sense.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 705b992d1..f774c500c 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -341,6 +341,11 @@ def temperature_direction(self) -> bool: return self._hysteresis_config.temperature_direction return DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_DIRECTION + @property + def dirty(self) -> bool: + """Sense hysteresis configuration dirty flag.""" + return self._hysteresis_config.dirty + # end region # region Configuration actions @@ -634,9 +639,17 @@ async def _run_awake_tasks(self) -> None: self._configure_sense_humidity_task(), self._configure_sense_temperature_task(), ) - if not all(configure_result): + if all(configure_result): self._hysteresis_config = replace(self._hysteresis_config, dirty=False) await self._sense_configure_update() + else: + _LOGGER.warning( + "Sense hysteresis configuration partially failed for %s " + "(humidity=%s, temperature=%s); will retry on next wake.", + self.name, + configure_result[0], + configure_result[1], + ) async def _configure_sense_humidity_task(self) -> bool: """Configure Sense humidity hysteresis device settings. Returns True if successful.""" @@ -648,7 +661,7 @@ async def _configure_sense_humidity_task(self) -> bool: if self.humidity_enabled: if self.humidity_lower_bound > self.humidity_upper_bound: raise ValueError( - "Invalid humidity lower bound {self.humidity_lower_bound}. It must be equal or below the upper bound {self.humidity_upper_bound}." + f"Invalid humidity lower bound {self.humidity_lower_bound}. It must be equal or below the upper bound {self.humidity_upper_bound}." ) humidity_lower_bound = int( (self.humidity_lower_bound + SENSE_HUMIDITY_OFFSET) @@ -702,7 +715,7 @@ async def _configure_sense_temperature_task(self) -> bool: if self.temperature_enabled: if self.temperature_lower_bound > self.temperature_upper_bound: raise ValueError( - "Invalid temperature lower bound {self.temperature_lower_bound}. It must be equal or below the upper bound {self.temperature_upper_bound}." + f"Invalid temperature lower bound {self.temperature_lower_bound}. It must be equal or below the upper bound {self.temperature_upper_bound}." ) temperature_lower_bound = int( (self.temperature_lower_bound + SENSE_TEMPERATURE_OFFSET) From b9fca5057cef565e3f370e1a55adc33af0ca33f8 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Sun, 31 Aug 2025 17:03:08 +0200 Subject: [PATCH 08/33] improve naming of dirty bit --- plugwise_usb/nodes/sense.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index f774c500c..3898feb89 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -60,7 +60,7 @@ CACHE_SENSE_HYSTERESIS_TEMPERATURE_UPPER_BOUND = "temperature_upper_bound" CACHE_SENSE_HYSTERESIS_TEMPERATURE_LOWER_BOUND = "temperature_lower_bound" CACHE_SENSE_HYSTERESIS_TEMPERATURE_DIRECTION = "temperature_direction" -CACHE_SENSE_HYSTERESIS_DIRTY = "sense_hysteresis_dirty" +CACHE_SENSE_HYSTERESIS_CONFIG_DIRTY = "sense_hysteresis_config_dirty" DEFAULT_SENSE_HYSTERESIS_HUMIDITY_ENABLED: Final = False DEFAULT_SENSE_HYSTERESIS_HUMIDITY_UPPER_BOUND: Final = 24.0 @@ -181,7 +181,7 @@ async def _load_from_cache(self) -> bool: if (temperature_direction := self._temperature_direction_from_cache()) is None: dirty = True temperature_direction = DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_DIRECTION - dirty |= self._sense_hysteresis_dirty_from_cache() + dirty |= self._sense_hysteresis_config_dirty_from_cache() self._hysteresis_config = SenseHysteresisConfig( humidity_enabled=humidity_enabled, @@ -254,9 +254,11 @@ def _temperature_direction_from_cache(self) -> bool | None: """Load Temperature hysteresis switch direction from cache.""" return self._get_cache_as_bool(CACHE_SENSE_HYSTERESIS_TEMPERATURE_DIRECTION) - def _sense_hysteresis_dirty_from_cache(self) -> bool: + def _sense_hysteresis_config_dirty_from_cache(self) -> bool: """Load sense hysteresis dirty from cache.""" - if (dirty := self._get_cache_as_bool(CACHE_SENSE_HYSTERESIS_DIRTY)) is not None: + if ( + dirty := self._get_cache_as_bool(CACHE_SENSE_HYSTERESIS_CONFIG_DIRTY) + ) is not None: return dirty return True @@ -282,7 +284,7 @@ def hysteresis_config(self) -> SenseHysteresisConfig: temperature_upper_bound=self.temperature_upper_bound, temperature_lower_bound=self.temperature_lower_bound, temperature_direction=self.temperature_direction, - dirty=self.dirty, + dirty=self.hysteresis_config_dirty, ) @property @@ -342,7 +344,7 @@ def temperature_direction(self) -> bool: return DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_DIRECTION @property - def dirty(self) -> bool: + def hysteresis_config_dirty(self) -> bool: """Sense hysteresis configuration dirty flag.""" return self._hysteresis_config.dirty @@ -783,7 +785,9 @@ async def _sense_configure_update(self) -> None: self._set_cache( CACHE_SENSE_HYSTERESIS_TEMPERATURE_DIRECTION, self.temperature_direction ) - self._set_cache(CACHE_SENSE_HYSTERESIS_DIRTY, self.dirty) + self._set_cache( + CACHE_SENSE_HYSTERESIS_CONFIG_DIRTY, self.hysteresis_config_dirty + ) await gather( self.publish_feature_update_to_subscribers( NodeFeature.SENSE_HYSTERESIS, From bfcfc9ca7f97462a8c45aa16b9109205b3643ad1 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 1 Sep 2025 09:34:55 +0200 Subject: [PATCH 09/33] remove NodeFeature.SWITCH --- plugwise_usb/nodes/sense.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 3898feb89..cf866b6ef 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -46,7 +46,6 @@ NodeFeature.INFO, NodeFeature.SENSE, NodeFeature.SENSE_HYSTERESIS, - NodeFeature.SWITCH, ) # Default firmware if not known From 6967202416e26056baca6ba3908d92388967b657 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 1 Sep 2025 11:36:55 +0200 Subject: [PATCH 10/33] initial testing code addition --- tests/test_usb.py | 250 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index be5b74e13..be35594ef 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -36,6 +36,7 @@ pw_circle = importlib.import_module("plugwise_usb.nodes.circle") pw_sed = importlib.import_module("plugwise_usb.nodes.sed") pw_scan = importlib.import_module("plugwise_usb.nodes.scan") +pw_sense = importlib.import_module("plugwise_usb.nodes.sense") pw_switch = importlib.import_module("plugwise_usb.nodes.switch") pw_energy_counter = importlib.import_module("plugwise_usb.nodes.helpers.counter") pw_energy_calibration = importlib.import_module("plugwise_usb.nodes.helpers") @@ -2312,6 +2313,255 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) assert not state[pw_api.NodeFeature.AVAILABLE].state + @pytest.mark.asyncio + async def test_sense_node(self, monkeypatch: pytest.MonkeyPatch) -> None: # noqa: PLR0915 + """Testing properties of sense.""" + + def fake_cache(dummy: object, setting: str) -> str | None: # noqa: PLR0911 PLR0912 + """Fake cache retrieval.""" + if setting == pw_node.CACHE_FIRMWARE: + return "2011-11-3-13-7-33" + if setting == pw_node.CACHE_HARDWARE: + return "070030" + if setting == pw_node.CACHE_RELAY: + return "True" + if setting == pw_node.CACHE_NODE_INFO_TIMESTAMP: + return "2024-12-7-1-0-0" + if setting == pw_sed.CACHE_SED_AWAKE_DURATION: + return "20" + if setting == pw_sed.CACHE_SED_CLOCK_INTERVAL: + return "12600" + if setting == pw_sed.CACHE_SED_MAINTENANCE_INTERVAL: + return "60" + if setting == pw_sed.CACHE_SED_SLEEP_DURATION: + return "60" + if setting == pw_sense.CACHE_SENSE_HYSTERESIS_HUMIDITY_UPPER_BOUND: + return "60" + if setting == pw_sense.CACHE_SENSE_HYSTERESIS_HUMIDITY_LOWER_BOUND: + return "60" + if setting == pw_sense.CACHE_SENSE_HYSTERESIS_TEMPERATURE_UPPER_BOUND: + return "25.0" + if setting == pw_sense.CACHE_SENSE_HYSTERESIS_TEMPERATURE_LOWER_BOUND: + return "25.0" + return None + + def fake_cache_bool(dummy: object, setting: str) -> bool | None: + """Fake cache_bool retrieval.""" + if setting in ( + pw_sed.CACHE_SED_CLOCK_SYNC, + pw_sense.CACHE_SENSE_HYSTERESIS_TEMPERATURE_DIRECTION, + pw_sense.CACHE_SENSE_HYSTERESIS_HUMIDITY_DIRECTION, + ): + return True + if setting in ( + pw_sed.CACHE_SED_DIRTY, + pw_sense.CACHE_SENSE_HYSTERESIS_HUMIDITY_ENABLED, + pw_sense.CACHE_SENSE_HYSTERESIS_TEMPERATURE_ENABLED, + pw_sense.CACHE_SENSE_HYSTERESIS_CONFIG_DIRTY, + ): + return False + return None + + monkeypatch.setattr(pw_node.PlugwiseBaseNode, "_get_cache", fake_cache) + monkeypatch.setattr( + pw_node.PlugwiseBaseNode, "_get_cache_as_bool", fake_cache_bool + ) + mock_stick_controller = MockStickController() + sense_config_accepted = pw_responses.NodeAckResponse() + sense_config_accepted.deserialize( + construct_message(b"0100555555555555555500B5", b"0000") + ) + sense_config_failed = pw_responses.NodeAckResponse() + sense_config_failed.deserialize( + construct_message(b"0100555555555555555500B6", b"0000") + ) + + async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] + """Load callback for event.""" + + test_sense = pw_sense.PlugwiseSense( + "1298347650AFBECD", + pw_api.NodeType.SENSE, + mock_stick_controller, + load_callback, + ) + assert not test_sense.cache_enabled + node_info = pw_api.NodeInfoMessage( + current_logaddress_pointer=None, + firmware=dt(2011, 11, 3, 13, 7, 33, tzinfo=UTC), + hardware="070030", + node_type=None, + relay_state=None, + ) + await test_sense.update_node_details(node_info) + await test_sense.load() + + # test hysteresis cache load + assert not test_sense.hysteresis_config_dirty + assert not test_sense.humidity_enabled + assert test_sense.humidity_upper_bound == 60 + assert test_sense.humidity_lower_bound == 60 + assert test_sense.humidity_direction + assert not test_sense.temperature_enabled + assert test_sense.temperature_upper_bound == 25 + assert test_sense.temperature_lower_bound == 25 + assert test_sense.temperature_direction + + # test humidity upper bound + with pytest.raises(ValueError): + await test_sense.set_hysteresis_humidity_upper_bound(0) + with pytest.raises(ValueError): + await test_sense.set_hysteresis_humidity_upper_bound(100) + assert not await test_sense.set_hysteresis_humidity_upper_bound(60) + assert not test_sense.hysteresis_config_dirty + with pytest.raises(ValueError): + await test_sense.set_hysteresis_humidity_upper_bound(55) + assert not test_sense.hysteresis_config_dirty + assert await test_sense.set_hysteresis_humidity_upper_bound(65) + assert test_sense.hysteresis_config_dirty + assert test_sense.humidity_upper_bound == 65 + assert not await test_sense.set_hysteresis_humidity_enabled(False) + assert await test_sense.set_hysteresis_humidity_enabled(True) + assert not await test_sense.set_hysteresis_humidity_direction(True) + assert await test_sense.set_hysteresis_humidity_direction(False) + + # Restore to original settings after failed config + awake_response1 = pw_responses.NodeAwakeResponse() + awake_response1.deserialize( + construct_message(b"004F555555555555555500", b"FFFE") + ) + mock_stick_controller.send_response = sense_config_failed + await test_sense._awake_response(awake_response1) # pylint: disable=protected-access + await asyncio.sleep(0.001) # Ensure time for task to be executed + await test_sense._awake_response(awake_response1) # pylint: disable=protected-access + await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sense.hysteresis_config_dirty + + # Successful config + awake_response2 = pw_responses.NodeAwakeResponse() + awake_response2.deserialize( + construct_message(b"004F555555555555555500", b"FFFE") + ) + awake_response2.timestamp = awake_response1.timestamp + td( + seconds=pw_sed.AWAKE_RETRY + ) + mock_stick_controller.send_response = sense_config_accepted + await test_sense._awake_response(awake_response2) # pylint: disable=protected-access + await asyncio.sleep(0.001) # Ensure time for task to be executed + await test_sense._awake_response(awake_response2) # pylint: disable=protected-access + await asyncio.sleep(0.001) # Ensure time for task to be executed + assert not test_sense.hysteresis_config_dirty + assert test_sense.humidity_upper_bound == 65 + + # test humidity lower bound + with pytest.raises(ValueError): + await test_sense.set_hysteresis_humidity_lower_bound(0) + with pytest.raises(ValueError): + await test_sense.set_hysteresis_humidity_lower_bound(100) + assert not await test_sense.set_hysteresis_humidity_lower_bound(60) + assert not test_sense.hysteresis_config_dirty + with pytest.raises(ValueError): + await test_sense.set_hysteresis_humidity_lower_bound(70) + assert not test_sense.hysteresis_config_dirty + assert await test_sense.set_hysteresis_humidity_lower_bound(55) + assert test_sense.hysteresis_config_dirty + assert test_sense.humidity_lower_bound == 55 + + # Successful config + awake_response3 = pw_responses.NodeAwakeResponse() + awake_response3.deserialize( + construct_message(b"004F555555555555555500", b"FFFE") + ) + awake_response3.timestamp = awake_response2.timestamp + td( + seconds=pw_sed.AWAKE_RETRY + ) + mock_stick_controller.send_response = sense_config_accepted + await test_sense._awake_response(awake_response3) # pylint: disable=protected-access + await asyncio.sleep(0.001) # Ensure time for task to be executed + await test_sense._awake_response(awake_response3) # pylint: disable=protected-access + await asyncio.sleep(0.001) # Ensure time for task to be executed + assert not test_sense.hysteresis_config_dirty + assert test_sense.humidity_lower_bound == 55 + + # test temperature upper bound + with pytest.raises(ValueError): + await test_sense.set_hysteresis_temperature_upper_bound(0) + with pytest.raises(ValueError): + await test_sense.set_hysteresis_temperature_upper_bound(61) + assert not await test_sense.set_hysteresis_temperature_upper_bound(25) + assert not test_sense.hysteresis_config_dirty + with pytest.raises(ValueError): + await test_sense.set_hysteresis_temperature_upper_bound(24) + assert not test_sense.hysteresis_config_dirty + assert await test_sense.set_hysteresis_temperature_upper_bound(26) + assert test_sense.hysteresis_config_dirty + assert test_sense.temperature_upper_bound == 26 + assert not await test_sense.set_hysteresis_temperature_enabled(False) + assert await test_sense.set_hysteresis_temperature_enabled(True) + assert not await test_sense.set_hysteresis_temperature_direction(True) + assert await test_sense.set_hysteresis_temperature_direction(False) + + # Restore to original settings after failed config + awake_response4 = pw_responses.NodeAwakeResponse() + awake_response4.deserialize( + construct_message(b"004F555555555555555500", b"FFFE") + ) + awake_response4.timestamp = awake_response3.timestamp + td( + seconds=pw_sed.AWAKE_RETRY + ) + mock_stick_controller.send_response = sense_config_failed + await test_sense._awake_response(awake_response4) # pylint: disable=protected-access + await asyncio.sleep(0.001) # Ensure time for task to be executed + await test_sense._awake_response(awake_response4) # pylint: disable=protected-access + await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sense.hysteresis_config_dirty + + # Successful config + awake_response5 = pw_responses.NodeAwakeResponse() + awake_response5.deserialize( + construct_message(b"004F555555555555555500", b"FFFE") + ) + awake_response5.timestamp = awake_response4.timestamp + td( + seconds=pw_sed.AWAKE_RETRY + ) + mock_stick_controller.send_response = sense_config_accepted + await test_sense._awake_response(awake_response5) # pylint: disable=protected-access + await asyncio.sleep(0.001) # Ensure time for task to be executed + await test_sense._awake_response(awake_response5) # pylint: disable=protected-access + await asyncio.sleep(0.001) # Ensure time for task to be executed + assert not test_sense.hysteresis_config_dirty + assert test_sense.temperature_upper_bound == 26 + + # test temperature lower bound + with pytest.raises(ValueError): + await test_sense.set_hysteresis_temperature_lower_bound(0) + with pytest.raises(ValueError): + await test_sense.set_hysteresis_temperature_lower_bound(61) + assert not await test_sense.set_hysteresis_temperature_lower_bound(25) + assert not test_sense.hysteresis_config_dirty + with pytest.raises(ValueError): + await test_sense.set_hysteresis_temperature_lower_bound(27) + assert not test_sense.hysteresis_config_dirty + assert await test_sense.set_hysteresis_temperature_lower_bound(24) + assert test_sense.hysteresis_config_dirty + assert test_sense.temperature_lower_bound == 24 + + # Successful config + awake_response6 = pw_responses.NodeAwakeResponse() + awake_response6.deserialize( + construct_message(b"004F555555555555555500", b"FFFE") + ) + awake_response6.timestamp = awake_response5.timestamp + td( + seconds=pw_sed.AWAKE_RETRY + ) + mock_stick_controller.send_response = sense_config_accepted + await test_sense._awake_response(awake_response6) # pylint: disable=protected-access + await asyncio.sleep(0.001) # Ensure time for task to be executed + await test_sense._awake_response(awake_response6) # pylint: disable=protected-access + await asyncio.sleep(0.001) # Ensure time for task to be executed + assert not test_sense.hysteresis_config_dirty + assert test_sense.temperature_lower_bound == 24 + @pytest.mark.asyncio async def test_switch_node(self, monkeypatch: pytest.MonkeyPatch) -> None: """Testing properties of switch.""" From bd3956da8b3c74e636c87b32ad4c1a09a0f00c75 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 1 Sep 2025 12:00:23 +0200 Subject: [PATCH 11/33] SonarQube: remove the unused function parameter timestamp --- plugwise_usb/nodes/sense.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index cf866b6ef..daeaee7c6 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -586,7 +586,7 @@ async def _switch_group(self, response: PlugwiseResponse) -> bool: return True async def _hysteresis_state_update( - self, switch_state: bool, switch_group: int, timestamp: datetime + self, switch_state: bool, switch_group: int ) -> None: """Process hysteresis state update.""" _LOGGER.debug( From 91aa5d94565f4f194c78acfa5c39eacc724b9ccc Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 1 Sep 2025 12:01:53 +0200 Subject: [PATCH 12/33] bump testpypi version to 0.45.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4e9dc2287..16a33d103 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "0.44.14" +version = "0.45.0a0" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 2345aaf756f8a50325eef8f731b565dac0604075 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 1 Sep 2025 12:16:59 +0200 Subject: [PATCH 13/33] CR: improve logging, fix logic error --- plugwise_usb/nodes/sense.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index daeaee7c6..302716996 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -392,7 +392,7 @@ async def set_hysteresis_humidity_upper_bound(self, upper_bound: float) -> bool: and upper_bound < self._hysteresis_config.humidity_lower_bound ): raise ValueError( - f"Invalid humidity upper bound {upper_bound}. It must be equal or above the lower bound {self._hysteresis_config.humidity_lower_bound}." + f"Invalid humidity upper bound {upper_bound}. It must be ≥ the lower bound {self._hysteresis_config.humidity_lower_bound}." ) if self._hysteresis_config.humidity_upper_bound == upper_bound: return False @@ -424,7 +424,7 @@ async def set_hysteresis_humidity_lower_bound(self, lower_bound: float) -> bool: and lower_bound > self._hysteresis_config.humidity_upper_bound ): raise ValueError( - f"Invalid humidity lower bound {lower_bound}. It must be equal or above the lower bound {self._hysteresis_config.humidity_lower_bound}." + f"Invalid humidity lower bound {lower_bound}. It must be ≤ the upper bound {self._hysteresis_config.humidity_upper_bound}." ) if self._hysteresis_config.humidity_lower_bound == lower_bound: return False @@ -498,7 +498,7 @@ async def set_hysteresis_temperature_upper_bound(self, upper_bound: float) -> bo and upper_bound < self._hysteresis_config.temperature_lower_bound ): raise ValueError( - f"Invalid temperature upper bound {upper_bound}. It must be equal or above the lower bound {self._hysteresis_config.temperature_lower_bound}." + f"Invalid temperature upper bound {upper_bound}. It must be ≥ the lower bound {self._hysteresis_config.temperature_lower_bound}." ) if self._hysteresis_config.temperature_upper_bound == upper_bound: return False @@ -530,7 +530,7 @@ async def set_hysteresis_temperature_lower_bound(self, lower_bound: float) -> bo and lower_bound > self._hysteresis_config.temperature_upper_bound ): raise ValueError( - f"Invalid temperature lower bound {lower_bound}. It must be equal or below the upper bound {self._hysteresis_config.temperature_upper_bound}." + f"Invalid temperature lower bound {lower_bound}. It must be ≤ the upper bound {self._hysteresis_config.temperature_upper_bound}." ) if self._hysteresis_config.temperature_lower_bound == lower_bound: return False @@ -731,7 +731,7 @@ async def _configure_sense_temperature_task(self) -> bool: request = SenseConfigureHysteresisRequest( self._send, self._mac_in_bytes, - False, + True, temperature_lower_bound, temperature_upper_bound, self.temperature_direction, From a35b551b533110f6f4b0f5e2f244c95e6b31b911 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 1 Sep 2025 12:57:03 +0200 Subject: [PATCH 14/33] CR: fix delay --- tests/test_usb.py | 66 +++++++++++++++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index be35594ef..40b00f085 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1992,7 +1992,8 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) mock_stick_controller.send_response = sed_config_failed await test_sed._awake_response(awake_response1) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sed._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sed._delayed_task), timeout=2) assert test_sed.battery_config.dirty assert test_sed.battery_config.awake_duration == 15 assert test_sed.awake_duration == 15 @@ -2009,7 +2010,8 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign assert test_sed.battery_config.dirty mock_stick_controller.send_response = sed_config_accepted await test_sed._awake_response(awake_response2) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sed._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sed._delayed_task), timeout=2) assert not test_sed.battery_config.dirty assert test_sed.battery_config.awake_duration == 20 assert test_sed.awake_duration == 20 @@ -2033,7 +2035,8 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign seconds=pw_sed.AWAKE_RETRY ) await test_sed._awake_response(awake_response3) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sed._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sed._delayed_task), timeout=2) assert not test_sed.battery_config.dirty assert test_sed.battery_config.maintenance_interval == 30 assert test_sed.maintenance_interval == 30 @@ -2057,7 +2060,8 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign seconds=pw_sed.AWAKE_RETRY ) await test_sed._awake_response(awake_response4) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sed._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sed._delayed_task), timeout=2) assert not test_sed.battery_config.dirty assert test_sed.battery_config.clock_interval == 15000 assert test_sed.clock_interval == 15000 @@ -2076,7 +2080,8 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign seconds=pw_sed.AWAKE_RETRY ) await test_sed._awake_response(awake_response5) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sed._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sed._delayed_task), timeout=2) assert not test_sed.battery_config.dirty assert test_sed.battery_config.clock_sync assert test_sed.clock_sync @@ -2099,7 +2104,8 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign seconds=pw_sed.AWAKE_RETRY ) await test_sed._awake_response(awake_response6) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sed._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sed._delayed_task), timeout=2) assert not test_sed.battery_config.dirty assert test_sed.battery_config.sleep_duration == 120 assert test_sed.sleep_duration == 120 @@ -2204,7 +2210,8 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) mock_stick_controller.send_response = scan_config_failed await test_scan._awake_response(awake_response1) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_scan._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_scan._delayed_task), timeout=2) assert test_scan.motion_config.dirty # Successful config @@ -2219,7 +2226,8 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign assert await test_scan.set_motion_reset_timer(25) assert test_scan.motion_config.dirty await test_scan._awake_response(awake_response2) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_scan._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_scan._delayed_task), timeout=2) assert not test_scan.motion_config.dirty assert test_scan.reset_timer == 25 assert test_scan.motion_config.reset_timer == 25 @@ -2239,7 +2247,8 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign seconds=pw_sed.AWAKE_RETRY ) await test_scan._awake_response(awake_response3) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_scan._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_scan._delayed_task), timeout=2) assert not test_scan.motion_config.dirty assert test_scan.daylight_mode assert test_scan.motion_config.daylight_mode @@ -2266,7 +2275,8 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign seconds=pw_sed.AWAKE_RETRY ) await test_scan._awake_response(awake_response4) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_scan._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_scan._delayed_task), timeout=2) assert not test_scan.motion_config.dirty assert test_scan.sensitivity_level == pw_api.MotionSensitivity.HIGH assert ( @@ -2432,9 +2442,11 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) mock_stick_controller.send_response = sense_config_failed await test_sense._awake_response(awake_response1) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sense._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sense._delayed_task), timeout=2) await test_sense._awake_response(awake_response1) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sense._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sense._delayed_task), timeout=2) assert test_sense.hysteresis_config_dirty # Successful config @@ -2447,9 +2459,11 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) mock_stick_controller.send_response = sense_config_accepted await test_sense._awake_response(awake_response2) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sense._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sense._delayed_task), timeout=2) await test_sense._awake_response(awake_response2) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sense._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sense._delayed_task), timeout=2) assert not test_sense.hysteresis_config_dirty assert test_sense.humidity_upper_bound == 65 @@ -2477,9 +2491,11 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) mock_stick_controller.send_response = sense_config_accepted await test_sense._awake_response(awake_response3) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sense._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sense._delayed_task), timeout=2) await test_sense._awake_response(awake_response3) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sense._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sense._delayed_task), timeout=2) assert not test_sense.hysteresis_config_dirty assert test_sense.humidity_lower_bound == 55 @@ -2511,9 +2527,11 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) mock_stick_controller.send_response = sense_config_failed await test_sense._awake_response(awake_response4) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sense._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sense._delayed_task), timeout=2) await test_sense._awake_response(awake_response4) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sense._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sense._delayed_task), timeout=2) assert test_sense.hysteresis_config_dirty # Successful config @@ -2526,9 +2544,11 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) mock_stick_controller.send_response = sense_config_accepted await test_sense._awake_response(awake_response5) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sense._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sense._delayed_task), timeout=2) await test_sense._awake_response(awake_response5) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sense._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sense._delayed_task), timeout=2) assert not test_sense.hysteresis_config_dirty assert test_sense.temperature_upper_bound == 26 @@ -2556,9 +2576,11 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) mock_stick_controller.send_response = sense_config_accepted await test_sense._awake_response(awake_response6) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed + assert test_sense._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sense._delayed_task), timeout=2) + assert test_sense._delayed_task is not None # pylint: disable=protected-access + await asyncio.wait_for(asyncio.shield(test_sense._delayed_task), timeout=2) await test_sense._awake_response(awake_response6) # pylint: disable=protected-access - await asyncio.sleep(0.001) # Ensure time for task to be executed assert not test_sense.hysteresis_config_dirty assert test_sense.temperature_lower_bound == 24 From 61452e9d662629fcefce11db17781b1d35fc63eb Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 1 Sep 2025 13:04:21 +0200 Subject: [PATCH 15/33] CR: improve comments --- plugwise_usb/nodes/sense.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 302716996..1d2551d22 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -385,7 +385,7 @@ async def set_hysteresis_humidity_upper_bound(self, upper_bound: float) -> bool: ) if upper_bound < 1 or upper_bound > 99: raise ValueError( - f"Invalid humidity upper bound {upper_bound}. It must be between 1 and 99 percent." + f"Invalid humidity upper bound {upper_bound}. It must be between 1 and 99 %." ) if ( self._hysteresis_config.humidity_lower_bound is not None @@ -417,7 +417,7 @@ async def set_hysteresis_humidity_lower_bound(self, lower_bound: float) -> bool: ) if lower_bound < 1 or lower_bound > 99: raise ValueError( - f"Invalid humidity lower bound {lower_bound}. It must be between 1 and 99 percent." + f"Invalid humidity lower bound {lower_bound}. It must be between 1 and 99 %." ) if ( self._hysteresis_config.humidity_upper_bound is not None @@ -491,7 +491,7 @@ async def set_hysteresis_temperature_upper_bound(self, upper_bound: float) -> bo ) if upper_bound < 1 or upper_bound > 60: raise ValueError( - f"Invalid temperature upper bound {upper_bound}. It must be between 1 and 60 degrees." + f"Invalid temperature upper bound {upper_bound}. It must be between 1 and 60 °C." ) if ( self._hysteresis_config.temperature_lower_bound is not None @@ -523,7 +523,7 @@ async def set_hysteresis_temperature_lower_bound(self, lower_bound: float) -> bo ) if lower_bound < 1 or lower_bound > 60: raise ValueError( - f"Invalid temperature lower bound {lower_bound}. It must be between 1 and 60 degrees." + f"Invalid temperature lower bound {lower_bound}. It must be between 1 and 60 °C." ) if ( self._hysteresis_config.temperature_upper_bound is not None @@ -543,7 +543,7 @@ async def set_hysteresis_temperature_lower_bound(self, lower_bound: float) -> bo return True async def set_hysteresis_temperature_direction(self, state: bool) -> bool: - """Configure humitidy hysteresis to switch on or off on increase or decreasing direction. + """Configure temperature hysteresis to switch on or off on increase or decreasing direction. Configuration request will be queued and will be applied the next time when node is awake for maintenance. """ @@ -596,7 +596,7 @@ async def _hysteresis_state_update( ) if switch_group == 1: self._sense_statistics.temperature_state = switch_state - if switch_group == 2: + elif switch_group == 2: self._sense_statistics.humidity_state = switch_state await self.publish_feature_update_to_subscribers( @@ -656,7 +656,7 @@ async def _configure_sense_humidity_task(self) -> bool: """Configure Sense humidity hysteresis device settings. Returns True if successful.""" if not self._hysteresis_config.dirty: return True - # Set value to -1 for disabled + # Set value to -1% for 'disabled' (humidity):2621 humidity_lower_bound = 2621 humidity_upper_bound = 2621 if self.humidity_enabled: @@ -710,7 +710,7 @@ async def _configure_sense_temperature_task(self) -> bool: """Configure Sense temperature hysteresis device settings. Returns True if successful.""" if not self._hysteresis_config.dirty: return True - # Set value to -1 for disabled + # Set value to -1 °C for disabled (temperature): 17099 temperature_lower_bound = 17099 temperature_upper_bound = 17099 if self.temperature_enabled: From 5f641b36d84accb50e070a07d59aefd27c5b46f3 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 1 Sep 2025 13:14:08 +0200 Subject: [PATCH 16/33] SQC: fix call to _hysteresis_state_update --- plugwise_usb/nodes/sense.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 1d2551d22..b35f92a2c 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -580,7 +580,6 @@ async def _switch_group(self, response: PlugwiseResponse) -> bool: self._hysteresis_state_update( response.switch_state, response.switch_group, - response.timestamp, ), ) return True From 8f2812384a0390946cc435e77a17cbb850e46dba Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 1 Sep 2025 13:35:37 +0200 Subject: [PATCH 17/33] bump to 0.45.0a1 on testpypi --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 16a33d103..259164f15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "0.45.0a0" +version = "0.45.0a1" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 4203b81b4ed0f4deee225ffbd55875ef6ea7eeb9 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 1 Sep 2025 13:43:00 +0200 Subject: [PATCH 18/33] CR: more comment nitpicks --- plugwise_usb/nodes/sense.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index b35f92a2c..813278907 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -148,9 +148,7 @@ async def _load_defaults(self) -> None: async def _load_from_cache(self) -> bool: """Load states from previous cached information. Returns True if successful.""" - super_load_success = True - if not await super()._load_from_cache(): - super_load_success = False + super_load_success = await super()._load_from_cache() dirty = False if (humidity_enabled := self._humidity_enabled_from_cache()) is None: dirty = True @@ -437,7 +435,7 @@ async def set_hysteresis_humidity_lower_bound(self, lower_bound: float) -> bool: return True async def set_hysteresis_humidity_direction(self, state: bool) -> bool: - """Configure humitidy hysteresis to switch on or off on increase or decreasing direction. + """Configure humitidy hysteresis to switch on or off on increasing or decreasing direction. Configuration request will be queued and will be applied the next time when node is awake for maintenance. """ @@ -543,7 +541,7 @@ async def set_hysteresis_temperature_lower_bound(self, lower_bound: float) -> bo return True async def set_hysteresis_temperature_direction(self, state: bool) -> bool: - """Configure temperature hysteresis to switch on or off on increase or decreasing direction. + """Configure temperature hysteresis to switch on or off on increasing or decreasing direction. Configuration request will be queued and will be applied the next time when node is awake for maintenance. """ @@ -597,6 +595,10 @@ async def _hysteresis_state_update( self._sense_statistics.temperature_state = switch_state elif switch_group == 2: self._sense_statistics.humidity_state = switch_state + else: + _LOGGER.debug( + "Ignoring unknown switch_group %s for %s", switch_group, self.name + ) await self.publish_feature_update_to_subscribers( NodeFeature.SENSE, self._sense_statistics @@ -661,7 +663,7 @@ async def _configure_sense_humidity_task(self) -> bool: if self.humidity_enabled: if self.humidity_lower_bound > self.humidity_upper_bound: raise ValueError( - f"Invalid humidity lower bound {self.humidity_lower_bound}. It must be equal or below the upper bound {self.humidity_upper_bound}." + f"Invalid humidity lower bound {self.humidity_lower_bound}. It must be ≤ the upper bound {self.humidity_upper_bound}." ) humidity_lower_bound = int( (self.humidity_lower_bound + SENSE_HUMIDITY_OFFSET) @@ -715,7 +717,7 @@ async def _configure_sense_temperature_task(self) -> bool: if self.temperature_enabled: if self.temperature_lower_bound > self.temperature_upper_bound: raise ValueError( - f"Invalid temperature lower bound {self.temperature_lower_bound}. It must be equal or below the upper bound {self.temperature_upper_bound}." + f"Invalid temperature lower bound {self.temperature_lower_bound}. It must be ≤ the upper bound {self.temperature_upper_bound}." ) temperature_lower_bound = int( (self.temperature_lower_bound + SENSE_TEMPERATURE_OFFSET) From 55939009bacb18f0d6ef3d696edb795311ad9445 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 1 Sep 2025 13:58:51 +0200 Subject: [PATCH 19/33] CR: few nitpicks --- plugwise_usb/nodes/sense.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 813278907..141e0da9b 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -435,7 +435,7 @@ async def set_hysteresis_humidity_lower_bound(self, lower_bound: float) -> bool: return True async def set_hysteresis_humidity_direction(self, state: bool) -> bool: - """Configure humitidy hysteresis to switch on or off on increasing or decreasing direction. + """Configure humidity hysteresis to switch on or off on increasing or decreasing direction. Configuration request will be queued and will be applied the next time when node is awake for maintenance. """ @@ -791,7 +791,7 @@ async def _sense_configure_update(self) -> None: await gather( self.publish_feature_update_to_subscribers( NodeFeature.SENSE_HYSTERESIS, - self._hysteresis_config, + self.hysteresis_config, ), self.save_cache(), ) From 63f61d946efb3a777185cc92be01f0c6bf3d59ed Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 1 Sep 2025 16:38:31 +0200 Subject: [PATCH 20/33] publish NodeFeature.SENSE_HYSTERESIS on node awake bump testpipi to 0.45.0a2 --- plugwise_usb/nodes/sense.py | 4 ++++ pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 141e0da9b..5bcf9e35c 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -652,6 +652,10 @@ async def _run_awake_tasks(self) -> None: configure_result[0], configure_result[1], ) + await self.publish_feature_update_to_subscribers( + NodeFeature.SENSE_HYSTERESIS, + self.hysteresis_config, + ) async def _configure_sense_humidity_task(self) -> bool: """Configure Sense humidity hysteresis device settings. Returns True if successful.""" diff --git a/pyproject.toml b/pyproject.toml index 259164f15..05ff7bf0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "0.45.0a1" +version = "0.45.0a2" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 161099aa0d19f3e691c3ddfda9d8984557e7ec0c Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 1 Sep 2025 16:54:18 +0200 Subject: [PATCH 21/33] CR: avoid double publishing of SENSE_HYSTERESIS on success --- plugwise_usb/nodes/sense.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 5bcf9e35c..51b78a20a 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -644,6 +644,7 @@ async def _run_awake_tasks(self) -> None: if all(configure_result): self._hysteresis_config = replace(self._hysteresis_config, dirty=False) await self._sense_configure_update() + return else: _LOGGER.warning( "Sense hysteresis configuration partially failed for %s " From e3bee0fcb4ef98dadb400ffafaaf59fda52a2c4e Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Tue, 2 Sep 2025 10:31:16 +0200 Subject: [PATCH 22/33] include NodeFeature.HYSTERESIS in PUSHING_FEATURES --- plugwise_usb/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index 14518b9b4..92c7e0e93 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -87,6 +87,7 @@ class NodeType(Enum): NodeFeature.MOTION_CONFIG, NodeFeature.TEMPERATURE, NodeFeature.SENSE, + NodeFeature.SENSE_HYSTERESIS, NodeFeature.SWITCH, ) From 1e42f134d2aef7e1c4cda2b6f044a607e135b88f Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Tue, 2 Sep 2025 11:45:29 +0200 Subject: [PATCH 23/33] switch groups are reversed --- plugwise_usb/nodes/sense.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 51b78a20a..1c52c6cb8 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -592,9 +592,9 @@ async def _hysteresis_state_update( switch_state, ) if switch_group == 1: - self._sense_statistics.temperature_state = switch_state - elif switch_group == 2: self._sense_statistics.humidity_state = switch_state + elif switch_group == 2: + self._sense_statistics.temperature_state = switch_state else: _LOGGER.debug( "Ignoring unknown switch_group %s for %s", switch_group, self.name From acaa6a0369d95f0ef36c4e3f1e5dc204c7e59937 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Tue, 2 Sep 2025 11:46:41 +0200 Subject: [PATCH 24/33] reverse logic --- plugwise_usb/messages/requests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 1a60b271d..244650c6c 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -1433,8 +1433,8 @@ def __init__( # noqa: PLR0913 temp_hum_value = Int(1 if temp_hum else 0, length=2) lower_bound_value = Int(lower_bound, length=4) upper_bound_value = Int(upper_bound, length=4) - direction_value_1 = Int(0 if direction else 1, length=2) - direction_value_2 = Int(1 if direction else 0, length=2) + direction_value_1 = Int(1 if direction else 0, length=2) + direction_value_2 = Int(0 if direction else 1, length=2) self._args += [ temp_hum_value, lower_bound_value, From cb447904733cdfc2438a79ce107f994e5877227b Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Tue, 2 Sep 2025 12:49:24 +0200 Subject: [PATCH 25/33] CR: docupdate --- plugwise_usb/api.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index 92c7e0e93..cdcd17c1b 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -271,15 +271,18 @@ class SenseHysteresisConfig: Attributes: humidity_enabled: bool | None: enable humidity hysteresis - humidity_upper_bound: int | None: upper humidity switching value - humidity_lower_bound: int | None: lower humidity switching value - humidity_direction: bool | None: True switch on on increasing humidity, False switch off on increasing humidity + humidity_upper_bound: float | None: upper humidity switching value + humidity_lower_bound: float | None: lower humidity switching value + humidity_direction: bool | None: True switch ON when humidity rises, False switch OFF when humidity rises temperature_enabled: bool | None: enable temperature hysteresis - temperature_upper_bound: int | None: upper temperature switching value - temperature_lower_bound: int | None: lower temperature switching value - temperature_direction: bool | None: True switch on on increasing temperature, False switch off on increasing temperature + temperature_upper_bound: float | None: upper temperature switching value + temperature_lower_bound: float | None: lower temperature switching value + temperature_direction: bool | None: True switch ON when temperature rises, False switch OFF when temperature rises dirty: bool: Settings changed, device update pending + Notes: + Disabled sentinel values are hardware-specific (temperature=17099 for -1°C, humidity=2621 for -1%) and are handled in the node layer; the public API exposes floats in SI units. + """ humidity_enabled: bool | None = None From 557a2a77517e83760785e5afaa02379cb7716a1c Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Tue, 2 Sep 2025 13:07:19 +0200 Subject: [PATCH 26/33] more docstring updates --- plugwise_usb/api.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index cdcd17c1b..c671ec57c 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -271,13 +271,13 @@ class SenseHysteresisConfig: Attributes: humidity_enabled: bool | None: enable humidity hysteresis - humidity_upper_bound: float | None: upper humidity switching value - humidity_lower_bound: float | None: lower humidity switching value - humidity_direction: bool | None: True switch ON when humidity rises, False switch OFF when humidity rises + humidity_upper_bound: float | None: upper humidity switching threshold (%RH) + humidity_lower_bound: float | None: lower humidity switching threshold (%RH) + humidity_direction: bool | None: True = switch ON when humidity rises; False = switch OFF when humidity rises temperature_enabled: bool | None: enable temperature hysteresis - temperature_upper_bound: float | None: upper temperature switching value - temperature_lower_bound: float | None: lower temperature switching value - temperature_direction: bool | None: True switch ON when temperature rises, False switch OFF when temperature rises + temperature_upper_bound: float | None: upper temperature switching threshold (°C) + temperature_lower_bound: float | None: lower temperature switching threshold (°C) + temperature_direction: bool | None: True = switch ON when temperature rises; False = switch OFF when temperature rises dirty: bool: Settings changed, device update pending Notes: From 0222d29c482f3a40730e532e19fd67f1b363281a Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Wed, 3 Sep 2025 09:39:21 +0200 Subject: [PATCH 27/33] bouwew: improve naming of state variables --- plugwise_usb/api.py | 4 ++-- plugwise_usb/nodes/sense.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index c671ec57c..33e7828fd 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -302,8 +302,8 @@ class SenseStatistics: temperature: float | None = None humidity: float | None = None - temperature_state: bool | None = None - humidity_state: bool | None = None + temperature_hysteresis_state: bool | None = None + humidity_hysteresis_state: bool | None = None class PlugwiseNode(Protocol): diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 1c52c6cb8..015f810d0 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -592,9 +592,9 @@ async def _hysteresis_state_update( switch_state, ) if switch_group == 1: - self._sense_statistics.humidity_state = switch_state + self._sense_statistics.humidity_hysteresis_state = switch_state elif switch_group == 2: - self._sense_statistics.temperature_state = switch_state + self._sense_statistics.temperature_hysteresis_state = switch_state else: _LOGGER.debug( "Ignoring unknown switch_group %s for %s", switch_group, self.name From 02e438a364a4475bde684363a7b856ac0ceb373b Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Wed, 3 Sep 2025 09:57:56 +0200 Subject: [PATCH 28/33] CR: unify publishing self.motion_config in all places --- plugwise_usb/nodes/scan.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index f88860a50..2c536a04b 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -430,7 +430,7 @@ async def _run_awake_tasks(self) -> None: await self._scan_calibrate_light() await self.publish_feature_update_to_subscribers( NodeFeature.MOTION_CONFIG, - self._motion_config, + self.motion_config, ) async def _configure_scan_task(self) -> bool: @@ -477,7 +477,7 @@ async def _scan_configure_update(self) -> None: await gather( self.publish_feature_update_to_subscribers( NodeFeature.MOTION_CONFIG, - self._motion_config, + self.motion_config, ), self.save_cache(), ) From 28ab5e0a5a65a40674fba82b7b6134f79bb60079 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Wed, 3 Sep 2025 10:13:03 +0200 Subject: [PATCH 29/33] bump to 0.45.0! --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d85d100..6c659a9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.45.0 - 2025-09-03 + +- PR [330](https://github.com/plugwise/python-plugwise-usb/pull/330): Add sense hysteresis based switch action + ## 0.44.14 - 2025-08-31 - PR [329](https://github.com/plugwise/python-plugwise-usb/pull/329): Improve EnergyLogs caching: store only data from MAX_LOG_HOURS (24) diff --git a/pyproject.toml b/pyproject.toml index 05ff7bf0c..ec276e74c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "0.45.0a2" +version = "0.45.0" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 4097eb4933a95b2446a25d2d22c648574414c096 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Wed, 3 Sep 2025 10:14:21 +0200 Subject: [PATCH 30/33] CR: fix docstring --- plugwise_usb/messages/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 244650c6c..dad70a850 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -1428,7 +1428,7 @@ def __init__( # noqa: PLR0913 upper_bound: int, direction: bool, ): - """Initialize ScanConfigureRequest message object.""" + """Initialize ScanConfigureHysteresisRequest message object.""" super().__init__(send_fn, mac) temp_hum_value = Int(1 if temp_hum else 0, length=2) lower_bound_value = Int(lower_bound, length=4) From 32464f52d29869ac9ba4f021bea66047464381fe Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Wed, 3 Sep 2025 10:16:00 +0200 Subject: [PATCH 31/33] CR: prefer None over 0.0 in case of default --- plugwise_usb/nodes/sense.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 015f810d0..5dd06e9ba 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -132,10 +132,7 @@ async def unload(self) -> None: async def _load_defaults(self) -> None: """Load default configuration settings.""" await super()._load_defaults() - self._sense_statistics = SenseStatistics( - temperature=0.0, - humidity=0.0, - ) + self._sense_statistics = SenseStatistics() if self._node_info.model is None: self._node_info.model = "Sense" self._sed_node_info_update_task_scheduled = True From 4cbe6c002972151bd8fd3290b2c521eab44170ec Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Wed, 3 Sep 2025 10:22:13 +0200 Subject: [PATCH 32/33] fix fault in test-order --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 40b00f085..d0a139908 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -2578,9 +2578,9 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign await test_sense._awake_response(awake_response6) # pylint: disable=protected-access assert test_sense._delayed_task is not None # pylint: disable=protected-access await asyncio.wait_for(asyncio.shield(test_sense._delayed_task), timeout=2) + await test_sense._awake_response(awake_response6) # pylint: disable=protected-access assert test_sense._delayed_task is not None # pylint: disable=protected-access await asyncio.wait_for(asyncio.shield(test_sense._delayed_task), timeout=2) - await test_sense._awake_response(awake_response6) # pylint: disable=protected-access assert not test_sense.hysteresis_config_dirty assert test_sense.temperature_lower_bound == 24 From f1abce90950bf953efed75754783bb6135386b98 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Wed, 3 Sep 2025 10:31:33 +0200 Subject: [PATCH 33/33] fix naming 2.0 --- plugwise_usb/messages/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index dad70a850..a57a9860d 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -1428,7 +1428,7 @@ def __init__( # noqa: PLR0913 upper_bound: int, direction: bool, ): - """Initialize ScanConfigureHysteresisRequest message object.""" + """Initialize SenseConfigureHysteresisRequest message object.""" super().__init__(send_fn, mac) temp_hum_value = Int(1 if temp_hum else 0, length=2) lower_bound_value = Int(lower_bound, length=4)