Skip to content
Merged
Show file tree
Hide file tree
Changes from 88 commits
Commits
Show all changes
92 commits
Select commit Hold shift + click to select a range
6153f94
schedule clock synchronization every 60 seconds
dirixmjm Sep 14, 2025
44651f2
CR: Drain cancellation to avoid “Task exception was never retrieved”.
dirixmjm Sep 14, 2025
c0eeed6
add raise after except
dirixmjm Sep 14, 2025
35d294a
add additional try/except
dirixmjm Sep 15, 2025
67e3ba9
create declaration for clock sync period and set to 1 hour
dirixmjm Sep 15, 2025
6c22b95
update changelog
dirixmjm Sep 15, 2025
131a90f
fix clock offset detection. Original code did not properly check for
dirixmjm Sep 15, 2025
9433690
attribute error
dirixmjm Sep 15, 2025
42801ee
improve logging of clock offset value
dirixmjm Sep 15, 2025
77093fe
CR: Add small jitter to prevent synchronization bursts
dirixmjm Sep 15, 2025
6bdc720
unifi log message between C+ and Circle
dirixmjm Sep 15, 2025
65d166a
fixup: mdi_clock Python code reformatted using Ruff
Sep 15, 2025
ffee1f1
Add CirclePlusRealTimeClockSetRequest with ACK
bouwew Sep 16, 2025
220e94d
Try with 00D7
bouwew Sep 16, 2025
64afae6
Add missing comma
bouwew Sep 16, 2025
af29876
Freeze-time for test_node_relay_and_power
bouwew Sep 16, 2025
9f0b17e
Update 0028-data from test-output
bouwew Sep 16, 2025
e8ececc
Use freeze_time to fix the test-datetime
bouwew Sep 16, 2025
5a08281
Try
bouwew Sep 16, 2025
2071570
Fix datetime returned by CirclePlus
bouwew Sep 16, 2025
9d8d9e7
Try 2
bouwew Sep 16, 2025
ca738ed
Try 3
bouwew Sep 16, 2025
f1ccb0a
Debug datetimes
bouwew Sep 16, 2025
0e032f8
Move freeze_time to test_energy_circle()
bouwew Sep 16, 2025
1bc9809
Add missing import
bouwew Sep 16, 2025
a208f0f
Try 4
bouwew Sep 16, 2025
5242ce5
Change time bij 30 secs
bouwew Sep 16, 2025
9ee3c0e
Fix 0028 datetime
bouwew Sep 16, 2025
59a5afc
Get proper datetime to compare the received datetime with
bouwew Sep 17, 2025
b459275
Ruffed
bouwew Sep 17, 2025
c750b97
Try
bouwew Sep 17, 2025
d9dede4
Freeze time a the full hour
bouwew Sep 17, 2025
385a76a
Revert back
bouwew Sep 17, 2025
add25dc
Update 0028-response to match change
bouwew Sep 17, 2025
332f27d
Shorten, improve var names
bouwew Sep 17, 2025
b31e15b
Try adding a 2nd optional response
bouwew Sep 18, 2025
bf44542
Try 2
bouwew Sep 18, 2025
a970073
Try 3
bouwew Sep 20, 2025
2ac6626
CirclePlus: handle time-diff larger than 24hrs
bouwew Sep 21, 2025
37f4c5c
Force int via value property
bouwew Sep 21, 2025
ea89f56
Test larger offset
bouwew Sep 21, 2025
68090b2
Fix adding/deleting day(s)
bouwew Sep 21, 2025
b5b31de
Full naming
bouwew Sep 21, 2025
58af3f4
Line up circle-clock-sync code
bouwew Sep 21, 2025
f8833b8
Debug weekday to large difference
bouwew Sep 21, 2025
66ea7c1
Properly calc resulting day value
bouwew Sep 21, 2025
466d008
Update circle too
bouwew Sep 21, 2025
1100f32
Clean up extra debug-logging
bouwew Sep 21, 2025
00d8ab2
Line up circle code in detail
bouwew Sep 21, 2025
698b531
Set debug-level to warning, line up messages
bouwew Sep 21, 2025
9034259
Revert node id in warning logs
bouwew Sep 21, 2025
b8e3ecc
Revert test-updates
bouwew Sep 21, 2025
e11ecd6
More logging clean up
bouwew Sep 21, 2025
439878b
fixup: test_bouwew Python code reformatted using Ruff
Sep 21, 2025
af313a7
More clean up
bouwew Sep 21, 2025
1e91943
Revert "Clean up extra debug-logging"
bouwew Sep 21, 2025
bbb598d
Full test-output
bouwew Sep 21, 2025
af54eb3
Add pytest settings
bouwew Sep 21, 2025
e40792a
Try
bouwew Sep 21, 2025
c615c92
fixup: test_bouwew Python code reformatted using Ruff
Sep 22, 2025
e298450
Back to using freeze_time for both related testcases
bouwew Sep 22, 2025
df1e9e9
Fix time for CircleEnergyLogsResponses
bouwew Sep 22, 2025
49bdb23
Update 0028 response
bouwew Sep 22, 2025
63bc7f9
Correct var name
bouwew Sep 22, 2025
85b0ce6
Revert back to utc_now
bouwew Sep 22, 2025
71e6f57
Fix year error
bouwew Sep 22, 2025
bb658d1
Also freeze_time test_node_discovery_and_load
bouwew Sep 22, 2025
61374d0
Introduce clock offset of 10s
bouwew Sep 22, 2025
6d4dfb6
Try
bouwew Sep 22, 2025
16efa76
Try 2
bouwew Sep 22, 2025
ae1cc42
10s offset Circle+
bouwew Sep 22, 2025
c211e7f
10s offset Circle
bouwew Sep 22, 2025
5e005ff
fixup: test_bouwew Python code reformatted using Ruff
Sep 22, 2025
6c89d60
Clean up
bouwew Sep 22, 2025
f572281
Modernize %-operator formatting
bouwew Sep 22, 2025
ae4e3e4
Fix typo
bouwew Sep 22, 2025
6a96ea2
Back to normal test-output
bouwew Sep 22, 2025
73b5cb3
Remove test-debugging
bouwew Sep 23, 2025
dbfdd30
Calculate also difference in days,
bouwew Sep 23, 2025
d1f0572
Update CHANGELOG
bouwew Sep 24, 2025
87434a5
v0.47.0a0 test-version
bouwew Sep 24, 2025
da3ae66
Line up debug messages
bouwew Sep 24, 2025
f50af68
Replave utc_now by fixed_time
bouwew Sep 24, 2025
d81c485
Clean up
bouwew Sep 24, 2025
2ba67e5
Improve pytest settings
bouwew Sep 24, 2025
1a687f9
Ruffed
bouwew Sep 24, 2025
e12211a
Disable pytest logging
bouwew Sep 24, 2025
f379b02
Improve clock-sync related log messages
bouwew Sep 25, 2025
51ec2b7
CRAI suggestions
bouwew Sep 25, 2025
226008a
Update CHANGELOG
bouwew Sep 25, 2025
d700822
Change debug levels
bouwew Sep 26, 2025
df5ae00
Set release-version 0.47.0 in pyproject.toml
bouwew Sep 26, 2025
File filter

