diff --git a/Home Connect Cloud/module.php b/Home Connect Cloud/module.php index 234c367..4ee8925 100644 --- a/Home Connect Cloud/module.php +++ b/Home Connect Cloud/module.php @@ -183,45 +183,22 @@ public function MessageSink($Timestamp, $SenderID, $MessageID, $Data) public function ForceRegisterServerEvents() { $parent = IPS_GetInstance($this->InstanceID)['ConnectionID']; - if (IPS_InstanceExists($parent)) { - IPS_SetProperty($parent, 'Active', true); - IPS_ApplyChanges($parent); - $this->RegisterServerEvents(); + if (IPS_InstanceExists($parent) && !$this->deferWhileRateLimited()) { + $this->connectEventStream(true, true); } } public function RegisterServerEvents() { - // Do not (re)connect the event stream while we are rate limited - every - // reconnect hits /events and counts against the daily quota. Defer the - // reconnect to the moment the limit expires instead. - if ($this->isRateLimitActive()) { - $remaining = $this->ReadAttributeInteger('RateLimitUntil') - time(); - $this->SendDebug('RegisterServerEvents', sprintf('Rate limit active, defer reconnect by %ds', $remaining), 0); - $this->SetTimerInterval('Reconnect', max(1, $remaining) * 1000); + if ($this->deferWhileRateLimited()) { return; } - try { - $url = self::HOME_CONNECT_BASE . 'homeappliances/events'; - $this->SendDebug('url', $url, 0); - $parent = IPS_GetInstance($this->InstanceID)['ConnectionID']; - if (!IPS_GetProperty($parent, 'Active')) { - echo $this->Translate('IO instance is not active'); - return; - } - IPS_SetProperty($parent, 'URL', $url); - IPS_SetProperty($parent, 'Headers', json_encode([['Name' => 'Authorization', 'Value' => 'Bearer ' . $this->FetchAccessToken()]])); - IPS_ApplyChanges($parent); - - // Mark connection as good for the moment - $this->SetBuffer('KeepAlive', time()); - - $this->SetTimerInterval('Reconnect', 0); - } catch (RuntimeException $e) { - $error = $this->DecodeModuleError($e); - $this->SendDebug('RegisterServerEventsError', json_encode($error), 0); - echo $error['error']['description']; + $parent = IPS_GetInstance($this->InstanceID)['ConnectionID']; + if (!IPS_GetProperty($parent, 'Active')) { + echo $this->Translate('IO instance is not active'); + return; } + $this->connectEventStream(false, true); } public function CheckServerEvents() @@ -230,6 +207,23 @@ public function CheckServerEvents() if ($this->isRateLimitActive()) { return; } + // A block that has expired but was never lifted: a restart during the block + // resets the RateLimit timer, while RateLimitUntil and the IO deactivated by + // applyRateLimit persist; or the stream could not be resumed when it ended. + // Lift it here, otherwise the stream stays dead. + $blockPending = $this->ReadAttributeInteger('RateLimitUntil') !== 0; + if (!$blockPending) { + // Nothing to reconnect: the user switched the IO off, or the login is + // missing. Stay silent - output of a timer ends up as a log warning. + $parent = IPS_GetInstance($this->InstanceID)['ConnectionID']; + if (!IPS_InstanceExists($parent) || !IPS_GetProperty($parent, 'Active')) { + return; + } + if (trim($this->ReadAttributeString('Token')) === '') { + $this->SendDebug('KeepAlive', 'Not registered - no reconnect', 0); + return; + } + } // A stale keep-alive means the event stream is dead - reconnect regardless of // the parent's current status. Gating this behind HasActiveParent() prevented // recovery exactly when the parent had dropped to an error state (keep-alives @@ -253,7 +247,12 @@ public function CheckServerEvents() $parent = IPS_GetInstance($this->InstanceID)['ConnectionID']; $parentStatus = IPS_InstanceExists($parent) ? IPS_GetInstance($parent)['InstanceStatus'] : 0; $this->SendDebug('KeepAlive', sprintf('Failed (IO status: %d). Reregistering... (attempt #%d, next attempt in %ds)', $parentStatus, $retries + 1, $delay), 0); - $this->RegisterServerEvents(); + if ($blockPending) { + $this->SendDebug('KeepAlive', 'Expired rate limit was not lifted - resetting', 0); + $this->ResetRateLimit(); + } else { + $this->connectEventStream(false, false); + } } } @@ -270,16 +269,20 @@ public function GetConfigurationForParent() public function ResetRateLimit() { - $this->WriteAttributeString('RateError', ''); - $this->WriteAttributeInteger('RateLimitUntil', 0); - $this->updateRateLimitNotice(); - $this->SetStatus(IS_ACTIVE); $this->SetTimerInterval('RateLimit', 0); + $this->SetTimerInterval('Reconnect', 0); // Limit is over - re-activate the IO (stopped in applyRateLimit) and resume the - // event stream exactly once, with a freshly fetched access token. - $this->SetTimerInterval('Reconnect', 0); - $this->ForceRegisterServerEvents(); + // event stream exactly once, with a freshly fetched access token. If that fails + // (no token), the expired block stays pending and the keep-alive watchdog retries. + if (!$this->connectEventStream(true, false)) { + return; + } + // An expired block is already lifted by connectEventStream; a block that is still + // running (the request went through anyway) is lifted here. + if ($this->ReadAttributeInteger('RateLimitUntil') !== 0) { + $this->liftRateLimit(); + } } public function GetConfigurationForm() @@ -376,10 +379,11 @@ private function handleExpiredTokenFromStream(string $JSONString): bool } $this->SendDebug('ReceiveTokenExpired', 'Access token expired on event stream - refreshing token and reconnecting', 0); - // Re-register the stream: RegisterServerEvents calls FetchAccessToken(), which - // automatically refreshes an expired access token via the refresh token before - // re-arming the /events request. No need to wait for the keep-alive watchdog. - $this->ForceRegisterServerEvents(); + // The server rejected the cached token, although it may still be valid by its + // local expiry (clock skew, revocation). Drop it, so FetchAccessToken() in + // connectEventStream fetches a new one instead of re-sending the rejected one. + $this->SetBuffer('AccessToken', ''); + $this->connectEventStream(true, false); return true; } @@ -616,6 +620,21 @@ private function isRateLimitActive(): bool return $this->ReadAttributeInteger('RateLimitUntil') > time(); } + /** + * Clears the block state and reports the instance active again. Call it only once + * the event stream runs: children answer the status change with HasActiveParent(), + * which walks up to the IO. Seeing it still inactive they would go inactive and, + * ignoring repeated identical status messages, stay so. + */ + private function liftRateLimit(): void + { + $this->WriteAttributeString('RateError', ''); + $this->WriteAttributeInteger('RateLimitUntil', 0); + $this->updateRateLimitNotice(); + $this->SendDebug('ResetRateLimit', 'Event stream resumed, instance active', 0); + $this->SetStatus(IS_ACTIVE); + } + private function updateRateLimitNotice(): void { $rateError = $this->ReadAttributeString('RateError'); @@ -710,6 +729,71 @@ private function stopEventStream(): void } } + /** + * Do not (re)connect the event stream while we are rate limited - every reconnect + * hits /events and counts against the daily quota. Defers the reconnect to the + * moment the limit expires instead. Returns true if it deferred. + */ + private function deferWhileRateLimited(): bool + { + if (!$this->isRateLimitActive()) { + return false; + } + $remaining = $this->ReadAttributeInteger('RateLimitUntil') - time(); + $this->SendDebug('RegisterServerEvents', sprintf('Rate limit active, defer reconnect by %ds', $remaining), 0); + $this->SetTimerInterval('Reconnect', max(1, $remaining) * 1000); + return true; + } + + /** + * Points the event-stream IO at /events with a current access token and applies it + * once. The token is fetched first: without one the IO is left untouched instead of + * connecting with a stale header. $activate also switches the IO on. $report echoes + * an error - only for calls by the user, output of a timer ends up in the log. + * Returns true if the stream was registered. + */ + private function connectEventStream(bool $activate, bool $report): bool + { + $parent = IPS_GetInstance($this->InstanceID)['ConnectionID']; + if (!IPS_InstanceExists($parent)) { + return false; + } + try { + $token = $this->FetchAccessToken(); + } catch (RuntimeException $e) { + $error = $this->DecodeModuleError($e); + $this->SendDebug('RegisterServerEventsError', json_encode($error), 0); + if ($report) { + echo $error['error']['description']; + } + return false; + } + + $url = self::HOME_CONNECT_BASE . 'homeappliances/events'; + $this->SendDebug('url', $url, 0); + if ($activate) { + IPS_SetProperty($parent, 'Active', true); + } + IPS_SetProperty($parent, 'URL', $url); + IPS_SetProperty($parent, 'Headers', json_encode([['Name' => 'Authorization', 'Value' => 'Bearer ' . $token]])); + IPS_ApplyChanges($parent); + + // Mark connection as good for the moment + $this->SetBuffer('KeepAlive', time()); + + $this->SetTimerInterval('Reconnect', 0); + + // A block that expired while the stream could not be resumed (no token) is still + // pending. Now that the stream runs again, lift it - whoever registered it: the + // reset timer, the watchdog, a new login (ProcessOAuthData) or the 401 recovery. + // Otherwise the instance would stay at the rate-limited status with a running + // stream until some REST request happens to succeed. + if ($this->ReadAttributeInteger('RateLimitUntil') !== 0 && !$this->isRateLimitActive()) { + $this->liftRateLimit(); + } + return true; + } + /** * Clears the rate limit after a successful request - but only if a block was actually * pending. Without this guard every successful request would run ResetRateLimit() and diff --git a/Home Connect Configurator/module.php b/Home Connect Configurator/module.php index f1fd817..0caed32 100644 --- a/Home Connect Configurator/module.php +++ b/Home Connect Configurator/module.php @@ -132,8 +132,15 @@ private function getInstanceIDForGuid($haid, $guid) private function getExistingDeviceRows(array $knownHaIDs) { $rows = []; + $cloudID = IPS_GetInstance($this->InstanceID)['ConnectionID']; foreach (self::MODULE_TYPES as $guid) { foreach (IPS_GetInstanceListByModuleID($guid) as $instanceID) { + // Only devices of this cloud (or of none) - the rows can be deleted from + // here, and a second cloud instance belongs to another account. + $deviceCloudID = IPS_GetInstance($instanceID)['ConnectionID']; + if ($deviceCloudID !== $cloudID && $deviceCloudID !== 0) { + continue; + } $haID = (string) @IPS_GetProperty($instanceID, 'HaID'); if ($haID === '' || isset($knownHaIDs[$haID])) { continue; diff --git a/Home Connect Device/locale.json b/Home Connect Device/locale.json index bc36e42..42962a9 100644 --- a/Home Connect Device/locale.json +++ b/Home Connect Device/locale.json @@ -14,6 +14,7 @@ "Seconds": "Sekunden", "Yes": "Ja", "No": "Nein", + "Connected": "Verbunden", "Inactive": "Inaktiv", "Ready": "Bereit", "Delayed Start": "Verzögerter Start", @@ -51,6 +52,7 @@ "Please fill bean container": "Bitte Bohnenbehälter füllen", "Please fill water tank": "Bitte Wassertank füllen", "Please fill RinseAid tank": "Bitte Klarspüler nachfüllen", + "Please fill salt": "Bitte Salz nachfüllen", "Please empty drip tray": "Bitte Tropfschalen leeren", "Please descale device": "Bitte Gerät entkalken", "Descaling overdue": "Entkalkung überfällig", diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 06a81db..927da14 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -78,6 +78,7 @@ class HomeConnectDevice extends IPSModule 'ConsumerProducts.CoffeeMaker.Event.DeviceCalcNCleanOverdue' => 'Device calc n clean overdue', 'ConsumerProducts.CoffeeMaker.Event.DeviceCalcNCleanBlockage' => 'Device blocked because of calc n clean overdue', 'Dishcare.Dishwasher.Event.RinseAidNearlyEmpty' => 'Please fill RinseAid tank', + 'Dishcare.Dishwasher.Event.SaltNearlyEmpty' => 'Please fill salt', 'LaundryCare.Dryer.Event.DryingProcessFinished' => 'Drying Process Finished', 'Refrigeration.FridgeFreezer.Event.DoorAlarmFreezer' => 'Please close door', 'Refrigeration.FridgeFreezer.Event.DoorAlarmRefrigerator' => 'Please close door', @@ -137,6 +138,9 @@ public function Create() $this->RegisterAttributeString('InitializationSignature', ''); $this->RegisterAttributeBoolean('Initialized', false); + // Retries an initialization the refresh throttle had to skip. + $this->RegisterTimer('RetryRefresh', 0, 'IPS_RequestAction($_IPS[\'TARGET\'], "RetryRefreshDeviceState", "");'); + //Common States //States if (!IPS_VariableProfileExists('HomeConnect.Common.Status.OperationState')) { @@ -242,12 +246,14 @@ public function ReceiveData($String) $data = json_decode($String, true); switch ($data['Event']) { case 'DISCONNECTED': + $this->setConnected(false); if (@IPS_GetObjectIDByIdent('OperationState', $this->InstanceID)) { // Offline device set OperationState to Inactive $this->SetValue('OperationState', 'BSH.Common.EnumType.OperationState.Inactive'); } break; case 'CONNECTED': + $this->setConnected(true); // Device comes online -> refresh states. Decouple from the event thread: // refreshDeviceState performs synchronous cloud calls, and doing them // inline blocks ReceiveData while the parent is busy (e.g. reconnecting the @@ -298,6 +304,13 @@ public function ReceiveData($String) $ident = 'Option' . $ident; } if (@IPS_GetObjectIDByIdent($ident, $this->InstanceID)) { + if (strpos($ident, 'Option') === 0) { + $this->ensureOptionAssociation($ident, $item['value']); + } elseif ($ident == 'SelectedProgram' && is_string($item['value']) && $item['value'] != '') { + // Programs chosen at the appliance are reported here + // although programs does not list them. + $this->ensureProgramAssociation($item['value']); + } $this->SetValue($ident, $item['value']); } elseif (strpos($ident, 'Option') === 0) { // The variable may be created only moments later by @@ -316,6 +329,7 @@ public function ReceiveData($String) $eventData = json_decode($data['Data'], true); foreach ($eventData['items'] as $item) { if ($item['value'] == 'BSH.Common.EnumType.EventPresentState.Present') { + $this->ensureEventAssociation($item['key']); $this->SetValue('Event', $item['key']); $level = $this->Translate($item['level']); $event = GetValueFormattedEx($this->GetIDForIdent('Event'), $item['key']); @@ -359,6 +373,12 @@ public function RequestAction($Ident, $Value) $this->refreshDeviceState($this->needsInitialization(), 'Event:CONNECTED (deferred)'); return; + case 'RetryRefreshDeviceState': + // Internal action, triggered by the RetryRefresh timer: repeats an + // initialization the refresh throttle had to skip. + $this->refreshDeviceState($this->needsInitialization(), 'RetryRefresh'); + return; + case 'RefreshActiveProgramOptions': // Internal action, triggered by the one-shot timer armed in // updateActiveProgram(). Creates the option variables for a program @@ -565,20 +585,37 @@ public function RequestDataFromParent(string $endpoint, string $payload = '') return $response; } + // Time source for the module timers of the test stubs (as in WebOAuthModule). + protected function getTime(): int + { + return time(); + } + private function refreshDeviceState(bool $initializeDevice, string $trigger = ''): void { // DEBUG (rate-limit analysis): record every refresh, its trigger and the chosen path. $this->SendDebug(__FUNCTION__, sprintf('trigger: %s, mode: %s, activeParent: %s', $trigger, $initializeDevice ? 'init' : 'valueRefresh', $this->HasActiveParent() ? 'yes' : 'no'), 0); if ($this->HasActiveParent() && $this->ReadPropertyString('HaID')) { $this->SetSummary($this->ReadPropertyString('HaID')); - if ($this->refreshThrottled()) { + $wait = $this->refreshThrottled(); + if ($wait > 0) { // Too soon since the last refresh - skip the server round-trips but // keep the instance active. Live STATUS/NOTIFY events still update - // values directly (they do not go through this path). + // values directly (they do not go through this path). A skipped + // initialization has no such substitute: retry it after the window. + // A throttled value refresh leaves a pending retry alone. $this->SendDebug(__FUNCTION__, sprintf('throttled (trigger: %s)', $trigger), 0); + if ($initializeDevice || $this->needsInitialization()) { + $this->SetTimerInterval('RetryRefresh', $wait * 1000); + } $this->setInstanceStatus(IS_ACTIVE); return; } + // This refresh runs now and supersedes a pending retry - unless it is a value + // refresh and the initialization is still due. + if ($initializeDevice || !$this->needsInitialization()) { + $this->SetTimerInterval('RetryRefresh', 0); + } if ($initializeDevice) { $this->InitializeDevice(); } else { @@ -597,16 +634,18 @@ private function refreshDeviceState(bool $initializeDevice, string $trigger = '' * Rate-limit guard shared by the init and the value-refresh path: allows at most * one full server refresh per REFRESH_MIN_INTERVAL seconds. The timestamp lives in * a runtime buffer, so it resets on restart (a restart should refresh immediately). + * Returns the seconds to wait, 0 if the refresh may run (and records it). */ - private function refreshThrottled(): bool + private function refreshThrottled(): int { $now = time(); $last = (int) $this->GetBuffer('LastRefresh'); if ($last !== 0 && ($now - $last) < self::REFRESH_MIN_INTERVAL) { - return true; + // Seconds until the next refresh is allowed. + return self::REFRESH_MIN_INTERVAL - ($now - $last); } $this->SetBuffer('LastRefresh', (string) $now); - return false; + return 0; } /** @@ -661,6 +700,17 @@ private function setInstanceStatus(int $status): void } } + /** + * "Connected" mirrors the connection state Home Connect reports for the appliance. + * OperationState Inactive cannot tell this apart: a switched-off but connected + * appliance reports Inactive as well. + */ + private function setConnected(bool $connected): void + { + $this->MaintainVariable('Connected', $this->Translate('Connected'), VARIABLETYPE_BOOLEAN, 'HomeConnect.YesNo', 0, true); + $this->SetValue('Connected', $connected); + } + private function needsInitialization(): bool { if ($this->ReadPropertyString('HaID') == '') { @@ -711,6 +761,12 @@ private function createPrograms() $ident = 'SelectedProgram'; $this->MaintainVariable($ident, $this->Translate('Program'), VARIABLETYPE_STRING, $profileName, 1, true); $this->EnableAction($ident); + // The rebuild keeps only the listed programs. A program chosen at the appliance + // and still selected would show its raw key until the next event. + $selectedProgram = $this->GetValue($ident); + if (is_string($selectedProgram) && $selectedProgram != '') { + $this->ensureProgramAssociation($selectedProgram); + } return true; } @@ -921,6 +977,7 @@ private function updateOptionValues($program) $this->syncUseDurationVariable(false, 0); return; } + $this->ensureProgramAssociation($program['key']); $this->SetValue('SelectedProgram', $program['key']); $this->updateOptionVariables($program); $optionKeys = []; @@ -941,6 +998,7 @@ private function updateOptionValues($program) } $debugValue = is_bool($value) ? ($value ? 'true' : 'false') : $value; $this->SendDebug(__FUNCTION__, sprintf('Ident: %s, Value: %s', $ident, $debugValue), 0); + $this->ensureOptionAssociation($ident, $value); $this->SetValue($ident, $value); } } @@ -1015,7 +1073,7 @@ private function ensureProgramAssociation($key) // The snippet fallback would show the bare number ("003"). $displayName = sprintf($this->Translate('Favorite %d'), (int) $matches['number']); } else { - $displayName = $this->getLastSnippet($key); + $displayName = $this->getReadableName($key); } IPS_SetVariableProfileAssociation($profileName, $key, $displayName, '', -1); return $profileName; @@ -1066,11 +1124,51 @@ private function refreshActiveProgramOptions() } foreach ($pending as $ident => $value) { if (@IPS_GetObjectIDByIdent($ident, $this->InstanceID)) { + $this->ensureOptionAssociation($ident, $value); $this->SetValue($ident, $value); } } } + /** + * Makes sure a string option value has an association in the variable's profile, so + * it never shows the raw key. Values can arrive via event before any program listing + * them was loaded (forum t/124612 #554). Program keys (e.g. a favorite's BaseProgram) + * reuse the name from the Programs profile, anything else gets the last key snippet. + */ + private function ensureOptionAssociation($ident, $value) + { + if (!is_string($value) || $value == '') { + return; + } + $variable = IPS_GetVariable($this->GetIDForIdent($ident)); + $profileName = $variable['VariableProfile']; + if ($profileName == '' || !IPS_VariableProfileExists($profileName)) { + return; + } + $profile = IPS_GetVariableProfile($profileName); + if ($profile['ProfileType'] != VARIABLETYPE_STRING) { + return; + } + foreach ($profile['Associations'] as $association) { + if ($association['Value'] === $value) { + return; + } + } + $displayName = $this->getLastSnippet($value); + $programsProfile = 'HomeConnect.' . $this->ReadPropertyString('DeviceType') . '.Programs'; + if (IPS_VariableProfileExists($programsProfile)) { + foreach (IPS_GetVariableProfile($programsProfile)['Associations'] as $association) { + if ($association['Value'] === $value) { + $displayName = $association['Name']; + break; + } + } + } + $this->SendDebug(__FUNCTION__, sprintf('Added %s => %s to %s', $value, $displayName, $profileName), 0); + IPS_SetVariableProfileAssociation($profileName, $value, $displayName, '', -1); + } + /** * Enrich selected program data with the available-program metadata so variable * creation can rely on stable option types and constraints while keeping the @@ -1134,8 +1232,14 @@ private function createStates($states = '') if (!$states) { $data = json_decode($this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/status'), true); if (isset($data['error'])) { + // Home Connect answers /status of an offline appliance with this error. + // Other errors (e.g. 429) say nothing about the connection. + if (($data['error']['key'] ?? '') === 'SDK.Error.HomeAppliance.Connection.Initialization.Failed') { + $this->setConnected(false); + } return false; } + $this->setConnected(true); } else { $data = $states; } @@ -1220,6 +1324,16 @@ private function getLastSnippet($string) return substr($string, strrpos($string, '.') + 1, strlen($string) - strrpos($string, '.')); } + /** + * Readable name built from the last key snippet for keys without a known name + * (SaltNearlyEmpty -> "Salt Nearly Empty", Eco50 -> "Eco 50"). Digits form a word of + * their own; a capital right after a digit belongs to it (HotAir3D -> "Hot Air 3D"). + */ + private function getReadableName(string $key): string + { + return trim(preg_replace('/(?<=[a-z])(?=[A-Z0-9])|(?<=[0-9])(?=[A-Z][a-z])/', ' ', $this->getLastSnippet($key))); + } + private function createAssociations($profileName, $associations) { foreach ($associations as $association) { @@ -1319,18 +1433,30 @@ private function createVariableFromConstraints($profileName, $data, $attribute, IPS_CreateVariableProfile($profileName, $variableType); } $existingProfileType = IPS_GetVariableProfile($profileName)['ProfileType']; - if ($existingProfileType === VARIABLETYPE_INTEGER || $existingProfileType === VARIABLETYPE_FLOAT) { + if ($existingProfileType === VARIABLETYPE_INTEGER && $variableType === VARIABLETYPE_FLOAT) { + // An integer profile would cut the float values (4.5 °C -> 4). Give them a + // float profile of their own, as createStates does, and leave the existing + // profile alone. The other direction is lossless and follows the profile. + $this->SendDebug(__FUNCTION__, sprintf('Profile %s exists as integer; using %s.f for the float values', $profileName, $profileName), 0); + $profileName .= '.f'; + if (!IPS_VariableProfileExists($profileName)) { + IPS_CreateVariableProfile($profileName, VARIABLETYPE_FLOAT); + } + $existingProfileType = IPS_GetVariableProfile($profileName)['ProfileType']; + } + if ($existingProfileType === $variableType) { IPS_SetVariableProfileText($profileName, '', isset($data['unit']) ? ' ' . $data['unit'] : ''); $min = isset($constraints['min']) ? $constraints['min'] : 0; $max = isset($constraints['max']) ? $constraints['max'] : 86340; IPS_SetVariableProfileValues($profileName, $min, $max, isset($constraints['stepsize']) ? $constraints['stepsize'] : 1); $this->SendDebug('UpdatedProfile', $min . ' - ' . $max, 0); } else { - // A profile with this name already exists with an incompatible - // (non-numeric) type. Modifying its values would emit "String - // profiles cannot be modified". Keep the existing profile and match - // the variable to it instead of fighting the type. - $this->SendDebug(__FUNCTION__, sprintf('Profile %s already exists as type %d; skipping numeric setup', $profileName, $existingProfileType), 0); + // A profile with this name already exists with another type - non-numeric + // (modifying its values would emit "String profiles cannot be modified") + // or the other numeric type (Symcon rejects a variable whose type does + // not match its profile). Keep the existing profile and match the + // variable to it instead of fighting the type. + $this->SendDebug(__FUNCTION__, sprintf('Profile %s already exists as type %d; skipping profile setup', $profileName, $existingProfileType), 0); $variableType = $existingProfileType; } break; @@ -1499,54 +1625,88 @@ private function createEventProfile() $this->SendDebug('Profile', 'HomeConnect.Event.' . $deviceType, 0); if (!IPS_VariableProfileExists('HomeConnect.Event.' . $deviceType)) { IPS_CreateVariableProfile('HomeConnect.Event.' . $deviceType, VARIABLETYPE_STRING); - $associations = []; - if (in_array($deviceType, ['Dishwasher', 'CleaningRobot', 'CookProcessor'])) { - $associations[] = ['Value' => 'BSH.Common.Event.ProgramAborted', 'Name' => 'Program Aborted']; - } - if (in_array($deviceType, ['Dishwasher'])) { - $associations[] = ['Value' => 'Dishcare.Dishwasher.Event.RinseAidNearlyEmpty', 'Name' => 'Please fill RinseAid tank']; - } - if (in_array($deviceType, ['Oven', 'Dishwasher', 'Washer', 'Dryer', 'WasherDryer', 'Cooktop', 'Hood', 'CleaningRobot', 'CookProcessor'])) { - $associations[] = ['Value' => 'BSH.Common.Event.ProgramFinished', 'Name' => 'Program Finished']; - } - if (in_array($deviceType, ['Oven', 'Cooktop'])) { - $associations[] = ['Value' => 'BSH.Common.Event.AlarmClockElapsed', 'Name' => 'Alarm Clock Elapsed']; - $associations[] = ['Value' => 'BSH.Common.Event.PreheatFinished', 'Name' => 'Pre-heat Finished']; - } - if (in_array($deviceType, ['Hob'])) { - $associations[] = ['Value' => 'BSH.Common.Event.ProgramFinished', 'Name' => 'Program Finished']; - $associations[] = ['Value' => 'BSH.Common.Event.AlarmClockElapsed', 'Name' => 'Alarm Clock Elapsed']; - } - if (in_array($deviceType, ['CoffeeMaker'])) { - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.BeanContainerEmpty', 'Name' => 'Bean Container Empty']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.WaterTankEmpty', 'Name' => 'Water Tank Empty']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DripTrayFull', 'Name' => 'Drip Tray Full']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceShouldBeDescaled', 'Name' => 'Please descale device']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceDescalingOverdue', 'Name' => 'Descaling overdue']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceDescalingBlockage', 'Name' => 'Device blocked because of descaling overdue']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceShouldBeCleaned', 'Name' => 'Please clean device']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceCleaningOverdue', 'Name' => 'Cleaning overdue']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceShouldBeCalcNCleaned', 'Name' => 'Please calc n clean device']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceCalcNCleanOverdue', 'Name' => 'Device calc n clean overdue']; - $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceCalcNCleanBlockage', 'Name' => 'Device blocked because of calc n clean overdue']; - } - if (in_array($deviceType, ['FridgeFreezer', 'Freezer'])) { - $associations[] = ['Value' => 'Refrigeration.FridgeFreezer.Event.DoorAlarmFreezer', 'Name' => 'Door Alarm Freezer']; - $associations[] = ['Value' => 'Refrigeration.FridgeFreezer.Event.TemperatureAlarmFreezer', 'Name' => 'Temperature Alarm Freezer']; - } - if (in_array($deviceType, ['FridgeFreezer', 'Refrigerator'])) { - $associations[] = ['Value' => 'Refrigeration.FridgeFreezer.Event.DoorAlarmRefrigerator', 'Name' => 'Door Alarm Refrigerator']; - } - if (in_array($deviceType, ['CleaningRobot'])) { - $associations[] = ['Value' => 'ConsumerProducts.CleaningRobot.Event.EmptyDustBoxAndCleanFilter', 'Name' => 'Empty Dust Box and Clean Filter']; - $associations[] = ['Value' => 'ConsumerProducts.CleaningRobot.Event.RobotIsStuck', 'Name' => 'Robot is Stuck']; - $associations[] = ['Value' => 'ConsumerProducts.CleaningRobot.Event.DockingStationNotFound', 'Name' => 'Docking Station not Found']; - } - $this->createAssociations('HomeConnect.Event.' . $deviceType, $associations); + $this->createAssociations('HomeConnect.Event.' . $deviceType, $this->getEventAssociations($deviceType)); } } } + /** + * The profile is created only once, so events added in later builds (or not known at + * all) would show "N/A" on existing installations. Adds a missing association when the + * event arrives: known events get their translated name, unknown ones a readable name + * built from the key (SaltNearlyEmpty -> "Salt Nearly Empty"). + */ + private function ensureEventAssociation(string $key): void + { + $profileName = IPS_GetVariable($this->GetIDForIdent('Event'))['VariableProfile']; + if ($profileName == '' || !IPS_VariableProfileExists($profileName)) { + return; + } + foreach (IPS_GetVariableProfile($profileName)['Associations'] as $association) { + if ($association['Value'] === $key) { + return; + } + } + $displayName = $this->getReadableName($key); + foreach ($this->getEventAssociations($this->ReadPropertyString('DeviceType')) as $association) { + if ($association['Value'] === $key) { + $displayName = $this->Translate($association['Name']); + break; + } + } + $this->SendDebug(__FUNCTION__, sprintf('Added %s => %s to %s', $key, $displayName, $profileName), 0); + IPS_SetVariableProfileAssociation($profileName, $key, $displayName, '', -1); + } + + private function getEventAssociations(string $deviceType): array + { + $associations = []; + if (in_array($deviceType, ['Dishwasher', 'CleaningRobot', 'CookProcessor'])) { + $associations[] = ['Value' => 'BSH.Common.Event.ProgramAborted', 'Name' => 'Program Aborted']; + } + if (in_array($deviceType, ['Dishwasher'])) { + $associations[] = ['Value' => 'Dishcare.Dishwasher.Event.RinseAidNearlyEmpty', 'Name' => 'Please fill RinseAid tank']; + $associations[] = ['Value' => 'Dishcare.Dishwasher.Event.SaltNearlyEmpty', 'Name' => 'Please fill salt']; + } + if (in_array($deviceType, ['Oven', 'Dishwasher', 'Washer', 'Dryer', 'WasherDryer', 'Cooktop', 'Hood', 'CleaningRobot', 'CookProcessor'])) { + $associations[] = ['Value' => 'BSH.Common.Event.ProgramFinished', 'Name' => 'Program Finished']; + } + if (in_array($deviceType, ['Oven', 'Cooktop'])) { + $associations[] = ['Value' => 'BSH.Common.Event.AlarmClockElapsed', 'Name' => 'Alarm Clock Elapsed']; + $associations[] = ['Value' => 'BSH.Common.Event.PreheatFinished', 'Name' => 'Pre-heat Finished']; + } + if (in_array($deviceType, ['Hob'])) { + $associations[] = ['Value' => 'BSH.Common.Event.ProgramFinished', 'Name' => 'Program Finished']; + $associations[] = ['Value' => 'BSH.Common.Event.AlarmClockElapsed', 'Name' => 'Alarm Clock Elapsed']; + } + if (in_array($deviceType, ['CoffeeMaker'])) { + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.BeanContainerEmpty', 'Name' => 'Bean Container Empty']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.WaterTankEmpty', 'Name' => 'Water Tank Empty']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DripTrayFull', 'Name' => 'Drip Tray Full']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceShouldBeDescaled', 'Name' => 'Please descale device']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceDescalingOverdue', 'Name' => 'Descaling overdue']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceDescalingBlockage', 'Name' => 'Device blocked because of descaling overdue']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceShouldBeCleaned', 'Name' => 'Please clean device']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceCleaningOverdue', 'Name' => 'Cleaning overdue']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceShouldBeCalcNCleaned', 'Name' => 'Please calc n clean device']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceCalcNCleanOverdue', 'Name' => 'Device calc n clean overdue']; + $associations[] = ['Value' => 'ConsumerProducts.CoffeeMaker.Event.DeviceCalcNCleanBlockage', 'Name' => 'Device blocked because of calc n clean overdue']; + } + if (in_array($deviceType, ['FridgeFreezer', 'Freezer'])) { + $associations[] = ['Value' => 'Refrigeration.FridgeFreezer.Event.DoorAlarmFreezer', 'Name' => 'Door Alarm Freezer']; + $associations[] = ['Value' => 'Refrigeration.FridgeFreezer.Event.TemperatureAlarmFreezer', 'Name' => 'Temperature Alarm Freezer']; + } + if (in_array($deviceType, ['FridgeFreezer', 'Refrigerator'])) { + $associations[] = ['Value' => 'Refrigeration.FridgeFreezer.Event.DoorAlarmRefrigerator', 'Name' => 'Door Alarm Refrigerator']; + } + if (in_array($deviceType, ['CleaningRobot'])) { + $associations[] = ['Value' => 'ConsumerProducts.CleaningRobot.Event.EmptyDustBoxAndCleanFilter', 'Name' => 'Empty Dust Box and Clean Filter']; + $associations[] = ['Value' => 'ConsumerProducts.CleaningRobot.Event.RobotIsStuck', 'Name' => 'Robot is Stuck']; + $associations[] = ['Value' => 'ConsumerProducts.CleaningRobot.Event.DockingStationNotFound', 'Name' => 'Docking Station not Found']; + } + return $associations; + } + private function executeApplicanceCommand($command) { $availableCommands = $this->getAvailableCommands(); diff --git a/library.json b/library.json index b765bc4..1ca3021 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 24, - "date": 1785663591 + "build": 32, + "date": 1791464032 } diff --git a/tests/HomeConnectCloudTest.php b/tests/HomeConnectCloudTest.php index 85ad700..ab68334 100644 --- a/tests/HomeConnectCloudTest.php +++ b/tests/HomeConnectCloudTest.php @@ -13,6 +13,7 @@ class HomeConnectCloudTest extends TestCase { private const CLOUD_GUID = '{CE76810D-B685-9BE0-CC04-38B204DEAD5E}'; + private const STATUS_RATE_LIMITED = 201; //A 429 as it arrives through the SSE event stream (see cbeham's dump.txt). private const RATE_LIMIT_PAYLOAD = '{"error":{"key":"429","description":"The rate limit \"1000 calls in 1 day\" was reached. Requests are blocked during the remaining period of 18113 seconds."}}'; @@ -150,15 +151,24 @@ public function testInvalidTokenReconnectsEventStream() $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); $parent = $this->prepareParentIo($cloudID); + //The stream was running. + IPS_SetProperty($parent, 'Active', true); + IPS_ApplyChanges($parent); - //Seed a valid access token so the reconnect reuses it instead of hitting OAuth. + //A cached access token the server has just rejected. Without a refresh token the + //refresh fails before any network access - which proves a new token was requested. $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + $before = count(IPS\DebugServer::getDebugMessages($cloudID)); + ob_start(); $cloud->ReceiveData('{"error":{"key":"invalid_token","description":"The access token expired"}}'); + $output = ob_get_clean(); + $messages = array_column(array_slice(IPS\DebugServer::getDebugMessages($cloudID), $before), 'Message'); $this->assertFalse($this->invoke($cloud, 'isRateLimitActive'), 'invalid_token must not be treated as a rate limit'); - $this->assertTrue(IPS_GetProperty($parent, 'Active'), 'Stream must be re-registered (IO active) after invalid_token'); - $this->assertStringContainsString('homeappliances/events', IPS_GetProperty($parent, 'URL'), 'Reconnect must re-arm the /events request'); + $this->assertTrue(IPS_GetProperty($parent, 'Active'), 'The IO stays active after invalid_token'); + $this->assertContains('RegisterServerEventsError', $messages, 'Reconnect must request a fresh access token'); + $this->assertSame('', $output, 'ReceiveData must not echo - its output ends up in the log'); } /** @@ -176,6 +186,8 @@ public function testCheckServerEventsReconnectsWhenParentInactive() IPS_SetProperty($parent, 'Active', true); IPS_ApplyChanges($parent); $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + //A registered cloud has a refresh token. + $this->invoke($cloud, 'WriteAttributeString', 'Token', 'refresh'); //Last keep-alive is well over 60s old -> the stream is considered dead. $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); IPS\InstanceManager::setStatus($parent, IS_INACTIVE); @@ -254,6 +266,8 @@ public function testCheckServerEventsBacksOffWhileStreamStaysDead() IPS_SetProperty($parent, 'Active', true); IPS_ApplyChanges($parent); $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + //A registered cloud has a refresh token. + $this->invoke($cloud, 'WriteAttributeString', 'Token', 'refresh'); //First failure: reconnects immediately and schedules the next attempt in 120s. $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); @@ -289,6 +303,8 @@ public function testWatchdogBackoffCappedAtOneHour() IPS_SetProperty($parent, 'Active', true); IPS_ApplyChanges($parent); $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + //A registered cloud has a refresh token. + $this->invoke($cloud, 'WriteAttributeString', 'Token', 'refresh'); $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); $this->invoke($cloud, 'SetBuffer', 'WatchdogRetries', '10'); @@ -315,6 +331,222 @@ public function testKeepAliveResetsWatchdogBackoff() $this->assertSame('', (string) $this->invoke($cloud, 'GetBuffer', 'WatchdogNextRetry'), 'A keep-alive must clear the pending backoff window'); } + /** + * Review finding 1: a Symcon restart during a block resets the RateLimit timer to 0 + * (timers are runtime state), while RateLimitUntil and the deactivated IO persist. + * Once the block has expired, the keep-alive watchdog must lift the block and + * re-activate the IO - otherwise RegisterServerEvents only reports "IO instance is + * not active" and the event stream stays dead until the user intervenes. + */ + public function testWatchdogLiftsExpiredBlockAfterRestart() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + + //State after the restart: block expired, IO still switched off by applyRateLimit, + //RateLimit timer not running, keep-alive stale. + $this->invoke($cloud, 'WriteAttributeInteger', 'RateLimitUntil', time() - 10); + $this->assertFalse(IPS_GetProperty($parent, 'Active')); + $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); + + ob_start(); + $cloud->CheckServerEvents(); + $output = ob_get_clean(); + + $this->assertSame('', $output, 'The watchdog must not end up at "IO instance is not active"'); + $this->assertSame(0, $this->invoke($cloud, 'ReadAttributeInteger', 'RateLimitUntil'), 'The expired block must be cleared'); + $this->assertTrue(IPS_GetProperty($parent, 'Active'), 'The IO stopped for the block must be re-activated'); + $this->assertStringContainsString('homeappliances/events', IPS_GetProperty($parent, 'URL'), 'The event stream must be re-registered'); + } + + /** + * Counterpart to finding 1: an IO the user switched off on purpose (no block pending) + * must stay off - the watchdog only re-activates an IO the module stopped itself. + */ + public function testWatchdogLeavesIntentionallyInactiveIoAlone() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + $this->invoke($cloud, 'WriteAttributeString', 'Token', 'refresh'); + $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); + + ob_start(); + $cloud->CheckServerEvents(); + $output = ob_get_clean(); + + $this->assertFalse(IPS_GetProperty($parent, 'Active'), 'An IO switched off by the user must not be re-activated'); + //Review finding 10: output of a timer lands in the log as a warning - every attempt. + $this->assertSame('', $output, 'The watchdog must not echo while the IO is switched off on purpose'); + } + + /** + * Review finding 10: without a login (no refresh token) every watchdog attempt + * echoed "login is missing" from the timer, i.e. a log warning each time. + */ + public function testWatchdogSilentWithoutLogin() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + IPS_SetProperty($parent, 'Active', true); + IPS_ApplyChanges($parent); + $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); + + ob_start(); + $cloud->CheckServerEvents(); + $output = ob_get_clean(); + + $this->assertSame('', $output, 'The watchdog must not echo while the login is missing'); + } + + /** + * Review finding 6: ForceRegisterServerEvents activated the IO (first ApplyChanges, + * with the old URL/header) before it fetched a token. Without a usable token it must + * not switch the IO on at all - it would only collect 401s from /events. + */ + public function testForceRegisterDoesNotActivateIoWithoutToken() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + + ob_start(); + $cloud->ForceRegisterServerEvents(); + $output = ob_get_clean(); + + $this->assertStringContainsString('login is missing', $output, 'A manual registration still reports the missing login'); + $this->assertFalse(IPS_GetProperty($parent, 'Active'), 'The IO must not be activated without a token'); + } + + /** + * Counterpart to finding 6: if the stream cannot be resumed when the block ends, the + * expired block must stay pending, so the watchdog retries the reset later instead of + * leaving the IO switched off with nobody responsible for it. + */ + public function testResetRateLimitKeepsBlockPendingWhenStreamCannotResume() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + IPS_SetProperty($parent, 'Active', true); + IPS_ApplyChanges($parent); + + $cloud->ReceiveData(self::RATE_LIMIT_PAYLOAD); + //The RateLimit timer fires when the block has just expired. + $this->invoke($cloud, 'WriteAttributeInteger', 'RateLimitUntil', time() - 1); + ob_start(); + $cloud->ResetRateLimit(); + ob_end_clean(); + + $this->assertNotSame(0, $this->invoke($cloud, 'ReadAttributeInteger', 'RateLimitUntil'), 'The block must stay pending until the stream runs again'); + $this->assertFalse(IPS_GetProperty($parent, 'Active'), 'The IO stays off while no token is available'); + } + + /** + * Review finding 2: a 401 "invalid_token" from the stream means the server rejected + * the cached access token. The reconnect must not re-send that same token (it is + * still "valid" by its local expiry) - each such attempt costs a GET /events. + */ + public function testInvalidTokenDropsCachedAccessToken() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + + //Locally still valid for an hour, but the server has rejected it. + $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'rejected', 'Expires' => time() + 3600])); + //Without a refresh token the refresh fails before any network access. + $this->invoke($cloud, 'WriteAttributeString', 'Token', ''); + + ob_start(); + $cloud->ReceiveData('{"error":{"key":"invalid_token","description":"The access token expired"}}'); + ob_end_clean(); + + $this->assertStringNotContainsString('Bearer rejected', (string) IPS_GetProperty($parent, 'Headers'), 'The rejected token must not be sent again'); + $this->assertStringNotContainsString('rejected', (string) $this->invoke($cloud, 'GetBuffer', 'AccessToken'), 'The rejected token must be dropped from the cache'); + } + + /** + * Review finding 3: ResetRateLimit must re-activate the IO before the instance reports + * IS_ACTIVE. Children react to that status change with HasActiveParent(), which walks + * up to the IO; seeing it still inactive they go inactive and - because of their + * LastParentStatus guard - never retry. The stubs deliver no messages, so the order is + * checked on the debug trace: the stream registration ('url') must precede the status. + */ + public function testResetRateLimitActivatesIoBeforeStatus() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $this->prepareParentIo($cloudID); + $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + + $cloud->ReceiveData(self::RATE_LIMIT_PAYLOAD); + //The debug log is not reset between tests - only look at what ResetRateLimit writes. + $before = count(IPS\DebugServer::getDebugMessages($cloudID)); + $cloud->ResetRateLimit(); + + $messages = array_column(array_slice(IPS\DebugServer::getDebugMessages($cloudID), $before), 'Message'); + $registered = array_search('url', $messages, true); + $activated = array_search('ResetRateLimit', $messages, true); + $this->assertNotFalse($registered, 'ResetRateLimit must re-register the event stream'); + $this->assertNotFalse($activated, 'ResetRateLimit must trace when the instance becomes active'); + $this->assertLessThan($activated, $registered, 'The IO must be active before the instance reports IS_ACTIVE'); + } + + /** + * Upstream review of PR #20: since build 30 an expired block stays pending when the + * stream cannot be resumed (no token). When the user then registers again, + * ProcessOAuthData only re-registers the stream - nothing cleared the block, so the + * stream ran while the cloud stayed at 201 and the devices stayed inactive. Any + * successful stream registration must lift an expired block. + */ + public function testReRegistrationLiftsPendingBlock() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + + $cloud->ReceiveData(self::RATE_LIMIT_PAYLOAD); + //The block expired, but the reset could not fetch a token (login revoked). + $this->invoke($cloud, 'WriteAttributeInteger', 'RateLimitUntil', time() - 1); + ob_start(); + $cloud->ResetRateLimit(); + ob_end_clean(); + $this->assertSame(self::STATUS_RATE_LIMITED, IPS_GetInstance($cloudID)['InstanceStatus'], 'Precondition: the block is still pending'); + + //The user registers again: a token is available, ProcessOAuthData re-registers. + $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + $cloud->ForceRegisterServerEvents(); + + $this->assertTrue(IPS_GetProperty($parent, 'Active'), 'The event stream runs again'); + $this->assertSame(0, $this->invoke($cloud, 'ReadAttributeInteger', 'RateLimitUntil'), 'The expired block must be lifted with the stream'); + $this->assertSame('', $this->invoke($cloud, 'ReadAttributeString', 'RateError'), 'The notice must be cleared'); + $this->assertSame(IS_ACTIVE, IPS_GetInstance($cloudID)['InstanceStatus'], 'The instance must leave the rate-limited status'); + } + + /** + * Counterpart: a block that is still running is not lifted by a registration - the + * registration is deferred instead (see testRegisterServerEventsDefersWhileRateLimited). + */ + public function testReRegistrationKeepsRunningBlock() + { + $cloudID = IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]; + $cloud = IPS\InstanceManager::getInstanceInterface($cloudID); + $parent = $this->prepareParentIo($cloudID); + $this->invoke($cloud, 'SetBuffer', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + + $cloud->ReceiveData(self::RATE_LIMIT_PAYLOAD); + $cloud->ForceRegisterServerEvents(); + + $this->assertFalse(IPS_GetProperty($parent, 'Active'), 'The IO stays off during the block'); + $this->assertNotSame(0, $this->invoke($cloud, 'ReadAttributeInteger', 'RateLimitUntil'), 'A running block is kept'); + $this->assertSame(self::STATUS_RATE_LIMITED, IPS_GetInstance($cloudID)['InstanceStatus']); + } + private function cloud() { return IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]); diff --git a/tests/HomeConnectCoffeeTest.php b/tests/HomeConnectCoffeeTest.php index 992a408..60d1980 100644 --- a/tests/HomeConnectCoffeeTest.php +++ b/tests/HomeConnectCoffeeTest.php @@ -24,7 +24,8 @@ class HomeConnectCoffeeTest extends TestCase 'RemoteControlStartAllowed' => 'Yes', 'LocalControlActive' => 'No', 'Event' => '', - 'EventDescription' => '' + 'EventDescription' => '', + 'Connected' => 'Yes' ]; public const ESPRESSO = [ @@ -39,7 +40,8 @@ class HomeConnectCoffeeTest extends TestCase 'RemoteControlStartAllowed' => 'Yes', 'LocalControlActive' => 'No', 'Event' => '', - 'EventDescription' => '' + 'EventDescription' => '', + 'Connected' => 'Yes' ]; protected function setUp(): void @@ -137,6 +139,65 @@ public function testSelectedProgramEventRefreshesViaRequestAction() $this->assertEquals(self::COFFEE, $this->getChildrenValues($coffeMaker), 'RefreshSelectedProgram action must refresh the options'); } + /** + * Regression for forum t/124612 #554: option profiles are shared by all programs of a + * device type and hold the value list of the last loaded program only - the list feeds + * the selection, so it must stay exact. A value of another program must nevertheless + * be shown readable. Values from the real API responses: only EspressoMachiato offers + * BeanAmount.TripleShotPlus, Espresso offers 14 other bean amounts. + */ + public function testOptionOfOtherProgramIsReadableWhileSelectionStaysExact() + { + $coffeMaker = $this->createCoffeeMaker(); + $intf = IPS\InstanceManager::getInstanceInterface($coffeMaker); + + //An ActiveProgram event loads the EspressoMachiato options (incl. TripleShotPlus). + $intf->ReceiveData($this->notify('BSH.Common.Root.ActiveProgram', 'ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMachiato')); + + //Switching to Espresso replaces the BeanAmount list with Espresso's values. + IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID('{CE76810D-B685-9BE0-CC04-38B204DEAD5E}')[0])->selectedProgram = 'Espresso'; + $intf->ReceiveData($this->notify('BSH.Common.Root.SelectedProgram', 'ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso')); + $profileName = IPS_GetVariable(IPS_GetObjectIDByIdent('OptionBeanAmount', $coffeMaker))['VariableProfile']; + $values = array_column(IPS_GetVariableProfile($profileName)['Associations'], 'Value'); + $this->assertCount(14, $values, 'The selection must offer exactly the values of the selected program'); + $this->assertNotContains('ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShotPlus', $values); + + $intf->ReceiveData($this->notify('ConsumerProducts.CoffeeMaker.Option.BeanAmount', 'ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShotPlus')); + $this->assertSame('TripleShotPlus', $this->formatValue(IPS_GetObjectIDByIdent('OptionBeanAmount', $coffeMaker))); + } + + /** + * Regression for forum t/124612 #554: an option value reported via event that is not + * (yet) in the profile must still get a readable name instead of the raw key. + */ + public function testUnknownOptionValueGetsReadableName() + { + $coffeMaker = $this->createCoffeeMaker(); + $intf = IPS\InstanceManager::getInstanceInterface($coffeMaker); + + $intf->ReceiveData($this->notify('ConsumerProducts.CoffeeMaker.Option.BeanAmount', 'ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShotPlus')); + $this->assertSame('TripleShotPlus', $this->formatValue(IPS_GetObjectIDByIdent('OptionBeanAmount', $coffeMaker))); + } + + private function createCoffeeMaker(): int + { + IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID('{CE76810D-B685-9BE0-CC04-38B204DEAD5E}')[0])->selectedProgram = 'Coffee'; + $coffeMaker = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + IPS_SetProperty($coffeMaker, 'HaID', 'SIEMENS-TI9575X1DE-68A40E251CAD'); + IPS_SetProperty($coffeMaker, 'DeviceType', 'CoffeMaker'); + IPS_ApplyChanges($coffeMaker); + return $coffeMaker; + } + + private function notify(string $key, string $value): string + { + return json_encode([ + 'Event' => 'NOTIFY', + 'Data' => json_encode(['items' => [['handling' => 'none', 'key' => $key, 'value' => $value, 'level' => 'hint']], 'haId' => 'SIEMENS-TI9575X1DE-68A40E251CAD']), + 'ID' => 'SIEMENS-TI9575X1DE-68A40E251CAD' + ]); + } + private function displayChildrenValues($id) { $children = IPS_GetChildrenIDs($id); @@ -179,9 +240,9 @@ private function formatValue(int $variableID): string case VARIABLETYPE_BOOLEAN: return $value ? $profile['Associations'][1]['Name'] : $profile['Associations'][0]['Name']; case VARIABLETYPE_STRING: - for ($i = count($profile['Associations']) - 1; $i >= 0; $i--) { - if ($value == $profile['Associations'][$i]['Value']) { - return $profile['Prefix'] . sprintf($profile['Associations'][$i]['Name'], $value) . $profile['Suffix']; + foreach (array_reverse($profile['Associations']) as $association) { + if ($value == $association['Value']) { + return $profile['Prefix'] . sprintf($association['Name'], $value) . $profile['Suffix']; } } return '-'; diff --git a/tests/HomeConnectConfiguratorTest.php b/tests/HomeConnectConfiguratorTest.php new file mode 100644 index 0000000..122d991 --- /dev/null +++ b/tests/HomeConnectConfiguratorTest.php @@ -0,0 +1,68 @@ +configuratorID = IPS_CreateInstance('{CA0E667D-8F28-8DF1-2750-5CF587ECA85A}'); + + parent::setUp(); + } + + /** + * Review finding 9: with a second cloud instance (a second Home Connect account), + * the configurator listed that account's devices as well - deletable from here. + */ + public function testExistingRowsOnlyFromOwnCloud() + { + $ownCloud = IPS_GetInstance($this->configuratorID)['ConnectionID']; + $foreignCloud = IPS_CreateInstance(self::CLOUD_GUID); + + $ownDevice = $this->createDevice($ownCloud, 'OWN-DEVICE'); + $foreignDevice = $this->createDevice($foreignCloud, 'FOREIGN-DEVICE'); + + //No live discovery: only the rows of the existing instances are listed. + IPS\InstanceManager::setStatus($ownCloud, IS_INACTIVE); + $form = json_decode(IPS\InstanceManager::getInstanceInterface($this->configuratorID)->GetConfigurationForm(), true); + $listed = array_column($form['actions'][0]['values'], 'instanceID'); + + $this->assertContains($ownDevice, $listed, 'A device of the own cloud is listed'); + $this->assertNotContains($foreignDevice, $listed, 'A device of another cloud must not be listed'); + } + + private function createDevice(int $cloudID, string $haID): int + { + $device = IPS_CreateInstance(self::DEVICE_GUID); + IPS\InstanceManager::connectInstance($device, $cloudID); + //Parent not active: the device does not try to reach the (stubbed) cloud. + IPS\InstanceManager::setStatus($cloudID, IS_INACTIVE); + IPS_SetProperty($device, 'HaID', $haID); + IPS_ApplyChanges($device); + return $device; + } +} diff --git a/tests/HomeConnectConnectedTest.php b/tests/HomeConnectConnectedTest.php new file mode 100644 index 0000000..771b081 --- /dev/null +++ b/tests/HomeConnectConnectedTest.php @@ -0,0 +1,189 @@ +createDevice(self::ONLINE_HAID, 'Dryer'); + + $connectedID = @IPS_GetObjectIDByIdent('Connected', $device); + $this->assertNotFalse($connectedID, 'Every device gets a Connected variable'); + $this->assertSame(VARIABLETYPE_BOOLEAN, IPS_GetVariable($connectedID)['VariableType']); + $this->assertTrue(GetValue($connectedID), 'A device answering /status is connected'); + } + + public function testOfflineDeviceIsNotConnected() + { + $device = $this->createDevice(self::OFFLINE_HAID, 'Washer'); + + $connectedID = @IPS_GetObjectIDByIdent('Connected', $device); + $this->assertNotFalse($connectedID, 'The Connected variable also exists for a device that is offline at setup'); + $this->assertFalse(GetValue($connectedID), 'A device reported as offline by /status is not connected'); + } + + public function testDisconnectedAndConnectedEventsToggleConnected() + { + $device = $this->createDevice(self::ONLINE_HAID, 'Dryer'); + $intf = IPS\InstanceManager::getInstanceInterface($device); + $connectedID = IPS_GetObjectIDByIdent('Connected', $device); + + $intf->ReceiveData(json_encode(['Event' => 'DISCONNECTED', 'Data' => '', 'ID' => self::ONLINE_HAID])); + $this->assertFalse(GetValue($connectedID), 'DISCONNECTED clears Connected'); + //Unchanged behavior: OperationState still falls back to Inactive. + $this->assertSame('BSH.Common.EnumType.OperationState.Inactive', GetValue(IPS_GetObjectIDByIdent('OperationState', $device))); + + //The refresh throttle is still active here; Connected must follow the event anyway. + $intf->ReceiveData(json_encode(['Event' => 'CONNECTED', 'Data' => '', 'ID' => self::ONLINE_HAID])); + $this->assertTrue(GetValue($connectedID), 'CONNECTED sets Connected again'); + } + + public function testConnectedDoesNotChangeInstanceStatus() + { + $device = $this->createDevice(self::OFFLINE_HAID, 'Washer'); + $this->assertEquals(IS_ACTIVE, IPS_GetInstance($device)['InstanceStatus'], 'An offline appliance is no instance error'); + + $intf = IPS\InstanceManager::getInstanceInterface($device); + $intf->ReceiveData(json_encode(['Event' => 'DISCONNECTED', 'Data' => '', 'ID' => self::OFFLINE_HAID])); + $this->assertEquals(IS_ACTIVE, IPS_GetInstance($device)['InstanceStatus']); + } + + /** + * Review finding 4: an appliance that is offline when its instance is set up keeps + * Initialized = false. When it comes online within REFRESH_MIN_INTERVAL, the CONNECTED + * refresh is throttled - and nothing ever retried the pending initialization, so the + * instance stayed without its variables. A throttled init must schedule a retry. + */ + public function testThrottledInitializationIsRetried() + { + $device = $this->createDevice(self::OFFLINE_HAID, 'Washer'); + $intf = IPS\InstanceManager::getInstanceInterface($device); + $this->assertFalse($this->invoke($intf, 'ReadAttributeBoolean', 'Initialized'), 'Setup of an offline appliance does not initialize'); + + //The appliance comes online right after the setup. + $intf->ReceiveData(json_encode(['Event' => 'CONNECTED', 'Data' => '', 'ID' => self::OFFLINE_HAID])); + + $retry = $this->invoke($intf, 'GetTimerInterval', 'RetryRefresh'); + $this->assertGreaterThan(0, $retry, 'A throttled initialization must be retried'); + $this->assertLessThanOrEqual(31000, $retry, 'The retry follows once the throttle window has passed'); + } + + /** + * Counterpart to finding 4: a throttled value refresh of an initialized appliance needs + * no retry - live STATUS/NOTIFY events keep its values current. + */ + public function testThrottledValueRefreshIsNotRetried() + { + $device = $this->createDevice(self::ONLINE_HAID, 'Dryer'); + $intf = IPS\InstanceManager::getInstanceInterface($device); + + $intf->ReceiveData(json_encode(['Event' => 'CONNECTED', 'Data' => '', 'ID' => self::ONLINE_HAID])); + + //The stub reports a stopped timer as 0 minus the elapsed time. + $this->assertLessThanOrEqual(0, $this->invoke($intf, 'GetTimerInterval', 'RetryRefresh'), 'No retry for a value refresh'); + } + + /** + * Upstream review of PR #20: refreshDeviceState() cleared the retry timer on every + * call, so a throttled value refresh in the same window dropped a pending + * initialization retry without re-arming it. The retry must survive. + */ + public function testThrottledValueRefreshKeepsPendingRetry() + { + $device = $this->createDevice(self::OFFLINE_HAID, 'Washer'); + $intf = IPS\InstanceManager::getInstanceInterface($device); + $intf->ReceiveData(json_encode(['Event' => 'CONNECTED', 'Data' => '', 'ID' => self::OFFLINE_HAID])); + $this->assertGreaterThan(0, $this->invoke($intf, 'GetTimerInterval', 'RetryRefresh'), 'Precondition: a retry is pending'); + + //A value refresh within the throttle window (e.g. a parent status flap). + $this->invoke($intf, 'refreshDeviceState', false, 'test'); + + $this->assertGreaterThan(0, $this->invoke($intf, 'GetTimerInterval', 'RetryRefresh'), 'The pending retry must not be lost'); + } + + /** + * Upstream review of PR #20: the retry ran through the CONNECTED action and showed up + * in the debug log as "Event:CONNECTED (deferred)". It must name its own trigger. + */ + public function testRetryRunsUnderItsOwnLabel() + { + $device = $this->createDevice(self::OFFLINE_HAID, 'Washer'); + $intf = IPS\InstanceManager::getInstanceInterface($device); + $intf->ReceiveData(json_encode(['Event' => 'CONNECTED', 'Data' => '', 'ID' => self::OFFLINE_HAID])); + + $before = count(IPS\DebugServer::getDebugMessages($device)); + //What the RetryRefresh timer runs. + $intf->RequestAction('RetryRefreshDeviceState', ''); + + $traces = []; + foreach (array_slice(IPS\DebugServer::getDebugMessages($device), $before) as $message) { + if ($message['Message'] === 'refreshDeviceState' && strpos($message['Data'], 'trigger: ') === 0) { + $traces[] = $message['Data']; + } + } + $this->assertCount(1, $traces, 'The retry runs exactly one refresh'); + $this->assertStringStartsWith('trigger: RetryRefresh,', $traces[0], 'The retry must be traced under its own trigger'); + } + + private function invoke($object, string $method, ...$args) + { + $ref = new ReflectionMethod($object, $method); + $ref->setAccessible(true); + return $ref->invoke($object, ...$args); + } + + private function createDevice(string $haID, string $deviceType): int + { + $device = IPS_CreateInstance(self::DEVICE_GUID); + $parent = IPS_GetInstance($device)['ConnectionID']; + IPS\InstanceManager::getInstanceInterface($parent)->selectedProgram = 'Cotton'; + IPS\InstanceManager::setStatus($parent, IS_ACTIVE); + + IPS_SetProperty($device, 'HaID', $haID); + IPS_SetProperty($device, 'DeviceType', $deviceType); + IPS_ApplyChanges($device); + + return $device; + } +} diff --git a/tests/HomeConnectDishwasherEventTest.php b/tests/HomeConnectDishwasherEventTest.php new file mode 100644 index 0000000..51efffa --- /dev/null +++ b/tests/HomeConnectDishwasherEventTest.php @@ -0,0 +1,120 @@ +createDishwasher(); + $intf = IPS\InstanceManager::getInstanceInterface($dishwasher); + + $intf->ReceiveData($this->event(self::SALT_EVENT)); + + $eventID = IPS_GetObjectIDByIdent('Event', $dishwasher); + $this->assertSame('Dishcare.Dishwasher.Event.SaltNearlyEmpty', GetValue($eventID)); + $this->assertSame('Please fill salt', $this->associationName($eventID)); + } + + public function testProfileFromOlderBuildIsCompletedOnEvent() + { + //Profile as found on the nuc: created by an older build, only two associations. + IPS_CreateVariableProfile('HomeConnect.Event.Dishwasher', VARIABLETYPE_STRING); + IPS_SetVariableProfileAssociation('HomeConnect.Event.Dishwasher', 'BSH.Common.Event.ProgramAborted', 'Program abgebrochen', '', -1); + IPS_SetVariableProfileAssociation('HomeConnect.Event.Dishwasher', 'BSH.Common.Event.ProgramFinished', 'Program fertig', '', -1); + + $dishwasher = $this->createDishwasher(); + $intf = IPS\InstanceManager::getInstanceInterface($dishwasher); + + //Same structure as pitti's line, key of an event the module already knows. + $intf->ReceiveData($this->event(str_replace('SaltNearlyEmpty', 'RinseAidNearlyEmpty', self::SALT_EVENT))); + + $this->assertSame('Please fill RinseAid tank', $this->associationName(IPS_GetObjectIDByIdent('Event', $dishwasher))); + } + + public function testUnknownEventFallsBackToKeyName() + { + $dishwasher = $this->createDishwasher(); + $intf = IPS\InstanceManager::getInstanceInterface($dishwasher); + + //Not a real Home Connect key: stands for any event the module does not know yet. + $intf->ReceiveData($this->event(str_replace('SaltNearlyEmpty', 'SomeFutureEvent', self::SALT_EVENT))); + + $this->assertSame('Some Future Event', $this->associationName(IPS_GetObjectIDByIdent('Event', $dishwasher))); + } + + private function createDishwasher(): int + { + $dishwasher = IPS_CreateInstance(self::DEVICE_GUID); + $parent = IPS_GetInstance($dishwasher)['ConnectionID']; + IPS\InstanceManager::getInstanceInterface($parent)->selectedProgram = 'Auto2'; + IPS\InstanceManager::setStatus($parent, IS_ACTIVE); + + IPS_SetProperty($dishwasher, 'HaID', self::HAID); + IPS_SetProperty($dishwasher, 'DeviceType', 'Dishwasher'); + IPS_ApplyChanges($dishwasher); + + return $dishwasher; + } + + /** + * Name of the profile association matching the current value. The stubs neither + * translate nor resolve profiles behind a presentation in GetValueFormatted, so the + * association is read directly (names stay English here, see locale.json). + */ + private function associationName(int $variableID): string + { + $variable = IPS_GetVariable($variableID); + foreach (IPS_GetVariableProfile($variable['VariableProfile'])['Associations'] as $association) { + if ($association['Value'] === $variable['VariableValue']) { + return $association['Name']; + } + } + return '-'; + } + + private function event(string $data): string + { + return json_encode(['Event' => 'EVENT', 'Data' => $data, 'ID' => self::HAID]); + } +} diff --git a/tests/HomeConnectDryerTest.php b/tests/HomeConnectDryerTest.php index dd1a659..b4fcded 100644 --- a/tests/HomeConnectDryerTest.php +++ b/tests/HomeConnectDryerTest.php @@ -23,7 +23,8 @@ class HomeConnectDryerTest extends TestCase 'RemoteControlActive' => 'Yes', 'RemoteControlStartAllowed' => 'Yes', 'Event' => '-', - 'EventDescription' => '' + 'EventDescription' => '', + 'Connected' => 'Yes' ]; public const TIME_COLD = [ @@ -38,7 +39,8 @@ class HomeConnectDryerTest extends TestCase 'RemoteControlActive' => 'Yes', 'RemoteControlStartAllowed' => 'Yes', 'Event' => '-', - 'EventDescription' => '' + 'EventDescription' => '', + 'Connected' => 'Yes' ]; protected function setUp(): void diff --git a/tests/HomeConnectFridgeFreezerTest.php b/tests/HomeConnectFridgeFreezerTest.php index bf5d7c7..b209ee4 100644 --- a/tests/HomeConnectFridgeFreezerTest.php +++ b/tests/HomeConnectFridgeFreezerTest.php @@ -190,4 +190,28 @@ public function testConnectedEventRefreshesViaDeferredAction() $this->assertSame(2, HomeConnectCloud::$requestCount, 'CONNECTED must refresh via the deferred RefreshDeviceState action'); $this->assertEquals(IS_ACTIVE, IPS_GetInstance($fridge)['InstanceStatus']); } + + /** + * Review finding 7: an option or setting profile that already exists with the other + * numeric type (INTEGER vs. FLOAT) passed the type guard, so the variable was created + * with its own type against a mismatching profile - Symcon rejects that and the + * variable is not created. The variable must follow the existing profile instead. + * The setting is real (/settings of the fridge); only the stale profile is staged. + */ + public function testNumericSettingFollowsExistingProfileType() + { + $profile = 'Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator'; + IPS_CreateVariableProfile($profile, VARIABLETYPE_FLOAT); + + $fridge = IPS_CreateInstance(self::DEVICE_GUID); + $parent = IPS_GetInstance($fridge)['ConnectionID']; + IPS\InstanceManager::setStatus($parent, IS_ACTIVE); + IPS_SetProperty($fridge, 'HaID', self::FRIDGE_HAID); + IPS_SetProperty($fridge, 'DeviceType', 'FridgeFreezer'); + IPS_ApplyChanges($fridge); + + $variableID = @IPS_GetObjectIDByIdent('SetpointTemperatureRefrigerator', $fridge); + $this->assertNotFalse($variableID, 'The setting variable must be created despite the existing profile'); + $this->assertSame(VARIABLETYPE_FLOAT, IPS_GetVariable($variableID)['VariableType'], 'The variable follows the type of the existing profile'); + } } diff --git a/tests/HomeConnectOvenTest.php b/tests/HomeConnectOvenTest.php index 6defa2b..2aff37f 100644 --- a/tests/HomeConnectOvenTest.php +++ b/tests/HomeConnectOvenTest.php @@ -31,6 +31,41 @@ protected function setUp(): void parent::setUp(); } + /** + * Upstream review of PR #20: an option profile that already exists as INTEGER while the + * API reports a Double made the variable an INTEGER too (build 30), so 4.5 would be cut + * to 4. The lossy direction gets a float profile of its own (".f", as createStates does) + * and a float variable. The option is the setpoint of the oven HM778GMB1 (real capture + * under tests/homeappliances/385110390226001436-001); only the stale profile is staged. + */ + public function testFloatOptionAgainstIntegerProfileGetsFloatProfile() + { + $profile = 'HomeConnect.Oven.Option.SetpointTemperature'; + IPS_CreateVariableProfile($profile, VARIABLETYPE_INTEGER); + + $oven = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + $intf = IPS\InstanceManager::getInstanceInterface($oven); + $program = json_decode(file_get_contents(__DIR__ . '/homeappliances/385110390226001436-001/programs/available/Cooking.Oven.Program.HeatingMode.HotAir/response.json'), true); + $option = $program['data']['options'][0]; + $this->assertSame('Cooking.Oven.Option.SetpointTemperature', $option['key']); + $this->assertSame('Double', $option['type']); + + $create = new ReflectionMethod($intf, 'createVariableFromConstraints'); + $create->setAccessible(true); + $create->invoke($intf, $profile, $option, 'Option', 10); + + $variableID = @IPS_GetObjectIDByIdent('OptionSetpointTemperature', $oven); + $this->assertNotFalse($variableID, 'The option variable must be created'); + $this->assertSame(VARIABLETYPE_FLOAT, IPS_GetVariable($variableID)['VariableType'], 'A Double option must not be cut to an integer'); + $this->assertSame($profile . '.f', IPS_GetVariable($variableID)['VariableProfile'], 'The float variable gets the float profile'); + $floatProfile = IPS_GetVariableProfile($profile . '.f'); + $this->assertSame(VARIABLETYPE_FLOAT, $floatProfile['ProfileType']); + $this->assertEquals(30, $floatProfile['MinValue']); + $this->assertEquals(275, $floatProfile['MaxValue']); + $this->assertSame(' °C', $floatProfile['Suffix']); + $this->assertSame(VARIABLETYPE_INTEGER, IPS_GetVariableProfile($profile)['ProfileType'], 'The existing profile is left alone'); + } + public function testBaseFunctionality() { $cloudInterface = IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID('{CE76810D-B685-9BE0-CC04-38B204DEAD5E}')[0]); diff --git a/tests/HomeConnectWasherDryerTest.php b/tests/HomeConnectWasherDryerTest.php new file mode 100644 index 0000000..8f5b3f1 --- /dev/null +++ b/tests/HomeConnectWasherDryerTest.php @@ -0,0 +1,153 @@ +ConfiguratorID = IPS_CreateInstance('{CA0E667D-8F28-8DF1-2750-5CF587ECA85A}'); + $this->CloudID = IPS_CreateInstance('{CE76810D-B685-9BE0-CC04-38B204DEAD5E}'); + IPS_ConnectInstance($this->ConfiguratorID, $this->CloudID); + IPS\InstanceManager::setStatus($this->ConfiguratorID, 102); + IPS\InstanceManager::setStatus($this->CloudID, 102); + + parent::setUp(); + } + + public function testProgramProfileContainsTheListedPrograms() + { + $this->createWasherDryer(); + + $this->assertEquals([ + 'LaundryCare.WasherDryer.Program.Cotton.Eco4060' => 'Eco 40-60', + 'LaundryCare.WasherDryer.Program.Cotton' => 'Baumwolle', + 'LaundryCare.WasherDryer.Program.Mix' => 'Schnell/Mix', + ], $this->associations()); + } + + /** + * A program selected at the appliance is reported via SelectedProgram although the + * API does not list it. Without an association the variable shows the raw key. + */ + public function testSelectedProgramEventAddsReadableAssociation() + { + $washerDryer = $this->createWasherDryer(); + $intf = IPS\InstanceManager::getInstanceInterface($washerDryer); + + $intf->ReceiveData($this->generateNotifyEvent('BSH.Common.Root.SelectedProgram', 'LaundryCare.WasherDryer.Program.PlushToy', 'programs/selected')); + + $this->assertEquals('LaundryCare.WasherDryer.Program.PlushToy', GetValue(IPS_GetObjectIDByIdent('SelectedProgram', $washerDryer))); + $this->assertSame('Plush Toy', $this->associations()['LaundryCare.WasherDryer.Program.PlushToy'] ?? null); + } + + /** + * ApplyChanges (e.g. on every kernel start) rebuilds the profile from the API list. + * The currently selected program must keep its association. + */ + public function testApplyChangesKeepsAssociationOfSelectedProgram() + { + $washerDryer = $this->createWasherDryer(); + $intf = IPS\InstanceManager::getInstanceInterface($washerDryer); + $intf->ReceiveData($this->generateNotifyEvent('BSH.Common.Root.SelectedProgram', 'LaundryCare.WasherDryer.Program.ShirtsBlouses', 'programs/selected')); + + IPS_ApplyChanges($washerDryer); + + $this->assertEquals('LaundryCare.WasherDryer.Program.ShirtsBlouses', GetValue(IPS_GetObjectIDByIdent('SelectedProgram', $washerDryer))); + $this->assertSame('Shirts Blouses', $this->associations()['LaundryCare.WasherDryer.Program.ShirtsBlouses'] ?? null); + $this->assertSame('Schnell/Mix', $this->associations()['LaundryCare.WasherDryer.Program.Mix'] ?? null); + } + + public function testActiveProgramEventGetsReadableName() + { + $washerDryer = $this->createWasherDryer(); + $intf = IPS\InstanceManager::getInstanceInterface($washerDryer); + + $intf->ReceiveData($this->generateNotifyEvent('BSH.Common.Root.ActiveProgram', 'LaundryCare.WasherDryer.Program.DelicatesSilk', 'programs/active')); + + $this->assertSame('Delicates Silk', $this->associations()['LaundryCare.WasherDryer.Program.DelicatesSilk'] ?? null); + } + + /** + * Upstream review of PR #20: a digit followed by a capital split oddly, "HotAir3D" + * became "Hot Air3 D". Digits form their own word, a capital after a digit does not. + */ + public function testReadableNameKeepsDigitGroupsTogether() + { + $intf = IPS\InstanceManager::getInstanceInterface($this->createWasherDryer()); + $readableName = new ReflectionMethod($intf, 'getReadableName'); + $readableName->setAccessible(true); + + //Keys of the washer WAV28G43 and the dishwasher (tests/homeappliances), example from the review. + $this->assertSame('IDos 1 Base Level', $readableName->invoke($intf, 'LaundryCare.Washer.Setting.IDos1BaseLevel')); + $this->assertSame('Eco 50', $readableName->invoke($intf, 'Dishcare.Dishwasher.Program.Eco50')); + $this->assertSame('Hot Air 3D', $readableName->invoke($intf, 'Cooking.Oven.Program.HeatingMode.HotAir3D')); + } + + private function createWasherDryer() + { + $washerDryer = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + IPS_ConnectInstance($washerDryer, $this->ConfiguratorID); + IPS_SetProperty($washerDryer, 'HaID', self::HA_ID); + IPS_SetProperty($washerDryer, 'DeviceType', 'WasherDryer'); + IPS_ApplyChanges($washerDryer); + return $washerDryer; + } + + private function associations() + { + $associations = []; + foreach (IPS_GetVariableProfile(self::PROFILE)['Associations'] as $association) { + $associations[$association['Value']] = $association['Name']; + } + return $associations; + } + + private function generateNotifyEvent($key, $value, $uriPath) + { + return json_encode([ + 'Event' => 'NOTIFY', + 'Data' => json_encode([ + 'items' => [[ + 'timestamp' => 1759561437, + 'handling' => 'none', + 'uri' => '/api/homeappliances/' . self::HA_ID . '/' . $uriPath, + 'key' => $key, + 'value' => $value, + 'level' => 'hint', + ]], + 'haId' => self::HA_ID, + ]), + 'id' => self::HA_ID, + ]); + } +} diff --git a/tests/homeappliances/015070396331014803/programs/available/Dishcare.Dishwasher.Program.Auto2/response.json b/tests/homeappliances/015070396331014803/programs/available/Dishcare.Dishwasher.Program.Auto2/response.json new file mode 100644 index 0000000..9ccb825 --- /dev/null +++ b/tests/homeappliances/015070396331014803/programs/available/Dishcare.Dishwasher.Program.Auto2/response.json @@ -0,0 +1,64 @@ +{ + "data": { + "key": "Dishcare.Dishwasher.Program.Auto2", + "name": "Auto 45-65°", + "options": [ + { + "name": "Startzeit", + "key": "BSH.Common.Option.StartInRelative", + "constraints": { + "min": 0, + "max": 86400, + "liveupdate": true + }, + "unit": "seconds", + "type": "Int" + }, + { + "key": "Dishcare.Dishwasher.Option.HygienePlus", + "type": "Boolean", + "constraints": { + "default": false, + "liveupdate": true + }, + "name": "HygienePlus" + }, + { + "key": "Dishcare.Dishwasher.Option.HalfLoad", + "type": "Boolean", + "constraints": { + "default": false, + "liveupdate": true + }, + "name": "Halbe Beladung" + }, + { + "key": "Dishcare.Dishwasher.Option.IntensivZone", + "type": "Boolean", + "constraints": { + "default": false, + "liveupdate": true + }, + "name": "IntensivZone" + }, + { + "key": "Dishcare.Dishwasher.Option.VarioSpeedPlus", + "type": "Boolean", + "constraints": { + "default": false, + "liveupdate": true + }, + "name": "VarioSpeedPlus" + }, + { + "key": "Dishcare.Dishwasher.Option.BrillianceDry", + "type": "Boolean", + "constraints": { + "default": false, + "liveupdate": true + }, + "name": "Glanztrocknen" + } + ] + } +} \ No newline at end of file diff --git a/tests/homeappliances/015070396331014803/programs/response.json b/tests/homeappliances/015070396331014803/programs/response.json new file mode 100644 index 0000000..7ef9c8f --- /dev/null +++ b/tests/homeappliances/015070396331014803/programs/response.json @@ -0,0 +1,141 @@ +{ + "data": { + "active": { + "constraints": { + "access": "readWrite" + } + }, + "programs": [ + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.Eco50", + "name": "Eco 50°" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.Auto2", + "name": "Auto 45-65°" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.Intensiv70", + "name": "Intensiv 70°" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.Kurz60", + "name": "Speed 60°" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.NightWash", + "name": "Leise 50°C" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.MachineCare", + "name": "Maschinenpflege" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.PreRinse", + "name": "Vorspülen" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "Dishcare.Dishwasher.Program.Quick45", + "name": "Schnell 45°C" + } + ], + "selected": { + "constraints": { + "access": "readWrite" + }, + "key": "Dishcare.Dishwasher.Program.Auto2", + "name": "Auto 45-65°", + "options": [ + { + "key": "BSH.Common.Option.StartInRelative", + "value": 0, + "unit": "seconds", + "name": "Startzeit" + }, + { + "key": "BSH.Common.Option.SmartEnergyService.SmartStartEnabled", + "value": false + }, + { + "key": "Dishcare.Dishwasher.Option.HygienePlus", + "value": false, + "name": "HygienePlus" + }, + { + "key": "Dishcare.Dishwasher.Option.HalfLoad", + "value": false, + "name": "Halbe Beladung" + }, + { + "key": "Dishcare.Dishwasher.Option.IntensivZone", + "value": false, + "name": "IntensivZone" + }, + { + "key": "Dishcare.Dishwasher.Option.VarioSpeedPlus", + "value": false, + "name": "VarioSpeedPlus" + }, + { + "key": "Dishcare.Dishwasher.Option.BrillianceDry", + "value": false, + "name": "Glanztrocknen" + }, + { + "key": "Dishcare.Dishwasher.Option.SilenceOnDemand", + "value": false, + "name": "Jetzt Leise" + }, + { + "key": "BSH.Common.Option.RemainingProgramTimeIsEstimated", + "value": true, + "name": "Geschätzte Restprogrammlaufzeit" + }, + { + "key": "BSH.Common.Option.EnergyForecast", + "value": 58, + "unit": "%", + "name": "Energie" + }, + { + "key": "BSH.Common.Option.WaterForecast", + "value": 49, + "unit": "%", + "name": "Wasser" + } + ] + } + } +} \ No newline at end of file diff --git a/tests/homeappliances/015070396331014803/programs/selected/Auto2.json b/tests/homeappliances/015070396331014803/programs/selected/Auto2.json new file mode 100644 index 0000000..84bb65c --- /dev/null +++ b/tests/homeappliances/015070396331014803/programs/selected/Auto2.json @@ -0,0 +1,68 @@ +{ + "data": { + "constraints": { + "access": "readWrite" + }, + "key": "Dishcare.Dishwasher.Program.Auto2", + "name": "Auto 45-65°", + "options": [ + { + "key": "BSH.Common.Option.StartInRelative", + "value": 0, + "unit": "seconds", + "name": "Startzeit" + }, + { + "key": "BSH.Common.Option.SmartEnergyService.SmartStartEnabled", + "value": false + }, + { + "key": "Dishcare.Dishwasher.Option.HygienePlus", + "value": false, + "name": "HygienePlus" + }, + { + "key": "Dishcare.Dishwasher.Option.HalfLoad", + "value": false, + "name": "Halbe Beladung" + }, + { + "key": "Dishcare.Dishwasher.Option.IntensivZone", + "value": false, + "name": "IntensivZone" + }, + { + "key": "Dishcare.Dishwasher.Option.VarioSpeedPlus", + "value": false, + "name": "VarioSpeedPlus" + }, + { + "key": "Dishcare.Dishwasher.Option.BrillianceDry", + "value": false, + "name": "Glanztrocknen" + }, + { + "key": "Dishcare.Dishwasher.Option.SilenceOnDemand", + "value": false, + "name": "Jetzt Leise" + }, + { + "key": "BSH.Common.Option.RemainingProgramTimeIsEstimated", + "value": true, + "name": "Geschätzte Restprogrammlaufzeit" + }, + { + "key": "BSH.Common.Option.EnergyForecast", + "value": 58, + "unit": "%", + "name": "Energie" + }, + { + "key": "BSH.Common.Option.WaterForecast", + "value": 49, + "unit": "%", + "name": "Wasser" + } + ] + } +} \ No newline at end of file diff --git a/tests/homeappliances/015070396331014803/settings/BSH.Common.Setting.PowerState/response.json b/tests/homeappliances/015070396331014803/settings/BSH.Common.Setting.PowerState/response.json new file mode 100644 index 0000000..786f44a --- /dev/null +++ b/tests/homeappliances/015070396331014803/settings/BSH.Common.Setting.PowerState/response.json @@ -0,0 +1,20 @@ +{ + "data": { + "name": "Energiezustand", + "key": "BSH.Common.Setting.PowerState", + "constraints": { + "allowedvalues": [ + "BSH.Common.EnumType.PowerState.Off", + "BSH.Common.EnumType.PowerState.On" + ], + "displayvalues": [ + "Aus", + "An" + ], + "access": "readWrite" + }, + "type": "BSH.Common.EnumType.PowerState", + "displayvalue": "Aus", + "value": "BSH.Common.EnumType.PowerState.Off" + } +} \ No newline at end of file diff --git a/tests/homeappliances/015070396331014803/settings/response.json b/tests/homeappliances/015070396331014803/settings/response.json new file mode 100644 index 0000000..1b979e9 --- /dev/null +++ b/tests/homeappliances/015070396331014803/settings/response.json @@ -0,0 +1,12 @@ +{ + "data": { + "settings": [ + { + "key": "BSH.Common.Setting.PowerState", + "value": "BSH.Common.EnumType.PowerState.Off", + "name": "Energiezustand", + "displayvalue": "Aus" + } + ] + } +} \ No newline at end of file diff --git a/tests/homeappliances/015070396331014803/status/response.json b/tests/homeappliances/015070396331014803/status/response.json new file mode 100644 index 0000000..841d039 --- /dev/null +++ b/tests/homeappliances/015070396331014803/status/response.json @@ -0,0 +1,33 @@ +{ + "data": { + "status": [ + { + "key": "BSH.Common.Status.RemoteControlStartAllowed", + "value": true, + "name": "Fernstart" + }, + { + "key": "BSH.Common.Status.RemoteControlActive", + "value": true, + "name": "Fernbedienung" + }, + { + "key": "BSH.Common.Status.DoorState", + "value": "BSH.Common.EnumType.DoorState.Closed", + "name": "Tür", + "displayvalue": "Geschlossen" + }, + { + "key": "BSH.Common.Status.OperationState", + "value": "BSH.Common.EnumType.OperationState.Ready", + "name": "Betriebszustand", + "displayvalue": "Bereit" + }, + { + "key": "BSH.Common.Status.InteriorIlluminationActive", + "value": true, + "name": "Innenbeleuchtung" + } + ] + } +} \ No newline at end of file diff --git a/tests/homeappliances/385110390226001436-001/programs/available/Cooking.Oven.Program.HeatingMode.HotAir/response.json b/tests/homeappliances/385110390226001436-001/programs/available/Cooking.Oven.Program.HeatingMode.HotAir/response.json new file mode 100644 index 0000000..5d23b85 --- /dev/null +++ b/tests/homeappliances/385110390226001436-001/programs/available/Cooking.Oven.Program.HeatingMode.HotAir/response.json @@ -0,0 +1,50 @@ +{ + "data": { + "key": "Cooking.Oven.Program.HeatingMode.HotAir", + "name": "4D Heißluft", + "options": [ + { + "name": "Temperatur", + "key": "Cooking.Oven.Option.SetpointTemperature", + "constraints": { + "min": 30, + "liveupdate": true, + "max": 275, + "stepsize": 1, + "default": 180 + }, + "unit": "°C", + "type": "Double" + }, + { + "name": "Dauer anpassen", + "key": "BSH.Common.Option.Duration", + "constraints": { + "min": 1, + "max": 86400, + "default": 1, + "liveupdate": true + }, + "unit": "seconds", + "type": "Int" + }, + { + "name": "Startzeit", + "key": "BSH.Common.Option.StartInRelative", + "constraints": { + "min": 0, + "max": 86340, + "default": 0, + "liveupdate": true + }, + "unit": "seconds", + "type": "Int" + }, + { + "key": "Cooking.Oven.Option.FastPreHeat", + "type": "Boolean", + "name": "Schnellaufheizen" + } + ] + } +} diff --git a/tests/homeappliances/385110390226001436-001/programs/available/response.json b/tests/homeappliances/385110390226001436-001/programs/available/response.json new file mode 100644 index 0000000..a224da7 --- /dev/null +++ b/tests/homeappliances/385110390226001436-001/programs/available/response.json @@ -0,0 +1,142 @@ +{ + "data": { + "active": { + "constraints": { + "access": "readWrite" + } + }, + "programs": [ + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.Microwave.90Watt", + "name": "90 Watt" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.Microwave.180Watt", + "name": "180 Watt" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.Microwave.360Watt", + "name": "360 Watt" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.Microwave.600Watt", + "name": "600 Watt" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.Microwave.Max", + "name": "Boost" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.HotAir", + "name": "4D Heißluft" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.TopBottomHeating", + "name": "Ober-/Unterhitze" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.TopBottomHeatingEco", + "name": "Sanfte Ober-/Unterhitze" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.HotAirGrilling", + "name": "Umluftgrillen" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.PizzaSetting", + "name": "Pizzastufe" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.FrozenHeatupSpecial", + "name": "coolStart-Funktion" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.IntensiveHeat", + "name": "Intensivhitze" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.SlowCook", + "name": "Sanftgaren" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.Desiccation", + "name": "Dörren" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.BottomHeating", + "name": "Unterhitze" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.KeepWarm", + "name": "Warmhalten" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.PreheatOvenware", + "name": "Geschirr vorwärmen" + }, + { + "constraints": { + "execution": "selectandstart" + }, + "key": "Cooking.Oven.Program.HeatingMode.PreHeating", + "name": "Vorheizen" + } + ], + "selected": { + "constraints": { + "access": "readWrite" + } + } + } +} diff --git a/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/response.json b/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/response.json new file mode 100644 index 0000000..81d3a0c --- /dev/null +++ b/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/response.json @@ -0,0 +1,11 @@ +{ + "data": { + "brand": "Bosch", + "connected": false, + "enumber": "WAV28G43/07", + "haId": "BOSCH-WAV28G43-68A40E970546", + "name": "Waschmaschine", + "type": "Washer", + "vib": "WAV28G43" + } +} \ No newline at end of file diff --git a/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/status/response.json b/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/status/response.json new file mode 100644 index 0000000..e242e93 --- /dev/null +++ b/tests/homeappliances/BOSCH-WAV28G43-68A40E970546/status/response.json @@ -0,0 +1,6 @@ +{ + "error": { + "description": "HomeAppliance is offline", + "key": "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + } +} \ No newline at end of file diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/programs/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/programs/response.json new file mode 100644 index 0000000..f12650b --- /dev/null +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/programs/response.json @@ -0,0 +1,40 @@ +{ + "data": { + "active": { + "constraints": { + "access": "read" + } + }, + "programs": [ + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "LaundryCare.WasherDryer.Program.Cotton.Eco4060", + "name": "Eco 40-60" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "LaundryCare.WasherDryer.Program.Cotton", + "name": "Baumwolle" + }, + { + "constraints": { + "available": true, + "execution": "selectandstart" + }, + "key": "LaundryCare.WasherDryer.Program.Mix", + "name": "Schnell/Mix" + } + ], + "selected": { + "constraints": { + "access": "read" + } + } + } +} \ No newline at end of file diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/BSH.Common.Setting.PowerState/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/BSH.Common.Setting.PowerState/response.json new file mode 100644 index 0000000..3ccb288 --- /dev/null +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/BSH.Common.Setting.PowerState/response.json @@ -0,0 +1,21 @@ +{ + "data": { + "name": "Energiezustand", + "key": "BSH.Common.Setting.PowerState", + "constraints": { + "allowedvalues": [ + "BSH.Common.EnumType.PowerState.Off", + "BSH.Common.EnumType.PowerState.On" + ], + "displayvalues": [ + "Aus", + "An" + ], + "default": "BSH.Common.EnumType.PowerState.On", + "access": "readWrite" + }, + "type": "BSH.Common.EnumType.PowerState", + "displayvalue": "Aus", + "value": "BSH.Common.EnumType.PowerState.Off" + } +} \ No newline at end of file diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos1BaseLevel/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos1BaseLevel/response.json new file mode 100644 index 0000000..1cd5186 --- /dev/null +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos1BaseLevel/response.json @@ -0,0 +1,16 @@ +{ + "data": { + "name": "Basisdosiermenge: Waschmittel", + "key": "LaundryCare.Washer.Setting.IDos1BaseLevel", + "constraints": { + "min": 5, + "max": 200, + "stepsize": 1, + "default": 36, + "access": "readWrite" + }, + "unit": "ml", + "type": "Int", + "value": 50 + } +} \ No newline at end of file diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos2BaseLevel/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos2BaseLevel/response.json new file mode 100644 index 0000000..cfe100a --- /dev/null +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/LaundryCare.Washer.Setting.IDos2BaseLevel/response.json @@ -0,0 +1,16 @@ +{ + "data": { + "name": "Basisdosiermenge: Waschmittel oder Weichspüler", + "key": "LaundryCare.Washer.Setting.IDos2BaseLevel", + "constraints": { + "min": 5, + "max": 200, + "stepsize": 1, + "default": 75, + "access": "readWrite" + }, + "unit": "ml", + "type": "Int", + "value": 65 + } +} \ No newline at end of file diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/response.json new file mode 100644 index 0000000..fab8d0a --- /dev/null +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/settings/response.json @@ -0,0 +1,24 @@ +{ + "data": { + "settings": [ + { + "key": "BSH.Common.Setting.PowerState", + "value": "BSH.Common.EnumType.PowerState.Off", + "name": "Energiezustand", + "displayvalue": "Aus" + }, + { + "key": "LaundryCare.Washer.Setting.IDos1BaseLevel", + "value": 50, + "unit": "ml", + "name": "Basisdosiermenge: Waschmittel" + }, + { + "key": "LaundryCare.Washer.Setting.IDos2BaseLevel", + "value": 65, + "unit": "ml", + "name": "Basisdosiermenge: Waschmittel oder Weichspüler" + } + ] + } +} \ No newline at end of file diff --git a/tests/homeappliances/BOSCH-WNC254A40-XYZ/status/response.json b/tests/homeappliances/BOSCH-WNC254A40-XYZ/status/response.json new file mode 100644 index 0000000..c294ac8 --- /dev/null +++ b/tests/homeappliances/BOSCH-WNC254A40-XYZ/status/response.json @@ -0,0 +1,33 @@ +{ + "data": { + "status": [ + { + "key": "BSH.Common.Status.DoorState", + "value": "BSH.Common.EnumType.DoorState.Open", + "name": "Tür", + "displayvalue": "Offen" + }, + { + "key": "BSH.Common.Status.LocalControlActive", + "value": false, + "name": "Lokale Bedienung aktiv" + }, + { + "key": "BSH.Common.Status.OperationState", + "value": "BSH.Common.EnumType.OperationState.Inactive", + "name": "Betriebszustand", + "displayvalue": "Inaktiv" + }, + { + "key": "BSH.Common.Status.RemoteControlActive", + "value": true, + "name": "Fernbedienung" + }, + { + "key": "BSH.Common.Status.RemoteControlStartAllowed", + "value": true, + "name": "Fernstart" + } + ] + } +} \ No newline at end of file