From a0507cce479e1d13087de2ca55ea94d6ae26ac4c Mon Sep 17 00:00:00 2001 From: bumaas Date: Mon, 28 Sep 2026 18:18:13 +0200 Subject: [PATCH 1/9] 1.1 build 25: Readable names for option values missing from the profile Option profiles (HomeConnect..Option.) are shared by all programs of a device type and hold the value list of the last loaded program only, since that list also feeds the selection. A value reported for another program (e.g. operated at the appliance, or before the program was ever selected) had no association and showed the raw API key (forum t/124612 #554/#556). ensureOptionAssociation() now adds a missing association before an option value is set (event, program refresh, buffered values): program keys such as a favorite's BaseProgram reuse the name from the Programs profile, any other value gets its last key snippet. The profile is still replaced on the next program selection, so the selection keeps offering exactly the values of the selected program. Tests: 2 new regression tests in HomeConnectCoffeeTest based on the real coffee maker responses. Before the fix 2 of 54 tests fail, after it all 54 pass (589 assertions). Also fixes the formatValue test helper, which indexed profile associations with gaps. Co-Authored-By: Claude Opus 5.5 (1M context) --- Home Connect Device/module.php | 44 ++++++++++++++++++++++ library.json | 4 +- tests/HomeConnectCoffeeTest.php | 65 +++++++++++++++++++++++++++++++-- 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 06a81db..d90c0ee 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -298,6 +298,9 @@ public function ReceiveData($String) $ident = 'Option' . $ident; } if (@IPS_GetObjectIDByIdent($ident, $this->InstanceID)) { + if (strpos($ident, 'Option') === 0) { + $this->ensureOptionAssociation($ident, $item['value']); + } $this->SetValue($ident, $item['value']); } elseif (strpos($ident, 'Option') === 0) { // The variable may be created only moments later by @@ -941,6 +944,7 @@ private function updateOptionValues($program) } $debugValue = is_bool($value) ? ($value ? 'true' : 'false') : $value; $this->SendDebug(__FUNCTION__, sprintf('Ident: %s, Value: %s', $ident, $debugValue), 0); + $this->ensureOptionAssociation($ident, $value); $this->SetValue($ident, $value); } } @@ -1066,11 +1070,51 @@ private function refreshActiveProgramOptions() } foreach ($pending as $ident => $value) { if (@IPS_GetObjectIDByIdent($ident, $this->InstanceID)) { + $this->ensureOptionAssociation($ident, $value); $this->SetValue($ident, $value); } } } + /** + * Makes sure a string option value has an association in the variable's profile, so + * it never shows the raw key. Values can arrive via event before any program listing + * them was loaded (forum t/124612 #554). Program keys (e.g. a favorite's BaseProgram) + * reuse the name from the Programs profile, anything else gets the last key snippet. + */ + private function ensureOptionAssociation($ident, $value) + { + if (!is_string($value) || $value == '') { + return; + } + $variable = IPS_GetVariable($this->GetIDForIdent($ident)); + $profileName = $variable['VariableProfile']; + if ($profileName == '' || !IPS_VariableProfileExists($profileName)) { + return; + } + $profile = IPS_GetVariableProfile($profileName); + if ($profile['ProfileType'] != VARIABLETYPE_STRING) { + return; + } + foreach ($profile['Associations'] as $association) { + if ($association['Value'] === $value) { + return; + } + } + $displayName = $this->getLastSnippet($value); + $programsProfile = 'HomeConnect.' . $this->ReadPropertyString('DeviceType') . '.Programs'; + if (IPS_VariableProfileExists($programsProfile)) { + foreach (IPS_GetVariableProfile($programsProfile)['Associations'] as $association) { + if ($association['Value'] === $value) { + $displayName = $association['Name']; + break; + } + } + } + $this->SendDebug(__FUNCTION__, sprintf('Added %s => %s to %s', $value, $displayName, $profileName), 0); + IPS_SetVariableProfileAssociation($profileName, $value, $displayName, '', -1); + } + /** * Enrich selected program data with the available-program metadata so variable * creation can rely on stable option types and constraints while keeping the diff --git a/library.json b/library.json index b765bc4..c2fd08c 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 24, - "date": 1785663591 + "build": 25, + "date": 1790610800 } diff --git a/tests/HomeConnectCoffeeTest.php b/tests/HomeConnectCoffeeTest.php index 992a408..631f1e2 100644 --- a/tests/HomeConnectCoffeeTest.php +++ b/tests/HomeConnectCoffeeTest.php @@ -137,6 +137,65 @@ public function testSelectedProgramEventRefreshesViaRequestAction() $this->assertEquals(self::COFFEE, $this->getChildrenValues($coffeMaker), 'RefreshSelectedProgram action must refresh the options'); } + /** + * Regression for forum t/124612 #554: option profiles are shared by all programs of a + * device type and hold the value list of the last loaded program only - the list feeds + * the selection, so it must stay exact. A value of another program must nevertheless + * be shown readable. Values from the real API responses: only EspressoMachiato offers + * BeanAmount.TripleShotPlus, Espresso offers 14 other bean amounts. + */ + public function testOptionOfOtherProgramIsReadableWhileSelectionStaysExact() + { + $coffeMaker = $this->createCoffeeMaker(); + $intf = IPS\InstanceManager::getInstanceInterface($coffeMaker); + + //An ActiveProgram event loads the EspressoMachiato options (incl. TripleShotPlus). + $intf->ReceiveData($this->notify('BSH.Common.Root.ActiveProgram', 'ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMachiato')); + + //Switching to Espresso replaces the BeanAmount list with Espresso's values. + IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID('{CE76810D-B685-9BE0-CC04-38B204DEAD5E}')[0])->selectedProgram = 'Espresso'; + $intf->ReceiveData($this->notify('BSH.Common.Root.SelectedProgram', 'ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso')); + $profileName = IPS_GetVariable(IPS_GetObjectIDByIdent('OptionBeanAmount', $coffeMaker))['VariableProfile']; + $values = array_column(IPS_GetVariableProfile($profileName)['Associations'], 'Value'); + $this->assertCount(14, $values, 'The selection must offer exactly the values of the selected program'); + $this->assertNotContains('ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShotPlus', $values); + + $intf->ReceiveData($this->notify('ConsumerProducts.CoffeeMaker.Option.BeanAmount', 'ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShotPlus')); + $this->assertSame('TripleShotPlus', $this->formatValue(IPS_GetObjectIDByIdent('OptionBeanAmount', $coffeMaker))); + } + + /** + * Regression for forum t/124612 #554: an option value reported via event that is not + * (yet) in the profile must still get a readable name instead of the raw key. + */ + public function testUnknownOptionValueGetsReadableName() + { + $coffeMaker = $this->createCoffeeMaker(); + $intf = IPS\InstanceManager::getInstanceInterface($coffeMaker); + + $intf->ReceiveData($this->notify('ConsumerProducts.CoffeeMaker.Option.BeanAmount', 'ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShotPlus')); + $this->assertSame('TripleShotPlus', $this->formatValue(IPS_GetObjectIDByIdent('OptionBeanAmount', $coffeMaker))); + } + + private function createCoffeeMaker(): int + { + IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID('{CE76810D-B685-9BE0-CC04-38B204DEAD5E}')[0])->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); + return $coffeMaker; + } + + private function notify(string $key, string $value): string + { + return json_encode([ + 'Event' => 'NOTIFY', + 'Data' => json_encode(['items' => [['handling' => 'none', 'key' => $key, 'value' => $value, 'level' => 'hint']], 'haId' => 'SIEMENS-TI9575X1DE-68A40E251CAD']), + 'ID' => 'SIEMENS-TI9575X1DE-68A40E251CAD' + ]); + } + private function displayChildrenValues($id) { $children = IPS_GetChildrenIDs($id); @@ -179,9 +238,9 @@ private function formatValue(int $variableID): string case VARIABLETYPE_BOOLEAN: return $value ? $profile['Associations'][1]['Name'] : $profile['Associations'][0]['Name']; case VARIABLETYPE_STRING: - for ($i = count($profile['Associations']) - 1; $i >= 0; $i--) { - if ($value == $profile['Associations'][$i]['Value']) { - return $profile['Prefix'] . sprintf($profile['Associations'][$i]['Name'], $value) . $profile['Suffix']; + foreach (array_reverse($profile['Associations']) as $association) { + if ($value == $association['Value']) { + return $profile['Prefix'] . sprintf($association['Name'], $value) . $profile['Suffix']; } } return '-'; From 5d38a427575c414fe8a4bc9dc8b5fb0fa3759044 Mon Sep 17 00:00:00 2001 From: bumaas Date: Fri, 2 Oct 2026 17:27:01 +0200 Subject: [PATCH 2/9] 1.1 build 26: Connected variable on device instances Every device instance gets a "Connected" variable (Yes/No), so a visualization can tell a switched-off appliance (OperationState Inactive, still connected) from one Home Connect reports as offline. It follows the DISCONNECTED/CONNECTED events and is refreshed from the /status request the refresh performs anyway: the error key SDK.Error.HomeAppliance.Connection.Initialization.Failed ("HomeAppliance is offline") means not connected, a regular answer means connected, other errors (e.g. 429) leave the value unchanged. No additional request. OperationState handling and instance status are unchanged. Fixture is a real capture of an offline washer. Before the change 7 new checks failed, after it 58 tests / 599 assertions pass. Co-Authored-By: Claude Opus 5.5 --- Home Connect Device/locale.json | 1 + Home Connect Device/module.php | 19 ++++ library.json | 4 +- tests/HomeConnectCoffeeTest.php | 6 +- tests/HomeConnectConnectedTest.php | 105 ++++++++++++++++++ tests/HomeConnectDryerTest.php | 6 +- .../BOSCH-WAV28G43-68A40E970546/response.json | 11 ++ .../status/response.json | 6 + 8 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 tests/HomeConnectConnectedTest.php create mode 100644 tests/homeappliances/BOSCH-WAV28G43-68A40E970546/response.json create mode 100644 tests/homeappliances/BOSCH-WAV28G43-68A40E970546/status/response.json diff --git a/Home Connect Device/locale.json b/Home Connect Device/locale.json index bc36e42..b0b3049 100644 --- a/Home Connect Device/locale.json +++ b/Home Connect Device/locale.json @@ -14,6 +14,7 @@ "Seconds": "Sekunden", "Yes": "Ja", "No": "Nein", + "Connected": "Verbunden", "Inactive": "Inaktiv", "Ready": "Bereit", "Delayed Start": "Verzögerter Start", diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index d90c0ee..855d541 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -242,12 +242,14 @@ public function ReceiveData($String) $data = json_decode($String, true); switch ($data['Event']) { case 'DISCONNECTED': + $this->setConnected(false); if (@IPS_GetObjectIDByIdent('OperationState', $this->InstanceID)) { // Offline device set OperationState to Inactive $this->SetValue('OperationState', 'BSH.Common.EnumType.OperationState.Inactive'); } break; case 'CONNECTED': + $this->setConnected(true); // 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 @@ -664,6 +666,17 @@ private function setInstanceStatus(int $status): void } } + /** + * "Connected" mirrors the connection state Home Connect reports for the appliance. + * OperationState Inactive cannot tell this apart: a switched-off but connected + * appliance reports Inactive as well. + */ + private function setConnected(bool $connected): void + { + $this->MaintainVariable('Connected', $this->Translate('Connected'), VARIABLETYPE_BOOLEAN, 'HomeConnect.YesNo', 0, true); + $this->SetValue('Connected', $connected); + } + private function needsInitialization(): bool { if ($this->ReadPropertyString('HaID') == '') { @@ -1178,8 +1191,14 @@ private function createStates($states = '') if (!$states) { $data = json_decode($this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/status'), true); if (isset($data['error'])) { + // Home Connect answers /status of an offline appliance with this error. + // Other errors (e.g. 429) say nothing about the connection. + if (($data['error']['key'] ?? '') === 'SDK.Error.HomeAppliance.Connection.Initialization.Failed') { + $this->setConnected(false); + } return false; } + $this->setConnected(true); } else { $data = $states; } diff --git a/library.json b/library.json index c2fd08c..631961a 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 25, - "date": 1790610800 + "build": 26, + "date": 1790954475 } diff --git a/tests/HomeConnectCoffeeTest.php b/tests/HomeConnectCoffeeTest.php index 631f1e2..60d1980 100644 --- a/tests/HomeConnectCoffeeTest.php +++ b/tests/HomeConnectCoffeeTest.php @@ -24,7 +24,8 @@ class HomeConnectCoffeeTest extends TestCase 'RemoteControlStartAllowed' => 'Yes', 'LocalControlActive' => 'No', 'Event' => '', - 'EventDescription' => '' + 'EventDescription' => '', + 'Connected' => 'Yes' ]; public const ESPRESSO = [ @@ -39,7 +40,8 @@ class HomeConnectCoffeeTest extends TestCase 'RemoteControlStartAllowed' => 'Yes', 'LocalControlActive' => 'No', 'Event' => '', - 'EventDescription' => '' + 'EventDescription' => '', + 'Connected' => 'Yes' ]; protected function setUp(): void diff --git a/tests/HomeConnectConnectedTest.php b/tests/HomeConnectConnectedTest.php new file mode 100644 index 0000000..b77813f --- /dev/null +++ b/tests/HomeConnectConnectedTest.php @@ -0,0 +1,105 @@ +createDevice(self::ONLINE_HAID, 'Dryer'); + + $connectedID = @IPS_GetObjectIDByIdent('Connected', $device); + $this->assertNotFalse($connectedID, 'Every device gets a Connected variable'); + $this->assertSame(VARIABLETYPE_BOOLEAN, IPS_GetVariable($connectedID)['VariableType']); + $this->assertTrue(GetValue($connectedID), 'A device answering /status is connected'); + } + + public function testOfflineDeviceIsNotConnected() + { + $device = $this->createDevice(self::OFFLINE_HAID, 'Washer'); + + $connectedID = @IPS_GetObjectIDByIdent('Connected', $device); + $this->assertNotFalse($connectedID, 'The Connected variable also exists for a device that is offline at setup'); + $this->assertFalse(GetValue($connectedID), 'A device reported as offline by /status is not connected'); + } + + public function testDisconnectedAndConnectedEventsToggleConnected() + { + $device = $this->createDevice(self::ONLINE_HAID, 'Dryer'); + $intf = IPS\InstanceManager::getInstanceInterface($device); + $connectedID = IPS_GetObjectIDByIdent('Connected', $device); + + $intf->ReceiveData(json_encode(['Event' => 'DISCONNECTED', 'Data' => '', 'ID' => self::ONLINE_HAID])); + $this->assertFalse(GetValue($connectedID), 'DISCONNECTED clears Connected'); + //Unchanged behavior: OperationState still falls back to Inactive. + $this->assertSame('BSH.Common.EnumType.OperationState.Inactive', GetValue(IPS_GetObjectIDByIdent('OperationState', $device))); + + //The refresh throttle is still active here; Connected must follow the event anyway. + $intf->ReceiveData(json_encode(['Event' => 'CONNECTED', 'Data' => '', 'ID' => self::ONLINE_HAID])); + $this->assertTrue(GetValue($connectedID), 'CONNECTED sets Connected again'); + } + + public function testConnectedDoesNotChangeInstanceStatus() + { + $device = $this->createDevice(self::OFFLINE_HAID, 'Washer'); + $this->assertEquals(IS_ACTIVE, IPS_GetInstance($device)['InstanceStatus'], 'An offline appliance is no instance error'); + + $intf = IPS\InstanceManager::getInstanceInterface($device); + $intf->ReceiveData(json_encode(['Event' => 'DISCONNECTED', 'Data' => '', 'ID' => self::OFFLINE_HAID])); + $this->assertEquals(IS_ACTIVE, IPS_GetInstance($device)['InstanceStatus']); + } + + private function createDevice(string $haID, string $deviceType): int + { + $device = IPS_CreateInstance(self::DEVICE_GUID); + $parent = IPS_GetInstance($device)['ConnectionID']; + IPS\InstanceManager::getInstanceInterface($parent)->selectedProgram = 'Cotton'; + IPS\InstanceManager::setStatus($parent, IS_ACTIVE); + + IPS_SetProperty($device, 'HaID', $haID); + IPS_SetProperty($device, 'DeviceType', $deviceType); + IPS_ApplyChanges($device); + + return $device; + } +} diff --git a/tests/HomeConnectDryerTest.php b/tests/HomeConnectDryerTest.php index dd1a659..b4fcded 100644 --- a/tests/HomeConnectDryerTest.php +++ b/tests/HomeConnectDryerTest.php @@ -23,7 +23,8 @@ class HomeConnectDryerTest extends TestCase 'RemoteControlActive' => 'Yes', 'RemoteControlStartAllowed' => 'Yes', 'Event' => '-', - 'EventDescription' => '' + 'EventDescription' => '', + 'Connected' => 'Yes' ]; public const TIME_COLD = [ @@ -38,7 +39,8 @@ class HomeConnectDryerTest extends TestCase 'RemoteControlActive' => 'Yes', 'RemoteControlStartAllowed' => 'Yes', 'Event' => '-', - 'EventDescription' => '' + 'EventDescription' => '', + 'Connected' => 'Yes' ]; protected function setUp(): void diff --git a/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/response.json b/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/response.json new file mode 100644 index 0000000..406f3a7 --- /dev/null +++ b/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/response.json @@ -0,0 +1,11 @@ +{ + "data": { + "brand": "Bosch", + "connected": false, + "enumber": "WAV28G43/07", + "haId": "BOSCH-WAV28G43-68A40E970546", + "name": "Waschmaschine", + "type": "Washer", + "vib": "WAV28G43" + } +} \ No newline at end of file diff --git a/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/status/response.json b/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/status/response.json new file mode 100644 index 0000000..6772754 --- /dev/null +++ b/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/status/response.json @@ -0,0 +1,6 @@ +{ + "error": { + "description": "HomeAppliance is offline", + "key": "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + } +} \ No newline at end of file From df030c35616e599a1def089263b3bb769489c178 Mon Sep 17 00:00:00 2001 From: bumaas Date: Sat, 3 Oct 2026 17:14:30 +0200 Subject: [PATCH 3/9] 1.1 build 27: Readable names for events missing from the event profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event profile (HomeConnect.Event.) is created only once, so events the module did not know showed "N/A" (Dishcare.Dishwasher.Event. SaltNearlyEmpty, reported by pitti), and associations added in later builds never reached existing installations (a dishwasher profile on a live system lacked RinseAidNearlyEmpty). ensureEventAssociation() now adds a missing association when an event arrives: known events get their translated name, unknown ones a readable name from the key (SomeEvent -> "Some Event"). The event list per device type moved to getEventAssociations(), shared by profile creation and the new check. SaltNearlyEmpty added ("Bitte Salz nachfüllen"). Tests: HomeConnectDishwasherEventTest with the reported event line and real dishwasher captures. Before the fix 3 of 61 tests fail, after it 61 tests / 603 assertions pass. Co-Authored-By: Claude Opus 5.5 --- Home Connect Device/locale.json | 1 + Home Connect Device/module.php | 124 +++++++++------ library.json | 4 +- tests/HomeConnectDishwasherEventTest.php | 120 +++++++++++++++ .../response.json | 64 ++++++++ .../015070396331014803/programs/response.json | 141 ++++++++++++++++++ .../programs/selected/Auto2.json | 68 +++++++++ .../response.json | 20 +++ .../015070396331014803/settings/response.json | 12 ++ .../015070396331014803/status/response.json | 33 ++++ 10 files changed, 541 insertions(+), 46 deletions(-) create mode 100644 tests/HomeConnectDishwasherEventTest.php create mode 100644 tests/homeappliances/015070396331014803/programs/available/Dishcare.Dishwasher.Program.Auto2/response.json create mode 100644 tests/homeappliances/015070396331014803/programs/response.json create mode 100644 tests/homeappliances/015070396331014803/programs/selected/Auto2.json create mode 100644 tests/homeappliances/015070396331014803/settings/BSH.Common.Setting.PowerState/response.json create mode 100644 tests/homeappliances/015070396331014803/settings/response.json create mode 100644 tests/homeappliances/015070396331014803/status/response.json diff --git a/Home Connect Device/locale.json b/Home Connect Device/locale.json index b0b3049..42962a9 100644 --- a/Home Connect Device/locale.json +++ b/Home Connect Device/locale.json @@ -52,6 +52,7 @@ "Please fill bean container": "Bitte Bohnenbehälter füllen", "Please fill water tank": "Bitte Wassertank füllen", "Please fill RinseAid tank": "Bitte Klarspüler nachfüllen", + "Please fill salt": "Bitte Salz nachfüllen", "Please empty drip tray": "Bitte Tropfschalen leeren", "Please descale device": "Bitte Gerät entkalken", "Descaling overdue": "Entkalkung überfällig", diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 855d541..b6cb62e 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -78,6 +78,7 @@ class HomeConnectDevice extends IPSModule 'ConsumerProducts.CoffeeMaker.Event.DeviceCalcNCleanOverdue' => 'Device calc n clean overdue', 'ConsumerProducts.CoffeeMaker.Event.DeviceCalcNCleanBlockage' => 'Device blocked because of calc n clean overdue', 'Dishcare.Dishwasher.Event.RinseAidNearlyEmpty' => 'Please fill RinseAid tank', + 'Dishcare.Dishwasher.Event.SaltNearlyEmpty' => 'Please fill salt', 'LaundryCare.Dryer.Event.DryingProcessFinished' => 'Drying Process Finished', 'Refrigeration.FridgeFreezer.Event.DoorAlarmFreezer' => 'Please close door', 'Refrigeration.FridgeFreezer.Event.DoorAlarmRefrigerator' => 'Please close door', @@ -321,6 +322,7 @@ public function ReceiveData($String) $eventData = json_decode($data['Data'], true); foreach ($eventData['items'] as $item) { if ($item['value'] == 'BSH.Common.EnumType.EventPresentState.Present') { + $this->ensureEventAssociation($item['key']); $this->SetValue('Event', $item['key']); $level = $this->Translate($item['level']); $event = GetValueFormattedEx($this->GetIDForIdent('Event'), $item['key']); @@ -1562,52 +1564,86 @@ private function createEventProfile() $this->SendDebug('Profile', 'HomeConnect.Event.' . $deviceType, 0); if (!IPS_VariableProfileExists('HomeConnect.Event.' . $deviceType)) { IPS_CreateVariableProfile('HomeConnect.Event.' . $deviceType, VARIABLETYPE_STRING); - $associations = []; - if (in_array($deviceType, ['Dishwasher', 'CleaningRobot', 'CookProcessor'])) { - $associations[] = ['Value' => 'BSH.Common.Event.ProgramAborted', 'Name' => 'Program Aborted']; - } - if (in_array($deviceType, ['Dishwasher'])) { - $associations[] = ['Value' => 'Dishcare.Dishwasher.Event.RinseAidNearlyEmpty', 'Name' => 'Please fill RinseAid tank']; - } - if (in_array($deviceType, ['Oven', 'Dishwasher', 'Washer', 'Dryer', 'WasherDryer', 'Cooktop', 'Hood', 'CleaningRobot', 'CookProcessor'])) { - $associations[] = ['Value' => 'BSH.Common.Event.ProgramFinished', 'Name' => 'Program Finished']; - } - if (in_array($deviceType, ['Oven', 'Cooktop'])) { - $associations[] = ['Value' => 'BSH.Common.Event.AlarmClockElapsed', 'Name' => 'Alarm Clock Elapsed']; - $associations[] = ['Value' => 'BSH.Common.Event.PreheatFinished', 'Name' => 'Pre-heat Finished']; - } - if (in_array($deviceType, ['Hob'])) { - $associations[] = ['Value' => 'BSH.Common.Event.ProgramFinished', 'Name' => 'Program Finished']; - $associations[] = ['Value' => 'BSH.Common.Event.AlarmClockElapsed', 'Name' => 'Alarm Clock Elapsed']; - } - if (in_array($deviceType, ['CoffeeMaker'])) { - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.BeanContainerEmpty', 'Name' => 'Bean Container Empty']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.WaterTankEmpty', 'Name' => 'Water Tank Empty']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DripTrayFull', 'Name' => 'Drip Tray Full']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceShouldBeDescaled', 'Name' => 'Please descale device']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceDescalingOverdue', 'Name' => 'Descaling overdue']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceDescalingBlockage', 'Name' => 'Device blocked because of descaling overdue']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceShouldBeCleaned', 'Name' => 'Please clean device']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceCleaningOverdue', 'Name' => 'Cleaning overdue']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceShouldBeCalcNCleaned', 'Name' => 'Please calc n clean device']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceCalcNCleanOverdue', 'Name' => 'Device calc n clean overdue']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceCalcNCleanBlockage', 'Name' => 'Device blocked because of calc n clean overdue']; - } - if (in_array($deviceType, ['FridgeFreezer', 'Freezer'])) { - $associations[] = ['Value' => 'Refrigeration.FridgeFreezer.Event.DoorAlarmFreezer', 'Name' => 'Door Alarm Freezer']; - $associations[] = ['Value' => 'Refrigeration.FridgeFreezer.Event.TemperatureAlarmFreezer', 'Name' => 'Temperature Alarm Freezer']; - } - if (in_array($deviceType, ['FridgeFreezer', 'Refrigerator'])) { - $associations[] = ['Value' => 'Refrigeration.FridgeFreezer.Event.DoorAlarmRefrigerator', 'Name' => 'Door Alarm Refrigerator']; - } - if (in_array($deviceType, ['CleaningRobot'])) { - $associations[] = ['Value' => 'ConsumerProducts.CleaningRobot.Event.EmptyDustBoxAndCleanFilter', 'Name' => 'Empty Dust Box and Clean Filter']; - $associations[] = ['Value' => 'ConsumerProducts.CleaningRobot.Event.RobotIsStuck', 'Name' => 'Robot is Stuck']; - $associations[] = ['Value' => 'ConsumerProducts.CleaningRobot.Event.DockingStationNotFound', 'Name' => 'Docking Station not Found']; - } - $this->createAssociations('HomeConnect.Event.' . $deviceType, $associations); + $this->createAssociations('HomeConnect.Event.' . $deviceType, $this->getEventAssociations($deviceType)); + } + } + } + + /** + * The profile is created only once, so events added in later builds (or not known at + * all) would show "N/A" on existing installations. Adds a missing association when the + * event arrives: known events get their translated name, unknown ones a readable name + * built from the key (SaltNearlyEmpty -> "Salt Nearly Empty"). + */ + private function ensureEventAssociation(string $key): void + { + $profileName = IPS_GetVariable($this->GetIDForIdent('Event'))['VariableProfile']; + if ($profileName == '' || !IPS_VariableProfileExists($profileName)) { + return; + } + foreach (IPS_GetVariableProfile($profileName)['Associations'] as $association) { + if ($association['Value'] === $key) { + return; } } + $displayName = trim(preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', ' ', $this->getLastSnippet($key))); + foreach ($this->getEventAssociations($this->ReadPropertyString('DeviceType')) as $association) { + if ($association['Value'] === $key) { + $displayName = $this->Translate($association['Name']); + break; + } + } + $this->SendDebug(__FUNCTION__, sprintf('Added %s => %s to %s', $key, $displayName, $profileName), 0); + IPS_SetVariableProfileAssociation($profileName, $key, $displayName, '', -1); + } + + private function getEventAssociations(string $deviceType): array + { + $associations = []; + if (in_array($deviceType, ['Dishwasher', 'CleaningRobot', 'CookProcessor'])) { + $associations[] = ['Value' => 'BSH.Common.Event.ProgramAborted', 'Name' => 'Program Aborted']; + } + if (in_array($deviceType, ['Dishwasher'])) { + $associations[] = ['Value' => 'Dishcare.Dishwasher.Event.RinseAidNearlyEmpty', 'Name' => 'Please fill RinseAid tank']; + $associations[] = ['Value' => 'Dishcare.Dishwasher.Event.SaltNearlyEmpty', 'Name' => 'Please fill salt']; + } + if (in_array($deviceType, ['Oven', 'Dishwasher', 'Washer', 'Dryer', 'WasherDryer', 'Cooktop', 'Hood', 'CleaningRobot', 'CookProcessor'])) { + $associations[] = ['Value' => 'BSH.Common.Event.ProgramFinished', 'Name' => 'Program Finished']; + } + if (in_array($deviceType, ['Oven', 'Cooktop'])) { + $associations[] = ['Value' => 'BSH.Common.Event.AlarmClockElapsed', 'Name' => 'Alarm Clock Elapsed']; + $associations[] = ['Value' => 'BSH.Common.Event.PreheatFinished', 'Name' => 'Pre-heat Finished']; + } + if (in_array($deviceType, ['Hob'])) { + $associations[] = ['Value' => 'BSH.Common.Event.ProgramFinished', 'Name' => 'Program Finished']; + $associations[] = ['Value' => 'BSH.Common.Event.AlarmClockElapsed', 'Name' => 'Alarm Clock Elapsed']; + } + if (in_array($deviceType, ['CoffeeMaker'])) { + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.BeanContainerEmpty', 'Name' => 'Bean Container Empty']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.WaterTankEmpty', 'Name' => 'Water Tank Empty']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DripTrayFull', 'Name' => 'Drip Tray Full']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceShouldBeDescaled', 'Name' => 'Please descale device']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceDescalingOverdue', 'Name' => 'Descaling overdue']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceDescalingBlockage', 'Name' => 'Device blocked because of descaling overdue']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceShouldBeCleaned', 'Name' => 'Please clean device']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceCleaningOverdue', 'Name' => 'Cleaning overdue']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceShouldBeCalcNCleaned', 'Name' => 'Please calc n clean device']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceCalcNCleanOverdue', 'Name' => 'Device calc n clean overdue']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceCalcNCleanBlockage', 'Name' => 'Device blocked because of calc n clean overdue']; + } + if (in_array($deviceType, ['FridgeFreezer', 'Freezer'])) { + $associations[] = ['Value' => 'Refrigeration.FridgeFreezer.Event.DoorAlarmFreezer', 'Name' => 'Door Alarm Freezer']; + $associations[] = ['Value' => 'Refrigeration.FridgeFreezer.Event.TemperatureAlarmFreezer', 'Name' => 'Temperature Alarm Freezer']; + } + if (in_array($deviceType, ['FridgeFreezer', 'Refrigerator'])) { + $associations[] = ['Value' => 'Refrigeration.FridgeFreezer.Event.DoorAlarmRefrigerator', 'Name' => 'Door Alarm Refrigerator']; + } + if (in_array($deviceType, ['CleaningRobot'])) { + $associations[] = ['Value' => 'ConsumerProducts.CleaningRobot.Event.EmptyDustBoxAndCleanFilter', 'Name' => 'Empty Dust Box and Clean Filter']; + $associations[] = ['Value' => 'ConsumerProducts.CleaningRobot.Event.RobotIsStuck', 'Name' => 'Robot is Stuck']; + $associations[] = ['Value' => 'ConsumerProducts.CleaningRobot.Event.DockingStationNotFound', 'Name' => 'Docking Station not Found']; + } + return $associations; } private function executeApplicanceCommand($command) diff --git a/library.json b/library.json index 631961a..7abe7dd 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 26, - "date": 1790954475 + "build": 27, + "date": 1791040379 } diff --git a/tests/HomeConnectDishwasherEventTest.php b/tests/HomeConnectDishwasherEventTest.php new file mode 100644 index 0000000..51efffa --- /dev/null +++ b/tests/HomeConnectDishwasherEventTest.php @@ -0,0 +1,120 @@ +createDishwasher(); + $intf = IPS\InstanceManager::getInstanceInterface($dishwasher); + + $intf->ReceiveData($this->event(self::SALT_EVENT)); + + $eventID = IPS_GetObjectIDByIdent('Event', $dishwasher); + $this->assertSame('Dishcare.Dishwasher.Event.SaltNearlyEmpty', GetValue($eventID)); + $this->assertSame('Please fill salt', $this->associationName($eventID)); + } + + public function testProfileFromOlderBuildIsCompletedOnEvent() + { + //Profile as found on the nuc: created by an older build, only two associations. + IPS_CreateVariableProfile('HomeConnect.Event.Dishwasher', VARIABLETYPE_STRING); + IPS_SetVariableProfileAssociation('HomeConnect.Event.Dishwasher', 'BSH.Common.Event.ProgramAborted', 'Program abgebrochen', '', -1); + IPS_SetVariableProfileAssociation('HomeConnect.Event.Dishwasher', 'BSH.Common.Event.ProgramFinished', 'Program fertig', '', -1); + + $dishwasher = $this->createDishwasher(); + $intf = IPS\InstanceManager::getInstanceInterface($dishwasher); + + //Same structure as pitti's line, key of an event the module already knows. + $intf->ReceiveData($this->event(str_replace('SaltNearlyEmpty', 'RinseAidNearlyEmpty', self::SALT_EVENT))); + + $this->assertSame('Please fill RinseAid tank', $this->associationName(IPS_GetObjectIDByIdent('Event', $dishwasher))); + } + + public function testUnknownEventFallsBackToKeyName() + { + $dishwasher = $this->createDishwasher(); + $intf = IPS\InstanceManager::getInstanceInterface($dishwasher); + + //Not a real Home Connect key: stands for any event the module does not know yet. + $intf->ReceiveData($this->event(str_replace('SaltNearlyEmpty', 'SomeFutureEvent', self::SALT_EVENT))); + + $this->assertSame('Some Future Event', $this->associationName(IPS_GetObjectIDByIdent('Event', $dishwasher))); + } + + private function createDishwasher(): int + { + $dishwasher = IPS_CreateInstance(self::DEVICE_GUID); + $parent = IPS_GetInstance($dishwasher)['ConnectionID']; + IPS\InstanceManager::getInstanceInterface($parent)->selectedProgram = 'Auto2'; + IPS\InstanceManager::setStatus($parent, IS_ACTIVE); + + IPS_SetProperty($dishwasher, 'HaID', self::HAID); + IPS_SetProperty($dishwasher, 'DeviceType', 'Dishwasher'); + IPS_ApplyChanges($dishwasher); + + return $dishwasher; + } + + /** + * Name of the profile association matching the current value. The stubs neither + * translate nor resolve profiles behind a presentation in GetValueFormatted, so the + * association is read directly (names stay English here, see locale.json). + */ + private function associationName(int $variableID): string + { + $variable = IPS_GetVariable($variableID); + foreach (IPS_GetVariableProfile($variable['VariableProfile'])['Associations'] as $association) { + if ($association['Value'] === $variable['VariableValue']) { + return $association['Name']; + } + } + return '-'; + } + + private function event(string $data): string + { + return json_encode(['Event' => 'EVENT', 'Data' => $data, 'ID' => self::HAID]); + } +} diff --git a/tests/homeappliances/015070396331014803/programs/available/Dishcare.Dishwasher.Program.Auto2/response.json b/tests/homeappliances/015070396331014803/programs/available/Dishcare.Dishwasher.Program.Auto2/response.json new file mode 100644 index 0000000..6e516c2 --- /dev/null +++ b/tests/homeappliances/015070396331014803/programs/available/Dishcare.Dishwasher.Program.Auto2/response.json @@ -0,0 +1,64 @@ +{ + "data": { + "key": "Dishcare.Dishwasher.Program.Auto2", + "name": "Auto 45-65°", + "options": [ + { + "name": "Startzeit", + "key": "BSH.Common.Option.StartInRelative", + "constraints": { + "min": 0, + "max": 86400, + "liveupdate": true + }, + "unit": "seconds", + "type": "Int" + }, + { + "key": "Dishcare.Dishwasher.Option.HygienePlus", + "type": "Boolean", + "constraints": { + "default": false, + "liveupdate": true + }, + "name": "HygienePlus" + }, + { + "key": "Dishcare.Dishwasher.Option.HalfLoad", + "type": "Boolean", + "constraints": { + "default": false, + "liveupdate": true + }, + "name": "Halbe Beladung" + }, + { + "key": "Dishcare.Dishwasher.Option.IntensivZone", + "type": "Boolean", + "constraints": { + "default": false, + "liveupdate": true + }, + "name": "IntensivZone" + }, + { + "key": "Dishcare.Dishwasher.Option.VarioSpeedPlus", + "type": "Boolean", + "constraints": { + "default": false, + "liveupdate": true + }, + "name": "VarioSpeedPlus" + }, + { + "key": "Dishcare.Dishwasher.Option.BrillianceDry", + "type": "Boolean", + "constraints": { + "default": false, + "liveupdate": true + }, + "name": "Glanztrocknen" + } + ] + } +} \ No newline at end of file diff --git a/tests/homeappliances/015070396331014803/programs/response.json b/tests/homeappliances/015070396331014803/programs/response.json new file mode 100644 index 0000000..e7846f7 --- /dev/null +++ b/tests/homeappliances/015070396331014803/programs/response.json @@ -0,0 +1,141 @@ +{ + "data": { + "active": { + "constraints": { + "access": "readWrite" + } + }, + "programs": [ + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.Eco50", + "name": "Eco 50°" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.Auto2", + "name": "Auto 45-65°" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.Intensiv70", + "name": "Intensiv 70°" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.Kurz60", + "name": "Speed 60°" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.NightWash", + "name": "Leise 50°C" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.MachineCare", + "name": "Maschinenpflege" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.PreRinse", + "name": "Vorspülen" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.Quick45", + "name": "Schnell 45°C" + } + ], + "selected": { + "constraints": { + "access": "readWrite" + }, + "key": "Dishcare.Dishwasher.Program.Auto2", + "name": "Auto 45-65°", + "options": [ + { + "key": "BSH.Common.Option.StartInRelative", + "value": 0, + "unit": "seconds", + "name": "Startzeit" + }, + { + "key": "BSH.Common.Option.SmartEnergyService.SmartStartEnabled", + "value": false + }, + { + "key": "Dishcare.Dishwasher.Option.HygienePlus", + "value": false, + "name": "HygienePlus" + }, + { + "key": "Dishcare.Dishwasher.Option.HalfLoad", + "value": false, + "name": "Halbe Beladung" + }, + { + "key": "Dishcare.Dishwasher.Option.IntensivZone", + "value": false, + "name": "IntensivZone" + }, + { + "key": "Dishcare.Dishwasher.Option.VarioSpeedPlus", + "value": false, + "name": "VarioSpeedPlus" + }, + { + "key": "Dishcare.Dishwasher.Option.BrillianceDry", + "value": false, + "name": "Glanztrocknen" + }, + { + "key": "Dishcare.Dishwasher.Option.SilenceOnDemand", + "value": false, + "name": "Jetzt Leise" + }, + { + "key": "BSH.Common.Option.RemainingProgramTimeIsEstimated", + "value": true, + "name": "Geschätzte Restprogrammlaufzeit" + }, + { + "key": "BSH.Common.Option.EnergyForecast", + "value": 58, + "unit": "%", + "name": "Energie" + }, + { + "key": "BSH.Common.Option.WaterForecast", + "value": 49, + "unit": "%", + "name": "Wasser" + } + ] + } + } +} \ No newline at end of file diff --git a/tests/homeappliances/015070396331014803/programs/selected/Auto2.json b/tests/homeappliances/015070396331014803/programs/selected/Auto2.json new file mode 100644 index 0000000..a5b95e8 --- /dev/null +++ b/tests/homeappliances/015070396331014803/programs/selected/Auto2.json @@ -0,0 +1,68 @@ +{ + "data": { + "constraints": { + "access": "readWrite" + }, + "key": "Dishcare.Dishwasher.Program.Auto2", + "name": "Auto 45-65°", + "options": [ + { + "key": "BSH.Common.Option.StartInRelative", + "value": 0, + "unit": "seconds", + "name": "Startzeit" + }, + { + "key": "BSH.Common.Option.SmartEnergyService.SmartStartEnabled", + "value": false + }, + { + "key": "Dishcare.Dishwasher.Option.HygienePlus", + "value": false, + "name": "HygienePlus" + }, + { + "key": "Dishcare.Dishwasher.Option.HalfLoad", + "value": false, + "name": "Halbe Beladung" + }, + { + "key": "Dishcare.Dishwasher.Option.IntensivZone", + "value": false, + "name": "IntensivZone" + }, + { + "key": "Dishcare.Dishwasher.Option.VarioSpeedPlus", + "value": false, + "name": "VarioSpeedPlus" + }, + { + "key": "Dishcare.Dishwasher.Option.BrillianceDry", + "value": false, + "name": "Glanztrocknen" + }, + { + "key": "Dishcare.Dishwasher.Option.SilenceOnDemand", + "value": false, + "name": "Jetzt Leise" + }, + { + "key": "BSH.Common.Option.RemainingProgramTimeIsEstimated", + "value": true, + "name": "Geschätzte Restprogrammlaufzeit" + }, + { + "key": "BSH.Common.Option.EnergyForecast", + "value": 58, + "unit": "%", + "name": "Energie" + }, + { + "key": "BSH.Common.Option.WaterForecast", + "value": 49, + "unit": "%", + "name": "Wasser" + } + ] + } +} \ No newline at end of file diff --git a/tests/homeappliances/015070396331014803/settings/BSH.Common.Setting.PowerState/response.json b/tests/homeappliances/015070396331014803/settings/BSH.Common.Setting.PowerState/response.json new file mode 100644 index 0000000..d6c42c3 --- /dev/null +++ b/tests/homeappliances/015070396331014803/settings/BSH.Common.Setting.PowerState/response.json @@ -0,0 +1,20 @@ +{ + "data": { + "name": "Energiezustand", + "key": "BSH.Common.Setting.PowerState", + "constraints": { + "allowedvalues": [ + "BSH.Common.EnumType.PowerState.Off", + "BSH.Common.EnumType.PowerState.On" + ], + "displayvalues": [ + "Aus", + "An" + ], + "access": "readWrite" + }, + "type": "BSH.Common.EnumType.PowerState", + "displayvalue": "Aus", + "value": "BSH.Common.EnumType.PowerState.Off" + } +} \ No newline at end of file diff --git a/tests/homeappliances/015070396331014803/settings/response.json b/tests/homeappliances/015070396331014803/settings/response.json new file mode 100644 index 0000000..4592880 --- /dev/null +++ b/tests/homeappliances/015070396331014803/settings/response.json @@ -0,0 +1,12 @@ +{ + "data": { + "settings": [ + { + "key": "BSH.Common.Setting.PowerState", + "value": "BSH.Common.EnumType.PowerState.Off", + "name": "Energiezustand", + "displayvalue": "Aus" + } + ] + } +} \ No newline at end of file diff --git a/tests/homeappliances/015070396331014803/status/response.json b/tests/homeappliances/015070396331014803/status/response.json new file mode 100644 index 0000000..8d99036 --- /dev/null +++ b/tests/homeappliances/015070396331014803/status/response.json @@ -0,0 +1,33 @@ +{ + "data": { + "status": [ + { + "key": "BSH.Common.Status.RemoteControlStartAllowed", + "value": true, + "name": "Fernstart" + }, + { + "key": "BSH.Common.Status.RemoteControlActive", + "value": true, + "name": "Fernbedienung" + }, + { + "key": "BSH.Common.Status.DoorState", + "value": "BSH.Common.EnumType.DoorState.Closed", + "name": "Tür", + "displayvalue": "Geschlossen" + }, + { + "key": "BSH.Common.Status.OperationState", + "value": "BSH.Common.EnumType.OperationState.Ready", + "name": "Betriebszustand", + "displayvalue": "Bereit" + }, + { + "key": "BSH.Common.Status.InteriorIlluminationActive", + "value": true, + "name": "Innenbeleuchtung" + } + ] + } +} \ No newline at end of file From 4155211cf9e4731e6b7b92d1104bc739192a733a Mon Sep 17 00:00:00 2001 From: bumaas Date: Sun, 4 Oct 2026 11:37:36 +0200 Subject: [PATCH 4/9] 1.1 build 28: Readable names for programs chosen at the appliance The API lists only the selectable programs (three on a WNC254A40 washer dryer), but programs chosen at the appliance are reported via SelectedProgram as well (about 25 according to forum t/124612 #572). Without a profile association the Program variable showed the raw key. SelectedProgram events and the selected-program refresh now add a missing association via ensureProgramAssociation(). createPrograms() re-adds the currently selected program after rebuilding the profile, so it survives ApplyChanges. Keys without a known name get a readable name from the key (DelicatesSilk -> "Delicates Silk"), shared with the event profile via getReadableName(). Tests: HomeConnectWasherDryerTest with fixtures taken unchanged from the user's debug dump (t/124612 #570). Before the fix 3 of 65 tests fail, after it 65 tests / 610 assertions pass. # doku:ok Co-Authored-By: Claude Opus 5.5 --- Home Connect Device/module.php | 24 ++- library.json | 4 +- tests/HomeConnectWasherDryerTest.php | 137 ++++++++++++++++++ .../programs/response.json | 40 +++++ .../response.json | 21 +++ .../response.json | 16 ++ .../response.json | 16 ++ .../settings/response.json | 24 +++ .../BOSCH-WNC254A40-XYZ/status/response.json | 33 +++++ 9 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 tests/HomeConnectWasherDryerTest.php create mode 100644 tests/homeappliances/BOSCH-WNC254A40-XYZ/programs/response.json create mode 100644 tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/BSH.Common.Setting.PowerState/response.json create mode 100644 tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos1BaseLevel/response.json create mode 100644 tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos2BaseLevel/response.json create mode 100644 tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/response.json create mode 100644 tests/homeappliances/BOSCH-WNC254A40-XYZ/status/response.json diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index b6cb62e..244c368 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -303,6 +303,10 @@ public function ReceiveData($String) if (@IPS_GetObjectIDByIdent($ident, $this->InstanceID)) { if (strpos($ident, 'Option') === 0) { $this->ensureOptionAssociation($ident, $item['value']); + } elseif ($ident == 'SelectedProgram' && is_string($item['value']) && $item['value'] != '') { + // Programs chosen at the appliance are reported here + // although programs does not list them. + $this->ensureProgramAssociation($item['value']); } $this->SetValue($ident, $item['value']); } elseif (strpos($ident, 'Option') === 0) { @@ -729,6 +733,12 @@ private function createPrograms() $ident = 'SelectedProgram'; $this->MaintainVariable($ident, $this->Translate('Program'), VARIABLETYPE_STRING, $profileName, 1, true); $this->EnableAction($ident); + // The rebuild keeps only the listed programs. A program chosen at the appliance + // and still selected would show its raw key until the next event. + $selectedProgram = $this->GetValue($ident); + if (is_string($selectedProgram) && $selectedProgram != '') { + $this->ensureProgramAssociation($selectedProgram); + } return true; } @@ -939,6 +949,7 @@ private function updateOptionValues($program) $this->syncUseDurationVariable(false, 0); return; } + $this->ensureProgramAssociation($program['key']); $this->SetValue('SelectedProgram', $program['key']); $this->updateOptionVariables($program); $optionKeys = []; @@ -1034,7 +1045,7 @@ private function ensureProgramAssociation($key) // The snippet fallback would show the bare number ("003"). $displayName = sprintf($this->Translate('Favorite %d'), (int) $matches['number']); } else { - $displayName = $this->getLastSnippet($key); + $displayName = $this->getReadableName($key); } IPS_SetVariableProfileAssociation($profileName, $key, $displayName, '', -1); return $profileName; @@ -1285,6 +1296,15 @@ private function getLastSnippet($string) return substr($string, strrpos($string, '.') + 1, strlen($string) - strrpos($string, '.')); } + /** + * Readable name built from the last key snippet for keys without a known name + * (SaltNearlyEmpty -> "Salt Nearly Empty", DelicatesSilk -> "Delicates Silk"). + */ + private function getReadableName(string $key): string + { + return trim(preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', ' ', $this->getLastSnippet($key))); + } + private function createAssociations($profileName, $associations) { foreach ($associations as $association) { @@ -1586,7 +1606,7 @@ private function ensureEventAssociation(string $key): void return; } } - $displayName = trim(preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', ' ', $this->getLastSnippet($key))); + $displayName = $this->getReadableName($key); foreach ($this->getEventAssociations($this->ReadPropertyString('DeviceType')) as $association) { if ($association['Value'] === $key) { $displayName = $this->Translate($association['Name']); diff --git a/library.json b/library.json index 7abe7dd..6aefc56 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 27, - "date": 1791040379 + "build": 28, + "date": 1791106619 } diff --git a/tests/HomeConnectWasherDryerTest.php b/tests/HomeConnectWasherDryerTest.php new file mode 100644 index 0000000..6ad041a --- /dev/null +++ b/tests/HomeConnectWasherDryerTest.php @@ -0,0 +1,137 @@ +ConfiguratorID = IPS_CreateInstance('{CA0E667D-8F28-8DF1-2750-5CF587ECA85A}'); + $this->CloudID = IPS_CreateInstance('{CE76810D-B685-9BE0-CC04-38B204DEAD5E}'); + IPS_ConnectInstance($this->ConfiguratorID, $this->CloudID); + IPS\InstanceManager::setStatus($this->ConfiguratorID, 102); + IPS\InstanceManager::setStatus($this->CloudID, 102); + + parent::setUp(); + } + + public function testProgramProfileContainsTheListedPrograms() + { + $this->createWasherDryer(); + + $this->assertEquals([ + 'LaundryCare.WasherDryer.Program.Cotton.Eco4060' => 'Eco 40-60', + 'LaundryCare.WasherDryer.Program.Cotton' => 'Baumwolle', + 'LaundryCare.WasherDryer.Program.Mix' => 'Schnell/Mix', + ], $this->associations()); + } + + /** + * A program selected at the appliance is reported via SelectedProgram although the + * API does not list it. Without an association the variable shows the raw key. + */ + public function testSelectedProgramEventAddsReadableAssociation() + { + $washerDryer = $this->createWasherDryer(); + $intf = IPS\InstanceManager::getInstanceInterface($washerDryer); + + $intf->ReceiveData($this->generateNotifyEvent('BSH.Common.Root.SelectedProgram', 'LaundryCare.WasherDryer.Program.PlushToy', 'programs/selected')); + + $this->assertEquals('LaundryCare.WasherDryer.Program.PlushToy', GetValue(IPS_GetObjectIDByIdent('SelectedProgram', $washerDryer))); + $this->assertSame('Plush Toy', $this->associations()['LaundryCare.WasherDryer.Program.PlushToy'] ?? null); + } + + /** + * ApplyChanges (e.g. on every kernel start) rebuilds the profile from the API list. + * The currently selected program must keep its association. + */ + public function testApplyChangesKeepsAssociationOfSelectedProgram() + { + $washerDryer = $this->createWasherDryer(); + $intf = IPS\InstanceManager::getInstanceInterface($washerDryer); + $intf->ReceiveData($this->generateNotifyEvent('BSH.Common.Root.SelectedProgram', 'LaundryCare.WasherDryer.Program.ShirtsBlouses', 'programs/selected')); + + IPS_ApplyChanges($washerDryer); + + $this->assertEquals('LaundryCare.WasherDryer.Program.ShirtsBlouses', GetValue(IPS_GetObjectIDByIdent('SelectedProgram', $washerDryer))); + $this->assertSame('Shirts Blouses', $this->associations()['LaundryCare.WasherDryer.Program.ShirtsBlouses'] ?? null); + $this->assertSame('Schnell/Mix', $this->associations()['LaundryCare.WasherDryer.Program.Mix'] ?? null); + } + + public function testActiveProgramEventGetsReadableName() + { + $washerDryer = $this->createWasherDryer(); + $intf = IPS\InstanceManager::getInstanceInterface($washerDryer); + + $intf->ReceiveData($this->generateNotifyEvent('BSH.Common.Root.ActiveProgram', 'LaundryCare.WasherDryer.Program.DelicatesSilk', 'programs/active')); + + $this->assertSame('Delicates Silk', $this->associations()['LaundryCare.WasherDryer.Program.DelicatesSilk'] ?? null); + } + + private function createWasherDryer() + { + $washerDryer = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + IPS_ConnectInstance($washerDryer, $this->ConfiguratorID); + IPS_SetProperty($washerDryer, 'HaID', self::HA_ID); + IPS_SetProperty($washerDryer, 'DeviceType', 'WasherDryer'); + IPS_ApplyChanges($washerDryer); + return $washerDryer; + } + + private function associations() + { + $associations = []; + foreach (IPS_GetVariableProfile(self::PROFILE)['Associations'] as $association) { + $associations[$association['Value']] = $association['Name']; + } + return $associations; + } + + private function generateNotifyEvent($key, $value, $uriPath) + { + return json_encode([ + 'Event' => 'NOTIFY', + 'Data' => json_encode([ + 'items' => [[ + 'timestamp' => 1759561437, + 'handling' => 'none', + 'uri' => '/api/homeappliances/' . self::HA_ID . '/' . $uriPath, + 'key' => $key, + 'value' => $value, + 'level' => 'hint', + ]], + 'haId' => self::HA_ID, + ]), + 'id' => self::HA_ID, + ]); + } +} diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/programs/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/programs/response.json new file mode 100644 index 0000000..bec8248 --- /dev/null +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/programs/response.json @@ -0,0 +1,40 @@ +{ + "data": { + "active": { + "constraints": { + "access": "read" + } + }, + "programs": [ + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "LaundryCare.WasherDryer.Program.Cotton.Eco4060", + "name": "Eco 40-60" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "LaundryCare.WasherDryer.Program.Cotton", + "name": "Baumwolle" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "LaundryCare.WasherDryer.Program.Mix", + "name": "Schnell/Mix" + } + ], + "selected": { + "constraints": { + "access": "read" + } + } + } +} diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/BSH.Common.Setting.PowerState/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/BSH.Common.Setting.PowerState/response.json new file mode 100644 index 0000000..ecab5af --- /dev/null +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/BSH.Common.Setting.PowerState/response.json @@ -0,0 +1,21 @@ +{ + "data": { + "name": "Energiezustand", + "key": "BSH.Common.Setting.PowerState", + "constraints": { + "allowedvalues": [ + "BSH.Common.EnumType.PowerState.Off", + "BSH.Common.EnumType.PowerState.On" + ], + "displayvalues": [ + "Aus", + "An" + ], + "default": "BSH.Common.EnumType.PowerState.On", + "access": "readWrite" + }, + "type": "BSH.Common.EnumType.PowerState", + "displayvalue": "Aus", + "value": "BSH.Common.EnumType.PowerState.Off" + } +} diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos1BaseLevel/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos1BaseLevel/response.json new file mode 100644 index 0000000..c7404cf --- /dev/null +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos1BaseLevel/response.json @@ -0,0 +1,16 @@ +{ + "data": { + "name": "Basisdosiermenge: Waschmittel", + "key": "LaundryCare.Washer.Setting.IDos1BaseLevel", + "constraints": { + "min": 5, + "max": 200, + "stepsize": 1, + "default": 36, + "access": "readWrite" + }, + "unit": "ml", + "type": "Int", + "value": 50 + } +} diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos2BaseLevel/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos2BaseLevel/response.json new file mode 100644 index 0000000..f782ec2 --- /dev/null +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos2BaseLevel/response.json @@ -0,0 +1,16 @@ +{ + "data": { + "name": "Basisdosiermenge: Waschmittel oder Weichspüler", + "key": "LaundryCare.Washer.Setting.IDos2BaseLevel", + "constraints": { + "min": 5, + "max": 200, + "stepsize": 1, + "default": 75, + "access": "readWrite" + }, + "unit": "ml", + "type": "Int", + "value": 65 + } +} diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/response.json new file mode 100644 index 0000000..f23cda5 --- /dev/null +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/response.json @@ -0,0 +1,24 @@ +{ + "data": { + "settings": [ + { + "key": "BSH.Common.Setting.PowerState", + "value": "BSH.Common.EnumType.PowerState.Off", + "name": "Energiezustand", + "displayvalue": "Aus" + }, + { + "key": "LaundryCare.Washer.Setting.IDos1BaseLevel", + "value": 50, + "unit": "ml", + "name": "Basisdosiermenge: Waschmittel" + }, + { + "key": "LaundryCare.Washer.Setting.IDos2BaseLevel", + "value": 65, + "unit": "ml", + "name": "Basisdosiermenge: Waschmittel oder Weichspüler" + } + ] + } +} diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/status/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/status/response.json new file mode 100644 index 0000000..b29f605 --- /dev/null +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/status/response.json @@ -0,0 +1,33 @@ +{ + "data": { + "status": [ + { + "key": "BSH.Common.Status.DoorState", + "value": "BSH.Common.EnumType.DoorState.Open", + "name": "Tür", + "displayvalue": "Offen" + }, + { + "key": "BSH.Common.Status.LocalControlActive", + "value": false, + "name": "Lokale Bedienung aktiv" + }, + { + "key": "BSH.Common.Status.OperationState", + "value": "BSH.Common.EnumType.OperationState.Inactive", + "name": "Betriebszustand", + "displayvalue": "Inaktiv" + }, + { + "key": "BSH.Common.Status.RemoteControlActive", + "value": true, + "name": "Fernbedienung" + }, + { + "key": "BSH.Common.Status.RemoteControlStartAllowed", + "value": true, + "name": "Fernstart" + } + ] + } +} From ae3d8d9c7f892f1a1a056f4785dd5bbb6f46ee01 Mon Sep 17 00:00:00 2001 From: bumaas Date: Sun, 4 Oct 2026 12:53:49 +0200 Subject: [PATCH 5/9] 1.1 build 29: Recover the event stream after a rate-limit block Three findings from the code review of the rate-limit path: - A restart during a block reset the RateLimit timer, while RateLimitUntil and the IO deactivated for the block persisted. Nothing re-activated the IO, so the event stream stayed dead until the user switched it on by hand. The keep-alive watchdog now lifts an expired block itself; an IO the user switched off (no block pending) is left alone. - A 401 "invalid_token" from the stream reconnected with the cached access token the server had just rejected (still valid by its local expiry), costing a GET /events per attempt. The cached token is now dropped. - ResetRateLimit reported IS_ACTIVE before the IO was re-activated. Children answering with HasActiveParent() could see the IO inactive, go inactive and, due to their LastParentStatus guard, never retry. The status is now set after the stream is registered. testInvalidTokenReconnectsEventStream relied on the reused token and now asserts that a fresh one is requested. Red/green: without the fix 4 of 69 tests fail, with it all 69 pass (621 assertions). Co-Authored-By: Claude Opus 5.5 --- Home Connect Cloud/module.php | 22 +++++-- library.json | 4 +- tests/HomeConnectCloudTest.php | 107 ++++++++++++++++++++++++++++++++- 3 files changed, 125 insertions(+), 8 deletions(-) diff --git a/Home Connect Cloud/module.php b/Home Connect Cloud/module.php index 234c367..6071bc2 100644 --- a/Home Connect Cloud/module.php +++ b/Home Connect Cloud/module.php @@ -230,6 +230,14 @@ public function CheckServerEvents() if ($this->isRateLimitActive()) { return; } + // A block that has expired but was never lifted: a restart during the block + // resets the RateLimit timer, while RateLimitUntil and the IO deactivated by + // applyRateLimit persist. Lift it here, otherwise the stream stays dead. + if ($this->ReadAttributeInteger('RateLimitUntil') !== 0) { + $this->SendDebug('KeepAlive', 'Expired rate limit was not lifted - resetting', 0); + $this->ResetRateLimit(); + return; + } // 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 @@ -273,13 +281,18 @@ public function ResetRateLimit() $this->WriteAttributeString('RateError', ''); $this->WriteAttributeInteger('RateLimitUntil', 0); $this->updateRateLimitNotice(); - $this->SetStatus(IS_ACTIVE); $this->SetTimerInterval('RateLimit', 0); // 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->ForceRegisterServerEvents(); + + // Only now report IS_ACTIVE: children answer the status change with + // HasActiveParent(), which walks up to the IO. Seeing it still inactive they + // would go inactive and, ignoring repeated identical status messages, stay so. + $this->SendDebug('ResetRateLimit', 'Event stream resumed, instance active', 0); + $this->SetStatus(IS_ACTIVE); } public function GetConfigurationForm() @@ -376,9 +389,10 @@ private function handleExpiredTokenFromStream(string $JSONString): bool } $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. + // The server rejected the cached token, although it may still be valid by its + // local expiry (clock skew, revocation). Drop it, so FetchAccessToken() in + // RegisterServerEvents fetches a new one instead of re-sending the rejected one. + $this->SetBuffer('AccessToken', ''); $this->ForceRegisterServerEvents(); return true; } diff --git a/library.json b/library.json index 6aefc56..7c09283 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 28, - "date": 1791106619 + "build": 29, + "date": 1791111176 } diff --git a/tests/HomeConnectCloudTest.php b/tests/HomeConnectCloudTest.php index 85ad700..357b742 100644 --- a/tests/HomeConnectCloudTest.php +++ b/tests/HomeConnectCloudTest.php @@ -151,14 +151,17 @@ public function testInvalidTokenReconnectsEventStream() $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); $parent = $this->prepareParentIo($cloudID); - //Seed a valid access token so the reconnect reuses it instead of hitting OAuth. + //A cached access token the server has just rejected. Without a refresh token the + //refresh fails before any network access - which proves a new token was requested. $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + ob_start(); $cloud->ReceiveData('{"error":{"key":"invalid_token","description":"The access token expired"}}'); + $output = ob_get_clean(); $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'); + $this->assertStringContainsString('login is missing', $output, 'Reconnect must request a fresh access token'); } /** @@ -315,6 +318,106 @@ public function testKeepAliveResetsWatchdogBackoff() $this->assertSame('', (string) $this->invoke($cloud, 'GetBuffer', 'WatchdogNextRetry'), 'A keep-alive must clear the pending backoff window'); } + /** + * Review finding 1: a Symcon restart during a block resets the RateLimit timer to 0 + * (timers are runtime state), while RateLimitUntil and the deactivated IO persist. + * Once the block has expired, the keep-alive watchdog must lift the block and + * re-activate the IO - otherwise RegisterServerEvents only reports "IO instance is + * not active" and the event stream stays dead until the user intervenes. + */ + public function testWatchdogLiftsExpiredBlockAfterRestart() + { + $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])); + + //State after the restart: block expired, IO still switched off by applyRateLimit, + //RateLimit timer not running, keep-alive stale. + $this->invoke($cloud, 'WriteAttributeInteger', 'RateLimitUntil', time() - 10); + $this->assertFalse(IPS_GetProperty($parent, 'Active')); + $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); + + ob_start(); + $cloud->CheckServerEvents(); + $output = ob_get_clean(); + + $this->assertSame('', $output, 'The watchdog must not end up at "IO instance is not active"'); + $this->assertSame(0, $this->invoke($cloud, 'ReadAttributeInteger', 'RateLimitUntil'), 'The expired block must be cleared'); + $this->assertTrue(IPS_GetProperty($parent, 'Active'), 'The IO stopped for the block must be re-activated'); + $this->assertStringContainsString('homeappliances/events', IPS_GetProperty($parent, 'URL'), 'The event stream must be re-registered'); + } + + /** + * Counterpart to finding 1: an IO the user switched off on purpose (no block pending) + * must stay off - the watchdog only re-activates an IO the module stopped itself. + */ + public function testWatchdogLeavesIntentionallyInactiveIoAlone() + { + $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])); + $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); + + ob_start(); + $cloud->CheckServerEvents(); + ob_end_clean(); + + $this->assertFalse(IPS_GetProperty($parent, 'Active'), 'An IO switched off by the user must not be re-activated'); + } + + /** + * Review finding 2: a 401 "invalid_token" from the stream means the server rejected + * the cached access token. The reconnect must not re-send that same token (it is + * still "valid" by its local expiry) - each such attempt costs a GET /events. + */ + public function testInvalidTokenDropsCachedAccessToken() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + + //Locally still valid for an hour, but the server has rejected it. + $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'rejected', 'Expires' => time() + 3600])); + //Without a refresh token the refresh fails before any network access. + $this->invoke($cloud, 'WriteAttributeString', 'Token', ''); + + ob_start(); + $cloud->ReceiveData('{"error":{"key":"invalid_token","description":"The access token expired"}}'); + ob_end_clean(); + + $this->assertStringNotContainsString('Bearer rejected', (string) IPS_GetProperty($parent, 'Headers'), 'The rejected token must not be sent again'); + $this->assertStringNotContainsString('rejected', (string) $this->invoke($cloud, 'GetBuffer', 'AccessToken'), 'The rejected token must be dropped from the cache'); + } + + /** + * Review finding 3: ResetRateLimit must re-activate the IO before the instance reports + * IS_ACTIVE. Children react to that status change with HasActiveParent(), which walks + * up to the IO; seeing it still inactive they go inactive and - because of their + * LastParentStatus guard - never retry. The stubs deliver no messages, so the order is + * checked on the debug trace: the stream registration ('url') must precede the status. + */ + public function testResetRateLimitActivatesIoBeforeStatus() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $this->prepareParentIo($cloudID); + $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + + $cloud->ReceiveData(self::RATE_LIMIT_PAYLOAD); + //The debug log is not reset between tests - only look at what ResetRateLimit writes. + $before = count(IPS\DebugServer::getDebugMessages($cloudID)); + $cloud->ResetRateLimit(); + + $messages = array_column(array_slice(IPS\DebugServer::getDebugMessages($cloudID), $before), 'Message'); + $registered = array_search('url', $messages, true); + $activated = array_search('ResetRateLimit', $messages, true); + $this->assertNotFalse($registered, 'ResetRateLimit must re-register the event stream'); + $this->assertNotFalse($activated, 'ResetRateLimit must trace when the instance becomes active'); + $this->assertLessThan($activated, $registered, 'The IO must be active before the instance reports IS_ACTIVE'); + } + private function cloud() { return IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]); From 4d59d9d9ca980bd32298b009bb7f72e29849f702 Mon Sep 17 00:00:00 2001 From: bumaas Date: Sun, 4 Oct 2026 13:13:36 +0200 Subject: [PATCH 6/9] 1.1 build 30: Retry throttled init, quiet watchdog, single stream connect Further findings from the code review, verified against the code: - A device initialization skipped by the 30 s refresh throttle was never retried (e.g. an appliance offline at setup that comes online right after). A throttled init now arms the RetryRefresh timer for the rest of the window; a throttled value refresh still needs no retry. - ForceRegisterServerEvents applied the IO twice, the first time with the old header, and switched it on even when no token could be fetched. One path (connectEventStream) now fetches the token first and applies once. It also no longer switches the IO on while a block is active. ResetRateLimit keeps an expired block pending if the stream cannot be resumed, so the watchdog retries instead of leaving the IO off. - The keep-alive watchdog echoed "IO instance is not active" or "login is missing" from its timer, a log warning per attempt. It now stays silent when the IO is switched off on purpose or no login exists; the stream token refresh after invalid_token no longer echoes either. - A numeric profile of the other numeric type (INTEGER vs. FLOAT) passed the type guard, and the variable was rejected. The variable now follows the existing profile's type. - The configurator listed device instances of other cloud instances as deletable rows. It now lists only devices of its own cloud (or of none). Adjusted tests: the watchdog tests now model a registered cloud (refresh token present); the invalid_token test expects no output from ReceiveData. Red/green: without the fix 9 of 76 tests fail, with it all 76 pass (638 assertions). Co-Authored-By: Claude Opus 5.5 --- Home Connect Cloud/module.php | 138 +++++++++++++++++-------- Home Connect Configurator/module.php | 7 ++ Home Connect Device/module.php | 41 ++++++-- library.json | 4 +- tests/HomeConnectCloudTest.php | 84 ++++++++++++++- tests/HomeConnectConfiguratorTest.php | 68 ++++++++++++ tests/HomeConnectConnectedTest.php | 42 ++++++++ tests/HomeConnectFridgeFreezerTest.php | 24 +++++ 8 files changed, 348 insertions(+), 60 deletions(-) create mode 100644 tests/HomeConnectConfiguratorTest.php diff --git a/Home Connect Cloud/module.php b/Home Connect Cloud/module.php index 6071bc2..416c7ba 100644 --- a/Home Connect Cloud/module.php +++ b/Home Connect Cloud/module.php @@ -183,45 +183,22 @@ public function MessageSink($Timestamp, $SenderID, $MessageID, $Data) public function ForceRegisterServerEvents() { $parent = IPS_GetInstance($this->InstanceID)['ConnectionID']; - if (IPS_InstanceExists($parent)) { - IPS_SetProperty($parent, 'Active', true); - IPS_ApplyChanges($parent); - $this->RegisterServerEvents(); + if (IPS_InstanceExists($parent) && !$this->deferWhileRateLimited()) { + $this->connectEventStream(true, true); } } public function RegisterServerEvents() { - // Do not (re)connect the event stream while we are rate limited - every - // reconnect hits /events and counts against the daily quota. Defer the - // reconnect to the moment the limit expires instead. - if ($this->isRateLimitActive()) { - $remaining = $this->ReadAttributeInteger('RateLimitUntil') - time(); - $this->SendDebug('RegisterServerEvents', sprintf('Rate limit active, defer reconnect by %ds', $remaining), 0); - $this->SetTimerInterval('Reconnect', max(1, $remaining) * 1000); + if ($this->deferWhileRateLimited()) { return; } - try { - $url = self::HOME_CONNECT_BASE . 'homeappliances/events'; - $this->SendDebug('url', $url, 0); - $parent = IPS_GetInstance($this->InstanceID)['ConnectionID']; - if (!IPS_GetProperty($parent, 'Active')) { - echo $this->Translate('IO instance is not active'); - return; - } - IPS_SetProperty($parent, 'URL', $url); - IPS_SetProperty($parent, 'Headers', json_encode([['Name' => 'Authorization', 'Value' => 'Bearer ' . $this->FetchAccessToken()]])); - IPS_ApplyChanges($parent); - - // Mark connection as good for the moment - $this->SetBuffer('KeepAlive', time()); - - $this->SetTimerInterval('Reconnect', 0); - } catch (RuntimeException $e) { - $error = $this->DecodeModuleError($e); - $this->SendDebug('RegisterServerEventsError', json_encode($error), 0); - echo $error['error']['description']; + $parent = IPS_GetInstance($this->InstanceID)['ConnectionID']; + if (!IPS_GetProperty($parent, 'Active')) { + echo $this->Translate('IO instance is not active'); + return; } + $this->connectEventStream(false, true); } public function CheckServerEvents() @@ -232,11 +209,20 @@ public function CheckServerEvents() } // A block that has expired but was never lifted: a restart during the block // resets the RateLimit timer, while RateLimitUntil and the IO deactivated by - // applyRateLimit persist. Lift it here, otherwise the stream stays dead. - if ($this->ReadAttributeInteger('RateLimitUntil') !== 0) { - $this->SendDebug('KeepAlive', 'Expired rate limit was not lifted - resetting', 0); - $this->ResetRateLimit(); - return; + // applyRateLimit persist; or the stream could not be resumed when it ended. + // Lift it here, otherwise the stream stays dead. + $blockPending = $this->ReadAttributeInteger('RateLimitUntil') !== 0; + if (!$blockPending) { + // Nothing to reconnect: the user switched the IO off, or the login is + // missing. Stay silent - output of a timer ends up as a log warning. + $parent = IPS_GetInstance($this->InstanceID)['ConnectionID']; + if (!IPS_InstanceExists($parent) || !IPS_GetProperty($parent, 'Active')) { + return; + } + if (trim($this->ReadAttributeString('Token')) === '') { + $this->SendDebug('KeepAlive', 'Not registered - no reconnect', 0); + return; + } } // A stale keep-alive means the event stream is dead - reconnect regardless of // the parent's current status. Gating this behind HasActiveParent() prevented @@ -261,7 +247,12 @@ public function CheckServerEvents() $parent = IPS_GetInstance($this->InstanceID)['ConnectionID']; $parentStatus = IPS_InstanceExists($parent) ? IPS_GetInstance($parent)['InstanceStatus'] : 0; $this->SendDebug('KeepAlive', sprintf('Failed (IO status: %d). Reregistering... (attempt #%d, next attempt in %ds)', $parentStatus, $retries + 1, $delay), 0); - $this->RegisterServerEvents(); + if ($blockPending) { + $this->SendDebug('KeepAlive', 'Expired rate limit was not lifted - resetting', 0); + $this->ResetRateLimit(); + } else { + $this->connectEventStream(false, false); + } } } @@ -278,15 +269,18 @@ public function GetConfigurationForParent() public function ResetRateLimit() { - $this->WriteAttributeString('RateError', ''); - $this->WriteAttributeInteger('RateLimitUntil', 0); - $this->updateRateLimitNotice(); $this->SetTimerInterval('RateLimit', 0); + $this->SetTimerInterval('Reconnect', 0); // 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->ForceRegisterServerEvents(); + // event stream exactly once, with a freshly fetched access token. If that fails + // (no token), the expired block stays pending and the keep-alive watchdog retries. + if (!$this->connectEventStream(true, false)) { + return; + } + $this->WriteAttributeString('RateError', ''); + $this->WriteAttributeInteger('RateLimitUntil', 0); + $this->updateRateLimitNotice(); // Only now report IS_ACTIVE: children answer the status change with // HasActiveParent(), which walks up to the IO. Seeing it still inactive they @@ -393,7 +387,7 @@ private function handleExpiredTokenFromStream(string $JSONString): bool // local expiry (clock skew, revocation). Drop it, so FetchAccessToken() in // RegisterServerEvents fetches a new one instead of re-sending the rejected one. $this->SetBuffer('AccessToken', ''); - $this->ForceRegisterServerEvents(); + $this->connectEventStream(true, false); return true; } @@ -724,6 +718,62 @@ private function stopEventStream(): void } } + /** + * Do not (re)connect the event stream while we are rate limited - every reconnect + * hits /events and counts against the daily quota. Defers the reconnect to the + * moment the limit expires instead. Returns true if it deferred. + */ + private function deferWhileRateLimited(): bool + { + if (!$this->isRateLimitActive()) { + return false; + } + $remaining = $this->ReadAttributeInteger('RateLimitUntil') - time(); + $this->SendDebug('RegisterServerEvents', sprintf('Rate limit active, defer reconnect by %ds', $remaining), 0); + $this->SetTimerInterval('Reconnect', max(1, $remaining) * 1000); + return true; + } + + /** + * Points the event-stream IO at /events with a current access token and applies it + * once. The token is fetched first: without one the IO is left untouched instead of + * connecting with a stale header. $activate also switches the IO on. $report echoes + * an error - only for calls by the user, output of a timer ends up in the log. + * Returns true if the stream was registered. + */ + private function connectEventStream(bool $activate, bool $report): bool + { + $parent = IPS_GetInstance($this->InstanceID)['ConnectionID']; + if (!IPS_InstanceExists($parent)) { + return false; + } + try { + $token = $this->FetchAccessToken(); + } catch (RuntimeException $e) { + $error = $this->DecodeModuleError($e); + $this->SendDebug('RegisterServerEventsError', json_encode($error), 0); + if ($report) { + echo $error['error']['description']; + } + return false; + } + + $url = self::HOME_CONNECT_BASE . 'homeappliances/events'; + $this->SendDebug('url', $url, 0); + if ($activate) { + IPS_SetProperty($parent, 'Active', true); + } + IPS_SetProperty($parent, 'URL', $url); + IPS_SetProperty($parent, 'Headers', json_encode([['Name' => 'Authorization', 'Value' => 'Bearer ' . $token]])); + IPS_ApplyChanges($parent); + + // Mark connection as good for the moment + $this->SetBuffer('KeepAlive', time()); + + $this->SetTimerInterval('Reconnect', 0); + return true; + } + /** * 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 diff --git a/Home Connect Configurator/module.php b/Home Connect Configurator/module.php index f1fd817..0caed32 100644 --- a/Home Connect Configurator/module.php +++ b/Home Connect Configurator/module.php @@ -132,8 +132,15 @@ private function getInstanceIDForGuid($haid, $guid) private function getExistingDeviceRows(array $knownHaIDs) { $rows = []; + $cloudID = IPS_GetInstance($this->InstanceID)['ConnectionID']; foreach (self::MODULE_TYPES as $guid) { foreach (IPS_GetInstanceListByModuleID($guid) as $instanceID) { + // Only devices of this cloud (or of none) - the rows can be deleted from + // here, and a second cloud instance belongs to another account. + $deviceCloudID = IPS_GetInstance($instanceID)['ConnectionID']; + if ($deviceCloudID !== $cloudID && $deviceCloudID !== 0) { + continue; + } $haID = (string) @IPS_GetProperty($instanceID, 'HaID'); if ($haID === '' || isset($knownHaIDs[$haID])) { continue; diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 244c368..777db5f 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -138,6 +138,9 @@ public function Create() $this->RegisterAttributeString('InitializationSignature', ''); $this->RegisterAttributeBoolean('Initialized', false); + // Retries an initialization the refresh throttle had to skip. + $this->RegisterTimer('RetryRefresh', 0, 'IPS_RequestAction($_IPS[\'TARGET\'], "RefreshDeviceState", "");'); + //Common States //States if (!IPS_VariableProfileExists('HomeConnect.Common.Status.OperationState')) { @@ -576,17 +579,30 @@ public function RequestDataFromParent(string $endpoint, string $payload = '') return $response; } + // Time source for the module timers of the test stubs (as in WebOAuthModule). + protected function getTime(): int + { + return time(); + } + 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); + // Any refresh supersedes a pending retry; a throttled init re-arms it below. + $this->SetTimerInterval('RetryRefresh', 0); if ($this->HasActiveParent() && $this->ReadPropertyString('HaID')) { $this->SetSummary($this->ReadPropertyString('HaID')); - if ($this->refreshThrottled()) { + $wait = $this->refreshThrottled(); + if ($wait > 0) { // 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). + // values directly (they do not go through this path). A skipped + // initialization has no such substitute: retry it after the window. $this->SendDebug(__FUNCTION__, sprintf('throttled (trigger: %s)', $trigger), 0); + if ($initializeDevice) { + $this->SetTimerInterval('RetryRefresh', $wait * 1000); + } $this->setInstanceStatus(IS_ACTIVE); return; } @@ -608,16 +624,18 @@ private function refreshDeviceState(bool $initializeDevice, string $trigger = '' * 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). + * Returns the seconds to wait, 0 if the refresh may run (and records it). */ - private function refreshThrottled(): bool + private function refreshThrottled(): int { $now = time(); $last = (int) $this->GetBuffer('LastRefresh'); if ($last !== 0 && ($now - $last) < self::REFRESH_MIN_INTERVAL) { - return true; + // Seconds until the next refresh is allowed. + return self::REFRESH_MIN_INTERVAL - ($now - $last); } $this->SetBuffer('LastRefresh', (string) $now); - return false; + return 0; } /** @@ -1404,18 +1422,19 @@ private function createVariableFromConstraints($profileName, $data, $attribute, IPS_CreateVariableProfile($profileName, $variableType); } $existingProfileType = IPS_GetVariableProfile($profileName)['ProfileType']; - if ($existingProfileType === VARIABLETYPE_INTEGER || $existingProfileType === VARIABLETYPE_FLOAT) { + if ($existingProfileType === $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); } 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); + // A profile with this name already exists with another type - non-numeric + // (modifying its values would emit "String profiles cannot be modified") + // or the other numeric type (Symcon rejects a variable whose type does + // not match its profile). 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 profile setup', $profileName, $existingProfileType), 0); $variableType = $existingProfileType; } break; diff --git a/library.json b/library.json index 7c09283..d52c593 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 29, - "date": 1791111176 + "build": 30, + "date": 1791112380 } diff --git a/tests/HomeConnectCloudTest.php b/tests/HomeConnectCloudTest.php index 357b742..f2aee00 100644 --- a/tests/HomeConnectCloudTest.php +++ b/tests/HomeConnectCloudTest.php @@ -150,18 +150,24 @@ public function testInvalidTokenReconnectsEventStream() $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); $parent = $this->prepareParentIo($cloudID); + //The stream was running. + IPS_SetProperty($parent, 'Active', true); + IPS_ApplyChanges($parent); //A cached access token the server has just rejected. Without a refresh token the //refresh fails before any network access - which proves a new token was requested. $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + $before = count(IPS\DebugServer::getDebugMessages($cloudID)); ob_start(); $cloud->ReceiveData('{"error":{"key":"invalid_token","description":"The access token expired"}}'); $output = ob_get_clean(); + $messages = array_column(array_slice(IPS\DebugServer::getDebugMessages($cloudID), $before), 'Message'); $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('login is missing', $output, 'Reconnect must request a fresh access token'); + $this->assertTrue(IPS_GetProperty($parent, 'Active'), 'The IO stays active after invalid_token'); + $this->assertContains('RegisterServerEventsError', $messages, 'Reconnect must request a fresh access token'); + $this->assertSame('', $output, 'ReceiveData must not echo - its output ends up in the log'); } /** @@ -179,6 +185,8 @@ public function testCheckServerEventsReconnectsWhenParentInactive() IPS_SetProperty($parent, 'Active', true); IPS_ApplyChanges($parent); $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + //A registered cloud has a refresh token. + $this->invoke($cloud, 'WriteAttributeString', 'Token', 'refresh'); //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); @@ -257,6 +265,8 @@ public function testCheckServerEventsBacksOffWhileStreamStaysDead() IPS_SetProperty($parent, 'Active', true); IPS_ApplyChanges($parent); $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + //A registered cloud has a refresh token. + $this->invoke($cloud, 'WriteAttributeString', 'Token', 'refresh'); //First failure: reconnects immediately and schedules the next attempt in 120s. $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); @@ -292,6 +302,8 @@ public function testWatchdogBackoffCappedAtOneHour() IPS_SetProperty($parent, 'Active', true); IPS_ApplyChanges($parent); $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + //A registered cloud has a refresh token. + $this->invoke($cloud, 'WriteAttributeString', 'Token', 'refresh'); $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); $this->invoke($cloud, 'SetBuffer', 'WatchdogRetries', '10'); @@ -358,13 +370,79 @@ public function testWatchdogLeavesIntentionallyInactiveIoAlone() $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); $parent = $this->prepareParentIo($cloudID); $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + $this->invoke($cloud, 'WriteAttributeString', 'Token', 'refresh'); $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); ob_start(); $cloud->CheckServerEvents(); - ob_end_clean(); + $output = ob_get_clean(); $this->assertFalse(IPS_GetProperty($parent, 'Active'), 'An IO switched off by the user must not be re-activated'); + //Review finding 10: output of a timer lands in the log as a warning - every attempt. + $this->assertSame('', $output, 'The watchdog must not echo while the IO is switched off on purpose'); + } + + /** + * Review finding 10: without a login (no refresh token) every watchdog attempt + * echoed "login is missing" from the timer, i.e. a log warning each time. + */ + public function testWatchdogSilentWithoutLogin() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + IPS_SetProperty($parent, 'Active', true); + IPS_ApplyChanges($parent); + $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); + + ob_start(); + $cloud->CheckServerEvents(); + $output = ob_get_clean(); + + $this->assertSame('', $output, 'The watchdog must not echo while the login is missing'); + } + + /** + * Review finding 6: ForceRegisterServerEvents activated the IO (first ApplyChanges, + * with the old URL/header) before it fetched a token. Without a usable token it must + * not switch the IO on at all - it would only collect 401s from /events. + */ + public function testForceRegisterDoesNotActivateIoWithoutToken() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + + ob_start(); + $cloud->ForceRegisterServerEvents(); + $output = ob_get_clean(); + + $this->assertStringContainsString('login is missing', $output, 'A manual registration still reports the missing login'); + $this->assertFalse(IPS_GetProperty($parent, 'Active'), 'The IO must not be activated without a token'); + } + + /** + * Counterpart to finding 6: if the stream cannot be resumed when the block ends, the + * expired block must stay pending, so the watchdog retries the reset later instead of + * leaving the IO switched off with nobody responsible for it. + */ + public function testResetRateLimitKeepsBlockPendingWhenStreamCannotResume() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + IPS_SetProperty($parent, 'Active', true); + IPS_ApplyChanges($parent); + + $cloud->ReceiveData(self::RATE_LIMIT_PAYLOAD); + //The RateLimit timer fires when the block has just expired. + $this->invoke($cloud, 'WriteAttributeInteger', 'RateLimitUntil', time() - 1); + ob_start(); + $cloud->ResetRateLimit(); + ob_end_clean(); + + $this->assertNotSame(0, $this->invoke($cloud, 'ReadAttributeInteger', 'RateLimitUntil'), 'The block must stay pending until the stream runs again'); + $this->assertFalse(IPS_GetProperty($parent, 'Active'), 'The IO stays off while no token is available'); } /** diff --git a/tests/HomeConnectConfiguratorTest.php b/tests/HomeConnectConfiguratorTest.php new file mode 100644 index 0000000..122d991 --- /dev/null +++ b/tests/HomeConnectConfiguratorTest.php @@ -0,0 +1,68 @@ +configuratorID = IPS_CreateInstance('{CA0E667D-8F28-8DF1-2750-5CF587ECA85A}'); + + parent::setUp(); + } + + /** + * Review finding 9: with a second cloud instance (a second Home Connect account), + * the configurator listed that account's devices as well - deletable from here. + */ + public function testExistingRowsOnlyFromOwnCloud() + { + $ownCloud = IPS_GetInstance($this->configuratorID)['ConnectionID']; + $foreignCloud = IPS_CreateInstance(self::CLOUD_GUID); + + $ownDevice = $this->createDevice($ownCloud, 'OWN-DEVICE'); + $foreignDevice = $this->createDevice($foreignCloud, 'FOREIGN-DEVICE'); + + //No live discovery: only the rows of the existing instances are listed. + IPS\InstanceManager::setStatus($ownCloud, IS_INACTIVE); + $form = json_decode(IPS\InstanceManager::getInstanceInterface($this->configuratorID)->GetConfigurationForm(), true); + $listed = array_column($form['actions'][0]['values'], 'instanceID'); + + $this->assertContains($ownDevice, $listed, 'A device of the own cloud is listed'); + $this->assertNotContains($foreignDevice, $listed, 'A device of another cloud must not be listed'); + } + + private function createDevice(int $cloudID, string $haID): int + { + $device = IPS_CreateInstance(self::DEVICE_GUID); + IPS\InstanceManager::connectInstance($device, $cloudID); + //Parent not active: the device does not try to reach the (stubbed) cloud. + IPS\InstanceManager::setStatus($cloudID, IS_INACTIVE); + IPS_SetProperty($device, 'HaID', $haID); + IPS_ApplyChanges($device); + return $device; + } +} diff --git a/tests/HomeConnectConnectedTest.php b/tests/HomeConnectConnectedTest.php index b77813f..82709ca 100644 --- a/tests/HomeConnectConnectedTest.php +++ b/tests/HomeConnectConnectedTest.php @@ -89,6 +89,48 @@ public function testConnectedDoesNotChangeInstanceStatus() $this->assertEquals(IS_ACTIVE, IPS_GetInstance($device)['InstanceStatus']); } + /** + * Review finding 4: an appliance that is offline when its instance is set up keeps + * Initialized = false. When it comes online within REFRESH_MIN_INTERVAL, the CONNECTED + * refresh is throttled - and nothing ever retried the pending initialization, so the + * instance stayed without its variables. A throttled init must schedule a retry. + */ + public function testThrottledInitializationIsRetried() + { + $device = $this->createDevice(self::OFFLINE_HAID, 'Washer'); + $intf = IPS\InstanceManager::getInstanceInterface($device); + $this->assertFalse($this->invoke($intf, 'ReadAttributeBoolean', 'Initialized'), 'Setup of an offline appliance does not initialize'); + + //The appliance comes online right after the setup. + $intf->ReceiveData(json_encode(['Event' => 'CONNECTED', 'Data' => '', 'ID' => self::OFFLINE_HAID])); + + $retry = $this->invoke($intf, 'GetTimerInterval', 'RetryRefresh'); + $this->assertGreaterThan(0, $retry, 'A throttled initialization must be retried'); + $this->assertLessThanOrEqual(31000, $retry, 'The retry follows once the throttle window has passed'); + } + + /** + * Counterpart to finding 4: a throttled value refresh of an initialized appliance needs + * no retry - live STATUS/NOTIFY events keep its values current. + */ + public function testThrottledValueRefreshIsNotRetried() + { + $device = $this->createDevice(self::ONLINE_HAID, 'Dryer'); + $intf = IPS\InstanceManager::getInstanceInterface($device); + + $intf->ReceiveData(json_encode(['Event' => 'CONNECTED', 'Data' => '', 'ID' => self::ONLINE_HAID])); + + //The stub reports a stopped timer as 0 minus the elapsed time. + $this->assertLessThanOrEqual(0, $this->invoke($intf, 'GetTimerInterval', 'RetryRefresh'), 'No retry for a value refresh'); + } + + private function invoke($object, string $method, ...$args) + { + $ref = new ReflectionMethod($object, $method); + $ref->setAccessible(true); + return $ref->invoke($object, ...$args); + } + private function createDevice(string $haID, string $deviceType): int { $device = IPS_CreateInstance(self::DEVICE_GUID); diff --git a/tests/HomeConnectFridgeFreezerTest.php b/tests/HomeConnectFridgeFreezerTest.php index bf5d7c7..b209ee4 100644 --- a/tests/HomeConnectFridgeFreezerTest.php +++ b/tests/HomeConnectFridgeFreezerTest.php @@ -190,4 +190,28 @@ public function testConnectedEventRefreshesViaDeferredAction() $this->assertSame(2, HomeConnectCloud::$requestCount, 'CONNECTED must refresh via the deferred RefreshDeviceState action'); $this->assertEquals(IS_ACTIVE, IPS_GetInstance($fridge)['InstanceStatus']); } + + /** + * Review finding 7: an option or setting profile that already exists with the other + * numeric type (INTEGER vs. FLOAT) passed the type guard, so the variable was created + * with its own type against a mismatching profile - Symcon rejects that and the + * variable is not created. The variable must follow the existing profile instead. + * The setting is real (/settings of the fridge); only the stale profile is staged. + */ + public function testNumericSettingFollowsExistingProfileType() + { + $profile = 'Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator'; + IPS_CreateVariableProfile($profile, VARIABLETYPE_FLOAT); + + $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); + + $variableID = @IPS_GetObjectIDByIdent('SetpointTemperatureRefrigerator', $fridge); + $this->assertNotFalse($variableID, 'The setting variable must be created despite the existing profile'); + $this->assertSame(VARIABLETYPE_FLOAT, IPS_GetVariable($variableID)['VariableType'], 'The variable follows the type of the existing profile'); + } } From f3a605327e63771f0f0045ac73548769d7a09c37 Mon Sep 17 00:00:00 2001 From: bumaas Date: Sun, 4 Oct 2026 14:18:17 +0200 Subject: [PATCH 7/9] Format test fixtures for the JSON style check The fixtures added in builds 26-28 were indented with two spaces; the Check Style workflow (.style/json-check.php) expects PHP's pretty print with four. Reformatted with `php .style/json-check.php fix`; the decoded content of all 14 files is unchanged, the test suite stays green. Co-Authored-By: Claude Opus 5.5 --- .../response.json | 124 ++++---- .../015070396331014803/programs/response.json | 274 +++++++++--------- .../programs/selected/Auto2.json | 132 ++++----- .../response.json | 36 +-- .../015070396331014803/settings/response.json | 20 +- .../015070396331014803/status/response.json | 62 ++-- .../BOSCH-WAV28G43-68A40E970546/response.json | 18 +- .../status/response.json | 8 +- .../programs/response.json | 74 ++--- .../response.json | 40 +-- .../response.json | 30 +- .../response.json | 30 +- .../settings/response.json | 46 +-- .../BOSCH-WNC254A40-XYZ/status/response.json | 64 ++-- 14 files changed, 479 insertions(+), 479 deletions(-) diff --git a/tests/homeappliances/015070396331014803/programs/available/Dishcare.Dishwasher.Program.Auto2/response.json b/tests/homeappliances/015070396331014803/programs/available/Dishcare.Dishwasher.Program.Auto2/response.json index 6e516c2..9ccb825 100644 --- a/tests/homeappliances/015070396331014803/programs/available/Dishcare.Dishwasher.Program.Auto2/response.json +++ b/tests/homeappliances/015070396331014803/programs/available/Dishcare.Dishwasher.Program.Auto2/response.json @@ -1,64 +1,64 @@ { - "data": { - "key": "Dishcare.Dishwasher.Program.Auto2", - "name": "Auto 45-65°", - "options": [ - { - "name": "Startzeit", - "key": "BSH.Common.Option.StartInRelative", - "constraints": { - "min": 0, - "max": 86400, - "liveupdate": true - }, - "unit": "seconds", - "type": "Int" - }, - { - "key": "Dishcare.Dishwasher.Option.HygienePlus", - "type": "Boolean", - "constraints": { - "default": false, - "liveupdate": true - }, - "name": "HygienePlus" - }, - { - "key": "Dishcare.Dishwasher.Option.HalfLoad", - "type": "Boolean", - "constraints": { - "default": false, - "liveupdate": true - }, - "name": "Halbe Beladung" - }, - { - "key": "Dishcare.Dishwasher.Option.IntensivZone", - "type": "Boolean", - "constraints": { - "default": false, - "liveupdate": true - }, - "name": "IntensivZone" - }, - { - "key": "Dishcare.Dishwasher.Option.VarioSpeedPlus", - "type": "Boolean", - "constraints": { - "default": false, - "liveupdate": true - }, - "name": "VarioSpeedPlus" - }, - { - "key": "Dishcare.Dishwasher.Option.BrillianceDry", - "type": "Boolean", - "constraints": { - "default": false, - "liveupdate": true - }, - "name": "Glanztrocknen" - } - ] - } + "data": { + "key": "Dishcare.Dishwasher.Program.Auto2", + "name": "Auto 45-65°", + "options": [ + { + "name": "Startzeit", + "key": "BSH.Common.Option.StartInRelative", + "constraints": { + "min": 0, + "max": 86400, + "liveupdate": true + }, + "unit": "seconds", + "type": "Int" + }, + { + "key": "Dishcare.Dishwasher.Option.HygienePlus", + "type": "Boolean", + "constraints": { + "default": false, + "liveupdate": true + }, + "name": "HygienePlus" + }, + { + "key": "Dishcare.Dishwasher.Option.HalfLoad", + "type": "Boolean", + "constraints": { + "default": false, + "liveupdate": true + }, + "name": "Halbe Beladung" + }, + { + "key": "Dishcare.Dishwasher.Option.IntensivZone", + "type": "Boolean", + "constraints": { + "default": false, + "liveupdate": true + }, + "name": "IntensivZone" + }, + { + "key": "Dishcare.Dishwasher.Option.VarioSpeedPlus", + "type": "Boolean", + "constraints": { + "default": false, + "liveupdate": true + }, + "name": "VarioSpeedPlus" + }, + { + "key": "Dishcare.Dishwasher.Option.BrillianceDry", + "type": "Boolean", + "constraints": { + "default": false, + "liveupdate": true + }, + "name": "Glanztrocknen" + } + ] + } } \ No newline at end of file diff --git a/tests/homeappliances/015070396331014803/programs/response.json b/tests/homeappliances/015070396331014803/programs/response.json index e7846f7..7ef9c8f 100644 --- a/tests/homeappliances/015070396331014803/programs/response.json +++ b/tests/homeappliances/015070396331014803/programs/response.json @@ -1,141 +1,141 @@ { - "data": { - "active": { - "constraints": { - "access": "readWrite" - } - }, - "programs": [ - { - "constraints": { - "available": true, - "execution": "selectandstart" - }, - "key": "Dishcare.Dishwasher.Program.Eco50", - "name": "Eco 50°" - }, - { - "constraints": { - "available": true, - "execution": "selectandstart" - }, - "key": "Dishcare.Dishwasher.Program.Auto2", - "name": "Auto 45-65°" - }, - { - "constraints": { - "available": true, - "execution": "selectandstart" - }, - "key": "Dishcare.Dishwasher.Program.Intensiv70", - "name": "Intensiv 70°" - }, - { - "constraints": { - "available": true, - "execution": "selectandstart" - }, - "key": "Dishcare.Dishwasher.Program.Kurz60", - "name": "Speed 60°" - }, - { - "constraints": { - "available": true, - "execution": "selectandstart" - }, - "key": "Dishcare.Dishwasher.Program.NightWash", - "name": "Leise 50°C" - }, - { - "constraints": { - "available": true, - "execution": "selectandstart" - }, - "key": "Dishcare.Dishwasher.Program.MachineCare", - "name": "Maschinenpflege" - }, - { - "constraints": { - "available": true, - "execution": "selectandstart" - }, - "key": "Dishcare.Dishwasher.Program.PreRinse", - "name": "Vorspülen" - }, - { - "constraints": { - "available": true, - "execution": "selectandstart" - }, - "key": "Dishcare.Dishwasher.Program.Quick45", - "name": "Schnell 45°C" - } - ], - "selected": { - "constraints": { - "access": "readWrite" - }, - "key": "Dishcare.Dishwasher.Program.Auto2", - "name": "Auto 45-65°", - "options": [ - { - "key": "BSH.Common.Option.StartInRelative", - "value": 0, - "unit": "seconds", - "name": "Startzeit" - }, - { - "key": "BSH.Common.Option.SmartEnergyService.SmartStartEnabled", - "value": false - }, - { - "key": "Dishcare.Dishwasher.Option.HygienePlus", - "value": false, - "name": "HygienePlus" - }, - { - "key": "Dishcare.Dishwasher.Option.HalfLoad", - "value": false, - "name": "Halbe Beladung" - }, - { - "key": "Dishcare.Dishwasher.Option.IntensivZone", - "value": false, - "name": "IntensivZone" - }, - { - "key": "Dishcare.Dishwasher.Option.VarioSpeedPlus", - "value": false, - "name": "VarioSpeedPlus" - }, - { - "key": "Dishcare.Dishwasher.Option.BrillianceDry", - "value": false, - "name": "Glanztrocknen" - }, - { - "key": "Dishcare.Dishwasher.Option.SilenceOnDemand", - "value": false, - "name": "Jetzt Leise" - }, - { - "key": "BSH.Common.Option.RemainingProgramTimeIsEstimated", - "value": true, - "name": "Geschätzte Restprogrammlaufzeit" - }, - { - "key": "BSH.Common.Option.EnergyForecast", - "value": 58, - "unit": "%", - "name": "Energie" - }, - { - "key": "BSH.Common.Option.WaterForecast", - "value": 49, - "unit": "%", - "name": "Wasser" + "data": { + "active": { + "constraints": { + "access": "readWrite" + } + }, + "programs": [ + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.Eco50", + "name": "Eco 50°" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.Auto2", + "name": "Auto 45-65°" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.Intensiv70", + "name": "Intensiv 70°" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.Kurz60", + "name": "Speed 60°" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.NightWash", + "name": "Leise 50°C" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.MachineCare", + "name": "Maschinenpflege" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.PreRinse", + "name": "Vorspülen" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.Quick45", + "name": "Schnell 45°C" + } + ], + "selected": { + "constraints": { + "access": "readWrite" + }, + "key": "Dishcare.Dishwasher.Program.Auto2", + "name": "Auto 45-65°", + "options": [ + { + "key": "BSH.Common.Option.StartInRelative", + "value": 0, + "unit": "seconds", + "name": "Startzeit" + }, + { + "key": "BSH.Common.Option.SmartEnergyService.SmartStartEnabled", + "value": false + }, + { + "key": "Dishcare.Dishwasher.Option.HygienePlus", + "value": false, + "name": "HygienePlus" + }, + { + "key": "Dishcare.Dishwasher.Option.HalfLoad", + "value": false, + "name": "Halbe Beladung" + }, + { + "key": "Dishcare.Dishwasher.Option.IntensivZone", + "value": false, + "name": "IntensivZone" + }, + { + "key": "Dishcare.Dishwasher.Option.VarioSpeedPlus", + "value": false, + "name": "VarioSpeedPlus" + }, + { + "key": "Dishcare.Dishwasher.Option.BrillianceDry", + "value": false, + "name": "Glanztrocknen" + }, + { + "key": "Dishcare.Dishwasher.Option.SilenceOnDemand", + "value": false, + "name": "Jetzt Leise" + }, + { + "key": "BSH.Common.Option.RemainingProgramTimeIsEstimated", + "value": true, + "name": "Geschätzte Restprogrammlaufzeit" + }, + { + "key": "BSH.Common.Option.EnergyForecast", + "value": 58, + "unit": "%", + "name": "Energie" + }, + { + "key": "BSH.Common.Option.WaterForecast", + "value": 49, + "unit": "%", + "name": "Wasser" + } + ] } - ] } - } } \ No newline at end of file diff --git a/tests/homeappliances/015070396331014803/programs/selected/Auto2.json b/tests/homeappliances/015070396331014803/programs/selected/Auto2.json index a5b95e8..84bb65c 100644 --- a/tests/homeappliances/015070396331014803/programs/selected/Auto2.json +++ b/tests/homeappliances/015070396331014803/programs/selected/Auto2.json @@ -1,68 +1,68 @@ { - "data": { - "constraints": { - "access": "readWrite" - }, - "key": "Dishcare.Dishwasher.Program.Auto2", - "name": "Auto 45-65°", - "options": [ - { - "key": "BSH.Common.Option.StartInRelative", - "value": 0, - "unit": "seconds", - "name": "Startzeit" - }, - { - "key": "BSH.Common.Option.SmartEnergyService.SmartStartEnabled", - "value": false - }, - { - "key": "Dishcare.Dishwasher.Option.HygienePlus", - "value": false, - "name": "HygienePlus" - }, - { - "key": "Dishcare.Dishwasher.Option.HalfLoad", - "value": false, - "name": "Halbe Beladung" - }, - { - "key": "Dishcare.Dishwasher.Option.IntensivZone", - "value": false, - "name": "IntensivZone" - }, - { - "key": "Dishcare.Dishwasher.Option.VarioSpeedPlus", - "value": false, - "name": "VarioSpeedPlus" - }, - { - "key": "Dishcare.Dishwasher.Option.BrillianceDry", - "value": false, - "name": "Glanztrocknen" - }, - { - "key": "Dishcare.Dishwasher.Option.SilenceOnDemand", - "value": false, - "name": "Jetzt Leise" - }, - { - "key": "BSH.Common.Option.RemainingProgramTimeIsEstimated", - "value": true, - "name": "Geschätzte Restprogrammlaufzeit" - }, - { - "key": "BSH.Common.Option.EnergyForecast", - "value": 58, - "unit": "%", - "name": "Energie" - }, - { - "key": "BSH.Common.Option.WaterForecast", - "value": 49, - "unit": "%", - "name": "Wasser" - } - ] - } + "data": { + "constraints": { + "access": "readWrite" + }, + "key": "Dishcare.Dishwasher.Program.Auto2", + "name": "Auto 45-65°", + "options": [ + { + "key": "BSH.Common.Option.StartInRelative", + "value": 0, + "unit": "seconds", + "name": "Startzeit" + }, + { + "key": "BSH.Common.Option.SmartEnergyService.SmartStartEnabled", + "value": false + }, + { + "key": "Dishcare.Dishwasher.Option.HygienePlus", + "value": false, + "name": "HygienePlus" + }, + { + "key": "Dishcare.Dishwasher.Option.HalfLoad", + "value": false, + "name": "Halbe Beladung" + }, + { + "key": "Dishcare.Dishwasher.Option.IntensivZone", + "value": false, + "name": "IntensivZone" + }, + { + "key": "Dishcare.Dishwasher.Option.VarioSpeedPlus", + "value": false, + "name": "VarioSpeedPlus" + }, + { + "key": "Dishcare.Dishwasher.Option.BrillianceDry", + "value": false, + "name": "Glanztrocknen" + }, + { + "key": "Dishcare.Dishwasher.Option.SilenceOnDemand", + "value": false, + "name": "Jetzt Leise" + }, + { + "key": "BSH.Common.Option.RemainingProgramTimeIsEstimated", + "value": true, + "name": "Geschätzte Restprogrammlaufzeit" + }, + { + "key": "BSH.Common.Option.EnergyForecast", + "value": 58, + "unit": "%", + "name": "Energie" + }, + { + "key": "BSH.Common.Option.WaterForecast", + "value": 49, + "unit": "%", + "name": "Wasser" + } + ] + } } \ No newline at end of file diff --git a/tests/homeappliances/015070396331014803/settings/BSH.Common.Setting.PowerState/response.json b/tests/homeappliances/015070396331014803/settings/BSH.Common.Setting.PowerState/response.json index d6c42c3..786f44a 100644 --- a/tests/homeappliances/015070396331014803/settings/BSH.Common.Setting.PowerState/response.json +++ b/tests/homeappliances/015070396331014803/settings/BSH.Common.Setting.PowerState/response.json @@ -1,20 +1,20 @@ { - "data": { - "name": "Energiezustand", - "key": "BSH.Common.Setting.PowerState", - "constraints": { - "allowedvalues": [ - "BSH.Common.EnumType.PowerState.Off", - "BSH.Common.EnumType.PowerState.On" - ], - "displayvalues": [ - "Aus", - "An" - ], - "access": "readWrite" - }, - "type": "BSH.Common.EnumType.PowerState", - "displayvalue": "Aus", - "value": "BSH.Common.EnumType.PowerState.Off" - } + "data": { + "name": "Energiezustand", + "key": "BSH.Common.Setting.PowerState", + "constraints": { + "allowedvalues": [ + "BSH.Common.EnumType.PowerState.Off", + "BSH.Common.EnumType.PowerState.On" + ], + "displayvalues": [ + "Aus", + "An" + ], + "access": "readWrite" + }, + "type": "BSH.Common.EnumType.PowerState", + "displayvalue": "Aus", + "value": "BSH.Common.EnumType.PowerState.Off" + } } \ No newline at end of file diff --git a/tests/homeappliances/015070396331014803/settings/response.json b/tests/homeappliances/015070396331014803/settings/response.json index 4592880..1b979e9 100644 --- a/tests/homeappliances/015070396331014803/settings/response.json +++ b/tests/homeappliances/015070396331014803/settings/response.json @@ -1,12 +1,12 @@ { - "data": { - "settings": [ - { - "key": "BSH.Common.Setting.PowerState", - "value": "BSH.Common.EnumType.PowerState.Off", - "name": "Energiezustand", - "displayvalue": "Aus" - } - ] - } + "data": { + "settings": [ + { + "key": "BSH.Common.Setting.PowerState", + "value": "BSH.Common.EnumType.PowerState.Off", + "name": "Energiezustand", + "displayvalue": "Aus" + } + ] + } } \ No newline at end of file diff --git a/tests/homeappliances/015070396331014803/status/response.json b/tests/homeappliances/015070396331014803/status/response.json index 8d99036..841d039 100644 --- a/tests/homeappliances/015070396331014803/status/response.json +++ b/tests/homeappliances/015070396331014803/status/response.json @@ -1,33 +1,33 @@ { - "data": { - "status": [ - { - "key": "BSH.Common.Status.RemoteControlStartAllowed", - "value": true, - "name": "Fernstart" - }, - { - "key": "BSH.Common.Status.RemoteControlActive", - "value": true, - "name": "Fernbedienung" - }, - { - "key": "BSH.Common.Status.DoorState", - "value": "BSH.Common.EnumType.DoorState.Closed", - "name": "Tür", - "displayvalue": "Geschlossen" - }, - { - "key": "BSH.Common.Status.OperationState", - "value": "BSH.Common.EnumType.OperationState.Ready", - "name": "Betriebszustand", - "displayvalue": "Bereit" - }, - { - "key": "BSH.Common.Status.InteriorIlluminationActive", - "value": true, - "name": "Innenbeleuchtung" - } - ] - } + "data": { + "status": [ + { + "key": "BSH.Common.Status.RemoteControlStartAllowed", + "value": true, + "name": "Fernstart" + }, + { + "key": "BSH.Common.Status.RemoteControlActive", + "value": true, + "name": "Fernbedienung" + }, + { + "key": "BSH.Common.Status.DoorState", + "value": "BSH.Common.EnumType.DoorState.Closed", + "name": "Tür", + "displayvalue": "Geschlossen" + }, + { + "key": "BSH.Common.Status.OperationState", + "value": "BSH.Common.EnumType.OperationState.Ready", + "name": "Betriebszustand", + "displayvalue": "Bereit" + }, + { + "key": "BSH.Common.Status.InteriorIlluminationActive", + "value": true, + "name": "Innenbeleuchtung" + } + ] + } } \ No newline at end of file diff --git a/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/response.json b/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/response.json index 406f3a7..81d3a0c 100644 --- a/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/response.json +++ b/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/response.json @@ -1,11 +1,11 @@ { - "data": { - "brand": "Bosch", - "connected": false, - "enumber": "WAV28G43/07", - "haId": "BOSCH-WAV28G43-68A40E970546", - "name": "Waschmaschine", - "type": "Washer", - "vib": "WAV28G43" - } + "data": { + "brand": "Bosch", + "connected": false, + "enumber": "WAV28G43/07", + "haId": "BOSCH-WAV28G43-68A40E970546", + "name": "Waschmaschine", + "type": "Washer", + "vib": "WAV28G43" + } } \ No newline at end of file diff --git a/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/status/response.json b/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/status/response.json index 6772754..e242e93 100644 --- a/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/status/response.json +++ b/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/status/response.json @@ -1,6 +1,6 @@ { - "error": { - "description": "HomeAppliance is offline", - "key": "SDK.Error.HomeAppliance.Connection.Initialization.Failed" - } + "error": { + "description": "HomeAppliance is offline", + "key": "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + } } \ No newline at end of file diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/programs/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/programs/response.json index bec8248..f12650b 100644 --- a/tests/homeappliances/BOSCH-WNC254A40-XYZ/programs/response.json +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/programs/response.json @@ -1,40 +1,40 @@ { - "data": { - "active": { - "constraints": { - "access": "read" - } - }, - "programs": [ - { - "constraints": { - "available": true, - "execution": "selectandstart" + "data": { + "active": { + "constraints": { + "access": "read" + } }, - "key": "LaundryCare.WasherDryer.Program.Cotton.Eco4060", - "name": "Eco 40-60" - }, - { - "constraints": { - "available": true, - "execution": "selectandstart" - }, - "key": "LaundryCare.WasherDryer.Program.Cotton", - "name": "Baumwolle" - }, - { - "constraints": { - "available": true, - "execution": "selectandstart" - }, - "key": "LaundryCare.WasherDryer.Program.Mix", - "name": "Schnell/Mix" - } - ], - "selected": { - "constraints": { - "access": "read" - } + "programs": [ + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "LaundryCare.WasherDryer.Program.Cotton.Eco4060", + "name": "Eco 40-60" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "LaundryCare.WasherDryer.Program.Cotton", + "name": "Baumwolle" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "LaundryCare.WasherDryer.Program.Mix", + "name": "Schnell/Mix" + } + ], + "selected": { + "constraints": { + "access": "read" + } + } } - } -} +} \ No newline at end of file diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/BSH.Common.Setting.PowerState/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/BSH.Common.Setting.PowerState/response.json index ecab5af..3ccb288 100644 --- a/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/BSH.Common.Setting.PowerState/response.json +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/BSH.Common.Setting.PowerState/response.json @@ -1,21 +1,21 @@ { - "data": { - "name": "Energiezustand", - "key": "BSH.Common.Setting.PowerState", - "constraints": { - "allowedvalues": [ - "BSH.Common.EnumType.PowerState.Off", - "BSH.Common.EnumType.PowerState.On" - ], - "displayvalues": [ - "Aus", - "An" - ], - "default": "BSH.Common.EnumType.PowerState.On", - "access": "readWrite" - }, - "type": "BSH.Common.EnumType.PowerState", - "displayvalue": "Aus", - "value": "BSH.Common.EnumType.PowerState.Off" - } -} + "data": { + "name": "Energiezustand", + "key": "BSH.Common.Setting.PowerState", + "constraints": { + "allowedvalues": [ + "BSH.Common.EnumType.PowerState.Off", + "BSH.Common.EnumType.PowerState.On" + ], + "displayvalues": [ + "Aus", + "An" + ], + "default": "BSH.Common.EnumType.PowerState.On", + "access": "readWrite" + }, + "type": "BSH.Common.EnumType.PowerState", + "displayvalue": "Aus", + "value": "BSH.Common.EnumType.PowerState.Off" + } +} \ No newline at end of file diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos1BaseLevel/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos1BaseLevel/response.json index c7404cf..1cd5186 100644 --- a/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos1BaseLevel/response.json +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos1BaseLevel/response.json @@ -1,16 +1,16 @@ { - "data": { - "name": "Basisdosiermenge: Waschmittel", - "key": "LaundryCare.Washer.Setting.IDos1BaseLevel", - "constraints": { - "min": 5, - "max": 200, - "stepsize": 1, - "default": 36, - "access": "readWrite" - }, - "unit": "ml", - "type": "Int", - "value": 50 - } -} + "data": { + "name": "Basisdosiermenge: Waschmittel", + "key": "LaundryCare.Washer.Setting.IDos1BaseLevel", + "constraints": { + "min": 5, + "max": 200, + "stepsize": 1, + "default": 36, + "access": "readWrite" + }, + "unit": "ml", + "type": "Int", + "value": 50 + } +} \ No newline at end of file diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos2BaseLevel/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos2BaseLevel/response.json index f782ec2..cfe100a 100644 --- a/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos2BaseLevel/response.json +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos2BaseLevel/response.json @@ -1,16 +1,16 @@ { - "data": { - "name": "Basisdosiermenge: Waschmittel oder Weichspüler", - "key": "LaundryCare.Washer.Setting.IDos2BaseLevel", - "constraints": { - "min": 5, - "max": 200, - "stepsize": 1, - "default": 75, - "access": "readWrite" - }, - "unit": "ml", - "type": "Int", - "value": 65 - } -} + "data": { + "name": "Basisdosiermenge: Waschmittel oder Weichspüler", + "key": "LaundryCare.Washer.Setting.IDos2BaseLevel", + "constraints": { + "min": 5, + "max": 200, + "stepsize": 1, + "default": 75, + "access": "readWrite" + }, + "unit": "ml", + "type": "Int", + "value": 65 + } +} \ No newline at end of file diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/response.json index f23cda5..fab8d0a 100644 --- a/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/response.json +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/response.json @@ -1,24 +1,24 @@ { - "data": { - "settings": [ - { - "key": "BSH.Common.Setting.PowerState", - "value": "BSH.Common.EnumType.PowerState.Off", - "name": "Energiezustand", - "displayvalue": "Aus" - }, - { - "key": "LaundryCare.Washer.Setting.IDos1BaseLevel", - "value": 50, - "unit": "ml", - "name": "Basisdosiermenge: Waschmittel" - }, - { - "key": "LaundryCare.Washer.Setting.IDos2BaseLevel", - "value": 65, - "unit": "ml", - "name": "Basisdosiermenge: Waschmittel oder Weichspüler" - } - ] - } -} + "data": { + "settings": [ + { + "key": "BSH.Common.Setting.PowerState", + "value": "BSH.Common.EnumType.PowerState.Off", + "name": "Energiezustand", + "displayvalue": "Aus" + }, + { + "key": "LaundryCare.Washer.Setting.IDos1BaseLevel", + "value": 50, + "unit": "ml", + "name": "Basisdosiermenge: Waschmittel" + }, + { + "key": "LaundryCare.Washer.Setting.IDos2BaseLevel", + "value": 65, + "unit": "ml", + "name": "Basisdosiermenge: Waschmittel oder Weichspüler" + } + ] + } +} \ No newline at end of file diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/status/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/status/response.json index b29f605..c294ac8 100644 --- a/tests/homeappliances/BOSCH-WNC254A40-XYZ/status/response.json +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/status/response.json @@ -1,33 +1,33 @@ { - "data": { - "status": [ - { - "key": "BSH.Common.Status.DoorState", - "value": "BSH.Common.EnumType.DoorState.Open", - "name": "Tür", - "displayvalue": "Offen" - }, - { - "key": "BSH.Common.Status.LocalControlActive", - "value": false, - "name": "Lokale Bedienung aktiv" - }, - { - "key": "BSH.Common.Status.OperationState", - "value": "BSH.Common.EnumType.OperationState.Inactive", - "name": "Betriebszustand", - "displayvalue": "Inaktiv" - }, - { - "key": "BSH.Common.Status.RemoteControlActive", - "value": true, - "name": "Fernbedienung" - }, - { - "key": "BSH.Common.Status.RemoteControlStartAllowed", - "value": true, - "name": "Fernstart" - } - ] - } -} + "data": { + "status": [ + { + "key": "BSH.Common.Status.DoorState", + "value": "BSH.Common.EnumType.DoorState.Open", + "name": "Tür", + "displayvalue": "Offen" + }, + { + "key": "BSH.Common.Status.LocalControlActive", + "value": false, + "name": "Lokale Bedienung aktiv" + }, + { + "key": "BSH.Common.Status.OperationState", + "value": "BSH.Common.EnumType.OperationState.Inactive", + "name": "Betriebszustand", + "displayvalue": "Inaktiv" + }, + { + "key": "BSH.Common.Status.RemoteControlActive", + "value": true, + "name": "Fernbedienung" + }, + { + "key": "BSH.Common.Status.RemoteControlStartAllowed", + "value": true, + "name": "Fernstart" + } + ] + } +} \ No newline at end of file From bd86f6a81f0c96217901c657cf49561d8c2468a9 Mon Sep 17 00:00:00 2001 From: bumaas Date: Thu, 8 Oct 2026 14:36:05 +0200 Subject: [PATCH 8/9] 1.1 build 31: Lift a pending block on any stream registration, review follow-ups Upstream review of PR #20: - Since build 30 an expired rate-limit block stayed pending when ResetRateLimit() could not fetch a token. A new login then re-registered the stream through ForceRegisterServerEvents(), but nothing cleared the block: the stream ran while the cloud stayed at status 201 and the devices inactive, until some REST request happened to succeed. connectEventStream() now lifts an expired block as soon as the stream runs again (liftRateLimit), whoever registered it: reset timer, watchdog, new login or 401 recovery. A block that is still running is lifted by ResetRateLimit() after a successful request, as before. - refreshDeviceState() cleared the RetryRefresh timer on every call, so a throttled value refresh dropped a pending initialization retry. The timer is now cleared only by a refresh that actually runs and does not leave the initialization due. - The retry ran through the CONNECTED action and showed up as "Event:CONNECTED (deferred)" in the debug log; it has its own action RetryRefreshDeviceState now. - getReadableName(): digits form a word of their own, a capital right after a digit belongs to it (IDos1BaseLevel -> "IDos 1 Base Level", HotAir3D -> "Hot Air 3D"). - Stale comment in handleExpiredTokenFromStream. Tests: the 4 new tests fail before the fix (81 tests, 4 failures), 81 tests / 653 assertions green after. --- Home Connect Cloud/module.php | 40 ++++++++++++++++------ Home Connect Device/module.php | 23 +++++++++---- library.json | 4 +-- tests/HomeConnectCloudTest.php | 51 ++++++++++++++++++++++++++++ tests/HomeConnectConnectedTest.php | 42 +++++++++++++++++++++++ tests/HomeConnectWasherDryerTest.php | 16 +++++++++ 6 files changed, 158 insertions(+), 18 deletions(-) diff --git a/Home Connect Cloud/module.php b/Home Connect Cloud/module.php index 416c7ba..4ee8925 100644 --- a/Home Connect Cloud/module.php +++ b/Home Connect Cloud/module.php @@ -278,15 +278,11 @@ public function ResetRateLimit() if (!$this->connectEventStream(true, false)) { return; } - $this->WriteAttributeString('RateError', ''); - $this->WriteAttributeInteger('RateLimitUntil', 0); - $this->updateRateLimitNotice(); - - // Only now report IS_ACTIVE: children answer the status change with - // HasActiveParent(), which walks up to the IO. Seeing it still inactive they - // would go inactive and, ignoring repeated identical status messages, stay so. - $this->SendDebug('ResetRateLimit', 'Event stream resumed, instance active', 0); - $this->SetStatus(IS_ACTIVE); + // An expired block is already lifted by connectEventStream; a block that is still + // running (the request went through anyway) is lifted here. + if ($this->ReadAttributeInteger('RateLimitUntil') !== 0) { + $this->liftRateLimit(); + } } public function GetConfigurationForm() @@ -385,7 +381,7 @@ private function handleExpiredTokenFromStream(string $JSONString): bool $this->SendDebug('ReceiveTokenExpired', 'Access token expired on event stream - refreshing token and reconnecting', 0); // The server rejected the cached token, although it may still be valid by its // local expiry (clock skew, revocation). Drop it, so FetchAccessToken() in - // RegisterServerEvents fetches a new one instead of re-sending the rejected one. + // connectEventStream fetches a new one instead of re-sending the rejected one. $this->SetBuffer('AccessToken', ''); $this->connectEventStream(true, false); return true; @@ -624,6 +620,21 @@ private function isRateLimitActive(): bool return $this->ReadAttributeInteger('RateLimitUntil') > time(); } + /** + * Clears the block state and reports the instance active again. Call it only once + * the event stream runs: children answer the status change with HasActiveParent(), + * which walks up to the IO. Seeing it still inactive they would go inactive and, + * ignoring repeated identical status messages, stay so. + */ + private function liftRateLimit(): void + { + $this->WriteAttributeString('RateError', ''); + $this->WriteAttributeInteger('RateLimitUntil', 0); + $this->updateRateLimitNotice(); + $this->SendDebug('ResetRateLimit', 'Event stream resumed, instance active', 0); + $this->SetStatus(IS_ACTIVE); + } + private function updateRateLimitNotice(): void { $rateError = $this->ReadAttributeString('RateError'); @@ -771,6 +782,15 @@ private function connectEventStream(bool $activate, bool $report): bool $this->SetBuffer('KeepAlive', time()); $this->SetTimerInterval('Reconnect', 0); + + // A block that expired while the stream could not be resumed (no token) is still + // pending. Now that the stream runs again, lift it - whoever registered it: the + // reset timer, the watchdog, a new login (ProcessOAuthData) or the 401 recovery. + // Otherwise the instance would stay at the rate-limited status with a running + // stream until some REST request happens to succeed. + if ($this->ReadAttributeInteger('RateLimitUntil') !== 0 && !$this->isRateLimitActive()) { + $this->liftRateLimit(); + } return true; } diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 777db5f..b4dfc46 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -139,7 +139,7 @@ public function Create() $this->RegisterAttributeBoolean('Initialized', false); // Retries an initialization the refresh throttle had to skip. - $this->RegisterTimer('RetryRefresh', 0, 'IPS_RequestAction($_IPS[\'TARGET\'], "RefreshDeviceState", "");'); + $this->RegisterTimer('RetryRefresh', 0, 'IPS_RequestAction($_IPS[\'TARGET\'], "RetryRefreshDeviceState", "");'); //Common States //States @@ -373,6 +373,12 @@ public function RequestAction($Ident, $Value) $this->refreshDeviceState($this->needsInitialization(), 'Event:CONNECTED (deferred)'); return; + case 'RetryRefreshDeviceState': + // Internal action, triggered by the RetryRefresh timer: repeats an + // initialization the refresh throttle had to skip. + $this->refreshDeviceState($this->needsInitialization(), 'RetryRefresh'); + return; + case 'RefreshActiveProgramOptions': // Internal action, triggered by the one-shot timer armed in // updateActiveProgram(). Creates the option variables for a program @@ -589,8 +595,6 @@ private function refreshDeviceState(bool $initializeDevice, string $trigger = '' { // 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); - // Any refresh supersedes a pending retry; a throttled init re-arms it below. - $this->SetTimerInterval('RetryRefresh', 0); if ($this->HasActiveParent() && $this->ReadPropertyString('HaID')) { $this->SetSummary($this->ReadPropertyString('HaID')); $wait = $this->refreshThrottled(); @@ -599,13 +603,19 @@ private function refreshDeviceState(bool $initializeDevice, string $trigger = '' // keep the instance active. Live STATUS/NOTIFY events still update // values directly (they do not go through this path). A skipped // initialization has no such substitute: retry it after the window. + // A throttled value refresh leaves a pending retry alone. $this->SendDebug(__FUNCTION__, sprintf('throttled (trigger: %s)', $trigger), 0); - if ($initializeDevice) { + if ($initializeDevice || $this->needsInitialization()) { $this->SetTimerInterval('RetryRefresh', $wait * 1000); } $this->setInstanceStatus(IS_ACTIVE); return; } + // This refresh runs now and supersedes a pending retry - unless it is a value + // refresh and the initialization is still due. + if ($initializeDevice || !$this->needsInitialization()) { + $this->SetTimerInterval('RetryRefresh', 0); + } if ($initializeDevice) { $this->InitializeDevice(); } else { @@ -1316,11 +1326,12 @@ private function getLastSnippet($string) /** * Readable name built from the last key snippet for keys without a known name - * (SaltNearlyEmpty -> "Salt Nearly Empty", DelicatesSilk -> "Delicates Silk"). + * (SaltNearlyEmpty -> "Salt Nearly Empty", Eco50 -> "Eco 50"). Digits form a word of + * their own; a capital right after a digit belongs to it (HotAir3D -> "Hot Air 3D"). */ private function getReadableName(string $key): string { - return trim(preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', ' ', $this->getLastSnippet($key))); + return trim(preg_replace('/(?<=[a-z])(?=[A-Z0-9])|(?<=[0-9])(?=[A-Z][a-z])/', ' ', $this->getLastSnippet($key))); } private function createAssociations($profileName, $associations) diff --git a/library.json b/library.json index d52c593..d38c0c5 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 30, - "date": 1791112380 + "build": 31, + "date": 1791462859 } diff --git a/tests/HomeConnectCloudTest.php b/tests/HomeConnectCloudTest.php index f2aee00..ab68334 100644 --- a/tests/HomeConnectCloudTest.php +++ b/tests/HomeConnectCloudTest.php @@ -13,6 +13,7 @@ class HomeConnectCloudTest extends TestCase { private const CLOUD_GUID = '{CE76810D-B685-9BE0-CC04-38B204DEAD5E}'; + private const STATUS_RATE_LIMITED = 201; //A 429 as it arrives through the SSE event stream (see cbeham's dump.txt). private const RATE_LIMIT_PAYLOAD = '{"error":{"key":"429","description":"The rate limit \"1000 calls in 1 day\" was reached. Requests are blocked during the remaining period of 18113 seconds."}}'; @@ -496,6 +497,56 @@ public function testResetRateLimitActivatesIoBeforeStatus() $this->assertLessThan($activated, $registered, 'The IO must be active before the instance reports IS_ACTIVE'); } + /** + * Upstream review of PR #20: since build 30 an expired block stays pending when the + * stream cannot be resumed (no token). When the user then registers again, + * ProcessOAuthData only re-registers the stream - nothing cleared the block, so the + * stream ran while the cloud stayed at 201 and the devices stayed inactive. Any + * successful stream registration must lift an expired block. + */ + public function testReRegistrationLiftsPendingBlock() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + + $cloud->ReceiveData(self::RATE_LIMIT_PAYLOAD); + //The block expired, but the reset could not fetch a token (login revoked). + $this->invoke($cloud, 'WriteAttributeInteger', 'RateLimitUntil', time() - 1); + ob_start(); + $cloud->ResetRateLimit(); + ob_end_clean(); + $this->assertSame(self::STATUS_RATE_LIMITED, IPS_GetInstance($cloudID)['InstanceStatus'], 'Precondition: the block is still pending'); + + //The user registers again: a token is available, ProcessOAuthData re-registers. + $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + $cloud->ForceRegisterServerEvents(); + + $this->assertTrue(IPS_GetProperty($parent, 'Active'), 'The event stream runs again'); + $this->assertSame(0, $this->invoke($cloud, 'ReadAttributeInteger', 'RateLimitUntil'), 'The expired block must be lifted with the stream'); + $this->assertSame('', $this->invoke($cloud, 'ReadAttributeString', 'RateError'), 'The notice must be cleared'); + $this->assertSame(IS_ACTIVE, IPS_GetInstance($cloudID)['InstanceStatus'], 'The instance must leave the rate-limited status'); + } + + /** + * Counterpart: a block that is still running is not lifted by a registration - the + * registration is deferred instead (see testRegisterServerEventsDefersWhileRateLimited). + */ + public function testReRegistrationKeepsRunningBlock() + { + $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])); + + $cloud->ReceiveData(self::RATE_LIMIT_PAYLOAD); + $cloud->ForceRegisterServerEvents(); + + $this->assertFalse(IPS_GetProperty($parent, 'Active'), 'The IO stays off during the block'); + $this->assertNotSame(0, $this->invoke($cloud, 'ReadAttributeInteger', 'RateLimitUntil'), 'A running block is kept'); + $this->assertSame(self::STATUS_RATE_LIMITED, IPS_GetInstance($cloudID)['InstanceStatus']); + } + private function cloud() { return IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]); diff --git a/tests/HomeConnectConnectedTest.php b/tests/HomeConnectConnectedTest.php index 82709ca..771b081 100644 --- a/tests/HomeConnectConnectedTest.php +++ b/tests/HomeConnectConnectedTest.php @@ -124,6 +124,48 @@ public function testThrottledValueRefreshIsNotRetried() $this->assertLessThanOrEqual(0, $this->invoke($intf, 'GetTimerInterval', 'RetryRefresh'), 'No retry for a value refresh'); } + /** + * Upstream review of PR #20: refreshDeviceState() cleared the retry timer on every + * call, so a throttled value refresh in the same window dropped a pending + * initialization retry without re-arming it. The retry must survive. + */ + public function testThrottledValueRefreshKeepsPendingRetry() + { + $device = $this->createDevice(self::OFFLINE_HAID, 'Washer'); + $intf = IPS\InstanceManager::getInstanceInterface($device); + $intf->ReceiveData(json_encode(['Event' => 'CONNECTED', 'Data' => '', 'ID' => self::OFFLINE_HAID])); + $this->assertGreaterThan(0, $this->invoke($intf, 'GetTimerInterval', 'RetryRefresh'), 'Precondition: a retry is pending'); + + //A value refresh within the throttle window (e.g. a parent status flap). + $this->invoke($intf, 'refreshDeviceState', false, 'test'); + + $this->assertGreaterThan(0, $this->invoke($intf, 'GetTimerInterval', 'RetryRefresh'), 'The pending retry must not be lost'); + } + + /** + * Upstream review of PR #20: the retry ran through the CONNECTED action and showed up + * in the debug log as "Event:CONNECTED (deferred)". It must name its own trigger. + */ + public function testRetryRunsUnderItsOwnLabel() + { + $device = $this->createDevice(self::OFFLINE_HAID, 'Washer'); + $intf = IPS\InstanceManager::getInstanceInterface($device); + $intf->ReceiveData(json_encode(['Event' => 'CONNECTED', 'Data' => '', 'ID' => self::OFFLINE_HAID])); + + $before = count(IPS\DebugServer::getDebugMessages($device)); + //What the RetryRefresh timer runs. + $intf->RequestAction('RetryRefreshDeviceState', ''); + + $traces = []; + foreach (array_slice(IPS\DebugServer::getDebugMessages($device), $before) as $message) { + if ($message['Message'] === 'refreshDeviceState' && strpos($message['Data'], 'trigger: ') === 0) { + $traces[] = $message['Data']; + } + } + $this->assertCount(1, $traces, 'The retry runs exactly one refresh'); + $this->assertStringStartsWith('trigger: RetryRefresh,', $traces[0], 'The retry must be traced under its own trigger'); + } + private function invoke($object, string $method, ...$args) { $ref = new ReflectionMethod($object, $method); diff --git a/tests/HomeConnectWasherDryerTest.php b/tests/HomeConnectWasherDryerTest.php index 6ad041a..8f5b3f1 100644 --- a/tests/HomeConnectWasherDryerTest.php +++ b/tests/HomeConnectWasherDryerTest.php @@ -97,6 +97,22 @@ public function testActiveProgramEventGetsReadableName() $this->assertSame('Delicates Silk', $this->associations()['LaundryCare.WasherDryer.Program.DelicatesSilk'] ?? null); } + /** + * Upstream review of PR #20: a digit followed by a capital split oddly, "HotAir3D" + * became "Hot Air3 D". Digits form their own word, a capital after a digit does not. + */ + public function testReadableNameKeepsDigitGroupsTogether() + { + $intf = IPS\InstanceManager::getInstanceInterface($this->createWasherDryer()); + $readableName = new ReflectionMethod($intf, 'getReadableName'); + $readableName->setAccessible(true); + + //Keys of the washer WAV28G43 and the dishwasher (tests/homeappliances), example from the review. + $this->assertSame('IDos 1 Base Level', $readableName->invoke($intf, 'LaundryCare.Washer.Setting.IDos1BaseLevel')); + $this->assertSame('Eco 50', $readableName->invoke($intf, 'Dishcare.Dishwasher.Program.Eco50')); + $this->assertSame('Hot Air 3D', $readableName->invoke($intf, 'Cooking.Oven.Program.HeatingMode.HotAir3D')); + } + private function createWasherDryer() { $washerDryer = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); From ee6ca57dcee7ef8b1195ed911686a84d1c80d6e1 Mon Sep 17 00:00:00 2001 From: bumaas Date: Thu, 8 Oct 2026 14:54:05 +0200 Subject: [PATCH 9/9] 1.1 build 32: Float option against an integer profile gets a float profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream review of PR #20, last point: an option or setting profile that already exists as INTEGER while the API reports a Double made the variable an INTEGER too (build 30), so a value like 4.5 °C was cut to 4. The lossy direction now gets a float profile of its own (".f" suffix, as createStates does) and a float variable; the existing profile is left alone. The other direction (FLOAT profile, Int setting) is lossless and keeps following the profile. Fixture: real capture of the oven HM778GMB1 (programs/available and the HotAir options, Cooking.Oven.Option.SetpointTemperature is a Double). Tests: the new test fails before the fix (82 tests, 1 failure), 82 tests / 663 assertions green after. --- Home Connect Device/module.php | 11 ++ library.json | 4 +- tests/HomeConnectOvenTest.php | 35 +++++ .../response.json | 50 ++++++ .../programs/available/response.json | 142 ++++++++++++++++++ 5 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 tests/homeappliances/385110390226001436-001/programs/available/Cooking.Oven.Program.HeatingMode.HotAir/response.json create mode 100644 tests/homeappliances/385110390226001436-001/programs/available/response.json diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index b4dfc46..927da14 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -1433,6 +1433,17 @@ private function createVariableFromConstraints($profileName, $data, $attribute, IPS_CreateVariableProfile($profileName, $variableType); } $existingProfileType = IPS_GetVariableProfile($profileName)['ProfileType']; + if ($existingProfileType === VARIABLETYPE_INTEGER && $variableType === VARIABLETYPE_FLOAT) { + // An integer profile would cut the float values (4.5 °C -> 4). Give them a + // float profile of their own, as createStates does, and leave the existing + // profile alone. The other direction is lossless and follows the profile. + $this->SendDebug(__FUNCTION__, sprintf('Profile %s exists as integer; using %s.f for the float values', $profileName, $profileName), 0); + $profileName .= '.f'; + if (!IPS_VariableProfileExists($profileName)) { + IPS_CreateVariableProfile($profileName, VARIABLETYPE_FLOAT); + } + $existingProfileType = IPS_GetVariableProfile($profileName)['ProfileType']; + } if ($existingProfileType === $variableType) { IPS_SetVariableProfileText($profileName, '', isset($data['unit']) ? ' ' . $data['unit'] : ''); $min = isset($constraints['min']) ? $constraints['min'] : 0; diff --git a/library.json b/library.json index d38c0c5..1ca3021 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 31, - "date": 1791462859 + "build": 32, + "date": 1791464032 } diff --git a/tests/HomeConnectOvenTest.php b/tests/HomeConnectOvenTest.php index 6defa2b..2aff37f 100644 --- a/tests/HomeConnectOvenTest.php +++ b/tests/HomeConnectOvenTest.php @@ -31,6 +31,41 @@ protected function setUp(): void parent::setUp(); } + /** + * Upstream review of PR #20: an option profile that already exists as INTEGER while the + * API reports a Double made the variable an INTEGER too (build 30), so 4.5 would be cut + * to 4. The lossy direction gets a float profile of its own (".f", as createStates does) + * and a float variable. The option is the setpoint of the oven HM778GMB1 (real capture + * under tests/homeappliances/385110390226001436-001); only the stale profile is staged. + */ + public function testFloatOptionAgainstIntegerProfileGetsFloatProfile() + { + $profile = 'HomeConnect.Oven.Option.SetpointTemperature'; + IPS_CreateVariableProfile($profile, VARIABLETYPE_INTEGER); + + $oven = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + $intf = IPS\InstanceManager::getInstanceInterface($oven); + $program = json_decode(file_get_contents(__DIR__ . '/homeappliances/385110390226001436-001/programs/available/Cooking.Oven.Program.HeatingMode.HotAir/response.json'), true); + $option = $program['data']['options'][0]; + $this->assertSame('Cooking.Oven.Option.SetpointTemperature', $option['key']); + $this->assertSame('Double', $option['type']); + + $create = new ReflectionMethod($intf, 'createVariableFromConstraints'); + $create->setAccessible(true); + $create->invoke($intf, $profile, $option, 'Option', 10); + + $variableID = @IPS_GetObjectIDByIdent('OptionSetpointTemperature', $oven); + $this->assertNotFalse($variableID, 'The option variable must be created'); + $this->assertSame(VARIABLETYPE_FLOAT, IPS_GetVariable($variableID)['VariableType'], 'A Double option must not be cut to an integer'); + $this->assertSame($profile . '.f', IPS_GetVariable($variableID)['VariableProfile'], 'The float variable gets the float profile'); + $floatProfile = IPS_GetVariableProfile($profile . '.f'); + $this->assertSame(VARIABLETYPE_FLOAT, $floatProfile['ProfileType']); + $this->assertEquals(30, $floatProfile['MinValue']); + $this->assertEquals(275, $floatProfile['MaxValue']); + $this->assertSame(' °C', $floatProfile['Suffix']); + $this->assertSame(VARIABLETYPE_INTEGER, IPS_GetVariableProfile($profile)['ProfileType'], 'The existing profile is left alone'); + } + public function testBaseFunctionality() { $cloudInterface = IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID('{CE76810D-B685-9BE0-CC04-38B204DEAD5E}')[0]); diff --git a/tests/homeappliances/385110390226001436-001/programs/available/Cooking.Oven.Program.HeatingMode.HotAir/response.json b/tests/homeappliances/385110390226001436-001/programs/available/Cooking.Oven.Program.HeatingMode.HotAir/response.json new file mode 100644 index 0000000..5d23b85 --- /dev/null +++ b/tests/homeappliances/385110390226001436-001/programs/available/Cooking.Oven.Program.HeatingMode.HotAir/response.json @@ -0,0 +1,50 @@ +{ + "data": { + "key": "Cooking.Oven.Program.HeatingMode.HotAir", + "name": "4D Heißluft", + "options": [ + { + "name": "Temperatur", + "key": "Cooking.Oven.Option.SetpointTemperature", + "constraints": { + "min": 30, + "liveupdate": true, + "max": 275, + "stepsize": 1, + "default": 180 + }, + "unit": "°C", + "type": "Double" + }, + { + "name": "Dauer anpassen", + "key": "BSH.Common.Option.Duration", + "constraints": { + "min": 1, + "max": 86400, + "default": 1, + "liveupdate": true + }, + "unit": "seconds", + "type": "Int" + }, + { + "name": "Startzeit", + "key": "BSH.Common.Option.StartInRelative", + "constraints": { + "min": 0, + "max": 86340, + "default": 0, + "liveupdate": true + }, + "unit": "seconds", + "type": "Int" + }, + { + "key": "Cooking.Oven.Option.FastPreHeat", + "type": "Boolean", + "name": "Schnellaufheizen" + } + ] + } +} diff --git a/tests/homeappliances/385110390226001436-001/programs/available/response.json b/tests/homeappliances/385110390226001436-001/programs/available/response.json new file mode 100644 index 0000000..a224da7 --- /dev/null +++ b/tests/homeappliances/385110390226001436-001/programs/available/response.json @@ -0,0 +1,142 @@ +{ + "data": { + "active": { + "constraints": { + "access": "readWrite" + } + }, + "programs": [ + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.Microwave.90Watt", + "name": "90 Watt" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.Microwave.180Watt", + "name": "180 Watt" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.Microwave.360Watt", + "name": "360 Watt" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.Microwave.600Watt", + "name": "600 Watt" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.Microwave.Max", + "name": "Boost" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.HotAir", + "name": "4D Heißluft" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.TopBottomHeating", + "name": "Ober-/Unterhitze" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.TopBottomHeatingEco", + "name": "Sanfte Ober-/Unterhitze" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.HotAirGrilling", + "name": "Umluftgrillen" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.PizzaSetting", + "name": "Pizzastufe" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.FrozenHeatupSpecial", + "name": "coolStart-Funktion" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.IntensiveHeat", + "name": "Intensivhitze" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.SlowCook", + "name": "Sanftgaren" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.Desiccation", + "name": "Dörren" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.BottomHeating", + "name": "Unterhitze" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.KeepWarm", + "name": "Warmhalten" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.PreheatOvenware", + "name": "Geschirr vorwärmen" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.PreHeating", + "name": "Vorheizen" + } + ], + "selected": { + "constraints": { + "access": "readWrite" + } + } + } +}