diff --git a/franklinwh/client.py b/franklinwh/client.py index 0d40166..c1ed32a 100644 --- a/franklinwh/client.py +++ b/franklinwh/client.py @@ -5,9 +5,9 @@ """ from __future__ import annotations -from collections.abc import Callable import asyncio +from collections.abc import Callable from dataclasses import dataclass from enum import Enum import hashlib @@ -19,6 +19,7 @@ import httpx from .api import DEFAULT_URL_BASE +from .time_cached import time_cached class AccessoryType(Enum): @@ -403,7 +404,7 @@ class GatewayOfflineException(Exception): class InvalidDataException(Exception): - """raised when the API returns data that is structurally invalid""" + """raised when the API returns data that is structurally invalid.""" class HttpClientFactory: @@ -649,12 +650,14 @@ def set_value(keys, value): return json.loads(data) # Sends a 203 which is a high level status + @time_cached() async def _status(self): payload = self._build_payload(203, {"opt": 1, "refreshData": 1}) data = (await self._mqtt_send(payload))["result"]["dataArea"] return json.loads(data) # Sends a 311 which appears to be a more specific switch command + @time_cached() async def _switch_status(self): payload = self._build_payload(311, {"opt": 0, "order": self.gateway}) data = (await self._mqtt_send(payload))["result"]["dataArea"] @@ -662,6 +665,7 @@ async def _switch_status(self): # Sends a 353 which grabs real-time smart-circuit load information # https://github.com/richo/homeassistant-franklinwh/issues/27#issuecomment-2714422732 + @time_cached() async def _switch_usage(self): payload = self._build_payload(353, {"opt": 0, "order": self.gateway}) data = (await self._mqtt_send(payload))["result"]["dataArea"] @@ -841,13 +845,15 @@ async def set_export_settings( discharge_max = 0.0 payload = {k: v for k, v in current.items() if v is not None} - payload.update({ - "gatewayId": self.gateway, - "lang": "EN_US", - "gridFeedMaxFlag": mode.value, - "gridFeedMax": feed_max, - "globalGridDischargeMax": discharge_max, - }) + payload.update( + { + "gatewayId": self.gateway, + "lang": "EN_US", + "gridFeedMaxFlag": mode.value, + "gridFeedMax": feed_max, + "globalGridDischargeMax": discharge_max, + } + ) res = await self.session.post( set_url, @@ -859,6 +865,7 @@ async def set_export_settings( if body.get("code") != 200: raise RuntimeError(f"set_export_settings failed: {body}") + @time_cached() async def get_composite_info(self): """Get composite information about the FranklinWH gateway.""" url = self.url_base + "hes-gateway/terminal/getDeviceCompositeInfo" diff --git a/franklinwh/time_cached.py b/franklinwh/time_cached.py new file mode 100644 index 0000000..d725f5a --- /dev/null +++ b/franklinwh/time_cached.py @@ -0,0 +1,35 @@ +"""Cache a function result for a specified time. + +This design provides a locked cache PER DECORATOR INSTANCE so SHOULD apply +only to functions that are definitely called periodically, otherwise it may +cache arguments and results indefinitely, think 'self' for member functions. +""" + +import asyncio +from datetime import datetime, timedelta +from functools import wraps + + +def time_cached(ttl: timedelta = timedelta(seconds=2)): + """Decorator to cache function results for a specified time-to-live (TTL).""" + + def wrapper(func): + __cache = {} + __lock = asyncio.Lock() + + @wraps(func) + async def wrapped(*args, **kwargs): + async with __lock: + now = datetime.now() + for key, value in __cache.copy().items(): + if now > value[0]: + del __cache[key] + key = (args, frozenset(kwargs.items())) + if key not in __cache: + __cache[key] = (now + ttl, await func(*args, **kwargs)) + return __cache[key][1] + + setattr(wrapped, "clear", __cache.clear) + return wrapped + + return wrapper