feat: Prognosetelemetrie aus Demoanlage senden
Tests / test (push) Successful in 48s

This commit is contained in:
dh
2026-09-23 06:40:17 +00:00
parent f250dea59a
commit efa8fe698c
7 changed files with 227 additions and 7 deletions
+21 -3
View File
@@ -539,9 +539,27 @@
"caption": "Prognose / Forecast", "caption": "Prognose / Forecast",
"items": [ "items": [
{ {
"type": "ValidationTextBox", "type": "SelectVariable",
"name": "PrognoseAnschluss", "name": "PrognosePVVariableID",
"caption": "Konfiguration als JSON" "caption": "PV-Leistung"
},
{
"type": "SelectVariable",
"name": "PrognoseHausverbrauchVariableID",
"caption": "Hausverbrauch"
},
{
"type": "SelectVariable",
"name": "PrognoseSOCVariableID",
"caption": "Batterie-Ladezustand"
},
{
"type": "NumberSpinner",
"name": "PrognoseSendeintervall",
"caption": "Sendeintervall",
"minimum": 60,
"maximum": 3600,
"suffix": " Sekunden"
} }
] ]
}, },
+165 -1
View File
@@ -22,6 +22,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
private const STATUS_LIZENZ_UNGUELTIG = 203; private const STATUS_LIZENZ_UNGUELTIG = 203;
private const LIZENZ_ENDPOINT = 'https://license.enelix.ch/api/v1/licenses/activate'; private const LIZENZ_ENDPOINT = 'https://license.enelix.ch/api/v1/licenses/activate';
private const PROGNOSE_TOPOLOGIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/topology'; private const PROGNOSE_TOPOLOGIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/topology';
private const PROGNOSE_TELEMETRIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/telemetry';
private const LIZENZ_RETRY_SEKUNDEN = 3600; private const LIZENZ_RETRY_SEKUNDEN = 3600;
private const VM_UPDATE = 10603; private const VM_UPDATE = 10603;
private const VERBRAUCHER_LIZENZEN = [ private const VERBRAUCHER_LIZENZEN = [
@@ -97,6 +98,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterPropertyString('AnlagenWechselrichter', '[]'); $this->RegisterPropertyString('AnlagenWechselrichter', '[]');
$this->RegisterPropertyString('AnlagenPVFlaechen', '[]'); $this->RegisterPropertyString('AnlagenPVFlaechen', '[]');
$this->RegisterPropertyString('AnlagenBatterien', '[]'); $this->RegisterPropertyString('AnlagenBatterien', '[]');
$this->RegisterPropertyInteger('PrognosePVVariableID', 0);
$this->RegisterPropertyInteger('PrognoseHausverbrauchVariableID', 0);
$this->RegisterPropertyInteger('PrognoseSOCVariableID', 0);
$this->RegisterPropertyInteger('PrognoseSendeintervall', 60);
$this->RegisterPropertyString('PrognoseAnschluss', '{}'); $this->RegisterPropertyString('PrognoseAnschluss', '{}');
$this->RegisterPropertyString('SDLAnschluss', '{}'); $this->RegisterPropertyString('SDLAnschluss', '{}');
$this->RegisterPropertyString('Lizenzcode', ''); $this->RegisterPropertyString('Lizenzcode', '');
@@ -130,6 +135,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
0, 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', true);" "IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', true);"
); );
$this->RegisterTimer(
'PrognoseSenden',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'PrognoseSenden', true);"
);
} }
public function ApplyChanges(): void public function ApplyChanges(): void
@@ -146,6 +156,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->SetTimerInterval('Regelzyklus', 0); $this->SetTimerInterval('Regelzyklus', 0);
$this->SetTimerInterval('VorgabenErneuern', 0); $this->SetTimerInterval('VorgabenErneuern', 0);
$this->SetTimerInterval('KeepAlive', 0); $this->SetTimerInterval('KeepAlive', 0);
$this->SetTimerInterval('PrognoseSenden', 0);
$this->protokolliere('Konfiguration', $fehler->getMessage()); $this->protokolliere('Konfiguration', $fehler->getMessage());
return; return;
} }
@@ -154,9 +165,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->SetTimerInterval('Regelzyklus', 0); $this->SetTimerInterval('Regelzyklus', 0);
$this->SetTimerInterval('VorgabenErneuern', 0); $this->SetTimerInterval('VorgabenErneuern', 0);
$this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000); $this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000);
$this->SetTimerInterval('PrognoseSenden', 0);
$this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen')); $this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen'));
$this->aktualisiereAnschlussstatus(); $this->aktualisiereAnschlussstatus();
$geraetezugangFehlt = $this->hatAnlagentopologie() $geraetezugangFehlt = ($this->hatAnlagentopologie() || $this->prognoseTelemetrieKonfiguriert())
&& $this->ReadAttributeString('PrognoseInstallationsToken') === ''; && $this->ReadAttributeString('PrognoseInstallationsToken') === '';
if (!$this->aktualisiereLizenzfreigabe( if (!$this->aktualisiereLizenzfreigabe(
$this->ReadPropertyString('Lizenzcode'), $this->ReadPropertyString('Lizenzcode'),
@@ -176,6 +188,13 @@ class Manager extends IPSModule implements ManagerSchnittstelle
} }
$this->SetStatus(self::STATUS_AKTIV); $this->SetStatus(self::STATUS_AKTIV);
$this->synchronisiereAnlagentopologie(); $this->synchronisiereAnlagentopologie();
if ($this->prognoseTelemetrieKonfiguriert()) {
$this->SetTimerInterval(
'PrognoseSenden',
$this->ReadPropertyInteger('PrognoseSendeintervall') * 1000
);
$this->sendePrognoseTelemetrie();
}
} }
public function GetConfigurationForm(): string public function GetConfigurationForm(): string
@@ -295,6 +314,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->regeln((bool) $wert); $this->regeln((bool) $wert);
return; return;
case 'PrognoseSenden':
$this->sendePrognoseTelemetrie();
return;
case 'FormLizenzPruefen': case 'FormLizenzPruefen':
if (!is_string($wert)) { if (!is_string($wert)) {
throw new InvalidArgumentException('Der Lizenzcode muss als Text uebergeben werden.'); throw new InvalidArgumentException('Der Lizenzcode muss als Text uebergeben werden.');
@@ -1447,6 +1470,29 @@ class Manager extends IPSModule implements ManagerSchnittstelle
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.'); throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
} }
} }
$intervall = $this->ReadPropertyInteger('PrognoseSendeintervall');
if ($intervall < 60 || $intervall > 3600) {
throw new InvalidArgumentException('PrognoseSendeintervall muss zwischen 60 und 3600 Sekunden liegen.');
}
$telemetrieIDs = [
$this->ReadPropertyInteger('PrognosePVVariableID'),
$this->ReadPropertyInteger('PrognoseHausverbrauchVariableID'),
$this->ReadPropertyInteger('PrognoseSOCVariableID'),
];
$konfigurierteIDs = array_values(array_filter($telemetrieIDs, static fn (int $id): bool => $id > 0));
if ($konfigurierteIDs !== [] && count($konfigurierteIDs) !== count($telemetrieIDs)) {
throw new InvalidArgumentException('Fuer die Prognose muessen PV, Hausverbrauch und SOC gemeinsam konfiguriert sein.');
}
foreach ($konfigurierteIDs as $variableID) {
if (!IPS_VariableExists($variableID)) {
throw new InvalidArgumentException('Eine konfigurierte Prognosevariable existiert nicht.');
}
}
if ($konfigurierteIDs !== []
&& !IPS_VariableExists($this->ReadPropertyInteger('NetzleistungVariableID'))
) {
throw new InvalidArgumentException('Die Netzleistungsvariable fuer die Prognose existiert nicht.');
}
if ($this->ReadPropertyFloat('Umschaltdifferenz') < 0 if ($this->ReadPropertyFloat('Umschaltdifferenz') < 0
|| $this->ReadPropertyFloat('Umschaltdifferenz') > 100 || $this->ReadPropertyFloat('Umschaltdifferenz') > 100
) { ) {
@@ -1484,6 +1530,13 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|| $topologie['batteries'] !== []; || $topologie['batteries'] !== [];
} }
private function prognoseTelemetrieKonfiguriert(): bool
{
return $this->ReadPropertyInteger('PrognosePVVariableID') > 0
&& $this->ReadPropertyInteger('PrognoseHausverbrauchVariableID') > 0
&& $this->ReadPropertyInteger('PrognoseSOCVariableID') > 0;
}
private function synchronisiereAnlagentopologie(): void private function synchronisiereAnlagentopologie(): void
{ {
try { try {
@@ -1572,6 +1625,117 @@ class Manager extends IPSModule implements ManagerSchnittstelle
} }
} }
private function sendePrognoseTelemetrie(): void
{
if (!$this->prognoseTelemetrieKonfiguriert()) {
return;
}
try {
$token = trim($this->ReadAttributeString('PrognoseInstallationsToken'));
if ($token === '') {
throw new RuntimeException('Der Geraetezugang fuer die Prognose fehlt.');
}
$werte = [
'PV' => GetValue($this->ReadPropertyInteger('PrognosePVVariableID')),
'Hausverbrauch' => GetValue($this->ReadPropertyInteger('PrognoseHausverbrauchVariableID')),
'Netzleistung' => GetValue($this->ReadPropertyInteger('NetzleistungVariableID')),
'SOC' => GetValue($this->ReadPropertyInteger('PrognoseSOCVariableID')),
];
foreach ($werte as $name => $wert) {
if (!is_int($wert) && !is_float($wert)) {
throw new RuntimeException('Der Prognosewert ' . $name . ' ist keine Zahl.');
}
$werte[$name] = (float) $wert;
if (!is_finite($werte[$name])) {
throw new RuntimeException('Der Prognosewert ' . $name . ' ist nicht endlich.');
}
}
$werte['Netzleistung'] *= $this->ReadPropertyFloat('Netzleistungsfaktor');
if ($werte['PV'] < 0.0 || $werte['Hausverbrauch'] < 0.0
|| $werte['SOC'] < 0.0 || $werte['SOC'] > 100.0
) {
throw new RuntimeException('Mindestens ein Prognosewert liegt ausserhalb des gueltigen Bereichs.');
}
$installationID = $this->ReadAttributeString('LizenzInstallationID');
$nutzlast = [
'version' => '1.0',
'installationId' => $installationID,
'capturedAt' => gmdate('Y-m-d\TH:i:s\Z'),
'values' => $werte,
];
$this->uebertragePrognoseTelemetrie($installationID, $nutzlast, $token);
$this->setzeDiagnosewert('Prognosestatus', 'Verbunden');
} catch (Throwable $fehler) {
$meldung = substr($fehler->getMessage(), 0, 500);
$this->setzeDiagnosewert('Prognosestatus', 'Fehler: ' . $meldung);
$this->protokolliere('Prognosetelemetrie', $meldung);
}
}
/** @param array<string, mixed> $nutzlast */
private function uebertragePrognoseTelemetrie(
string $installationID,
array $nutzlast,
string $token
): void {
$url = sprintf(self::PROGNOSE_TELEMETRIE_ENDPOINT, rawurlencode($installationID));
$json = json_encode(
$nutzlast,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION
);
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Die Prognosetelemetrie konnte nicht vorbereitet werden.');
}
try {
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
],
CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.2',
]);
$antwort = curl_exec($curl);
if ($antwort === false) {
throw new RuntimeException('Prognosedienst nicht erreichbar: ' . curl_error($curl));
}
if (strlen($antwort) > 65536) {
throw new RuntimeException('Die Antwort der Prognosetelemetrie ist zu gross.');
}
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
} finally {
curl_close($curl);
}
if ($httpStatus === 401 || $httpStatus === 403) {
$this->WriteAttributeString('PrognoseInstallationsToken', '');
}
if ($httpStatus !== 200) {
$meldung = 'Unbekannter Fehler';
try {
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
if (is_array($daten) && is_string($daten['error'] ?? null)) {
$meldung = $daten['error'];
}
} catch (Throwable $fehler) {
// Der HTTP-Status bleibt die fuehrende Fehlerinformation.
}
throw new RuntimeException(
'Prognosetelemetrie HTTP ' . $httpStatus . ': ' . $meldung
);
}
}
/** @return array<int, float> */ /** @return array<int, float> */
private function dekodiereMonatsgrenzen(string $json): array private function dekodiereMonatsgrenzen(string $json): array
{ {
+14 -1
View File
@@ -50,7 +50,11 @@ Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netz
| `AnlagenWechselrichter` | String/JSON | `[]`; Wechselrichter mit Typ und AC-Grenzen. | | `AnlagenWechselrichter` | String/JSON | `[]`; Wechselrichter mit Typ und AC-Grenzen. |
| `AnlagenPVFlaechen` | String/JSON | `[]`; PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichter-ID. | | `AnlagenPVFlaechen` | String/JSON | `[]`; PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichter-ID. |
| `AnlagenBatterien` | String/JSON | `[]`; Batteriespeicher mit Kapazitaeten, Leistungen, Kopplung und Wechselrichter-ID. | | `AnlagenBatterien` | String/JSON | `[]`; Batteriespeicher mit Kapazitaeten, Leistungen, Kopplung und Wechselrichter-ID. |
| `PrognoseAnschluss` | String/JSON | Optionaler Forecast-Anschluss. | | `PrognosePVVariableID` | Integer | `0`; PV-Leistung in W fuer die Prognosetelemetrie. |
| `PrognoseHausverbrauchVariableID` | Integer | `0`; Hausverbrauch in W fuer die Prognosetelemetrie. |
| `PrognoseSOCVariableID` | Integer | `0`; Batterie-Ladezustand in Prozent. |
| `PrognoseSendeintervall` | Integer | `60` s; zulaessig sind 60 bis 3600 Sekunden. |
| `PrognoseAnschluss` | String/JSON | Verdeckte Altproperty fuer bestehende Konfigurationen. |
| `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. | | `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. |
| `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. | | `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. |
| `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. | | `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. |
@@ -87,6 +91,15 @@ internen Attribut. Der Lizenzcode wird nicht als API-Token verwendet. Ein
Synchronisationsfehler erscheint im `Prognosestatus`, blockiert die lokale Synchronisationsfehler erscheint im `Prognosestatus`, blockiert die lokale
EMS-Regelung aber nicht. EMS-Regelung aber nicht.
Sind PV-Leistung, Hausverbrauch und Batterie-SOC gemeinsam konfiguriert, sendet
der Manager diese Werte zusammen mit der faktorbereinigten Netzleistung und
einem UTC-Zeitstempel im eingestellten Intervall. Das Minimum von 60 Sekunden
passt zum Rate-Limit des Lizenzportals. Die Uebertragung nutzt denselben
widerrufbaren Installationszugang wie die Topologie; Lizenzcode und Geraet Token
werden weder als Telemetriefelder noch im Debug-Log ausgegeben. Bei HTTP 401
oder 403 verwirft der Manager den Geraetezugang und fordert ihn bei der naechsten
Lizenzpruefung neu an.
Die Entscheidung und ihre Alternativen sind in Die Entscheidung und ihre Alternativen sind in
[ADR 0005](../../adr/0005-anlagentopologie-fuer-prognosen.md) dokumentiert. [ADR 0005](../../adr/0005-anlagentopologie-fuer-prognosen.md) dokumentiert.
+5 -1
View File
@@ -9,6 +9,8 @@ Die Demo erzeugt idempotent eine vollständige, spielbare EMS-Anlage in IP-Symco
- einen bidirektionalen Batteriespeicher (10 kWh, +/-3,5 kW), - einen bidirektionalen Batteriespeicher (10 kWh, +/-3,5 kW),
- eine technische Anlagentopologie mit Hybridwechselrichter, PV-Fläche und - eine technische Anlagentopologie mit Hybridwechselrichter, PV-Fläche und
Batteriespeicher für die Prognoseanbindung, Batteriespeicher für die Prognoseanbindung,
- Prognosetelemetrie aus PV, Hausverbrauch, Netzleistung und Batterie-SOC im
60-Sekunden-Intervall,
- simulierte PV-Erzeugung, variable Bewölkung, Tageslastgang, Zusatzlast, - simulierte PV-Erzeugung, variable Bewölkung, Tageslastgang, Zusatzlast,
Außentemperatur, Wärmeverluste, Netzleistung und Speicherzustände, Außentemperatur, Wärmeverluste, Netzleistung und Speicherzustände,
- native Symcon-Instanz `Energy Distribution` für acht Energieflussknoten, - native Symcon-Instanz `Energy Distribution` für acht Energieflussknoten,
@@ -45,7 +47,9 @@ Die Demo übernimmt die einstellbare PV-Spitzenleistung als AC- und
DC-Anlagenleistung. Für die feste Beispieltopologie gelten 30 Grad Neigung und DC-Anlagenleistung. Für die feste Beispieltopologie gelten 30 Grad Neigung und
Südausrichtung. Der 10-kWh-Speicher nutzt den gemeinsamen Hybridwechselrichter; Südausrichtung. Der 10-kWh-Speicher nutzt den gemeinsamen Hybridwechselrichter;
seine dynamischen Lade- und Entladegrenzen werden beim Bootstrap in die seine dynamischen Lade- und Entladegrenzen werden beim Bootstrap in die
technischen Stammdaten übernommen. technischen Stammdaten übernommen. Bei aktiver Manager-Lizenz uebertraegt der
Manager zusaetzlich PV-Leistung, Hausverbrauch, Netzleistung und Batterie-SOC
alle 60 Sekunden an den Enelix-Prognosedienst.
## Bedienung ## Bedienung
+4
View File
@@ -313,6 +313,10 @@ function enelixDemoInstall(): array
$managerProperties = [ $managerProperties = [
'NetzleistungVariableID' => $grid, 'NetzleistungVariableID' => $grid,
'Netzleistungsfaktor' => 1.0, 'Netzleistungsfaktor' => 1.0,
'PrognosePVVariableID' => $pvDisplay,
'PrognoseHausverbrauchVariableID' => $houseDisplay,
'PrognoseSOCVariableID' => $batterySoc,
'PrognoseSendeintervall' => 60,
'MesswertMaxAlter' => 10, 'MesswertMaxAlter' => 10,
'AutomatischeSuche' => false, 'AutomatischeSuche' => false,
'SuchbereichID' => $root, 'SuchbereichID' => $root,
+4
View File
@@ -119,6 +119,10 @@ final class DemoanlageBootstrapTest extends TestCase
self::assertStringContainsString("'AnlagenWechselrichter'", $source); self::assertStringContainsString("'AnlagenWechselrichter'", $source);
self::assertStringContainsString("'AnlagenPVFlaechen'", $source); self::assertStringContainsString("'AnlagenPVFlaechen'", $source);
self::assertStringContainsString("'AnlagenBatterien'", $source); self::assertStringContainsString("'AnlagenBatterien'", $source);
self::assertStringContainsString("'PrognosePVVariableID' => \$pvDisplay", $source);
self::assertStringContainsString("'PrognoseHausverbrauchVariableID' => \$houseDisplay", $source);
self::assertStringContainsString("'PrognoseSOCVariableID' => \$batterySoc", $source);
self::assertStringContainsString("'PrognoseSendeintervall' => 60", $source);
self::assertStringContainsString("'demo-hybrid-1'", $source); self::assertStringContainsString("'demo-hybrid-1'", $source);
self::assertStringContainsString("'demo-pv-1'", $source); self::assertStringContainsString("'demo-pv-1'", $source);
self::assertStringContainsString("'demo-battery-1'", $source); self::assertStringContainsString("'demo-battery-1'", $source);
+14 -1
View File
@@ -59,6 +59,10 @@ final class ManagerModulstrukturTest extends TestCase
'AnlagenWechselrichter', 'AnlagenWechselrichter',
'AnlagenPVFlaechen', 'AnlagenPVFlaechen',
'AnlagenBatterien', 'AnlagenBatterien',
'PrognosePVVariableID',
'PrognoseHausverbrauchVariableID',
'PrognoseSOCVariableID',
'PrognoseSendeintervall',
] as $property) { ] as $property) {
self::assertStringContainsString($property, $json); self::assertStringContainsString($property, $json);
} }
@@ -190,7 +194,7 @@ final class ManagerModulstrukturTest extends TestCase
self::assertStringContainsString("'issueDeviceToken' =>", $inhalt); self::assertStringContainsString("'issueDeviceToken' =>", $inhalt);
self::assertStringContainsString("unset(\$neueLease['deviceToken'])", $inhalt); self::assertStringContainsString("unset(\$neueLease['deviceToken'])", $inhalt);
self::assertStringContainsString( self::assertStringContainsString(
"\$geraetezugangFehlt = \$this->hatAnlagentopologie()", "\$geraetezugangFehlt = (\$this->hatAnlagentopologie() || \$this->prognoseTelemetrieKonfiguriert())",
$inhalt $inhalt
); );
self::assertStringContainsString( self::assertStringContainsString(
@@ -198,6 +202,15 @@ final class ManagerModulstrukturTest extends TestCase
$inhalt $inhalt
); );
self::assertStringContainsString('synchronisiereAnlagentopologie()', $inhalt); self::assertStringContainsString('synchronisiereAnlagentopologie()', $inhalt);
self::assertStringContainsString(
'https://license.enelix.ch/api/v1/installations/%s/prognosis/telemetry',
$inhalt
);
self::assertStringContainsString("'PrognoseSenden',", $inhalt);
self::assertStringContainsString("'capturedAt' => gmdate(", $inhalt);
foreach (["'PV'", "'Hausverbrauch'", "'Netzleistung'", "'SOC'"] as $messwert) {
self::assertStringContainsString($messwert, $inhalt);
}
self::assertStringContainsString("'Authorization: Bearer ' . \$token", $inhalt); self::assertStringContainsString("'Authorization: Bearer ' . \$token", $inhalt);
self::assertStringNotContainsString( self::assertStringNotContainsString(
"protokolliere('Prognosesynchronisation', \$token)", "protokolliere('Prognosesynchronisation', \$token)",