Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Home Connect Cloud/api-test.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ trait TestAPI
{
public $selectedProgram = '';

//Counts API GET requests so tests can assert that no retry loop burns the rate limit.
public static $requestCount = 0;

public function getRequest(string $endpoint)
{
self::$requestCount++;
preg_match('/homeappliances\/.+\/programs\/selected/', $endpoint, $matches);
if ($matches) {
return file_get_contents(__DIR__ . '/../tests/' . $endpoint . '/' . $this->selectedProgram . '.json');
Expand Down
103 changes: 81 additions & 22 deletions Home Connect Cloud/module.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,16 @@ public function ForwardData($Data)
public function ReceiveData($JSONString)
{
$this->SendDebug('Receive', $JSONString, 0);

// A 429 can also arrive through the event stream itself (the /events
// request is rejected). Feed it into the same rate-limit machinery so the
// reconnect logic backs off instead of hammering /events every 60s.
if ($this->applyRateLimitFromStream($JSONString)) {
return;
}

$data = json_decode($JSONString, true);
switch ($data['Event']) {
switch ($data['Event'] ?? '') {
case 'KEEP-ALIVE': {
$this->SendDebug('KeepAlive', 'OK', 0);
$this->SetBuffer('KeepAlive', time());
Expand Down Expand Up @@ -154,6 +162,15 @@ public function ForceRegisterServerEvents()

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);
return;
}
try {
$url = self::HOME_CONNECT_BASE . 'homeappliances/events';
$this->SendDebug('url', $url, 0);
Expand All @@ -179,6 +196,10 @@ public function RegisterServerEvents()

public function CheckServerEvents()
{
// While rate limited the missing keep-alive is expected - do not reconnect.
if ($this->isRateLimitActive()) {
return;
}
if ($this->HasActiveParent()) {
if (time() - intval($this->GetBuffer('KeepAlive')) > 60 /* Seconds */) {
$this->SendDebug('KeepAlive', 'Failed. Reregistering...', 0);
Expand All @@ -205,6 +226,10 @@ public function ResetRateLimit()
$this->updateRateLimitNotice();
$this->SetStatus(IS_ACTIVE);
$this->SetTimerInterval('RateLimit', 0);

// Limit is over - resume the event stream exactly once.
$this->SetTimerInterval('Reconnect', 0);
$this->RegisterServerEvents();
}

public function GetConfigurationForm()
Expand Down Expand Up @@ -259,6 +284,31 @@ protected function ProcessOAuthData()
}
}

/**
* Detects a 429 rate-limit response carried by the SSE stream and activates
* the rate limit. Envelope-agnostic: matches on the raw payload because the
* IO wraps the body in different ways. Returns true if a 429 was handled.
*/
private function applyRateLimitFromStream(string $JSONString): bool
{
if (!preg_match('/"key"\s*:\s*"429"/', $JSONString)) {
return false;
}

$retryAfter = preg_match('/remaining period of (\d+) seconds/', $JSONString, $m) ? (int) $m[1] : 60;

$type = null;
if (strpos($JSONString, 'in 1 day') !== false) {
$type = 'day';
} elseif (strpos($JSONString, 'in 1 minute') !== false) {
$type = 'minute';
}

$this->SendDebug('ReceiveRateLimit', sprintf('429 from event stream, blocking for %ds', $retryAfter), 0);
$this->applyRateLimit($retryAfter, $type);
return true;
}

private function resetRetries()
{
$this->SetTimerInterval('Reconnect', 0);
Expand Down Expand Up @@ -527,32 +577,41 @@ private function handleHttpErrors($code, $responseHeader)
//Too Many Requests
case 429:
$head = $this->parseResponseHeaders($responseHeader);
$retryAfter = $this->getRateLimitDelay($responseHeader);
$nextRun = time() + $retryAfter;
$this->WriteAttributeInteger('RateLimitUntil', $nextRun);
$this->SetTimerInterval('RateLimit', $retryAfter * 1000);

$this->WriteAttributeString(
'RateError',
isset($head['rate-limit-type']) ?
sprintf(
$this->Translate(
'The rate limit of %s was reached. Requests are blocked until %s.'
),
$head['rate-limit-type'] == 'day' ?
$this->Translate('1000 calls in 1 day') : $this->Translate('50 calls in 1 minute'),
date('d.m.Y H:i:s', $nextRun),
) : sprintf($this->Translate('A rate limit was reached. Requests are blocked until %s.'), date('d.m.Y H:i:s', $nextRun))
);
$this->updateRateLimitNotice();
if ($this->HasActiveParent() && $this->GetStatus() != IS_ACTIVE) {
$this->SetStatus(IS_ACTIVE);
}
$this->applyRateLimit($this->getRateLimitDelay($responseHeader), $head['rate-limit-type'] ?? null);
return;

}
}

/**
* Activates the rate-limit state shared by the REST and the SSE path:
* remembers the block end, schedules the reset timer and updates the notice.
*/
private function applyRateLimit(int $retryAfter, ?string $type): void
{
$retryAfter = max(1, $retryAfter);
$nextRun = time() + $retryAfter;
$this->WriteAttributeInteger('RateLimitUntil', $nextRun);
$this->SetTimerInterval('RateLimit', $retryAfter * 1000);

$this->WriteAttributeString(
'RateError',
$type !== null ?
sprintf(
$this->Translate(
'The rate limit of %s was reached. Requests are blocked until %s.'
),
$type == 'day' ?
$this->Translate('1000 calls in 1 day') : $this->Translate('50 calls in 1 minute'),
date('d.m.Y H:i:s', $nextRun),
) : sprintf($this->Translate('A rate limit was reached. Requests are blocked until %s.'), date('d.m.Y H:i:s', $nextRun))
);
$this->updateRateLimitNotice();
if ($this->HasActiveParent() && $this->GetStatus() != IS_ACTIVE) {
$this->SetStatus(IS_ACTIVE);
}
}

private function getData($endpoint)
{
$opts = [
Expand Down
26 changes: 18 additions & 8 deletions Home Connect Device/module.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public function Create()
$this->RegisterAttributeString('Settings', '[]');
$this->RegisterAttributeString('OptionKeys', '[]');
$this->RegisterAttributeString('InitializationSignature', '');
$this->RegisterAttributeBoolean('Initialized', false);

//Common States
//States
Expand Down Expand Up @@ -157,7 +158,7 @@ public function MessageSink($Timestamp, $SenderID, $MessageID, $Data)
return;

case FM_DISCONNECT:
$this->SetStatus(IS_INACTIVE);
$this->setInstanceStatus(IS_INACTIVE);
return;
}
}
Expand Down Expand Up @@ -385,21 +386,20 @@ public function RequestAction($Ident, $Value)

public function InitializeDevice()
{
// Write signature before API calls to prevent retry loops when initialization fails
// (e.g. device offline or rate limit hit mid-way through setupSettings)
$this->WriteAttributeString('InitializationSignature', $this->getInitializationSignature());
$this->WriteAttributeBoolean('Initialized', false);
if ($this->createStates()) {
$this->setupSettings();
if ($this->createPrograms()) {
$this->ensureControlVariable(2);
//If the device is inactive, we cannot retrieve information about the current selected Program
if (@IPS_GetObjectIDByIdent('OperationState', $this->InstanceID) && ($this->GetValue('OperationState') == 'BSH.Common.EnumType.OperationState.Ready')) {
$this->updateOptionValues($this->getSelectedProgram());
}
}
$this->createEventProfile();
$this->MaintainVariable('Event', $this->Translate('Event'), VARIABLETYPE_STRING, 'HomeConnect.Event.' . $this->ReadPropertyString('DeviceType'), 0, true);
$this->MaintainVariable('EventDescription', $this->Translate('Event Description'), VARIABLETYPE_STRING, '', 0, true);
$this->WriteAttributeString('InitializationSignature', $this->getInitializationSignature());
$this->WriteAttributeBoolean('Initialized', true);
}
}

Expand Down Expand Up @@ -439,6 +439,7 @@ public function RequestDataFromParent(string $endpoint, string $payload = '')
case 'SDK.Error.UnsupportedOperation':
case 'SDK.Error.NoProgramSelected':
case 'SDK.Error.HomeAppliance.Connection.Initialization.Failed':
case '429':
return $response;

default:
Expand All @@ -461,11 +462,20 @@ private function refreshDeviceState(bool $initializeDevice): void
if ($initializeDevice) {
$this->InitializeDevice();
}
$this->SetStatus(IS_ACTIVE);
$this->setInstanceStatus(IS_ACTIVE);
return;
}

$this->SetStatus(IS_INACTIVE);
$this->setInstanceStatus(IS_INACTIVE);
}

private function setInstanceStatus(int $status): void
{
// Only update the status when it actually changes, so monitoring
// (e.g. EventControl scripts) is not triggered on every refresh.
if ($this->GetStatus() !== $status) {
$this->SetStatus($status);
}
}

private function needsInitialization(): bool
Expand All @@ -474,7 +484,7 @@ private function needsInitialization(): bool
return false;
}

if (!@IPS_GetObjectIDByIdent('OperationState', $this->InstanceID)) {
if (!$this->ReadAttributeBoolean('Initialized')) {
return true;
}

Expand Down
4 changes: 2 additions & 2 deletions library.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
"version": "6.0"
},
"version": "1.1",
"build": 11,
"date": 1781870400
"build": 14,
"date": 1782691200
}
104 changes: 104 additions & 0 deletions tests/HomeConnectCloudTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

declare(strict_types=1);

include_once __DIR__ . '/stubs/GlobalStubs.php';
include_once __DIR__ . '/stubs/KernelStubs.php';
include_once __DIR__ . '/stubs/ModuleStubs.php';
include_once __DIR__ . '/stubs/ConstantStubs.php';
include_once __DIR__ . '/stubs/MessageStubs.php';

use PHPUnit\Framework\TestCase;

class HomeConnectCloudTest extends TestCase
{
private const CLOUD_GUID = '{CE76810D-B685-9BE0-CC04-38B204DEAD5E}';

//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."}}';

protected function setUp(): void
{
IPS\Kernel::reset();
IPS\ModuleLoader::loadLibrary(__DIR__ . '/stubs/CoreStubs/library.json');
IPS\ModuleLoader::loadLibrary(__DIR__ . '/stubs/IOStubs/library.json');
IPS\ModuleLoader::loadLibrary(__DIR__ . '/../library.json');

$this->ConfiguratorID = IPS_CreateInstance('{CA0E667D-8F28-8DF1-2750-5CF587ECA85A}');

parent::setUp();
}

/**
* A 429 carried by the event stream must activate the shared rate-limit state,
* even though no REST call (getData/putData) was involved.
*/
public function testReceiveDataActivatesRateLimitOn429()
{
$cloud = $this->cloud();
$cloud->ReceiveData(self::RATE_LIMIT_PAYLOAD);

$this->assertTrue($this->invoke($cloud, 'isRateLimitActive'), 'A 429 from the stream must activate the rate limit');

$until = $this->invoke($cloud, 'ReadAttributeInteger', 'RateLimitUntil');
$this->assertGreaterThan(time() + 18000, $until, 'RateLimitUntil should reflect the ~18113s retry-after');

$this->assertNotSame('', $this->invoke($cloud, 'ReadAttributeString', 'RateError'), 'RateError should be set');
}

/**
* Core of the fix: while rate limited, RegisterServerEvents must NOT reconnect
* the event stream (which would hit /events again) but defer via the Reconnect
* timer. With the old code it would fall through and try to talk to the parent IO.
*/
public function testRegisterServerEventsDefersWhileRateLimited()
{
$cloud = $this->cloud();
$cloud->ReceiveData(self::RATE_LIMIT_PAYLOAD);

//Must not throw and must not touch the parent IO.
$cloud->RegisterServerEvents();

$reconnect = $this->invoke($cloud, 'GetTimerInterval', 'Reconnect');
$this->assertGreaterThan(0, $reconnect, 'Reconnect must be deferred until the limit expires');
}

/**
* The 60s keep-alive check must not trigger a reconnect while rate limited -
* otherwise it hammers /events every minute (~1440 calls/day).
*/
public function testCheckServerEventsSkipsWhileRateLimited()
{
$cloud = $this->cloud();
$cloud->ReceiveData(self::RATE_LIMIT_PAYLOAD);

//Would throw (parent IO has no URL/Active) if it tried to reconnect.
$cloud->CheckServerEvents();

$this->assertTrue($this->invoke($cloud, 'isRateLimitActive'), 'Still rate limited, no reconnect attempted');
}

/**
* A normal keep-alive event must still be processed (and not be mistaken for a
* rate-limit payload).
*/
public function testKeepAliveStillProcessedWhenNotLimited()
{
$cloud = $this->cloud();
$cloud->ReceiveData('{"Event":"KEEP-ALIVE"}');

$this->assertFalse($this->invoke($cloud, 'isRateLimitActive'), 'A keep-alive must not activate the rate limit');
}

private function cloud()
{
return IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]);
}

private function invoke($object, string $method, ...$args)
{
$ref = new ReflectionMethod($object, $method);
$ref->setAccessible(true);
return $ref->invoke($object, ...$args);
}
}
Loading