From 78d973a00f8c9ad7f39dd86ea50657ee4edee5d8 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 9 Aug 2025 09:33:19 +0200 Subject: [PATCH 01/32] Add new energy_log_record at the front of the cache this fits better to the reverse sorting of the data in the cache --- 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 8d79d9c75..03b7dc31c 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -712,7 +712,7 @@ async def _energy_log_record_update_state( self._mac_in_str, ) self._set_cache( - CACHE_ENERGY_COLLECTION, cached_logs + "|" + log_cache_record + CACHE_ENERGY_COLLECTION, log_cache_record + "|" + cached_logs ) return True From 8daa87cfbd994641041b1c8ee59d27b5d85f5cf4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 9 Aug 2025 09:51:01 +0200 Subject: [PATCH 02/32] Remove double reverse sorting --- plugwise_usb/nodes/circle.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 03b7dc31c..e33e8c94e 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -671,9 +671,10 @@ async def _energy_log_records_save_to_cache(self) -> None: self._energy_counters.get_pulse_logs() ) cached_logs = "" - for address in sorted(logs.keys(), reverse=True): - for slot in sorted(logs[address].keys(), reverse=True): - log = logs[address][slot] + # logs is already sorted in reverse + for address, record in logs.items(): + for slot in record: + log = record[slot] if cached_logs != "": cached_logs += "|" cached_logs += f"{address}:{slot}:{log.timestamp.year}" From 3f6126da5a1255136166c1671390ac1aa20b3472 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 12 Aug 2025 20:15:57 +0200 Subject: [PATCH 03/32] Improve _energy_log_records_load_from_cache() --- plugwise_usb/nodes/circle.py | 55 +++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index e33e8c94e..c73cfe379 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -5,7 +5,7 @@ from asyncio import Task, create_task, gather from collections.abc import Awaitable, Callable from dataclasses import replace -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from functools import wraps import logging from math import ceil @@ -604,14 +604,14 @@ def _check_timestamp_is_recent( return False return True - async def _energy_log_records_load_from_cache(self) -> bool: + async def _energy_log_records_load_from_cache(self) -> bool: # noqa: PLR0912 """Load energy_log_record from cache.""" if (cache_data := self._get_cache(CACHE_ENERGY_COLLECTION)) is None: _LOGGER.warning( "Failed to restore energy log records from cache for node %s", self.name ) return False - restored_logs: dict[int, list[int]] = {} + restored_logs: dict[int, dict[int, tuple[datetime, int]]] = {} if cache_data == "": _LOGGER.debug("Cache-record is empty") return False @@ -624,24 +624,41 @@ async def _energy_log_records_load_from_cache(self) -> bool: if len(timestamp_energy_log) == 6: address = int(log_fields[0]) slot = int(log_fields[1]) - self._energy_counters.add_pulse_log( - address=address, - slot=slot, - timestamp=datetime( - year=int(timestamp_energy_log[0]), - month=int(timestamp_energy_log[1]), - day=int(timestamp_energy_log[2]), - hour=int(timestamp_energy_log[3]), - minute=int(timestamp_energy_log[4]), - second=int(timestamp_energy_log[5]), - tzinfo=UTC, - ), - pulses=int(log_fields[3]), - import_only=True, + pulses = int(log_fields[3]) + timestamp = datetime( + year=int(timestamp_energy_log[0]), + month=int(timestamp_energy_log[1]), + day=int(timestamp_energy_log[2]), + hour=int(timestamp_energy_log[3]), + minute=int(timestamp_energy_log[4]), + second=int(timestamp_energy_log[5]), + tzinfo=UTC, ) if restored_logs.get(address) is None: - restored_logs[address] = [] - restored_logs[address].append(slot) + restored_logs[address] = {} + restored_logs[address][slot] = (timestamp, pulses) + + # Sort and prune the records loaded from cache + sorted_logs: dict[int, dict[int, tuple[datetime, int]]] = {} + skip_before = datetime.now(tz=UTC) - timedelta(hours=DAY_IN_HOURS) + sorted_addresses = sorted(restored_logs.keys(), reverse=True) + for address in sorted_addresses: + sorted_slots = sorted(restored_logs[address].keys(), reverse=True) + for slot in sorted_slots: + if restored_logs[address][slot][0] > skip_before: + if sorted_logs.get(address) is None: + sorted_logs[address] = {} + sorted_logs[address][slot] = restored_logs[address][slot] + + for address, data in sorted_logs.items(): + for slot, pulse_data in data.items(): + self._energy_counters.add_pulse_log( + address=address, + slot=slot, + pulses=pulse_data[1], + timestamp=pulse_data[0], + import_only=True, + ) self._energy_counters.update() From 375551a9d7e96d0f20cb2d8bf39ba34b1097470e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 19:05:29 +0200 Subject: [PATCH 04/32] Implement CRAI suggestions --- plugwise_usb/nodes/circle.py | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index c73cfe379..bb838e6ad 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -640,7 +640,7 @@ async def _energy_log_records_load_from_cache(self) -> bool: # noqa: PLR0912 # Sort and prune the records loaded from cache sorted_logs: dict[int, dict[int, tuple[datetime, int]]] = {} - skip_before = datetime.now(tz=UTC) - timedelta(hours=DAY_IN_HOURS) + skip_before = datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) sorted_addresses = sorted(restored_logs.keys(), reverse=True) for address in sorted_addresses: sorted_slots = sorted(restored_logs[address].keys(), reverse=True) @@ -687,18 +687,17 @@ async def _energy_log_records_save_to_cache(self) -> None: logs: dict[int, dict[int, PulseLogRecord]] = ( self._energy_counters.get_pulse_logs() ) - cached_logs = "" - # logs is already sorted in reverse + # Efficiently serialize newest-first (logs is already sorted) + records: list[str] = [] for address, record in logs.items(): - for slot in record: - log = record[slot] - if cached_logs != "": - cached_logs += "|" - cached_logs += f"{address}:{slot}:{log.timestamp.year}" - cached_logs += f"-{log.timestamp.month}-{log.timestamp.day}" - cached_logs += f"-{log.timestamp.hour}-{log.timestamp.minute}" - cached_logs += f"-{log.timestamp.second}:{log.pulses}" - + for slot, log in record.items(): + records.append( + f"{address}:{slot}:{log.timestamp.year}" + f"-{log.timestamp.month}-{log.timestamp.day}" + f"-{log.timestamp.hour}-{log.timestamp.minute}" + f"-{log.timestamp.second}:{log.pulses}" + ) + cached_logs = "|".join(records) _LOGGER.debug("Saving energy logrecords to cache for %s", self._mac_in_str) self._set_cache(CACHE_ENERGY_COLLECTION, cached_logs) @@ -722,16 +721,20 @@ async def _energy_log_record_update_state( log_cache_record += f"-{timestamp.hour}-{timestamp.minute}" log_cache_record += f"-{timestamp.second}:{pulses}" if (cached_logs := self._get_cache(CACHE_ENERGY_COLLECTION)) is not None: - if log_cache_record not in cached_logs: + entries = cached_logs.split("|") if cached_logs else [] + if log_cache_record not in entries: _LOGGER.debug( "Adding logrecord (%s, %s) to cache of %s", str(address), str(slot), self._mac_in_str, ) - self._set_cache( - CACHE_ENERGY_COLLECTION, log_cache_record + "|" + cached_logs + new_cache = ( + f"{log_cache_record}|{cached_logs}" + if cached_logs + else log_cache_record ) + self._set_cache(CACHE_ENERGY_COLLECTION, new_cache) return True _LOGGER.debug( From 771f8ebba8cc0d41afa4d56801026e30c0b9826f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 19:06:56 +0200 Subject: [PATCH 05/32] Ruffed --- plugwise_usb/nodes/circle.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index bb838e6ad..15ec0f945 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -381,7 +381,9 @@ 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, save_cache=True) + 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", @@ -415,7 +417,9 @@ 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], save_cache=True) + result = await self.energy_log_update( + missing_addresses[0], save_cache=True + ) if result: await self.power_update() _LOGGER.debug( @@ -528,7 +532,9 @@ 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, save_cache: bool = True) -> 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: @@ -590,8 +596,7 @@ def _check_timestamp_is_recent( ) -> bool: """Check if a log record timestamp is within the last MAX_LOG_HOURS hours.""" age_seconds = max( - 0.0, - (datetime.now(tz=UTC) - timestamp.replace(tzinfo=UTC)).total_seconds() + 0.0, (datetime.now(tz=UTC) - timestamp.replace(tzinfo=UTC)).total_seconds() ) if age_seconds > MAX_LOG_HOURS * 3600: _LOGGER.warning( From d668c177176371ff65c257d9a5e419dc929d196a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 19:14:35 +0200 Subject: [PATCH 06/32] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1099fbbba..d1d41728c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Ongoing +- Improve reading from energy-logs cache via PR [314](https://github.com/plugwise/python-plugwise-usb/pull/314) - Improve energy-collection via PR [311](https://github.com/plugwise/python-plugwise-usb/pull/311) ## v0.44.10 - 2025-08-11 From d931a5085e0ab6c34a080c89df197bbee0be3745 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 19:15:42 +0200 Subject: [PATCH 07/32] Bump to v0.44.11a2 for testing --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cb97c1a36..d53598acc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "0.44.10" +version = "0.44.11a2" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 35e041a534865bbf0ff5bb2115a4255a47a02c80 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 19:27:35 +0200 Subject: [PATCH 08/32] Implement more CRAI suggestions --- plugwise_usb/nodes/circle.py | 37 +++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 15ec0f945..2978d5bdc 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -625,18 +625,25 @@ async def _energy_log_records_load_from_cache(self) -> bool: # noqa: PLR0912 for log_record in log_data: log_fields = log_record.split(":") if len(log_fields) == 4: - timestamp_energy_log = log_fields[2].split("-") - if len(timestamp_energy_log) == 6: - address = int(log_fields[0]) - slot = int(log_fields[1]) - pulses = int(log_fields[3]) + address = int(log_fields[0]) + slot = int(log_fields[1]) + pulses = int(log_fields[3]) + # Parse zero-padded timestamp, fallback to manual split + try: + timestamp = datetime.strptime( + log_fields[2], "%Y-%m-%d-%H-%M-%S" + ).replace(tzinfo=UTC) + except ValueError: + parts = log_fields[2].split("-") + if len(parts) != 6: + continue timestamp = datetime( - year=int(timestamp_energy_log[0]), - month=int(timestamp_energy_log[1]), - day=int(timestamp_energy_log[2]), - hour=int(timestamp_energy_log[3]), - minute=int(timestamp_energy_log[4]), - second=int(timestamp_energy_log[5]), + year=int(parts[0]), + month=int(parts[1]), + day=int(parts[2]), + hour=int(parts[3]), + minute=int(parts[4]), + second=int(parts[5]), tzinfo=UTC, ) if restored_logs.get(address) is None: @@ -696,15 +703,15 @@ async def _energy_log_records_save_to_cache(self) -> None: records: list[str] = [] for address, record in logs.items(): for slot, log in record.items(): + ts = log.timestamp records.append( - f"{address}:{slot}:{log.timestamp.year}" - f"-{log.timestamp.month}-{log.timestamp.day}" - f"-{log.timestamp.hour}-{log.timestamp.minute}" - f"-{log.timestamp.second}:{log.pulses}" + f"{address}:{slot}:{ts.strftime('%Y-%m-%d-%H-%M-%S')}:{log.pulses}" ) cached_logs = "|".join(records) _LOGGER.debug("Saving energy logrecords to cache for %s", self._mac_in_str) self._set_cache(CACHE_ENERGY_COLLECTION, cached_logs) + # Persist new cache entries to disk immediately + await self.save_cache(trigger_only=True) async def _energy_log_record_update_state( self, From 136f9efa4c256221158639a2f69e9e7d3f52c834 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 19:29:05 +0200 Subject: [PATCH 09/32] Bump to a3 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d53598acc..84104aa6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "0.44.11a2" +version = "0.44.11a3" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From c9e76062a9498afbb67de68769b8336c67626656 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 20:12:07 +0200 Subject: [PATCH 10/32] Fix wrong ident --- plugwise_usb/nodes/circle.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 2978d5bdc..4fb792181 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -646,9 +646,9 @@ async def _energy_log_records_load_from_cache(self) -> bool: # noqa: PLR0912 second=int(parts[5]), tzinfo=UTC, ) - if restored_logs.get(address) is None: - restored_logs[address] = {} - restored_logs[address][slot] = (timestamp, pulses) + if restored_logs.get(address) is None: + restored_logs[address] = {} + restored_logs[address][slot] = (timestamp, pulses) # Sort and prune the records loaded from cache sorted_logs: dict[int, dict[int, tuple[datetime, int]]] = {} From 1a99b5f672392c4eed08f5339ae2bbb4a6e616df Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 13 Aug 2025 20:14:52 +0200 Subject: [PATCH 11/32] Bump to a4 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 84104aa6a..a35189a3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "0.44.11a3" +version = "0.44.11a4" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From b6f4e8160f9735f5d7727f6580877fdcaa8dc6a6 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 10:16:13 +0200 Subject: [PATCH 12/32] energy_log_update(): revert some changes --- plugwise_usb/nodes/circle.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 4fb792181..b06481857 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -536,7 +536,6 @@ 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: return False @@ -555,6 +554,7 @@ async def energy_log_update( _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 @@ -580,16 +580,16 @@ async def energy_log_update( log_pulses, import_only=True, ) - any_record_stored = True + energy_record_update = True self._energy_counters.update() - if any_record_stored and self._cache_enabled and save_cache: + if energy_record_update and self._cache_enabled and save_cache: _LOGGER.debug( "Saving energy record update to cache for %s", self._mac_in_str ) await self.save_cache() - return any_record_stored + return True def _check_timestamp_is_recent( self, address: int, slot: int, timestamp: datetime From 02944f553ca9c276b09e9959c72520d5df1ab447 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 10:17:55 +0200 Subject: [PATCH 13/32] 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 b06481857..d12a2632a 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -580,7 +580,7 @@ async def energy_log_update( log_pulses, import_only=True, ) - energy_record_update = True + energy_record_update = True self._energy_counters.update() if energy_record_update and self._cache_enabled and save_cache: From 8c7128bd931f9497ef02a37cfb04b406a9df3d10 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 10:22:00 +0200 Subject: [PATCH 14/32] Bump to a5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a35189a3e..c22999446 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "0.44.11a4" +version = "0.44.11a5" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 160bf900614c5a385ff74969cf6f41706b23df84 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 13:03:15 +0200 Subject: [PATCH 15/32] Break out _collect_records() function --- plugwise_usb/nodes/circle.py | 66 ++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index d12a2632a..7f225cb05 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -80,6 +80,41 @@ _LOGGER = logging.getLogger(__name__) +def _collect_records(data: str) -> dict[int, dict[int, tuple[datetime, int]]]: + """Collect logs from a cache data string.""" + logs: dict[int, dict[int, tuple[datetime, int]]] = {} + log_data = data.split("|") + for log_record in log_data: + log_fields = log_record.split(":") + if len(log_fields) == 4: + address = int(log_fields[0]) + slot = int(log_fields[1]) + pulses = int(log_fields[3]) + # Parse zero-padded timestamp, fallback to manual split + try: + timestamp = datetime.strptime( + log_fields[2], "%Y-%m-%d-%H-%M-%S" + ).replace(tzinfo=UTC) + except ValueError: + parts = log_fields[2].split("-") + if len(parts) != 6: + continue + timestamp = datetime( + year=int(parts[0]), + month=int(parts[1]), + day=int(parts[2]), + hour=int(parts[3]), + minute=int(parts[4]), + second=int(parts[5]), + tzinfo=UTC, + ) + if logs.get(address) is None: + logs[address] = {} + logs[address][slot] = (timestamp, pulses) + + return logs + + def raise_calibration_missing(func: FuncT) -> FuncT: """Validate energy calibration settings are available.""" @@ -616,40 +651,11 @@ async def _energy_log_records_load_from_cache(self) -> bool: # noqa: PLR0912 "Failed to restore energy log records from cache for node %s", self.name ) return False - restored_logs: dict[int, dict[int, tuple[datetime, int]]] = {} if cache_data == "": _LOGGER.debug("Cache-record is empty") return False - log_data = cache_data.split("|") - for log_record in log_data: - log_fields = log_record.split(":") - if len(log_fields) == 4: - address = int(log_fields[0]) - slot = int(log_fields[1]) - pulses = int(log_fields[3]) - # Parse zero-padded timestamp, fallback to manual split - try: - timestamp = datetime.strptime( - log_fields[2], "%Y-%m-%d-%H-%M-%S" - ).replace(tzinfo=UTC) - except ValueError: - parts = log_fields[2].split("-") - if len(parts) != 6: - continue - timestamp = datetime( - year=int(parts[0]), - month=int(parts[1]), - day=int(parts[2]), - hour=int(parts[3]), - minute=int(parts[4]), - second=int(parts[5]), - tzinfo=UTC, - ) - if restored_logs.get(address) is None: - restored_logs[address] = {} - restored_logs[address][slot] = (timestamp, pulses) - + restored_logs = _collect_records(cache_data) # Sort and prune the records loaded from cache sorted_logs: dict[int, dict[int, tuple[datetime, int]]] = {} skip_before = datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) From 058b0d2aa300361f6011cddedb04a7b3c10cc886 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 13:03:54 +0200 Subject: [PATCH 16/32] Remove noqa --- 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 7f225cb05..dbd1ca4b6 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -644,7 +644,7 @@ def _check_timestamp_is_recent( return False return True - async def _energy_log_records_load_from_cache(self) -> bool: # noqa: PLR0912 + async def _energy_log_records_load_from_cache(self) -> bool: """Load energy_log_record from cache.""" if (cache_data := self._get_cache(CACHE_ENERGY_COLLECTION)) is None: _LOGGER.warning( From 4e1bc5478719a505f1da4c8cf7f0bc9be59d07d5 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 13:42:26 +0200 Subject: [PATCH 17/32] Improve _energy_log_record_update_state() --- plugwise_usb/nodes/circle.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index dbd1ca4b6..33e36051d 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -734,10 +734,9 @@ async def _energy_log_record_update_state( if not self._cache_enabled: return False - log_cache_record = f"{address}:{slot}:{timestamp.year}" - log_cache_record += f"-{timestamp.month}-{timestamp.day}" - log_cache_record += f"-{timestamp.hour}-{timestamp.minute}" - log_cache_record += f"-{timestamp.second}:{pulses}" + log_cache_record = ( + f"{address}:{slot}:{timestamp.strftime('%Y-%m-%d-%H-%M-%S')}:{pulses}" + ) if (cached_logs := self._get_cache(CACHE_ENERGY_COLLECTION)) is not None: entries = cached_logs.split("|") if cached_logs else [] if log_cache_record not in entries: @@ -753,6 +752,7 @@ async def _energy_log_record_update_state( else log_cache_record ) self._set_cache(CACHE_ENERGY_COLLECTION, new_cache) + await self.save_cache(trigger_only=True) return True _LOGGER.debug( From 19ca62344fdcfd650c9997dd949c20636cd3876f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 13:45:49 +0200 Subject: [PATCH 18/32] Improve var-name --- plugwise_usb/nodes/circle.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 33e36051d..681bc6244 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -655,18 +655,18 @@ async def _energy_log_records_load_from_cache(self) -> bool: _LOGGER.debug("Cache-record is empty") return False - restored_logs = _collect_records(cache_data) + collected_logs = _collect_records(cache_data) # Sort and prune the records loaded from cache sorted_logs: dict[int, dict[int, tuple[datetime, int]]] = {} skip_before = datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) - sorted_addresses = sorted(restored_logs.keys(), reverse=True) + sorted_addresses = sorted(collected_logs.keys(), reverse=True) for address in sorted_addresses: - sorted_slots = sorted(restored_logs[address].keys(), reverse=True) + sorted_slots = sorted(collected_logs[address].keys(), reverse=True) for slot in sorted_slots: - if restored_logs[address][slot][0] > skip_before: + if collected_logs[address][slot][0] > skip_before: if sorted_logs.get(address) is None: sorted_logs[address] = {} - sorted_logs[address][slot] = restored_logs[address][slot] + sorted_logs[address][slot] = collected_logs[address][slot] for address, data in sorted_logs.items(): for slot, pulse_data in data.items(): From 9e0a3beb9695d2b2b719713049dafddd1aa86d99 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 14:08:17 +0200 Subject: [PATCH 19/32] Optimized code by ChatGPT --- plugwise_usb/nodes/circle.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 681bc6244..abf0f41e8 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -656,25 +656,24 @@ async def _energy_log_records_load_from_cache(self) -> bool: return False collected_logs = _collect_records(cache_data) - # Sort and prune the records loaded from cache - sorted_logs: dict[int, dict[int, tuple[datetime, int]]] = {} + + # Cutoff timestamp for filtering skip_before = datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) - sorted_addresses = sorted(collected_logs.keys(), reverse=True) - for address in sorted_addresses: - sorted_slots = sorted(collected_logs[address].keys(), reverse=True) - for slot in sorted_slots: - if collected_logs[address][slot][0] > skip_before: - if sorted_logs.get(address) is None: - sorted_logs[address] = {} - sorted_logs[address][slot] = collected_logs[address][slot] - - for address, data in sorted_logs.items(): - for slot, pulse_data in data.items(): + + # Iterate in reverse sorted order directly + for address in sorted(collected_logs, reverse=True): + slots = collected_logs[address] + filtered_slots = { + slot: data + for slot, data in sorted(slots.items(), reverse=True) + if data[0] > skip_before + } + for slot, (timestamp, pulses) in filtered_slots.items(): self._energy_counters.add_pulse_log( address=address, slot=slot, - pulses=pulse_data[1], - timestamp=pulse_data[0], + pulses=pulses, + timestamp=timestamp, import_only=True, ) From 6ad85234a19d3fea212842e8e63914579ef3d2e3 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 14:19:15 +0200 Subject: [PATCH 20/32] Optimize energy_log_update() further --- 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 abf0f41e8..8696de7ee 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -589,7 +589,6 @@ async def energy_log_update( _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 @@ -608,17 +607,16 @@ async def energy_log_update( self._energy_counters.add_empty_log(response.log_address, _slot) continue - await self._energy_log_record_update_state( + cache_updated = 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 self._energy_counters.update() - if energy_record_update and self._cache_enabled and save_cache: + if cache_updated and save_cache: _LOGGER.debug( "Saving energy record update to cache for %s", self._mac_in_str ) From 8d7daa777430c496dbb893acb6f4a382760e28a7 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 14:39:03 +0200 Subject: [PATCH 21/32] More optimization as suggested by @dirixmjm --- plugwise_usb/nodes/circle.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 8696de7ee..862216709 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -660,20 +660,17 @@ async def _energy_log_records_load_from_cache(self) -> bool: # Iterate in reverse sorted order directly for address in sorted(collected_logs, reverse=True): - slots = collected_logs[address] - filtered_slots = { - slot: data - for slot, data in sorted(slots.items(), reverse=True) - if data[0] > skip_before - } - for slot, (timestamp, pulses) in filtered_slots.items(): + for slot in range(4,0,-1): + (timestamp, pulses) = collected_logs[address][slot] + if timestamp > skip_before: + continue self._energy_counters.add_pulse_log( address=address, slot=slot, pulses=pulses, timestamp=timestamp, import_only=True, - ) + ) self._energy_counters.update() From c4e0e74ac2f6c2b9548c97723d7ced8e40baf17c Mon Sep 17 00:00:00 2001 From: autoruff Date: Thu, 14 Aug 2025 12:39:42 +0000 Subject: [PATCH 22/32] fixup: improve-energy-caching Python code reformatted using Ruff --- 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 862216709..5a5e2423a 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -660,7 +660,7 @@ async def _energy_log_records_load_from_cache(self) -> bool: # Iterate in reverse sorted order directly for address in sorted(collected_logs, reverse=True): - for slot in range(4,0,-1): + for slot in range(4, 0, -1): (timestamp, pulses) = collected_logs[address][slot] if timestamp > skip_before: continue @@ -670,7 +670,7 @@ async def _energy_log_records_load_from_cache(self) -> bool: pulses=pulses, timestamp=timestamp, import_only=True, - ) + ) self._energy_counters.update() From 7f9255d956e91b032699df3233b5d2ca56f204b2 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 14:53:57 +0200 Subject: [PATCH 23/32] CRAI nitpick --- plugwise_usb/nodes/circle.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 5a5e2423a..807b6c53e 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -108,9 +108,10 @@ def _collect_records(data: str) -> dict[int, dict[int, tuple[datetime, int]]]: second=int(parts[5]), tzinfo=UTC, ) - if logs.get(address) is None: - logs[address] = {} - logs[address][slot] = (timestamp, pulses) + bucket = logs.setdefault(address, {}) + # Keep the first occurrence (cache is newest-first), skip older duplicates + if slot not in bucket: + bucket[slot] = (timestamp, pulses) return logs From c5d044aeba410ac3485b093b0df46e02ad87ba15 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 14:58:51 +0200 Subject: [PATCH 24/32] Fix inverted filtering as suggested by CRAI --- 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 807b6c53e..ed9a4b6fd 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -663,7 +663,7 @@ async def _energy_log_records_load_from_cache(self) -> bool: for address in sorted(collected_logs, reverse=True): for slot in range(4, 0, -1): (timestamp, pulses) = collected_logs[address][slot] - if timestamp > skip_before: + if timestamp <= skip_before: continue self._energy_counters.add_pulse_log( address=address, From 5b5b3a6f567c4842f984456087d5a3301386302b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 15:02:39 +0200 Subject: [PATCH 25/32] Bump to a6 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c22999446..146cc2678 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "0.44.11a5" +version = "0.44.11a6" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From bbeedb208feeb0a569304453e36123f613069090 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 15:21:28 +0200 Subject: [PATCH 26/32] Follow CRAI suggestion fully --- 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 ed9a4b6fd..e2ef01bd2 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -662,7 +662,11 @@ async def _energy_log_records_load_from_cache(self) -> bool: # Iterate in reverse sorted order directly for address in sorted(collected_logs, reverse=True): for slot in range(4, 0, -1): - (timestamp, pulses) = collected_logs[address][slot] + bucket = collected_logs.get(address, {}) + if slot not in bucket: + continue + (timestamp, pulses) = bucket[slot] + # Keep only recent entries; prune older-or-equal than cutoff if timestamp <= skip_before: continue self._energy_counters.add_pulse_log( From c1dbf8c0f83069c65b8fa0de32f233dc4d564a4e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 15:21:50 +0200 Subject: [PATCH 27/32] Bump to a7 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 146cc2678..3cd2dd566 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "0.44.11a6" +version = "0.44.11a7" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 495031819d025e574673c3db2bc7b65738072c62 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 15:34:44 +0200 Subject: [PATCH 28/32] Revert back to sorted slots --- 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 e2ef01bd2..690404c8f 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -661,11 +661,8 @@ async def _energy_log_records_load_from_cache(self) -> bool: # Iterate in reverse sorted order directly for address in sorted(collected_logs, reverse=True): - for slot in range(4, 0, -1): - bucket = collected_logs.get(address, {}) - if slot not in bucket: - continue - (timestamp, pulses) = bucket[slot] + for slot in sorted(collected_logs[address].keys(), reverse=True): + (timestamp, pulses) = collected_logs[address][slot] # Keep only recent entries; prune older-or-equal than cutoff if timestamp <= skip_before: continue From 1225a1aeeca70fceaf5ae5564b273c5a2f7aac01 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 17:19:25 +0200 Subject: [PATCH 29/32] Set init for cache_updated bool --- 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 690404c8f..07a20774e 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -594,6 +594,7 @@ async def energy_log_update( # 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 + cache_updated = False for _slot in range(4, 0, -1): log_timestamp, log_pulses = response.log_data[_slot] _LOGGER.debug( From 98f5368064e668122c10b81ad5309f8d67aea1ff Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 17:23:52 +0200 Subject: [PATCH 30/32] Update CHANGELOG for v0.44.11 release --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1d41728c..6093479c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Ongoing +## v0.44.11 - 2025-08-14 - Improve reading from energy-logs cache via PR [314](https://github.com/plugwise/python-plugwise-usb/pull/314) - Improve energy-collection via PR [311](https://github.com/plugwise/python-plugwise-usb/pull/311) From 6bec5315f489777c2cc5c83756006dfe28d48be0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 19:03:44 +0200 Subject: [PATCH 31/32] Bump to a8 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3cd2dd566..2840f0420 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "0.44.11a7" +version = "0.44.11a8" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From fa0f93f54df58f779bd3f892c127c9463a5d7315 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 14 Aug 2025 19:21:33 +0200 Subject: [PATCH 32/32] Change logger to info --- 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 07a20774e..8e644a26b 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -634,7 +634,7 @@ def _check_timestamp_is_recent( 0.0, (datetime.now(tz=UTC) - timestamp.replace(tzinfo=UTC)).total_seconds() ) if age_seconds > MAX_LOG_HOURS * 3600: - _LOGGER.warning( + _LOGGER.info( "EnergyLog from Node %s | address %s | slot %s | timestamp %s is outdated, ignoring...", self._mac_in_str, address,