Skip to content
Merged
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Changelog

## ongoing
## 0.46.0 - 2025-09-12

- PR [338](https://github.com/plugwise/python-plugwise-usb/pull/338): Append report interval to Sense node configuration
- PR [333](https://github.com/plugwise/python-plugwise-usb/pull/333): Improve node_info_update and update_node_details logic

## 0.45.0 - 2025-09-03
Expand Down
2 changes: 2 additions & 0 deletions plugwise_usb/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ class SenseHysteresisConfig:
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
report_interval: int | None = None: Interval in minutes at which the temperature and humidity are reported (1-60)
dirty: bool: Settings changed, device update pending

Notes:
Expand All @@ -293,6 +294,7 @@ class SenseHysteresisConfig:
temperature_upper_bound: float | None = None
temperature_lower_bound: float | None = None
temperature_direction: bool | None = None
report_interval: int | None = None
dirty: bool = False


Expand Down
2 changes: 1 addition & 1 deletion plugwise_usb/messages/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -1493,7 +1493,7 @@ def __init__(
mac: bytes,
interval: int,
):
"""Initialize ScanLightCalibrateRequest message object."""
"""Initialize SenseReportIntervalRequest message object."""
super().__init__(send_fn, mac)
self._args.append(Int(interval, length=2))

Expand Down
133 changes: 105 additions & 28 deletions plugwise_usb/nodes/sense.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
)
from ..connection import StickController
from ..exceptions import MessageError, NodeError
from ..messages.requests import SenseConfigureHysteresisRequest
from ..messages.requests import (
SenseConfigureHysteresisRequest,
SenseReportIntervalRequest,
)
from ..messages.responses import (
NODE_SWITCH_GROUP_ID,
SENSE_REPORT_ID,
Expand Down Expand Up @@ -59,16 +62,18 @@
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_REPORT_INTERVAL = "report_interval"
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
DEFAULT_SENSE_HYSTERESIS_HUMIDITY_UPPER_BOUND: Final[float] = 24.0
DEFAULT_SENSE_HYSTERESIS_HUMIDITY_LOWER_BOUND: Final[float] = 24.0
DEFAULT_SENSE_HYSTERESIS_HUMIDITY_DIRECTION: Final[bool] = True
DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_ENABLED: Final[bool] = False
DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_UPPER_BOUND: Final[float] = 50.0
DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_LOWER_BOUND: Final[float] = 50.0
DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_DIRECTION: Final[bool] = True
DEFAULT_SENSE_HYSTERESIS_REPORT_INTERVAL: Final[int] = 15


class PlugwiseSense(NodeSED):
Expand Down Expand Up @@ -175,6 +180,9 @@ 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
if (report_interval := self._report_interval_from_cache()) is None:
dirty = True
report_interval = DEFAULT_SENSE_HYSTERESIS_REPORT_INTERVAL
dirty |= self._sense_hysteresis_config_dirty_from_cache()

self._hysteresis_config = SenseHysteresisConfig(
Expand All @@ -186,6 +194,7 @@ async def _load_from_cache(self) -> bool:
temperature_upper_bound=temperature_upper_bound,
temperature_lower_bound=temperature_lower_bound,
temperature_direction=temperature_direction,
report_interval=report_interval,
dirty=dirty,
)
if dirty:
Expand Down Expand Up @@ -248,6 +257,14 @@ 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 _report_interval_from_cache(self) -> int | None:
"""Load report interval from cache."""
if (
report_interval := self._get_cache(CACHE_SENSE_HYSTERESIS_REPORT_INTERVAL)
) is not None:
return int(report_interval)
return None

def _sense_hysteresis_config_dirty_from_cache(self) -> bool:
"""Load sense hysteresis dirty from cache."""
if (
Expand Down Expand Up @@ -278,6 +295,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,
report_interval=self.report_interval,
dirty=self.hysteresis_config_dirty,
)

Expand Down Expand Up @@ -337,6 +355,13 @@ def temperature_direction(self) -> bool:
return self._hysteresis_config.temperature_direction
return DEFAULT_SENSE_HYSTERESIS_TEMPERATURE_DIRECTION

@property
def report_interval(self) -> int:
"""Sense report interval in minutes."""
if self._hysteresis_config.report_interval is not None:
return self._hysteresis_config.report_interval
return DEFAULT_SENSE_HYSTERESIS_REPORT_INTERVAL

@property
def hysteresis_config_dirty(self) -> bool:
"""Sense hysteresis configuration dirty flag."""
Expand Down Expand Up @@ -537,6 +562,31 @@ async def set_hysteresis_temperature_lower_bound(self, lower_bound: float) -> bo
await self._sense_configure_update()
return True

async def set_report_interval(self, report_interval: int) -> bool:
"""Configure Sense measurement interval.

Configuration request will be queued and will be applied the next time when node is awake for maintenance.
"""
_LOGGER.debug(
"set_report_interval | Device %s | %s -> %s",
self.name,
self._hysteresis_config.report_interval,
report_interval,
)
if report_interval < 1 or report_interval > 60:
raise ValueError(
f"Invalid measurement interval {report_interval}. It must be between 1 and 60 minutes"
)
if self._hysteresis_config.report_interval == report_interval:
return False
self._hysteresis_config = replace(
self._hysteresis_config,
report_interval=report_interval,
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.

Expand Down Expand Up @@ -637,6 +687,7 @@ async def _run_awake_tasks(self) -> None:
configure_result = await gather(
self._configure_sense_humidity_task(),
self._configure_sense_temperature_task(),
self._configure_sense_report_interval_task(),
)
if all(configure_result):
self._hysteresis_config = replace(self._hysteresis_config, dirty=False)
Expand All @@ -645,10 +696,11 @@ async def _run_awake_tasks(self) -> None:
else:
_LOGGER.warning(
"Sense hysteresis configuration partially failed for %s "
"(humidity=%s, temperature=%s); will retry on next wake.",
"(humidity=%s, temperature=%s, report_interval=%s); will retry on next wake.",
self.name,
configure_result[0],
configure_result[1],
configure_result[2],
)
await self.publish_feature_update_to_subscribers(
NodeFeature.SENSE_HYSTERESIS,
Expand Down Expand Up @@ -686,27 +738,18 @@ async def _configure_sense_humidity_task(self) -> bool:
self.humidity_direction,
)
if (response := await request.send()) is None:
_LOGGER.warning(
"No response from %s to configure humidity hysteresis settings request",
self.name,
)
self._log_configure_failed("humidity hysteresis")
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._log_configure_success("humidity hysteresis")
return True

_LOGGER.warning(
"Unexpected response ack type %s for %s",
response.node_ack_type,
self.name,
)
self._log_unexpected_response_ack(response.node_ack_type)
return False

async def _configure_sense_temperature_task(self) -> bool:
Expand Down Expand Up @@ -746,22 +789,55 @@ async def _configure_sense_temperature_task(self) -> bool:
)
return False
if response.node_ack_type == NodeAckResponseType.SENSE_BOUNDARIES_FAILED:
_LOGGER.warning(
"Failed to configure temperature hysteresis settings for %s", self.name
)
self._log_configure_failed("temperature hysteresis")
return False
if response.node_ack_type == NodeAckResponseType.SENSE_BOUNDARIES_ACCEPTED:
_LOGGER.debug(
"Successful configure temperature hysteresis settings for %s", self.name
self._log_configure_success("temperature hysteresis")
return True

self._log_unexpected_response_ack(response.node_ack_type)
return False

async def _configure_sense_report_interval_task(self) -> bool:
"""Configure Sense report interval setting. Returns True if successful."""
if not self._hysteresis_config.dirty:
return True
request = SenseReportIntervalRequest(
self._send,
self._mac_in_bytes,
self.report_interval,
)
if (response := await request.send()) is None:
_LOGGER.warning(
"No response from %s to configure report interval.",
self.name,
)
return False
if response.node_ack_type == NodeAckResponseType.SENSE_INTERVAL_FAILED:
self._log_configure_failed("report interval")
return False
if response.node_ack_type == NodeAckResponseType.SENSE_INTERVAL_ACCEPTED:
self._log_configure_success("report interval")
return True

self._log_unexpected_response_ack(response.node_ack_type)
return False

def _log_unexpected_response_ack(self, response: NodeAckResponseType) -> None:
"""Log unexpected response."""
_LOGGER.warning(
"Unexpected response ack type %s for %s",
response.node_ack_type,
response.name,
self.name,
)
return False

def _log_configure_failed(self, parameter: str) -> None:
"""Log configuration failed."""
_LOGGER.warning("Failed to configure %s for %s", parameter, self.name)

def _log_configure_success(self, parameter: str) -> None:
"""Log configuration success."""
_LOGGER.debug("Successful configure %s for %s", parameter, self.name)

async def _sense_configure_update(self) -> None:
"""Push sense configuration update to cache."""
Expand All @@ -787,6 +863,7 @@ async def _sense_configure_update(self) -> None:
self._set_cache(
CACHE_SENSE_HYSTERESIS_TEMPERATURE_DIRECTION, self.temperature_direction
)
self._set_cache(CACHE_SENSE_HYSTERESIS_REPORT_INTERVAL, self.report_interval)
self._set_cache(
CACHE_SENSE_HYSTERESIS_CONFIG_DIRTY, self.hysteresis_config_dirty
)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "plugwise_usb"
version = "0.45.0"
version = "0.46.0"
license = "MIT"
keywords = ["home", "automation", "plugwise", "module", "usb"]
classifiers = [
Expand Down
Loading