From 750d93c7fbd89ed1a3af5de0cdfe89a8dbf2571d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 12 Aug 2025 17:57:22 +0200 Subject: [PATCH 01/27] Move energy-log validity-check into energy_log_update() --- plugwise_usb/nodes/circle.py | 114 +++++++++++++++-------------------- 1 file changed, 50 insertions(+), 64 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index ea0150259..54200f7e2 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -364,7 +364,7 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 # Always request last energy log records at initial startup if not self._last_energy_log_requested: - self._last_energy_log_requested, _ = await self.energy_log_update( + self._last_energy_log_requested = await self.energy_log_update( self._current_log_address ) @@ -378,7 +378,7 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 return None # Try collecting energy-stats for _current_log_address - result, _ = await self.energy_log_update(self._current_log_address) + result = await self.energy_log_update(self._current_log_address) if not result: _LOGGER.debug( "async_energy_update | %s | Log rollover | energy_log_update failed", @@ -392,7 +392,7 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 _prev_log_address, _ = calc_log_address( self._current_log_address, 1, -4 ) - result, _ = await self.energy_log_update(_prev_log_address) + result = await self.energy_log_update(_prev_log_address) if not result: _LOGGER.debug( "async_energy_update | %s | Log rollover | energy_log_update %s failed", @@ -413,7 +413,7 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 return self._energy_counters.energy_statistics if len(missing_addresses) == 1: - result, _ = await self.energy_log_update(missing_addresses[0]) + result = await self.energy_log_update(missing_addresses[0]) if result: await self.power_update() _LOGGER.debug( @@ -464,52 +464,15 @@ async def _get_initial_energy_logs(self) -> None: log_address = self._current_log_address prev_address_timestamp: datetime | None = None while total_addresses > 0: - result, empty_log = await self.energy_log_update(log_address) - if result and empty_log: + result = await self.energy_log_update(log_address) + if not result: # Handle case with None-data in all address slots _LOGGER.debug( - "Energy None-data collected from log address %s, stopping collection", + "All slots at log address %s are empty or outdated – stopping initial collection", log_address, ) break - # Check if the most recent timestamp of an earlier address is recent - # (within 2/4 * log_interval plus 5 mins margin) - log_interval = self.energy_consumption_interval - factor = 2 if self.energy_production_interval is not None else 4 - - if log_interval is not None: - max_gap_minutes = (factor * log_interval) + 5 - if log_address == self._current_log_address: - if ( - self._last_collected_energy_timestamp is not None - and ( - datetime.now(tz=UTC) - self._last_collected_energy_timestamp - ).total_seconds() - // 60 - > max_gap_minutes - ): - _LOGGER.debug( - "Energy data collected from the current log address is outdated, stopping collection" - ) - break - elif ( - prev_address_timestamp is not None - and self._last_collected_energy_timestamp is not None - and ( - prev_address_timestamp - self._last_collected_energy_timestamp - ).total_seconds() - // 60 - > max_gap_minutes - ): - _LOGGER.debug( - "Collected energy data is outdated, stopping collection" - ) - break - - if self._last_collected_energy_timestamp is not None: - prev_address_timestamp = self._last_collected_energy_timestamp - log_address, _ = calc_log_address(log_address, 1, -4) total_addresses -= 1 @@ -544,30 +507,27 @@ async def get_missing_energy_logs(self) -> None: if self._cache_enabled: await self._energy_log_records_save_to_cache() - async def energy_log_update(self, address: int | None) -> tuple[bool, bool]: - """Request energy log statistics from node. Returns true if successful.""" - empty_log = False - result = False + async def energy_log_update(self, address: int | None) -> bool: + """Request energy logs and return True only when at least one recent, non-empty record was stored; otherwise return False.""" + any_record_stored = False if address is None: - return result, empty_log + return False _LOGGER.debug( - "Request of energy log at address %s for node %s", - str(address), - self.name, + "Requesting EnergyLogs from node %s address %s", + self._mac_in_str, + address, ) request = CircleEnergyLogsRequest(self._send, self._mac_in_bytes, address) if (response := await request.send()) is None: _LOGGER.debug( - "Retrieving of energy log at address %s for node %s failed", - str(address), + "Retrieving EnergyLogs data from node %s failed", self._mac_in_str, ) - return result, empty_log + return False - _LOGGER.debug("EnergyLogs data from %s, address=%s", self._mac_in_str, address) + _LOGGER.debug("EnergyLogs from node %s, address=%s:", self._mac_in_str, address) await self._available_update_state(True, response.timestamp) - energy_record_update = False # Forward historical energy log information to energy counters # Each response message contains 4 log counters (slots) of the @@ -578,17 +538,23 @@ async def energy_log_update(self, address: int | None) -> tuple[bool, bool]: _LOGGER.debug( "In slot=%s: pulses=%s, timestamp=%s", _slot, log_pulses, log_timestamp ) - if log_timestamp is None or log_pulses is None: + if ( + log_timestamp is None + or log_pulses is None + # Don't store an old log-record, store am empty record instead + or not self._check_timestamp_is_recent(address, _slot, log_timestamp) + ): self._energy_counters.add_empty_log(response.log_address, _slot) - empty_log = True - elif await self._energy_log_record_update_state( + continue + + if await self._energy_log_record_update_state( response.log_address, _slot, log_timestamp.replace(tzinfo=UTC), log_pulses, import_only=True, ): - energy_record_update = True + any_record_stored = True if not last_energy_timestamp_collected: # Collect the timestamp of the most recent response self._last_collected_energy_timestamp = log_timestamp.replace( @@ -600,15 +566,35 @@ async def energy_log_update(self, address: int | None) -> tuple[bool, bool]: ) last_energy_timestamp_collected = True - result = True self._energy_counters.update() - if energy_record_update: + if any_record_stored: _LOGGER.debug( "Saving energy record update to cache for %s", self._mac_in_str ) await self.save_cache() - return result, empty_log + return any_record_stored + + def _check_timestamp_is_recent( + self, address: int, slot: int, timestamp: datetime + ) -> bool: + """Check if the timestamp of the received log-record is recent. + + A timestamp from within the last 24 hours is considered recent. + """ + age_seconds = ( + datetime.now(tz=UTC) - timestamp.replace(tzinfo=UTC) + ).total_seconds() + if age_seconds > DAY_IN_HOURS * 3600: + _LOGGER.warning( + "EnergyLog from Node %s | address %s | slot %s | timestamp %s is outdated, ignoring...", + self._mac_in_str, + address, + slot, + timestamp, + ) + return False + return True async def _energy_log_records_load_from_cache(self) -> bool: """Load energy_log_record from cache.""" From 78516e8325a319bd42084c9b1bb7407275441660 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 12 Aug 2025 18:03:20 +0200 Subject: [PATCH 02/27] Use MAX_LOG_HOUR as in pulses.py --- plugwise_usb/nodes/circle.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 54200f7e2..ca2615198 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -24,6 +24,7 @@ ) from ..connection import StickController from ..constants import ( + DAY_IN_HOURS, DEFAULT_CONS_INTERVAL, MAX_TIME_DRIFT, MINIMAL_POWER_UPDATE, @@ -72,6 +73,8 @@ # Default firmware if not known DEFAULT_FIRMWARE: Final = datetime(2008, 8, 26, 15, 46, tzinfo=UTC) +MAX_LOG_HOURS = DAY_IN_HOURS + FuncT = TypeVar("FuncT", bound=Callable[..., Any]) _LOGGER = logging.getLogger(__name__) @@ -468,7 +471,7 @@ async def _get_initial_energy_logs(self) -> None: if not result: # Handle case with None-data in all address slots _LOGGER.debug( - "All slots at log address %s are empty or outdated – stopping initial collection", + "All slots at log address %s are empty or outdated – stopping initial collection", log_address, ) break @@ -580,12 +583,12 @@ def _check_timestamp_is_recent( ) -> bool: """Check if the timestamp of the received log-record is recent. - A timestamp from within the last 24 hours is considered recent. + A timestamp newer than MAX_LOG_HOURS is considered recent. """ age_seconds = ( datetime.now(tz=UTC) - timestamp.replace(tzinfo=UTC) ).total_seconds() - if age_seconds > DAY_IN_HOURS * 3600: + if age_seconds > MAX_LOG_HOURS * 3600: _LOGGER.warning( "EnergyLog from Node %s | address %s | slot %s | timestamp %s is outdated, ignoring...", self._mac_in_str, From 0fdf1726cf23fb2d8c2482606008434742d1426b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 12 Aug 2025 18:08:14 +0200 Subject: [PATCH 03/27] Implement CRAI suggestions --- plugwise_usb/nodes/circle.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index ca2615198..a8ecc24b3 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -544,33 +544,33 @@ async def energy_log_update(self, address: int | None) -> bool: if ( log_timestamp is None or log_pulses is None - # Don't store an old log-record, store am empty record instead + # Don't store an old log-record; store am empty record instead or not self._check_timestamp_is_recent(address, _slot, log_timestamp) ): self._energy_counters.add_empty_log(response.log_address, _slot) continue - if await self._energy_log_record_update_state( + await self._energy_log_record_update_state( response.log_address, _slot, log_timestamp.replace(tzinfo=UTC), log_pulses, import_only=True, - ): - any_record_stored = True - if not last_energy_timestamp_collected: - # Collect the timestamp of the most recent response - self._last_collected_energy_timestamp = log_timestamp.replace( - tzinfo=UTC - ) - _LOGGER.debug( - "Setting last_collected_energy_timestamp to %s", - self._last_collected_energy_timestamp, - ) - last_energy_timestamp_collected = True + ) + any_record_stored = True + if not last_energy_timestamp_collected: + # Collect the timestamp of the most recent response + self._last_collected_energy_timestamp = log_timestamp.replace( + tzinfo=UTC + ) + _LOGGER.debug( + "Setting last_collected_energy_timestamp to %s", + self._last_collected_energy_timestamp, + ) + last_energy_timestamp_collected = True self._energy_counters.update() - if any_record_stored: + if any_record_stored and self._cache_enabled: _LOGGER.debug( "Saving energy record update to cache for %s", self._mac_in_str ) From 28769c02c6e01ced34b0cc7ba1bbfc8f4ab98ff6 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 12 Aug 2025 18:13:37 +0200 Subject: [PATCH 04/27] Clean up --- 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 a8ecc24b3..9790d5860 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -465,7 +465,6 @@ async def _get_initial_energy_logs(self) -> None: ) total_addresses = 11 log_address = self._current_log_address - prev_address_timestamp: datetime | None = None while total_addresses > 0: result = await self.energy_log_update(log_address) if not result: From e24a92f121a31b19082abdfb6c1b5a5e14a288cc Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 12 Aug 2025 18:32:01 +0200 Subject: [PATCH 05/27] Implement @ArnoutD's idea on how many logs to collect initially --- 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 9790d5860..c8ffcc383 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -8,6 +8,7 @@ from datetime import UTC, datetime from functools import wraps import logging +from math import floor from typing import Any, Final, TypeVar, cast from ..api import ( @@ -463,7 +464,7 @@ async def _get_initial_energy_logs(self) -> None: "Start collecting initial energy logs from the last 10 log addresses for node %s.", self._mac_in_str, ) - total_addresses = 11 + total_addresses = int(floor(datetime.now(tz=UTC).hour / 4) + 1) log_address = self._current_log_address while total_addresses > 0: result = await self.energy_log_update(log_address) From b689b9656ddde620ccb8a6f56ae5651a6617bbf4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 12 Aug 2025 18:33:53 +0200 Subject: [PATCH 06/27] Adapt corresponding log-message --- 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 c8ffcc383..0ca17b427 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -461,7 +461,7 @@ async def _get_initial_energy_logs(self) -> None: return _LOGGER.debug( - "Start collecting initial energy logs from the last 10 log addresses for node %s.", + "Start collecting today's energy logs for node %s.", self._mac_in_str, ) total_addresses = int(floor(datetime.now(tz=UTC).hour / 4) + 1) From afb572478ff137cd56dc4e6ce32b7565272ba897 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 12 Aug 2025 19:32:38 +0200 Subject: [PATCH 07/27] Improve comment --- 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 0ca17b427..56a50edc7 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -469,7 +469,8 @@ async def _get_initial_energy_logs(self) -> None: while total_addresses > 0: result = await self.energy_log_update(log_address) if not result: - # Handle case with None-data in all address slots + # Stop initial log collection when an address contains no (None) or outdated data + # Outdated data can indicate a EnergyLog address rollover: from address 6014 to 0 _LOGGER.debug( "All slots at log address %s are empty or outdated – stopping initial collection", log_address, From 8d0484283e722f2b3bf2186c6bc043c741f530a7 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 12 Aug 2025 19:47:28 +0200 Subject: [PATCH 08/27] Stop missing_log collection when None or invalid data is collected Other improvements --- plugwise_usb/nodes/circle.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 56a50edc7..0cc7e8dbc 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -385,23 +385,22 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 result = await self.energy_log_update(self._current_log_address) if not result: _LOGGER.debug( - "async_energy_update | %s | Log rollover | energy_log_update failed", + "async_energy_update | %s | Log rollover | energy_log_update from address %s failed", self._mac_in_str, + self._current_log_address, ) return None if self._current_log_address is not None: # Retry with previous log address as Circle node pointer to self._current_log_address # could be rolled over while the last log is at previous address/slot - _prev_log_address, _ = calc_log_address( - self._current_log_address, 1, -4 - ) - result = await self.energy_log_update(_prev_log_address) + prev_log_address, _ = calc_log_address(self._current_log_address, 1, -4) + result = await self.energy_log_update(prev_log_address) if not result: _LOGGER.debug( - "async_energy_update | %s | Log rollover | energy_log_update %s failed", + "async_energy_update | %s | Log rollover | energy_log_update from address %s failed", self._mac_in_str, - _prev_log_address, + prev_log_address, ) return None @@ -507,6 +506,10 @@ async def get_missing_energy_logs(self) -> None: ] for task in tasks: await task + # When an energy log collection task returns False, do not execute the remaining tasks + if not task.result(): + for t in tasks: + t.cancel() if self._cache_enabled: await self._energy_log_records_save_to_cache() From e9943e6732b42173fc01c493bcf1bfa221b52122 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 12 Aug 2025 20:23:04 +0200 Subject: [PATCH 09/27] Remove obsolete _last_collected_energy_timestamp state --- plugwise_usb/nodes/circle.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 0cc7e8dbc..6f6a270f5 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -118,7 +118,6 @@ def __init__( self._energy_counters = EnergyCounters(mac) self._retrieve_energy_logs_task: None | Task[None] = None self._last_energy_log_requested: bool = False - self._last_collected_energy_timestamp: datetime | None = None self._group_member: list[int] = [] @@ -562,16 +561,6 @@ async def energy_log_update(self, address: int | None) -> bool: import_only=True, ) any_record_stored = True - if not last_energy_timestamp_collected: - # Collect the timestamp of the most recent response - self._last_collected_energy_timestamp = log_timestamp.replace( - tzinfo=UTC - ) - _LOGGER.debug( - "Setting last_collected_energy_timestamp to %s", - self._last_collected_energy_timestamp, - ) - last_energy_timestamp_collected = True self._energy_counters.update() if any_record_stored and self._cache_enabled: From 688014fc21f4e8b1edf99c495b482c36838c5e14 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 12 Aug 2025 20:27:45 +0200 Subject: [PATCH 10/27] CRAI: improve total_addresses calculation --- plugwise_usb/nodes/circle.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 6f6a270f5..ed879643d 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -8,7 +8,6 @@ from datetime import UTC, datetime from functools import wraps import logging -from math import floor from typing import Any, Final, TypeVar, cast from ..api import ( @@ -462,7 +461,7 @@ async def _get_initial_energy_logs(self) -> None: "Start collecting today's energy logs for node %s.", self._mac_in_str, ) - total_addresses = int(floor(datetime.now(tz=UTC).hour / 4) + 1) + total_addresses = min(MAX_LOG_HOURS / 2, datetime.now(tz=UTC).hour + 1) log_address = self._current_log_address while total_addresses > 0: result = await self.energy_log_update(log_address) From fff4117e095a551281afb54b0b26a3baf61bc5a9 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 12 Aug 2025 20:31:38 +0200 Subject: [PATCH 11/27] CRAI: improve remaining tasks cancellation --- plugwise_usb/nodes/circle.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index ed879643d..8b93cd041 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -502,12 +502,13 @@ async def get_missing_energy_logs(self) -> None: create_task(self.energy_log_update(address)) for address in missing_addresses ] - for task in tasks: - await task - # When an energy log collection task returns False, do not execute the remaining tasks - if not task.result(): - for t in tasks: + for idx, task in enumerate(tasks): + result = await task + # When an energy log collection task returns False, stop and cancel the remaining tasks + if not result: + for t in tasks[idx + 1 :]: t.cancel() + break if self._cache_enabled: await self._energy_log_records_save_to_cache() From 40e01793e7c8b8b57726165a63648879bea1caf1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 12 Aug 2025 20:33:39 +0200 Subject: [PATCH 12/27] Docstring, comment improvements --- 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 8b93cd041..5b69fe937 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -453,7 +453,7 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 return None async def _get_initial_energy_logs(self) -> None: - """Collect initial energy logs from the last 10 log addresses.""" + """Collect initial energy logs for recent hours up to MAX_LOG_HOURS/2 (or hours elapsed today).""" if self._current_log_address is None: return @@ -547,7 +547,7 @@ async def energy_log_update(self, address: int | None) -> bool: if ( log_timestamp is None or log_pulses is None - # Don't store an old log-record; store am empty record instead + # Don't store an old log record; store am empty record instead or not self._check_timestamp_is_recent(address, _slot, log_timestamp) ): self._energy_counters.add_empty_log(response.log_address, _slot) From 60c09d2b450ea09e5f11933431f5cea5c24f2b9d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 12 Aug 2025 20:41:59 +0200 Subject: [PATCH 13/27] Further CRAI tasks cancellation improvement --- plugwise_usb/nodes/circle.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 5b69fe937..0c9f4c685 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -506,8 +506,12 @@ async def get_missing_energy_logs(self) -> None: result = await task # When an energy log collection task returns False, stop and cancel the remaining tasks if not result: - for t in tasks[idx + 1 :]: + to_cancel = tasks[idx + 1 :] + for t in to_cancel: t.cancel() + # Drain cancellations to avoid "Task exception was never retrieved" + from asyncio import gather as _gather + await _gather(*to_cancel, return_exceptions=True) break if self._cache_enabled: From d9006454b8e1eef158259659c31597bbadd998ba Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 12 Aug 2025 20:43:33 +0200 Subject: [PATCH 14/27] Force division-result to int --- 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 0c9f4c685..eeac049bb 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -461,7 +461,7 @@ async def _get_initial_energy_logs(self) -> None: "Start collecting today's energy logs for node %s.", self._mac_in_str, ) - total_addresses = min(MAX_LOG_HOURS / 2, datetime.now(tz=UTC).hour + 1) + total_addresses = min(int(MAX_LOG_HOURS / 2), datetime.now(tz=UTC).hour + 1) log_address = self._current_log_address while total_addresses > 0: result = await self.energy_log_update(log_address) From c3a91cb266df244c369f2a7431938405ffa2ded8 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 12 Aug 2025 20:47:45 +0200 Subject: [PATCH 15/27] Retry setting total_addresses --- 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 eeac049bb..6c900a5b3 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -453,7 +453,7 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 return None async def _get_initial_energy_logs(self) -> None: - """Collect initial energy logs for recent hours up to MAX_LOG_HOURS/2 (or hours elapsed today).""" + """Collect initial energy logs for up to 10 last log addresses or from the hours elapsed today.""" if self._current_log_address is None: return @@ -461,7 +461,7 @@ async def _get_initial_energy_logs(self) -> None: "Start collecting today's energy logs for node %s.", self._mac_in_str, ) - total_addresses = min(int(MAX_LOG_HOURS / 2), datetime.now(tz=UTC).hour + 1) + total_addresses = min(11, datetime.now(tz=UTC).hour + 1) log_address = self._current_log_address while total_addresses > 0: result = await self.energy_log_update(log_address) From e6c9050df5f50eaaac6d685297c75c03bf53d65a Mon Sep 17 00:00:00 2001 From: autoruff Date: Tue, 12 Aug 2025 18:48:24 +0000 Subject: [PATCH 16/27] fixup: improve-energy-collection Python code reformatted using Ruff --- 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 6c900a5b3..591ec7d85 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -511,6 +511,7 @@ async def get_missing_energy_logs(self) -> None: t.cancel() # Drain cancellations to avoid "Task exception was never retrieved" from asyncio import gather as _gather + await _gather(*to_cancel, return_exceptions=True) break From bd0195d4799fa9cbdca8ae544efaf285b036dad3 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 08:02:28 +0200 Subject: [PATCH 17/27] Import gather --- plugwise_usb/nodes/circle.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 591ec7d85..7ad88e1e1 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 +from asyncio import Task, create_task, gather from collections.abc import Awaitable, Callable from dataclasses import replace from datetime import UTC, datetime @@ -510,9 +510,7 @@ async def get_missing_energy_logs(self) -> None: for t in to_cancel: t.cancel() # Drain cancellations to avoid "Task exception was never retrieved" - from asyncio import gather as _gather - - await _gather(*to_cancel, return_exceptions=True) + await gather(*to_cancel, return_exceptions=True) break if self._cache_enabled: From 34481b7887c3516cabb43efc8a45e65c97feda18 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 08:04:31 +0200 Subject: [PATCH 18/27] Clean up --- 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 7ad88e1e1..702979952 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -541,7 +541,6 @@ async def energy_log_update(self, address: int | None) -> bool: # Forward historical energy log information to energy counters # Each response message contains 4 log counters (slots) of the # energy pulses collected during the previous hour of given timestamp - last_energy_timestamp_collected = False for _slot in range(4, 0, -1): log_timestamp, log_pulses = response.log_data[_slot] _LOGGER.debug( From 501cfada6955848f317624b3e7b16b5683cdbf5b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 08:16:08 +0200 Subject: [PATCH 19/27] Add MAX_ADDRESSES_COLLECTED constant --- 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 702979952..b1add4091 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -74,6 +74,7 @@ DEFAULT_FIRMWARE: Final = datetime(2008, 8, 26, 15, 46, tzinfo=UTC) MAX_LOG_HOURS = DAY_IN_HOURS +MAX_ADDRESSES_COLLECTED: Final = 11 FuncT = TypeVar("FuncT", bound=Callable[..., Any]) _LOGGER = logging.getLogger(__name__) @@ -461,7 +462,7 @@ async def _get_initial_energy_logs(self) -> None: "Start collecting today's energy logs for node %s.", self._mac_in_str, ) - total_addresses = min(11, datetime.now(tz=UTC).hour + 1) + total_addresses = min(MAX_ADDRESSES_COLLECTED, datetime.now(tz=UTC).hour + 1) log_address = self._current_log_address while total_addresses > 0: result = await self.energy_log_update(log_address) From b10698cd9b30eb4938babd3fb25da0f755228ce1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 08:21:50 +0200 Subject: [PATCH 20/27] Update CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 713130353..1099fbbba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Ongoing + +- Improve energy-collection via PR [311](https://github.com/plugwise/python-plugwise-usb/pull/311) + ## v0.44.10 - 2025-08-11 - PR [302](https://github.com/plugwise/python-plugwise-usb/pull/302) Improve registry discovery and SED/SCAN configuration management From 0e2252aeec75e7f12634faeda6f444b995334ff5 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 11:54:58 +0200 Subject: [PATCH 21/27] Correct total_addresses calculation --- plugwise_usb/nodes/circle.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index b1add4091..f4dff4622 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -8,6 +8,7 @@ from datetime import UTC, datetime from functools import wraps import logging +from match import ceil from typing import Any, Final, TypeVar, cast from ..api import ( @@ -454,15 +455,23 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 return None async def _get_initial_energy_logs(self) -> None: - """Collect initial energy logs for up to 10 last log addresses or from the hours elapsed today.""" + """Collect initial energy logs for the hours elapsed today up to MAX_LOG_HOURS .""" if self._current_log_address is None: return + if self.energy_consumption_interval is None: + return + _LOGGER.debug( "Start collecting today's energy logs for node %s.", self._mac_in_str, ) - total_addresses = min(MAX_ADDRESSES_COLLECTED, datetime.now(tz=UTC).hour + 1) + + # When only consumption is measured, 1 address contains data from 4 hours + # When both consumption and production are measured, 1 address contains data from 2 hours + factor = 4 if self.energy_production_interval is not None else 2 + max_addresses_to_collect = int(MAX_LOG_HOURS / factor) + total_addresses = min(max_addresses_to_collect, ceil(datetime.now(tz=UTC).hour / factor) + 1) log_address = self._current_log_address while total_addresses > 0: result = await self.energy_log_update(log_address) From 399ffb45462a14ef504ca75b5b290224fcd2b292 Mon Sep 17 00:00:00 2001 From: autoruff Date: Wed, 13 Aug 2025 09:55:30 +0000 Subject: [PATCH 22/27] fixup: improve-energy-collection Python code reformatted using Ruff --- plugwise_usb/nodes/circle.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index f4dff4622..4d349f34f 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -471,7 +471,9 @@ async def _get_initial_energy_logs(self) -> None: # When both consumption and production are measured, 1 address contains data from 2 hours factor = 4 if self.energy_production_interval is not None else 2 max_addresses_to_collect = int(MAX_LOG_HOURS / factor) - total_addresses = min(max_addresses_to_collect, ceil(datetime.now(tz=UTC).hour / factor) + 1) + total_addresses = min( + max_addresses_to_collect, ceil(datetime.now(tz=UTC).hour / factor) + 1 + ) log_address = self._current_log_address while total_addresses > 0: result = await self.energy_log_update(log_address) From 677f678bbc134185558ac6ece1f629db3b512f1c Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 11:59:22 +0200 Subject: [PATCH 23/27] Fix typo --- 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 4d349f34f..5311f937e 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -8,7 +8,7 @@ from datetime import UTC, datetime from functools import wraps import logging -from match import ceil +from math import ceil from typing import Any, Final, TypeVar, cast from ..api import ( From 8378501a30812bc99f26f157495a3e337165a4c1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 12:30:33 +0200 Subject: [PATCH 24/27] Implement CRAI suggestions --- plugwise_usb/nodes/circle.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 5311f937e..43d5c4615 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -75,7 +75,6 @@ DEFAULT_FIRMWARE: Final = datetime(2008, 8, 26, 15, 46, tzinfo=UTC) MAX_LOG_HOURS = DAY_IN_HOURS -MAX_ADDRESSES_COLLECTED: Final = 11 FuncT = TypeVar("FuncT", bound=Callable[..., Any]) _LOGGER = logging.getLogger(__name__) @@ -455,7 +454,7 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 return None async def _get_initial_energy_logs(self) -> None: - """Collect initial energy logs for the hours elapsed today up to MAX_LOG_HOURS .""" + """Collect initial energy logs for the hours elapsed today up to MAX_LOG_HOURS.""" if self._current_log_address is None: return @@ -469,8 +468,8 @@ async def _get_initial_energy_logs(self) -> None: # When only consumption is measured, 1 address contains data from 4 hours # When both consumption and production are measured, 1 address contains data from 2 hours - factor = 4 if self.energy_production_interval is not None else 2 - max_addresses_to_collect = int(MAX_LOG_HOURS / factor) + factor = 4 if self.energy_production_interval is None else 2 + max_addresses_to_collect = MAX_LOG_HOURS // factor total_addresses = min( max_addresses_to_collect, ceil(datetime.now(tz=UTC).hour / factor) + 1 ) From 702af007a3cfcc517c71247edb8d723f40fe1ab6 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 13:09:48 +0200 Subject: [PATCH 25/27] Fix typo, improve docstring --- plugwise_usb/nodes/circle.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 43d5c4615..aacda7e39 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -560,7 +560,7 @@ async def energy_log_update(self, address: int | None) -> bool: if ( log_timestamp is None or log_pulses is None - # Don't store an old log record; store am empty record instead + # Don't store an old log record; store an empty record instead or not self._check_timestamp_is_recent(address, _slot, log_timestamp) ): self._energy_counters.add_empty_log(response.log_address, _slot) @@ -587,10 +587,7 @@ async def energy_log_update(self, address: int | None) -> bool: def _check_timestamp_is_recent( self, address: int, slot: int, timestamp: datetime ) -> bool: - """Check if the timestamp of the received log-record is recent. - - A timestamp newer than MAX_LOG_HOURS is considered recent. - """ + """Check if a log record timestamp is within the last MAX_LOG_HOURS hours.""" age_seconds = ( datetime.now(tz=UTC) - timestamp.replace(tzinfo=UTC) ).total_seconds() From 991743eabcadacf1ad7526db3c4e5563919e74d3 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 15:24:53 +0200 Subject: [PATCH 26/27] Implement another CRAI suggestion --- 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 aacda7e39..9193236c9 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -468,7 +468,8 @@ async def _get_initial_energy_logs(self) -> None: # When only consumption is measured, 1 address contains data from 4 hours # When both consumption and production are measured, 1 address contains data from 2 hours - factor = 4 if self.energy_production_interval is None else 2 + cons_only = self.energy_production_interval is None + factor = 4 if cons_only else 2 max_addresses_to_collect = MAX_LOG_HOURS // factor total_addresses = min( max_addresses_to_collect, ceil(datetime.now(tz=UTC).hour / factor) + 1 From f6c4d36e9f3e25690a66c8d51558ab64ad222fa5 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 18:18:30 +0200 Subject: [PATCH 27/27] Final CRAI suggestions --- plugwise_usb/nodes/circle.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 9193236c9..8d79d9c75 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -368,7 +368,7 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 # Always request last energy log records at initial startup if not self._last_energy_log_requested: self._last_energy_log_requested = await self.energy_log_update( - self._current_log_address + self._current_log_address, save_cache=False ) if self._energy_counters.log_rollover: @@ -381,7 +381,7 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 return None # Try collecting energy-stats for _current_log_address - result = await self.energy_log_update(self._current_log_address) + result = await self.energy_log_update(self._current_log_address, save_cache=True) if not result: _LOGGER.debug( "async_energy_update | %s | Log rollover | energy_log_update from address %s failed", @@ -394,7 +394,7 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 # Retry with previous log address as Circle node pointer to self._current_log_address # could be rolled over while the last log is at previous address/slot prev_log_address, _ = calc_log_address(self._current_log_address, 1, -4) - result = await self.energy_log_update(prev_log_address) + result = await self.energy_log_update(prev_log_address, save_cache=True) if not result: _LOGGER.debug( "async_energy_update | %s | Log rollover | energy_log_update from address %s failed", @@ -415,7 +415,7 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 return self._energy_counters.energy_statistics if len(missing_addresses) == 1: - result = await self.energy_log_update(missing_addresses[0]) + result = await self.energy_log_update(missing_addresses[0], save_cache=True) if result: await self.power_update() _LOGGER.debug( @@ -454,7 +454,7 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 return None async def _get_initial_energy_logs(self) -> None: - """Collect initial energy logs for the hours elapsed today up to MAX_LOG_HOURS.""" + """Collect initial energy logs for recent hours up to MAX_LOG_HOURS.""" if self._current_log_address is None: return @@ -476,7 +476,7 @@ async def _get_initial_energy_logs(self) -> None: ) log_address = self._current_log_address while total_addresses > 0: - result = await self.energy_log_update(log_address) + result = await self.energy_log_update(log_address, save_cache=False) if not result: # Stop initial log collection when an address contains no (None) or outdated data # Outdated data can indicate a EnergyLog address rollover: from address 6014 to 0 @@ -511,7 +511,7 @@ async def get_missing_energy_logs(self) -> None: ) missing_addresses = sorted(missing_addresses, reverse=True) tasks = [ - create_task(self.energy_log_update(address)) + create_task(self.energy_log_update(address, save_cache=False)) for address in missing_addresses ] for idx, task in enumerate(tasks): @@ -528,7 +528,7 @@ async def get_missing_energy_logs(self) -> None: if self._cache_enabled: await self._energy_log_records_save_to_cache() - async def energy_log_update(self, address: int | None) -> bool: + async def energy_log_update(self, address: int | None, save_cache: bool = True) -> bool: """Request energy logs and return True only when at least one recent, non-empty record was stored; otherwise return False.""" any_record_stored = False if address is None: @@ -577,7 +577,7 @@ async def energy_log_update(self, address: int | None) -> bool: any_record_stored = True self._energy_counters.update() - if any_record_stored and self._cache_enabled: + if any_record_stored and self._cache_enabled and save_cache: _LOGGER.debug( "Saving energy record update to cache for %s", self._mac_in_str ) @@ -589,9 +589,10 @@ def _check_timestamp_is_recent( self, address: int, slot: int, timestamp: datetime ) -> bool: """Check if a log record timestamp is within the last MAX_LOG_HOURS hours.""" - age_seconds = ( - datetime.now(tz=UTC) - timestamp.replace(tzinfo=UTC) - ).total_seconds() + age_seconds = max( + 0.0, + (datetime.now(tz=UTC) - timestamp.replace(tzinfo=UTC)).total_seconds() + ) if age_seconds > MAX_LOG_HOURS * 3600: _LOGGER.warning( "EnergyLog from Node %s | address %s | slot %s | timestamp %s is outdated, ignoring...",