From 561d18f7c2ae0ca3a5bb808f3104aa6a15355961 Mon Sep 17 00:00:00 2001 From: Dzhuneyt Date: Sat, 18 Apr 2026 15:54:17 +0300 Subject: [PATCH] fix: auto-clear stale encryption key after repeated update failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the AC rotates its session key (e.g. after Wi-Fi re-pairing via MODE+WIFI on the unit), the integration kept encrypting with the stale cached key forever, leaving the entity Unavailable until a manual reload. Track consecutive update failures; after STALE_KEY_THRESHOLD (default 3), discard the cached key so the existing async_update re-bind path auto-recovers on the next tick. User-configured keys (CONF_ENCRYPTION_KEY) are preserved and only logged — silently overriding a static key the user explicitly set would be surprising. Refs #441 --- custom_components/gree/climate.py | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index 539373e..7f8e9f3 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -108,6 +108,12 @@ async def create_gree_device(hass, config): # update() interval SCAN_INTERVAL = timedelta(seconds=60) +# After this many consecutive update failures, discard the cached encryption +# key so the next update re-binds. Recovers from device-side key rotation +# (power outage, router restart, WiFi re-association) without a manual reload. +# Only applied to auto-acquired keys; user-configured keys are never cleared. +STALE_KEY_THRESHOLD = 3 + async def async_setup_entry(hass, entry, async_add_devices): """Set up Gree climate from a config entry.""" @@ -202,6 +208,12 @@ def __init__( self.encryption_version = encryption_version self.CIPHER = None + # Tracks whether the user explicitly configured the key. Auto-acquired + # keys may be discarded after repeated failures (see STALE_KEY_THRESHOLD); + # user-configured keys are preserved even under persistent timeouts. + self._user_provided_key = bool(encryption_key) + self._consecutive_update_failures = 0 + if encryption_key: _LOGGER.info(f"{self._name}: Using configured encryption key: {encryption_key}") self._encryption_key = encryption_key.encode("utf8") @@ -466,6 +478,28 @@ def UpdateHAStateToCurrentACState(self): self.UpdateHAOutsideTemperature() self.UpdateHARoomHumidity() + def _handle_comms_failure(self): + # Discard an auto-acquired key after repeated failures so the next update + # re-binds and recovers from a device-side key rotation. User-provided + # keys are preserved — a silent override would surprise the user. + self._consecutive_update_failures += 1 + if self._consecutive_update_failures < STALE_KEY_THRESHOLD: + return + if self._user_provided_key: + _LOGGER.warning( + f"{self._name}: {self._consecutive_update_failures} consecutive update failures " + f"with a user-configured encryption key. Not auto-clearing; reload the integration " + f"or update the key in options if the device has rotated its key." + ) + return + _LOGGER.warning( + f"{self._name}: {self._consecutive_update_failures} consecutive update failures; " + f"discarding cached encryption key to trigger re-bind on next update." + ) + self._encryption_key = None + self.CIPHER = None + self._consecutive_update_failures = 0 + async def SyncState(self, acOptions={}): # Fetch current settings from HVAC _LOGGER.debug(f"{self._name}: Starting device state sync") @@ -563,7 +597,9 @@ async def SyncState(self, acOptions={}): if not self._disable_available_check: _LOGGER.info(f"{self._name}: Device marked offline after failed communication") self._device_online = False + self._handle_comms_failure() else: + self._consecutive_update_failures = 0 if not self._disable_available_check: if not self._device_online: self._device_online = True @@ -586,6 +622,7 @@ async def SyncState(self, acOptions={}): if not self._disable_available_check: _LOGGER.info(f"{self._name}: Device marked offline after failed send attempt") self._device_online = False + self._handle_comms_failure() else: # loop used once for Gree Climate initialisation only self._firstTimeRun = False