From 8b4b55c75745b4ec1f74b88eabedf4589824d9e0 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Sun, 14 Sep 2025 14:46:56 +0200 Subject: [PATCH 01/14] schedule clock synchronization every 60 seconds --- plugwise_usb/nodes/circle.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index b06f6c8e9..e5e5956aa 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import Task, create_task, gather +from asyncio import Task, create_task, gather, sleep from collections.abc import Awaitable, Callable from dataclasses import replace from datetime import UTC, datetime, timedelta @@ -141,6 +141,8 @@ def __init__( """Initialize base class for Sleeping End Device.""" super().__init__(mac, node_type, controller, loaded_callback) + # Clock + self._clock_synchronize_task: Task[None] | None = None # Relay self._relay_lock: RelayLock = RelayLock() self._relay_state: RelayState = RelayState() @@ -852,6 +854,12 @@ async def _relay_update_lock( ) await self.save_cache() + async def _clock_synchronize_scheduler(self) -> bool: + """Synchronize clock scheduler.""" + while True: + await sleep(60) + await self.clock_synchronize() + async def clock_synchronize(self) -> bool: """Synchronize clock. Returns true if successful.""" get_clock_request = CircleClockGetRequest(self._send, self._mac_in_bytes) @@ -992,6 +1000,10 @@ async def initialize(self) -> bool: ) self._initialized = False return False + if self._clock_synchronize_task is None or self._clock_synchronize_task.done(): + self._clock_synchronize_task = create_task( + self._clock_synchronize_scheduler() + ) if not self._calibration and not await self.calibration_update(): _LOGGER.debug( @@ -1082,6 +1094,13 @@ async def unload(self) -> None: if self._cache_enabled: await self._energy_log_records_save_to_cache() + if ( + hasattr(self, "_clock_synchronize_task") + and self._clock_synchronize_task + and not self._clock_synchronize_task.done() + ): + self._clock_synchronize_task.cancel() + await super().unload() @raise_not_loaded From bfebf95b41d138d0ecac171dff4ebc1892ef23d6 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Sun, 14 Sep 2025 15:08:23 +0200 Subject: [PATCH 02/14] =?UTF-8?q?CR:=20Drain=20cancellation=20to=20avoid?= =?UTF-8?q?=20=E2=80=9CTask=20exception=20was=20never=20retrieved=E2=80=9D?= =?UTF-8?q?.=20CR:=20Make=20the=20scheduler=20resilient:=20fix=20return=20?= =?UTF-8?q?type,=20handle=20cancellation,=20and=20keep=20the=20loop=20aliv?= =?UTF-8?q?e=20on=20errors.=20CR:=20Import=20CancelledError=20for=20proper?= =?UTF-8?q?=20task=20shutdown=20handling.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugwise_usb/nodes/circle.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index e5e5956aa..b6d8c4f62 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import Task, create_task, gather, sleep +from asyncio import CancelledError, Task, create_task, gather, sleep from collections.abc import Awaitable, Callable from dataclasses import replace from datetime import UTC, datetime, timedelta @@ -854,11 +854,14 @@ async def _relay_update_lock( ) await self.save_cache() - async def _clock_synchronize_scheduler(self) -> bool: - """Synchronize clock scheduler.""" - while True: - await sleep(60) - await self.clock_synchronize() + async def _clock_synchronize_scheduler(self) -> None: + """Background task: periodically synchronize the clock until cancelled.""" + try: + while True: + await sleep(60) + await self.clock_synchronize() + except CancelledError: + _LOGGER.debug("Clock sync scheduler cancelled for %s", self.name) async def clock_synchronize(self) -> bool: """Synchronize clock. Returns true if successful.""" @@ -1094,12 +1097,10 @@ async def unload(self) -> None: if self._cache_enabled: await self._energy_log_records_save_to_cache() - if ( - hasattr(self, "_clock_synchronize_task") - and self._clock_synchronize_task - and not self._clock_synchronize_task.done() - ): + if self._clock_synchronize_task: self._clock_synchronize_task.cancel() + await gather(self._clock_synchronize_task, return_exceptions=True) + self._clock_synchronize_task = None await super().unload() From bfa1752062014a2afed70bd47fab0530eece71fc Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Sun, 14 Sep 2025 15:15:20 +0200 Subject: [PATCH 03/14] add raise after except --- plugwise_usb/nodes/circle.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index b6d8c4f62..4f8cd1a2b 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -862,6 +862,7 @@ async def _clock_synchronize_scheduler(self) -> None: await self.clock_synchronize() except CancelledError: _LOGGER.debug("Clock sync scheduler cancelled for %s", self.name) + raise async def clock_synchronize(self) -> bool: """Synchronize clock. Returns true if successful.""" From 8c258bf89f9413dfc8d1cc222a1797f2f6957556 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 15 Sep 2025 09:34:33 +0200 Subject: [PATCH 04/14] add additional try/except --- plugwise_usb/nodes/circle.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 4f8cd1a2b..788f8c096 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -859,7 +859,10 @@ async def _clock_synchronize_scheduler(self) -> None: try: while True: await sleep(60) - await self.clock_synchronize() + try: + await self.clock_synchronize() + except Exception: + _LOGGER.exception("Clock synchronisation failed for %s", self.name) except CancelledError: _LOGGER.debug("Clock sync scheduler cancelled for %s", self.name) raise From a4553eccf414ccaa55012dc602467d2e0db07c03 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 15 Sep 2025 09:47:11 +0200 Subject: [PATCH 05/14] create declaration for clock sync period and set to 1 hour --- plugwise_usb/nodes/circle.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 788f8c096..58e8dd892 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -74,7 +74,9 @@ # Default firmware if not known DEFAULT_FIRMWARE: Final = datetime(2008, 8, 26, 15, 46, tzinfo=UTC) -MAX_LOG_HOURS = DAY_IN_HOURS +MAX_LOG_HOURS: Final = DAY_IN_HOURS + +CLOCK_SYNC_PERIOD: Final = 3600 FuncT = TypeVar("FuncT", bound=Callable[..., Any]) _LOGGER = logging.getLogger(__name__) @@ -858,7 +860,7 @@ async def _clock_synchronize_scheduler(self) -> None: """Background task: periodically synchronize the clock until cancelled.""" try: while True: - await sleep(60) + await sleep(CLOCK_SYNC_PERIOD) try: await self.clock_synchronize() except Exception: From 296b0a01b4e7c0bc642e5ea5ea9bfde2896c71b0 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 15 Sep 2025 09:50:07 +0200 Subject: [PATCH 06/14] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e714cd0f1..82a146083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Ongoing +- PR [341](https://github.com/plugwise/python-plugwise-usb/pull/341): Schedule clock synchronization every 3600 seconds - PR [342](https://github.com/plugwise/python-plugwise-usb/pull/342): Improve node_type chaching. ## 0.46.0 - 2025-09-12 From eba5cb6bc219156ff07d4930ee8cb522ad7c4318 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 15 Sep 2025 18:54:46 +0200 Subject: [PATCH 07/14] fix clock offset detection. Original code did not properly check for negative offsets. --- plugwise_usb/nodes/circle.py | 8 +++----- plugwise_usb/nodes/circle_plus.py | 4 +--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 58e8dd892..00b2d0ce4 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -864,9 +864,9 @@ async def _clock_synchronize_scheduler(self) -> None: try: await self.clock_synchronize() except Exception: - _LOGGER.exception("Clock synchronisation failed for %s", self.name) + _LOGGER.exception("Clock synchronisation failed for %s", self.mac_in_str) except CancelledError: - _LOGGER.debug("Clock sync scheduler cancelled for %s", self.name) + _LOGGER.debug("Clock sync scheduler cancelled for %s", self.mac_in_str) raise async def clock_synchronize(self) -> bool: @@ -883,9 +883,7 @@ async def clock_synchronize(self) -> bool: tzinfo=UTC, ) clock_offset = clock_response.timestamp.replace(microsecond=0) - _dt_of_circle - if (clock_offset.seconds < MAX_TIME_DRIFT) or ( - clock_offset.seconds > -(MAX_TIME_DRIFT) - ): + if abs(clock_offset.total_seconds()) < MAX_TIME_DRIFT: return True _LOGGER.info( "Reset clock of node %s because time has drifted %s sec", diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 66a52fcdb..a5938cd97 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -86,9 +86,7 @@ async def clock_synchronize(self) -> bool: tzinfo=UTC, ) clock_offset = clock_response.timestamp.replace(microsecond=0) - _dt_of_circle - if (clock_offset.seconds < MAX_TIME_DRIFT) or ( - clock_offset.seconds > -(MAX_TIME_DRIFT) - ): + if abs(clock_offset.total_seconds()) < MAX_TIME_DRIFT: return True _LOGGER.info( "Reset realtime clock of node %s because time has drifted %s seconds while max drift is set to %s seconds)", From a5606431e3f3b624792f533eb0cc5e17ae1248a3 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 15 Sep 2025 19:08:00 +0200 Subject: [PATCH 08/14] attribute error --- plugwise_usb/nodes/circle.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 00b2d0ce4..8783d41ef 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -864,9 +864,9 @@ async def _clock_synchronize_scheduler(self) -> None: try: await self.clock_synchronize() except Exception: - _LOGGER.exception("Clock synchronisation failed for %s", self.mac_in_str) + _LOGGER.exception("Clock synchronisation failed for %s", self._mac_in_str) except CancelledError: - _LOGGER.debug("Clock sync scheduler cancelled for %s", self.mac_in_str) + _LOGGER.debug("Clock sync scheduler cancelled for %s", self._mac_in_str) raise async def clock_synchronize(self) -> bool: From 5fec0d30d5d79f29e19a095f838468419cf40eda Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 15 Sep 2025 19:09:39 +0200 Subject: [PATCH 09/14] improve logging of clock offset value --- plugwise_usb/nodes/circle.py | 2 +- plugwise_usb/nodes/circle_plus.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 8783d41ef..90822adc6 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -888,7 +888,7 @@ async def clock_synchronize(self) -> bool: _LOGGER.info( "Reset clock of node %s because time has drifted %s sec", self._mac_in_str, - str(clock_offset.seconds), + str(int(abs(clock_offset.total_seconds()))), ) if self._node_protocols is None: raise NodeError( diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index a5938cd97..672afb61d 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -91,7 +91,7 @@ async def clock_synchronize(self) -> bool: _LOGGER.info( "Reset realtime clock of node %s because time has drifted %s seconds while max drift is set to %s seconds)", self._node_info.mac, - str(clock_offset.seconds), + str(int(abs(clock_offset.total_seconds()))), str(MAX_TIME_DRIFT), ) clock_set_request = CirclePlusRealTimeClockSetRequest( From 23c1a44698dbebff71085d5fc4744c37437a7edd Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 15 Sep 2025 19:22:09 +0200 Subject: [PATCH 10/14] CR: Add small jitter to prevent synchronization bursts --- plugwise_usb/nodes/circle.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 90822adc6..adb5761b3 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -860,11 +860,11 @@ async def _clock_synchronize_scheduler(self) -> None: """Background task: periodically synchronize the clock until cancelled.""" try: while True: - await sleep(CLOCK_SYNC_PERIOD) + await sleep(CLOCK_SYNC_PERIOD + (random.uniform(-5, 5))) try: await self.clock_synchronize() except Exception: - _LOGGER.exception("Clock synchronisation failed for %s", self._mac_in_str) + _LOGGER.exception("Clock synchronization failed for %s", self._mac_in_str) except CancelledError: _LOGGER.debug("Clock sync scheduler cancelled for %s", self._mac_in_str) raise From af2d642aca80d1a9ae59d266cfcb79f551270f8d Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Mon, 15 Sep 2025 19:25:12 +0200 Subject: [PATCH 11/14] unifi log message between C+ and Circle --- plugwise_usb/nodes/circle.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index adb5761b3..2978434c2 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -886,9 +886,10 @@ async def clock_synchronize(self) -> bool: if abs(clock_offset.total_seconds()) < MAX_TIME_DRIFT: return True _LOGGER.info( - "Reset clock of node %s because time has drifted %s sec", + "Reset clock of node %s because time drifted %s seconds (max %s seconds)", self._mac_in_str, str(int(abs(clock_offset.total_seconds()))), + str(MAX_TIME_DRIFT), ) if self._node_protocols is None: raise NodeError( From 22cbfeee4336b89e0961d127438773ebd0d40df7 Mon Sep 17 00:00:00 2001 From: autoruff Date: Mon, 15 Sep 2025 17:27:02 +0000 Subject: [PATCH 12/14] fixup: mdi_clock Python code reformatted using Ruff --- plugwise_usb/network/cache.py | 8 ++++++-- plugwise_usb/nodes/circle.py | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/network/cache.py b/plugwise_usb/network/cache.py index 3f6d4a3f8..06e672ccb 100644 --- a/plugwise_usb/network/cache.py +++ b/plugwise_usb/network/cache.py @@ -30,7 +30,9 @@ async def save_cache(self) -> None: mac: node_type.name for mac, node_type in self._nodetypes.items() } _LOGGER.debug("Save NodeTypes for %s Nodes", len(cache_data_to_save)) - await self.write_cache(cache_data_to_save, rewrite=True) # Make sure the cache-contents is actual + await self.write_cache( + cache_data_to_save, rewrite=True + ) # Make sure the cache-contents is actual async def clear_cache(self) -> None: """Clear current cache.""" @@ -54,7 +56,9 @@ async def restore_cache(self) -> None: node_type = None if node_type is None: - _LOGGER.warning("Invalid NodeType in cache for mac %s: %s", mac, node_value) + _LOGGER.warning( + "Invalid NodeType in cache for mac %s: %s", mac, node_value + ) continue self._nodetypes[mac] = node_type _LOGGER.debug( diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 2978434c2..fe1a6c2a0 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -864,7 +864,9 @@ async def _clock_synchronize_scheduler(self) -> None: try: await self.clock_synchronize() except Exception: - _LOGGER.exception("Clock synchronization failed for %s", self._mac_in_str) + _LOGGER.exception( + "Clock synchronization failed for %s", self._mac_in_str + ) except CancelledError: _LOGGER.debug("Clock sync scheduler cancelled for %s", self._mac_in_str) raise From 90788216af46d4e441e550aa3b7c61c7d7b0057f Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Tue, 16 Sep 2025 17:44:15 +0200 Subject: [PATCH 13/14] import random --- plugwise_usb/nodes/circle.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index fe1a6c2a0..e50fc9e5f 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -8,6 +8,7 @@ from datetime import UTC, datetime, timedelta from functools import wraps import logging +import random from math import ceil from typing import Any, Final, TypeVar, cast @@ -860,7 +861,7 @@ async def _clock_synchronize_scheduler(self) -> None: """Background task: periodically synchronize the clock until cancelled.""" try: while True: - await sleep(CLOCK_SYNC_PERIOD + (random.uniform(-5, 5))) + await sleep(CLOCK_SYNC_PERIOD + random.uniform(-5, 5)) try: await self.clock_synchronize() except Exception: @@ -895,7 +896,7 @@ async def clock_synchronize(self) -> bool: ) if self._node_protocols is None: raise NodeError( - "Unable to synchronize clock en when protocol version is unknown" + "Unable to synchronize clock when protocol version is unknown" ) set_clock_request = CircleClockSetRequest( self._send, From c340353b94a6d6c0052bae583c2586c2dcefc166 Mon Sep 17 00:00:00 2001 From: Marc Dirix Date: Wed, 17 Sep 2025 08:36:47 +0200 Subject: [PATCH 14/14] convert stick_test_data to class --- tests/stick_test_data.py | 130 ++++++++++++++++++++++++++++++ tests/test_usb.py | 168 +++++---------------------------------- 2 files changed, 152 insertions(+), 146 deletions(-) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index 897f56f89..5cb64bc22 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -1,7 +1,19 @@ """Stick Test Program.""" +import asyncio +from collections.abc import Callable from datetime import UTC, datetime, timedelta import importlib +import logging +import random + +import crcmod + +crc_fun = crcmod.mkCrcFun(0x11021, rev=False, initCrc=0x0000, xorOut=0x0000) + + +_LOGGER = logging.getLogger(__name__) +_LOGGER.setLevel(logging.DEBUG) pw_constants = importlib.import_module("plugwise_usb.constants") @@ -12,6 +24,124 @@ # generate energy log timestamps with fixed hour timestamp used in tests hour_timestamp = utc_now.replace(minute=0, second=0, microsecond=0) + +def construct_message(data: bytes, seq_id: bytes = b"0000") -> bytes: + """Construct plugwise message.""" + body = data[:4] + seq_id + data[4:] + return bytes( + pw_constants.MESSAGE_HEADER + + body + + bytes(f"{crc_fun(body):04X}", pw_constants.UTF8) + + pw_constants.MESSAGE_FOOTER + ) + + +class DummyTransport: + """Dummy transport class.""" + + protocol_data_received: Callable[[bytes], None] + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + test_data: dict[bytes, tuple[str, bytes, bytes | None]] | None = None, + ) -> None: + """Initialize dummy transport class.""" + self._loop = loop + self._msg = 0 + self._seq_id = b"1233" + self._processed: list[bytes] = [] + self._first_response = test_data + self._second_response = test_data + if test_data is None: + self._first_response = RESPONSE_MESSAGES + self._second_response = SECOND_RESPONSE_MESSAGES + self.random_extra_byte = 0 + self._closing = False + + def inc_seq_id(self, seq_id: bytes | None) -> bytes: + """Increment sequence id.""" + if seq_id is None: + return b"0000" + temp_int = int(seq_id, 16) + 1 + if temp_int >= 65532: + temp_int = 0 + temp_str = str(hex(temp_int)).lstrip("0x").upper() + while len(temp_str) < 4: + temp_str = "0" + temp_str + return temp_str.encode() + + def is_closing(self) -> bool: + """Close connection.""" + return self._closing + + def write(self, data: bytes) -> None: + """Write data back to system.""" + log = None + ack = None + response = None + if data in self._processed and self._second_response is not None: + log, ack, response = self._second_response.get(data, (None, None, None)) + if log is None and self._first_response is not None: + log, ack, response = self._first_response.get(data, (None, None, None)) + if log is None: + resp = PARTLY_RESPONSE_MESSAGES.get(data[:24], (None, None, None)) + if resp is None: + _LOGGER.debug("No msg response for %s", str(data)) + return + log, ack, response = resp + if ack is None: + _LOGGER.debug("No ack response for %s", str(data)) + return + + self._seq_id = self.inc_seq_id(self._seq_id) + if response and self._msg == 0: + self.message_response_at_once(ack, response, self._seq_id) + self._processed.append(data) + else: + self.message_response(ack, self._seq_id) + self._processed.append(data) + if response is None or self._closing: + return + self._loop.create_task(self._delayed_response(response, self._seq_id)) + self._msg += 1 + + async def _delayed_response(self, data: bytes, seq_id: bytes) -> None: + delay = random.uniform(0.005, 0.025) + await asyncio.sleep(delay) + self.message_response(data, seq_id) + + def message_response(self, data: bytes, seq_id: bytes) -> None: + """Handle message response.""" + self.random_extra_byte += 1 + if self.random_extra_byte > 25: + self.protocol_data_received(b"\x83") + self.random_extra_byte = 0 + self.protocol_data_received(construct_message(data, seq_id) + b"\x83") + else: + self.protocol_data_received(construct_message(data, seq_id)) + + def message_response_at_once(self, ack: bytes, data: bytes, seq_id: bytes) -> None: + """Full message.""" + self.random_extra_byte += 1 + if self.random_extra_byte > 25: + self.protocol_data_received(b"\x83") + self.random_extra_byte = 0 + self.protocol_data_received( + construct_message(ack, seq_id) + + construct_message(data, seq_id) + + b"\x83" + ) + else: + self.protocol_data_received( + construct_message(ack, seq_id) + construct_message(data, seq_id) + ) + + def close(self) -> None: + """Close connection.""" + self._closing = True + + LOG_TIMESTAMPS = {} _one_hour = timedelta(hours=1) for x in range(168): diff --git a/tests/test_usb.py b/tests/test_usb.py index af5cbdffe..a24c37f19 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -5,18 +5,14 @@ from datetime import UTC, datetime as dt, timedelta as td import importlib import logging -import random from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest import aiofiles # type: ignore[import-untyped] -import crcmod from freezegun import freeze_time -crc_fun = crcmod.mkCrcFun(0x11021, rev=False, initCrc=0x0000, xorOut=0x0000) - pw_stick = importlib.import_module("plugwise_usb") pw_api = importlib.import_module("plugwise_usb.api") pw_exceptions = importlib.import_module("plugwise_usb.exceptions") @@ -46,126 +42,6 @@ _LOGGER.setLevel(logging.DEBUG) -def inc_seq_id(seq_id: bytes | None) -> bytes: - """Increment sequence id.""" - if seq_id is None: - return b"0000" - temp_int = int(seq_id, 16) + 1 - if temp_int >= 65532: - temp_int = 0 - temp_str = str(hex(temp_int)).lstrip("0x").upper() - while len(temp_str) < 4: - temp_str = "0" + temp_str - return temp_str.encode() - - -def construct_message(data: bytes, seq_id: bytes = b"0000") -> bytes: - """Construct plugwise message.""" - body = data[:4] + seq_id + data[4:] - return bytes( - pw_constants.MESSAGE_HEADER - + body - + bytes(f"{crc_fun(body):04X}", pw_constants.UTF8) - + pw_constants.MESSAGE_FOOTER - ) - - -class DummyTransport: - """Dummy transport class.""" - - protocol_data_received: Callable[[bytes], None] - - def __init__( - self, - loop: asyncio.AbstractEventLoop, - test_data: dict[bytes, tuple[str, bytes, bytes | None]] | None = None, - ) -> None: - """Initialize dummy transport class.""" - self._loop = loop - self._msg = 0 - self._seq_id = b"1233" - self._processed: list[bytes] = [] - self._first_response = test_data - self._second_response = test_data - if test_data is None: - self._first_response = pw_userdata.RESPONSE_MESSAGES - self._second_response = pw_userdata.SECOND_RESPONSE_MESSAGES - self.random_extra_byte = 0 - self._closing = False - - def is_closing(self) -> bool: - """Close connection.""" - return self._closing - - def write(self, data: bytes) -> None: - """Write data back to system.""" - log = None - ack = None - response = None - if data in self._processed and self._second_response is not None: - log, ack, response = self._second_response.get(data, (None, None, None)) - if log is None and self._first_response is not None: - log, ack, response = self._first_response.get(data, (None, None, None)) - if log is None: - resp = pw_userdata.PARTLY_RESPONSE_MESSAGES.get( - data[:24], (None, None, None) - ) - if resp is None: - _LOGGER.debug("No msg response for %s", str(data)) - return - log, ack, response = resp - if ack is None: - _LOGGER.debug("No ack response for %s", str(data)) - return - - self._seq_id = inc_seq_id(self._seq_id) - if response and self._msg == 0: - self.message_response_at_once(ack, response, self._seq_id) - self._processed.append(data) - else: - self.message_response(ack, self._seq_id) - self._processed.append(data) - if response is None or self._closing: - return - self._loop.create_task(self._delayed_response(response, self._seq_id)) - self._msg += 1 - - async def _delayed_response(self, data: bytes, seq_id: bytes) -> None: - delay = random.uniform(0.005, 0.025) - await asyncio.sleep(delay) - self.message_response(data, seq_id) - - def message_response(self, data: bytes, seq_id: bytes) -> None: - """Handle message response.""" - self.random_extra_byte += 1 - if self.random_extra_byte > 25: - self.protocol_data_received(b"\x83") - self.random_extra_byte = 0 - self.protocol_data_received(construct_message(data, seq_id) + b"\x83") - else: - self.protocol_data_received(construct_message(data, seq_id)) - - def message_response_at_once(self, ack: bytes, data: bytes, seq_id: bytes) -> None: - """Full message.""" - self.random_extra_byte += 1 - if self.random_extra_byte > 25: - self.protocol_data_received(b"\x83") - self.random_extra_byte = 0 - self.protocol_data_received( - construct_message(ack, seq_id) - + construct_message(data, seq_id) - + b"\x83" - ) - else: - self.protocol_data_received( - construct_message(ack, seq_id) + construct_message(data, seq_id) - ) - - def close(self) -> None: - """Close connection.""" - self._closing = True - - class MockSerial: """Mock serial connection.""" @@ -175,7 +51,7 @@ def __init__( """Init mocked serial connection.""" self.custom_response = custom_response self._protocol: pw_receiver.StickReceiver | None = None # type: ignore[name-defined] - self._transport: DummyTransport | None = None + self._transport: pw_userdata.DummyTransport | None = None def inject_message(self, data: bytes, seq_id: bytes) -> None: """Inject message to be received from stick.""" @@ -194,10 +70,10 @@ async def mock_connection( loop: asyncio.AbstractEventLoop, protocol_factory: Callable[[], pw_receiver.StickReceiver], # type: ignore[name-defined] **kwargs: dict[str, Any], - ) -> tuple[DummyTransport, pw_receiver.StickReceiver]: # type: ignore[name-defined] + ) -> tuple[pw_userdata.DummyTransport, pw_receiver.StickReceiver]: # type: ignore[name-defined] """Mock connection with dummy connection.""" self._protocol = protocol_factory() - self._transport = DummyTransport(loop, self.custom_response) + self._transport = pw_userdata.DummyTransport(loop, self.custom_response) self._transport.protocol_data_received = self._protocol.data_received loop.call_soon_threadsafe(self._protocol.connection_made, self._transport) return self._transport, self._protocol @@ -1975,11 +1851,11 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign sed_config_accepted = pw_responses.NodeResponse() sed_config_accepted.deserialize( - construct_message(b"000000F65555555555555555", b"0000") + pw_userdata.construct_message(b"000000F65555555555555555", b"0000") ) sed_config_failed = pw_responses.NodeResponse() sed_config_failed.deserialize( - construct_message(b"000000F75555555555555555", b"0000") + pw_userdata.construct_message(b"000000F75555555555555555", b"0000") ) # test awake duration @@ -2000,7 +1876,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign # Restore to original settings after failed config awake_response1 = pw_responses.NodeAwakeResponse() awake_response1.deserialize( - construct_message(b"004F555555555555555500", b"FFFE") + pw_userdata.construct_message(b"004F555555555555555500", b"FFFE") ) mock_stick_controller.append_response(sed_config_failed) await test_sed._awake_response(awake_response1) # pylint: disable=protected-access @@ -2013,7 +1889,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign # Successful config awake_response2 = pw_responses.NodeAwakeResponse() awake_response2.deserialize( - construct_message(b"004F555555555555555500", b"FFFE") + pw_userdata.construct_message(b"004F555555555555555500", b"FFFE") ) awake_response2.timestamp = awake_response1.timestamp + td( seconds=pw_sed.AWAKE_RETRY @@ -2041,7 +1917,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign assert test_sed.battery_config.dirty awake_response3 = pw_responses.NodeAwakeResponse() awake_response3.deserialize( - construct_message(b"004F555555555555555500", b"FFFE") + pw_userdata.construct_message(b"004F555555555555555500", b"FFFE") ) awake_response3.timestamp = awake_response2.timestamp + td( seconds=pw_sed.AWAKE_RETRY @@ -2067,7 +1943,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign assert test_sed.battery_config.dirty awake_response4 = pw_responses.NodeAwakeResponse() awake_response4.deserialize( - construct_message(b"004F555555555555555500", b"FFFE") + pw_userdata.construct_message(b"004F555555555555555500", b"FFFE") ) awake_response4.timestamp = awake_response3.timestamp + td( seconds=pw_sed.AWAKE_RETRY @@ -2088,7 +1964,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign assert test_sed.battery_config.dirty awake_response5 = pw_responses.NodeAwakeResponse() awake_response5.deserialize( - construct_message(b"004F555555555555555500", b"FFFE") + pw_userdata.construct_message(b"004F555555555555555500", b"FFFE") ) awake_response5.timestamp = awake_response4.timestamp + td( seconds=pw_sed.AWAKE_RETRY @@ -2113,7 +1989,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign assert test_sed.battery_config.dirty awake_response6 = pw_responses.NodeAwakeResponse() awake_response6.deserialize( - construct_message(b"004F555555555555555500", b"FFFE") + pw_userdata.construct_message(b"004F555555555555555500", b"FFFE") ) awake_response6.timestamp = awake_response5.timestamp + td( seconds=pw_sed.AWAKE_RETRY @@ -2177,11 +2053,11 @@ def fake_cache_bool(dummy: object, setting: str) -> bool | None: mock_stick_controller = MockStickController() scan_config_accepted = pw_responses.NodeAckResponse() scan_config_accepted.deserialize( - construct_message(b"0100555555555555555500BE", b"0000") + pw_userdata.construct_message(b"0100555555555555555500BE", b"0000") ) scan_config_failed = pw_responses.NodeAckResponse() scan_config_failed.deserialize( - construct_message(b"0100555555555555555500BF", b"0000") + pw_userdata.construct_message(b"0100555555555555555500BF", b"0000") ) async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] @@ -2222,7 +2098,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign # Restore to original settings after failed config awake_response1 = pw_responses.NodeAwakeResponse() awake_response1.deserialize( - construct_message(b"004F555555555555555500", b"FFFE") + pw_userdata.construct_message(b"004F555555555555555500", b"FFFE") ) mock_stick_controller.append_response(scan_config_failed) await test_scan._awake_response(awake_response1) # pylint: disable=protected-access @@ -2233,7 +2109,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign # Successful config awake_response2 = pw_responses.NodeAwakeResponse() awake_response2.deserialize( - construct_message(b"004F555555555555555500", b"FFFE") + pw_userdata.construct_message(b"004F555555555555555500", b"FFFE") ) awake_response2.timestamp = awake_response1.timestamp + td( seconds=pw_sed.AWAKE_RETRY @@ -2257,7 +2133,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign assert test_scan.motion_config.dirty awake_response3 = pw_responses.NodeAwakeResponse() awake_response3.deserialize( - construct_message(b"004F555555555555555500", b"FFFE") + pw_userdata.construct_message(b"004F555555555555555500", b"FFFE") ) awake_response3.timestamp = awake_response2.timestamp + td( seconds=pw_sed.AWAKE_RETRY @@ -2286,7 +2162,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign assert test_scan.motion_config.dirty awake_response4 = pw_responses.NodeAwakeResponse() awake_response4.deserialize( - construct_message(b"004F555555555555555500", b"FFFE") + pw_userdata.construct_message(b"004F555555555555555500", b"FFFE") ) awake_response4.timestamp = awake_response3.timestamp + td( seconds=pw_sed.AWAKE_RETRY @@ -2399,25 +2275,25 @@ def fake_cache_bool(dummy: object, setting: str) -> bool | None: mock_stick_controller = MockStickController() sense_config_accepted = pw_responses.NodeAckResponse() sense_config_accepted.deserialize( - construct_message(b"0100555555555555555500B5", b"0000") + pw_userdata.construct_message(b"0100555555555555555500B5", b"0000") ) sense_config_failed = pw_responses.NodeAckResponse() sense_config_failed.deserialize( - construct_message(b"0100555555555555555500B6", b"0000") + pw_userdata.construct_message(b"0100555555555555555500B6", b"0000") ) sense_config_report_interval_accepted = pw_responses.NodeAckResponse() sense_config_report_interval_accepted.deserialize( - construct_message(b"0100555555555555555500B3", b"0000") + pw_userdata.construct_message(b"0100555555555555555500B3", b"0000") ) sense_config_report_interval_failed = pw_responses.NodeAckResponse() sense_config_report_interval_failed.deserialize( - construct_message(b"0100555555555555555500B4", b"0000") + pw_userdata.construct_message(b"0100555555555555555500B4", b"0000") ) awake_response = pw_responses.NodeAwakeResponse() awake_response.deserialize( - construct_message(b"004F555555555555555500", b"FFFE") + pw_userdata.construct_message(b"004F555555555555555500", b"FFFE") ) async def run_awake_with_response(