Skip to content
Open
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
21 changes: 20 additions & 1 deletion Home Connect Cloud/module.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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)
Expand Down
30 changes: 29 additions & 1 deletion Home Connect Device/locale.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
}
211 changes: 207 additions & 4 deletions Home Connect Device/module.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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'];
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<number>\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
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": 19,
"date": 1783346061
"build": 24,
"date": 1785663591
}
Loading