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/plugwise_usb/api.py b/plugwise_usb/api.py index 97873d328..33e7828fd 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" @@ -86,6 +87,7 @@ class NodeType(Enum): NodeFeature.MOTION_CONFIG, NodeFeature.TEMPERATURE, NodeFeature.SENSE, + NodeFeature.SENSE_HYSTERESIS, NodeFeature.SWITCH, ) @@ -260,12 +262,48 @@ class EnergyStatistics: day_production_reset: datetime | 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: 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 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: + 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 + 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: 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_hysteresis_state: bool | None = None + humidity_hysteresis_state: bool | None = None class PlugwiseNode(Protocol): diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 39e07a46b..a57a9860d 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -1404,6 +1404,57 @@ 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 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) + upper_bound_value = Int(upper_bound, length=4) + 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, + direction_value_1, + upper_bound_value, + direction_value_2, + ] + + 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. 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/scan.py b/plugwise_usb/nodes/scan.py index 3607f870a..2c536a04b 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -430,21 +430,13 @@ 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: """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, @@ -485,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(), ) @@ -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: diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index a7f7c431b..5dd06e9ba 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,31 @@ SENSE_FEATURES: Final = ( NodeFeature.INFO, NodeFeature.SENSE, + NodeFeature.SENSE_HYSTERESIS, ) # 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_CONFIG_DIRTY = "sense_hysteresis_config_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 +85,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 +112,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,16 +124,15 @@ 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 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 @@ -102,6 +143,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 = await super()._load_from_cache() + 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_config_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_config_dirty_from_cache(self) -> bool: + """Load sense hysteresis dirty from cache.""" + if ( + dirty := self._get_cache_as_bool(CACHE_SENSE_HYSTERESIS_CONFIG_DIRTY) + ) is not None: + return dirty + return True + # endregion # region properties @@ -112,8 +266,341 @@ 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.hysteresis_config_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 + + @property + def hysteresis_config_dirty(self) -> bool: + """Sense hysteresis configuration dirty flag.""" + return self._hysteresis_config.dirty + # 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 > 99: + raise ValueError( + f"Invalid humidity upper bound {upper_bound}. It must be between 1 and 99 %." + ) + if ( + self._hysteresis_config.humidity_lower_bound is not None + and upper_bound < self._hysteresis_config.humidity_lower_bound + ): + raise ValueError( + 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 + self._hysteresis_config = replace( + self._hysteresis_config, + humidity_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 > 99: + raise ValueError( + f"Invalid humidity lower bound {lower_bound}. It must be between 1 and 99 %." + ) + if ( + self._hysteresis_config.humidity_upper_bound is not None + and lower_bound > self._hysteresis_config.humidity_upper_bound + ): + raise ValueError( + 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 + self._hysteresis_config = replace( + self._hysteresis_config, + humidity_lower_bound=lower_bound, + dirty=True, + ) + await self._sense_configure_update() + return True + + async def set_hysteresis_humidity_direction(self, state: bool) -> bool: + """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. + """ + _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 > 60: + raise ValueError( + 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 + and upper_bound < self._hysteresis_config.temperature_lower_bound + ): + raise ValueError( + 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 + self._hysteresis_config = replace( + self._hysteresis_config, + temperature_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 > 60: + raise ValueError( + 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 + and lower_bound > self._hysteresis_config.temperature_upper_bound + ): + raise ValueError( + 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 + self._hysteresis_config = replace( + self._hysteresis_config, + temperature_lower_bound=lower_bound, + dirty=True, + ) + await self._sense_configure_update() + return True + + async def set_hysteresis_temperature_direction(self, state: bool) -> bool: + """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. + """ + _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.switch_group, + ), + ) + return True + + async def _hysteresis_state_update( + self, switch_state: bool, switch_group: int + ) -> None: + """Process hysteresis state update.""" + _LOGGER.debug( + "_hysteresis_state_update for %s: %s", + self.name, + switch_state, + ) + if switch_group == 1: + self._sense_statistics.humidity_hysteresis_state = switch_state + elif switch_group == 2: + self._sense_statistics.temperature_hysteresis_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 + ) + 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 +630,174 @@ 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: + configure_result = await gather( + self._configure_sense_humidity_task(), + self._configure_sense_temperature_task(), + ) + 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 " + "(humidity=%s, temperature=%s); will retry on next wake.", + self.name, + 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.""" + if not self._hysteresis_config.dirty: + return True + # Set value to -1% for 'disabled' (humidity):2621 + humidity_lower_bound = 2621 + humidity_upper_bound = 2621 + 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 ≤ 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 + ) + 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 °C for disabled (temperature): 17099 + temperature_lower_bound = 17099 + temperature_upper_bound = 17099 + 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 ≤ 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, + True, + 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 + ) + 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_CONFIG_DIRTY, self.hysteresis_config_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 +818,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: diff --git a/pyproject.toml b/pyproject.toml index 4e9dc2287..ec276e74c 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.0" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ diff --git a/tests/test_usb.py b/tests/test_usb.py index be5b74e13..d0a139908 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") @@ -1991,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 @@ -2008,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 @@ -2032,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 @@ -2056,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 @@ -2075,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 @@ -2098,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 @@ -2203,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 @@ -2218,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 @@ -2238,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 @@ -2265,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 ( @@ -2312,6 +2323,267 @@ 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 + 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 + 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 + 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 + 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 + 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 + + # 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 + 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 + 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 + + # 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 + 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 + 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 + 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 + 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 + 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 + + # 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 + 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) + 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."""