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 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 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/locale.json b/Home Connect Device/locale.json index cdb878a..9f4e950 100644 --- a/Home Connect Device/locale.json +++ b/Home Connect Device/locale.json @@ -70,7 +70,10 @@ "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 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 eaf2e42..2900695 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -45,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']; @@ -120,12 +121,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); } } @@ -213,7 +215,12 @@ public function GetConfigurationForm() public function RequestAction($Ident, $Value) { + $applyValue = false; switch ($Ident) { + case 'UseDuration': + $applyValue = true; + break; + case 'SelectedProgram': if (!$this->switchable()) { //TODO: better error message @@ -231,10 +238,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; @@ -288,20 +300,28 @@ 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')) { $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 +334,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); } } @@ -351,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); @@ -365,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; } } @@ -407,15 +446,56 @@ 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'); + // 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; } - $optionsPayload[] = $this->createOptionRequestData($ident, $optionKeys[$ident], $this->GetValue($ident)); + $optionKey = $optionKeys[$ident]; + if ($optionKey == self::OPTION_DURATION && !$useDuration) { + continue; + } + 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 = [ @@ -443,17 +523,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); + $rawOptions = $this->resolveProgramData($program); + $this->SendDebug('RawOptions', json_encode($rawOptions), 0); if (!$rawOptions) { $this->SetValue('SelectedProgram', ''); $this->setOptionsDisabled(true); + $this->syncUseDurationVariable(false, 0); return; } $this->setOptionsDisabled(false); - $options = $rawOptions['options']; + $options = isset($rawOptions['options']) && is_array($rawOptions['options']) ? $rawOptions['options'] : []; $position = 10; $availableOptions = []; $deviceType = $this->ReadPropertyString('DeviceType'); @@ -482,9 +567,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'); @@ -528,19 +629,24 @@ 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. + */ 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) { + $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']) { @@ -548,8 +654,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); @@ -559,6 +668,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) { @@ -680,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); @@ -748,7 +918,6 @@ private function createVariableFromConstraints($profileName, $data, $attribute, $variableType = VARIABLETYPE_STRING; break; } - switch ($variableType) { case VARIABLETYPE_INTEGER: case VARIABLETYPE_FLOAT: @@ -781,6 +950,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,8 +1121,65 @@ private function executeApplicanceCommand($command) return true; } + private function responseHasError($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') { + 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']; + $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 b878916..bbaa585 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": 8, + "date": 1778241600 +} 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 b12e63d..2a0b558 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); @@ -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 4adf59d..dd1a659 100644 --- a/tests/HomeConnectDryerTest.php +++ b/tests/HomeConnectDryerTest.php @@ -31,6 +31,7 @@ class HomeConnectDryerTest extends TestCase 'DoorState' => 'Closed', 'PowerState' => 'An', 'SelectedProgram' => 'Zeitprogramm kalt', + 'UseDuration' => 'No', 'OptionDuration' => '1200 seconds', 'Control' => '-', 'LocalControlActive' => 'No', @@ -53,19 +54,24 @@ 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(); } 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); $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,10 +97,45 @@ 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); + 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']); + } } 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) 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