diff --git a/setup.py b/setup.py index 7ac97db..56e8373 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ def requirements_from_file(filename="requirements.txt"): setup( - version="1.0.8.dev10", + version="1.0.8", packages=["apyhiveapi", "apyhiveapi.api", "apyhiveapi.helper"], package_dir={"apyhiveapi": "src"}, package_data={"data": ["*.json"]}, diff --git a/src/action.py b/src/action.py index abfcd2b..365e62f 100644 --- a/src/action.py +++ b/src/action.py @@ -32,6 +32,14 @@ async def getAction(self, device: dict): Returns: dict: Updated device. """ + if self.session.shouldUseCachedData(): + cached = self.session.getCachedDevice(device) + if cached is not None: + _LOGGER.debug( + "Returning cached state for action %s (slow/busy poll).", + device["haName"], + ) + return cached dev_data = {} if device["hiveID"] in self.data["action"]: @@ -47,8 +55,7 @@ async def getAction(self, device: dict): "custom": device.get("custom", None), } - self.session.devices.update({device["hiveID"]: dev_data}) - return self.session.devices[device["hiveID"]] + return self.session.setCachedDevice(device, dev_data) else: exists = self.session.data.actions.get("hiveID", False) if exists is False: diff --git a/src/alarm.py b/src/alarm.py index 2108962..f813ae3 100644 --- a/src/alarm.py +++ b/src/alarm.py @@ -96,6 +96,14 @@ async def getAlarm(self, device: dict): Returns: dict: Updated device. """ + if self.session.shouldUseCachedData(): + cached = self.session.getCachedDevice(device) + if cached is not None: + _LOGGER.debug( + "Returning cached state for alarm %s (slow/busy poll).", + device["haName"], + ) + return cached device["deviceData"].update( {"online": await self.session.attr.onlineOffline(device["device_id"])} ) @@ -125,8 +133,7 @@ async def getAlarm(self, device: dict): ), } - self.session.devices.update({device["hiveID"]: dev_data}) - return self.session.devices[device["hiveID"]] + return self.session.setCachedDevice(device, dev_data) else: await self.session.helper.errorCheck( device["device_id"], "ERROR", device["deviceData"]["online"] diff --git a/src/api/hive_auth_async.py b/src/api/hive_auth_async.py index 0bef108..e1532b3 100644 --- a/src/api/hive_auth_async.py +++ b/src/api/hive_auth_async.py @@ -425,9 +425,9 @@ async def login(self): self.device_group_key = result["AuthenticationResult"][ "NewDeviceMetadata" ]["DeviceGroupKey"] - self.device_key = result["AuthenticationResult"][ - "NewDeviceMetadata" - ]["DeviceKey"] + self.device_key = result["AuthenticationResult"]["NewDeviceMetadata"][ + "DeviceKey" + ] _LOGGER.debug("SRP auth challenge completed successfully.") return result diff --git a/src/camera.py b/src/camera.py index 4b3de6f..15e2661 100644 --- a/src/camera.py +++ b/src/camera.py @@ -152,6 +152,14 @@ async def getCamera(self, device: dict): Returns: dict: Updated device. """ + if self.session.shouldUseCachedData(): + cached = self.session.getCachedDevice(device) + if cached is not None: + _LOGGER.debug( + "Returning cached state for camera %s (slow/busy poll).", + device["haName"], + ) + return cached device["deviceData"].update( {"online": await self.session.attr.onlineOffline(device["device_id"])} ) @@ -183,8 +191,7 @@ async def getCamera(self, device: dict): ), } - self.session.devices.update({device["hiveID"]: dev_data}) - return self.session.devices[device["hiveID"]] + return self.session.setCachedDevice(device, dev_data) else: await self.session.helper.errorCheck( device["device_id"], "ERROR", device["deviceData"]["online"] diff --git a/src/heating.py b/src/heating.py index d2bcc67..3a9cfd2 100644 --- a/src/heating.py +++ b/src/heating.py @@ -476,6 +476,14 @@ async def getClimate(self, device: dict): Returns: dict: Updated device. """ + if self.session.shouldUseCachedData(): + cached = self.session.getCachedDevice(device) + if cached is not None: + _LOGGER.debug( + "Returning cached state for climate %s (slow/busy poll).", + device["haName"], + ) + return cached device["deviceData"].update( {"online": await self.session.attr.onlineOffline(device["device_id"])} ) @@ -510,20 +518,11 @@ async def getClimate(self, device: dict): device["device_id"], device["hiveType"] ), } - self.session.devices.update({device["hiveID"]: dev_data}) - return self.session.devices[device["hiveID"]] + return self.session.setCachedDevice(device, dev_data) else: await self.session.helper.errorCheck( device["device_id"], "ERROR", device["deviceData"]["online"] ) - if self.session._lastPollSlow: - cached = self.session.devices.get(device["hiveID"]) - if cached is not None: - _LOGGER.debug( - "Returning cached state for offline climate %s (slow poll).", - device["haName"], - ) - return cached device.setdefault( "status", { diff --git a/src/helper/hive_helper.py b/src/helper/hive_helper.py index 3ada8b7..77ab690 100644 --- a/src/helper/hive_helper.py +++ b/src/helper/hive_helper.py @@ -84,13 +84,11 @@ def getDeviceFromID(self, n_id: str): Returns: dict: Device data. """ - data = False - try: - data = self.session.devices[n_id] - except KeyError: - pass - - return data + if hasattr(self.session, "entityCache"): + for cached in self.session.entityCache.values(): + if cached.get("hiveID") == n_id or cached.get("device_id") == n_id: + return cached + return False def getDeviceData(self, product: dict): """Get device from product data. diff --git a/src/hotwater.py b/src/hotwater.py index bd57e27..3976ae7 100644 --- a/src/hotwater.py +++ b/src/hotwater.py @@ -221,7 +221,7 @@ def __init__(self, session: object = None): """ self.session = session - async def getWaterHeater(self, device: dict): + async def getWaterHeater(self, device: dict): """Update water heater device. Args: @@ -230,11 +230,19 @@ async def getWaterHeater(self, device: dict): Returns: dict: Updated device. """ - device["deviceData"].update( - {"online": await self.session.attr.onlineOffline(device["device_id"])} - ) - - if device["deviceData"]["online"]: + if self.session.shouldUseCachedData(): + cached = self.session.getCachedDevice(device) + if cached is not None: + _LOGGER.debug( + "Returning cached state for water heater %s (slow/busy poll).", + device["haName"], + ) + return cached + device["deviceData"].update( + {"online": await self.session.attr.onlineOffline(device["device_id"])} + ) + + if device["deviceData"]["online"]: dev_data = {} self.session.helper.deviceRecovered(device["device_id"]) @@ -257,22 +265,13 @@ async def getWaterHeater(self, device: dict): ), } - self.session.devices.update({device["hiveID"]: dev_data}) - return self.session.devices[device["hiveID"]] - else: - await self.session.helper.errorCheck( - device["device_id"], "ERROR", device["deviceData"]["online"] - ) - if self.session._lastPollSlow: - cached = self.session.devices.get(device["hiveID"]) - if cached is not None: - _LOGGER.debug( - "Returning cached state for offline water heater %s (slow poll).", - device["haName"], - ) - return cached - device.setdefault("status", {"current_operation": None}) - return device + return self.session.setCachedDevice(device, dev_data) + else: + await self.session.helper.errorCheck( + device["device_id"], "ERROR", device["deviceData"]["online"] + ) + device.setdefault("status", {"current_operation": None}) + return device async def getScheduleNowNextLater(self, device: dict): """Hive get hotwater schedule now, next and later. diff --git a/src/light.py b/src/light.py index 7bfd60c..b5ea001 100644 --- a/src/light.py +++ b/src/light.py @@ -359,6 +359,14 @@ async def getLight(self, device: dict): Returns: dict: Updated device. """ + if self.session.shouldUseCachedData(): + cached = self.session.getCachedDevice(device) + if cached is not None: + _LOGGER.debug( + "Returning cached state for light %s (slow/busy poll).", + device["haName"], + ) + return cached device["deviceData"].update( {"online": await self.session.attr.onlineOffline(device["device_id"])} ) @@ -414,20 +422,11 @@ async def getLight(self, device: dict): } ) - self.session.devices.update({device["hiveID"]: dev_data}) - return self.session.devices[device["hiveID"]] + return self.session.setCachedDevice(device, dev_data) else: await self.session.helper.errorCheck( device["device_id"], "ERROR", device["deviceData"]["online"] ) - if self.session._lastPollSlow: - cached = self.session.devices.get(device["hiveID"]) - if cached is not None: - _LOGGER.debug( - "Returning cached state for light %s (slow poll).", - device["haName"], - ) - return cached device.setdefault("status", {"state": None}) return device diff --git a/src/plug.py b/src/plug.py index cdf7064..46d6c78 100644 --- a/src/plug.py +++ b/src/plug.py @@ -135,6 +135,14 @@ async def getSwitch(self, device: dict): Returns: dict: Return device after update is complete. """ + if self.session.shouldUseCachedData(): + cached = self.session.getCachedDevice(device) + if cached is not None: + _LOGGER.debug( + "Returning cached state for switch %s (slow/busy poll).", + device["haName"], + ) + return cached device["deviceData"].update( {"online": await self.session.attr.onlineOffline(device["device_id"])} ) @@ -174,20 +182,11 @@ async def getSwitch(self, device: dict): } ) - self.session.devices.update({device["hiveID"]: dev_data}) - return self.session.devices[device["hiveID"]] + return self.session.setCachedDevice(device, dev_data) else: await self.session.helper.errorCheck( device["device_id"], "ERROR", device["deviceData"]["online"] ) - if self.session._lastPollSlow: - cached = self.session.devices.get(device["hiveID"]) - if cached is not None: - _LOGGER.debug( - "Returning cached state for offline switch %s (slow poll).", - device["haName"], - ) - return cached device.setdefault("status", {"state": None}) return device diff --git a/src/sensor.py b/src/sensor.py index e0ba2a7..296d16a 100644 --- a/src/sensor.py +++ b/src/sensor.py @@ -74,8 +74,8 @@ def __init__(self, session: object = None): """ self.session = session - async def getSensor(self, device: dict): - """Gets updated sensor data. + async def getSensor(self, device: dict): + """Gets updated sensor data. Args: device (dict): Device to update. @@ -83,10 +83,18 @@ async def getSensor(self, device: dict): Returns: dict: Updated device. """ - device["deviceData"].update( - {"online": await self.session.attr.onlineOffline(device["device_id"])} - ) - data = {} + if self.session.shouldUseCachedData(): + cached = self.session.getCachedDevice(device) + if cached is not None: + _LOGGER.debug( + "Returning cached state for sensor %s (slow/busy poll).", + device["haName"], + ) + return cached + device["deviceData"].update( + {"online": await self.session.attr.onlineOffline(device["device_id"])} + ) + data = {} if device["deviceData"]["online"] or device["hiveType"] in ( "Availability", @@ -146,19 +154,10 @@ async def getSensor(self, device: dict): } ) - self.session.devices.update({device["hiveID"]: dev_data}) - return self.session.devices[device["hiveID"]] - else: - await self.session.helper.errorCheck( - device["device_id"], "ERROR", device["deviceData"]["online"] - ) - if self.session._lastPollSlow: - cached = self.session.devices.get(device["hiveID"]) - if cached is not None: - _LOGGER.debug( - "Returning cached state for offline sensor %s (slow poll).", - device["haName"], - ) - return cached - device.setdefault("status", {"state": None}) - return device + return self.session.setCachedDevice(device, dev_data) + else: + await self.session.helper.errorCheck( + device["device_id"], "ERROR", device["deviceData"]["online"] + ) + device.setdefault("status", {"state": None}) + return device diff --git a/src/session.py b/src/session.py index af7b957..3d55986 100644 --- a/src/session.py +++ b/src/session.py @@ -105,12 +105,47 @@ def __init__( "camera": {}, } ) - self.devices = {} + self.entityCache = {} self.deviceList = {} self.hub_id = None self._lastPollSlow = False self._slowPollThreshold = 3 self._refreshThreshold = 0.90 + self._updateTask = None + + @staticmethod + def _entityCacheKey(device: dict): + """Build a stable cache key for an entity instance.""" + return "|".join( + [ + str(device.get("haType", "")), + str(device.get("hiveID", "")), + str(device.get("hiveType", "")), + ] + ) + + def getCachedDevice(self, device: dict): + """Get cached state for a specific entity.""" + cache_key = self._entityCacheKey(device) + return self.entityCache.get(cache_key) + + def setCachedDevice(self, device: dict, dev_data: dict): + """Store cached state for a specific entity.""" + self.entityCache[self._entityCacheKey(device)] = dev_data + return dev_data + + def shouldUseCachedData(self): + """Determine whether callers should use cached entity state. + + Returns: + bool: True when the last poll was slow or another task is currently polling. + """ + if self._lastPollSlow: + return True + if self.updateLock.locked(): + current_task = asyncio.current_task() + return self._updateTask is None or current_task is not self._updateTask + return False def openFile(self, file: str): """Open a file. @@ -352,7 +387,8 @@ async def _retryDeviceLogin(self): """Attempt device login with retries and backoff. Raises: - HiveReauthRequired: All retry attempts have been exhausted. + HiveInvalidDeviceAuthentication: Device credentials are invalid. + HiveApiError: API error or no internet connection. """ last_err = None for delay_s in (0, 5, 10): @@ -363,19 +399,20 @@ async def _retryDeviceLogin(self): await self.deviceLogin() last_err = None break - except HiveInvalidDeviceAuthentication as err: + except HiveInvalidDeviceAuthentication: _LOGGER.error( "Device login failed with invalid credentials, reauthentication required." ) - raise HiveReauthRequired from err except HiveApiError as err: _LOGGER.error("Device login attempt failed: %s", err) last_err = err if last_err is not None: _LOGGER.error( - "All device login retries exhausted, reauthentication required." + "All device login retries exhausted, but device login may resolve the issue." ) - raise HiveReauthRequired from last_err + # Don't raise HiveReauthRequired - let the caller handle the error + # Device login itself should resolve 403 issues when successful + return await self.hiveRefreshTokens(force_refresh=True) @@ -470,22 +507,38 @@ async def updateData(self, device: dict): updated = False ep = self.config.lastUpdate + self.config.scanInterval if datetime.now() >= ep: + current_task = asyncio.current_task() + if self.updateLock.locked() and ( + self._updateTask is None or current_task is not self._updateTask + ): + _LOGGER.debug( + "Poll already in progress — using cached device data for %s.", + device["hiveID"], + ) + return updated async with self.updateLock: # Re-check after acquiring lock — another caller may have already updated ep = self.config.lastUpdate + self.config.scanInterval if datetime.now() < ep: return updated - _LOGGER.debug("Polling Hive API for device updates.") - updated = await self.getDevices(device["hiveID"]) - if updated and len(self.deviceList["camera"]) > 0: - for camera in self.data.camera: - await self.getCamera(self.devices[camera]) - if updated: - _LOGGER.debug("Device update completed successfully.") - else: - _LOGGER.debug( - "Device update failed, will retry after scan interval." - ) + self._updateTask = current_task + try: + _LOGGER.debug("Polling Hive API for device updates.") + updated = await self.getDevices(device["hiveID"]) + if updated and len(self.deviceList["camera"]) > 0: + for camera in self.data.camera: + camera_device = self.data.devices.get(camera) + if camera_device is not None: + await self.getCamera(camera_device) + if updated: + _LOGGER.debug("Device update completed successfully.") + else: + _LOGGER.debug( + "Device update failed, will retry after scan interval." + ) + finally: + if self._updateTask is current_task: + self._updateTask = None return updated