Skip to content

Commit 774ba29

Browse files
authored
Merge pull request #345 from plugwise/test_bouwew
Implement energy device clock synchronization
2 parents 17837d9 + df5ae00 commit 774ba29

File tree

6 files changed

+149
-88
lines changed

6 files changed

+149
-88
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## v0.47.0 - 2025-09-26
4+
5+
- PR [345](https://github.com/plugwise/python-plugwise-usb/pull/345): New Feature: schedule clock synchronization every 3600 seconds
6+
37
## v0.46.1 - 2025-09-25
48

59
- 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.

plugwise_usb/nodes/circle.py

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22

33
from __future__ import annotations
44

5-
from asyncio import Task, create_task, gather
5+
from asyncio import CancelledError, Task, create_task, gather, sleep
66
from collections.abc import Awaitable, Callable
77
from dataclasses import replace
88
from datetime import UTC, datetime, timedelta
99
from functools import wraps
1010
import logging
1111
from math import ceil
12+
import random
1213
from typing import Any, Final, TypeVar, cast
1314

1415
from ..api import (
@@ -74,7 +75,9 @@
7475
# Default firmware if not known
7576
DEFAULT_FIRMWARE: Final = datetime(2008, 8, 26, 15, 46, tzinfo=UTC)
7677

77-
MAX_LOG_HOURS = DAY_IN_HOURS
78+
MAX_LOG_HOURS: Final = DAY_IN_HOURS
79+
80+
CLOCK_SYNC_PERIOD: Final = 3600
7881

7982
FuncT = TypeVar("FuncT", bound=Callable[..., Any])
8083
_LOGGER = logging.getLogger(__name__)
@@ -141,6 +144,8 @@ def __init__(
141144
"""Initialize base class for Sleeping End Device."""
142145
super().__init__(mac, node_type, controller, loaded_callback)
143146

147+
# Clock
148+
self._clock_synchronize_task: Task[None] | None = None
144149
# Relay
145150
self._relay_lock: RelayLock = RelayLock()
146151
self._relay_state: RelayState = RelayState()
@@ -852,47 +857,61 @@ async def _relay_update_lock(
852857
)
853858
await self.save_cache()
854859

860+
async def _clock_synchronize_scheduler(self) -> None:
861+
"""Background task: periodically synchronize the clock until cancelled."""
862+
try:
863+
while True:
864+
await sleep(CLOCK_SYNC_PERIOD + (random.uniform(-5, 5)))
865+
try:
866+
await self.clock_synchronize()
867+
except Exception:
868+
_LOGGER.exception(
869+
"Clock synchronization failed for %s", self._mac_in_str
870+
)
871+
except CancelledError:
872+
_LOGGER.debug("Clock sync scheduler cancelled for %s", self._mac_in_str)
873+
raise
874+
855875
async def clock_synchronize(self) -> bool:
856876
"""Synchronize clock. Returns true if successful."""
857-
get_clock_request = CircleClockGetRequest(self._send, self._mac_in_bytes)
858-
clock_response = await get_clock_request.send()
859-
if clock_response is None or clock_response.timestamp is None:
877+
request = CircleClockGetRequest(self._send, self._mac_in_bytes)
878+
response = await request.send()
879+
if response is None or response.timestamp is None:
860880
return False
861-
_dt_of_circle = datetime.now(tz=UTC).replace(
862-
hour=clock_response.time.hour.value,
863-
minute=clock_response.time.minute.value,
864-
second=clock_response.time.second.value,
881+
882+
dt_now = datetime.now(tz=UTC)
883+
days_diff = (response.day_of_week.value - dt_now.weekday()) % 7
884+
circle_timestamp: datetime = dt_now.replace(
885+
day=dt_now.day + days_diff,
886+
hour=response.time.value.hour,
887+
minute=response.time.value.minute,
888+
second=response.time.value.second,
865889
microsecond=0,
866890
tzinfo=UTC,
867891
)
868-
clock_offset = clock_response.timestamp.replace(microsecond=0) - _dt_of_circle
869-
if (clock_offset.seconds < MAX_TIME_DRIFT) or (
870-
clock_offset.seconds > -(MAX_TIME_DRIFT)
871-
):
892+
clock_offset = response.timestamp.replace(microsecond=0) - circle_timestamp
893+
if abs(clock_offset.total_seconds()) < MAX_TIME_DRIFT:
872894
return True
895+
873896
_LOGGER.info(
874-
"Reset clock of node %s because time has drifted %s sec",
897+
"Sync clock of node %s because time drifted %s seconds",
875898
self._mac_in_str,
876-
str(clock_offset.seconds),
899+
int(abs(clock_offset.total_seconds())),
877900
)
878901
if self._node_protocols is None:
879902
raise NodeError(
880-
"Unable to synchronize clock en when protocol version is unknown"
903+
"Unable to synchronize clock when protocol version is unknown"
881904
)
882-
set_clock_request = CircleClockSetRequest(
905+
906+
set_request = CircleClockSetRequest(
883907
self._send,
884908
self._mac_in_bytes,
885909
datetime.now(tz=UTC),
886910
self._node_protocols.max,
887911
)
888-
if (node_response := await set_clock_request.send()) is None:
889-
_LOGGER.warning(
890-
"Failed to (re)set the internal clock of %s",
891-
self.name,
892-
)
893-
return False
894-
if node_response.ack_id == NodeResponseType.CLOCK_ACCEPTED:
895-
return True
912+
if (node_response := await set_request.send()) is not None:
913+
return node_response.ack_id == NodeResponseType.CLOCK_ACCEPTED
914+
_LOGGER.warning("Failed to sync the clock of %s", self.name)
896915
return False
897916

898917
async def load(self) -> None:
@@ -1016,6 +1035,10 @@ async def initialize(self) -> bool:
10161035
return False
10171036

10181037
await super().initialize()
1038+
if self._clock_synchronize_task is None or self._clock_synchronize_task.done():
1039+
self._clock_synchronize_task = create_task(
1040+
self._clock_synchronize_scheduler()
1041+
)
10191042
return True
10201043

10211044
async def node_info_update(
@@ -1082,6 +1105,11 @@ async def unload(self) -> None:
10821105
if self._cache_enabled:
10831106
await self._energy_log_records_save_to_cache()
10841107

1108+
if self._clock_synchronize_task:
1109+
self._clock_synchronize_task.cancel()
1110+
await gather(self._clock_synchronize_task, return_exceptions=True)
1111+
self._clock_synchronize_task = None
1112+
10851113
await super().unload()
10861114

10871115
@raise_not_loaded
@@ -1318,7 +1346,7 @@ async def energy_reset_request(self) -> None:
13181346
f"Unexpected NodeResponseType {response.ack_id!r} received as response to CircleClockSetRequest"
13191347
)
13201348

1321-
_LOGGER.warning("Energy reset for Node %s successful", self._mac_in_str)
1349+
_LOGGER.info("Energy reset for Node %s successful", self._mac_in_str)
13221350

13231351
# Follow up by an energy-intervals (re)set
13241352
interval_request = CircleMeasureIntervalRequest(
@@ -1337,20 +1365,20 @@ async def energy_reset_request(self) -> None:
13371365
raise MessageError(
13381366
f"Unknown NodeResponseType '{interval_response.response_type.name}' received"
13391367
)
1340-
_LOGGER.warning("Resetting energy intervals to default (= consumption only)")
1368+
_LOGGER.info("Resetting energy intervals to default (= consumption only)")
13411369

13421370
# Clear the cached energy_collection
13431371
if self._cache_enabled:
13441372
self._set_cache(CACHE_ENERGY_COLLECTION, "")
1345-
_LOGGER.warning(
1373+
_LOGGER.info(
13461374
"Energy-collection cache cleared successfully, updating cache for %s",
13471375
self._mac_in_str,
13481376
)
13491377
await self.save_cache()
13501378

13511379
# Clear PulseCollection._logs
13521380
self._energy_counters.reset_pulse_collection()
1353-
_LOGGER.warning("Resetting pulse-collection")
1381+
_LOGGER.info("Resetting pulse-collection")
13541382

13551383
# Request a NodeInfo update
13561384
if await self.node_info_update() is None:
@@ -1359,7 +1387,7 @@ async def energy_reset_request(self) -> None:
13591387
self._mac_in_str,
13601388
)
13611389
else:
1362-
_LOGGER.warning(
1390+
_LOGGER.info(
13631391
"Node info update after energy-reset successful for %s",
13641392
self._mac_in_str,
13651393
)

plugwise_usb/nodes/circle_plus.py

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -67,44 +67,61 @@ async def load(self) -> bool:
6767

6868
async def clock_synchronize(self) -> bool:
6969
"""Synchronize realtime clock. Returns true if successful."""
70-
clock_request = CirclePlusRealTimeClockGetRequest(
71-
self._send, self._mac_in_bytes
72-
)
73-
if (clock_response := await clock_request.send()) is None:
74-
_LOGGER.debug(
75-
"No response for async_realtime_clock_synchronize() for %s", self.mac
70+
request = CirclePlusRealTimeClockGetRequest(self._send, self._mac_in_bytes)
71+
if (response := await request.send()) is None:
72+
_LOGGER.warning(
73+
"No response for clock_synchronize() for %s", self._mac_in_str
7674
)
7775
await self._available_update_state(False)
7876
return False
79-
await self._available_update_state(True, clock_response.timestamp)
77+
await self._available_update_state(True, response.timestamp)
78+
79+
dt_now = datetime.now(tz=UTC)
80+
dt_now_date = dt_now.replace(hour=0, minute=0, second=0, microsecond=0)
81+
response_date = datetime(
82+
response.date.value.year,
83+
response.date.value.month,
84+
response.date.value.day,
85+
hour=0,
86+
minute=0,
87+
second=0,
88+
microsecond=0,
89+
tzinfo=UTC,
90+
)
91+
if dt_now_date != response_date:
92+
_LOGGER.info(
93+
"Sync clock of node %s because time has drifted %s days",
94+
self._mac_in_str,
95+
int(abs((dt_now_date - response_date).days)),
96+
)
97+
return await self._send_clock_set_req()
8098

81-
_dt_of_circle: datetime = datetime.now(tz=UTC).replace(
82-
hour=clock_response.time.value.hour,
83-
minute=clock_response.time.value.minute,
84-
second=clock_response.time.value.second,
99+
circle_plus_timestamp: datetime = dt_now.replace(
100+
hour=response.time.value.hour,
101+
minute=response.time.value.minute,
102+
second=response.time.value.second,
85103
microsecond=0,
86104
tzinfo=UTC,
87105
)
88-
clock_offset = clock_response.timestamp.replace(microsecond=0) - _dt_of_circle
89-
if (clock_offset.seconds < MAX_TIME_DRIFT) or (
90-
clock_offset.seconds > -(MAX_TIME_DRIFT)
91-
):
106+
clock_offset = response.timestamp.replace(microsecond=0) - circle_plus_timestamp
107+
if abs(clock_offset.total_seconds()) < MAX_TIME_DRIFT:
92108
return True
109+
93110
_LOGGER.info(
94-
"Reset realtime clock of node %s because time has drifted %s seconds while max drift is set to %s seconds)",
95-
self._node_info.mac,
96-
str(clock_offset.seconds),
97-
str(MAX_TIME_DRIFT),
111+
"Sync clock of node %s because time drifted %s seconds",
112+
self._mac_in_str,
113+
int(abs(clock_offset.total_seconds())),
98114
)
99-
clock_set_request = CirclePlusRealTimeClockSetRequest(
115+
return await self._send_clock_set_req()
116+
117+
async def _send_clock_set_req(self) -> bool:
118+
"""Send CirclePlusRealTimeClockSetRequest."""
119+
set_request = CirclePlusRealTimeClockSetRequest(
100120
self._send, self._mac_in_bytes, datetime.now(tz=UTC)
101121
)
102-
if (node_response := await clock_set_request.send()) is not None:
122+
if (node_response := await set_request.send()) is not None:
103123
return node_response.ack_id == NodeResponseType.CLOCK_ACCEPTED
104-
_LOGGER.warning(
105-
"Failed to (re)set the internal realtime clock of %s",
106-
self.name,
107-
)
124+
_LOGGER.warning("Failed to sync the clock of %s", self.name)
108125
return False
109126

110127
@raise_not_loaded

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "plugwise_usb"
7-
version = "0.46.1"
7+
version = "0.47.0"
88
license = "MIT"
99
keywords = ["home", "automation", "plugwise", "module", "usb"]
1010
classifiers = [
@@ -540,3 +540,7 @@ testpaths = [
540540
]
541541
asyncio_default_fixture_loop_scope = "session"
542542
asyncio_mode = "strict"
543+
# log_cli = true
544+
# log_cli_level = "DEBUG"
545+
# log_format = "%(asctime)s %(levelname)s %(message)s"
546+
# log_date_format = "%Y-%m-%d %H:%M:%S"

0 commit comments

Comments
 (0)