Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## v0.47.6 - 2026-03-11

PR [425](https://github.com/plugwise/python-plugwise-usb/pull/425): More 0138-related improvements

## v0.47.5 - 2026-03-09

PR [424](https://github.com/plugwise/python-plugwise-usb/pull/424): Fix 0138 AckResponse
Expand Down
26 changes: 19 additions & 7 deletions plugwise_usb/messages/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
CirclePlusRealTimeClockResponse,
CirclePlusScanResponse,
CirclePowerUsageResponse,
CircleRelayInitStateResponse,
EnergyCalibrationResponse,
NodeAckResponse,
NodeFeaturesResponse,
Expand Down Expand Up @@ -60,6 +61,7 @@ class PlugwiseRequest(PlugwiseMessage):
"""Base class for request messages to be sent from by USB-Stick."""

_reply_identifier: bytes | None = b"0000"
_reply_identifier_2: bytes | None = None

def __init__(
self,
Expand Down Expand Up @@ -157,7 +159,7 @@ async def subscribe_to_response(
[
Callable[[PlugwiseResponse], Coroutine[Any, Any, bool]],
bytes | None,
tuple[bytes | None, ...] | None,
tuple[bytes, ...] | None,
bytes | None,
],
Coroutine[Any, Any, Callable[[], None]],
Expand All @@ -171,10 +173,18 @@ async def subscribe_to_response(
self._unsubscribe_stick_response = await stick_subscription_fn(
self._process_stick_response, self._seq_id, None
)
reply_identifiers = (
tuple(
reply_id
for reply_id in (self._reply_identifier, self._reply_identifier_2)
if reply_id is not None
)
or None
)
self._unsubscribe_node_response = await node_subscription_fn(
self.process_node_response,
self._mac,
(self._reply_identifier,),
reply_identifiers,
self._seq_id,
)

Expand Down Expand Up @@ -1510,11 +1520,13 @@ class CircleRelayInitStateRequest(PlugwiseRequest):
"""Get or set initial relay state after power-up of Circle.

Supported protocols : 2.6
Response message : NodeAckResponse # CircleInitRelayStateResponse
Response message : NodeAckResponse for set
: CircleRelayInitStateResponse for get
"""

_identifier = b"0138" # PWCircleGetSetInitialRelaisStateRequestV2_6
_reply_identifier = b"0100" # b"0139" # PWCircleGetSetInitialRelaisStateReplyV2_6
_reply_identifier = b"0139" # CircleRelayInitStateResponse
_reply_identifier_2 = b"0100" # NodeAckResponse

def __init__(
self,
Expand All @@ -1530,13 +1542,13 @@ def __init__(
self.relay = Int(1 if relay_state else 0, length=2)
self._args += [self.set_or_get, self.relay]

async def send(self) -> NodeAckResponse | None:
async def send(self) -> CircleRelayInitStateResponse | NodeAckResponse | None:
"""Send request."""
result = await self._send_request()
if isinstance(result, NodeAckResponse):
if isinstance(result, CircleRelayInitStateResponse | NodeAckResponse):
return result
if result is None:
return None
raise MessageError(
f"Invalid response message. Received {result.__class__.__name__}, expected NodeAckResponse"
f"Invalid response message. Received {result.__class__.__name__}, expected CircleRelayInitStateResponse or NodeAckResponse"
)
8 changes: 5 additions & 3 deletions plugwise_usb/messages/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,19 +68,21 @@ class NodeResponseType(bytes, Enum):
CIRCLE_PLUS = b"00DD" # ack for CirclePlusAllowJoiningRequest with state false - HN_ALLOWNEWNODESTOJOIN_ACK_0
RELAY_SWITCHED_OFF = b"00DE" # HN_SETRELAISPOSITION_OFF
REAL_TIME_CLOCK_ACCEPTED = b"00DF" # HN_SETRTCDATA_ACK
RELAY_SWITCH_FAILED = b"00E2" # HN_SETRELAISPOSITION_DENIED
RELAY_SWITCH_FAILED = b"00E2" # HN_SETRELAISPOSITION_DENIED
REAL_TIME_CLOCK_FAILED = b"00E7" # HN_SETRTCDATA_NACK
NODE_RESET_ACK = b"00F2" # HN_REPLYROLECHANGED_OK
NODE_RESET_FAIL = b"00F3" # HN_REPLYROLECHANGED_NOT_OK
SED_CONFIG_ACCEPTED = b"00F6" # HN_ACKSETSLEEPBEHAVIOR
SED_CONFIG_FAILED = b"00F7" # HN_ACKREQUESTSELFREMOVALFROMNETWORK
POWER_LOG_INTERVAL_ACCEPTED = b"00F8" # ack for CircleMeasureIntervalRequest - HN_ACKSETPOWERRECORDING
POWER_LOG_INTERVAL_ACCEPTED = (
b"00F8" # ack for CircleMeasureIntervalRequest - HN_ACKSETPOWERRECORDING
)


class NodeAckResponseType(bytes, Enum):
"""Response types of a 'NodeAckResponse' reply message."""

DEFAULT_ACK= b"00A0" # HN_DEFAULT_ACK
DEFAULT_ACK = b"00A0" # HN_DEFAULT_ACK
DEFAULT_FAIL = b"00A1" # HN_DEFAULT_NACK
SENSE_INTERVAL_ACCEPTED = b"00B3" # HN_ACKSETSENSEINTERVAL_ACK
SENSE_INTERVAL_FAILED = b"00B4" # HN_ACKSETSENSEINTERVAL_NACK
Expand Down
63 changes: 47 additions & 16 deletions plugwise_usb/nodes/circle.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@
EnergyCalibrationRequest,
NodeInfoRequest,
)
from ..messages.responses import NodeInfoResponse, NodeResponseType
from ..messages.responses import (
CircleRelayInitStateResponse,
NodeAckResponseType,
NodeInfoResponse,
NodeResponseType,
)
from .helpers import EnergyCalibration, raise_not_loaded
from .helpers.counter import EnergyCounters
from .helpers.firmware import CIRCLE_FIRMWARE_SUPPORT
Expand Down Expand Up @@ -1140,28 +1145,54 @@ async def _relay_init_get(self) -> bool | None:
"Retrieval of initial state of relay is not "
+ f"supported for device {self.name}"
)
request = CircleRelayInitStateRequest(
self._send, self._mac_in_bytes, False, False
)
if (response := await request.send()) is not None:
await self._relay_init_update_state(response.relay.value == 1)
return self._relay_config.init_state
return None

async def _relay_init_set(self, state: bool) -> bool | None:
try:
request = CircleRelayInitStateRequest(
self._send, self._mac_in_bytes, False, False
)
except MessageError as err:
raise NodeError(f"{self._mac_in_str} error: {err}") from err

if (response := await request.send()) is None:
_LOGGER.warning(
"No response from %s to get relay init setting", self._mac_in_str
)
return None

if isinstance(response, CircleRelayInitStateResponse):
_LOGGER.debug("Successful get of relay init state for %s", self._mac_in_str)
state = response.relay.value == 1
await self._relay_init_update_state(state)
return state

async def _relay_init_set(self, state: bool) -> None:
"""Configure relay init state."""
if NodeFeature.RELAY_INIT not in self._features:
raise NodeError(
"Configuring of initial state of relay is not"
+ f"supported for device {self.name}"
)
request = CircleRelayInitStateRequest(
self._send, self._mac_in_bytes, True, state
)
if (response := await request.send()) is not None:
await self._relay_init_update_state(response.relay.value == 1)
return self._relay_config.init_state
return None

try:
request = CircleRelayInitStateRequest(
self._send, self._mac_in_bytes, True, state
)
except MessageError as err:
raise NodeError(f"{self._mac_in_str} error: {err}") from err

if (response := await request.send()) is None:
_LOGGER.warning(
"No response from %s to configure relay init setting", self._mac_in_str
)
return None

if response.node_ack_type == NodeAckResponseType.DEFAULT_FAIL:
_LOGGER.warning("Failed to set relay init state for %s", self._mac_in_str)
return None

if response.node_ack_type == NodeAckResponseType.DEFAULT_ACK:
_LOGGER.debug("Successful set relay init state for %s", self._mac_in_str)
await self._relay_init_update_state(state)
Comment on lines +1183 to +1195
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Propagate relay-init write failures instead of returning stale cached state.

On Line 1179 and Line 1185, this helper only logs and returns. If _relay_config.init_state was already populated, set_relay_init() will return that stale value and relay_init_on() / relay_init_off() will appear to succeed even though the write failed. Any non-default ack also falls through silently.

🐛 Proposed fix
         if (response := await request.send()) is None:
             _LOGGER.warning(
                 "No response from %s to configure relay init setting", self._mac_in_str
             )
-            return None
+            raise NodeError(
+                f"No response from {self._mac_in_str} to configure relay init setting"
+            )

         if response.node_ack_type == NodeAckResponseType.DEFAULT_FAIL:
             _LOGGER.warning("Failed to set relay init state for %s", self._mac_in_str)
-            return None
+            raise NodeError(f"Failed to set relay init state for {self._mac_in_str}")

         if response.node_ack_type == NodeAckResponseType.DEFAULT_ACK:
             _LOGGER.debug("Successful set relay init state for %s", self._mac_in_str)
             await self._relay_init_update_state(state)
+            return
+
+        raise NodeError(
+            f"Unexpected ack {response.node_ack_type!r} for relay init set on {self._mac_in_str}"
+        )


async def _relay_init_load_from_cache(self) -> bool:
"""Load relay init state from cache. Returns True if retrieval was successful."""
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.47.5"
version = "0.47.6"
license = "MIT"
keywords = ["home", "automation", "plugwise", "module", "usb"]
classifiers = [
Expand Down
Loading