From 6c95537995de6421e6388e1bf3b898c3fb5d05c8 Mon Sep 17 00:00:00 2001 From: bumaas Date: Sat, 11 Apr 2026 16:05:32 +0200 Subject: [PATCH 01/17] Improve device option update error handling Only apply option/setting values locally after successful API responses in RequestAction. Add responseHasError() helper and guard program updates when no program data is returned. Sort PowerState associations in a fixed order via sortAssociations(). --- Home Connect Device/module.php | 64 ++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index eaf2e42..96e35f2 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -213,6 +213,7 @@ public function GetConfigurationForm() public function RequestAction($Ident, $Value) { + $applyValue = false; switch ($Ident) { case 'SelectedProgram': if (!$this->switchable()) { @@ -231,10 +232,15 @@ public function RequestAction($Ident, $Value) 'options' => [] ] ]; - $this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/programs/selected', json_encode($payload)); - $this->updateOptionValues($this->getSelectedProgram()); + $response = $this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/programs/selected', json_encode($payload)); + if (!$this->responseHasError($response)) { + $this->updateOptionValues($this->getSelectedProgram()); + } } else { - $this->updateOptionValues($this->getProgram($Value)); + $program = $this->getProgram($Value); + if ($program !== false) { + $this->updateOptionValues($program); + } } break; @@ -293,15 +299,19 @@ public function RequestAction($Ident, $Value) if (@IPS_GetObjectIDByIdent('OperationState', $this->InstanceID) && ($this->GetValue('OperationState') == 'BSH.Common.EnumType.OperationState.DelayedStart')) { $payload = ['data' => $this->createOptionRequestData($Ident, $optionKey, $Value)]; $endpoint = 'homeappliances/' . $this->ReadPropertyString('HaID') . '/programs/active/options/' . $optionKey; - $this->RequestDataFromParent($endpoint, json_encode($payload)); + $response = $this->RequestDataFromParent($endpoint, json_encode($payload)); + $applyValue = !$this->responseHasError($response); } else { $this->SendDebug(__FUNCTION__, self::START_IN_RELATIVE . ' is sent with programs/active on start command', 0); + $applyValue = true; } } else { $payload = ['data' => $this->createOptionRequestData($Ident, $optionKey, $Value)]; - $this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/programs/selected/options/' . $optionKey, json_encode($payload)); + $response = $this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/programs/selected/options/' . $optionKey, json_encode($payload)); + $applyValue = !$this->responseHasError($response); } } else { + $applyValue = true; } } @@ -314,11 +324,12 @@ public function RequestAction($Ident, $Value) 'value' => $Value ] ]; - $this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/settings/' . $availableSettings[$Ident]['key'], json_encode($payload)); + $response = $this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/settings/' . $availableSettings[$Ident]['key'], json_encode($payload)); + $applyValue = !$this->responseHasError($response); } break; } - if ($Ident != 'Control') { + if ($Ident != 'Control' && $applyValue) { $this->SetValue($Ident, $Value); } } @@ -781,6 +792,7 @@ private function createVariableFromConstraints($profileName, $data, $attribute, $displayName = isset($constraints['displayvalues'][$i]) ? $constraints['displayvalues'][$i] : $this->getLastSnippet($constraints['allowedvalues'][$i]); $newAssociations[$constraints['allowedvalues'][$i]] = $displayName; } + $newAssociations = $this->sortAssociations($data['key'], $newAssociations); //Get current options from profile $oldAssociations = []; @@ -951,6 +963,44 @@ private function executeApplicanceCommand($command) return true; } + private function responseHasError($response) + { + if (!is_string($response) || $response === '') { + return false; + } + + $decodedResponse = json_decode($response, true); + return isset($decodedResponse['error']); + } + + private function sortAssociations($key, array $associations) + { + if ($key !== 'BSH.Common.Setting.PowerState') { + return $associations; + } + + $preferredOrder = [ + 'BSH.Common.EnumType.PowerState.MainsOff', + 'BSH.Common.EnumType.PowerState.Off', + 'BSH.Common.EnumType.PowerState.Standby', + 'BSH.Common.EnumType.PowerState.On' + ]; + + $sortedAssociations = []; + foreach ($preferredOrder as $preferredValue) { + if (isset($associations[$preferredValue])) { + $sortedAssociations[$preferredValue] = $associations[$preferredValue]; + unset($associations[$preferredValue]); + } + } + + foreach ($associations as $value => $name) { + $sortedAssociations[$value] = $name; + } + + return $sortedAssociations; + } + private function getAvailableCommands() { return json_decode($this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/commands'), true)['data']['commands']; From 2157e0c01b48fe5972b0c207ace8d241f1edc5eb Mon Sep 17 00:00:00 2001 From: bumaas Date: Sat, 11 Apr 2026 17:32:45 +0200 Subject: [PATCH 02/17] Fix device instance status after module reload Set Home Connect Device to IS_ACTIVE when kernel is ready, parent is active, and HaID is configured. Set IS_INACTIVE otherwise so the instance no longer remains in IS_CREATING. --- Home Connect Device/module.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 96e35f2..9faf110 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -120,12 +120,13 @@ public function ApplyChanges() //Never delete this line! parent::ApplyChanges(); - if (IPS_GetKernelRunlevel() == KR_READY) { - if ($this->HasActiveParent()) { - if ($this->ReadPropertyString('HaID')) { - $this->SetSummary($this->ReadPropertyString('HaID')); - $this->InitializeDevice(); - } + if (IPS_GetKernelRunlevel() === KR_READY) { + if ($this->HasActiveParent() && $this->ReadPropertyString('HaID')) { + $this->SetSummary($this->ReadPropertyString('HaID')); + $this->InitializeDevice(); + $this->SetStatus(IS_ACTIVE); + } else { + $this->SetStatus(IS_INACTIVE); } } From 2e16492313cda3139d292705f4cd8f66e4c35c43 Mon Sep 17 00:00:00 2001 From: bumaas Date: Sat, 11 Apr 2026 17:34:52 +0200 Subject: [PATCH 03/17] Bump library build metadata Increase library.json build to 6 and update date timestamp. --- library.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/library.json b/library.json index b878916..7458856 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 5, - "date": 1773335113 -} \ No newline at end of file + "build": 6, + "date": 1775921670 +} From 0020a5e62fc4cb3a1542500ae2676066d1bf2709 Mon Sep 17 00:00:00 2001 From: bumaas Date: Sat, 11 Apr 2026 21:16:16 +0200 Subject: [PATCH 04/17] Handle missing option constraints when updating values --- Home Connect Device/module.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 9faf110..c7439cf 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -560,8 +560,11 @@ private function updateOptionValues($program) $value = $option['value']; } elseif (isset($option['constraints']['default'])) { $value = $option['constraints']['default']; - } else { + } elseif (isset($option['constraints']['allowedvalues']) && is_array($option['constraints']['allowedvalues']) && count($option['constraints']['allowedvalues']) > 0) { $value = $option['constraints']['allowedvalues'][0]; + } else { + $this->SendDebug(__FUNCTION__, sprintf('Skipping option without usable value: %s', json_encode($option)), 0); + continue; } $debugValue = is_bool($value) ? ($value ? 'true' : 'false') : $value; $this->SendDebug(__FUNCTION__, sprintf('Ident: %s, Value: %s', $ident, $debugValue), 0); From bf9f9815148bb3b23e4772897d3d515c981f9451 Mon Sep 17 00:00:00 2001 From: bumaas Date: Sun, 12 Apr 2026 14:22:18 +0200 Subject: [PATCH 05/17] feat(HomeConnectDevice): add UseDuration variable and optimize API calls. Introduced UseDuration variable to toggle BSH.Common.Option.Duration during program start. Optimized updateOptionVariables and updateOptionValues to use existing program data, reducing redundant API calls. Positioned UseDuration before OptionDuration in the visualization. Added PHPDoc documentation for optimized methods. Updated HomeConnectDryerTest expectations for UseDuration. --- Home Connect Device/module.php | 46 ++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index c7439cf..7ac2f0e 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -3,6 +3,7 @@ declare(strict_types=1); class HomeConnectDevice extends IPSModule { + private const OPTION_DURATION = 'BSH.Common.Option.Duration'; public const RESTRICTIONS = [ 'BSH.Common.Status.RemoteControlStartAllowed', 'BSH.Common.Status.RemoteControlActive', @@ -216,6 +217,10 @@ public function RequestAction($Ident, $Value) { $applyValue = false; switch ($Ident) { + case 'UseDuration': + $applyValue = true; + break; + case 'SelectedProgram': if (!$this->switchable()) { //TODO: better error message @@ -295,6 +300,10 @@ public function RequestAction($Ident, $Value) break; } $optionKey = $optionKeys[$Ident]; + if ($optionKey == self::OPTION_DURATION && @IPS_GetObjectIDByIdent('UseDuration', $this->InstanceID) && !$this->GetValue('UseDuration')) { + $applyValue = true; + break; + } if (!in_array($this->ReadPropertyString('DeviceType'), ['Oven', 'Hood'])) { if ($optionKey == self::START_IN_RELATIVE && $this->useStartInRelativeStartCommand()) { if (@IPS_GetObjectIDByIdent('OperationState', $this->InstanceID) && ($this->GetValue('OperationState') == 'BSH.Common.EnumType.OperationState.DelayedStart')) { @@ -419,10 +428,14 @@ private function createOptionPayload() $optionKeys = json_decode($this->ReadAttributeString('OptionKeys'), true); $this->SendDebug('OptionKeys', json_encode($optionKeys), 0); $optionsPayload = []; + $useDuration = !@IPS_GetObjectIDByIdent('UseDuration', $this->InstanceID) || $this->GetValue('UseDuration'); foreach ($availableOptions as $ident => $key) { if (!isset($optionKeys[$ident])) { continue; } + if ($optionKeys[$ident] == self::OPTION_DURATION && !$useDuration) { + continue; + } $optionsPayload[] = $this->createOptionRequestData($ident, $optionKeys[$ident], $this->GetValue($ident)); } return $optionsPayload; @@ -455,13 +468,22 @@ private function sendOptionsOnProgramStart() return in_array($this->ReadPropertyString('DeviceType'), ['Oven', 'Hood', 'Dishwasher', 'Microwave']); } + /** + * @param string|array $program Der Programmschlüssel oder das bereits abgerufene Programmdaten-Array. + */ private function updateOptionVariables($program) { - $rawOptions = $this->getProgram($program); + if (is_array($program)) { + $rawOptions = $program; + } else { + $rawOptions = $this->getProgram($program); + } + $this->SendDebug('RawOptions', json_encode($rawOptions), 0); if (!$rawOptions) { $this->SetValue('SelectedProgram', ''); $this->setOptionsDisabled(true); + $this->syncUseDurationVariable(false, 0); return; } $this->setOptionsDisabled(false); @@ -494,9 +516,25 @@ private function updateOptionVariables($program) IPS_SetHidden($variableID, !in_array($ident, $availableOptions)); } + $this->syncUseDurationVariable(in_array('OptionDuration', $availableOptions), $position); + $position++; $this->ensureControlVariable($position); } + private function syncUseDurationVariable($visible, $position) + { + $ident = 'UseDuration'; + $exists = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); + $this->MaintainVariable($ident, $this->Translate('Use duration option'), VARIABLETYPE_BOOLEAN, 'HomeConnect.YesNo', $position, true); + $this->EnableAction($ident); + if (!$exists) { + $this->SetValue($ident, false); + } + $variableID = $this->GetIDForIdent($ident); + IPS_SetHidden($variableID, !$visible); + IPS_SetDisabled($variableID, !$visible); + } + private function ensureControlVariable($position) { $deviceType = $this->ReadPropertyString('DeviceType'); @@ -543,14 +581,18 @@ private function getOption($key) $data = json_decode($this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/programs/selected/options/' . $key), true)['data']; return $data; } + /** + * @param string|array $program Der Programmschlüssel oder das bereits abgerufene Programmdaten-Array. + */ private function updateOptionValues($program) { if (!$program) { $this->setOptionsDisabled(true); + $this->syncUseDurationVariable(false, 0); return; } $this->SetValue('SelectedProgram', $program['key']); - $this->updateOptionVariables($program['key']); + $this->updateOptionVariables($program); $optionKeys = []; foreach ($program['options'] as $option) { $ident = 'Option' . $this->getLastSnippet($option['key']); From a7c629038b0572eaf7531a6a6965e0780b12ced9 Mon Sep 17 00:00:00 2001 From: bumaas Date: Sun, 12 Apr 2026 14:29:12 +0200 Subject: [PATCH 06/17] test(HomeConnectDryerTest): add UseDuration to expectations and fix test setup --- tests/HomeConnectDryerTest.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/HomeConnectDryerTest.php b/tests/HomeConnectDryerTest.php index 4adf59d..8ac3f99 100644 --- a/tests/HomeConnectDryerTest.php +++ b/tests/HomeConnectDryerTest.php @@ -17,6 +17,7 @@ class HomeConnectDryerTest extends TestCase 'OperationState' => 'Ready', 'PowerState' => 'An', 'SelectedProgram' => 'Baumwolle', + 'UseDuration' => 'No', 'OptionDryingTarget' => 'Schranktrocken', 'Control' => '-', 'LocalControlActive' => 'No', @@ -31,6 +32,7 @@ class HomeConnectDryerTest extends TestCase 'DoorState' => 'Closed', 'PowerState' => 'An', 'SelectedProgram' => 'Zeitprogramm kalt', + 'UseDuration' => 'No', 'OptionDuration' => '1200 seconds', 'Control' => '-', 'LocalControlActive' => 'No', @@ -53,8 +55,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}'); + $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(); } @@ -64,8 +69,10 @@ public function testBaseFunctionality() $cloudInterface = IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID('{CE76810D-B685-9BE0-CC04-38B204DEAD5E}')[0]); $cloudInterface->selectedProgram = 'Cotton'; $dryer = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + IPS_ConnectInstance($dryer, $this->ConfiguratorID); $this->assertTrue(true); $dryer = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + IPS_ConnectInstance($dryer, $this->ConfiguratorID); IPS_SetProperty($dryer, 'HaID', 'BOSCH-WTX87E90-68A40E44C6B9'); IPS_SetProperty($dryer, 'DeviceType', 'Dryer'); IPS_ApplyChanges($dryer); @@ -91,9 +98,7 @@ private function getChildrenValues($id) $children = IPS_GetChildrenIDs($id); $result = []; foreach ($children as $child) { - if (!IPS_GetObject($child)['ObjectIsHidden']) { - $result[IPS_GetObject($child)['ObjectIdent']] = GetValueFormatted($child); - } + $result[IPS_GetObject($child)['ObjectIdent']] = GetValueFormatted($child); } return $result; } From a683a5056c9dd4090ae845cc2602af13b982f1a4 Mon Sep 17 00:00:00 2001 From: bumaas Date: Sun, 12 Apr 2026 15:04:54 +0200 Subject: [PATCH 07/17] chore(style): install php-cs-fixer locally and update workflow for root config --- .github/workflows/style.yml | 12 ++- .gitignore | 3 +- .php-cs-fixer.php | 186 ++++++++++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 .php-cs-fixer.php diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 3713f68..6ef4dda 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -9,5 +9,13 @@ jobs: steps: - name: Checkout module uses: actions/checkout@master - - name: Check style - uses: symcon/action-style@v3 \ No newline at end of file + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + - name: Install PHP CS Fixer + run: | + curl -L https://github.com/FriendsOfPHP/PHP-CS-Fixer/releases/latest/download/php-cs-fixer.phar -o php-cs-fixer.phar + chmod +x php-cs-fixer.phar + - name: Run style check + run: php php-cs-fixer.phar fix --config=.php-cs-fixer.php --dry-run --diff --allow-risky=yes \ No newline at end of file diff --git a/.gitignore b/.gitignore index df90c89..14619ff 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .idea/ .phpunit.* .php_cs.cache -.php-cs-fixer.cache \ No newline at end of file +.php-cs-fixer.cache +php-cs-fixer.phar \ No newline at end of file diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..47a60d6 --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,186 @@ +exclude('tests/stubs') // exclude the tests-stubs, but not the tests for this module + ->exclude('docs') // exclude the docs + ->notPath('/libs\/.*\//') // regex, exclude only dirs in libs, not the files + ->in(__DIR__ . "/../"); + +return (new PhpCsFixer\Config())->setRules([ + 'align_multiline_comment' => [ + 'comment_type' => 'all_multiline' + ], + 'array_indentation' => true, + 'array_syntax' => [ + 'syntax' => 'short' + ], + //backtick_to_shell_exec + 'binary_operator_spaces' => [ + 'operators' => ['=>' => 'align'] + ], + 'blank_line_after_namespace' => true, + 'blank_line_after_opening_tag' => true, + 'blank_line_before_statement' => false, + "single_space_around_construct" => true, + //control_structure_braces, + //control_structure_continuation_position + "declare_parentheses" => true, + "no_multiple_statements_per_line" => true, + "braces_position" => [ + 'anonymous_functions_opening_brace' => 'next_line_unless_newline_at_signature_end' + ], + "statement_indentation" => true, + "no_extra_blank_lines" => true, + 'cast_spaces' => true, + 'class_attributes_separation' => false, + 'class_definition' => true, + //class_keyword_remove + //combine_consecutive_issets + //combine_consecutive_unsets + //combine_nested_dirname + //comment_to_phpdoc + //compact_nullable_typehint + 'concat_space' => [ + 'spacing' => 'one' + ], + //date_time_immutable + 'declare_strict_types' => true, + 'declare_equal_normalize' => true, + 'declare_strict_types' => true, + //dir_constant + 'elseif' => true, + 'encoding' => true, + //ereg_to_preg + //error_suppression + //escape_implicit_backslashes + //explicit_indirect_variable + //explicit_string_variable + //final_class + //final_internal_class + //fopen_flag_order + //fopen_flags + 'full_opening_tag' => true, + //fully_qualified_strict_types + 'function_declaration' => true, + //function_to_constant + 'type_declaration_spaces' => true, + //header_comment + 'implode_call' => true, + 'include' => true, + //increment_style + 'indentation_type' => true, + //is_null + 'line_ending' => true, + 'linebreak_after_opening_tag' => true, + //list_syntax + 'logical_operators' => true, + 'lowercase_cast' => true, + 'constant_case' => ['case' => 'lower'], + 'lowercase_keywords' => true, + 'lowercase_static_reference' => true, + 'magic_constant_casing' => true, + 'magic_method_casing' => true, + //mb_str_functions + 'method_argument_space' => true, + 'method_chaining_indentation' => true, + //modernize_types_casting + 'multiline_comment_opening_closing' => true, + 'multiline_whitespace_before_semicolons' => true, + //native_constant_invocation + 'native_function_casing' => true, + //native_function_invocation + 'native_type_declaration_casing' => true, + 'new_with_parentheses' => true, + 'no_alias_functions' => true, + 'no_alternative_syntax' => true, + //no_binary_string + 'no_blank_lines_after_class_opening' => true, + 'no_blank_lines_after_phpdoc' => true, + 'blank_lines_before_namespace' => true, + 'no_break_comment' => [ + 'comment_text' => 'No break. Add additional comment above this line if intentional' + ], + 'no_closing_tag' => true, + 'no_empty_comment' => true, + 'no_empty_phpdoc' => true, + 'no_empty_statement' => true, + 'no_extra_blank_lines' => true, + //no_homoglyph_names + //no_leading_import_slash + 'no_leading_namespace_whitespace' => true, + 'no_mixed_echo_print' => true, + 'no_multiline_whitespace_around_double_arrow' => true, + //no_null_property_initialization + //no_short_bool_cast + 'echo_tag_syntax' => ['format' => 'long'], + 'no_singleline_whitespace_before_semicolons' => true, + 'no_spaces_after_function_name' => true, + 'no_spaces_around_offset' => false, + 'spaces_inside_parentheses' => true, + //no_superfluous_elseif + //no_superfluous_phpdoc_tags + 'no_trailing_comma_in_singleline' => true, + 'no_trailing_whitespace' => true, + 'no_trailing_whitespace_in_comment' => true, + 'no_unneeded_control_parentheses' => true, + 'no_unneeded_braces' => false, + //no_unneeded_final_method + //no_unset_cast + //no_unset_on_property + //no_unused_imports + 'no_useless_else' => false, + 'no_useless_return' => false, + 'no_whitespace_before_comma_in_array' => true, + 'no_whitespace_in_blank_line' => true, + //non_printable_character + 'normalize_index_brace' => true, + 'not_operator_with_space' => false, + 'not_operator_with_successor_space' => false, + 'object_operator_without_whitespace' => true, + 'ordered_class_elements' => true, + 'ordered_imports' => true, + 'ordered_interfaces' => true, + //php_unit_* + //php_doc_* + 'pow_to_exponentiation' => false, + 'protected_to_private' => false, + //psr0 + //psr4 + //random_api_migration + //return_assignment + 'return_type_declaration' => true, + 'self_accessor' => true, + 'semicolon_after_instruction' => true, + //set_type_to_cast + //short_scalar_cast + //simple_to_complex_string_variable + //simplified_null_return + //single_blank_line_at_eof + 'single_class_element_per_statement' => true, + 'single_import_per_statement' => true, + 'single_line_after_imports' => true, + //single_line_comment_style + 'single_quote' => true, + 'single_trait_insert_per_statement' => true, + 'space_after_semicolon' => true, + //standardize_increment + 'standardize_not_equals' => true, + //static_lambda + //strict_comparison + //strict_param + //string_line_ending + 'switch_case_semicolon_to_colon' => true, + 'switch_case_space' => true, + 'ternary_operator_spaces' => true, + //ternary_to_null_coalescing + 'trailing_comma_in_multiline' => false, + 'trim_array_spaces' => true, + 'unary_operator_spaces' => true, + 'modifier_keywords' => true, + //void_return + 'whitespace_after_comma_in_array' => true, + //yoda_style + ]) + ->setFinder($finder) + ->setIndent(" ") + ->setLineEnding("\n"); From b69c519a51a62123f418274c702b34832c2da37b Mon Sep 17 00:00:00 2001 From: bumaas Date: Sun, 12 Apr 2026 15:17:10 +0200 Subject: [PATCH 08/17] translate(HomeConnectDevice): add German translation for Use duration option --- Home Connect Device/locale.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Home Connect Device/locale.json b/Home Connect Device/locale.json index cdb878a..6cfa2d1 100644 --- a/Home Connect Device/locale.json +++ b/Home Connect Device/locale.json @@ -70,7 +70,8 @@ "Action can currently not be performed": "Aktion kann zur Zeit nicht ausgeführt werden", "Initialize Device": "Gerät Initialisieren", "https://www.symcon.de/en/service/documentation/module-reference/home-connect/home-connect-device/": "https://www.symcon.de/de/service/dokumentation/modulreferenz/home-connect/home-connect-device/", - "Home Connect Device": "Home Connect Gerät" + "Home Connect Device": "Home Connect Gerät", + "Use duration option": "Option Dauer verwenden" } } } \ No newline at end of file From 6e3287b9dab5e3da18271504b21576035107cbe6 Mon Sep 17 00:00:00 2001 From: bumaas Date: Sun, 12 Apr 2026 15:30:39 +0200 Subject: [PATCH 09/17] test(HomeConnectOvenTest): robustify helper methods to prevent TypeErrors --- tests/HomeConnectOvenTest.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/HomeConnectOvenTest.php b/tests/HomeConnectOvenTest.php index 5a6e32a..d331b8a 100644 --- a/tests/HomeConnectOvenTest.php +++ b/tests/HomeConnectOvenTest.php @@ -84,14 +84,22 @@ private function generateTestData($tempValue) private function getValueType($id) { - return IPS_GetVariable(IPS_GetObjectIDByIdent('CurrentCavityTemperature', $id))['VariableType']; + $variableId = @IPS_GetObjectIDByIdent('CurrentCavityTemperature', $id); + if ($variableId === false) { + return -1; + } + return IPS_GetVariable($variableId)['VariableType']; } private function displayTemperature($id) { - $temperature = IPS_GetObjectIDByIdent('CurrentCavityTemperature', $id); - $type = IPS_GetVariable($temperature)['VariableType']; - echo PHP_EOL . $temperature . ' - ' . $type . PHP_EOL; + $variableId = @IPS_GetObjectIDByIdent('CurrentCavityTemperature', $id); + if ($variableId === false) { + echo PHP_EOL . 'CurrentCavityTemperature not found' . PHP_EOL; + return; + } + $type = IPS_GetVariable($variableId)['VariableType']; + echo PHP_EOL . $variableId . ' - ' . $type . PHP_EOL; } private function displayChildrenValues($id) From 6f967e5df8be0ff67e4c8aa9fe5b04c419f34221 Mon Sep 17 00:00:00 2001 From: bumaas Date: Sun, 12 Apr 2026 15:50:57 +0200 Subject: [PATCH 10/17] chore(CI): revert changes to style.yml --- .github/workflows/style.yml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 6ef4dda..3713f68 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -9,13 +9,5 @@ jobs: steps: - name: Checkout module uses: actions/checkout@master - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - - name: Install PHP CS Fixer - run: | - curl -L https://github.com/FriendsOfPHP/PHP-CS-Fixer/releases/latest/download/php-cs-fixer.phar -o php-cs-fixer.phar - chmod +x php-cs-fixer.phar - - name: Run style check - run: php php-cs-fixer.phar fix --config=.php-cs-fixer.php --dry-run --diff --allow-risky=yes \ No newline at end of file + - name: Check style + uses: symcon/action-style@v3 \ No newline at end of file From f26ae6d31dd91a99e53df2b9fc17336a66c93759 Mon Sep 17 00:00:00 2001 From: bumaas Date: Thu, 23 Apr 2026 12:50:56 +0200 Subject: [PATCH 11/17] =?UTF-8?q?fix(HomeConnectDevice):=20beim=20Start=20?= =?UTF-8?q?nur=20unterst=C3=BCtzte=20Programmoptionen=20senden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prüfe vor dem Start die Optionen des gewählten Programms erneut - sende nur Optionen an programs/active, die für das Programm schreibbar sind - vermeide dadurch Fehler durch nicht unterstützte Startoptionen - fange fehlende options-Arrays bei Programm- und Optionsdaten defensiv ab - erhöhe den Library-Build auf 7 --- Home Connect Device/module.php | 47 ++++++++++++++++++++++++++++++---- library.json | 4 +-- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 7ac2f0e..8723ed5 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -429,18 +429,55 @@ private function createOptionPayload() $this->SendDebug('OptionKeys', json_encode($optionKeys), 0); $optionsPayload = []; $useDuration = !@IPS_GetObjectIDByIdent('UseDuration', $this->InstanceID) || $this->GetValue('UseDuration'); + // Re-check the selected program definition so the start payload only contains options accepted by programs/active. + $startableOptionKeys = $this->getStartableOptionKeys(); foreach ($availableOptions as $ident => $key) { if (!isset($optionKeys[$ident])) { continue; } - if ($optionKeys[$ident] == self::OPTION_DURATION && !$useDuration) { + $optionKey = $optionKeys[$ident]; + if ($optionKey == self::OPTION_DURATION && !$useDuration) { continue; } - $optionsPayload[] = $this->createOptionRequestData($ident, $optionKeys[$ident], $this->GetValue($ident)); + if ($startableOptionKeys !== [] && !isset($startableOptionKeys[$optionKey])) { + $this->SendDebug(__FUNCTION__, sprintf('Skipping unsupported start option: %s', $optionKey), 0); + continue; + } + $optionsPayload[] = $this->createOptionRequestData($ident, $optionKey, $this->GetValue($ident)); } return $optionsPayload; } + private function getStartableOptionKeys() + { + $selectedProgram = $this->GetValue('SelectedProgram'); + if ($selectedProgram == '') { + return []; + } + + $program = $this->getProgram($selectedProgram); + if (!is_array($program) || !isset($program['options']) || !is_array($program['options'])) { + return []; + } + + $startableOptionKeys = []; + foreach ($program['options'] as $option) { + if (!isset($option['key'])) { + continue; + } + + $constraints = isset($option['constraints']) && is_array($option['constraints']) ? $option['constraints'] : []; + $access = isset($constraints['access']) ? strtolower((string) $constraints['access']) : ''; + if ($access !== '' && strpos($access, 'write') === false) { + continue; + } + + $startableOptionKeys[$option['key']] = true; + } + + return $startableOptionKeys; + } + private function createOptionRequestData($ident, $key, $value) { $data = [ @@ -487,7 +524,7 @@ private function updateOptionVariables($program) return; } $this->setOptionsDisabled(false); - $options = $rawOptions['options']; + $options = isset($rawOptions['options']) && is_array($rawOptions['options']) ? $rawOptions['options'] : []; $position = 10; $availableOptions = []; $deviceType = $this->ReadPropertyString('DeviceType'); @@ -594,7 +631,8 @@ private function updateOptionValues($program) $this->SetValue('SelectedProgram', $program['key']); $this->updateOptionVariables($program); $optionKeys = []; - foreach ($program['options'] as $option) { + $programOptions = isset($program['options']) && is_array($program['options']) ? $program['options'] : []; + foreach ($programOptions as $option) { $ident = 'Option' . $this->getLastSnippet($option['key']); $optionKeys[$ident] = $option['key']; if (@IPS_GetObjectIDByIdent($ident, $this->InstanceID) && !IPS_GetObject($this->GetIDForIdent($ident))['ObjectIsHidden']) { @@ -805,7 +843,6 @@ private function createVariableFromConstraints($profileName, $data, $attribute, $variableType = VARIABLETYPE_STRING; break; } - switch ($variableType) { case VARIABLETYPE_INTEGER: case VARIABLETYPE_FLOAT: diff --git a/library.json b/library.json index 7458856..a18eeff 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 6, - "date": 1775921670 + "build": 7, + "date": 1776909600 } From a1da36a6a9faceaae7e33b2b2c44c4353ae59532 Mon Sep 17 00:00:00 2001 From: TillBrede <51406030+TillBrede@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:04:55 +0200 Subject: [PATCH 12/17] remove cs fixer php the files are provided by the submodule --- .gitignore | 3 +- .php-cs-fixer.php | 186 ---------------------------------------------- 2 files changed, 1 insertion(+), 188 deletions(-) delete mode 100644 .php-cs-fixer.php diff --git a/.gitignore b/.gitignore index 14619ff..df90c89 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,4 @@ .idea/ .phpunit.* .php_cs.cache -.php-cs-fixer.cache -php-cs-fixer.phar \ No newline at end of file +.php-cs-fixer.cache \ No newline at end of file diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php deleted file mode 100644 index 47a60d6..0000000 --- a/.php-cs-fixer.php +++ /dev/null @@ -1,186 +0,0 @@ -exclude('tests/stubs') // exclude the tests-stubs, but not the tests for this module - ->exclude('docs') // exclude the docs - ->notPath('/libs\/.*\//') // regex, exclude only dirs in libs, not the files - ->in(__DIR__ . "/../"); - -return (new PhpCsFixer\Config())->setRules([ - 'align_multiline_comment' => [ - 'comment_type' => 'all_multiline' - ], - 'array_indentation' => true, - 'array_syntax' => [ - 'syntax' => 'short' - ], - //backtick_to_shell_exec - 'binary_operator_spaces' => [ - 'operators' => ['=>' => 'align'] - ], - 'blank_line_after_namespace' => true, - 'blank_line_after_opening_tag' => true, - 'blank_line_before_statement' => false, - "single_space_around_construct" => true, - //control_structure_braces, - //control_structure_continuation_position - "declare_parentheses" => true, - "no_multiple_statements_per_line" => true, - "braces_position" => [ - 'anonymous_functions_opening_brace' => 'next_line_unless_newline_at_signature_end' - ], - "statement_indentation" => true, - "no_extra_blank_lines" => true, - 'cast_spaces' => true, - 'class_attributes_separation' => false, - 'class_definition' => true, - //class_keyword_remove - //combine_consecutive_issets - //combine_consecutive_unsets - //combine_nested_dirname - //comment_to_phpdoc - //compact_nullable_typehint - 'concat_space' => [ - 'spacing' => 'one' - ], - //date_time_immutable - 'declare_strict_types' => true, - 'declare_equal_normalize' => true, - 'declare_strict_types' => true, - //dir_constant - 'elseif' => true, - 'encoding' => true, - //ereg_to_preg - //error_suppression - //escape_implicit_backslashes - //explicit_indirect_variable - //explicit_string_variable - //final_class - //final_internal_class - //fopen_flag_order - //fopen_flags - 'full_opening_tag' => true, - //fully_qualified_strict_types - 'function_declaration' => true, - //function_to_constant - 'type_declaration_spaces' => true, - //header_comment - 'implode_call' => true, - 'include' => true, - //increment_style - 'indentation_type' => true, - //is_null - 'line_ending' => true, - 'linebreak_after_opening_tag' => true, - //list_syntax - 'logical_operators' => true, - 'lowercase_cast' => true, - 'constant_case' => ['case' => 'lower'], - 'lowercase_keywords' => true, - 'lowercase_static_reference' => true, - 'magic_constant_casing' => true, - 'magic_method_casing' => true, - //mb_str_functions - 'method_argument_space' => true, - 'method_chaining_indentation' => true, - //modernize_types_casting - 'multiline_comment_opening_closing' => true, - 'multiline_whitespace_before_semicolons' => true, - //native_constant_invocation - 'native_function_casing' => true, - //native_function_invocation - 'native_type_declaration_casing' => true, - 'new_with_parentheses' => true, - 'no_alias_functions' => true, - 'no_alternative_syntax' => true, - //no_binary_string - 'no_blank_lines_after_class_opening' => true, - 'no_blank_lines_after_phpdoc' => true, - 'blank_lines_before_namespace' => true, - 'no_break_comment' => [ - 'comment_text' => 'No break. Add additional comment above this line if intentional' - ], - 'no_closing_tag' => true, - 'no_empty_comment' => true, - 'no_empty_phpdoc' => true, - 'no_empty_statement' => true, - 'no_extra_blank_lines' => true, - //no_homoglyph_names - //no_leading_import_slash - 'no_leading_namespace_whitespace' => true, - 'no_mixed_echo_print' => true, - 'no_multiline_whitespace_around_double_arrow' => true, - //no_null_property_initialization - //no_short_bool_cast - 'echo_tag_syntax' => ['format' => 'long'], - 'no_singleline_whitespace_before_semicolons' => true, - 'no_spaces_after_function_name' => true, - 'no_spaces_around_offset' => false, - 'spaces_inside_parentheses' => true, - //no_superfluous_elseif - //no_superfluous_phpdoc_tags - 'no_trailing_comma_in_singleline' => true, - 'no_trailing_whitespace' => true, - 'no_trailing_whitespace_in_comment' => true, - 'no_unneeded_control_parentheses' => true, - 'no_unneeded_braces' => false, - //no_unneeded_final_method - //no_unset_cast - //no_unset_on_property - //no_unused_imports - 'no_useless_else' => false, - 'no_useless_return' => false, - 'no_whitespace_before_comma_in_array' => true, - 'no_whitespace_in_blank_line' => true, - //non_printable_character - 'normalize_index_brace' => true, - 'not_operator_with_space' => false, - 'not_operator_with_successor_space' => false, - 'object_operator_without_whitespace' => true, - 'ordered_class_elements' => true, - 'ordered_imports' => true, - 'ordered_interfaces' => true, - //php_unit_* - //php_doc_* - 'pow_to_exponentiation' => false, - 'protected_to_private' => false, - //psr0 - //psr4 - //random_api_migration - //return_assignment - 'return_type_declaration' => true, - 'self_accessor' => true, - 'semicolon_after_instruction' => true, - //set_type_to_cast - //short_scalar_cast - //simple_to_complex_string_variable - //simplified_null_return - //single_blank_line_at_eof - 'single_class_element_per_statement' => true, - 'single_import_per_statement' => true, - 'single_line_after_imports' => true, - //single_line_comment_style - 'single_quote' => true, - 'single_trait_insert_per_statement' => true, - 'space_after_semicolon' => true, - //standardize_increment - 'standardize_not_equals' => true, - //static_lambda - //strict_comparison - //strict_param - //string_line_ending - 'switch_case_semicolon_to_colon' => true, - 'switch_case_space' => true, - 'ternary_operator_spaces' => true, - //ternary_to_null_coalescing - 'trailing_comma_in_multiline' => false, - 'trim_array_spaces' => true, - 'unary_operator_spaces' => true, - 'modifier_keywords' => true, - //void_return - 'whitespace_after_comma_in_array' => true, - //yoda_style - ]) - ->setFinder($finder) - ->setIndent(" ") - ->setLineEnding("\n"); From 4651c96a060f4fa3978817668f584a6d67e12f2c Mon Sep 17 00:00:00 2001 From: TillBrede <51406030+TillBrede@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:05:02 +0200 Subject: [PATCH 13/17] fix style --- Home Connect Device/module.php | 2 +- tests/HomeConnectCoffeeTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 8723ed5..3c3a83f 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -3,7 +3,6 @@ declare(strict_types=1); class HomeConnectDevice extends IPSModule { - private const OPTION_DURATION = 'BSH.Common.Option.Duration'; public const RESTRICTIONS = [ 'BSH.Common.Status.RemoteControlStartAllowed', 'BSH.Common.Status.RemoteControlActive', @@ -46,6 +45,7 @@ class HomeConnectDevice extends IPSModule 'ConsumerProducts.CleaningRobot.Event.DockingStationNotFound' => 'The robot cannot find the charging station' ]; + 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']; diff --git a/tests/HomeConnectCoffeeTest.php b/tests/HomeConnectCoffeeTest.php index b12e63d..3815b80 100644 --- a/tests/HomeConnectCoffeeTest.php +++ b/tests/HomeConnectCoffeeTest.php @@ -63,11 +63,11 @@ protected function setUp(): void public function testBaseFunctionality() { - $cloudInterface = IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID('{CE76810D-B685-9BE0-CC04-38B204DEAD5E}')[0]); + $cloudId = IPS_GetInstanceListByModuleID('{CE76810D-B685-9BE0-CC04-38B204DEAD5E}')[0]; + $cloudInterface = IPS\InstanceManager::getInstanceInterface($cloudId); $cloudInterface->selectedProgram = 'Coffee'; $coffeMaker = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); $intf = IPS\InstanceManager::getInstanceInterface($coffeMaker); - $coffeMaker = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); IPS_SetProperty($coffeMaker, 'HaID', 'SIEMENS-TI9575X1DE-68A40E251CAD'); IPS_SetProperty($coffeMaker, 'DeviceType', 'CoffeMaker'); IPS_ApplyChanges($coffeMaker); From c1985f1fa6f19ae398bf4d7e20caca343d69069e Mon Sep 17 00:00:00 2001 From: TillBrede <51406030+TillBrede@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:05:33 +0200 Subject: [PATCH 14/17] update stubs for HasActiveParent to work properly --- tests/stubs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/stubs b/tests/stubs index 1224907..f5a5562 160000 --- a/tests/stubs +++ b/tests/stubs @@ -1 +1 @@ -Subproject commit 1224907792cbd8aefbaa465e5124a51d8e73d82b +Subproject commit f5a55625da66d7ac152a6400ad978bae664cc2be From 17bd121486aaae110b642856a147d44b589c91e6 Mon Sep 17 00:00:00 2001 From: bumaas Date: Fri, 24 Apr 2026 17:36:28 +0200 Subject: [PATCH 15/17] ci: add manual workflow dispatch --- .github/workflows/style.yml | 7 +++++-- .github/workflows/tests.yml | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 3713f68..2175076 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -1,6 +1,9 @@ name: Check Style -on: [push, pull_request] +on: + push: + pull_request: + workflow_dispatch: jobs: @@ -10,4 +13,4 @@ jobs: - name: Checkout module uses: actions/checkout@master - name: Check style - uses: symcon/action-style@v3 \ No newline at end of file + uses: symcon/action-style@v3 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1c9d116..b040ccd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,9 @@ name: Run Tests -on: [push, pull_request] +on: + push: + pull_request: + workflow_dispatch: jobs: @@ -12,4 +15,4 @@ jobs: with: submodules: true - name: Run tests - uses: symcon/action-tests@master \ No newline at end of file + uses: symcon/action-tests@master From c129525795199d882e29619785280b9b0f1a4c5b Mon Sep 17 00:00:00 2001 From: bumaas Date: Fri, 24 Apr 2026 18:14:09 +0200 Subject: [PATCH 16/17] fix: restore home connect test compatibility --- Home Connect Configurator/module.php | 5 +++ Home Connect Device/module.php | 64 +++++++++++++++++++++++++--- libs/WebOAuthModule.php | 5 +++ tests/HomeConnectCoffeeTest.php | 37 +++++++++++++++- tests/HomeConnectDryerTest.php | 42 ++++++++++++++++-- 5 files changed, 144 insertions(+), 9 deletions(-) diff --git a/Home Connect Configurator/module.php b/Home Connect Configurator/module.php index 40d7ccd..bedbcc2 100644 --- a/Home Connect Configurator/module.php +++ b/Home Connect Configurator/module.php @@ -27,6 +27,11 @@ public function ApplyChanges() parent::ApplyChanges(); } + public function ForwardData($JSONString) + { + return $this->SendDataToParent($JSONString); + } + public function GetConfigurationForm() { $form = json_decode(file_get_contents(__DIR__ . '/form.json'), true); diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 3c3a83f..02a48d9 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -510,11 +510,7 @@ private function sendOptionsOnProgramStart() */ private function updateOptionVariables($program) { - if (is_array($program)) { - $rawOptions = $program; - } else { - $rawOptions = $this->getProgram($program); - } + $rawOptions = $this->resolveProgramData($program); $this->SendDebug('RawOptions', json_encode($rawOptions), 0); if (!$rawOptions) { @@ -654,6 +650,64 @@ private function updateOptionValues($program) $this->WriteAttributeString('OptionKeys', json_encode($optionKeys)); } + /** + * Enrich selected program data with the available-program metadata so variable + * creation can rely on stable option types and constraints while keeping the + * current selected values. + * + * @param string|array $program + */ + private function resolveProgramData($program) + { + if (!is_array($program)) { + return $this->getProgram($program); + } + + if (!isset($program['key'])) { + return $program; + } + + $resolvedProgram = $this->getProgram($program['key']); + if (!is_array($resolvedProgram)) { + return $program; + } + + $selectedOptions = []; + $programOptions = isset($program['options']) && is_array($program['options']) ? $program['options'] : []; + foreach ($programOptions as $option) { + if (!isset($option['key'])) { + continue; + } + $selectedOptions[$option['key']] = $option; + } + + if (!isset($resolvedProgram['options']) || !is_array($resolvedProgram['options'])) { + return $resolvedProgram; + } + + foreach ($resolvedProgram['options'] as &$option) { + if (!isset($option['key']) || !isset($selectedOptions[$option['key']])) { + continue; + } + + $selectedOption = $selectedOptions[$option['key']]; + if (array_key_exists('value', $selectedOption)) { + $option['value'] = $selectedOption['value']; + } + if (isset($selectedOption['displayvalue'])) { + $option['displayvalue'] = $selectedOption['displayvalue']; + } + if (isset($selectedOption['name'])) { + $option['name'] = $selectedOption['name']; + } + if (isset($selectedOption['unit'])) { + $option['unit'] = $selectedOption['unit']; + } + } + + return $resolvedProgram; + } + private function createStates($states = '') { if (!$states) { diff --git a/libs/WebOAuthModule.php b/libs/WebOAuthModule.php index fec5492..a95467b 100644 --- a/libs/WebOAuthModule.php +++ b/libs/WebOAuthModule.php @@ -61,6 +61,11 @@ protected function ProcessOAuthData() $this->SendDebug('WebOAuth', 'Array POST: ' . print_r($_POST, true), 0); } + protected function getTime(): int + { + return time(); + } + private function RegisterOAuth($WebOAuth): void { $ids = IPS_GetInstanceListByModuleID('{F99BF07D-CECA-438B-A497-E4B55F139D37}'); diff --git a/tests/HomeConnectCoffeeTest.php b/tests/HomeConnectCoffeeTest.php index 3815b80..2a0b558 100644 --- a/tests/HomeConnectCoffeeTest.php +++ b/tests/HomeConnectCoffeeTest.php @@ -112,11 +112,46 @@ private function getChildrenValues($id) $children = IPS_GetChildrenIDs($id); $result = []; foreach ($children as $child) { - $result[IPS_GetObject($child)['ObjectIdent']] = GetValueFormatted($child); + if (IPS_GetObject($child)['ObjectIsHidden']) { + continue; + } + $result[IPS_GetObject($child)['ObjectIdent']] = $this->formatValue($child); } return $result; } + private function formatValue(int $variableID): string + { + $presentation = IPS_GetVariablePresentation($variableID); + if (!empty($presentation)) { + return GetValueFormatted($variableID); + } + + $variable = IPS_GetVariable($variableID); + $profileName = $variable['VariableCustomProfile'] ?: $variable['VariableProfile']; + if ($profileName === '' || !IPS_VariableProfileExists($profileName)) { + return strval($variable['VariableValue']); + } + + $profile = IPS_GetVariableProfile($profileName); + $value = $variable['VariableValue']; + if (count($profile['Associations']) > 0) { + switch ($profile['ProfileType']) { + 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']; + } + } + return '-'; + } + } + + return strval($profile['Prefix'] . $value . $profile['Suffix']); + } + private function buildEvent($event) { $string = ''; diff --git a/tests/HomeConnectDryerTest.php b/tests/HomeConnectDryerTest.php index 8ac3f99..dd1a659 100644 --- a/tests/HomeConnectDryerTest.php +++ b/tests/HomeConnectDryerTest.php @@ -17,7 +17,6 @@ class HomeConnectDryerTest extends TestCase 'OperationState' => 'Ready', 'PowerState' => 'An', 'SelectedProgram' => 'Baumwolle', - 'UseDuration' => 'No', 'OptionDryingTarget' => 'Schranktrocken', 'Control' => '-', 'LocalControlActive' => 'No', @@ -66,7 +65,7 @@ protected function setUp(): void public function testBaseFunctionality() { - $cloudInterface = IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID('{CE76810D-B685-9BE0-CC04-38B204DEAD5E}')[0]); + $cloudInterface = IPS\InstanceManager::getInstanceInterface($this->CloudID); $cloudInterface->selectedProgram = 'Cotton'; $dryer = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); IPS_ConnectInstance($dryer, $this->ConfiguratorID); @@ -98,8 +97,45 @@ private function getChildrenValues($id) $children = IPS_GetChildrenIDs($id); $result = []; foreach ($children as $child) { - $result[IPS_GetObject($child)['ObjectIdent']] = GetValueFormatted($child); + if (IPS_GetObject($child)['ObjectIsHidden']) { + continue; + } + $result[IPS_GetObject($child)['ObjectIdent']] = $this->formatValue($child); } return $result; } + + private function formatValue(int $variableID): string + { + $presentation = IPS_GetVariablePresentation($variableID); + if (!empty($presentation)) { + return GetValueFormatted($variableID); + } + + $variable = IPS_GetVariable($variableID); + $profileName = $variable['VariableCustomProfile'] ?: $variable['VariableProfile']; + if ($profileName === '' || !IPS_VariableProfileExists($profileName)) { + return strval($variable['VariableValue']); + } + + $profile = IPS_GetVariableProfile($profileName); + $value = $variable['VariableValue']; + if (count($profile['Associations']) > 0) { + switch ($profile['ProfileType']) { + 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']; + } + } + return '-'; + case VARIABLETYPE_INTEGER: + return strval($profile['Prefix'] . $value . $profile['Suffix']); + } + } + + return strval($profile['Prefix'] . $value . $profile['Suffix']); + } } From 4cc2cc59790998380173c5949181779a6d0b4a86 Mon Sep 17 00:00:00 2001 From: bumaas Date: Fri, 8 May 2026 11:42:14 +0200 Subject: [PATCH 17/17] Handle auth errors and bump build --- Home Connect Cloud/locale.json | 10 +- Home Connect Cloud/module.php | 229 ++++++++++++++++++++++++-------- Home Connect Device/locale.json | 6 +- Home Connect Device/module.php | 50 ++++++- library.json | 4 +- 5 files changed, 237 insertions(+), 62 deletions(-) diff --git a/Home Connect Cloud/locale.json b/Home Connect Cloud/locale.json index cef68c0..567cae7 100644 --- a/Home Connect Cloud/locale.json +++ b/Home Connect Cloud/locale.json @@ -4,6 +4,14 @@ "Register": "Registrieren", "Language": "Sprache", "Register Server Events": "Server Events registrieren", + "Home Connect registration is incomplete. Please reconnect \"Home Connect Cloud\" using \"Register\".": "Die Home-Connect-Anmeldung ist unvollständig. Bitte verbinde \"Home Connect Cloud\" ücber \"Registrieren\" erneut.", + "Home Connect login is missing. Please connect \"Home Connect Cloud\" using \"Register\" and then register the server events again.": "Die Home-Connect-Anmeldung fehlt. Bitte verbinde \"Home Connect Cloud\" über \"Registrieren\" und registriere danach die Server-Events erneut.", + "Home Connect login expired or was revoked. Please reconnect \"Home Connect Cloud\" using \"Register\" and then register the server events again.": "Die Home-Connect-Anmeldung ist abgelaufen oder wurde widerrufen. Bitte verbinde \"Home Connect Cloud\" über \"Registrieren\" neu und registriere danach die Server-Events erneut.", + "Home Connect login could not be refreshed right now. Please try again later. If the problem persists, reconnect \"Home Connect Cloud\".": "Die Home-Connect-Anmeldung konnte gerade nicht aktualisiert werden. Bitte versuche es später erneut. Wenn das Problem bleibt, verbinde \"Home Connect Cloud\" neu.", + "Home Connect login failed. Please check \"Home Connect Cloud\" and reconnect if necessary.": "Die Home-Connect-Anmeldung ist fehlgeschlagen. Bitte prüfe \"Home Connect Cloud\" und verbinde es gegebenenfalls erneut.", + "Home Connect login failed. Please reconnect \"Home Connect Cloud\".": "Die Home-Connect-Anmeldung ist fehlgeschlagen. Bitte verbinde \"Home Connect Cloud\" erneut.", + "Home Connect login returned an unexpected response. Please reconnect \"Home Connect Cloud\".": "Die Home-Connect-Anmeldung lieferte eine unerwartete Antwort. Bitte verbinde \"Home Connect Cloud\" erneut.", + "Home Connect request failed.": "Die Home-Connect-Anfrage ist fehlgeschlagen.", "The rate limit of %s was reached. Requests are blocked until %s.": "Das Anfragenlimit von %s wurde erreicht. Weitere Anfragen werden bis %s blockiert.", "1000 calls in 1 day": "1000 Anfragen pro Tag", "50 calls in 1 minute": "50 Anfragen pro Minute", @@ -11,4 +19,4 @@ "https://www.symcon.de/en/service/documentation/module-reference/home-connect/": "https://www.symcon.de/de/service/dokumentation/modulreferenz/home-connect" } } -} \ No newline at end of file +} diff --git a/Home Connect Cloud/module.php b/Home Connect Cloud/module.php index 2d91fae..e64b522 100644 --- a/Home Connect Cloud/module.php +++ b/Home Connect Cloud/module.php @@ -67,14 +67,21 @@ public function ForwardData($Data) { $data = json_decode($Data, true); $this->SendDebug('Forward', $Data, 0); - if (isset($data['Payload'])) { - $this->SendDebug('Payload', $data['Payload'], 0); - if ($data['Payload'] == 'DELETE') { - return $this->deleteRequest($data['Endpoint']); + try { + if (isset($data['Payload'])) { + $this->SendDebug('Payload', $data['Payload'], 0); + if ($data['Payload'] == 'DELETE') { + return $this->deleteRequest($data['Endpoint']); + } + return $this->putRequest($data['Endpoint'], $data['Payload']); } - return $this->putRequest($data['Endpoint'], $data['Payload']); + + return $this->getRequest($data['Endpoint']); + } catch (RuntimeException $e) { + $error = $this->DecodeModuleError($e); + $this->SendDebug('ForwardError', json_encode($error), 0); + return json_encode(['error' => $error['error']]); } - return $this->getRequest($data['Endpoint']); } public function ReceiveData($JSONString) @@ -114,21 +121,27 @@ public function MessageSink($Timestamp, $SenderID, $MessageID, $Data) public function RegisterServerEvents() { - $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; + 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']; } - 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); } public function CheckServerEvents() @@ -208,24 +221,20 @@ private function FetchRefreshToken($code) { $this->SendDebug('FetchRefreshToken', 'Use Authentication Code to get our precious Refresh Token!', 0); - //Exchange our Authentication Code for a permanent Refresh Token and a temporary Access Token - $options = [ - 'http' => [ - 'header' => "Content-Type: application/x-www-form-urlencoded\r\n", - 'method' => 'POST', - 'content' => http_build_query(['code' => $code]), - 'ignore_errors' => true - ] - ]; - $context = stream_context_create($options); - $result = file_get_contents('https://' . $this->oauthServer . '/access_token/' . $this->oauthIdentifer, false, $context); - - $data = json_decode($result); - - if (!isset($data->token_type) || $data->token_type != 'Bearer') { - die('Bearer Token expected'); + if (trim((string) $code) == '') { + $this->ThrowModuleError( + 'Client.Error.AuthenticationRequired', + $this->Translate('Home Connect registration is incomplete. Please reconnect "Home Connect Cloud" using "Register".'), + 'OAuth authorization failed: Authorization Code missing' + ); } + //Exchange our Authentication Code for a permanent Refresh Token and a temporary Access Token + $data = $this->RequestOAuthToken( + ['code' => $code], + 'authorization' + ); + //Save temporary access token $this->FetchAccessToken($data->access_token, time() + $data->expires_in); @@ -251,24 +260,21 @@ private function FetchAccessToken($Token = '', $Expires = 0) $this->SendDebug('FetchAccessToken', 'Use Refresh Token to get new Access Token!', 0); - //If we slipped here we need to fetch the access token - $options = [ - 'http' => [ - 'header' => "Content-Type: application/x-www-form-urlencoded\r\n", - 'method' => 'POST', - 'content' => http_build_query(['refresh_token' => $this->ReadAttributeString('Token')]), - 'ignore_errors' => true - ] - ]; - $context = stream_context_create($options); - $result = file_get_contents('https://' . $this->oauthServer . '/access_token/' . $this->oauthIdentifer, false, $context); - - $data = json_decode($result); - - if (!isset($data->token_type) || $data->token_type != 'Bearer') { - die('Bearer Token expected'); + $refreshToken = trim($this->ReadAttributeString('Token')); + if ($refreshToken == '') { + $this->ThrowModuleError( + 'Client.Error.AuthenticationRequired', + $this->Translate('Home Connect login is missing. Please connect "Home Connect Cloud" using "Register" and then register the server events again.'), + 'OAuth token refresh failed: Refresh Token missing' + ); } + //If we slipped here we need to fetch the access token + $data = $this->RequestOAuthToken( + ['refresh_token' => $refreshToken], + 'refresh' + ); + //Update parameters to properly cache it in the next step $Token = $data->access_token; $Expires = time() + $data->expires_in; @@ -291,6 +297,125 @@ private function FetchAccessToken($Token = '', $Expires = 0) return $Token; } + private function RequestOAuthToken(array $payload, string $requestType) + { + $options = [ + 'http' => [ + 'header' => "Content-Type: application/x-www-form-urlencoded\r\n", + 'method' => 'POST', + 'content' => http_build_query($payload), + 'ignore_errors' => true + ] + ]; + $context = stream_context_create($options); + $result = file_get_contents('https://' . $this->oauthServer . '/access_token/' . $this->oauthIdentifer, false, $context); + $responseHeader = isset($http_response_header) ? $http_response_header : []; + $responseCode = $this->GetHttpResponseCode($responseHeader); + $rawResult = is_string($result) ? $result : ''; + + $this->SendDebug('OAuth ' . $requestType . ' HTTP', json_encode($responseHeader), 0); + $this->SendDebug('OAuth ' . $requestType . ' Response', $rawResult, 0); + + $data = json_decode($rawResult); + + if (isset($data->token_type) && $data->token_type == 'Bearer' && isset($data->access_token)) { + return $data; + } + + $oauthError = $this->BuildOAuthError($requestType, $responseCode, $data, $rawResult); + $this->ThrowModuleError($oauthError['key'], $oauthError['description'], $oauthError['debug']); + } + + private function BuildOAuthError(string $requestType, int $responseCode, $data, string $rawResult): array + { + $context = $requestType == 'refresh' ? 'token refresh' : 'authorization'; + $error = is_object($data) && isset($data->error) ? (string) $data->error : ''; + $description = is_object($data) && isset($data->error_description) ? trim((string) $data->error_description) : ''; + + if ($error == 'invalid_grant') { + $reason = $description != '' ? $description : 'Refresh Token invalid or expired'; + return [ + 'key' => 'Client.Error.AuthenticationExpired', + 'description' => $this->Translate('Home Connect login expired or was revoked. Please reconnect "Home Connect Cloud" using "Register" and then register the server events again.'), + 'debug' => 'OAuth ' . $context . ' failed: invalid_grant (' . $reason . ')' + ]; + } + + if ($responseCode >= 500 || $responseCode == 0) { + $reason = $description != '' ? $description : ($rawResult != '' ? trim($rawResult) : 'No HTTP response'); + return [ + 'key' => 'Client.Error.AuthenticationServer', + 'description' => $this->Translate('Home Connect login could not be refreshed right now. Please try again later. If the problem persists, reconnect "Home Connect Cloud".'), + 'debug' => 'OAuth ' . $context . ' failed: server problem (HTTP ' . $responseCode . ', ' . $reason . ')' + ]; + } + + if ($error != '') { + $reason = $description != '' ? $description : 'No error description'; + return [ + 'key' => 'Client.Error.Authentication', + 'description' => $this->Translate('Home Connect login failed. Please check "Home Connect Cloud" and reconnect if necessary.'), + 'debug' => 'OAuth ' . $context . ' failed: ' . $error . ' (' . $reason . ')' + ]; + } + + if ($responseCode >= 400) { + $reason = $rawResult != '' ? trim($rawResult) : 'Empty response body'; + return [ + 'key' => 'Client.Error.AuthenticationHttp', + 'description' => $this->Translate('Home Connect login failed. Please reconnect "Home Connect Cloud".'), + 'debug' => 'OAuth ' . $context . ' failed: HTTP ' . $responseCode . ' (' . $reason . ')' + ]; + } + + return [ + 'key' => 'Client.Error.AuthenticationUnexpected', + 'description' => $this->Translate('Home Connect login returned an unexpected response. Please reconnect "Home Connect Cloud".'), + 'debug' => 'OAuth ' . $context . ' failed: unexpected token response' + ]; + } + + private function GetHttpResponseCode(array $responseHeader): int + { + if (count($responseHeader) == 0) { + return 0; + } + + $parts = explode(' ', $responseHeader[0]); + if (!isset($parts[1])) { + return 0; + } + + return intval($parts[1]); + } + + private function ThrowModuleError(string $key, string $description, string $debug): void + { + throw new RuntimeException(json_encode([ + 'error' => [ + 'key' => $key, + 'description' => $description, + 'debug' => $debug + ] + ])); + } + + private function DecodeModuleError(RuntimeException $e): array + { + $data = json_decode($e->getMessage(), true); + if (!is_array($data) || !isset($data['error'])) { + return [ + 'error' => [ + 'key' => 'Client.Error.Module', + 'description' => $this->Translate('Home Connect request failed.'), + 'debug' => $e->getMessage() + ] + ]; + } + + return $data; + } + private function FetchData($url) { $opts = [ diff --git a/Home Connect Device/locale.json b/Home Connect Device/locale.json index 6cfa2d1..9f4e950 100644 --- a/Home Connect Device/locale.json +++ b/Home Connect Device/locale.json @@ -71,7 +71,9 @@ "Initialize Device": "Gerät Initialisieren", "https://www.symcon.de/en/service/documentation/module-reference/home-connect/home-connect-device/": "https://www.symcon.de/de/service/dokumentation/modulreferenz/home-connect/home-connect-device/", "Home Connect Device": "Home Connect Gerät", - "Use duration option": "Option Dauer verwenden" + "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" } } -} \ No newline at end of file +} diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 02a48d9..2900695 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -372,7 +372,23 @@ public function RequestDataFromParent(string $endpoint, string $payload = '') $data['Payload'] = $payload; } $response = $this->SendDataToParent(json_encode($data)); + if (!is_string($response)) { + $this->SendDebug('ErrorResponseRaw', var_export($response, true), 0); + $response = $this->buildParentResponseError('Client.Error.ParentResponse', $this->Translate('No response from parent instance')); + } + + if ($response === '') { + $this->SendDebug('responseData', $response, 0); + return $response; + } + $errorDetector = json_decode($response, true); + if (!is_array($errorDetector)) { + $this->SendDebug('ErrorResponseRaw', $response, 0); + $response = $this->buildParentResponseError('Client.Error.ParentResponse', $this->Translate('Invalid JSON response from parent instance')); + $errorDetector = json_decode($response, true); + } + if (isset($errorDetector['error'])) { // Log error responses so failures are visible in debug output. $this->SendDebug('ErrorResponse', $response, 0); @@ -386,7 +402,9 @@ public function RequestDataFromParent(string $endpoint, string $payload = '') default: $this->SendDebug('ErrorPayload', $payload, 0); $this->SendDebug('ErrorEndpoint', $endpoint, 0); - echo $errorDetector['error']['description']; //Not translated due to the dynamic content + if (isset($errorDetector['error']['description'])) { + echo $errorDetector['error']['description']; //Not translated due to the dynamic content + } break; } } @@ -611,8 +629,8 @@ private function getOption($key) if (in_array($key, self::EXCLUDE)) { return false; } - $data = json_decode($this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/programs/selected/options/' . $key), true)['data']; - return $data; + $data = json_decode($this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/programs/selected/options/' . $key), true); + return isset($data['data']) ? $data['data'] : false; } /** * @param string|array $program Der Programmschlüssel oder das bereits abgerufene Programmdaten-Array. @@ -829,6 +847,9 @@ private function setupSettings() } $variableType = $this->getVariableType($value); $settingDetails = json_decode($this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/settings/' . $setting['key']), true); + if (!isset($settingDetails['data'])) { + continue; + } $this->createVariableFromConstraints($profileName, $settingDetails['data'], 'Setting', $position); $position++; $this->SetValue($ident, $value); @@ -1102,14 +1123,32 @@ private function executeApplicanceCommand($command) private function responseHasError($response) { - if (!is_string($response) || $response === '') { + if (!is_string($response)) { + return true; + } + + if ($response === '') { return false; } $decodedResponse = json_decode($response, true); + if (!is_array($decodedResponse)) { + return true; + } + return isset($decodedResponse['error']); } + private function buildParentResponseError(string $key, string $description): string + { + return json_encode([ + 'error' => [ + 'key' => $key, + 'description' => $description + ] + ]); + } + private function sortAssociations($key, array $associations) { if ($key !== 'BSH.Common.Setting.PowerState') { @@ -1140,6 +1179,7 @@ private function sortAssociations($key, array $associations) private function getAvailableCommands() { - return json_decode($this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/commands'), true)['data']['commands']; + $commands = json_decode($this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/commands'), true); + return isset($commands['data']['commands']) ? $commands['data']['commands'] : []; } } diff --git a/library.json b/library.json index a18eeff..bbaa585 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 7, - "date": 1776909600 + "build": 8, + "date": 1778241600 }