diff --git a/Home Connect Cloud/module.php b/Home Connect Cloud/module.php index 82648b2..234c367 100644 --- a/Home Connect Cloud/module.php +++ b/Home Connect Cloud/module.php @@ -235,7 +235,24 @@ public function CheckServerEvents() // recovery exactly when the parent had dropped to an error state (keep-alives // stopped and never came back). if (time() - intval($this->GetBuffer('KeepAlive')) > 60 /* Seconds */) { - $this->SendDebug('KeepAlive', 'Failed. Reregistering...', 0); + // Back off between the reconnect attempts: each one costs a GET /events + // against the daily quota, and on a permanently dead stream a fixed 2 + // minute cadence burns ~700 requests per day - enough to keep the + // "1000 calls in 1 day" limit exhausted for good. The first retries stay + // fast so a briefly dropped stream still recovers quickly. + if (time() < intval($this->GetBuffer('WatchdogNextRetry'))) { + return; + } + $retries = intval($this->GetBuffer('WatchdogRetries')); + $delay = intval(min(120 * pow(2, $retries), 3600)); + $this->SetBuffer('WatchdogRetries', $retries + 1); + $this->SetBuffer('WatchdogNextRetry', time() + $delay); + // Include the IO status: on a silently dead stream nothing reaches + // ReceiveData, so this is the only hint whether the SSE client saw an + // HTTP error or still believes it is connected. + $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(); } } @@ -370,6 +387,8 @@ private function resetRetries() { $this->SetTimerInterval('Reconnect', 0); $this->WriteAttributeInteger('RetryCounter', 0); + $this->SetBuffer('WatchdogRetries', ''); + $this->SetBuffer('WatchdogNextRetry', ''); } private function FetchRefreshToken($code) diff --git a/Home Connect Device/locale.json b/Home Connect Device/locale.json index 43ea342..bc36e42 100644 --- a/Home Connect Device/locale.json +++ b/Home Connect Device/locale.json @@ -3,6 +3,7 @@ "de": { "Control": "Steuern", "Program": "Programm", + "Active Program": "Aktives Programm", "DeviceType": "Gerätetyp", "Current Cavity Temperature": "Aktuelle Temperatur", "Remote control not active": "Fernsteuerung nicht aktiviert", @@ -75,7 +76,34 @@ "Home Connect Device": "Home Connect Gerät", "Use duration option": "Option Dauer verwenden", "No response from parent instance": "Keine Antwort von der Parent-Instanz", - "Invalid JSON response from parent instance": "Ungültige JSON-Antwort von der Parent-Instanz" + "Invalid JSON response from parent instance": "Ungültige JSON-Antwort von der Parent-Instanz", + "Power level mode": "Power-Level-Modus", + "Frying sensor mode": "Bratsensor-Modus", + "PowerMove mode": "PowerMove-Modus", + "Interval venting": "Intervalllüftung", + "Continue cooking": "Weitergaren", + "Keep warm": "Warmhalten", + "Leave to rest": "Ruhen lassen", + "Microwave": "Mikrowelle", + "Subsequent cooking": "Nachgaren", + "Pyrolytic self-cleaning": "Pyrolyse-Selbstreinigung", + "Draining": "Entleeren", + "Drying": "Trocknen", + "Auto steam calibration": "Automatische Dampf-Kalibrierung", + "Rinsing on switch-on": "Spülen beim Einschalten", + "Rinsing on switch-off": "Spülen beim Ausschalten", + "Auto clean": "Automatische Reinigung", + "Auto descale": "Automatisches Entkalken", + "Clean": "Reinigen", + "Descale": "Entkalken", + "Clean brewing unit manually": "Brüheinheit manuell reinigen", + "Clean brewing unit manually (detailed)": "Brüheinheit manuell reinigen (ausführlich)", + "Clean outlet manually": "Auslauf manuell reinigen", + "Frost protection": "Frostschutz", + "Remove water filter": "Wasserfilter entfernen", + "Replace water filter": "Wasserfilter wechseln", + "Rinse milk system": "Milchsystem spülen", + "Favorite %d": "Favorit %d" } } } diff --git a/Home Connect Device/module.php b/Home Connect Device/module.php index 5825a47..06a81db 100644 --- a/Home Connect Device/module.php +++ b/Home Connect Device/module.php @@ -20,6 +20,46 @@ class HomeConnectDevice extends IPSModule 'BSH.Common.Option.ElapsedProgramTime' ]; + // Programs that appliances report only via ActiveProgram events and that the + // API does not list under programs/available (hobs are monitoring-only, oven + // follow-up/cleaning modes and coffee maker auto-rinsing are started at the + // appliance). Values are the English display names, translated via locale.json. + public const EVENT_ONLY_PROGRAM_NAMES = [ + // Hob (monitoring-only appliance) + 'Cooking.Hob.Program.PowerLevelMode' => 'Power level mode', + 'Cooking.Hob.Program.FryingSensorMode' => 'Frying sensor mode', + 'Cooking.Hob.Program.PowerMoveMode' => 'PowerMove mode', + // Hood + 'Cooking.Common.Program.Hood.Interval' => 'Interval venting', + // Oven follow-up / runtime modes + 'Cooking.Oven.Program.SubsequentMode.ContinueCooking' => 'Continue cooking', + 'Cooking.Oven.Program.SubsequentMode.KeepWarm' => 'Keep warm', + 'Cooking.Oven.Program.SubsequentMode.LeaveToRest' => 'Leave to rest', + 'Cooking.Oven.Program.SubsequentMode.Microwave' => 'Microwave', + 'Cooking.Oven.Program.Dish.SubsequentCooking' => 'Subsequent cooking', + // Oven cleaning programs (started at the appliance) + 'Cooking.Oven.Program.Cleaning.Pyrolysis' => 'Pyrolytic self-cleaning', + 'Cooking.Oven.Program.Cleaning.Draining' => 'Draining', + 'Cooking.Oven.Program.Cleaning.Drying' => 'Drying', + 'Cooking.Oven.Program.Cleaning.Ecolysis' => 'Ecolysis', + 'Cooking.Oven.Program.CleaningModes.AutoSteamCalibration' => 'Auto steam calibration', + // Coffee maker cleaning modes (auto-rinsing runs on every power cycle) + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.ApplianceOnRinsing' => 'Rinsing on switch-on', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.ApplianceOffRinsing' => 'Rinsing on switch-off', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.AutoClean' => 'Auto clean', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.AutoDescale' => 'Auto descale', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.CalcNClean' => "calc'nClean", + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.Clean' => 'Clean', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.Descale' => 'Descale', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.CleanBrewingUnitManually' => 'Clean brewing unit manually', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.CleanBrewingUnitManuallyDetailed' => 'Clean brewing unit manually (detailed)', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.CleanOutletManually' => 'Clean outlet manually', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.FrostProtection' => 'Frost protection', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.RemoveWaterFilter' => 'Remove water filter', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.ReplaceWaterFilter' => 'Replace water filter', + 'ConsumerProducts.CoffeeMaker.Program.CleaningModes.RinseMilkSystem' => 'Rinse milk system' + ]; + public const EVENT_DESCRIPTIONS = [ 'BSH.Common.Event.ProgramAborted' => 'The program was aborted', 'BSH.Common.Event.ProgramFinished' => 'The program is finished', @@ -47,6 +87,20 @@ class HomeConnectDevice extends IPSModule 'ConsumerProducts.CleaningRobot.Event.DockingStationNotFound' => 'The robot cannot find the charging station' ]; + + // Appliance types for which the API provides no program list. Documented at + // api-docs.home-connect.com ("Programs and Options"): "There are no programs + // available for ..." (Refrigerator, Freezer, Fridge Freezer, Wine Cooler); for + // the cook processor "Program support is currently not planned to be released" + // (only programs/selected and programs/active work). Requesting /programs for + // these types is a guaranteed SDK.Error.UnsupportedOperation. + public const PROGRAMLESS_DEVICE_TYPES = [ + 'Refrigerator', + 'Freezer', + 'FridgeFreezer', + 'WineCooler', + 'CookProcessor' + ]; private const OPTION_DURATION = 'BSH.Common.Option.Duration'; private const START_IN_RELATIVE = 'BSH.Common.Option.StartInRelative'; private const START_IN_RELATIVE_DEVICES = ['Microwave', 'Dishwasher', 'Oven']; @@ -205,6 +259,10 @@ public function ReceiveData($String) $items = json_decode($data['Data'], true)['items']; // $this->SendDebug($cleanData['event'], json_encode($items), 0); foreach ($items as $item) { + if ($item['key'] == 'BSH.Common.Root.ActiveProgram') { + $this->updateActiveProgram($item['value'] ?? null); + continue; + } if (in_array($item['key'], self::EXCLUDE)) { continue; } @@ -241,6 +299,11 @@ public function ReceiveData($String) } if (@IPS_GetObjectIDByIdent($ident, $this->InstanceID)) { $this->SetValue($ident, $item['value']); + } elseif (strpos($ident, 'Option') === 0) { + // The variable may be created only moments later by + // refreshActiveProgramOptions - keep the value so it + // does not start out empty (see rememberPendingOptionValue). + $this->rememberPendingOptionValue($ident, $item['value']); } $this->SendDebug($ident, strval($item['value']), 0); break; @@ -296,6 +359,13 @@ public function RequestAction($Ident, $Value) $this->refreshDeviceState($this->needsInitialization(), 'Event:CONNECTED (deferred)'); return; + case 'RefreshActiveProgramOptions': + // Internal action, triggered by the one-shot timer armed in + // updateActiveProgram(). Creates the option variables for a program + // that is only reported via ActiveProgram. + $this->refreshActiveProgramOptions(); + return; + case 'UseDuration': $applyValue = true; break; @@ -614,6 +684,10 @@ private function getInitializationSignature(): string private function createPrograms() { + if (in_array($this->ReadPropertyString('DeviceType'), self::PROGRAMLESS_DEVICE_TYPES, true)) { + $this->SendDebug(__FUNCTION__, 'Skipped: the API provides no programs for this appliance type', 0); + return; + } $rawPrograms = json_decode($this->RequestDataFromParent('homeappliances/' . $this->ReadPropertyString('HaID') . '/programs'), true); if (isset($rawPrograms['error'])) { return; @@ -725,16 +799,21 @@ private function sendOptionsOnProgramStart() /** * @param string|array $program Der Programmschlüssel oder das bereits abgerufene Programmdaten-Array. + * @param bool $clearSelectionOnFailure Bei fehlenden Programmdaten die Programmauswahl + * zurücksetzen (Standard, Selected-Program-Pfad) oder + * unverändert lassen (Active-Program-Pfad). */ - private function updateOptionVariables($program) + private function updateOptionVariables($program, $clearSelectionOnFailure = true) { $rawOptions = $this->resolveProgramData($program); $this->SendDebug('RawOptions', json_encode($rawOptions), 0); if (!$rawOptions) { - $this->SetValue('SelectedProgram', ''); - $this->setOptionsDisabled(true); - $this->syncUseDurationVariable(false, 0); + if ($clearSelectionOnFailure) { + $this->SetValue('SelectedProgram', ''); + $this->setOptionsDisabled(true); + $this->syncUseDurationVariable(false, 0); + } return; } $this->setOptionsDisabled(false); @@ -868,6 +947,130 @@ private function updateOptionValues($program) $this->WriteAttributeString('OptionKeys', json_encode($optionKeys)); } + /** + * Mirrors BSH.Common.Root.ActiveProgram events into a read-only display variable. + * Some appliances (e.g. hood fan run-on / interval venting) report the running + * program only via ActiveProgram, never via SelectedProgram. The variable is + * created on the first event, so it only shows up on devices that actually + * report an active program. A null value (program finished) clears the display. + */ + private function updateActiveProgram($value) + { + $ident = 'ActiveProgram'; + if (!@IPS_GetObjectIDByIdent($ident, $this->InstanceID)) { + if (!is_string($value) || $value == '') { + // Do not create the variable just to show "nothing running". + return; + } + } + if (is_string($value) && $value != '') { + // Ensure a readable display name for the reported program. Some programs + // never appear under programs/available (see EVENT_ONLY_PROGRAM_NAMES), + // so createPrograms() cannot add them to the profile - e.g. a hob's + // profile stays completely empty and the variable would show the raw key. + $profileName = $this->ensureProgramAssociation($value); + // Not only on creation: upgrades an ActiveProgram variable created + // without a profile by an earlier build (MaintainVariable is idempotent). + $this->MaintainVariable($ident, $this->Translate('Active Program'), VARIABLETYPE_STRING, $profileName, 2, true); + } + $newValue = is_string($value) ? $value : ''; + $changed = $this->GetValue($ident) != $newValue; + $this->SetValue($ident, $newValue); + $this->SendDebug(__FUNCTION__, $newValue != '' ? $newValue : '(cleared)', 0); + if (!$changed) { + return; + } + if ($newValue == '') { + // Program finished - drop values buffered for it. + $this->SetBuffer('PendingOptionValues', ''); + return; + } + // Locally operated appliances (e.g. hoods) report their program only via + // ActiveProgram - no SelectedProgram event ever creates the option variables, + // so trigger that from here. Decoupled from the event thread (see + // RefreshSelectedProgram) and only on a program change, so repeated events + // for the same program do not cost extra server requests. + $this->RegisterOnceTimer('RefreshActiveProgramOptions', 'IPS_RequestAction($_IPS[\'TARGET\'], "RefreshActiveProgramOptions", "");'); + } + + /** + * Makes sure the device-type Programs profile exists and contains an association + * for the given program key, so ActiveProgram displays a readable name instead of + * the raw key. Returns the profile name. + */ + private function ensureProgramAssociation($key) + { + $profileName = 'HomeConnect.' . $this->ReadPropertyString('DeviceType') . '.Programs'; + if (!IPS_VariableProfileExists($profileName)) { + IPS_CreateVariableProfile($profileName, VARIABLETYPE_STRING); + } + foreach (IPS_GetVariableProfile($profileName)['Associations'] as $association) { + if ($association['Value'] === $key) { + return $profileName; + } + } + if (isset(self::EVENT_ONLY_PROGRAM_NAMES[$key])) { + $displayName = $this->Translate(self::EVENT_ONLY_PROGRAM_NAMES[$key]); + } elseif (preg_match('/^BSH\.Common\.Program\.Favorite\.(?P\d+)$/', $key, $matches)) { + // The snippet fallback would show the bare number ("003"). + $displayName = sprintf($this->Translate('Favorite %d'), (int) $matches['number']); + } else { + $displayName = $this->getLastSnippet($key); + } + IPS_SetVariableProfileAssociation($profileName, $key, $displayName, '', -1); + return $profileName; + } + + /** + * Remembers an option value received via event while its variable does not exist + * yet. refreshActiveProgramOptions() applies the buffered values once the + * variables are created; without this, a freshly created option variable would + * stay empty until the appliance sends the next change (e.g. the hood's venting + * level, which is only reported again when the stage changes). + */ + private function rememberPendingOptionValue($ident, $value) + { + $pending = json_decode($this->GetBuffer('PendingOptionValues'), true); + if (!is_array($pending)) { + $pending = []; + } + $pending[$ident] = $value; + $this->SetBuffer('PendingOptionValues', json_encode($pending)); + } + + /** + * Creates the option variables for the program reported via ActiveProgram. Hoods + * (and other locally operated appliances) never send a SelectedProgram event, so + * the regular option refresh does not run for them and events like VentingLevel + * had no variable to update. Uses the available-program metadata (one server + * request per program change) for proper profiles and constraints and + * deliberately leaves SelectedProgram untouched - its value feeds the Start + * payload. Undocumented runtime programs (e.g. an oven's ContinueCooking) are + * not listed under programs/available; then nothing is created and - unlike the + * selected-program path - nothing is cleared either. + */ + private function refreshActiveProgramOptions() + { + if ($this->ReadPropertyString('HaID') == '' || !@IPS_GetObjectIDByIdent('ActiveProgram', $this->InstanceID)) { + return; + } + $key = $this->GetValue('ActiveProgram'); + if (!is_string($key) || $key == '') { + return; + } + $this->updateOptionVariables($key, false); + $pending = json_decode($this->GetBuffer('PendingOptionValues'), true); + $this->SetBuffer('PendingOptionValues', ''); + if (!is_array($pending)) { + return; + } + foreach ($pending as $ident => $value) { + if (@IPS_GetObjectIDByIdent($ident, $this->InstanceID)) { + $this->SetValue($ident, $value); + } + } + } + /** * Enrich selected program data with the available-program metadata so variable * creation can rely on stable option types and constraints while keeping the diff --git a/library.json b/library.json index 513743d..b765bc4 100644 --- a/library.json +++ b/library.json @@ -7,6 +7,6 @@ "version": "6.0" }, "version": "1.1", - "build": 19, - "date": 1783346061 + "build": 24, + "date": 1785663591 } diff --git a/tests/HomeConnectCloudTest.php b/tests/HomeConnectCloudTest.php index c35b5fd..85ad700 100644 --- a/tests/HomeConnectCloudTest.php +++ b/tests/HomeConnectCloudTest.php @@ -241,6 +241,80 @@ public function testSuccessClearsPendingRateLimitAndResumes() $this->assertStringContainsString('homeappliances/events', IPS_GetProperty($parent, 'URL'), 'Stream must resume after the block clears'); } + /** + * Watchdog backoff: on a dead stream the keep-alive watchdog must not re-register + * every 2 minutes forever (~700 GET /events per day, keeping the "1000 calls in + * 1 day" quota exhausted for good). Attempts must back off exponentially. + */ + public function testCheckServerEventsBacksOffWhileStreamStaysDead() + { + $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', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + + //First failure: reconnects immediately and schedules the next attempt in 120s. + $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); + $cloud->CheckServerEvents(); + $this->assertStringContainsString('homeappliances/events', IPS_GetProperty($parent, 'URL'), 'First failure must reconnect'); + $this->assertSame('1', (string) $this->invoke($cloud, 'GetBuffer', 'WatchdogRetries')); + + //Still within the backoff window: no further reconnect. + IPS_SetProperty($parent, 'URL', ''); + IPS_ApplyChanges($parent); + $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); + $cloud->CheckServerEvents(); + $this->assertSame('', IPS_GetProperty($parent, 'URL'), 'Within the backoff window the watchdog must not reconnect'); + + //Backoff window elapsed: reconnects again and doubles the delay (120s -> 240s). + $this->invoke($cloud, 'SetBuffer', 'WatchdogNextRetry', (string) (time() - 1)); + $cloud->CheckServerEvents(); + $this->assertStringContainsString('homeappliances/events', IPS_GetProperty($parent, 'URL'), 'After the backoff window the watchdog must reconnect'); + $this->assertSame('2', (string) $this->invoke($cloud, 'GetBuffer', 'WatchdogRetries')); + $nextRetry = intval($this->invoke($cloud, 'GetBuffer', 'WatchdogNextRetry')); + $this->assertGreaterThan(time() + 200, $nextRetry, 'Second attempt must schedule the next one ~240s ahead'); + } + + /** + * The watchdog backoff must be capped at 1 hour so a dead stream is still probed + * regularly (~30 requests/day) without ever burning the daily quota. + */ + public function testWatchdogBackoffCappedAtOneHour() + { + $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', 'AccessToken', json_encode(['Token' => 'test', 'Expires' => time() + 3600])); + + $this->invoke($cloud, 'SetBuffer', 'KeepAlive', (string) (time() - 120)); + $this->invoke($cloud, 'SetBuffer', 'WatchdogRetries', '10'); + $cloud->CheckServerEvents(); + + $nextRetry = intval($this->invoke($cloud, 'GetBuffer', 'WatchdogNextRetry')); + $this->assertLessThanOrEqual(time() + 3600, $nextRetry, 'Backoff must be capped at 1 hour'); + $this->assertGreaterThan(time() + 3500, $nextRetry, 'Capped backoff must still be ~1 hour'); + } + + /** + * The first keep-alive after a recovery proves the stream is alive again - it must + * reset the watchdog backoff so a future drop reconnects quickly again. + */ + public function testKeepAliveResetsWatchdogBackoff() + { + $cloud = $this->cloud(); + $this->invoke($cloud, 'SetBuffer', 'WatchdogRetries', '5'); + $this->invoke($cloud, 'SetBuffer', 'WatchdogNextRetry', (string) (time() + 3600)); + + $cloud->ReceiveData('{"Event":"KEEP-ALIVE"}'); + + $this->assertSame('', (string) $this->invoke($cloud, 'GetBuffer', 'WatchdogRetries'), 'A keep-alive must reset the watchdog backoff'); + $this->assertSame('', (string) $this->invoke($cloud, 'GetBuffer', 'WatchdogNextRetry'), 'A keep-alive must clear the pending backoff window'); + } + private function cloud() { return IPS\InstanceManager::getInstanceInterface(IPS_GetInstanceListByModuleID(self::CLOUD_GUID)[0]); diff --git a/tests/HomeConnectFridgeFreezerTest.php b/tests/HomeConnectFridgeFreezerTest.php index 0596129..bf5d7c7 100644 --- a/tests/HomeConnectFridgeFreezerTest.php +++ b/tests/HomeConnectFridgeFreezerTest.php @@ -135,6 +135,31 @@ public function testValueRefreshFetchesStatusAndSettingsOnly() $this->assertNotFalse(@IPS_GetObjectIDByIdent('DoorState', $fridge), 'DoorState remains present'); } + /** + * The API documents that fridge freezers have no programs at all ("There are no + * programs available for Fridge Freezers"), so createPrograms() must not spend a + * request on the guaranteed SDK.Error.UnsupportedOperation from /programs. + */ + public function testCreateProgramsSkipsRequestForProgramlessType() + { + $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); + + $intf = IPS\InstanceManager::getInstanceInterface($fridge); + HomeConnectCloud::$requestCount = 0; + $method = new ReflectionMethod($intf, 'createPrograms'); + $method->setAccessible(true); + $method->invoke($intf); + + $this->assertSame(0, HomeConnectCloud::$requestCount, 'createPrograms must not request /programs for a programless appliance type'); + $this->assertFalse(@IPS_GetObjectIDByIdent('SelectedProgram', $fridge), 'No program selection variable for a programless appliance type'); + } + /** * A CONNECTED event must not refresh synchronously on the event thread (that blocks * ReceiveData -> "Warten auf Skriptresultat fehlgeschlagen"). It arms a one-shot timer diff --git a/tests/HomeConnectHoodTest.php b/tests/HomeConnectHoodTest.php new file mode 100644 index 0000000..8ddf439 --- /dev/null +++ b/tests/HomeConnectHoodTest.php @@ -0,0 +1,262 @@ +ConfiguratorID = IPS_CreateInstance('{CA0E667D-8F28-8DF1-2750-5CF587ECA85A}'); + $cloudID = IPS_GetInstanceListByModuleID('{CE76810D-B685-9BE0-CC04-38B204DEAD5E}')[0]; + IPS\InstanceManager::setStatus($this->ConfiguratorID, 102); + IPS\InstanceManager::setStatus($cloudID, 102); + + parent::setUp(); + } + + public function testActiveProgramEventCreatesAndFillsVariable() + { + $hood = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + $intf = IPS\InstanceManager::getInstanceInterface($hood); + + // Fan run-on reports the running program only via ActiveProgram. + $intf->ReceiveData($this->generateActiveProgramEvent('Cooking.Common.Program.Hood.DelayedShutOff')); + + $variableID = IPS_GetObjectIDByIdent('ActiveProgram', $hood); + $this->assertNotFalse($variableID); + $this->assertEquals(VARIABLETYPE_STRING, IPS_GetVariable($variableID)['VariableType']); + $this->assertEquals('Cooking.Common.Program.Hood.DelayedShutOff', GetValue($variableID)); + } + + public function testActiveProgramNullClearsVariable() + { + $hood = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + $intf = IPS\InstanceManager::getInstanceInterface($hood); + + $intf->ReceiveData($this->generateActiveProgramEvent('Cooking.Common.Program.Hood.DelayedShutOff')); + $intf->ReceiveData($this->generateActiveProgramEvent(null)); + + $variableID = IPS_GetObjectIDByIdent('ActiveProgram', $hood); + $this->assertNotFalse($variableID); + $this->assertEquals('', GetValue($variableID)); + } + + public function testActiveProgramNullWithoutVariableDoesNotCreateIt() + { + $hood = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + $intf = IPS\InstanceManager::getInstanceInterface($hood); + + // A null value (nothing running) must not create the variable. + $intf->ReceiveData($this->generateActiveProgramEvent(null)); + + $this->assertFalse(@IPS_GetObjectIDByIdent('ActiveProgram', $hood)); + } + + /** + * A hood operated at the appliance never sends SelectedProgram, so the option + * variables have to be created from the ActiveProgram event: one lookup of the + * available-program metadata provides the profiles, the option events themselves + * provide the values. + */ + public function testActiveProgramCreatesOptionVariables() + { + $hood = $this->createInitializedHood(); + $intf = IPS\InstanceManager::getInstanceInterface($hood); + + // Idle after init: no program related option variables yet. + $this->assertFalse(@IPS_GetObjectIDByIdent('OptionVentingLevel', $hood)); + + HomeConnectCloud::$requestCount = 0; + // Local program start as captured from a real device: ActiveProgram plus the + // current option values arrive in one NOTIFY batch. + $intf->ReceiveData($this->generateNotifyEvent([ + $this->item('BSH.Common.Setting.PowerState', 'BSH.Common.EnumType.PowerState.On', 'settings/BSH.Common.Setting.PowerState'), + $this->item('BSH.Common.Root.ActiveProgram', 'Cooking.Common.Program.Hood.Automatic', 'programs/active'), + $this->item('Cooking.Common.Option.Hood.VentingLevel', 'Cooking.Hood.EnumType.Stage.FanOff', 'programs/selected/options/Cooking.Common.Option.Hood.VentingLevel'), + $this->item('Cooking.Common.Option.Hood.IntensiveLevel', 'Cooking.Hood.EnumType.IntensiveStage.IntensiveStageOff', 'programs/selected/options/Cooking.Common.Option.Hood.IntensiveLevel'), + ])); + + $this->assertEquals(1, HomeConnectCloud::$requestCount, 'Creating the option variables must cost exactly one request'); + $ventingID = IPS_GetObjectIDByIdent('OptionVentingLevel', $hood); + $this->assertNotFalse($ventingID); + $this->assertEquals('Cooking.Hood.EnumType.Stage.FanOff', GetValue($ventingID)); + $this->assertEquals('Cooking.Hood.EnumType.IntensiveStage.IntensiveStageOff', GetValue(IPS_GetObjectIDByIdent('OptionIntensiveLevel', $hood))); + + // The variable must carry the enum profile built from the constraints. + $profileName = IPS_GetVariable($ventingID)['VariableProfile']; + $this->assertEquals('HomeConnect.Hood.Option.VentingLevel', $profileName); + $associations = []; + foreach (IPS_GetVariableProfile($profileName)['Associations'] as $association) { + $associations[$association['Value']] = $association['Name']; + } + $this->assertEquals('Lüfterstufe 1', $associations['Cooking.Hood.EnumType.Stage.FanStage01']); + + // The program itself must not pollute the (empty) program selection. + $this->assertEquals('', GetValue(IPS_GetObjectIDByIdent('SelectedProgram', $hood))); + + // Subsequent option events update the value without any further request. + $intf->ReceiveData($this->generateNotifyEvent([ + $this->item('Cooking.Common.Option.Hood.VentingLevel', 'Cooking.Hood.EnumType.Stage.FanStage02', 'programs/selected/options/Cooking.Common.Option.Hood.VentingLevel'), + ])); + $this->assertEquals('Cooking.Hood.EnumType.Stage.FanStage02', GetValue($ventingID)); + $this->assertEquals(1, HomeConnectCloud::$requestCount, 'Option value updates must not cost requests'); + + // A repeated ActiveProgram event for the same program must not refetch. + $intf->ReceiveData($this->generateNotifyEvent([ + $this->item('BSH.Common.Root.ActiveProgram', 'Cooking.Common.Program.Hood.Automatic', 'programs/active'), + ])); + $this->assertEquals(1, HomeConnectCloud::$requestCount, 'An unchanged active program must not refetch the options'); + } + + /** + * An option value that arrives before the option variable exists is buffered and + * applied right after the deferred refresh created the variable. + */ + public function testActiveProgramAppliesBufferedOptionValue() + { + $hood = $this->createInitializedHood(); + $intf = IPS\InstanceManager::getInstanceInterface($hood); + + $intf->ReceiveData($this->generateNotifyEvent([ + $this->item('Cooking.Common.Option.Hood.VentingLevel', 'Cooking.Hood.EnumType.Stage.FanStage02', 'programs/selected/options/Cooking.Common.Option.Hood.VentingLevel'), + ])); + $this->assertFalse(@IPS_GetObjectIDByIdent('OptionVentingLevel', $hood)); + + $intf->ReceiveData($this->generateNotifyEvent([ + $this->item('BSH.Common.Root.ActiveProgram', 'Cooking.Common.Program.Hood.Automatic', 'programs/active'), + ])); + + $ventingID = IPS_GetObjectIDByIdent('OptionVentingLevel', $hood); + $this->assertNotFalse($ventingID); + $this->assertEquals('Cooking.Hood.EnumType.Stage.FanStage02', GetValue($ventingID)); + } + + /** + * Regression: a program that is not listed under programs/available (undocumented + * runtime programs like the fan run-on) must neither create variables nor clear + * the program selection - the selected-program failure path used to wipe it. + */ + public function testActiveProgramUnsupportedProgramLeavesSelectionUntouched() + { + $hood = $this->createInitializedHood(); + $intf = IPS\InstanceManager::getInstanceInterface($hood); + + $selectedID = IPS_GetObjectIDByIdent('SelectedProgram', $hood); + SetValue($selectedID, 'Cooking.Common.Program.Hood.Venting'); + + $intf->ReceiveData($this->generateNotifyEvent([ + $this->item('BSH.Common.Root.ActiveProgram', 'Cooking.Common.Program.Hood.DelayedShutOff', 'programs/active'), + ])); + + $this->assertEquals('Cooking.Common.Program.Hood.DelayedShutOff', GetValue(IPS_GetObjectIDByIdent('ActiveProgram', $hood))); + $this->assertFalse(@IPS_GetObjectIDByIdent('OptionVentingLevel', $hood)); + $this->assertEquals('Cooking.Common.Program.Hood.Venting', GetValue($selectedID), 'A failed program lookup must not clear the program selection'); + } + + /** + * Hobs are monitoring-only: the API lists no programs at all, so the Programs + * profile stays empty and ActiveProgram used to display the raw key. The event + * must add a translated association for the reported program instead. + */ + public function testActiveProgramAddsTranslatedProfileAssociation() + { + $hob = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + IPS_SetProperty($hob, 'DeviceType', 'Hob'); + IPS_ApplyChanges($hob); + $intf = IPS\InstanceManager::getInstanceInterface($hob); + + $intf->ReceiveData($this->generateActiveProgramEvent('Cooking.Hob.Program.PowerLevelMode')); + + $variableID = IPS_GetObjectIDByIdent('ActiveProgram', $hob); + $this->assertNotFalse($variableID); + $this->assertEquals('HomeConnect.Hob.Programs', IPS_GetVariable($variableID)['VariableProfile']); + $associations = []; + foreach (IPS_GetVariableProfile('HomeConnect.Hob.Programs')['Associations'] as $association) { + $associations[$association['Value']] = $association['Name']; + } + // The test stub's Translate() returns the text unchanged (English key). + $this->assertEquals('Power level mode', $associations['Cooking.Hob.Program.PowerLevelMode']); + } + + /** + * Favorites are numbered keys (BSH.Common.Program.Favorite.003) - the last + * snippet fallback would display the bare number. + */ + public function testActiveProgramFavoriteGetsNumberedName() + { + $coffee = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + IPS_SetProperty($coffee, 'DeviceType', 'CoffeeMaker'); + IPS_ApplyChanges($coffee); + $intf = IPS\InstanceManager::getInstanceInterface($coffee); + + $intf->ReceiveData($this->generateActiveProgramEvent('BSH.Common.Program.Favorite.003')); + + $associations = []; + foreach (IPS_GetVariableProfile('HomeConnect.CoffeeMaker.Programs')['Associations'] as $association) { + $associations[$association['Value']] = $association['Name']; + } + $this->assertEquals('Favorite 3', $associations['BSH.Common.Program.Favorite.003']); + } + + private function createInitializedHood() + { + $hood = IPS_CreateInstance('{F29DF312-A62E-9989-1F1A-0D1E1D171AD3}'); + IPS_SetProperty($hood, 'HaID', self::HA_ID); + IPS_SetProperty($hood, 'DeviceType', 'Hood'); + IPS_ApplyChanges($hood); + return $hood; + } + + private function item($key, $value, $uriPath) + { + return [ + 'timestamp' => 1753731205, + 'handling' => 'none', + 'uri' => '/api/homeappliances/' . self::HA_ID . '/' . $uriPath, + 'key' => $key, + 'value' => $value, + 'level' => 'hint', + ]; + } + + private function generateNotifyEvent(array $items) + { + return json_encode([ + 'Event' => 'NOTIFY', + 'Data' => json_encode([ + 'items' => $items, + 'haId' => self::HA_ID, + ]), + 'id' => self::HA_ID, + ]); + } + + private function generateActiveProgramEvent($value) + { + return $this->generateNotifyEvent([ + $this->item('BSH.Common.Root.ActiveProgram', $value, 'programs/active'), + ]); + } +} diff --git a/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/available/Cooking.Common.Program.Hood.Automatic/response.json b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/available/Cooking.Common.Program.Hood.Automatic/response.json new file mode 100644 index 0000000..ab81a7d --- /dev/null +++ b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/available/Cooking.Common.Program.Hood.Automatic/response.json @@ -0,0 +1,46 @@ +{ + "data": { + "key": "Cooking.Common.Program.Hood.Automatic", + "options": [ + { + "key": "Cooking.Common.Option.Hood.VentingLevel", + "type": "Cooking.Hood.EnumType.Stage", + "constraints": { + "allowedvalues": [ + "Cooking.Hood.EnumType.Stage.FanOff", + "Cooking.Hood.EnumType.Stage.FanStage01", + "Cooking.Hood.EnumType.Stage.FanStage02", + "Cooking.Hood.EnumType.Stage.FanStage03" + ], + "displayvalues": [ + "Lüfter aus", + "Lüfterstufe 1", + "Lüfterstufe 2", + "Lüfterstufe 3" + ], + "liveupdate": true + }, + "name": "Lüfterstufe" + }, + { + "key": "Cooking.Common.Option.Hood.IntensiveLevel", + "type": "Cooking.Hood.EnumType.IntensiveStage", + "constraints": { + "allowedvalues": [ + "Cooking.Hood.EnumType.IntensiveStage.IntensiveStageOff", + "Cooking.Hood.EnumType.IntensiveStage.IntensiveStage1", + "Cooking.Hood.EnumType.IntensiveStage.IntensiveStage2" + ], + "displayvalues": [ + "Intensivstufe aus", + "Intensivstufe 1", + "Intensivstufe 2" + ], + "liveupdate": true + }, + "name": "Intensivstufe" + } + ], + "name": "Automatik" + } +} diff --git a/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/available/Cooking.Common.Program.Hood.DelayedShutOff/response.json b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/available/Cooking.Common.Program.Hood.DelayedShutOff/response.json new file mode 100644 index 0000000..6f31593 --- /dev/null +++ b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/available/Cooking.Common.Program.Hood.DelayedShutOff/response.json @@ -0,0 +1,6 @@ +{ + "error": { + "key": "SDK.Error.UnsupportedProgram", + "description": "The program is not supported" + } +} diff --git a/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/response.json b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/response.json new file mode 100644 index 0000000..9e74f46 --- /dev/null +++ b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/programs/response.json @@ -0,0 +1,30 @@ +{ + "data": { + "programs": [ + { + "key": "Cooking.Common.Program.Hood.Automatic", + "name": "Automatik", + "constraints": { + "execution": "startonly", + "available": true + } + }, + { + "key": "Cooking.Common.Program.Hood.Venting", + "name": "Lüften", + "constraints": { + "execution": "startonly", + "available": true + } + }, + { + "key": "Cooking.Common.Program.Hood.DelayedShutOff", + "name": "Lüfternachlauf", + "constraints": { + "execution": "startonly", + "available": true + } + } + ] + } +} diff --git a/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/settings/BSH.Common.Setting.PowerState/response.json b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/settings/BSH.Common.Setting.PowerState/response.json new file mode 100644 index 0000000..c9adb39 --- /dev/null +++ b/tests/homeappliances/SIEMENS-LD88WMM66-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.Off", + "access": "readWrite" + }, + "type": "BSH.Common.EnumType.PowerState", + "displayvalue": "Aus", + "value": "BSH.Common.EnumType.PowerState.Off" + } +} diff --git a/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/settings/response.json b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/settings/response.json new file mode 100644 index 0000000..c5c052d --- /dev/null +++ b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/settings/response.json @@ -0,0 +1,12 @@ +{ + "data": { + "settings": [ + { + "key": "BSH.Common.Setting.PowerState", + "value": "BSH.Common.EnumType.PowerState.Off", + "name": "Energiezustand", + "displayvalue": "Aus" + } + ] + } +} diff --git a/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/status/response.json b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/status/response.json new file mode 100644 index 0000000..7b70774 --- /dev/null +++ b/tests/homeappliances/SIEMENS-LD88WMM66-XYZ/status/response.json @@ -0,0 +1,27 @@ +{ + "data": { + "status": [ + { + "key": "BSH.Common.Status.LocalControlActive", + "value": false, + "name": "Lokale Bedienung aktiv" + }, + { + "key": "BSH.Common.Status.RemoteControlStartAllowed", + "value": true, + "name": "Fernstart" + }, + { + "key": "BSH.Common.Status.RemoteControlActive", + "value": true, + "name": "Fernbedienung" + }, + { + "key": "BSH.Common.Status.OperationState", + "value": "BSH.Common.EnumType.OperationState.Inactive", + "name": "Betriebsstatus", + "displayvalue": "Inaktiv" + } + ] + } +}