From 3bed6788235a13be5162a2f336101874b249628c Mon Sep 17 00:00:00 2001 From: bumaas Date: Wed, 29 Jul 2026 19:16:33 +0200 Subject: [PATCH 1/5] =?UTF-8?q?1.1=20build=2020:=20Aktives=20Programm=20au?= =?UTF-8?q?s=20ActiveProgram-Event=20(Haube=20L=C3=BCfternachlauf)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Geräte wie Dunstabzugshauben melden das laufende Programm beim Lüfternachlauf / der Intervall-Lüftung nur über BSH.Common.Root.ActiveProgram, nie über SelectedProgram - bisher wurde das Event per EXCLUDE-Liste komplett ignoriert und die Programmanzeige blieb leer (Forum-Meldung pitti, Beitrag #530/#531). Neu: rein anzeigende Variable "Aktives Programm" (Ident ActiveProgram), befüllt direkt aus dem Event, angelegt erst beim ersten ActiveProgram-Event, geleert bei value=null (Programmende). Keine zusätzlichen API-Aufrufe, keine Auswirkung auf SelectedProgram/Start. Co-Authored-By: Claude Fable 5 --- Home Connect Device/locale.json | 1 + Home Connect Device/module.php | 29 +++++++++++ library.json | 4 +- tests/HomeConnectHoodTest.php | 91 +++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tests/HomeConnectHoodTest.php diff --git a/Home Connect Device/locale.json b/Home Connect Device/locale.json index 43ea342..dd493d2 100644 --- a/Home Connect Device/locale.json +++ b/Home Connect Device/locale.json @@ -3,6 +3,7 @@ "de": { "Control": "Steuern", "Program": "Programm", + "Active Program": "Aktives Programm", "DeviceType": "Gerätetyp", "Current Cavity Temperature": "Aktuelle Temperatur", "Remote control not active": "Fernsteuerung nicht aktiviert", diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 5825a47..7dbf6c3 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -205,6 +205,10 @@ public function ReceiveData($String) $items = json_decode($data['Data'], true)['items']; // $this->SendDebug($cleanData['event'], json_encode($items), 0); foreach ($items as $item) { + if ($item['key'] == 'BSH.Common.Root.ActiveProgram') { + $this->updateActiveProgram($item['value'] ?? null); + continue; + } if (in_array($item['key'], self::EXCLUDE)) { continue; } @@ -868,6 +872,31 @@ private function updateOptionValues($program) $this->WriteAttributeString('OptionKeys', json_encode($optionKeys)); } + /** + * Mirrors BSH.Common.Root.ActiveProgram events into a read-only display variable. + * Some appliances (e.g. hood fan run-on / interval venting) report the running + * program only via ActiveProgram, never via SelectedProgram. The variable is + * created on the first event, so it only shows up on devices that actually + * report an active program. A null value (program finished) clears the display. + */ + private function updateActiveProgram($value) + { + $ident = 'ActiveProgram'; + if (!@IPS_GetObjectIDByIdent($ident, $this->InstanceID)) { + if (!is_string($value) || $value == '') { + // Do not create the variable just to show "nothing running". + return; + } + $profileName = 'HomeConnect.' . $this->ReadPropertyString('DeviceType') . '.Programs'; + if (!IPS_VariableProfileExists($profileName)) { + $profileName = ''; + } + $this->MaintainVariable($ident, $this->Translate('Active Program'), VARIABLETYPE_STRING, $profileName, 2, true); + } + $this->SetValue($ident, is_string($value) ? $value : ''); + $this->SendDebug(__FUNCTION__, is_string($value) ? $value : '(cleared)', 0); + } + /** * 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 513743d..c2d30b4 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 19, - "date": 1783346061 + "build": 20, + "date": 1785345327 } diff --git a/tests/HomeConnectHoodTest.php b/tests/HomeConnectHoodTest.php new file mode 100644 index 0000000..50c64b9 --- /dev/null +++ b/tests/HomeConnectHoodTest.php @@ -0,0 +1,91 @@ +ReceiveData($this->generateActiveProgramEvent('Cooking.Common.Program.Hood.DelayedShutOff')); + + $variableID = IPS_GetObjectIDByIdent('ActiveProgram', $hood); + $this->assertNotFalse($variableID); + $this->assertEquals(VARIABLETYPE_STRING, IPS_GetVariable($variableID)['VariableType']); + $this->assertEquals('Cooking.Common.Program.Hood.DelayedShutOff', GetValue($variableID)); + } + + public function testActiveProgramNullClearsVariable() + { + $hood = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + $intf = IPS\InstanceManager::getInstanceInterface($hood); + + $intf->ReceiveData($this->generateActiveProgramEvent('Cooking.Common.Program.Hood.DelayedShutOff')); + $intf->ReceiveData($this->generateActiveProgramEvent(null)); + + $variableID = IPS_GetObjectIDByIdent('ActiveProgram', $hood); + $this->assertNotFalse($variableID); + $this->assertEquals('', GetValue($variableID)); + } + + public function testActiveProgramNullWithoutVariableDoesNotCreateIt() + { + $hood = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + $intf = IPS\InstanceManager::getInstanceInterface($hood); + + // A null value (nothing running) must not create the variable. + $intf->ReceiveData($this->generateActiveProgramEvent(null)); + + $this->assertFalse(@IPS_GetObjectIDByIdent('ActiveProgram', $hood)); + } + + private function generateActiveProgramEvent($value) + { + $data = [ + 'Event' => 'NOTIFY', + 'Data' => json_encode([ + 'items' => [ + 0 => [ + 'timestamp' => 1753731205, + 'handling' => 'none', + 'uri' => '/api/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/active', + 'key' => 'BSH.Common.Root.ActiveProgram', + 'value' => $value, + 'level' => 'hint', + ], + ], + 'haId' => 'SIEMENS-LD88WMM66-XYZ', + ]), + 'id' => 'SIEMENS-LD88WMM66-XYZ', + ]; + return json_encode($data); + } +} From 943fa68486848362c4f77a867017cc68a526fbee Mon Sep 17 00:00:00 2001 From: bumaas Date: Thu, 30 Jul 2026 17:23:44 +0200 Subject: [PATCH 2/5] 1.1 build 21: Options-Variablen aus ActiveProgram-Event (Haube VentingLevel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Haube bei lokaler Bedienung meldet ihr Programm nur über ActiveProgram, nie über SelectedProgram - der Pfad, der die Options-Variablen anlegt, lief daher nie und Events wie VentingLevel hatten keine Variable zum Aktualisieren (Forum-Fall pitti, LD88WMM66). - ActiveProgram-Event stößt (entkoppelt per OnceTimer, nur bei Programmwechsel) einen Abruf von programs/available/ an und legt die Options-Variablen mit Profil aus den Constraints an - Option-Werte, die vor der Variablenerzeugung eintreffen, werden gepuffert und nach dem Anlegen angewendet - updateOptionVariables: neuer Parameter clearSelectionOnFailure, damit ein nicht auflösbares Laufzeitprogramm (z. B. Lüfternachlauf, SDK.Error.UnsupportedProgram) die Programmauswahl nicht mehr leert - Tests: Haube mit vollständigem Fixture-Satz (Init, Variablenerzeugung inkl. Request-Zählung, Puffer-Replay, UnsupportedProgram-Regression) Co-Authored-By: Claude Fable 5 --- Home Connect Device/module.php | 95 ++++++++++- library.json | 4 +- tests/HomeConnectHoodTest.php | 158 ++++++++++++++++-- .../response.json | 46 +++++ .../response.json | 6 + .../programs/response.json | 30 ++++ .../response.json | 21 +++ .../settings/response.json | 12 ++ .../status/response.json | 27 +++ 9 files changed, 375 insertions(+), 24 deletions(-) create mode 100644 tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/available/Cooking.Common.Program.Hood.Automatic/response.json create mode 100644 tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/available/Cooking.Common.Program.Hood.DelayedShutOff/response.json create mode 100644 tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/response.json create mode 100644 tests/homeappliances/SIEMENS-LD88WMM66-XYZ/settings/BSH.Common.Setting.PowerState/response.json create mode 100644 tests/homeappliances/SIEMENS-LD88WMM66-XYZ/settings/response.json create mode 100644 tests/homeappliances/SIEMENS-LD88WMM66-XYZ/status/response.json diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 7dbf6c3..57ce5b8 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -245,6 +245,11 @@ public function ReceiveData($String) } if (@IPS_GetObjectIDByIdent($ident, $this->InstanceID)) { $this->SetValue($ident, $item['value']); + } elseif (strpos($ident, 'Option') === 0) { + // The variable may be created only moments later by + // refreshActiveProgramOptions - keep the value so it + // does not start out empty (see rememberPendingOptionValue). + $this->rememberPendingOptionValue($ident, $item['value']); } $this->SendDebug($ident, strval($item['value']), 0); break; @@ -300,6 +305,13 @@ public function RequestAction($Ident, $Value) $this->refreshDeviceState($this->needsInitialization(), 'Event:CONNECTED (deferred)'); return; + case 'RefreshActiveProgramOptions': + // Internal action, triggered by the one-shot timer armed in + // updateActiveProgram(). Creates the option variables for a program + // that is only reported via ActiveProgram. + $this->refreshActiveProgramOptions(); + return; + case 'UseDuration': $applyValue = true; break; @@ -729,16 +741,21 @@ private function sendOptionsOnProgramStart() /** * @param string|array $program Der Programmschlüssel oder das bereits abgerufene Programmdaten-Array. + * @param bool $clearSelectionOnFailure Bei fehlenden Programmdaten die Programmauswahl + * zurücksetzen (Standard, Selected-Program-Pfad) oder + * unverändert lassen (Active-Program-Pfad). */ - private function updateOptionVariables($program) + private function updateOptionVariables($program, $clearSelectionOnFailure = true) { $rawOptions = $this->resolveProgramData($program); $this->SendDebug('RawOptions', json_encode($rawOptions), 0); if (!$rawOptions) { - $this->SetValue('SelectedProgram', ''); - $this->setOptionsDisabled(true); - $this->syncUseDurationVariable(false, 0); + if ($clearSelectionOnFailure) { + $this->SetValue('SelectedProgram', ''); + $this->setOptionsDisabled(true); + $this->syncUseDurationVariable(false, 0); + } return; } $this->setOptionsDisabled(false); @@ -893,8 +910,74 @@ private function updateActiveProgram($value) } $this->MaintainVariable($ident, $this->Translate('Active Program'), VARIABLETYPE_STRING, $profileName, 2, true); } - $this->SetValue($ident, is_string($value) ? $value : ''); - $this->SendDebug(__FUNCTION__, is_string($value) ? $value : '(cleared)', 0); + $newValue = is_string($value) ? $value : ''; + $changed = $this->GetValue($ident) != $newValue; + $this->SetValue($ident, $newValue); + $this->SendDebug(__FUNCTION__, $newValue != '' ? $newValue : '(cleared)', 0); + if (!$changed) { + return; + } + if ($newValue == '') { + // Program finished - drop values buffered for it. + $this->SetBuffer('PendingOptionValues', ''); + return; + } + // Locally operated appliances (e.g. hoods) report their program only via + // ActiveProgram - no SelectedProgram event ever creates the option variables, + // so trigger that from here. Decoupled from the event thread (see + // RefreshSelectedProgram) and only on a program change, so repeated events + // for the same program do not cost extra server requests. + $this->RegisterOnceTimer('RefreshActiveProgramOptions', 'IPS_RequestAction($_IPS[\'TARGET\'], "RefreshActiveProgramOptions", "");'); + } + + /** + * Remembers an option value received via event while its variable does not exist + * yet. refreshActiveProgramOptions() applies the buffered values once the + * variables are created; without this, a freshly created option variable would + * stay empty until the appliance sends the next change (e.g. the hood's venting + * level, which is only reported again when the stage changes). + */ + private function rememberPendingOptionValue($ident, $value) + { + $pending = json_decode($this->GetBuffer('PendingOptionValues'), true); + if (!is_array($pending)) { + $pending = []; + } + $pending[$ident] = $value; + $this->SetBuffer('PendingOptionValues', json_encode($pending)); + } + + /** + * Creates the option variables for the program reported via ActiveProgram. Hoods + * (and other locally operated appliances) never send a SelectedProgram event, so + * the regular option refresh does not run for them and events like VentingLevel + * had no variable to update. Uses the available-program metadata (one server + * request per program change) for proper profiles and constraints and + * deliberately leaves SelectedProgram untouched - its value feeds the Start + * payload. Undocumented runtime programs (e.g. an oven's ContinueCooking) are + * not listed under programs/available; then nothing is created and - unlike the + * selected-program path - nothing is cleared either. + */ + private function refreshActiveProgramOptions() + { + if ($this->ReadPropertyString('HaID') == '' || !@IPS_GetObjectIDByIdent('ActiveProgram', $this->InstanceID)) { + return; + } + $key = $this->GetValue('ActiveProgram'); + if (!is_string($key) || $key == '') { + return; + } + $this->updateOptionVariables($key, false); + $pending = json_decode($this->GetBuffer('PendingOptionValues'), true); + $this->SetBuffer('PendingOptionValues', ''); + if (!is_array($pending)) { + return; + } + foreach ($pending as $ident => $value) { + if (@IPS_GetObjectIDByIdent($ident, $this->InstanceID)) { + $this->SetValue($ident, $value); + } + } } /** diff --git a/library.json b/library.json index c2d30b4..4e77246 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 20, - "date": 1785345327 + "build": 21, + "date": 1785424998 } diff --git a/tests/HomeConnectHoodTest.php b/tests/HomeConnectHoodTest.php index 50c64b9..10e3659 100644 --- a/tests/HomeConnectHoodTest.php +++ b/tests/HomeConnectHoodTest.php @@ -12,6 +12,8 @@ class HomeConnectHoodTest extends TestCase { + private const HA_ID = 'SIEMENS-LD88WMM66-XYZ'; + protected function setUp(): void { //Reset @@ -26,6 +28,11 @@ protected function setUp(): void //Register our library we need for testing IPS\ModuleLoader::loadLibrary(__DIR__ . '/../library.json'); + $this->ConfiguratorID = IPS_CreateInstance('{CA0E667D-8F28-8DF1-2750-5CF587ECA85A}'); + $cloudID = IPS_GetInstanceListByModuleID('{CE76810D-B685-9BE0-CC04-38B204DEAD5E}')[0]; + IPS\InstanceManager::setStatus($this->ConfiguratorID, 102); + IPS\InstanceManager::setStatus($cloudID, 102); + parent::setUp(); } @@ -67,25 +74,144 @@ public function testActiveProgramNullWithoutVariableDoesNotCreateIt() $this->assertFalse(@IPS_GetObjectIDByIdent('ActiveProgram', $hood)); } - private function generateActiveProgramEvent($value) + /** + * A hood operated at the appliance never sends SelectedProgram, so the option + * variables have to be created from the ActiveProgram event: one lookup of the + * available-program metadata provides the profiles, the option events themselves + * provide the values. + */ + public function testActiveProgramCreatesOptionVariables() + { + $hood = $this->createInitializedHood(); + $intf = IPS\InstanceManager::getInstanceInterface($hood); + + // Idle after init: no program related option variables yet. + $this->assertFalse(@IPS_GetObjectIDByIdent('OptionVentingLevel', $hood)); + + HomeConnectCloud::$requestCount = 0; + // Local program start as captured from a real device: ActiveProgram plus the + // current option values arrive in one NOTIFY batch. + $intf->ReceiveData($this->generateNotifyEvent([ + $this->item('BSH.Common.Setting.PowerState', 'BSH.Common.EnumType.PowerState.On', 'settings/BSH.Common.Setting.PowerState'), + $this->item('BSH.Common.Root.ActiveProgram', 'Cooking.Common.Program.Hood.Automatic', 'programs/active'), + $this->item('Cooking.Common.Option.Hood.VentingLevel', 'Cooking.Hood.EnumType.Stage.FanOff', 'programs/selected/options/Cooking.Common.Option.Hood.VentingLevel'), + $this->item('Cooking.Common.Option.Hood.IntensiveLevel', 'Cooking.Hood.EnumType.IntensiveStage.IntensiveStageOff', 'programs/selected/options/Cooking.Common.Option.Hood.IntensiveLevel'), + ])); + + $this->assertEquals(1, HomeConnectCloud::$requestCount, 'Creating the option variables must cost exactly one request'); + $ventingID = IPS_GetObjectIDByIdent('OptionVentingLevel', $hood); + $this->assertNotFalse($ventingID); + $this->assertEquals('Cooking.Hood.EnumType.Stage.FanOff', GetValue($ventingID)); + $this->assertEquals('Cooking.Hood.EnumType.IntensiveStage.IntensiveStageOff', GetValue(IPS_GetObjectIDByIdent('OptionIntensiveLevel', $hood))); + + // The variable must carry the enum profile built from the constraints. + $profileName = IPS_GetVariable($ventingID)['VariableProfile']; + $this->assertEquals('HomeConnect.Hood.Option.VentingLevel', $profileName); + $associations = []; + foreach (IPS_GetVariableProfile($profileName)['Associations'] as $association) { + $associations[$association['Value']] = $association['Name']; + } + $this->assertEquals('Lüfterstufe 1', $associations['Cooking.Hood.EnumType.Stage.FanStage01']); + + // The program itself must not pollute the (empty) program selection. + $this->assertEquals('', GetValue(IPS_GetObjectIDByIdent('SelectedProgram', $hood))); + + // Subsequent option events update the value without any further request. + $intf->ReceiveData($this->generateNotifyEvent([ + $this->item('Cooking.Common.Option.Hood.VentingLevel', 'Cooking.Hood.EnumType.Stage.FanStage02', 'programs/selected/options/Cooking.Common.Option.Hood.VentingLevel'), + ])); + $this->assertEquals('Cooking.Hood.EnumType.Stage.FanStage02', GetValue($ventingID)); + $this->assertEquals(1, HomeConnectCloud::$requestCount, 'Option value updates must not cost requests'); + + // A repeated ActiveProgram event for the same program must not refetch. + $intf->ReceiveData($this->generateNotifyEvent([ + $this->item('BSH.Common.Root.ActiveProgram', 'Cooking.Common.Program.Hood.Automatic', 'programs/active'), + ])); + $this->assertEquals(1, HomeConnectCloud::$requestCount, 'An unchanged active program must not refetch the options'); + } + + /** + * An option value that arrives before the option variable exists is buffered and + * applied right after the deferred refresh created the variable. + */ + public function testActiveProgramAppliesBufferedOptionValue() + { + $hood = $this->createInitializedHood(); + $intf = IPS\InstanceManager::getInstanceInterface($hood); + + $intf->ReceiveData($this->generateNotifyEvent([ + $this->item('Cooking.Common.Option.Hood.VentingLevel', 'Cooking.Hood.EnumType.Stage.FanStage02', 'programs/selected/options/Cooking.Common.Option.Hood.VentingLevel'), + ])); + $this->assertFalse(@IPS_GetObjectIDByIdent('OptionVentingLevel', $hood)); + + $intf->ReceiveData($this->generateNotifyEvent([ + $this->item('BSH.Common.Root.ActiveProgram', 'Cooking.Common.Program.Hood.Automatic', 'programs/active'), + ])); + + $ventingID = IPS_GetObjectIDByIdent('OptionVentingLevel', $hood); + $this->assertNotFalse($ventingID); + $this->assertEquals('Cooking.Hood.EnumType.Stage.FanStage02', GetValue($ventingID)); + } + + /** + * Regression: a program that is not listed under programs/available (undocumented + * runtime programs like the fan run-on) must neither create variables nor clear + * the program selection - the selected-program failure path used to wipe it. + */ + public function testActiveProgramUnsupportedProgramLeavesSelectionUntouched() { - $data = [ + $hood = $this->createInitializedHood(); + $intf = IPS\InstanceManager::getInstanceInterface($hood); + + $selectedID = IPS_GetObjectIDByIdent('SelectedProgram', $hood); + SetValue($selectedID, 'Cooking.Common.Program.Hood.Venting'); + + $intf->ReceiveData($this->generateNotifyEvent([ + $this->item('BSH.Common.Root.ActiveProgram', 'Cooking.Common.Program.Hood.DelayedShutOff', 'programs/active'), + ])); + + $this->assertEquals('Cooking.Common.Program.Hood.DelayedShutOff', GetValue(IPS_GetObjectIDByIdent('ActiveProgram', $hood))); + $this->assertFalse(@IPS_GetObjectIDByIdent('OptionVentingLevel', $hood)); + $this->assertEquals('Cooking.Common.Program.Hood.Venting', GetValue($selectedID), 'A failed program lookup must not clear the program selection'); + } + + private function createInitializedHood() + { + $hood = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + IPS_SetProperty($hood, 'HaID', self::HA_ID); + IPS_SetProperty($hood, 'DeviceType', 'Hood'); + IPS_ApplyChanges($hood); + return $hood; + } + + private function item($key, $value, $uriPath) + { + return [ + 'timestamp' => 1753731205, + 'handling' => 'none', + 'uri' => '/api/homeappliances/' . self::HA_ID . '/' . $uriPath, + 'key' => $key, + 'value' => $value, + 'level' => 'hint', + ]; + } + + private function generateNotifyEvent(array $items) + { + return json_encode([ 'Event' => 'NOTIFY', 'Data' => json_encode([ - 'items' => [ - 0 => [ - 'timestamp' => 1753731205, - 'handling' => 'none', - 'uri' => '/api/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/active', - 'key' => 'BSH.Common.Root.ActiveProgram', - 'value' => $value, - 'level' => 'hint', - ], - ], - 'haId' => 'SIEMENS-LD88WMM66-XYZ', + 'items' => $items, + 'haId' => self::HA_ID, ]), - 'id' => 'SIEMENS-LD88WMM66-XYZ', - ]; - return json_encode($data); + 'id' => self::HA_ID, + ]); + } + + private function generateActiveProgramEvent($value) + { + return $this->generateNotifyEvent([ + $this->item('BSH.Common.Root.ActiveProgram', $value, 'programs/active'), + ]); } } diff --git a/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/available/Cooking.Common.Program.Hood.Automatic/response.json b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/available/Cooking.Common.Program.Hood.Automatic/response.json new file mode 100644 index 0000000..ab81a7d --- /dev/null +++ b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/available/Cooking.Common.Program.Hood.Automatic/response.json @@ -0,0 +1,46 @@ +{ + "data": { + "key": "Cooking.Common.Program.Hood.Automatic", + "options": [ + { + "key": "Cooking.Common.Option.Hood.VentingLevel", + "type": "Cooking.Hood.EnumType.Stage", + "constraints": { + "allowedvalues": [ + "Cooking.Hood.EnumType.Stage.FanOff", + "Cooking.Hood.EnumType.Stage.FanStage01", + "Cooking.Hood.EnumType.Stage.FanStage02", + "Cooking.Hood.EnumType.Stage.FanStage03" + ], + "displayvalues": [ + "Lüfter aus", + "Lüfterstufe 1", + "Lüfterstufe 2", + "Lüfterstufe 3" + ], + "liveupdate": true + }, + "name": "Lüfterstufe" + }, + { + "key": "Cooking.Common.Option.Hood.IntensiveLevel", + "type": "Cooking.Hood.EnumType.IntensiveStage", + "constraints": { + "allowedvalues": [ + "Cooking.Hood.EnumType.IntensiveStage.IntensiveStageOff", + "Cooking.Hood.EnumType.IntensiveStage.IntensiveStage1", + "Cooking.Hood.EnumType.IntensiveStage.IntensiveStage2" + ], + "displayvalues": [ + "Intensivstufe aus", + "Intensivstufe 1", + "Intensivstufe 2" + ], + "liveupdate": true + }, + "name": "Intensivstufe" + } + ], + "name": "Automatik" + } +} diff --git a/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/available/Cooking.Common.Program.Hood.DelayedShutOff/response.json b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/available/Cooking.Common.Program.Hood.DelayedShutOff/response.json new file mode 100644 index 0000000..6f31593 --- /dev/null +++ b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/available/Cooking.Common.Program.Hood.DelayedShutOff/response.json @@ -0,0 +1,6 @@ +{ + "error": { + "key": "SDK.Error.UnsupportedProgram", + "description": "The program is not supported" + } +} diff --git a/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/response.json b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/response.json new file mode 100644 index 0000000..9e74f46 --- /dev/null +++ b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/response.json @@ -0,0 +1,30 @@ +{ + "data": { + "programs": [ + { + "key": "Cooking.Common.Program.Hood.Automatic", + "name": "Automatik", + "constraints": { + "execution": "startonly", + "available": true + } + }, + { + "key": "Cooking.Common.Program.Hood.Venting", + "name": "Lüften", + "constraints": { + "execution": "startonly", + "available": true + } + }, + { + "key": "Cooking.Common.Program.Hood.DelayedShutOff", + "name": "Lüfternachlauf", + "constraints": { + "execution": "startonly", + "available": true + } + } + ] + } +} diff --git a/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/settings/BSH.Common.Setting.PowerState/response.json b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/settings/BSH.Common.Setting.PowerState/response.json new file mode 100644 index 0000000..c9adb39 --- /dev/null +++ b/tests/homeappliances/SIEMENS-LD88WMM66-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.Off", + "access": "readWrite" + }, + "type": "BSH.Common.EnumType.PowerState", + "displayvalue": "Aus", + "value": "BSH.Common.EnumType.PowerState.Off" + } +} diff --git a/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/settings/response.json b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/settings/response.json new file mode 100644 index 0000000..c5c052d --- /dev/null +++ b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/settings/response.json @@ -0,0 +1,12 @@ +{ + "data": { + "settings": [ + { + "key": "BSH.Common.Setting.PowerState", + "value": "BSH.Common.EnumType.PowerState.Off", + "name": "Energiezustand", + "displayvalue": "Aus" + } + ] + } +} diff --git a/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/status/response.json b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/status/response.json new file mode 100644 index 0000000..7b70774 --- /dev/null +++ b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/status/response.json @@ -0,0 +1,27 @@ +{ + "data": { + "status": [ + { + "key": "BSH.Common.Status.LocalControlActive", + "value": false, + "name": "Lokale Bedienung aktiv" + }, + { + "key": "BSH.Common.Status.RemoteControlStartAllowed", + "value": true, + "name": "Fernstart" + }, + { + "key": "BSH.Common.Status.RemoteControlActive", + "value": true, + "name": "Fernbedienung" + }, + { + "key": "BSH.Common.Status.OperationState", + "value": "BSH.Common.EnumType.OperationState.Inactive", + "name": "Betriebsstatus", + "displayvalue": "Inaktiv" + } + ] + } +} From 6b4ac3adbe277b661db7514b9b587425d52fbc0c Mon Sep 17 00:00:00 2001 From: bumaas Date: Fri, 31 Jul 2026 09:46:00 +0200 Subject: [PATCH 3/5] 1.1 build 22: Back off keep-alive watchdog reconnects (quota death spiral on dead stream) With a permanently dead event stream the watchdog used to reconnect every 2 minutes - ~700 GET /events per day, keeping the "1000 calls in 1 day" quota exhausted for good (forum case zman0801). Reconnect attempts now back off exponentially (2/4/8/... min, capped at 1 hour) and reset as soon as the first keep-alive arrives. Co-Authored-By: Claude Fable 5 --- Home Connect Cloud/module.php | 16 +++++++- library.json | 4 +- tests/HomeConnectCloudTest.php | 74 ++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/Home Connect Cloud/module.php b/Home Connect Cloud/module.php index 82648b2..7235795 100644 --- a/Home Connect Cloud/module.php +++ b/Home Connect Cloud/module.php @@ -235,7 +235,19 @@ public function CheckServerEvents() // recovery exactly when the parent had dropped to an error state (keep-alives // stopped and never came back). if (time() - intval($this->GetBuffer('KeepAlive')) > 60 /* Seconds */) { - $this->SendDebug('KeepAlive', 'Failed. Reregistering...', 0); + // Back off between the reconnect attempts: each one costs a GET /events + // against the daily quota, and on a permanently dead stream a fixed 2 + // minute cadence burns ~700 requests per day - enough to keep the + // "1000 calls in 1 day" limit exhausted for good. The first retries stay + // fast so a briefly dropped stream still recovers quickly. + if (time() < intval($this->GetBuffer('WatchdogNextRetry'))) { + return; + } + $retries = intval($this->GetBuffer('WatchdogRetries')); + $delay = intval(min(120 * pow(2, $retries), 3600)); + $this->SetBuffer('WatchdogRetries', $retries + 1); + $this->SetBuffer('WatchdogNextRetry', time() + $delay); + $this->SendDebug('KeepAlive', sprintf('Failed. Reregistering... (attempt #%d, next attempt in %ds)', $retries + 1, $delay), 0); $this->RegisterServerEvents(); } } @@ -370,6 +382,8 @@ private function resetRetries() { $this->SetTimerInterval('Reconnect', 0); $this->WriteAttributeInteger('RetryCounter', 0); + $this->SetBuffer('WatchdogRetries', ''); + $this->SetBuffer('WatchdogNextRetry', ''); } private function FetchRefreshToken($code) diff --git a/library.json b/library.json index 4e77246..2cc185b 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 21, - "date": 1785424998 + "build": 22, + "date": 1785483864 } diff --git a/tests/HomeConnectCloudTest.php b/tests/HomeConnectCloudTest.php index c35b5fd..85ad700 100644 --- a/tests/HomeConnectCloudTest.php +++ b/tests/HomeConnectCloudTest.php @@ -241,6 +241,80 @@ public function testSuccessClearsPendingRateLimitAndResumes() $this->assertStringContainsString('homeappliances/events', IPS_GetProperty($parent, 'URL'), 'Stream must resume after the block clears'); } + /** + * Watchdog backoff: on a dead stream the keep-alive watchdog must not re-register + * every 2 minutes forever (~700 GET /events per day, keeping the "1000 calls in + * 1 day" quota exhausted for good). Attempts must back off exponentially. + */ + public function testCheckServerEventsBacksOffWhileStreamStaysDead() + { + $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', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + + //First failure: reconnects immediately and schedules the next attempt in 120s. + $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); + $cloud->CheckServerEvents(); + $this->assertStringContainsString('homeappliances/events', IPS_GetProperty($parent, 'URL'), 'First failure must reconnect'); + $this->assertSame('1', (string) $this->invoke($cloud, 'GetBuffer', 'WatchdogRetries')); + + //Still within the backoff window: no further reconnect. + IPS_SetProperty($parent, 'URL', ''); + IPS_ApplyChanges($parent); + $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); + $cloud->CheckServerEvents(); + $this->assertSame('', IPS_GetProperty($parent, 'URL'), 'Within the backoff window the watchdog must not reconnect'); + + //Backoff window elapsed: reconnects again and doubles the delay (120s -> 240s). + $this->invoke($cloud, 'SetBuffer', 'WatchdogNextRetry', (string) (time() - 1)); + $cloud->CheckServerEvents(); + $this->assertStringContainsString('homeappliances/events', IPS_GetProperty($parent, 'URL'), 'After the backoff window the watchdog must reconnect'); + $this->assertSame('2', (string) $this->invoke($cloud, 'GetBuffer', 'WatchdogRetries')); + $nextRetry = intval($this->invoke($cloud, 'GetBuffer', 'WatchdogNextRetry')); + $this->assertGreaterThan(time() + 200, $nextRetry, 'Second attempt must schedule the next one ~240s ahead'); + } + + /** + * The watchdog backoff must be capped at 1 hour so a dead stream is still probed + * regularly (~30 requests/day) without ever burning the daily quota. + */ + public function testWatchdogBackoffCappedAtOneHour() + { + $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', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + + $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); + $this->invoke($cloud, 'SetBuffer', 'WatchdogRetries', '10'); + $cloud->CheckServerEvents(); + + $nextRetry = intval($this->invoke($cloud, 'GetBuffer', 'WatchdogNextRetry')); + $this->assertLessThanOrEqual(time() + 3600, $nextRetry, 'Backoff must be capped at 1 hour'); + $this->assertGreaterThan(time() + 3500, $nextRetry, 'Capped backoff must still be ~1 hour'); + } + + /** + * The first keep-alive after a recovery proves the stream is alive again - it must + * reset the watchdog backoff so a future drop reconnects quickly again. + */ + public function testKeepAliveResetsWatchdogBackoff() + { + $cloud = $this->cloud(); + $this->invoke($cloud, 'SetBuffer', 'WatchdogRetries', '5'); + $this->invoke($cloud, 'SetBuffer', 'WatchdogNextRetry', (string) (time() + 3600)); + + $cloud->ReceiveData('{"Event":"KEEP-ALIVE"}'); + + $this->assertSame('', (string) $this->invoke($cloud, 'GetBuffer', 'WatchdogRetries'), 'A keep-alive must reset the watchdog backoff'); + $this->assertSame('', (string) $this->invoke($cloud, 'GetBuffer', 'WatchdogNextRetry'), 'A keep-alive must clear the pending backoff window'); + } + private function cloud() { return IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]); From bc8037af44481b87273170d541c94a1ff22c30f2 Mon Sep 17 00:00:00 2001 From: bumaas Date: Fri, 31 Jul 2026 14:32:53 +0200 Subject: [PATCH 4/5] 1.1 build 23: Translated display names for event-only programs (hob modes, oven/coffee runtime programs) Hobs are monitoring-only: the API lists no programs, so the Programs profile stayed empty and ActiveProgram showed the raw key. ActiveProgram events now add a translated profile association for programs that never appear under programs/available (hob modes, hood interval venting, oven follow-up/cleaning modes, coffee maker cleaning modes, favorites). Co-Authored-By: Claude Fable 5 --- Home Connect Device/locale.json | 29 +++++++++++- Home Connect Device/module.php | 81 +++++++++++++++++++++++++++++++-- library.json | 4 +- tests/HomeConnectHoodTest.php | 45 ++++++++++++++++++ 4 files changed, 152 insertions(+), 7 deletions(-) diff --git a/Home Connect Device/locale.json b/Home Connect Device/locale.json index dd493d2..bc36e42 100644 --- a/Home Connect Device/locale.json +++ b/Home Connect Device/locale.json @@ -76,7 +76,34 @@ "Home Connect Device": "Home Connect Gerät", "Use duration option": "Option Dauer verwenden", "No response from parent instance": "Keine Antwort von der Parent-Instanz", - "Invalid JSON response from parent instance": "Ungültige JSON-Antwort von der Parent-Instanz" + "Invalid JSON response from parent instance": "Ungültige JSON-Antwort von der Parent-Instanz", + "Power level mode": "Power-Level-Modus", + "Frying sensor mode": "Bratsensor-Modus", + "PowerMove mode": "PowerMove-Modus", + "Interval venting": "Intervalllüftung", + "Continue cooking": "Weitergaren", + "Keep warm": "Warmhalten", + "Leave to rest": "Ruhen lassen", + "Microwave": "Mikrowelle", + "Subsequent cooking": "Nachgaren", + "Pyrolytic self-cleaning": "Pyrolyse-Selbstreinigung", + "Draining": "Entleeren", + "Drying": "Trocknen", + "Auto steam calibration": "Automatische Dampf-Kalibrierung", + "Rinsing on switch-on": "Spülen beim Einschalten", + "Rinsing on switch-off": "Spülen beim Ausschalten", + "Auto clean": "Automatische Reinigung", + "Auto descale": "Automatisches Entkalken", + "Clean": "Reinigen", + "Descale": "Entkalken", + "Clean brewing unit manually": "Brüheinheit manuell reinigen", + "Clean brewing unit manually (detailed)": "Brüheinheit manuell reinigen (ausführlich)", + "Clean outlet manually": "Auslauf manuell reinigen", + "Frost protection": "Frostschutz", + "Remove water filter": "Wasserfilter entfernen", + "Replace water filter": "Wasserfilter wechseln", + "Rinse milk system": "Milchsystem spülen", + "Favorite %d": "Favorit %d" } } } diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 57ce5b8..3f43e81 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -20,6 +20,46 @@ class HomeConnectDevice extends IPSModule 'BSH.Common.Option.ElapsedProgramTime' ]; + // Programs that appliances report only via ActiveProgram events and that the + // API does not list under programs/available (hobs are monitoring-only, oven + // follow-up/cleaning modes and coffee maker auto-rinsing are started at the + // appliance). Values are the English display names, translated via locale.json. + public const EVENT_ONLY_PROGRAM_NAMES = [ + // Hob (monitoring-only appliance) + 'Cooking.Hob.Program.PowerLevelMode' => 'Power level mode', + 'Cooking.Hob.Program.FryingSensorMode' => 'Frying sensor mode', + 'Cooking.Hob.Program.PowerMoveMode' => 'PowerMove mode', + // Hood + 'Cooking.Common.Program.Hood.Interval' => 'Interval venting', + // Oven follow-up / runtime modes + 'Cooking.Oven.Program.SubsequentMode.ContinueCooking' => 'Continue cooking', + 'Cooking.Oven.Program.SubsequentMode.KeepWarm' => 'Keep warm', + 'Cooking.Oven.Program.SubsequentMode.LeaveToRest' => 'Leave to rest', + 'Cooking.Oven.Program.SubsequentMode.Microwave' => 'Microwave', + 'Cooking.Oven.Program.Dish.SubsequentCooking' => 'Subsequent cooking', + // Oven cleaning programs (started at the appliance) + 'Cooking.Oven.Program.Cleaning.Pyrolysis' => 'Pyrolytic self-cleaning', + 'Cooking.Oven.Program.Cleaning.Draining' => 'Draining', + 'Cooking.Oven.Program.Cleaning.Drying' => 'Drying', + 'Cooking.Oven.Program.Cleaning.Ecolysis' => 'Ecolysis', + 'Cooking.Oven.Program.CleaningModes.AutoSteamCalibration' => 'Auto steam calibration', + // Coffee maker cleaning modes (auto-rinsing runs on every power cycle) + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.ApplianceOnRinsing' => 'Rinsing on switch-on', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.ApplianceOffRinsing' => 'Rinsing on switch-off', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.AutoClean' => 'Auto clean', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.AutoDescale' => 'Auto descale', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.CalcNClean' => "calc'nClean", + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.Clean' => 'Clean', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.Descale' => 'Descale', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.CleanBrewingUnitManually' => 'Clean brewing unit manually', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.CleanBrewingUnitManuallyDetailed' => 'Clean brewing unit manually (detailed)', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.CleanOutletManually' => 'Clean outlet manually', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.FrostProtection' => 'Frost protection', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.RemoveWaterFilter' => 'Remove water filter', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.ReplaceWaterFilter' => 'Replace water filter', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.RinseMilkSystem' => 'Rinse milk system' + ]; + public const EVENT_DESCRIPTIONS = [ 'BSH.Common.Event.ProgramAborted' => 'The program was aborted', 'BSH.Common.Event.ProgramFinished' => 'The program is finished', @@ -904,10 +944,15 @@ private function updateActiveProgram($value) // Do not create the variable just to show "nothing running". return; } - $profileName = 'HomeConnect.' . $this->ReadPropertyString('DeviceType') . '.Programs'; - if (!IPS_VariableProfileExists($profileName)) { - $profileName = ''; - } + } + if (is_string($value) && $value != '') { + // Ensure a readable display name for the reported program. Some programs + // never appear under programs/available (see EVENT_ONLY_PROGRAM_NAMES), + // so createPrograms() cannot add them to the profile - e.g. a hob's + // profile stays completely empty and the variable would show the raw key. + $profileName = $this->ensureProgramAssociation($value); + // Not only on creation: upgrades an ActiveProgram variable created + // without a profile by an earlier build (MaintainVariable is idempotent). $this->MaintainVariable($ident, $this->Translate('Active Program'), VARIABLETYPE_STRING, $profileName, 2, true); } $newValue = is_string($value) ? $value : ''; @@ -930,6 +975,34 @@ private function updateActiveProgram($value) $this->RegisterOnceTimer('RefreshActiveProgramOptions', 'IPS_RequestAction($_IPS[\'TARGET\'], "RefreshActiveProgramOptions", "");'); } + /** + * Makes sure the device-type Programs profile exists and contains an association + * for the given program key, so ActiveProgram displays a readable name instead of + * the raw key. Returns the profile name. + */ + private function ensureProgramAssociation($key) + { + $profileName = 'HomeConnect.' . $this->ReadPropertyString('DeviceType') . '.Programs'; + if (!IPS_VariableProfileExists($profileName)) { + IPS_CreateVariableProfile($profileName, VARIABLETYPE_STRING); + } + foreach (IPS_GetVariableProfile($profileName)['Associations'] as $association) { + if ($association['Value'] === $key) { + return $profileName; + } + } + if (isset(self::EVENT_ONLY_PROGRAM_NAMES[$key])) { + $displayName = $this->Translate(self::EVENT_ONLY_PROGRAM_NAMES[$key]); + } elseif (preg_match('/^BSH\.Common\.Program\.Favorite\.(?P\d+)$/', $key, $matches)) { + // The snippet fallback would show the bare number ("003"). + $displayName = sprintf($this->Translate('Favorite %d'), (int) $matches['number']); + } else { + $displayName = $this->getLastSnippet($key); + } + IPS_SetVariableProfileAssociation($profileName, $key, $displayName, '', -1); + return $profileName; + } + /** * Remembers an option value received via event while its variable does not exist * yet. refreshActiveProgramOptions() applies the buffered values once the diff --git a/library.json b/library.json index 2cc185b..286cdcf 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 22, - "date": 1785483864 + "build": 23, + "date": 1785500922 } diff --git a/tests/HomeConnectHoodTest.php b/tests/HomeConnectHoodTest.php index 10e3659..8ddf439 100644 --- a/tests/HomeConnectHoodTest.php +++ b/tests/HomeConnectHoodTest.php @@ -175,6 +175,51 @@ public function testActiveProgramUnsupportedProgramLeavesSelectionUntouched() $this->assertEquals('Cooking.Common.Program.Hood.Venting', GetValue($selectedID), 'A failed program lookup must not clear the program selection'); } + /** + * Hobs are monitoring-only: the API lists no programs at all, so the Programs + * profile stays empty and ActiveProgram used to display the raw key. The event + * must add a translated association for the reported program instead. + */ + public function testActiveProgramAddsTranslatedProfileAssociation() + { + $hob = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + IPS_SetProperty($hob, 'DeviceType', 'Hob'); + IPS_ApplyChanges($hob); + $intf = IPS\InstanceManager::getInstanceInterface($hob); + + $intf->ReceiveData($this->generateActiveProgramEvent('Cooking.Hob.Program.PowerLevelMode')); + + $variableID = IPS_GetObjectIDByIdent('ActiveProgram', $hob); + $this->assertNotFalse($variableID); + $this->assertEquals('HomeConnect.Hob.Programs', IPS_GetVariable($variableID)['VariableProfile']); + $associations = []; + foreach (IPS_GetVariableProfile('HomeConnect.Hob.Programs')['Associations'] as $association) { + $associations[$association['Value']] = $association['Name']; + } + // The test stub's Translate() returns the text unchanged (English key). + $this->assertEquals('Power level mode', $associations['Cooking.Hob.Program.PowerLevelMode']); + } + + /** + * Favorites are numbered keys (BSH.Common.Program.Favorite.003) - the last + * snippet fallback would display the bare number. + */ + public function testActiveProgramFavoriteGetsNumberedName() + { + $coffee = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + IPS_SetProperty($coffee, 'DeviceType', 'CoffeeMaker'); + IPS_ApplyChanges($coffee); + $intf = IPS\InstanceManager::getInstanceInterface($coffee); + + $intf->ReceiveData($this->generateActiveProgramEvent('BSH.Common.Program.Favorite.003')); + + $associations = []; + foreach (IPS_GetVariableProfile('HomeConnect.CoffeeMaker.Programs')['Associations'] as $association) { + $associations[$association['Value']] = $association['Name']; + } + $this->assertEquals('Favorite 3', $associations['BSH.Common.Program.Favorite.003']); + } + private function createInitializedHood() { $hood = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); From 05bda74ca137b616ae48380a58c27be22d31d1f5 Mon Sep 17 00:00:00 2001 From: bumaas Date: Sun, 2 Aug 2026 11:41:14 +0200 Subject: [PATCH 5/5] 1.1 build 24: Watchdog logs IO status; skip /programs for programless appliance types - CheckServerEvents now includes the SSE client IO's InstanceStatus in the "KeepAlive | Failed" debug line. On a silently dead stream nothing reaches ReceiveData, so this is the only hint whether the IO saw an HTTP error or still believes it is connected (diagnosis gap seen in a support case where the stream stayed dead with no visible cause). - createPrograms() skips the GET /programs request for appliance types the API documents as having no programs (Refrigerator, Freezer, FridgeFreezer, WineCooler, CookProcessor) - it was a guaranteed SDK.Error.UnsupportedOperation costing one request per initialization. Co-Authored-By: Claude Fable 5 --- Home Connect Cloud/module.php | 7 ++++++- Home Connect Device/module.php | 18 ++++++++++++++++++ library.json | 4 ++-- tests/HomeConnectFridgeFreezerTest.php | 25 +++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/Home Connect Cloud/module.php b/Home Connect Cloud/module.php index 7235795..234c367 100644 --- a/Home Connect Cloud/module.php +++ b/Home Connect Cloud/module.php @@ -247,7 +247,12 @@ public function CheckServerEvents() $delay = intval(min(120 * pow(2, $retries), 3600)); $this->SetBuffer('WatchdogRetries', $retries + 1); $this->SetBuffer('WatchdogNextRetry', time() + $delay); - $this->SendDebug('KeepAlive', sprintf('Failed. Reregistering... (attempt #%d, next attempt in %ds)', $retries + 1, $delay), 0); + // Include the IO status: on a silently dead stream nothing reaches + // ReceiveData, so this is the only hint whether the SSE client saw an + // HTTP error or still believes it is connected. + $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(); } } diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 3f43e81..06a81db 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -87,6 +87,20 @@ class HomeConnectDevice extends IPSModule 'ConsumerProducts.CleaningRobot.Event.DockingStationNotFound' => 'The robot cannot find the charging station' ]; + + // Appliance types for which the API provides no program list. Documented at + // api-docs.home-connect.com ("Programs and Options"): "There are no programs + // available for ..." (Refrigerator, Freezer, Fridge Freezer, Wine Cooler); for + // the cook processor "Program support is currently not planned to be released" + // (only programs/selected and programs/active work). Requesting /programs for + // these types is a guaranteed SDK.Error.UnsupportedOperation. + public const PROGRAMLESS_DEVICE_TYPES = [ + 'Refrigerator', + 'Freezer', + 'FridgeFreezer', + 'WineCooler', + 'CookProcessor' + ]; private const OPTION_DURATION = 'BSH.Common.Option.Duration'; private const START_IN_RELATIVE = 'BSH.Common.Option.StartInRelative'; private const START_IN_RELATIVE_DEVICES = ['Microwave', 'Dishwasher', 'Oven']; @@ -670,6 +684,10 @@ private function getInitializationSignature(): string private function createPrograms() { + if (in_array($this->ReadPropertyString('DeviceType'), self::PROGRAMLESS_DEVICE_TYPES, true)) { + $this->SendDebug(__FUNCTION__, 'Skipped: the API provides no programs for this appliance type', 0); + return; + } $rawPrograms = json_decode($this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/programs'), true); if (isset($rawPrograms['error'])) { return; diff --git a/library.json b/library.json index 286cdcf..b765bc4 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 23, - "date": 1785500922 + "build": 24, + "date": 1785663591 } diff --git a/tests/HomeConnectFridgeFreezerTest.php b/tests/HomeConnectFridgeFreezerTest.php index 0596129..bf5d7c7 100644 --- a/tests/HomeConnectFridgeFreezerTest.php +++ b/tests/HomeConnectFridgeFreezerTest.php @@ -135,6 +135,31 @@ public function testValueRefreshFetchesStatusAndSettingsOnly() $this->assertNotFalse(@IPS_GetObjectIDByIdent('DoorState', $fridge), 'DoorState remains present'); } + /** + * The API documents that fridge freezers have no programs at all ("There are no + * programs available for Fridge Freezers"), so createPrograms() must not spend a + * request on the guaranteed SDK.Error.UnsupportedOperation from /programs. + */ + public function testCreateProgramsSkipsRequestForProgramlessType() + { + $fridge = IPS_CreateInstance(self::DEVICE_GUID); + $parent = IPS_GetInstance($fridge)['ConnectionID']; + IPS\InstanceManager::setStatus($parent, IS_ACTIVE); + + IPS_SetProperty($fridge, 'HaID', self::FRIDGE_HAID); + IPS_SetProperty($fridge, 'DeviceType', 'FridgeFreezer'); + IPS_ApplyChanges($fridge); + + $intf = IPS\InstanceManager::getInstanceInterface($fridge); + HomeConnectCloud::$requestCount = 0; + $method = new ReflectionMethod($intf, 'createPrograms'); + $method->setAccessible(true); + $method->invoke($intf); + + $this->assertSame(0, HomeConnectCloud::$requestCount, 'createPrograms must not request /programs for a programless appliance type'); + $this->assertFalse(@IPS_GetObjectIDByIdent('SelectedProgram', $fridge), 'No program selection variable for a programless appliance type'); + } + /** * A CONNECTED event must not refresh synchronously on the event thread (that blocks * ReceiveData -> "Warten auf Skriptresultat fehlgeschlagen"). It arms a one-shot timer