Filter by extension

Filter by extension


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

## Ongoing

- PR [345](https://github.com/plugwise/python-plugwise-usb/pull/345): New Feature: schedule clock synchronization every 3600 seconds

## v0.46.1 - 2025-09-25

- PR [337](https://github.com/plugwise/python-plugwise-usb/pull/337): Improve node removal, remove and reset the node as executed by Source, and remove the cache-file.
Expand Down
78 changes: 53 additions & 25 deletions plugwise_usb/nodes/circle.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

from __future__ import annotations

from asyncio import Task, create_task, gather
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
from functools import wraps
import logging
from math import ceil
import random
from typing import Any, Final, TypeVar, cast

from ..api import (
Expand Down Expand Up @@ -74,7 +75,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__)
Expand Down Expand Up @@ -141,6 +144,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()
Expand Down Expand Up @@ -852,47 +857,61 @@ async def _relay_update_lock(
)
await self.save_cache()

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)))
try:
await self.clock_synchronize()
except Exception:
_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

async def clock_synchronize(self) -> bool:
"""Synchronize clock. Returns true if successful."""
get_clock_request = CircleClockGetRequest(self._send, self._mac_in_bytes)
clock_response = await get_clock_request.send()
if clock_response is None or clock_response.timestamp is None:
request = CircleClockGetRequest(self._send, self._mac_in_bytes)
response = await request.send()
if response is None or response.timestamp is None:
return False
_dt_of_circle = datetime.now(tz=UTC).replace(
hour=clock_response.time.hour.value,
minute=clock_response.time.minute.value,
second=clock_response.time.second.value,

dt_now = datetime.now(tz=UTC)
days_diff = (response.day_of_week.value - dt_now.weekday()) % 7
circle_timestamp: datetime = dt_now.replace(
day=dt_now.day + days_diff,
hour=response.time.value.hour,
minute=response.time.value.minute,
second=response.time.value.second,
microsecond=0,
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)
):
clock_offset = response.timestamp.replace(microsecond=0) - circle_timestamp
if abs(clock_offset.total_seconds()) < MAX_TIME_DRIFT:
return True
_LOGGER.info(
"Reset clock of node %s because time has drifted %s sec",

_LOGGER.warning(
"Sync clock of node %s because time drifted %s seconds",
self._mac_in_str,
str(clock_offset.seconds),
str(int(abs(clock_offset.total_seconds()))),
)
if self._node_protocols is None:
raise NodeError(
"Unable to synchronize clock en when protocol version is unknown"
)
set_clock_request = CircleClockSetRequest(

set_request = CircleClockSetRequest(
self._send,
self._mac_in_bytes,
datetime.now(tz=UTC),
self._node_protocols.max,
)
if (node_response := await set_clock_request.send()) is None:
_LOGGER.warning(
"Failed to (re)set the internal clock of %s",
self.name,
)
return False
if node_response.ack_id == NodeResponseType.CLOCK_ACCEPTED:
return True
if (node_response := await set_request.send()) is not None:
return node_response.ack_id == NodeResponseType.CLOCK_ACCEPTED
_LOGGER.debug("Failed to sync the clock of %s", self.name)
return False

async def load(self) -> None:
Expand Down Expand Up @@ -992,6 +1011,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(
Expand Down Expand Up @@ -1082,6 +1105,11 @@ async def unload(self) -> None:
if self._cache_enabled:
await self._energy_log_records_save_to_cache()

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()

@raise_not_loaded
Expand Down
67 changes: 42 additions & 25 deletions plugwise_usb/nodes/circle_plus.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,44 +67,61 @@ async def load(self) -> bool:

async def clock_synchronize(self) -> bool:
"""Synchronize realtime clock. Returns true if successful."""
clock_request = CirclePlusRealTimeClockGetRequest(
self._send, self._mac_in_bytes
)
if (clock_response := await clock_request.send()) is None:
request = CirclePlusRealTimeClockGetRequest(self._send, self._mac_in_bytes)
if (response := await request.send()) is None:
_LOGGER.debug(
"No response for async_realtime_clock_synchronize() for %s", self.mac
"No response for clock_synchronize() for %s", self._mac_in_str
)
await self._available_update_state(False)
return False
await self._available_update_state(True, clock_response.timestamp)
await self._available_update_state(True, response.timestamp)

dt_now = datetime.now(tz=UTC)
dt_now_date = dt_now.replace(hour=0, minute=0, second=0, microsecond=0)
response_date = datetime(
response.date.value.year,
response.date.value.month,
response.date.value.day,
hour=0,
minute=0,
second=0,
microsecond=0,
tzinfo=UTC,
)
if dt_now_date != response_date:
_LOGGER.warning(
"Sync clock of node %s because time has drifted %s days",
self._mac_in_str,
int(abs((dt_now_date - response_date).days)),
)
return await self._send_clock_set_req()

_dt_of_circle: datetime = datetime.now(tz=UTC).replace(
hour=clock_response.time.value.hour,
minute=clock_response.time.value.minute,
second=clock_response.time.value.second,
circle_plus_timestamp: datetime = dt_now.replace(
hour=response.time.value.hour,
minute=response.time.value.minute,
second=response.time.value.second,
microsecond=0,
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)
):
clock_offset = response.timestamp.replace(microsecond=0) - circle_plus_timestamp
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)",
self._node_info.mac,
str(clock_offset.seconds),
str(MAX_TIME_DRIFT),

_LOGGER.warning(
"Sync clock of node %s because time drifted %s seconds",
self._mac_in_str,
int(abs(clock_offset.total_seconds())),
)
clock_set_request = CirclePlusRealTimeClockSetRequest(
return await self._send_clock_set_req()

async def _send_clock_set_req(self) -> bool:
"""Send CirclePlusRealTimeClockSetRequest."""
set_request = CirclePlusRealTimeClockSetRequest(
self._send, self._mac_in_bytes, datetime.now(tz=UTC)
)
if (node_response := await clock_set_request.send()) is not None:
if (node_response := await set_request.send()) is not None:
return node_response.ack_id == NodeResponseType.CLOCK_ACCEPTED
_LOGGER.warning(
"Failed to (re)set the internal realtime clock of %s",
self.name,
)
_LOGGER.debug("Failed to sync the clock of %s", self.name)
return False

@raise_not_loaded
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "plugwise_usb"
version = "0.46.1"
version = "0.47.0a0"
license = "MIT"
keywords = ["home", "automation", "plugwise", "module", "usb"]
classifiers = [
Expand Down Expand Up @@ -540,3 +540,7 @@ testpaths = [
]
asyncio_default_fixture_loop_scope = "session"
asyncio_mode = "strict"
# log_cli = true
# log_cli_level = "DEBUG"
# log_format = "%(asctime)s %(levelname)s %(message)s"
# log_date_format = "%Y-%m-%d %H:%M:%S"
69 changes: 37 additions & 32 deletions tests/stick_test_data.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
"""Stick Test Program."""

from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
import importlib

pw_constants = importlib.import_module("plugwise_usb.constants")

# test using utc timezone
utc_now = datetime.now(tz=UTC).replace(tzinfo=UTC)

# test using utc timezone - 2025-04-03 22:00:00
fixed_time = datetime(
2025, 4, 3, 22, 0, 0
) # changed from datetime.now(tz=UTC).replace(tzinfo=UTC)

# generate energy log timestamps with fixed hour timestamp used in tests
hour_timestamp = utc_now.replace(minute=0, second=0, microsecond=0)
hour_timestamp = fixed_time.replace(minute=0, second=0, microsecond=0)

LOG_TIMESTAMPS = {}
_one_hour = timedelta(hours=1)
for x in range(168):
delta_month = hour_timestamp - hour_timestamp.replace(day=1, hour=0)
LOG_TIMESTAMPS[x] = (
bytes(("%%0%dX" % 2) % (hour_timestamp.year - 2000), pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dX" % 2) % hour_timestamp.month, pw_constants.UTF8) # noqa: UP031
bytes(f"{(hour_timestamp.year - 2000):02x}", pw_constants.UTF8)
+ bytes(f"{hour_timestamp.month:02x}", pw_constants.UTF8)
+ bytes(
("%%0%dX" % 4) # noqa: UP031
% int((delta_month.days * 1440) + (delta_month.seconds / 60)),
f"{int((delta_month.days * 1440) + (delta_month.seconds / 60)):04x}",
pw_constants.UTF8,
)
)
Expand Down Expand Up @@ -596,23 +596,28 @@
b"000000C1", # Success ack
b"003A" # msg_id
+ b"0098765432101234" # mac
+ bytes(("%%0%dd" % 2) % utc_now.second, pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dd" % 2) % utc_now.minute, pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dd" % 2) % utc_now.hour, pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dd" % 2) % utc_now.weekday(), pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dd" % 2) % utc_now.day, pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dd" % 2) % utc_now.month, pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dd" % 2) % (utc_now.year - 2000), pw_constants.UTF8), # noqa: UP031
+ bytes(f"{(fixed_time + timedelta(seconds=10)).second:02d}", pw_constants.UTF8)
+ bytes(f"{fixed_time.minute:02d}", pw_constants.UTF8)
+ bytes(f"{fixed_time.hour:02d}", pw_constants.UTF8)
+ bytes(f"{fixed_time.weekday():02d}", pw_constants.UTF8)
+ bytes(f"{fixed_time.day:02d}", pw_constants.UTF8)
+ bytes(f"{fixed_time.month:02d}", pw_constants.UTF8)
+ bytes(f"{(fixed_time.year - 2000):02d}", pw_constants.UTF8),
),
b"\x05\x05\x03\x0300280098765432101234000022030304259DDF\r\n": (
"Circle+ Realtime set clock 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
b"003F" # msg_id
+ b"1111111111111111" # mac
+ bytes(("%%0%dX" % 2) % utc_now.hour, pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dX" % 2) % utc_now.minute, pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dX" % 2) % utc_now.second, pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dX" % 2) % utc_now.weekday(), pw_constants.UTF8) # noqa: UP031
+ bytes(f"{fixed_time.hour:02x}", pw_constants.UTF8)
+ bytes(f"{fixed_time.minute:02x}", pw_constants.UTF8)
+ bytes(f"{(fixed_time + timedelta(seconds=10)).second:02x}", pw_constants.UTF8)
+ bytes(f"{fixed_time.weekday():02x}", pw_constants.UTF8)
+ b"00" # unknown
+ b"0000", # unknown2
),
Expand All @@ -621,10 +626,10 @@
b"000000C1", # Success ack
b"003F" # msg_id
+ b"2222222222222222" # mac
+ bytes(("%%0%dX" % 2) % utc_now.hour, pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dX" % 2) % utc_now.minute, pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dX" % 2) % utc_now.second, pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dX" % 2) % utc_now.weekday(), pw_constants.UTF8) # noqa: UP031
+ bytes(f"{fixed_time.hour:02x}", pw_constants.UTF8)
+ bytes(f"{fixed_time.minute:02x}", pw_constants.UTF8)
+ bytes(f"{fixed_time.second:02x}", pw_constants.UTF8)
+ bytes(f"{fixed_time.weekday():02x}", pw_constants.UTF8)
+ b"00" # unknown
+ b"0000", # unknown2
),
Expand All @@ -633,10 +638,10 @@
b"000000C1", # Success ack
b"003F" # msg_id
+ b"3333333333333333" # mac
+ bytes(("%%0%dX" % 2) % utc_now.hour, pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dX" % 2) % utc_now.minute, pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dX" % 2) % utc_now.second, pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dX" % 2) % utc_now.weekday(), pw_constants.UTF8) # noqa: UP031
+ bytes(f"{fixed_time.hour:02x}", pw_constants.UTF8)
+ bytes(f"{fixed_time.minute:02x}", pw_constants.UTF8)
+ bytes(f"{fixed_time.second:02x}", pw_constants.UTF8)
+ bytes(f"{fixed_time.weekday():02x}", pw_constants.UTF8)
+ b"00" # unknown
+ b"0000", # unknown2
),
Expand All @@ -645,10 +650,10 @@
b"000000C1", # Success ack
b"003F" # msg_id
+ b"4444444444444444" # mac
+ bytes(("%%0%dX" % 2) % utc_now.hour, pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dX" % 2) % utc_now.minute, pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dX" % 2) % utc_now.second, pw_constants.UTF8) # noqa: UP031
+ bytes(("%%0%dX" % 2) % utc_now.weekday(), pw_constants.UTF8) # noqa: UP031
+ bytes(f"{fixed_time.hour:02x}", pw_constants.UTF8)
+ bytes(f"{fixed_time.minute:02x}", pw_constants.UTF8)
+ bytes(f"{fixed_time.second:02x}", pw_constants.UTF8)
+ bytes(f"{fixed_time.weekday():02x}", pw_constants.UTF8)
+ b"00" # unknown
+ b"0000", # unknown2
),
Expand Down
Loading