From 163b86909cd363fe8a5b62fcea540b6b9d3960a2 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 27 Jan 2026 18:02:58 +0100 Subject: [PATCH 01/14] Solve monthly overflow as reported in #399 --- plugwise_usb/nodes/circle.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 1ff06fb92..935f6c2ae 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -3,6 +3,7 @@ from __future__ import annotations from asyncio import CancelledError, Task, create_task, gather, sleep +import calendar from collections.abc import Awaitable, Callable from dataclasses import replace from datetime import UTC, datetime, timedelta @@ -881,8 +882,13 @@ async def clock_synchronize(self) -> bool: dt_now = datetime.now(tz=UTC) days_diff = (response.day_of_week.value - dt_now.weekday()) % 7 + last_day_of_month = calendar.monthrange(dt_now.year, dt_now.month)[1] + days_to_end_of_month = last_day_of_month - dt_now.day + corrected_day = dt_now.day + days_diff + if (difference := days_diff - days_to_end_of_month) > 0: + corrected_day = difference circle_timestamp: datetime = dt_now.replace( - day=dt_now.day + days_diff, + day=corrected_day, hour=response.time.value.hour, minute=response.time.value.minute, second=response.time.value.second, From c7ffc60df532f664d10e75164abe9f24c4985c75 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 27 Jan 2026 19:26:17 +0100 Subject: [PATCH 02/14] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 827b77ca6..71f7d9766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Ongoing +- PR [400](https://github.com/plugwise/python-plugwise-usb/pull/400): Fix for Issue [#399](https://github.com/plugwise/python-plugwise-usb/issues/399) - Test/validate for Python 3.14 ## v0.47.1 - 2025-09-27 From 06d41ad427912fb36a8b11d19fddb710faeec673 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 27 Jan 2026 20:10:19 +0100 Subject: [PATCH 03/14] Implement simpler suggestion --- plugwise_usb/nodes/circle.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 935f6c2ae..0f671ded4 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -3,7 +3,6 @@ from __future__ import annotations from asyncio import CancelledError, Task, create_task, gather, sleep -import calendar from collections.abc import Awaitable, Callable from dataclasses import replace from datetime import UTC, datetime, timedelta @@ -882,13 +881,9 @@ async def clock_synchronize(self) -> bool: dt_now = datetime.now(tz=UTC) days_diff = (response.day_of_week.value - dt_now.weekday()) % 7 - last_day_of_month = calendar.monthrange(dt_now.year, dt_now.month)[1] - days_to_end_of_month = last_day_of_month - dt_now.day - corrected_day = dt_now.day + days_diff - if (difference := days_diff - days_to_end_of_month) > 0: - corrected_day = difference - circle_timestamp: datetime = dt_now.replace( - day=corrected_day, + target_date = dt_now + timedelta(days=days_diff) + circle_timestamp = target_date.replace( + day=target_date.day, hour=response.time.value.hour, minute=response.time.value.minute, second=response.time.value.second, From f1b840274bf49408e5fbdf326a16b8fad7de7985 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 27 Jan 2026 20:29:26 +0100 Subject: [PATCH 04/14] Modulo 7 is not required --- plugwise_usb/nodes/circle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 0f671ded4..46a720d46 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -880,7 +880,7 @@ async def clock_synchronize(self) -> bool: return False dt_now = datetime.now(tz=UTC) - days_diff = (response.day_of_week.value - dt_now.weekday()) % 7 + days_diff = response.day_of_week.value - dt_now.weekday() target_date = dt_now + timedelta(days=days_diff) circle_timestamp = target_date.replace( day=target_date.day, From dec3280f9f62b5953fede0ce566a1f53c066e880 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 27 Jan 2026 20:32:01 +0100 Subject: [PATCH 05/14] Simplify as suggested --- plugwise_usb/nodes/circle.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 46a720d46..833c3f8c3 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -883,7 +883,6 @@ async def clock_synchronize(self) -> bool: days_diff = response.day_of_week.value - dt_now.weekday() target_date = dt_now + timedelta(days=days_diff) circle_timestamp = target_date.replace( - day=target_date.day, hour=response.time.value.hour, minute=response.time.value.minute, second=response.time.value.second, From 2171abd6b6b3f4cf01878e47231e437dc99b1595 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 28 Jan 2026 07:59:54 +0100 Subject: [PATCH 06/14] Add clock-sync testcase to test monthly rollover --- tests/test_usb.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index 7de3159ab..93c4bbb36 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -3039,3 +3039,58 @@ def fake_cache_bool(dummy: object, setting: str) -> bool | None: with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): await stick.disconnect() await asyncio.sleep(1) + + @freeze_time("2026-01-31 10:30:00", real_asyncio=True) + @pytest.mark.asyncio + async def test_clock_synchronize_month_overflow( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test clock_synchronize handles month-end date rollover correctly. + + Regression test for issue `#399`: ensures that when the Circle's day_of_week + differs from the current weekday near month-end, the date calculation + doesn't attempt an invalid day value (e.g., Jan 32). + """ + mock_serial = MockSerial(None) + monkeypatch.setattr( + pw_connection_manager, + "create_serial_connection", + mock_serial.mock_connection, + ) + monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) + monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 2.0) + + stick = pw_stick.Stick("test_port", cache_enabled=False) + await stick.connect() + await stick.initialize() + await stick.discover_nodes(load=False) + await self._wait_for_scan(stick) + + # Get a Circle node + circle_node = stick.nodes.get("0098765432101234") + assert circle_node is not None + await circle_node.load() + + # Mock CircleClockGetRequest.send() to return a response where + # day_of_week is Saturday (5) while frozen time is Friday (4), Jan 31 + async def mock_clock_get_send(self): + response = pw_responses.CircleClockResponse() + response.timestamp = dt.now(tz=UTC) + # Set day_of_week to Saturday (5), requiring +1 day from Friday Jan 31 + # Old code: Jan 31 + 1 = day 32 (ValueError) + # New code: Jan 31 + timedelta(days=1) = Feb 1 (correct) + response.day_of_week.value = 5 # Saturday + response.time.value = dt.now(tz=UTC).time() + return response + + monkeypatch.setattr( + pw_requests.CircleClockGetRequest, + "send", + mock_clock_get_send, + ) + + # This should not raise ValueError about invalid day + result = await circle_node.clock_synchronize() + assert result is True + + await stick.disconnect() \ No newline at end of file From 75c87b9c7780c63b3d694dfd8167e6f89dad5419 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 28 Jan 2026 08:18:34 +0100 Subject: [PATCH 07/14] Extend stick_test_data --- tests/stick_test_data.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index b70ca5239..a1744428b 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -609,6 +609,11 @@ b"000000C1", # Success ack b"0000" + b"00D7" + b"0098765432101234", # msg_id, clock_ack, mac ), + b"\x05\x05\x03\x0300280098765432101234003010053101261F3D\r\n": ( + "Circle+ Realtime set clock at month-end for 0098765432101234", + b"000000C1", # Success ack + b"0000" + b"00D7" + b"0098765432101234", # msg_id, clock_ack, mac + ), b"\x05\x05\x03\x03003E11111111111111111B8A\r\n": ( "clock for 0011111111111111", b"000000C1", # Success ack From 4ae27e8f8ceff01df092bbf83e80df06d41303ce Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 28 Jan 2026 08:22:35 +0100 Subject: [PATCH 08/14] Fix end of file --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 93c4bbb36..2818d7867 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -3093,4 +3093,4 @@ async def mock_clock_get_send(self): result = await circle_node.clock_synchronize() assert result is True - await stick.disconnect() \ No newline at end of file + await stick.disconnect() From b1dc7331fab331987c90b2ba2c367dc604c6dfe7 Mon Sep 17 00:00:00 2001 From: autoruff Date: Wed, 28 Jan 2026 07:23:14 +0000 Subject: [PATCH 09/14] fixup: fix-399 Python code fixed using Ruff --- tests/stick_test_data.py | 2 +- tests/test_usb.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index a1744428b..722ea2463 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -613,7 +613,7 @@ "Circle+ Realtime set clock at month-end for 0098765432101234", b"000000C1", # Success ack b"0000" + b"00D7" + b"0098765432101234", # msg_id, clock_ack, mac - ), + ), b"\x05\x05\x03\x03003E11111111111111111B8A\r\n": ( "clock for 0011111111111111", b"000000C1", # Success ack diff --git a/tests/test_usb.py b/tests/test_usb.py index 2818d7867..dd4be8071 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -3046,7 +3046,7 @@ async def test_clock_synchronize_month_overflow( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Test clock_synchronize handles month-end date rollover correctly. - + Regression test for issue `#399`: ensures that when the Circle's day_of_week differs from the current weekday near month-end, the date calculation doesn't attempt an invalid day value (e.g., Jan 32). @@ -3065,12 +3065,12 @@ async def test_clock_synchronize_month_overflow( await stick.initialize() await stick.discover_nodes(load=False) await self._wait_for_scan(stick) - + # Get a Circle node circle_node = stick.nodes.get("0098765432101234") assert circle_node is not None await circle_node.load() - + # Mock CircleClockGetRequest.send() to return a response where # day_of_week is Saturday (5) while frozen time is Friday (4), Jan 31 async def mock_clock_get_send(self): @@ -3082,15 +3082,15 @@ async def mock_clock_get_send(self): response.day_of_week.value = 5 # Saturday response.time.value = dt.now(tz=UTC).time() return response - + monkeypatch.setattr( pw_requests.CircleClockGetRequest, "send", mock_clock_get_send, ) - + # This should not raise ValueError about invalid day result = await circle_node.clock_synchronize() assert result is True - + await stick.disconnect() From d0680291b9232efcdd4fdd0418a4cd267dfc4d38 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 28 Jan 2026 08:27:46 +0100 Subject: [PATCH 10/14] Change to non-async function --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index dd4be8071..a18fa4d0a 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -3073,7 +3073,7 @@ async def test_clock_synchronize_month_overflow( # Mock CircleClockGetRequest.send() to return a response where # day_of_week is Saturday (5) while frozen time is Friday (4), Jan 31 - async def mock_clock_get_send(self): + def mock_clock_get_send(self): response = pw_responses.CircleClockResponse() response.timestamp = dt.now(tz=UTC) # Set day_of_week to Saturday (5), requiring +1 day from Friday Jan 31 From 54de6e2413ad21c1e030a49dd7993e878bb7310b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 28 Jan 2026 08:34:28 +0100 Subject: [PATCH 11/14] Implement suggested correction --- tests/test_usb.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index a18fa4d0a..5f50cd8e6 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -3072,14 +3072,14 @@ async def test_clock_synchronize_month_overflow( await circle_node.load() # Mock CircleClockGetRequest.send() to return a response where - # day_of_week is Saturday (5) while frozen time is Friday (4), Jan 31 + # day_of_week is Sunday (6) while frozen time is Saturday (5), Jan 31 def mock_clock_get_send(self): response = pw_responses.CircleClockResponse() response.timestamp = dt.now(tz=UTC) - # Set day_of_week to Saturday (5), requiring +1 day from Friday Jan 31 + # Set day_of_week to Sunday (6), requiring +1 day from Saturday Jan 31 # Old code: Jan 31 + 1 = day 32 (ValueError) # New code: Jan 31 + timedelta(days=1) = Feb 1 (correct) - response.day_of_week.value = 5 # Saturday + response.day_of_week.value = 6 # Sunday response.time.value = dt.now(tz=UTC).time() return response From 5d3a91140e91262bc0d16d41c070c5d45b069be9 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 28 Jan 2026 19:14:18 +0100 Subject: [PATCH 12/14] Make sure to test circle clock-sync --- tests/stick_test_data.py | 6 +++--- tests/test_usb.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index 722ea2463..1b1a18f39 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -609,10 +609,10 @@ b"000000C1", # Success ack b"0000" + b"00D7" + b"0098765432101234", # msg_id, clock_ack, mac ), - b"\x05\x05\x03\x0300280098765432101234003010053101261F3D\r\n": ( - "Circle+ Realtime set clock at month-end for 0098765432101234", + b"\x05\x05\x03\x0300281111111111111111003010053101261F3D\r\n": ( + "Circle+ Realtime set clock at month-end for 1111111111111111", b"000000C1", # Success ack - b"0000" + b"00D7" + b"0098765432101234", # msg_id, clock_ack, mac + b"0000" + b"00D7" + b"1111111111111111", # msg_id, clock_ack, mac ), b"\x05\x05\x03\x03003E11111111111111111B8A\r\n": ( "clock for 0011111111111111", diff --git a/tests/test_usb.py b/tests/test_usb.py index 5f50cd8e6..377f89a60 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -3067,7 +3067,7 @@ async def test_clock_synchronize_month_overflow( await self._wait_for_scan(stick) # Get a Circle node - circle_node = stick.nodes.get("0098765432101234") + circle_node = stick.nodes.get("1111111111111111") assert circle_node is not None await circle_node.load() From 81a2ec3451b8d454de54cc6e8e43920b53e1910e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 28 Jan 2026 19:19:25 +0100 Subject: [PATCH 13/14] Try --- tests/test_usb.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 377f89a60..c8daa3482 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -3073,24 +3073,24 @@ async def test_clock_synchronize_month_overflow( # Mock CircleClockGetRequest.send() to return a response where # day_of_week is Sunday (6) while frozen time is Saturday (5), Jan 31 - def mock_clock_get_send(self): - response = pw_responses.CircleClockResponse() - response.timestamp = dt.now(tz=UTC) + # def mock_clock_get_send(self): + # response = pw_responses.CircleClockResponse() + # response.timestamp = dt.now(tz=UTC) # Set day_of_week to Sunday (6), requiring +1 day from Saturday Jan 31 # Old code: Jan 31 + 1 = day 32 (ValueError) # New code: Jan 31 + timedelta(days=1) = Feb 1 (correct) - response.day_of_week.value = 6 # Sunday - response.time.value = dt.now(tz=UTC).time() - return response + # response.day_of_week.value = 6 # Sunday + # response.time.value = dt.now(tz=UTC).time() + # return response - monkeypatch.setattr( - pw_requests.CircleClockGetRequest, - "send", - mock_clock_get_send, - ) + # monkeypatch.setattr( + # pw_requests.CircleClockGetRequest, + # "send", + # mock_clock_get_send, + # ) # This should not raise ValueError about invalid day - result = await circle_node.clock_synchronize() - assert result is True + # result = await circle_node.clock_synchronize() + # assert result is True await stick.disconnect() From c9293adb064bb39147f29d245eb2fda754bcf68a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 28 Jan 2026 19:59:24 +0100 Subject: [PATCH 14/14] Simplify testcase, check for occurrence of ValueError --- tests/test_usb.py | 34 +++++++++------------------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index c8daa3482..b8a6e94fc 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -3067,30 +3067,14 @@ async def test_clock_synchronize_month_overflow( await self._wait_for_scan(stick) # Get a Circle node - circle_node = stick.nodes.get("1111111111111111") - assert circle_node is not None - await circle_node.load() - - # Mock CircleClockGetRequest.send() to return a response where - # day_of_week is Sunday (6) while frozen time is Saturday (5), Jan 31 - # def mock_clock_get_send(self): - # response = pw_responses.CircleClockResponse() - # response.timestamp = dt.now(tz=UTC) - # Set day_of_week to Sunday (6), requiring +1 day from Saturday Jan 31 - # Old code: Jan 31 + 1 = day 32 (ValueError) - # New code: Jan 31 + timedelta(days=1) = Feb 1 (correct) - # response.day_of_week.value = 6 # Sunday - # response.time.value = dt.now(tz=UTC).time() - # return response - - # monkeypatch.setattr( - # pw_requests.CircleClockGetRequest, - # "send", - # mock_clock_get_send, - # ) - - # This should not raise ValueError about invalid day - # result = await circle_node.clock_synchronize() - # assert result is True + circle = stick.nodes.get("1111111111111111") + assert circle is not None + result = True + try: + await circle.load() + except ValueError: + result = False + + assert result await stick.disconnect()