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",
"items": [
{
"type": "ValidationTextBox",
"name": "PrognoseAnschluss",
"caption": "Konfiguration als JSON"
"type": "SelectVariable",
"name": "PrognosePVVariableID",
"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 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_TELEMETRIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/telemetry';
private const LIZENZ_RETRY_SEKUNDEN = 3600;
private const VM_UPDATE = 10603;
private const VERBRAUCHER_LIZENZEN = [
@@ -97,6 +98,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterPropertyString('AnlagenWechselrichter', '[]');
$this->RegisterPropertyString('AnlagenPVFlaechen', '[]');
$this->RegisterPropertyString('AnlagenBatterien', '[]');
$this->RegisterPropertyInteger('PrognosePVVariableID', 0);
$this->RegisterPropertyInteger('PrognoseHausverbrauchVariableID', 0);
$this->RegisterPropertyInteger('PrognoseSOCVariableID', 0);
$this->RegisterPropertyInteger('PrognoseSendeintervall', 60);
$this->RegisterPropertyString('PrognoseAnschluss', '{}');
$this->RegisterPropertyString('SDLAnschluss', '{}');
$this->RegisterPropertyString('Lizenzcode', '');
@@ -130,6 +135,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', true);"
);
$this->RegisterTimer(
'PrognoseSenden',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'PrognoseSenden', true);"
);
}
public function ApplyChanges(): void
@@ -146,6 +156,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->SetTimerInterval('Regelzyklus', 0);
$this->SetTimerInterval('VorgabenErneuern', 0);
$this->SetTimerInterval('KeepAlive', 0);
$this->SetTimerInterval('PrognoseSenden', 0);
$this->protokolliere('Konfiguration', $fehler->getMessage());
return;
}
@@ -154,9 +165,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->SetTimerInterval('Regelzyklus', 0);
$this->SetTimerInterval('VorgabenErneuern', 0);
$this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000);
$this->SetTimerInterval('PrognoseSenden', 0);
$this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen'));
$this->aktualisiereAnschlussstatus();
$geraetezugangFehlt = $this->hatAnlagentopologie()
$geraetezugangFehlt = ($this->hatAnlagentopologie() || $this->prognoseTelemetrieKonfiguriert())
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '';
if (!$this->aktualisiereLizenzfreigabe(
$this->ReadPropertyString('Lizenzcode'),
@@ -176,6 +188,13 @@ class Manager extends IPSModule implements ManagerSchnittstelle
}
$this->SetStatus(self::STATUS_AKTIV);
$this->synchronisiereAnlagentopologie();
if ($this->prognoseTelemetrieKonfiguriert()) {
$this->SetTimerInterval(
'PrognoseSenden',
$this->ReadPropertyInteger('PrognoseSendeintervall') * 1000
);
$this->sendePrognoseTelemetrie();
}
}
public function GetConfigurationForm(): string
@@ -295,6 +314,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->regeln((bool) $wert);
return;
case 'PrognoseSenden':
$this->sendePrognoseTelemetrie();
return;
case 'FormLizenzPruefen':
if (!is_string($wert)) {
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.');
}
}
$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
|| $this->ReadPropertyFloat('Umschaltdifferenz') > 100
) {
@@ -1484,6 +1530,13 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|| $topologie['batteries'] !== [];
}
private function prognoseTelemetrieKonfiguriert(): bool
{
return $this->ReadPropertyInteger('PrognosePVVariableID') > 0
&& $this->ReadPropertyInteger('PrognoseHausverbrauchVariableID') > 0
&& $this->ReadPropertyInteger('PrognoseSOCVariableID') > 0;
}
private function synchronisiereAnlagentopologie(): void
{
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> */
private function dekodiereMonatsgrenzen(string $json): array
{