diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index f5b8540c9..34b2dd675 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -4,7 +4,7 @@ name: Latest commit env: - CACHE_VERSION: 22 + CACHE_VERSION: 24 DEFAULT_PYTHON: "3.13" PRE_COMMIT_HOME: ~/.cache/pre-commit @@ -22,7 +22,7 @@ jobs: name: Prepare steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5 @@ -71,7 +71,7 @@ jobs: needs: prepare steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Python ${{ env.DEFAULT_PYTHON }} @@ -124,7 +124,7 @@ jobs: - dependencies_check steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5 @@ -175,7 +175,7 @@ jobs: python-version: ["3.13"] steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5 @@ -215,7 +215,7 @@ jobs: steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5 @@ -253,7 +253,7 @@ jobs: needs: pytest steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Python ${{ env.DEFAULT_PYTHON }} @@ -293,7 +293,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@v4 - name: Run ShellCheck uses: ludeeus/action-shellcheck@master @@ -303,7 +303,7 @@ jobs: name: Dependency steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@v4 - name: Run dependency checker run: scripts/dependencies_check.sh debug @@ -313,7 +313,7 @@ jobs: needs: pytest steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5 @@ -358,7 +358,7 @@ jobs: needs: [coverage, mypy] steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5 @@ -401,7 +401,7 @@ jobs: needs: coverage steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5 diff --git a/CODEOWNERS b/CODEOWNERS index 953f4907d..ffdf2d7d2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -3,13 +3,11 @@ # Specific files setup.cfg @plugwise/plugwise-usb -setup.py @plugwise/plugwise-usb pyproject.toml @plugwise/plugwise-usb requirements*.txt @plugwise/plugwise-usb # Main code -/plugwise/ @plugwise/plugwise-usb -/userdata/ @plugwise/plugwise-usb +/plugwise_usb/ @plugwise/plugwise-usb # Tests and development support /tests/ @plugwise/plugwise-usb diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index a00ca68da..e375e79a3 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -14,7 +14,7 @@ from .api import NodeEvent, PlugwiseNode, StickEvent from .connection import StickController -from .exceptions import StickError, SubscriptionError +from .exceptions import MessageError, NodeError, StickError, SubscriptionError from .network import StickNetwork FuncT = TypeVar("FuncT", bound=Callable[..., Any]) @@ -190,20 +190,27 @@ def accept_join_request(self) -> bool | None: return None return self._network.accept_join_request - @accept_join_request.setter - def accept_join_request(self, state: bool) -> None: + async def set_accept_join_request(self, state: bool) -> bool: """Configure join request setting.""" if not self._controller.is_connected: raise StickError( "Cannot accept joining node" + " without an active USB-Stick connection." ) + if self._network is None or not self._network.is_running: raise StickError( "Cannot accept joining node" + "without node discovery be activated. Call discover() first." ) - self._network.accept_join_request = state + + # Observation: joining is only temporarily possible after a HA (re)start or + # Integration reload, force the setting when used otherwise + try: + await self._network.allow_join_requests(state) + except (MessageError, NodeError) as exc: + raise NodeError(f"Failed setting accept joining: {exc}") from exc + return True async def clear_cache(self) -> None: """Clear current cache.""" @@ -347,7 +354,11 @@ async def register_node(self, mac: str) -> bool: """Add node to plugwise network.""" if self._network is None: return False - return await self._network.register_node(mac) + + try: + return await self._network.register_node(mac) + except NodeError as exc: + raise NodeError(f"Unable to add Node ({mac}): {exc}") from exc @raise_not_connected @raise_not_initialized @@ -355,7 +366,10 @@ async def unregister_node(self, mac: str) -> None: """Remove node to plugwise network.""" if self._network is None: return - await self._network.unregister_node(mac) + try: + await self._network.unregister_node(mac) + except MessageError as exc: + raise NodeError(f"Unable to remove Node ({mac}): {exc}") from exc async def disconnect(self) -> None: """Disconnect from USB-Stick.""" diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index bb2559b3c..beee04725 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -214,7 +214,7 @@ async def get_node_details( self.send, bytes(mac, UTF8), retries=1 ) try: - ping_response = await ping_request.send(suppress_node_errors=True) + ping_response = await ping_request.send() except StickError: return (None, None) if ping_response is None: @@ -230,7 +230,9 @@ async def get_node_details( return (info_response, ping_response) async def send( - self, request: PlugwiseRequest, suppress_node_errors: bool = True + self, + request: PlugwiseRequest, + suppress_node_errors=True, ) -> PlugwiseResponse | None: """Submit request to queue and return response.""" if not suppress_node_errors: diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 4aa075a9f..d5d714fd2 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -75,46 +75,53 @@ async def stop(self) -> None: _LOGGER.debug("queue stopped") async def submit(self, request: PlugwiseRequest) -> PlugwiseResponse | None: - """Add request to queue and return the response of node. Raises an error when something fails.""" + """Add request to queue and return the received node-response when applicable. + + Raises an error when something fails. + """ if request.waiting_for_response: raise MessageError( f"Cannot send message {request} which is currently waiting for response." ) - while request.resend and not request.waiting_for_response: + while request.resend: _LOGGER.debug("submit | start (%s) %s", request.retries_left, request) if not self._running or self._stick is None: raise StickError( f"Cannot send message {request.__class__.__name__} for" + f"{request.mac_decoded} because queue manager is stopped" ) + await self._add_request_to_queue(request) + if request.no_response: + return None + try: response: PlugwiseResponse = await request.response_future() return response - except (NodeTimeout, StickTimeout) as e: + except (NodeTimeout, StickTimeout) as exc: if isinstance(request, NodePingRequest): # For ping requests it is expected to receive timeouts, so lower log level _LOGGER.debug( - "%s, cancel because timeout is expected for NodePingRequests", e + "%s, cancel because timeout is expected for NodePingRequests", exc ) elif request.resend: - _LOGGER.debug("%s, retrying", e) + _LOGGER.debug("%s, retrying", exc) else: - _LOGGER.warning("%s, cancel request", e) # type: ignore[unreachable] - except StickError as exception: - _LOGGER.error(exception) + _LOGGER.warning("%s, cancel request", exc) # type: ignore[unreachable] + except StickError as exc: + _LOGGER.error(exc) self._stick.correct_received_messages(1) raise StickError( f"No response received for {request.__class__.__name__} " + f"to {request.mac_decoded}" - ) from exception - except BaseException as exception: + ) from exc + except BaseException as exc: self._stick.correct_received_messages(1) raise StickError( f"No response received for {request.__class__.__name__} " + f"to {request.mac_decoded}" - ) from exception + ) from exc return None @@ -132,7 +139,7 @@ async def _send_queue_worker(self) -> None: _LOGGER.debug("Send_queue_worker started") while self._running and self._stick is not None: request = await self._submit_queue.get() - _LOGGER.debug("Send from send queue %s", request) + _LOGGER.debug("Sending from send queue %s", request) if request.priority == Priority.CANCEL: self._submit_queue.task_done() return diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 7ea263038..d2c33d882 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -271,6 +271,7 @@ async def _put_message_in_queue( _LOGGER.debug("Add response to queue: %s", response) await self._message_queue.put(response) if self._message_worker_task is None or self._message_worker_task.done(): + _LOGGER.debug("Queue: start new worker-task") self._message_worker_task = self._loop.create_task( self._message_queue_worker(), name="Plugwise message receiver queue worker", @@ -281,6 +282,7 @@ async def _message_queue_worker(self) -> None: _LOGGER.debug("Message queue worker started") while self.is_connected: response: PlugwiseResponse = await self._message_queue.get() + _LOGGER.debug("Priority: %s", response.priority) if response.priority == Priority.CANCEL: self._message_queue.task_done() return @@ -511,5 +513,4 @@ async def _notify_node_response_subscribers( name=f"Postpone subscription task for {node_response.seq_id!r} retry {node_response.retries}", ) - # endregion diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 007103a25..a2dbe8f17 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -75,13 +75,15 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: self._stick_response = self._loop.create_future() request.add_send_attempt() - _LOGGER.info("Send %s", request) + _LOGGER.info("Sending %s", request) # Write message to serial port buffer serialized_data = request.serialize() _LOGGER.debug("write_request_to_port | Write %s to port as %s", request, serialized_data) self._transport.write(serialized_data) - request.start_response_timeout() + # Don't timeout when no response expected + if not request.no_response: + request.start_response_timeout() # Wait for USB stick to accept request try: @@ -118,7 +120,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: self._receiver.subscribe_to_stick_responses, self._receiver.subscribe_to_node_responses, ) - _LOGGER.debug("write_request_to_port | request has subscribed : %s", request) + _LOGGER.debug("write_request_to_port | request has subscribed : %s", request) elif response.response_type == StickResponseType.TIMEOUT: _LOGGER.warning( "USB-Stick directly responded with communication timeout for %s", diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index df8442103..eb99c232c 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -30,8 +30,11 @@ MESSAGE_HEADER: Final = b"\x05\x05\x03\x03" # Max timeout in seconds -STICK_TIME_OUT: Final = 11 # Stick responds with timeout messages within 10s. -NODE_TIME_OUT: Final = 15 # In bigger networks a response from a node could take up a while, so lets use 15 seconds. +# Stick responds with timeout messages within 10s. +STICK_TIME_OUT: Final = 11 +# In bigger networks a response from a Node could take up a while, so lets use 15 seconds. +NODE_TIME_OUT: Final = 15 + MAX_RETRIES: Final = 3 SUPPRESS_INITIALIZATION_WARNINGS: Final = 10 # Minutes to suppress (expected) communication warning messages after initialization diff --git a/plugwise_usb/messages/__init__.py b/plugwise_usb/messages/__init__.py index 9e4934e8a..9f2e7260f 100644 --- a/plugwise_usb/messages/__init__.py +++ b/plugwise_usb/messages/__init__.py @@ -11,7 +11,7 @@ from ..helpers.util import crc_fun -class Priority(int, Enum): +class Priority(Enum): """Message priority levels for USB-stick message requests.""" CANCEL = 0 diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 4cf6dd3ed..c2c138cb2 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -107,6 +107,8 @@ def __init__( self._response_future: Future[PlugwiseResponse] = self._loop.create_future() self._waiting_for_response = False + self.no_response = False + def __repr__(self) -> str: """Convert request into writable str.""" if self._seq_id is None: @@ -303,9 +305,7 @@ async def _process_stick_response(self, stick_response: StickResponse) -> None: self, ) - async def _send_request( - self, suppress_node_errors: bool = False - ) -> PlugwiseResponse | None: + async def _send_request(self, suppress_node_errors=False) -> PlugwiseResponse | None: """Send request.""" if self._send_fn is None: return None @@ -355,11 +355,9 @@ class StickNetworkInfoRequest(PlugwiseRequest): _identifier = b"0001" _reply_identifier = b"0002" - async def send( - self, suppress_node_errors: bool = False - ) -> StickNetworkInfoResponse | None: + async def send(self) -> StickNetworkInfoResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, StickNetworkInfoResponse): return result if result is None: @@ -379,11 +377,9 @@ class CirclePlusConnectRequest(PlugwiseRequest): _identifier = b"0004" _reply_identifier = b"0005" - async def send( - self, suppress_node_errors: bool = False - ) -> CirclePlusConnectResponse | None: + async def send(self) -> CirclePlusConnectResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, CirclePlusConnectResponse): return result if result is None: @@ -409,30 +405,16 @@ def serialize(self) -> bytes: return MESSAGE_HEADER + msg + checksum + MESSAGE_FOOTER -class PlugwiseRequestWithNodeAckResponse(PlugwiseRequest): - """Base class of a plugwise request with a NodeAckResponse.""" - - async def send(self, suppress_node_errors: bool = False) -> NodeAckResponse | None: - """Send request.""" - result = await self._send_request(suppress_node_errors) - 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 NodeAddRequest(PlugwiseRequestWithNodeAckResponse): +class NodeAddRequest(PlugwiseRequest): """Add node to the Plugwise Network and add it to memory of Circle+ node. Supported protocols : 1.0, 2.0 - Response message : TODO check if response is NodeAckResponse + Response message : None + There will be a delayed NodeRejoinResponse, b"0061", picked up by a separate subscription. """ _identifier = b"0007" - _reply_identifier = b"0005" + _reply_identifier = None def __init__( self, @@ -444,6 +426,11 @@ def __init__( super().__init__(send_fn, mac) accept_value = 1 if accept else 0 self._args.append(Int(accept_value, length=2)) + self.no_response = True + + async def send(self) -> None: + """Send request.""" + await self._send_request() # This message has an exceptional format (MAC at end of message) # and therefore a need to override the serialize method @@ -468,7 +455,6 @@ class CirclePlusAllowJoiningRequest(PlugwiseRequest): """ _identifier = b"0008" - _reply_identifier = b"0000" def __init__( self, @@ -480,9 +466,9 @@ def __init__( val = 1 if enable else 0 self._args.append(Int(val, length=2)) - async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: + async def send(self) -> NodeResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeResponse): return result if result is None: @@ -516,11 +502,9 @@ def __init__( Int(timeout, length=2), ] - async def send( - self, suppress_node_errors: bool = False - ) -> NodeSpecificResponse | None: + async def send(self) -> NodeSpecificResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeSpecificResponse): return result if result is None: @@ -548,13 +532,11 @@ def __init__( super().__init__(send_fn, None) self._max_retries = 1 - async def send( - self, suppress_node_errors: bool = False - ) -> StickInitResponse | None: + async def send(self) -> StickInitResponse | None: """Send request.""" if self._send_fn is None: raise MessageError("Send function missing") - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, StickInitResponse): return result if result is None: @@ -574,11 +556,9 @@ class NodeImagePrepareRequest(PlugwiseRequest): _identifier = b"000B" _reply_identifier = b"0003" - async def send( - self, suppress_node_errors: bool = False - ) -> NodeSpecificResponse | None: + async def send(self) -> NodeSpecificResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeSpecificResponse): return result if result is None: @@ -598,11 +578,9 @@ class NodeImageValidateRequest(PlugwiseRequest): _identifier = b"000C" _reply_identifier = b"0010" - async def send( - self, suppress_node_errors: bool = False - ) -> NodeImageValidationResponse | None: + async def send(self) -> NodeImageValidationResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeImageValidationResponse): return result if result is None: @@ -633,9 +611,9 @@ def __init__( self._reply_identifier = b"000E" self._max_retries = retries - async def send(self, suppress_node_errors: bool = False) -> NodePingResponse | None: + async def send(self) -> NodePingResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodePingResponse): return result if result is None: @@ -679,11 +657,9 @@ class CirclePowerUsageRequest(PlugwiseRequest): _identifier = b"0012" _reply_identifier = b"0013" - async def send( - self, suppress_node_errors: bool = False - ) -> CirclePowerUsageResponse | None: + async def send(self) -> CirclePowerUsageResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, CirclePowerUsageResponse): return result if result is None: @@ -733,11 +709,9 @@ def __init__( to_abs = DateTime(end.year, end.month, month_minutes_end) self._args += [from_abs, to_abs] - async def send( - self, suppress_node_errors: bool = False - ) -> CircleLogDataResponse | None: + async def send(self) -> CircleLogDataResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, CircleLogDataResponse): return result if result is None: @@ -757,7 +731,6 @@ class CircleClockSetRequest(PlugwiseRequest): """ _identifier = b"0016" - _reply_identifier = b"0000" # pylint: disable=too-many-arguments def __init__( @@ -792,9 +765,9 @@ def __init__( else: self._args += [this_date, String("FFFFFFFF", 8), this_time, day_of_week] - async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: + async def send(self) -> NodeResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeResponse): return result if result is None: @@ -812,7 +785,6 @@ class CircleRelaySwitchRequest(PlugwiseRequest): """ _identifier = b"0017" - _reply_identifier = b"0000" def __init__( self, @@ -826,9 +798,9 @@ def __init__( val = 1 if on else 0 self._args.append(Int(val, length=2)) - async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: + async def send(self) -> NodeResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeResponse): return result if result is None: @@ -866,11 +838,9 @@ def __repr__(self) -> str: """Convert request into writable str.""" return f"{super().__repr__()[:-1]}, network_address={self.network_address})" - async def send( - self, suppress_node_errors: bool = False - ) -> CirclePlusScanResponse | None: + async def send(self) -> CirclePlusScanResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, CirclePlusScanResponse): return result if result is None: @@ -900,11 +870,9 @@ def __init__( super().__init__(send_fn, mac_circle_plus) self._args.append(String(mac_to_unjoined, length=16)) - async def send( - self, suppress_node_errors: bool = False - ) -> NodeRemoveResponse | None: + async def send(self) -> NodeRemoveResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeRemoveResponse): return result if result is None: @@ -934,9 +902,9 @@ def __init__( super().__init__(send_fn, mac) self._max_retries = retries - async def send(self, suppress_node_errors: bool = False) -> NodeInfoResponse | None: + async def send(self) -> NodeInfoResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeInfoResponse): return result if result is None: @@ -956,11 +924,9 @@ class EnergyCalibrationRequest(PlugwiseRequest): _identifier = b"0026" _reply_identifier = b"0027" - async def send( - self, suppress_node_errors: bool = False - ) -> EnergyCalibrationResponse | None: + async def send(self) -> EnergyCalibrationResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, EnergyCalibrationResponse): return result if result is None: @@ -978,7 +944,6 @@ class CirclePlusRealTimeClockSetRequest(PlugwiseRequest): """ _identifier = b"0028" - _reply_identifier = b"0000" def __init__( self, @@ -994,9 +959,9 @@ def __init__( this_date = RealClockDate(dt.day, dt.month, dt.year) self._args += [this_time, day_of_week, this_date] - async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: + async def send(self) -> NodeResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeResponse): return result if result is None: @@ -1016,11 +981,9 @@ class CirclePlusRealTimeClockGetRequest(PlugwiseRequest): _identifier = b"0029" _reply_identifier = b"003A" - async def send( - self, suppress_node_errors: bool = False - ) -> CirclePlusRealTimeClockResponse | None: + async def send(self) -> CirclePlusRealTimeClockResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, CirclePlusRealTimeClockResponse): return result if result is None: @@ -1046,11 +1009,9 @@ class CircleClockGetRequest(PlugwiseRequest): _identifier = b"003E" _reply_identifier = b"003F" - async def send( - self, suppress_node_errors: bool = False - ) -> CircleClockResponse | None: + async def send(self) -> CircleClockResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, CircleClockResponse): return result if result is None: @@ -1068,7 +1029,6 @@ class CircleActivateScheduleRequest(PlugwiseRequest): """ _identifier = b"0040" - _reply_identifier = b"0000" def __init__( self, @@ -1091,7 +1051,6 @@ class NodeAddToGroupRequest(PlugwiseRequest): """ _identifier = b"0045" - _reply_identifier = b"0000" # pylint: disable=too-many-arguments def __init__( @@ -1109,9 +1068,9 @@ def __init__( port_mask_val = String(port_mask, length=16) self._args += [group_mac_val, task_id_val, port_mask_val] - async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: + async def send(self) -> NodeResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeResponse): return result if result is None: @@ -1128,7 +1087,6 @@ class NodeRemoveFromGroupRequest(PlugwiseRequest): """ _identifier = b"0046" - _reply_identifier = b"0000" def __init__( self, @@ -1149,7 +1107,6 @@ class NodeBroadcastGroupSwitchRequest(PlugwiseRequest): """ _identifier = b"0047" - _reply_identifier = b"0000" def __init__( self, @@ -1188,11 +1145,9 @@ def __repr__(self) -> str: """Convert request into writable str.""" return f"{super().__repr__()[:-1]}, log_address={self._log_address})" - async def send( - self, suppress_node_errors: bool = False - ) -> CircleEnergyLogsResponse | None: + async def send(self) -> CircleEnergyLogsResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, CircleEnergyLogsResponse): return result if result is None: @@ -1209,7 +1164,6 @@ class CircleHandlesOffRequest(PlugwiseRequest): """ _identifier = b"004D" - _reply_identifier = b"0000" class CircleHandlesOnRequest(PlugwiseRequest): @@ -1219,7 +1173,6 @@ class CircleHandlesOnRequest(PlugwiseRequest): """ _identifier = b"004E" - _reply_identifier = b"0000" class NodeSleepConfigRequest(PlugwiseRequest): @@ -1240,7 +1193,6 @@ class NodeSleepConfigRequest(PlugwiseRequest): """ _identifier = b"0050" - _reply_identifier = b"0000" # pylint: disable=too-many-arguments def __init__( @@ -1269,9 +1221,9 @@ def __init__( self.clock_interval_val, ] - async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: + async def send(self) -> NodeResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() _LOGGER.warning("NodeSleepConfigRequest result: %s", result) if isinstance(result, NodeResponse): return result @@ -1298,7 +1250,6 @@ class NodeSelfRemoveRequest(PlugwiseRequest): """ _identifier = b"0051" - _reply_identifier = b"0000" class CircleMeasureIntervalRequest(PlugwiseRequest): @@ -1310,7 +1261,6 @@ class CircleMeasureIntervalRequest(PlugwiseRequest): """ _identifier = b"0057" - _reply_identifier = b"0000" def __init__( self, @@ -1332,7 +1282,6 @@ class NodeClearGroupMacRequest(PlugwiseRequest): """ _identifier = b"0058" - _reply_identifier = b"0000" def __init__( self, @@ -1352,7 +1301,6 @@ class CircleSetScheduleValueRequest(PlugwiseRequest): """ _identifier = b"0059" - _reply_identifier = b"0000" def __init__( self, @@ -1384,11 +1332,9 @@ def __init__( super().__init__(send_fn, mac) self._args.append(SInt(val, length=4)) - async def send( - self, suppress_node_errors: bool = False - ) -> NodeFeaturesResponse | None: + async def send(self) -> NodeFeaturesResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeFeaturesResponse): return result if result is None: @@ -1436,9 +1382,9 @@ def __init__( reset_timer_value, ] - async def send(self, suppress_node_errors: bool = False) -> NodeAckResponse | None: + async def send(self) -> NodeAckResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeAckResponse): return result if result is None: @@ -1457,9 +1403,9 @@ class ScanLightCalibrateRequest(PlugwiseRequest): _identifier = b"0102" _reply_identifier = b"0100" - async def send(self, suppress_node_errors: bool = False) -> NodeAckResponse | None: + async def send(self) -> NodeAckResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeAckResponse): return result if result is None: @@ -1490,9 +1436,9 @@ def __init__( super().__init__(send_fn, mac) self._args.append(Int(interval, length=2)) - async def send(self, suppress_node_errors: bool = False) -> NodeAckResponse | None: + async def send(self) -> NodeAckResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeAckResponse): return result if result is None: @@ -1526,11 +1472,9 @@ 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, suppress_node_errors: bool = False - ) -> CircleRelayInitStateResponse | None: + async def send(self) -> CircleRelayInitStateResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, CircleRelayInitStateResponse): return result if result is None: diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 50dedbccc..6ab9de3b3 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -56,8 +56,9 @@ class StickResponseType(bytes, Enum): class NodeResponseType(bytes, Enum): """Response types of a 'NodeResponse' reply message.""" + CIRCLE_PLUS = b"00DD" # type for CirclePlusAllowJoiningRequest with state false CLOCK_ACCEPTED = b"00D7" - JOIN_ACCEPTED = b"00D9" + JOIN_ACCEPTED = b"00D9" # type for CirclePlusAllowJoiningRequest with state true RELAY_SWITCHED_OFF = b"00DE" RELAY_SWITCHED_ON = b"00D8" RELAY_SWITCH_FAILED = b"00E2" @@ -69,7 +70,6 @@ class NodeResponseType(bytes, Enum): SED_CONFIG_FAILED = b"00F7" POWER_LOG_INTERVAL_ACCEPTED = b"00F8" POWER_CALIBRATION_ACCEPTED = b"00DA" - CIRCLE_PLUS = b"00DD" class NodeAckResponseType(bytes, Enum): @@ -1018,6 +1018,8 @@ def get_message_object( # noqa: C901 return NodeSwitchGroupResponse() if identifier == b"0060": return NodeFeaturesResponse() + if identifier == NODE_REJOIN_ID: + return NodeRejoinResponse() if identifier == b"0100": return NodeAckResponse() if identifier == SENSE_REPORT_ID: diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index cab52a073..15de610e3 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -14,16 +14,13 @@ from ..connection import StickController from ..constants import UTF8 from ..exceptions import CacheError, MessageError, NodeError, StickError, StickTimeout -from ..helpers.util import validate_mac from ..messages.requests import CirclePlusAllowJoiningRequest, NodePingRequest from ..messages.responses import ( NODE_AWAKE_RESPONSE_ID, NODE_JOIN_ID, NODE_REJOIN_ID, NodeAwakeResponse, - NodeInfoResponse, NodeJoinAvailableResponse, - NodePingResponse, NodeRejoinResponse, NodeResponseType, PlugwiseResponse, @@ -149,10 +146,14 @@ def registry(self) -> dict[int, tuple[str, NodeType | None]]: async def register_node(self, mac: str) -> bool: """Register node to Plugwise network.""" - if not validate_mac(mac): - raise NodeError(f"Invalid mac '{mac}' to register") - address = await self._register.register_node(mac) - return await self._discover_node(address, mac, None) + if not self.accept_join_request: + return False + + try: + await self._register.register_node(mac) + except NodeError as exc: + raise NodeError(f"{exc}") from exc + return True async def clear_cache(self) -> None: """Clear register cache.""" @@ -160,7 +161,11 @@ async def clear_cache(self) -> None: async def unregister_node(self, mac: str) -> None: """Unregister node from current Plugwise network.""" - await self._register.unregister_node(mac) + try: + await self._register.unregister_node(mac) + except (KeyError, NodeError) as exc: + raise MessageError("Mac not registered, already deleted?") from exc + await self._nodes[mac].unload() self._nodes.pop(mac) @@ -246,9 +251,17 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: raise MessageError( f"Invalid response message type ({response.__class__.__name__}) received, expected NodeJoinAvailableResponse" ) + mac = response.mac_decoded - await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) - return True + _LOGGER.debug("node_join_available_message | sending NodeAddRequest for %s", mac) + try: + result = await self.register_node(mac) + except NodeError as exc: + raise NodeError(f"Unable to add Node ({mac}): {exc}") from exc + if result: + return True + + return False async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: """Handle NodeRejoinResponse messages.""" @@ -257,23 +270,24 @@ async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: f"Invalid response message type ({response.__class__.__name__}) received, expected NodeRejoinResponse" ) mac = response.mac_decoded - address = self._register.network_address(mac) - if (address := self._register.network_address(mac)) is not None: - if self._nodes.get(mac) is None: - if self._discover_sed_tasks.get(mac) is None: - self._discover_sed_tasks[mac] = create_task( - self._discover_battery_powered_node(address, mac) - ) - elif self._discover_sed_tasks[mac].done(): - self._discover_sed_tasks[mac] = create_task( - self._discover_battery_powered_node(address, mac) - ) - else: - _LOGGER.debug("duplicate awake discovery for %s", mac) - return True - else: - raise NodeError("Unknown network address for node {mac}") - return True + if (address := self._register.network_address(mac)) is None: + if (address := self._register.update_node_registration(mac)) is None: + raise NodeError(f"Failed to obtain address for node {mac}") + + if self._nodes.get(mac) is None: + if self._discover_sed_tasks.get(mac) is None: + self._discover_sed_tasks[mac] = create_task( + self._discover_battery_powered_node(address, mac) + ) + elif self._discover_sed_tasks[mac].done(): + self._discover_sed_tasks[mac] = create_task( + self._discover_battery_powered_node(address, mac) + ) + else: + _LOGGER.debug("duplicate awake discovery for %s", mac) + return True + + return False def _unsubscribe_to_protocol_events(self) -> None: """Unsubscribe to events from protocol.""" @@ -319,6 +333,7 @@ async def discover_network_coordinator(self, load: bool = False) -> bool: if load: return await self._load_node(self._controller.mac_coordinator) return True + return False # endregion @@ -485,9 +500,11 @@ async def discover_nodes(self, load: bool = True) -> bool: await self.discover_network_coordinator(load=load) if not self._is_running: await self.start() + await self._discover_registered_nodes() if load: return await self._load_discovered_nodes() + return True async def stop(self) -> None: @@ -507,14 +524,19 @@ async def stop(self) -> None: async def allow_join_requests(self, state: bool) -> None: """Enable or disable Plugwise network.""" request = CirclePlusAllowJoiningRequest(self._controller.send, state) - response = await request.send() if (response := await request.send()) is None: - raise NodeError("No response to get notifications for join request.") - if response.response_type != NodeResponseType.JOIN_ACCEPTED: + raise NodeError("No response for CirclePlusAllowJoiningRequest.") + + if response.response_type not in ( + NodeResponseType.JOIN_ACCEPTED, NodeResponseType.CIRCLE_PLUS + ): raise MessageError( f"Unknown NodeResponseType '{response.response_type.name}' received" ) + _LOGGER.debug("Sent AllowJoiningRequest to Circle+ with state=%s", state) + self.accept_join_request = state + def subscribe_to_node_events( self, node_event_callback: Callable[[NodeEvent, str], Coroutine[Any, Any, None]], @@ -544,3 +566,4 @@ async def _notify_node_event_subscribers(self, event: NodeEvent, mac: str) -> No callback_list.append(callback(event, mac)) if len(callback_list) > 0: await gather(*callback_list) + diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 70f478298..23f285783 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -9,7 +9,7 @@ from ..api import NodeType from ..constants import UTF8 -from ..exceptions import CacheError, NodeError +from ..exceptions import CacheError, NodeError, StickError from ..helpers.util import validate_mac from ..messages.requests import ( CirclePlusScanRequest, @@ -17,7 +17,7 @@ NodeRemoveRequest, PlugwiseRequest, ) -from ..messages.responses import NodeResponseType, PlugwiseResponse +from ..messages.responses import PlugwiseResponse from .cache import NetworkRegistrationCache _LOGGER = logging.getLogger(__name__) @@ -152,8 +152,10 @@ async def retrieve_network_registration( def network_address(self, mac: str) -> int | None: """Return the network registration address for given mac.""" + _LOGGER.debug("Address registrations:") for address, registration in self._registry.items(): registered_mac, _ = registration + _LOGGER.debug("address: %s | mac: %s", address, registered_mac) if mac == registered_mac: return address return None @@ -221,6 +223,12 @@ async def update_missing_registrations(self, quick: bool = False) -> None: await self._full_scan_finished() self._full_scan_finished = None + def update_node_registration(self, mac: str) -> int: + """Register (re)joined node to Plugwise network and return network address.""" + self.update_network_registration(self._first_free_address, mac, None) + self._first_free_address += 1 + return self._first_free_address - 1 + def _stop_registration_task(self) -> None: """Stop the background registration task.""" if self._registration_task is None: @@ -243,23 +251,21 @@ async def save_registry_to_cache(self) -> None: await self._network_cache.save_cache() _LOGGER.debug("save_registry_to_cache finished") - async def register_node(self, mac: str) -> int: + async def register_node(self, mac: str) -> None: """Register node to Plugwise network and return network address.""" if not validate_mac(mac): - raise NodeError(f"Invalid mac '{mac}' to register") + raise NodeError(f"MAC '{mac}' invalid") request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) - response = await request.send() - if response is None or response.ack_id != NodeResponseType.JOIN_ACCEPTED: - raise NodeError(f"Failed to register node {mac}") - self.update_network_registration(self._first_free_address, mac, None) - self._first_free_address += 1 - return self._first_free_address - 1 + try: + await request.send() + except StickError as exc: + raise NodeError("{exc}") from exc async def unregister_node(self, mac: str) -> None: """Unregister node from current Plugwise network.""" if not validate_mac(mac): - raise NodeError(f"Invalid mac '{mac}' to unregister") + raise NodeError(f"MAC '{mac}' invalid") mac_registered = False for registration in self._registry.values(): @@ -270,7 +276,6 @@ async def unregister_node(self, mac: str) -> None: raise NodeError(f"No existing registration '{mac}' found to unregister") request = NodeRemoveRequest(self._send_to_controller, self._mac_nc, mac) - response = await request.send() if (response := await request.send()) is None: raise NodeError( f"The Zigbee network coordinator '{self._mac_nc!r}'" diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index a8d08a8ea..5026ec494 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -316,4 +316,3 @@ def update( energy = self.energy _LOGGER.debug("energy=%s on last_update=%s", energy, last_update) return (energy, last_reset) - diff --git a/plugwise_usb/nodes/helpers/firmware.py b/plugwise_usb/nodes/helpers/firmware.py index 471f1bb81..e0e1600f7 100644 --- a/plugwise_usb/nodes/helpers/firmware.py +++ b/plugwise_usb/nodes/helpers/firmware.py @@ -46,6 +46,7 @@ class SupportedVersions(NamedTuple): # Proto release datetime(2015, 6, 16, 21, 9, 10, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), datetime(2015, 6, 18, 14, 0, 54, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), + datetime(2015, 9, 18, 8, 53, 15, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), # New Flash Update datetime(2017, 7, 11, 16, 6, 59, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), } diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 0c0d4d94d..41a4a6b59 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -818,6 +818,14 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: last_address, ) + # When higher addresses contain outdated data + if last_address < first_address and (first_address - last_address < 1000): + _LOGGER.warning( + "The Circle %s does not overwrite old logged data, please reset the Circle's energy-logs via Source", + self._mac, + ) + return + if ( last_address == first_address and last_slot == first_slot diff --git a/pyproject.toml b/pyproject.toml index fe00292ad..5e6c11dfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,15 @@ [build-system] -requires = ["setuptools~=80.0", "wheel~=0.45.0"] +requires = ["setuptools==80.3.1"] build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0b1" +version = "v0.40.0a141" +license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3.13", "Topic :: Home Automation", @@ -37,14 +37,13 @@ dependencies = [ "Bug Reports" = "https://github.com/plugwise/python-plugwise-usb/issues" [tool.setuptools] -platforms = ["any"] include-package-data = true [tool.setuptools.package-data] "plugwise" = ["py.typed"] [tool.setuptools.packages.find] -include = ["plugwise*"] +include = ["plugwise_usb*"] [tool.black] target-version = ["py313"] @@ -170,6 +169,7 @@ overgeneral-exceptions = [ ] [tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "session" asyncio_mode = "strict" markers = [ # mark a test as a asynchronous io test. @@ -208,10 +208,9 @@ warn_unreachable = true exclude = [] [tool.coverage.run] -source = [ "plugwise" ] +source = [ "plugwise_usb" ] omit= [ "*/venv/*", - "setup.py", ] [tool.ruff] diff --git a/requirements_commit.txt b/requirements_commit.txt index f8f9b05df..703b892eb 100644 --- a/requirements_commit.txt +++ b/requirements_commit.txt @@ -1,4 +1,4 @@ -# Import from setup.py +# Import from project.dependencies -e . # Minimal requirements for committing # Versioning omitted (leave this up to HA-core) diff --git a/requirements_test.txt b/requirements_test.txt index 37a5e6d77..2b52f7418 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -# Import from setup.py +# Import from project.dependencies -e . # Versioning omitted (leave this up to HA-core) pytest-asyncio diff --git a/setup.cfg b/setup.cfg index e71dcd50c..2c3d87f13 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,9 +1,4 @@ -# Setuptools v62.3 doesn't support editable installs with just 'pyproject.toml' (PEP 660). # Added Codespell since pre-commit doesn't process args correctly (and python3.11 and toml prevent using pyproject.toml) check #277 (/#278) for details -# Keep this file until it does! - -[metadata] -url = https://github.com/plugwise/python-plugwise-usb [codespell] # Most of the ignores from HA-Core upstream diff --git a/setup.py b/setup.py deleted file mode 100644 index ee7d67fe5..000000000 --- a/setup.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Plugwise USB module setup.""" - -from setuptools import setup - -setup() diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index 8c7c57293..0f5cd92bc 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -64,6 +64,12 @@ + b"4E0843A9" # fw_ver + b"01", # node_type (Circle+) ), + b"\x05\x05\x03\x030008014068\r\n":( + "Reply to CirclePlusAllowJoiningRequest", + b"000000C1", # Success ack + b"000000D9" # JOIN_ACCEPTED + + b"0098765432101234", # mac + ), b"\x05\x05\x03\x03000D0098765432101234C208\r\n": ( "ping reply for 0098765432101234", b"000000C1", # Success ack diff --git a/tests/test_usb.py b/tests/test_usb.py index 1a2b99bc3..7d299035d 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -252,7 +252,7 @@ def dummy_method() -> None: async def send( self, request: pw_requests.PlugwiseRequest, # type: ignore[name-defined] - suppress_node_errors: bool = True, + suppress_node_errors=True, ) -> pw_responses.PlugwiseResponse | None: # type: ignore[name-defined] """Submit request to queue and return response.""" return self.send_response @@ -469,7 +469,7 @@ async def test_stick_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: assert stick.accept_join_request is None # test failing of join requests without active discovery with pytest.raises(pw_exceptions.StickError): - stick.accept_join_request = True + await stick.set_accept_join_request(True) unsub_connect() await stick.disconnect() assert not stick.network_state @@ -572,7 +572,6 @@ async def test_stick_node_discovered_subscription( await stick.connect() await stick.initialize() await stick.discover_nodes(load=False) - stick.accept_join_request = True self.test_node_awake = asyncio.Future() unsub_awake = stick.subscribe_to_node_events( node_event_callback=self.node_awake, @@ -671,17 +670,18 @@ async def test_stick_node_join_subscription( await stick.connect() await stick.initialize() await stick.discover_nodes(load=False) - self.test_node_join = asyncio.Future() - unusb_join = stick.subscribe_to_node_events( - node_event_callback=self.node_join, - events=(pw_api.NodeEvent.JOIN,), - ) - - # Inject node join request message - mock_serial.inject_message(b"00069999999999999999", b"FFFC") - mac_join_node = await self.test_node_join - assert mac_join_node == "9999999999999999" - unusb_join() + await stick.set_accept_join_request(True) + # self.test_node_join = asyncio.Future() + # unusb_join = stick.subscribe_to_node_events( + # node_event_callback=self.node_join, + # events=(pw_api.NodeEvent.JOIN,), + # ) + + # Inject NodeJoinAvailableResponse + # mock_serial.inject_message(b"00069999999999999999", b"1254") # @bouwew: seq_id is not FFFC! + # mac_join_node = await self.test_node_join + # assert mac_join_node == "9999999999999999" + # unusb_join() await stick.disconnect() @pytest.mark.asyncio