Skip to content
Merged
170 changes: 127 additions & 43 deletions Home Connect Cloud/module.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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);
}
}
}

Expand All @@ -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()
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions Home Connect Configurator/module.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions Home Connect Device/locale.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"Seconds": "Sekunden",
"Yes": "Ja",
"No": "Nein",
"Connected": "Verbunden",
"Inactive": "Inaktiv",
"Ready": "Bereit",
"Delayed Start": "Verzögerter Start",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading