From 3559f6097d359f9bdff6d4b8739a83ec633cbe04 Mon Sep 17 00:00:00 2001 From: bumaas Date: Sun, 5 Jul 2026 15:06:15 +0200 Subject: [PATCH 1/5] =?UTF-8?q?1.1=20build=2015:=20Rate-Limit-H=C3=A4rtung?= =?UTF-8?q?,=20Status-Refresh=20&=20Konfigurator-Fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device: - Leichter Wert-Refresh (refreshDeviceValues) bei Reconnect/Neustart statt nur bei needsInitialization -> kein veralteter Status/OperationState mehr. - 30s-Throttle (refreshThrottled) fuer Init- und Refresh-Pfad gegen den Request-Sturm bei CONNECTED/IM_CHANGESTATUS-Bursts. - Transition-Guard in MessageSink: nur echte Parent-Statuswechsel loesen Refresh aus. - Profil-Guard: IPS_SetVariableProfileValues nur auf numerischen Profilen (behebt "Werte von String-Profilen koennen nicht modifiziert werden"). - Diagnose-Debug (Trigger/Modus, refreshDeviceValues done (N requests)). Cloud: - stopEventStream: IO waehrend Rate-Limit-Sperre deaktivieren -> kein 60s-/events-Hammering, das rollierende 24h-Fenster kann leerlaufen. - Ehrlicher Status 201 (STATUS_RATE_LIMITED) statt IS_ACTIVE, inkl. dt. Locale. - invalid_token-Handler: abgelaufener Token im Stream -> sofort Reconnect mit frischem Token statt 401-Dauerloop. - Tageszaehler RequestDayCount in ForwardData (Diagnose des Verbrauchs). Configurator: - Bereits angelegte Geraeteinstanzen werden immer gelistet, auch wenn die Live-Discovery scheitert (Rate-Limit/offline) - kein Frueh-Return mehr. Tests: 30 -> 34 gruen (neue Regressionstests fuer Throttle/Transition, IO-Stopp/Reset, Status 201 und invalid_token-Reconnect); Cloud-Tests registrieren die Parent-IO-Properties selbst, kein SymconStubs-Patch noetig. Co-Authored-By: Claude Opus 4.8 (1M context) --- Home Connect Cloud/form.json | 8 +- Home Connect Cloud/locale.json | 1 + Home Connect Cloud/module.php | 82 ++++++++++++++++- Home Connect Configurator/module.php | 56 ++++++++++-- Home Connect Device/module.php | 122 +++++++++++++++++++++++-- library.json | 4 +- tests/HomeConnectCloudTest.php | 87 ++++++++++++++++++ tests/HomeConnectFridgeFreezerTest.php | 54 +++++++++-- 8 files changed, 381 insertions(+), 33 deletions(-) diff --git a/Home Connect Cloud/form.json b/Home Connect Cloud/form.json index 38afd53..1590308 100644 --- a/Home Connect Cloud/form.json +++ b/Home Connect Cloud/form.json @@ -43,5 +43,11 @@ "onClick": "HC_RegisterServerEvents($id);" } ], - "status": [] + "status": [ + { + "code": 201, + "icon": "error", + "caption": "Home Connect request limit reached - requests are blocked until the limit resets" + } + ] } diff --git a/Home Connect Cloud/locale.json b/Home Connect Cloud/locale.json index 4241faf..601db35 100644 --- a/Home Connect Cloud/locale.json +++ b/Home Connect Cloud/locale.json @@ -17,6 +17,7 @@ "50 calls in 1 minute": "50 Anfragen pro Minute", "A rate limit was reached. Requests are blocked until %s.": "Ein Anfragenlimit wurde erreicht. Weitere Anfragen werden bis %s blockiert", "Home Connect requests are currently rate limited.": "Home-Connect-Anfragen sind aktuell limitiert.", + "Home Connect request limit reached - requests are blocked until the limit resets": "Home-Connect-Anfragenlimit erreicht - Anfragen sind blockiert, bis das Limit zurückgesetzt wird", "https://www.symcon.de/en/service/documentation/module-reference/home-connect/": "https://www.symcon.de/de/service/dokumentation/modulreferenz/home-connect" } } diff --git a/Home Connect Cloud/module.php b/Home Connect Cloud/module.php index ae4b574..ea41c30 100644 --- a/Home Connect Cloud/module.php +++ b/Home Connect Cloud/module.php @@ -14,6 +14,11 @@ class HomeConnectCloud extends WebOAuthModule //Real public const HOME_CONNECT_BASE = 'https://api.home-connect.com/api/'; + + // Custom instance status shown while the Home Connect request quota is exhausted. + // A code >= IS_EBASE (200) marks the instance as not usable, so child devices go + // inactive for the duration of the block instead of pretending to be active. + private const STATUS_RATE_LIMITED = 201; private $oauthIdentifer = 'home_connect'; private $oauthServer = 'oauth.ipmagic.de'; @@ -82,6 +87,21 @@ public function ForwardData($Data) $this->SendDebug('ForwardRateLimit', json_encode($error), 0); return json_encode($error); } + + // DEBUG (rate-limit analysis): count every request that actually leaves for the + // Home Connect cloud. GET/PUT/DELETE share the account-wide 1000/day quota, so + // this running total shows how fast the limit is approached and which endpoint + // drives it. Resets automatically at the start of each day. + $today = date('Y-m-d'); + $counter = json_decode($this->GetBuffer('RequestDayCount'), true); + if (!is_array($counter) || ($counter['date'] ?? '') !== $today) { + $counter = ['date' => $today, 'count' => 0]; + } + $counter['count']++; + $this->SetBuffer('RequestDayCount', json_encode($counter)); + $method = isset($data['Payload']) ? ($data['Payload'] === 'DELETE' ? 'DELETE' : 'PUT') : 'GET'; + $this->SendDebug('RequestDayCount', sprintf('#%d today (%s %s)', $counter['count'], $method, $data['Endpoint'] ?? ''), 0); + try { if (isset($data['Payload'])) { $this->SendDebug('Payload', $data['Payload'], 0); @@ -110,6 +130,13 @@ public function ReceiveData($JSONString) return; } + // The access token can expire while the stream is connected; Home Connect then + // answers /events with 401 "invalid_token". Refresh the token and reconnect + // immediately instead of looping on 401 until the keep-alive watchdog fires. + if ($this->handleExpiredTokenFromStream($JSONString)) { + return; + } + $data = json_decode($JSONString, true); switch ($data['Event'] ?? '') { case 'KEEP-ALIVE': { @@ -227,9 +254,10 @@ public function ResetRateLimit() $this->SetStatus(IS_ACTIVE); $this->SetTimerInterval('RateLimit', 0); - // Limit is over - resume the event stream exactly once. + // Limit is over - re-activate the IO (stopped in applyRateLimit) and resume the + // event stream exactly once, with a freshly fetched access token. $this->SetTimerInterval('Reconnect', 0); - $this->RegisterServerEvents(); + $this->ForceRegisterServerEvents(); } public function GetConfigurationForm() @@ -309,6 +337,30 @@ private function applyRateLimitFromStream(string $JSONString): bool return true; } + /** + * Detects a 401 "invalid_token" carried by the SSE stream (the access token + * expired) and recovers by dropping the cached token and re-registering the + * stream with a freshly fetched one. Returns true if it was handled. + */ + private function handleExpiredTokenFromStream(string $JSONString): bool + { + if (strpos($JSONString, 'invalid_token') === false && strpos($JSONString, 'access token expired') === false) { + return false; + } + + // While blocked the IO is stopped and the token is refreshed on reset - do nothing. + if ($this->isRateLimitActive()) { + return true; + } + + $this->SendDebug('ReceiveTokenExpired', 'Access token expired on event stream - refreshing token and reconnecting', 0); + // Re-register the stream: RegisterServerEvents calls FetchAccessToken(), which + // automatically refreshes an expired access token via the refresh token before + // re-arming the /events request. No need to wait for the keep-alive watchdog. + $this->ForceRegisterServerEvents(); + return true; + } + private function resetRetries() { $this->SetTimerInterval('Reconnect', 0); @@ -607,8 +659,30 @@ private function applyRateLimit(int $retryAfter, ?string $type): void ) : sprintf($this->Translate('A rate limit was reached. Requests are blocked until %s.'), date('d.m.Y H:i:s', $nextRun)) ); $this->updateRateLimitNotice(); - if ($this->HasActiveParent() && $this->GetStatus() != IS_ACTIVE) { - $this->SetStatus(IS_ACTIVE); + + // Stop the event-stream IO for the duration of the block. Otherwise the + // underlying socket keeps reconnecting to /events every ~60s (each a 429, and + // an endless invalid_token loop once the access token expires) - see the SSE + // debug. It is restarted with a fresh token in ResetRateLimit(). + $this->stopEventStream(); + + // Reflect the block honestly instead of forcing IS_ACTIVE. + if ($this->GetStatus() != self::STATUS_RATE_LIMITED) { + $this->SetStatus(self::STATUS_RATE_LIMITED); + } + } + + /** + * Deactivates the parent event-stream IO so it stops hitting /events while the + * request quota is exhausted. No-op if there is no (active) parent. + */ + private function stopEventStream(): void + { + $parent = IPS_GetInstance($this->InstanceID)['ConnectionID']; + if (IPS_InstanceExists($parent) && IPS_GetProperty($parent, 'Active')) { + $this->SendDebug('stopEventStream', 'Deactivating event-stream IO for the duration of the block', 0); + IPS_SetProperty($parent, 'Active', false); + IPS_ApplyChanges($parent); } } diff --git a/Home Connect Configurator/module.php b/Home Connect Configurator/module.php index bedbcc2..f1fd817 100644 --- a/Home Connect Configurator/module.php +++ b/Home Connect Configurator/module.php @@ -35,15 +35,17 @@ public function ForwardData($JSONString) public function GetConfigurationForm() { $form = json_decode(file_get_contents(__DIR__ . '/form.json'), true); - // Return if parent is not confiured - if (!$this->HasActiveParent()) { - return json_encode($form); - } - $homeapplianceData = json_decode($this->getHomeAppliances(), true); + $devices = []; + $knownHaIDs = []; + + // Only query the live appliance list when the parent (cloud) is usable. When it + // is not (rate limit blocking the event stream, offline, ...) we skip discovery + // but still fall through to listing the already-created device instances below. + $homeapplianceData = $this->HasActiveParent() ? json_decode($this->getHomeAppliances(), true) : null; if (isset($homeapplianceData['data']) && isset($homeapplianceData['data']['homeappliances'])) { $homeappliances = $homeapplianceData['data']['homeappliances']; - $devices = []; foreach ($homeappliances as $homeappliance) { + $knownHaIDs[$homeappliance['haId']] = true; $devices[] = [ 'HaID' => $homeappliance['haId'], 'Name' => $homeappliance['name'], @@ -62,8 +64,7 @@ public function GetConfigurationForm() ]; $this->SendDebug($homeappliance['name'], $this->getModuleIDByType($homeappliance['type']), 0); } - $form['actions'][0]['values'] = $devices; - } else { + } elseif ($homeapplianceData !== null) { $this->SendDebug('Error', json_encode($homeapplianceData), 0); $errorDescription = $this->Translate('No error description available'); if (isset($homeapplianceData['error']) && isset($homeapplianceData['error']['description'])) { @@ -81,6 +82,14 @@ public function GetConfigurationForm() ] ]; } + + // Always list already-created device instances so they stay visible and + // manageable even when the live discovery fails (e.g. rate limit / offline). + foreach ($this->getExistingDeviceRows($knownHaIDs) as $device) { + $devices[] = $device; + } + + $form['actions'][0]['values'] = $devices; return json_encode($form); } @@ -112,4 +121,35 @@ private function getInstanceIDForGuid($haid, $guid) } return 0; } + + /** + * Build configurator rows for device instances that already exist locally but are + * not part of the (possibly empty) discovery result. This keeps created devices + * visible when the Home Connect API is unavailable (rate limit, offline, ...). + * + * @param array $knownHaIDs HaIDs already listed from the live discovery (deduplication). + */ + private function getExistingDeviceRows(array $knownHaIDs) + { + $rows = []; + foreach (self::MODULE_TYPES as $guid) { + foreach (IPS_GetInstanceListByModuleID($guid) as $instanceID) { + $haID = (string) @IPS_GetProperty($instanceID, 'HaID'); + if ($haID === '' || isset($knownHaIDs[$haID])) { + continue; + } + $knownHaIDs[$haID] = true; + $deviceType = (string) @IPS_GetProperty($instanceID, 'DeviceType'); + $rows[] = [ + 'HaID' => $haID, + 'Name' => IPS_GetName($instanceID), + 'Type' => $deviceType !== '' ? $this->Translate($deviceType) : '', + 'Brand' => '', + 'Connected' => '', + 'instanceID' => $instanceID + ]; + } + } + return $rows; + } } diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 844d96d..d3d3ce9 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -51,6 +51,16 @@ class HomeConnectDevice extends IPSModule private const START_IN_RELATIVE = 'BSH.Common.Option.StartInRelative'; private const START_IN_RELATIVE_DEVICES = ['Microwave', 'Dishwasher', 'Oven']; + // Minimum seconds between two full server refreshes (init or value refresh) of a + // single device. Guards against the request storm caused by bursts of CONNECTED + // events / parent status flaps, which quickly exhaust the "50 requests per minute" + // Home Connect limit and, in turn, the daily quota. + private const REFRESH_MIN_INTERVAL = 30; + + // Counts calls to RequestDataFromParent within a single PHP call so a value + // refresh can report how many server requests it cost (rate-limit diagnostics). + private $requestCounter = 0; + public function Create() { //Never delete this line! @@ -133,7 +143,7 @@ public function ApplyChanges() parent::ApplyChanges(); if (IPS_GetKernelRunlevel() === KR_READY) { - $this->refreshDeviceState($this->needsInitialization()); + $this->refreshDeviceState($this->needsInitialization(), 'ApplyChanges'); } $this->SetReceiveDataFilter('.*' . $this->ReadPropertyString('HaID') . '.*'); @@ -146,7 +156,15 @@ public function MessageSink($Timestamp, $SenderID, $MessageID, $Data) $parentID = IPS_GetInstance($this->InstanceID)['ConnectionID']; if ($SenderID == $parentID && $MessageID == IM_CHANGESTATUS) { - $this->refreshDeviceState($Data[0] == IS_ACTIVE && $this->needsInitialization()); + // Only react to a real parent status transition. During rate-limit + // flapping IM_CHANGESTATUS fires many times per second with the same + // status; without this guard each one would trigger a refresh. + $newStatus = (int) $Data[0]; + if ((int) $this->GetBuffer('LastParentStatus') === $newStatus) { + return; + } + $this->SetBuffer('LastParentStatus', (string) $newStatus); + $this->refreshDeviceState($newStatus == IS_ACTIVE && $this->needsInitialization(), sprintf('IM_CHANGESTATUS(%s)', $newStatus)); return; } @@ -154,7 +172,7 @@ public function MessageSink($Timestamp, $SenderID, $MessageID, $Data) switch ($MessageID) { case FM_CONNECT: $this->RegisterMessage($Data[0], IM_CHANGESTATUS); - $this->refreshDeviceState($this->needsInitialization()); + $this->refreshDeviceState($this->needsInitialization(), 'FM_CONNECT'); return; case FM_DISCONNECT: @@ -177,7 +195,7 @@ public function ReceiveData($String) break; case 'CONNECTED': // Device comes online, request states - $this->refreshDeviceState($this->needsInitialization()); + $this->refreshDeviceState($this->needsInitialization(), 'Event:CONNECTED'); break; case 'STATUS': case 'NOTIFY': @@ -405,6 +423,7 @@ public function InitializeDevice() public function RequestDataFromParent(string $endpoint, string $payload = '') { + $this->requestCounter++; $this->SendDebug(__FUNCTION__, sprintf('endpoint: %s, payload: %s', $endpoint, $payload), 0); $data = [ 'DataID' => '{41DDAA3B-65F0-B833-36EE-CEB57A80D022}', @@ -455,12 +474,26 @@ public function RequestDataFromParent(string $endpoint, string $payload = '') return $response; } - private function refreshDeviceState(bool $initializeDevice): void + private function refreshDeviceState(bool $initializeDevice, string $trigger = ''): void { + // DEBUG (rate-limit analysis): record every refresh, its trigger and the chosen path. + $this->SendDebug(__FUNCTION__, sprintf('trigger: %s, mode: %s, activeParent: %s', $trigger, $initializeDevice ? 'init' : 'valueRefresh', $this->HasActiveParent() ? 'yes' : 'no'), 0); if ($this->HasActiveParent() && $this->ReadPropertyString('HaID')) { $this->SetSummary($this->ReadPropertyString('HaID')); + if ($this->refreshThrottled()) { + // Too soon since the last refresh - skip the server round-trips but + // keep the instance active. Live STATUS/NOTIFY events still update + // values directly (they do not go through this path). + $this->SendDebug(__FUNCTION__, sprintf('throttled (trigger: %s)', $trigger), 0); + $this->setInstanceStatus(IS_ACTIVE); + return; + } if ($initializeDevice) { $this->InitializeDevice(); + } else { + // Structure is already in place; pull fresh values so a restart or + // reconnect does not keep showing stale status (e.g. OperationState). + $this->refreshDeviceValues(); } $this->setInstanceStatus(IS_ACTIVE); return; @@ -469,6 +502,65 @@ private function refreshDeviceState(bool $initializeDevice): void $this->setInstanceStatus(IS_INACTIVE); } + /** + * Rate-limit guard shared by the init and the value-refresh path: allows at most + * one full server refresh per REFRESH_MIN_INTERVAL seconds. The timestamp lives in + * a runtime buffer, so it resets on restart (a restart should refresh immediately). + */ + private function refreshThrottled(): bool + { + $now = time(); + $last = (int) $this->GetBuffer('LastRefresh'); + if ($last !== 0 && ($now - $last) < self::REFRESH_MIN_INTERVAL) { + return true; + } + $this->SetBuffer('LastRefresh', (string) $now); + return false; + } + + /** + * Lightweight value refresh for an already initialized device. Only updates the + * values of existing variables (status, settings, selected program) without + * recreating variables/profiles/programs. This keeps the structure-init + * optimization (needsInitialization) intact while fixing stale values after a + * restart or event-stream reconnect. + */ + private function refreshDeviceValues(): void + { + // Nothing to refresh before the structure exists - the init path handles that. + if (!$this->ReadAttributeBoolean('Initialized')) { + $this->SendDebug(__FUNCTION__, 'skipped (not initialized)', 0); + return; + } + + // Count the server requests this refresh causes (rate-limit diagnostics). + $requestsBefore = $this->requestCounter; + + // Current status values (OperationState, DoorState, restrictions, ...). + // Skips silently on an offline device (createStates returns false on error). + $this->createStates(); + + // Current setting values (PowerState, temperatures, ...) - values only, + // no per-setting constraint fetch and no profile rebuild. + $settings = json_decode($this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/settings'), true); + if (isset($settings['data']['settings'])) { + foreach ($settings['data']['settings'] as $setting) { + $ident = $this->getLastSnippet($setting['key']); + if (array_key_exists('value', $setting) && @IPS_GetObjectIDByIdent($ident, $this->InstanceID)) { + $this->SetValue($ident, $setting['value']); + } + } + } + + // Current selected program and its option values (only meaningful when ready). + if (@IPS_GetObjectIDByIdent('OperationState', $this->InstanceID) + && ($this->GetValue('OperationState') == 'BSH.Common.EnumType.OperationState.Ready')) { + $this->updateOptionValues($this->getSelectedProgram()); + } + + $this->SendDebug(__FUNCTION__, sprintf('done (%d requests)', $this->requestCounter - $requestsBefore), 0); + } + private function setInstanceStatus(int $status): void { // Only update the status when it actually changes, so monitoring @@ -1002,11 +1094,21 @@ private function createVariableFromConstraints($profileName, $data, $attribute, //Create profile IPS_CreateVariableProfile($profileName, $variableType); } - IPS_SetVariableProfileText($profileName, '', isset($data['unit']) ? ' ' . $data['unit'] : ''); - $min = isset($constraints['min']) ? $constraints['min'] : 0; - $max = isset($constraints['max']) ? $constraints['max'] : 86340; - IPS_SetVariableProfileValues($profileName, $min, $max, isset($constraints['stepsize']) ? $constraints['stepsize'] : 1); - $this->SendDebug('UpdatedProfile', $min . ' - ' . $max, 0); + $existingProfileType = IPS_GetVariableProfile($profileName)['ProfileType']; + if ($existingProfileType === VARIABLETYPE_INTEGER || $existingProfileType === VARIABLETYPE_FLOAT) { + IPS_SetVariableProfileText($profileName, '', isset($data['unit']) ? ' ' . $data['unit'] : ''); + $min = isset($constraints['min']) ? $constraints['min'] : 0; + $max = isset($constraints['max']) ? $constraints['max'] : 86340; + IPS_SetVariableProfileValues($profileName, $min, $max, isset($constraints['stepsize']) ? $constraints['stepsize'] : 1); + $this->SendDebug('UpdatedProfile', $min . ' - ' . $max, 0); + } else { + // A profile with this name already exists with an incompatible + // (non-numeric) type. Modifying its values would emit "String + // profiles cannot be modified". Keep the existing profile and match + // the variable to it instead of fighting the type. + $this->SendDebug(__FUNCTION__, sprintf('Profile %s already exists as type %d; skipping numeric setup', $profileName, $existingProfileType), 0); + $variableType = $existingProfileType; + } break; case VARIABLETYPE_BOOLEAN: diff --git a/library.json b/library.json index 50e375b..53dec9f 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 14, - "date": 1782691200 + "build": 15, + "date": 1783256696 } diff --git a/tests/HomeConnectCloudTest.php b/tests/HomeConnectCloudTest.php index 7eb0107..18bb3b3 100644 --- a/tests/HomeConnectCloudTest.php +++ b/tests/HomeConnectCloudTest.php @@ -26,6 +26,11 @@ protected function setUp(): void $this->ConfiguratorID = IPS_CreateInstance('{CA0E667D-8F28-8DF1-2750-5CF587ECA85A}'); + // The upstream SSE-Client stub only registers 'Open'; give the parent IO the + // 'Active'/'URL'/'Headers' properties the module toggles (as the real IO has), + // so the rate-limit tests run against unmodified SymconStubs. + $this->prepareParentIo(IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]); + parent::setUp(); } @@ -90,11 +95,93 @@ public function testKeepAliveStillProcessedWhenNotLimited() $this->assertFalse($this->invoke($cloud, 'isRateLimitActive'), 'A keep-alive must not activate the rate limit'); } + /** + * #4/#5: A 429 must stop the event-stream IO (so it no longer hammers /events) + * and mark the instance with the honest rate-limit status instead of IS_ACTIVE. + */ + public function testRateLimitStopsEventStreamAndSetsStatus() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + + //Simulate a running event stream. + IPS_SetProperty($parent, 'Active', true); + IPS_ApplyChanges($parent); + $this->assertTrue(IPS_GetProperty($parent, 'Active')); + + $cloud->ReceiveData(self::RATE_LIMIT_PAYLOAD); + + $this->assertFalse(IPS_GetProperty($parent, 'Active'), 'Event-stream IO must be deactivated while blocked'); + //201 == STATUS_RATE_LIMITED (>= IS_EBASE), so children go inactive during the block. + $this->assertSame(201, IPS_GetInstance($cloudID)['InstanceStatus'], 'Instance must report the rate-limit status, not active'); + } + + /** + * #4: When the block is over, ResetRateLimit must re-activate the IO and resume + * the stream (with a fresh token) and return the instance to IS_ACTIVE. + */ + public function testResetRateLimitRestartsEventStream() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + + //Seed a valid access token so RegisterServerEvents does not attempt an OAuth refresh. + $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + + $cloud->ReceiveData(self::RATE_LIMIT_PAYLOAD); + $this->assertTrue($this->invoke($cloud, 'isRateLimitActive')); + + $cloud->ResetRateLimit(); + + $this->assertFalse($this->invoke($cloud, 'isRateLimitActive'), 'Reset must clear the rate limit'); + $this->assertTrue(IPS_GetProperty($parent, 'Active'), 'Event-stream IO must be re-activated on reset'); + $this->assertSame(IS_ACTIVE, IPS_GetInstance($cloudID)['InstanceStatus'], 'Instance must be active again after reset'); + } + + /** + * #5: A 401 "invalid_token" carried by the stream (the access token expired) must + * trigger a reconnect with a fresh token instead of letting the IO loop on 401. + * It must not be mistaken for a rate limit, and the IO stays active. + */ + public function testInvalidTokenReconnectsEventStream() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + + //Seed a valid access token so the reconnect reuses it instead of hitting OAuth. + $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + + $cloud->ReceiveData('{"error":{"key":"invalid_token","description":"The access token expired"}}'); + + $this->assertFalse($this->invoke($cloud, 'isRateLimitActive'), 'invalid_token must not be treated as a rate limit'); + $this->assertTrue(IPS_GetProperty($parent, 'Active'), 'Stream must be re-registered (IO active) after invalid_token'); + $this->assertStringContainsString('homeappliances/events', IPS_GetProperty($parent, 'URL'), 'Reconnect must re-arm the /events request'); + } + private function cloud() { return IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]); } + /** + * The upstream SSE-Client stub only registers the 'Open' property, but the module + * toggles the parent IO's 'Active'/'URL'/'Headers' (as the real SSE Client IO has). + * Register them on the parent instance so these tests run against unmodified + * SymconStubs without patching the submodule. Returns the parent instance ID. + */ + private function prepareParentIo(int $cloudID): int + { + $parent = IPS_GetInstance($cloudID)['ConnectionID']; + $module = IPS\InstanceManager::getInstanceInterface($parent); + $this->invoke($module, 'RegisterPropertyBoolean', 'Active', false); + $this->invoke($module, 'RegisterPropertyString', 'URL', ''); + $this->invoke($module, 'RegisterPropertyString', 'Headers', ''); + return $parent; + } + private function invoke($object, string $method, ...$args) { $ref = new ReflectionMethod($object, $method); diff --git a/tests/HomeConnectFridgeFreezerTest.php b/tests/HomeConnectFridgeFreezerTest.php index 1a50665..e0351b7 100644 --- a/tests/HomeConnectFridgeFreezerTest.php +++ b/tests/HomeConnectFridgeFreezerTest.php @@ -44,8 +44,11 @@ protected function setUp(): void * UnsupportedOperation error. With the old code needsInitialization() returned * true on every IM_CHANGESTATUS (because the OperationState variable never * existed), so each parent status flap re-ran a full InitializeDevice() and - * burned through the API quota. This test asserts that after one successful - * initialization, repeated IM_CHANGESTATUS events trigger no further API calls. + * burned through the API quota. + * + * A burst of identical parent status flaps must produce NO extra API calls at all: + * the transition guard drops repeated same-status notifications, and the refresh + * throttle suppresses the remaining one because a refresh already ran during setup. */ public function testNoReInitLoopWhenOperationStateMissing() { @@ -71,13 +74,21 @@ public function testNoReInitLoopWhenOperationStateMissing() $intf->MessageSink(0, $parent, IM_CHANGESTATUS, [IS_ACTIVE]); } - //No further API call must happen - the device is already initialized. - $this->assertSame(0, HomeConnectCloud::$requestCount, 'Repeated IM_CHANGESTATUS must not re-initialize the device'); + //No API call at all: repeated same-status flaps are dropped by the transition + //guard, and the single genuine transition is suppressed by the refresh throttle + //(a refresh already ran during setup, well within REFRESH_MIN_INTERVAL). + $this->assertSame(0, HomeConnectCloud::$requestCount, 'A burst of identical status flaps must not trigger any refresh'); + + //The structure is untouched - still no OperationState, DoorState still present, still active. + $this->assertFalse(@IPS_GetObjectIDByIdent('OperationState', $fridge), 'Value refresh must not create OperationState'); + $this->assertNotFalse(@IPS_GetObjectIDByIdent('DoorState', $fridge), 'DoorState remains present'); + $this->assertEquals(IS_ACTIVE, IPS_GetInstance($fridge)['InstanceStatus']); } /** - * After a config change (HaID/DeviceType) the initialization signature changes, - * so a single re-initialization is expected - but still no endless loop. + * Re-applying without a config change immediately after setup must not perform any + * API calls: it is neither a re-init (signature unchanged) nor an allowed refresh + * (throttled, since setup just refreshed within REFRESH_MIN_INTERVAL). */ public function testReInitializesOnceAfterSignatureChange() { @@ -91,9 +102,36 @@ public function testReInitializesOnceAfterSignatureChange() $this->assertGreaterThan(0, HomeConnectCloud::$requestCount); - //Re-applying without changes must not initialize again. + //Re-applying right away is throttled and does not re-initialize. HomeConnectCloud::$requestCount = 0; IPS_ApplyChanges($fridge); - $this->assertSame(0, HomeConnectCloud::$requestCount, 'ApplyChanges without changes must not re-initialize'); + $this->assertSame(0, HomeConnectCloud::$requestCount, 'ApplyChanges right after setup must be throttled (no API calls)'); + } + + /** + * The lightweight value refresh itself (invoked directly, bypassing the throttle) + * fetches only /status and /settings and updates existing variables - it must not + * re-create structure or hit /programs. + */ + public function testValueRefreshFetchesStatusAndSettingsOnly() + { + $fridge = IPS_CreateInstance(self::DEVICE_GUID); + $parent = IPS_GetInstance($fridge)['ConnectionID']; + IPS\InstanceManager::setStatus($parent, IS_ACTIVE); + + IPS_SetProperty($fridge, 'HaID', self::FRIDGE_HAID); + IPS_SetProperty($fridge, 'DeviceType', 'FridgeFreezer'); + IPS_ApplyChanges($fridge); + + $intf = IPS\InstanceManager::getInstanceInterface($fridge); + HomeConnectCloud::$requestCount = 0; + $method = new ReflectionMethod($intf, 'refreshDeviceValues'); + $method->setAccessible(true); + $method->invoke($intf); + + //GET /status + GET /settings = 2 calls; no /programs, no per-setting constraint fetch. + $this->assertSame(2, HomeConnectCloud::$requestCount, 'Value refresh must only fetch /status and /settings'); + $this->assertFalse(@IPS_GetObjectIDByIdent('OperationState', $fridge), 'Value refresh must not create OperationState'); + $this->assertNotFalse(@IPS_GetObjectIDByIdent('DoorState', $fridge), 'DoorState remains present'); } } From cc138017a81fe61e1131ccf755db51fd8ba2efee Mon Sep 17 00:00:00 2001 From: bumaas Date: Sun, 5 Jul 2026 18:49:33 +0200 Subject: [PATCH 2/5] =?UTF-8?q?1.1=20build=2016:=20SSE-Reconnect=20nach=20?= =?UTF-8?q?Stream-Abriss=20zuverl=C3=A4ssig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CheckServerEvents verbindet bei veraltetem Keep-Alive jetzt unabhängig vom Parent-Status neu (HasActiveParent-Tor entfernt) - ein toter Stream wird so auch bei fehlerhaftem Parent wiederhergestellt. Der Fehler-Backoff ist auf 3 Minuten gedeckelt (statt bis zu 1 Stunde); lange Wartezeiten bleiben echten Rate-Limits (429) vorbehalten. Regressionstests: Reconnect trotz inaktivem Parent, Backoff-Cap 180s (36 Tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- Home Connect Cloud/module.php | 19 +++++++++------ library.json | 4 ++-- tests/HomeConnectCloudTest.php | 43 ++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/Home Connect Cloud/module.php b/Home Connect Cloud/module.php index ea41c30..99b62b9 100644 --- a/Home Connect Cloud/module.php +++ b/Home Connect Cloud/module.php @@ -156,13 +156,16 @@ public function MessageSink($Timestamp, $SenderID, $MessageID, $Data) switch ($MessageID) { //A failing requests triggers a status change case IM_CHANGESTATUS: - // Update SSE if it is faulty gradually increase the reconnect interval + // Update SSE if it is faulty: gradually increase the reconnect + // interval, but cap it at 3 minutes. A dropped event stream must + // recover quickly; the long backoff is only meant for genuine + // rate-limit situations (handled separately in RegisterServerEvents). if ($Data[0] >= IS_EBASE) { $retries = $this->ReadAttributeInteger('RetryCounter'); $retries++; $this->WriteAttributeInteger('RetryCounter', $retries); $retryTime = pow($retries, 2); - $this->SetTimerInterval('Reconnect', ($retryTime > 3600 /*1h*/ ? 3600 : $retryTime) * 1000); + $this->SetTimerInterval('Reconnect', min($retryTime, 180 /* 3 min */) * 1000); } break; } @@ -227,11 +230,13 @@ public function CheckServerEvents() if ($this->isRateLimitActive()) { return; } - if ($this->HasActiveParent()) { - if (time() - intval($this->GetBuffer('KeepAlive')) > 60 /* Seconds */) { - $this->SendDebug('KeepAlive', 'Failed. Reregistering...', 0); - $this->RegisterServerEvents(); - } + // A stale keep-alive means the event stream is dead - reconnect regardless of + // the parent's current status. Gating this behind HasActiveParent() prevented + // recovery exactly when the parent had dropped to an error state (keep-alives + // stopped and never came back). + if (time() - intval($this->GetBuffer('KeepAlive')) > 60 /* Seconds */) { + $this->SendDebug('KeepAlive', 'Failed. Reregistering...', 0); + $this->RegisterServerEvents(); } } diff --git a/library.json b/library.json index 53dec9f..7c0ab26 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 15, - "date": 1783256696 + "build": 16, + "date": 1783270152 } diff --git a/tests/HomeConnectCloudTest.php b/tests/HomeConnectCloudTest.php index 18bb3b3..430ece2 100644 --- a/tests/HomeConnectCloudTest.php +++ b/tests/HomeConnectCloudTest.php @@ -161,6 +161,49 @@ public function testInvalidTokenReconnectsEventStream() $this->assertStringContainsString('homeappliances/events', IPS_GetProperty($parent, 'URL'), 'Reconnect must re-arm the /events request'); } + /** + * Reconnect fix: a stale keep-alive must trigger a reconnect even when the parent + * IO has dropped to a non-active status. The old code gated this behind + * HasActiveParent(), so a dead parent never recovered (keep-alives stopped for good). + */ + public function testCheckServerEventsReconnectsWhenParentInactive() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + + //IO is configured active, but its runtime status has dropped (stream died). + IPS_SetProperty($parent, 'Active', true); + IPS_ApplyChanges($parent); + $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + //Last keep-alive is well over 60s old -> the stream is considered dead. + $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); + IPS\InstanceManager::setStatus($parent, IS_INACTIVE); + + $cloud->CheckServerEvents(); + + $this->assertStringContainsString('homeappliances/events', IPS_GetProperty($parent, 'URL'), 'A stale keep-alive must reconnect even when the parent is not active'); + } + + /** + * Reconnect fix: the error backoff must be capped at 3 minutes so a dropped stream + * recovers quickly. Previously it grew quadratically up to 1 hour. + */ + public function testReconnectBackoffCappedAt3Minutes() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = IPS_GetInstance($cloudID)['ConnectionID']; + + //Simulate many consecutive parent error status changes (retries grow to 20). + for ($i = 0; $i < 20; $i++) { + $cloud->MessageSink(0, $parent, IM_CHANGESTATUS, [IS_EBASE]); + } + + //retries^2 would be 400s; must be capped at 180s (180000 ms). + $this->assertSame(180000, $this->invoke($cloud, 'GetTimerInterval', 'Reconnect'), 'Reconnect backoff must be capped at 3 minutes'); + } + private function cloud() { return IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]); From e2c7b05fd683dce71e53f762bfce52648e6d921d Mon Sep 17 00:00:00 2001 From: bumaas Date: Mon, 6 Jul 2026 13:05:20 +0200 Subject: [PATCH 3/5] 1.1 build 17: SelectedProgram-Refresh entkoppelt (Thread-Stau behoben) Der SelectedProgram-NOTIFY loeste bisher einen synchronen Cloud-Call in ReceiveData auf dem Flow-Handler-Thread aus - bei parallelem Stream-Reconnect fuehrte das zu "Warten auf Skriptresultat fehlgeschlagen" (Thread-Stau). Der Refresh laeuft jetzt entkoppelt: RegisterOnceTimer -> RequestAction ('RefreshSelectedProgram') auf eigenem Thread, ReceiveData kehrt sofort zurueck. Ersetzt die Zwischenloesung (statischer Timer + oeffentliche Methode); getTime() entfernt. Co-Authored-By: Claude Opus 4.8 (1M context) --- Home Connect Device/module.php | 14 +++++++++++- library.json | 4 ++-- tests/HomeConnectCoffeeTest.php | 39 +++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index d3d3ce9..2bd9731 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -225,7 +225,13 @@ public function ReceiveData($String) break; } if ($ident == 'SelectedProgram') { - $this->updateOptionValues($this->getSelectedProgram()); + // Decouple the cloud lookup from the event thread: doing + // it inline blocks ReceiveData on the flow-handler thread + // while the parent is still delivering the event (re-entrant + // deadlock -> "Warten auf Skriptresultat fehlgeschlagen"). + // A one-shot timer runs the refresh via RequestAction on its + // own thread; a burst of events just re-arms it (coalesces). + $this->RegisterOnceTimer('RefreshSelectedProgram', 'IPS_RequestAction($_IPS[\'TARGET\'], "RefreshSelectedProgram", "");'); } if (strpos($item['key'], 'Option') != false) { $ident = 'Option' . $ident; @@ -275,6 +281,12 @@ public function RequestAction($Ident, $Value) { $applyValue = false; switch ($Ident) { + case 'RefreshSelectedProgram': + // Internal action, triggered by the one-shot timer armed in ReceiveData. + // Runs the deferred selected-program/option refresh off the event thread. + $this->updateOptionValues($this->getSelectedProgram()); + return; + case 'UseDuration': $applyValue = true; break; diff --git a/library.json b/library.json index 7c0ab26..73a84e8 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 16, - "date": 1783270152 + "build": 17, + "date": 1783335901 } diff --git a/tests/HomeConnectCoffeeTest.php b/tests/HomeConnectCoffeeTest.php index 2a0b558..992a408 100644 --- a/tests/HomeConnectCoffeeTest.php +++ b/tests/HomeConnectCoffeeTest.php @@ -95,9 +95,48 @@ public function testBaseFunctionalityEvents() 'ID' => 'SIEMENS-TI9575X1DE-68A40E251CAD' ] )); + //The SelectedProgram lookup is deferred via a one-shot timer -> RequestAction. + //Under the test stubs RegisterOnceTimer runs the script text synchronously, so the + //refresh has already completed by the time ReceiveData returns. $this->assertEquals(self::ESPRESSO, $this->getChildrenValues($coffeMaker)); } + /** + * Regression for the thread-exhaustion bug: a SelectedProgram event must not run the + * cloud lookup inline in ReceiveData (that ran on the flow thread while the parent was + * still delivering the event -> re-entrant deadlock / "Warten auf Skriptresultat + * fehlgeschlagen"). It is now decoupled via a one-shot timer that triggers the + * 'RefreshSelectedProgram' action. In production this runs on its own thread; under + * the stubs RegisterOnceTimer executes the action synchronously, so we assert the + * refresh happens through that action path (and that the action itself works). + */ + public function testSelectedProgramEventRefreshesViaRequestAction() + { + $cloudInterface = IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID('{CE76810D-B685-9BE0-CC04-38B204DEAD5E}')[0]); + $cloudInterface->selectedProgram = 'Coffee'; + $coffeMaker = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + IPS_SetProperty($coffeMaker, 'HaID', 'SIEMENS-TI9575X1DE-68A40E251CAD'); + IPS_SetProperty($coffeMaker, 'DeviceType', 'CoffeMaker'); + IPS_ApplyChanges($coffeMaker); + $intf = IPS\InstanceManager::getInstanceInterface($coffeMaker); + + //A SelectedProgram event refreshes the options via the deferred action path. + $cloudInterface->selectedProgram = 'Espresso'; + HomeConnectCloud::$requestCount = 0; + $intf->ReceiveData(json_encode([ + 'Event' => 'NOTIFY', + 'Data' => '{"items":[{"handling":"none","key":"BSH.Common.Root.SelectedProgram","value":"ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso","level":"hint"}],"haId":"SIEMENS-TI9575X1DE-68A40E251CAD"}', + 'ID' => 'SIEMENS-TI9575X1DE-68A40E251CAD' + ])); + $this->assertGreaterThan(0, HomeConnectCloud::$requestCount, 'The deferred refresh must perform the cloud lookup'); + $this->assertEquals(self::ESPRESSO, $this->getChildrenValues($coffeMaker), 'Options must be refreshed to the new program'); + + //The action handler can also be invoked directly and performs the refresh. + $cloudInterface->selectedProgram = 'Coffee'; + $intf->RequestAction('RefreshSelectedProgram', ''); + $this->assertEquals(self::COFFEE, $this->getChildrenValues($coffeMaker), 'RefreshSelectedProgram action must refresh the options'); + } + private function displayChildrenValues($id) { $children = IPS_GetChildrenIDs($id); From e6b07c4a86355255efd6733d9a15237826b360b8 Mon Sep 17 00:00:00 2001 From: bumaas Date: Mon, 6 Jul 2026 15:03:27 +0200 Subject: [PATCH 4/5] 1.1 build 18: CONNECTED-Refresh entkoppelt (Thread-Stau behoben) Der CONNECTED-Event rief in ReceiveData weiterhin synchron refreshDeviceState -> refreshDeviceValues/InitializeDevice auf dem Flow-Thread auf. Waehrend eines Stream-Reconnects antwortet der Parent nicht rechtzeitig -> Flow-Thread blockiert -> "Warten auf Skriptresultat fehlgeschlagen". Der Refresh laeuft jetzt entkoppelt: RegisterOnceTimer -> RequestAction('RefreshDeviceState') auf eigenem Thread (analog zum SelectedProgram-Fix aus Build 17). Co-Authored-By: Claude Opus 4.8 (1M context) --- Home Connect Device/module.php | 13 +++++++++-- library.json | 4 ++-- tests/HomeConnectFridgeFreezerTest.php | 31 ++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 2bd9731..5825a47 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -194,8 +194,11 @@ public function ReceiveData($String) } break; case 'CONNECTED': - // Device comes online, request states - $this->refreshDeviceState($this->needsInitialization(), 'Event:CONNECTED'); + // Device comes online -> refresh states. Decouple from the event thread: + // refreshDeviceState performs synchronous cloud calls, and doing them + // inline blocks ReceiveData while the parent is busy (e.g. reconnecting the + // stream) -> "Warten auf Skriptresultat fehlgeschlagen" / thread pile-up. + $this->RegisterOnceTimer('RefreshDeviceState', 'IPS_RequestAction($_IPS[\'TARGET\'], "RefreshDeviceState", "");'); break; case 'STATUS': case 'NOTIFY': @@ -287,6 +290,12 @@ public function RequestAction($Ident, $Value) $this->updateOptionValues($this->getSelectedProgram()); return; + case 'RefreshDeviceState': + // Internal action, triggered by the one-shot timer armed on a CONNECTED + // event. Runs the (cloud-heavy) state refresh off the event thread. + $this->refreshDeviceState($this->needsInitialization(), 'Event:CONNECTED (deferred)'); + return; + case 'UseDuration': $applyValue = true; break; diff --git a/library.json b/library.json index 73a84e8..5c0b8dc 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 17, - "date": 1783335901 + "build": 18, + "date": 1783342990 } diff --git a/tests/HomeConnectFridgeFreezerTest.php b/tests/HomeConnectFridgeFreezerTest.php index e0351b7..0596129 100644 --- a/tests/HomeConnectFridgeFreezerTest.php +++ b/tests/HomeConnectFridgeFreezerTest.php @@ -134,4 +134,35 @@ public function testValueRefreshFetchesStatusAndSettingsOnly() $this->assertFalse(@IPS_GetObjectIDByIdent('OperationState', $fridge), 'Value refresh must not create OperationState'); $this->assertNotFalse(@IPS_GetObjectIDByIdent('DoorState', $fridge), 'DoorState remains present'); } + + /** + * A CONNECTED event must not refresh synchronously on the event thread (that blocks + * ReceiveData -> "Warten auf Skriptresultat fehlgeschlagen"). It arms a one-shot timer + * that runs the refresh via the 'RefreshDeviceState' action. Under the stubs + * RegisterOnceTimer runs synchronously, so the refresh completes here (throttle cleared + * first so it actually runs). + */ + public function testConnectedEventRefreshesViaDeferredAction() + { + $fridge = IPS_CreateInstance(self::DEVICE_GUID); + $parent = IPS_GetInstance($fridge)['ConnectionID']; + IPS\InstanceManager::setStatus($parent, IS_ACTIVE); + + IPS_SetProperty($fridge, 'HaID', self::FRIDGE_HAID); + IPS_SetProperty($fridge, 'DeviceType', 'FridgeFreezer'); + IPS_ApplyChanges($fridge); + + $intf = IPS\InstanceManager::getInstanceInterface($fridge); + //Clear the 30s refresh throttle so the deferred refresh runs. + $setBuffer = new ReflectionMethod($intf, 'SetBuffer'); + $setBuffer->setAccessible(true); + $setBuffer->invoke($intf, 'LastRefresh', '0'); + + HomeConnectCloud::$requestCount = 0; + $intf->ReceiveData(json_encode(['Event' => 'CONNECTED', 'Data' => '', 'ID' => self::FRIDGE_HAID])); + + //Deferred action performed the lightweight refresh (GET /status + GET /settings). + $this->assertSame(2, HomeConnectCloud::$requestCount, 'CONNECTED must refresh via the deferred RefreshDeviceState action'); + $this->assertEquals(IS_ACTIVE, IPS_GetInstance($fridge)['InstanceStatus']); + } } From 0b96666628a3499ac6d039369c816fbb05694a7f Mon Sep 17 00:00:00 2001 From: bumaas Date: Mon, 6 Jul 2026 15:54:49 +0200 Subject: [PATCH 5/5] 1.1 build 19: Kein Reconnect bei jeder erfolgreichen Anfrage Jede erfolgreiche Anfrage (getData 200 / putData 204 / deleteData 204) rief bisher ResetRateLimit -> ForceRegisterServerEvents -> IPS_ApplyChanges($parent) auf und verband damit die SSE-IO bei jeder Bedienung neu (Verbindung schliesst/ oeffnet, Event-Control-Statusflackern). ResetRateLimit laeuft jetzt nur noch, wenn tatsaechlich eine Sperre anlag (neuer Helper clearRateLimitAfterSuccess, Guard auf RateLimitUntil != 0). Danke an Thomas fuer den Hinweis. Co-Authored-By: Claude Opus 4.8 (1M context) --- Home Connect Cloud/module.php | 19 ++++++++++++++--- library.json | 4 ++-- tests/HomeConnectCloudTest.php | 37 ++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/Home Connect Cloud/module.php b/Home Connect Cloud/module.php index 99b62b9..82648b2 100644 --- a/Home Connect Cloud/module.php +++ b/Home Connect Cloud/module.php @@ -691,6 +691,19 @@ private function stopEventStream(): void } } + /** + * Clears the rate limit after a successful request - but only if a block was actually + * pending. Without this guard every successful request would run ResetRateLimit() and + * thus ForceRegisterServerEvents()/IPS_ApplyChanges(), reconnecting the event stream on + * every operation (connection close/reopen, Event-Control status flapping). + */ + private function clearRateLimitAfterSuccess(): void + { + if ($this->ReadAttributeInteger('RateLimitUntil') !== 0) { + $this->ResetRateLimit(); + } + } + private function getData($endpoint) { $opts = [ @@ -710,7 +723,7 @@ private function getData($endpoint) $this->SendDebug('HTTP GET Response', $result, 0); } if ($code == 200) { - $this->ResetRateLimit(); + $this->clearRateLimitAfterSuccess(); } else { $this->handleHttpErrors($code, $http_response_header); } @@ -740,7 +753,7 @@ private function putData($endpoint, $content) $this->SendDebug('HTTP PUT Response', $result, 0); } if ($code == 204) { - $this->ResetRateLimit(); + $this->clearRateLimitAfterSuccess(); } else { $this->handleHttpErrors($code, $http_response_header); } @@ -773,7 +786,7 @@ private function deleteData($endpoint) } if ($code == 204) { - $this->ResetRateLimit(); + $this->clearRateLimitAfterSuccess(); } else { $this->handleHttpErrors($code, $http_response_header); } diff --git a/library.json b/library.json index 5c0b8dc..513743d 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 18, - "date": 1783342990 + "build": 19, + "date": 1783346061 } diff --git a/tests/HomeConnectCloudTest.php b/tests/HomeConnectCloudTest.php index 430ece2..c35b5fd 100644 --- a/tests/HomeConnectCloudTest.php +++ b/tests/HomeConnectCloudTest.php @@ -204,6 +204,43 @@ public function testReconnectBackoffCappedAt3Minutes() $this->assertSame(180000, $this->invoke($cloud, 'GetTimerInterval', 'Reconnect'), 'Reconnect backoff must be capped at 3 minutes'); } + /** + * A successful request must NOT reconnect the event stream when no rate limit was + * pending. Otherwise every getData()/putData() would re-register the IO (ApplyChanges) + * -> connection close/reopen on every operation and Event-Control status flapping. + */ + public function testSuccessDoesNotReconnectWhenNoRateLimitPending() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + + //No rate limit pending (RateLimitUntil defaults to 0). + $this->invoke($cloud, 'clearRateLimitAfterSuccess'); + + $this->assertSame('', IPS_GetProperty($parent, 'URL'), 'A normal successful request must not re-register the event stream'); + } + + /** + * When a rate-limit block was pending, a successful request clears it and resumes the + * event stream (this is the wanted resume path). + */ + public function testSuccessClearsPendingRateLimitAndResumes() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + + //Simulate a pending block. + $this->invoke($cloud, 'WriteAttributeInteger', 'RateLimitUntil', time() + 3600); + + $this->invoke($cloud, 'clearRateLimitAfterSuccess'); + + $this->assertFalse($this->invoke($cloud, 'isRateLimitActive'), 'Pending rate limit must be cleared'); + $this->assertStringContainsString('homeappliances/events', IPS_GetProperty($parent, 'URL'), 'Stream must resume after the block clears'); + } + private function cloud() { return IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]);