diff --git a/CHANGELOG.md b/CHANGELOG.md index e714cd0f1..73db66ed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Ongoing +- PR [337](https://github.com/plugwise/python-plugwise-usb/pull/337): Improve node removal, remove and reset the node as executed by Source, and remove the cache-file. - PR [342](https://github.com/plugwise/python-plugwise-usb/pull/342): Improve node_type chaching. ## 0.46.0 - 2025-09-12 diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 6068e3e62..3184b875b 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -481,11 +481,10 @@ class NodeResetRequest(PlugwiseRequest): """TODO:Some kind of reset request. Supported protocols : 1.0, 2.0, 2.1 - Response message : + Response message : NodeResponse with NODE_RESET_ACK/NACK (@dirixmjm & @bouwew 20250910) """ _identifier = b"0009" - _reply_identifier = b"0003" def __init__( self, @@ -496,20 +495,18 @@ def __init__( ) -> None: """Initialize NodeResetRequest message object.""" super().__init__(send_fn, mac) - self._args += [ - Int(moduletype, length=2), - Int(timeout, length=2), - ] + module_id = getattr(moduletype, "value", moduletype) + self._args += [Int(module_id, length=2), Int(timeout, length=2)] - async def send(self) -> NodeSpecificResponse | None: + async def send(self) -> NodeResponse | None: """Send request.""" result = await self._send_request() - if isinstance(result, NodeSpecificResponse): + if isinstance(result, NodeResponse): return result if result is None: return None raise MessageError( - f"Invalid response message. Received {result.__class__.__name__}, expected NodeSpecificResponse" + f"Invalid response message. Received {result.__class__.__name__}, expected NodeResponse" ) @@ -863,11 +860,11 @@ def __init__( self, send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], mac_circle_plus: bytes, - mac_to_unjoined: str, + mac_to_unjoin: str, ) -> None: """Initialize NodeRemoveRequest message object.""" super().__init__(send_fn, mac_circle_plus) - self._args.append(String(mac_to_unjoined, length=16)) + self._args.append(String(mac_to_unjoin, length=16)) async def send(self) -> NodeRemoveResponse | None: """Send request.""" diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 1f4f1923d..74da5ead8 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -59,13 +59,14 @@ class NodeResponseType(bytes, Enum): CIRCLE_PLUS = b"00DD" # ack for CirclePlusAllowJoiningRequest with state false CLOCK_ACCEPTED = b"00D7" JOIN_ACCEPTED = b"00D9" # ack for CirclePlusAllowJoiningRequest with state true + NODE_RESET_ACK = b"00F2" POWER_LOG_INTERVAL_ACCEPTED = b"00F8" # ack for CircleMeasureIntervalRequest + REAL_TIME_CLOCK_ACCEPTED = b"00DF" + REAL_TIME_CLOCK_FAILED = b"00E7" RELAY_SWITCHED_OFF = b"00DE" RELAY_SWITCHED_ON = b"00D8" RELAY_SWITCH_FAILED = b"00E2" SED_CONFIG_ACCEPTED = b"00F6" - REAL_TIME_CLOCK_ACCEPTED = b"00DF" - REAL_TIME_CLOCK_FAILED = b"00E7" # TODO: Validate these response types SED_CONFIG_FAILED = b"00F7" diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 8272747b7..985cd7581 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -166,10 +166,17 @@ async def clear_cache(self) -> None: async def unregister_node(self, mac: str) -> None: """Unregister node from current Plugwise network.""" + if not validate_mac(mac): + raise NodeError(f"MAC {mac} invalid") + + node_to_remove = self._nodes[mac] try: - await self._register.unregister_node(mac) - except (KeyError, NodeError) as exc: - raise MessageError("Mac not registered, already deleted?") from exc + await self._register.unregister_node(node_to_remove) + except NodeError as exc: + # Preserve precise failure cause from registry/reset/remove. + raise MessageError(str(exc)) from exc + except KeyError as exc: + raise MessageError(f"Mac {mac} not registered, already deleted?") from exc await self._nodes[mac].unload() self._nodes.pop(mac) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 1945be1dc..ca18a1cb3 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -8,7 +8,7 @@ import logging from typing import Final -from ..api import NodeType +from ..api import NodeType, PlugwiseNode from ..constants import UTF8 from ..exceptions import CacheError, NodeError, StickError from ..helpers.util import validate_mac @@ -266,27 +266,27 @@ async def register_node(self, mac: str) -> None: if self.update_network_registration(mac): await self._exec_node_discover_callback(mac, None, False) - async def unregister_node(self, mac: str) -> None: + async def unregister_node(self, node: PlugwiseNode) -> None: """Unregister node from current Plugwise network.""" - if not validate_mac(mac): - raise NodeError(f"MAC {mac} invalid") - - if mac not in self._registry: - raise NodeError(f"No existing Node ({mac}) found to unregister") + if node.mac not in self._registry: + raise NodeError(f"No existing Node ({node.mac}) found to unregister") - request = NodeRemoveRequest(self._send_to_controller, self._mac_nc, mac) + # First remove the node, when succesful then reset it. + request = NodeRemoveRequest(self._send_to_controller, self._mac_nc, node.mac) if (response := await request.send()) is None: raise NodeError( f"The Zigbee network coordinator '{self._mac_nc!r}'" - + f" did not respond to unregister node '{mac}'" + + f" did not respond to unregister node '{node.mac}'" ) if response.status.value != 1: raise NodeError( f"The Zigbee network coordinator '{self._mac_nc!r}'" - + f" failed to unregister node '{mac}'" + + f" failed to unregister node '{node.mac}'" ) - await self.remove_network_registration(mac) + await node.reset_node() + await self.remove_network_registration(node.mac) + await node.clear_cache() async def clear_register_cache(self) -> None: """Clear current cache.""" diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index a41611e1f..0e98d8380 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -32,8 +32,8 @@ from ..constants import SUPPRESS_INITIALIZATION_WARNINGS, TYPE_MODEL, UTF8 from ..exceptions import FeatureError, NodeError from ..helpers.util import version_to_model -from ..messages.requests import NodeInfoRequest, NodePingRequest -from ..messages.responses import NodeInfoResponse, NodePingResponse +from ..messages.requests import NodeInfoRequest, NodePingRequest, NodeResetRequest +from ..messages.responses import NodeInfoResponse, NodePingResponse, NodeResponseType from .helpers import raise_not_loaded from .helpers.cache import NodeCache from .helpers.firmware import FEATURE_SUPPORTED_AT_FIRMWARE, SupportedVersions @@ -391,6 +391,7 @@ async def _load_cache_file(self) -> bool: async def clear_cache(self) -> None: """Clear current cache.""" if self._node_cache is not None: + _LOGGER.debug("Removing node % cache", self._mac_in_str) await self._node_cache.clear_cache() async def _load_from_cache(self) -> bool: @@ -641,6 +642,20 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any return states + async def reset_node(self) -> None: + """Reset node present in the current Plugwise network.""" + timeout = 4 + request = NodeResetRequest( + self._send, self._mac_in_bytes, self.node_type.value, timeout + ) + response = await request.send() + if response is None or response.ack_id != NodeResponseType.NODE_RESET_ACK: + _LOGGER.warning( + "Node %s reset not acknowledged (response=%s)", + self._mac_in_str, + getattr(response, "ack_id", None), + ) + async def unload(self) -> None: """Deactivate and unload node features.""" if not self._cache_enabled: diff --git a/pyproject.toml b/pyproject.toml index 4661415b8..524d50398 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "0.46.0" +version = "0.46.1a1" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index 897f56f89..4d22e5c64 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -1344,6 +1344,19 @@ + b"00000444" + b"00044020", ), + b"\x05\x05\x03\x030009333333333333333302047290\r\n": ( + "Reset node request for 3333333333333333", + b"000000C1", # Success ack + b"0000" + b"00F2" + b"3333333333333333", # msg_id, reset_ack, mac + ), + b"\x05\x05\x03\x03001C009876543210123433333333333333331E2D\r\n": ( + "Remove node request for 3333333333333333", + b"000000C1", # Success ack + b"001D" # msg_id + + b"0098765432101234" # Circle + mac + + b"3333333333333333" + + b"01", # status + ), } diff --git a/tests/test_usb.py b/tests/test_usb.py index af5cbdffe..fae43238c 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -719,6 +719,11 @@ async def test_node_discovery(self, monkeypatch: pytest.MonkeyPatch) -> None: assert stick.joined_nodes == 9 assert stick.nodes.get("0098765432101234") is not None assert len(stick.nodes) == 7 # Discovered nodes + + # Test unregistering of node + await stick.unregister_node("3333333333333333") + assert stick.nodes.get("3333333333333333") is None + assert len(stick.nodes) == 6 await stick.disconnect() async def node_relay_state(