1810 lines
70 KiB
PHP
1810 lines
70 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../libs/ManagerSchnittstelle.php';
|
|
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
|
|
require_once __DIR__ . '/../libs/ManagerRegler.php';
|
|
require_once __DIR__ . '/../libs/Lizenzpruefung.php';
|
|
require_once __DIR__ . '/../libs/Anlagentopologie.php';
|
|
|
|
use Belevo\EnelixEMS\Anlagentopologie;
|
|
use Belevo\EnelixEMS\Lizenzpruefung;
|
|
use Belevo\EnelixEMS\ManagerRegler;
|
|
use Belevo\EnelixEMS\ManagerSchnittstelle;
|
|
use Belevo\EnelixEMS\Nachrichtenvertrag;
|
|
|
|
class Manager extends IPSModule implements ManagerSchnittstelle
|
|
{
|
|
private const STATUS_AKTIV = 102;
|
|
private const STATUS_MESSWERT_UNGUELTIG = 201;
|
|
private const STATUS_KONFIGURATION_UNGUELTIG = 202;
|
|
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 = [
|
|
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}' => [
|
|
'Katalog' => 'boiler_multi',
|
|
'Name' => 'Boiler mehrstufig',
|
|
],
|
|
'{15879A4E-D0C2-4495-83DE-46E1E462591E}' => [
|
|
'Katalog' => 'consumer_single',
|
|
'Name' => 'Verbraucher einstufig',
|
|
],
|
|
'{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}' => [
|
|
'Katalog' => 'consumer_single',
|
|
'Name' => 'Waermepumpe',
|
|
],
|
|
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}' => [
|
|
'Katalog' => 'ev_charger',
|
|
'Name' => 'EV-Ladestation Stand-Alone',
|
|
],
|
|
'{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}' => [
|
|
'Katalog' => 'ev_charger',
|
|
'Name' => 'EV-Ladestation Gateway',
|
|
],
|
|
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}' => [
|
|
'Katalog' => 'buffer_storage',
|
|
'Name' => 'Pufferspeicher',
|
|
],
|
|
'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}' => [
|
|
'Katalog' => 'battery',
|
|
'Name' => 'Batteriespeicher',
|
|
],
|
|
];
|
|
private const MONATSNAMEN = [
|
|
1 => 'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
|
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
|
];
|
|
private const DIAGNOSE_VARIABLEN = [
|
|
'NetzleistungGueltig',
|
|
'WirksameLastspitzengrenze',
|
|
'Verteilbudget',
|
|
'VerbraucherAnzahl',
|
|
'VerbraucherVerfuegbar',
|
|
'Verbraucherstatus',
|
|
'NichtGeregelteGeraete',
|
|
'Monatsgrenzen',
|
|
'Prognosestatus',
|
|
'SDLStatus',
|
|
'Lizenzstatus',
|
|
'Stoerueberwachungsstatus',
|
|
'Sammelstoerung',
|
|
'Stoertext',
|
|
];
|
|
|
|
public function Create(): void
|
|
{
|
|
parent::Create();
|
|
|
|
$this->RegisterPropertyInteger('Rolle', 0);
|
|
$this->RegisterPropertyInteger('NetzleistungVariableID', 0);
|
|
$this->RegisterPropertyFloat('Netzleistungsfaktor', 1.0);
|
|
$this->RegisterPropertyInteger('MesswertMaxAlter', 60);
|
|
$this->RegisterPropertyString('VerbraucherZuordnung', '[]');
|
|
$this->RegisterPropertyString('AutomatischeVerbraucherZuordnung', '[]');
|
|
$this->RegisterPropertyBoolean('AutomatischeSuche', true);
|
|
$this->RegisterPropertyInteger('SuchbereichID', 0);
|
|
$this->RegisterPropertyInteger('KeepAlive', 60);
|
|
$this->RegisterPropertyInteger('VerbraucherTimeout', 60);
|
|
$this->RegisterPropertyInteger('Lastspitzenmodus', 0);
|
|
$this->RegisterPropertyFloat('Lastspitzengrenze', 0.0);
|
|
$this->RegisterPropertyString('Monatsgrenzen', $this->standardMonatsgrenzen());
|
|
$this->RegisterPropertyFloat('SollwertSolarladen', 0.0);
|
|
$this->RegisterPropertyFloat('Umschaltdifferenz', 5.0);
|
|
$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', '');
|
|
// Verdeckte Altproperty fuer bestehende Instanzkonfigurationen.
|
|
$this->RegisterPropertyString('LizenzAnschluss', '{}');
|
|
$this->RegisterPropertyString('StoermeldeAnschluss', '{}');
|
|
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
|
|
$this->RegisterPropertyBoolean('LoggingEin', false);
|
|
|
|
$this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10);
|
|
$this->EnableAction('Aktiv');
|
|
$this->RegisterVariableString('Betriebsart', 'Betriebsart', '', 20);
|
|
$this->RegisterVariableFloat('Netzleistung', 'Netzleistung', '', 30);
|
|
|
|
$this->RegisterAttributeString('VerbraucherCache', '{}');
|
|
$this->RegisterAttributeString('LetzteSollwerte', '{}');
|
|
$this->RegisterAttributeInteger('RegistrierteNetzleistungVariableID', 0);
|
|
$this->RegisterAttributeString('LizenzInstallationID', '');
|
|
$this->RegisterAttributeString('LizenzLease', '{}');
|
|
$this->RegisterAttributeString('LizenzcodeHash', '');
|
|
$this->RegisterAttributeInteger('LizenzNaechsterVersuch', 0);
|
|
$this->RegisterAttributeInteger('LizenzBerechtigungenNaechsterVersuch', 0);
|
|
$this->RegisterAttributeString('LizenzLetzterFehler', '');
|
|
$this->RegisterAttributeString('PrognoseInstallationsToken', '');
|
|
|
|
// Die alten Timer bleiben deaktiviert registriert, damit bestehende Instanzen sauber migrieren.
|
|
$this->RegisterTimer('Regelzyklus', 0, '');
|
|
$this->RegisterTimer('VorgabenErneuern', 0, '');
|
|
$this->RegisterTimer(
|
|
'KeepAlive',
|
|
0,
|
|
"IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', true);"
|
|
);
|
|
$this->RegisterTimer(
|
|
'PrognoseSenden',
|
|
0,
|
|
"IPS_RequestAction(\$_IPS['TARGET'], 'PrognoseSenden', true);"
|
|
);
|
|
}
|
|
|
|
public function ApplyChanges(): void
|
|
{
|
|
parent::ApplyChanges();
|
|
|
|
$this->initialisiereLizenzInstallationID();
|
|
$this->aktualisiereVariablen();
|
|
|
|
try {
|
|
$this->pruefeKonfiguration();
|
|
} catch (Throwable $fehler) {
|
|
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
|
|
$this->SetTimerInterval('Regelzyklus', 0);
|
|
$this->SetTimerInterval('VorgabenErneuern', 0);
|
|
$this->SetTimerInterval('KeepAlive', 0);
|
|
$this->SetTimerInterval('PrognoseSenden', 0);
|
|
$this->protokolliere('Konfiguration', $fehler->getMessage());
|
|
return;
|
|
}
|
|
|
|
$this->registriereNetzleistungsmeldung();
|
|
$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() || $this->prognoseTelemetrieKonfiguriert())
|
|
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '';
|
|
if (!$this->aktualisiereLizenzfreigabe(
|
|
$this->ReadPropertyString('Lizenzcode'),
|
|
$geraetezugangFehlt
|
|
)) {
|
|
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
|
|
$this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG);
|
|
return;
|
|
}
|
|
try {
|
|
$this->aktiveVerbraucherIDs();
|
|
} catch (Throwable $fehler) {
|
|
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
|
|
$this->setzeDiagnosewert('Lizenzstatus', 'Nicht freigegeben: ' . $fehler->getMessage());
|
|
$this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG);
|
|
return;
|
|
}
|
|
$this->SetStatus(self::STATUS_AKTIV);
|
|
$this->synchronisiereAnlagentopologie();
|
|
if ($this->prognoseTelemetrieKonfiguriert()) {
|
|
$this->SetTimerInterval(
|
|
'PrognoseSenden',
|
|
$this->ReadPropertyInteger('PrognoseSendeintervall') * 1000
|
|
);
|
|
$this->sendePrognoseTelemetrie();
|
|
}
|
|
}
|
|
|
|
public function GetConfigurationForm(): string
|
|
{
|
|
$formular = json_decode(
|
|
(string) file_get_contents(__DIR__ . '/form.json'),
|
|
true,
|
|
512,
|
|
JSON_THROW_ON_ERROR
|
|
);
|
|
$automatisch = $this->ReadPropertyBoolean('AutomatischeSuche');
|
|
$lastspitzenmodus = $this->ReadPropertyInteger('Lastspitzenmodus');
|
|
$peakShavingLizenziert = $this->peakShavingLizenziert();
|
|
$monatsgrenzenSichtbar = $peakShavingLizenziert
|
|
&& $lastspitzenmodus === 2
|
|
&& $this->GetBuffer('MonatsgrenzenSichtbar') === '1';
|
|
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'LizenzInstallationID',
|
|
'caption',
|
|
'Installations-ID: ' . $this->ReadAttributeString('LizenzInstallationID')
|
|
);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'LizenzInformation',
|
|
'caption',
|
|
$this->lizenzStatustext()
|
|
);
|
|
$this->setzeFormularParameter($formular['elements'], 'VerbraucherZuordnung', 'visible', !$automatisch);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'AutomatischeVerbraucherZuordnung',
|
|
'visible',
|
|
$automatisch
|
|
);
|
|
$this->setzeFormularParameter($formular['elements'], 'SuchbereichID', 'visible', $automatisch);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'VerbraucherAktualisieren',
|
|
'visible',
|
|
$automatisch
|
|
);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'AutomatischeVerbraucherZuordnung',
|
|
'values',
|
|
$this->automatischeVerbraucherFuerFormular()
|
|
);
|
|
$lastspitzenOptionen = $this->lastspitzenOptionen($peakShavingLizenziert);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'Lastspitzenmodus',
|
|
'options',
|
|
$lastspitzenOptionen
|
|
);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'Lastspitzengrenze',
|
|
'visible',
|
|
$peakShavingLizenziert && $lastspitzenmodus === 1
|
|
);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'MonatsgrenzenUmschalten',
|
|
'visible',
|
|
$peakShavingLizenziert && $lastspitzenmodus === 2
|
|
);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'MonatsgrenzenUmschalten',
|
|
'caption',
|
|
$monatsgrenzenSichtbar ? 'Monatsgrenzen ausblenden' : 'Monatsgrenzen anzeigen'
|
|
);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'Monatsgrenzen',
|
|
'visible',
|
|
$monatsgrenzenSichtbar
|
|
);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'Monatsgrenzen',
|
|
'values',
|
|
$this->monatsgrenzenFuerFormular()
|
|
);
|
|
|
|
return json_encode($formular, JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
public function AnlagentopologieExportieren(): string
|
|
{
|
|
return json_encode(
|
|
$this->leseAnlagentopologie(),
|
|
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION
|
|
);
|
|
}
|
|
|
|
public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void
|
|
{
|
|
if ((int) $senderID === $this->ReadPropertyInteger('NetzleistungVariableID')
|
|
&& (int) $nachricht === self::VM_UPDATE
|
|
) {
|
|
$this->regeln(false);
|
|
}
|
|
}
|
|
|
|
public function RequestAction($ident, $wert): void
|
|
{
|
|
switch ($ident) {
|
|
case 'Aktiv':
|
|
$this->SetValue('Aktiv', (bool) $wert);
|
|
$this->regeln(false);
|
|
return;
|
|
|
|
case 'Regeln':
|
|
$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.');
|
|
}
|
|
$this->aktualisiereLizenzfreigabe($wert, true);
|
|
$this->UpdateFormField('LizenzInformation', 'caption', $this->lizenzStatustext($wert));
|
|
$this->aktualisiereLastspitzenformular();
|
|
return;
|
|
|
|
case 'FormAutomatischeSuche':
|
|
$automatisch = (bool) $wert;
|
|
$this->UpdateFormField('VerbraucherZuordnung', 'visible', !$automatisch);
|
|
$this->UpdateFormField('AutomatischeVerbraucherZuordnung', 'visible', $automatisch);
|
|
$this->UpdateFormField('SuchbereichID', 'visible', $automatisch);
|
|
$this->UpdateFormField('VerbraucherAktualisieren', 'visible', $automatisch);
|
|
return;
|
|
|
|
case 'FormVerbraucherAktualisieren':
|
|
if (!is_string($wert)) {
|
|
throw new InvalidArgumentException('Suchparameter muessen als JSON uebergeben werden.');
|
|
}
|
|
$suchparameter = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
|
|
if (!is_array($suchparameter) || !is_array($suchparameter['Zuordnung'] ?? null)) {
|
|
throw new InvalidArgumentException('Suchparameter sind ungueltig.');
|
|
}
|
|
$suchbereichID = $suchparameter['SuchbereichID'] ?? 0;
|
|
if (!is_int($suchbereichID)) {
|
|
throw new InvalidArgumentException('SuchbereichID muss eine Ganzzahl sein.');
|
|
}
|
|
$werte = $this->automatischeVerbraucherFuerFormular(
|
|
$suchparameter['Zuordnung'],
|
|
$suchbereichID
|
|
);
|
|
$this->UpdateFormField(
|
|
'AutomatischeVerbraucherZuordnung',
|
|
'values',
|
|
json_encode($werte, JSON_THROW_ON_ERROR)
|
|
);
|
|
return;
|
|
|
|
case 'FormLastspitzenmodus':
|
|
$modus = (int) $wert;
|
|
if ($modus !== 0 && !$this->peakShavingLizenziert()) {
|
|
$this->UpdateFormField('Lastspitzenmodus', 'value', 0);
|
|
$this->UpdateFormField('Lastspitzengrenze', 'visible', false);
|
|
$this->UpdateFormField('MonatsgrenzenUmschalten', 'visible', false);
|
|
throw new InvalidArgumentException(
|
|
'Konstant und Monatlich benoetigen eine Peak-Shaving-Lizenz.'
|
|
);
|
|
}
|
|
$this->UpdateFormField('Lastspitzengrenze', 'visible', $modus === 1);
|
|
$this->UpdateFormField('MonatsgrenzenUmschalten', 'visible', $modus === 2);
|
|
if ($modus !== 2) {
|
|
$this->SetBuffer('MonatsgrenzenSichtbar', '0');
|
|
$this->UpdateFormField('Monatsgrenzen', 'visible', false);
|
|
$this->UpdateFormField(
|
|
'MonatsgrenzenUmschalten',
|
|
'caption',
|
|
'Monatsgrenzen anzeigen'
|
|
);
|
|
}
|
|
return;
|
|
|
|
case 'FormMonatsgrenzenUmschalten':
|
|
$sichtbar = $this->GetBuffer('MonatsgrenzenSichtbar') !== '1';
|
|
$this->SetBuffer('MonatsgrenzenSichtbar', $sichtbar ? '1' : '0');
|
|
$this->UpdateFormField('Monatsgrenzen', 'visible', $sichtbar);
|
|
$this->UpdateFormField(
|
|
'MonatsgrenzenUmschalten',
|
|
'caption',
|
|
$sichtbar ? 'Monatsgrenzen ausblenden' : 'Monatsgrenzen anzeigen'
|
|
);
|
|
return;
|
|
|
|
case 'VerbraucherdatenEmpfangen':
|
|
if (!is_string($wert)) {
|
|
throw new InvalidArgumentException('Verbraucherdaten muessen als JSON uebergeben werden.');
|
|
}
|
|
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
|
|
if (!is_array($daten)) {
|
|
throw new InvalidArgumentException('Verbraucherdaten muessen ein JSON-Objekt sein.');
|
|
}
|
|
$this->VerbraucherdatenEmpfangen($daten);
|
|
return;
|
|
|
|
}
|
|
|
|
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $daten
|
|
*/
|
|
public function VerbraucherdatenEmpfangen(array $daten): void
|
|
{
|
|
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
|
|
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
|
|
throw new InvalidArgumentException('Verbraucherdaten sind an eine andere Instanz adressiert.');
|
|
}
|
|
|
|
$absenderID = $daten['Kopf']['AbsenderID'];
|
|
if (!in_array($absenderID, $this->aktiveVerbraucherIDs(), true)) {
|
|
throw new InvalidArgumentException('Der Absender ist diesem Manager nicht aktiv zugeordnet.');
|
|
}
|
|
|
|
$cache = $this->leseJsonAttribut('VerbraucherCache');
|
|
$cache[(string) $absenderID] = [
|
|
'EmpfangenAm' => time(),
|
|
'Daten' => $daten,
|
|
];
|
|
$this->WriteAttributeString('VerbraucherCache', json_encode($cache, JSON_THROW_ON_ERROR));
|
|
$this->regeln(false);
|
|
}
|
|
|
|
private function regeln(bool $erneuern): void
|
|
{
|
|
if (!$this->aktualisiereLizenzfreigabe($this->ReadPropertyString('Lizenzcode'), false)) {
|
|
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
|
|
$this->setzeDiagnosewert('Verteilbudget', 0.0);
|
|
$this->setzeNichtGeregelteVerbraucher('Lizenz nicht freigegeben');
|
|
$this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG);
|
|
return;
|
|
}
|
|
try {
|
|
$this->aktiveVerbraucherIDs();
|
|
} catch (Throwable $fehler) {
|
|
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
|
|
$this->setzeDiagnosewert('Verteilbudget', 0.0);
|
|
$this->setzeDiagnosewert('Lizenzstatus', 'Nicht freigegeben: ' . $fehler->getMessage());
|
|
$this->setzeNichtGeregelteVerbraucher($fehler->getMessage());
|
|
$this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG);
|
|
return;
|
|
}
|
|
|
|
[$netzleistung, $netzleistungGueltig] = $this->leseNetzleistung();
|
|
$this->SetValue('Netzleistung', $netzleistung);
|
|
$this->setzeDiagnosewert('NetzleistungGueltig', $netzleistungGueltig);
|
|
|
|
$aktiv = (bool) $this->GetValue('Aktiv');
|
|
if (!$aktiv || !$netzleistungGueltig) {
|
|
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
|
|
$this->setzeDiagnosewert('Verteilbudget', 0.0);
|
|
$this->setzeNichtGeregelteVerbraucher(
|
|
$aktiv ? 'Netzleistungsmessung fehlt oder ist veraltet' : 'Manager ist deaktiviert'
|
|
);
|
|
$this->SetStatus($aktiv ? self::STATUS_MESSWERT_UNGUELTIG : self::STATUS_AKTIV);
|
|
return;
|
|
}
|
|
|
|
$lastspitzengrenze = ManagerRegler::wirksameLastspitzengrenze(
|
|
$this->ReadPropertyInteger('Lastspitzenmodus'),
|
|
$this->ReadPropertyFloat('Lastspitzengrenze'),
|
|
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')),
|
|
(int) date('n')
|
|
);
|
|
$bisherigeBetriebsart = (string) $this->GetValue('Betriebsart');
|
|
$betriebsart = ManagerRegler::bestimmeBetriebsart(
|
|
true,
|
|
true,
|
|
$netzleistung,
|
|
$bisherigeBetriebsart,
|
|
$lastspitzengrenze !== null,
|
|
$this->ReadPropertyFloat('SollwertSolarladen'),
|
|
$lastspitzengrenze ?? 0.0,
|
|
$this->ReadPropertyFloat('Umschaltdifferenz')
|
|
);
|
|
$this->SetValue('Betriebsart', $betriebsart);
|
|
if ($betriebsart !== $bisherigeBetriebsart) {
|
|
$this->WriteAttributeString('LetzteSollwerte', '{}');
|
|
}
|
|
|
|
$stoerungen = [];
|
|
$nichtGeregelt = [];
|
|
$synchronisiert = true;
|
|
$verbraucher = $this->aktuelleVerbraucher(
|
|
$stoerungen,
|
|
$nichtGeregelt,
|
|
$betriebsart,
|
|
$synchronisiert
|
|
);
|
|
$this->setzeDiagnosewert('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0);
|
|
if (!$synchronisiert) {
|
|
$this->setzeDiagnosewert('Verteilbudget', 0.0);
|
|
$this->setzeDiagnosewert('Verbraucherstatus', json_encode([
|
|
'Betriebsart' => $betriebsart,
|
|
'Synchronisiert' => false,
|
|
'Verbraucher' => $verbraucher,
|
|
'NichtGeregelt' => $nichtGeregelt,
|
|
], JSON_THROW_ON_ERROR));
|
|
$this->setzeDiagnosewert(
|
|
'NichtGeregelteGeraete',
|
|
$this->nichtGeregelteGeraeteText($nichtGeregelt)
|
|
);
|
|
$this->aktualisiereStoerungen($verbraucher, $stoerungen);
|
|
$this->SetStatus(self::STATUS_AKTIV);
|
|
return;
|
|
}
|
|
|
|
$zielnetzleistung = $betriebsart === ManagerRegler::BETRIEBSART_PEAK
|
|
? (float) $lastspitzengrenze
|
|
: $this->ReadPropertyFloat('SollwertSolarladen');
|
|
$ergebnis = ManagerRegler::verteile(
|
|
$netzleistung,
|
|
$zielnetzleistung,
|
|
$verbraucher,
|
|
$betriebsart === ManagerRegler::BETRIEBSART_PEAK ? 'PrioritaetPeak' : 'PrioritaetPV'
|
|
);
|
|
$this->setzeDiagnosewert('Verteilbudget', (float) $ergebnis['Verteilbudget_W']);
|
|
$this->setzeDiagnosewert('Verbraucherstatus', json_encode([
|
|
'Betriebsart' => $betriebsart,
|
|
'Synchronisiert' => true,
|
|
'Restabweichung_W' => $ergebnis['Restabweichung_W'],
|
|
'Verbraucher' => $verbraucher,
|
|
'NichtGeregelt' => [],
|
|
], JSON_THROW_ON_ERROR));
|
|
$this->setzeDiagnosewert(
|
|
'NichtGeregelteGeraete',
|
|
'Alle aktiven Verbraucher werden geregelt.'
|
|
);
|
|
|
|
$letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte');
|
|
foreach ($ergebnis['Sollwerte'] as $instanzID => $sollleistung) {
|
|
if (!$erneuern && ($letzteSollwerte[(string) $instanzID] ?? null) === $sollleistung) {
|
|
continue;
|
|
}
|
|
try {
|
|
$this->sendeManagerdaten((int) $instanzID, $betriebsart, $sollleistung);
|
|
$letzteSollwerte[(string) $instanzID] = $sollleistung;
|
|
} catch (Throwable $fehler) {
|
|
$stoerungen[] = 'Sollwert an Instanz ' . $instanzID . ': ' . $fehler->getMessage();
|
|
}
|
|
}
|
|
$this->WriteAttributeString('LetzteSollwerte', json_encode($letzteSollwerte, JSON_THROW_ON_ERROR));
|
|
|
|
$this->aktualisiereStoerungen($verbraucher, $stoerungen);
|
|
$this->SetStatus(self::STATUS_AKTIV);
|
|
}
|
|
|
|
/** @return array{0: float, 1: bool} */
|
|
private function leseNetzleistung(): array
|
|
{
|
|
$variablenID = $this->ReadPropertyInteger('NetzleistungVariableID');
|
|
if ($variablenID <= 0 || !IPS_VariableExists($variablenID)) {
|
|
return [0.0, false];
|
|
}
|
|
|
|
$variable = IPS_GetVariable($variablenID);
|
|
if (time() - (int) $variable['VariableUpdated'] > $this->ReadPropertyInteger('MesswertMaxAlter')) {
|
|
return [0.0, false];
|
|
}
|
|
$wert = GetValue($variablenID);
|
|
if (!is_int($wert) && !is_float($wert)) {
|
|
return [0.0, false];
|
|
}
|
|
|
|
return [(float) $wert * $this->ReadPropertyFloat('Netzleistungsfaktor'), true];
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $stoerungen
|
|
* @param list<array<string, mixed>> $nichtGeregelt
|
|
* @return list<array{InstanzID: int, Daten: array<string, mixed>}>
|
|
*/
|
|
private function aktuelleVerbraucher(
|
|
array &$stoerungen,
|
|
array &$nichtGeregelt,
|
|
string $betriebsart,
|
|
bool &$synchronisiert
|
|
): array {
|
|
$ids = $this->aktiveVerbraucherIDs();
|
|
$this->setzeDiagnosewert('VerbraucherAnzahl', count($ids));
|
|
$cache = $this->leseJsonAttribut('VerbraucherCache');
|
|
$timeout = $this->ReadPropertyInteger('VerbraucherTimeout');
|
|
$aktuell = [];
|
|
|
|
foreach ($ids as $instanzID) {
|
|
$eintrag = $cache[(string) $instanzID] ?? null;
|
|
$grund = '';
|
|
if (!is_array($eintrag) || !isset($eintrag['EmpfangenAm'], $eintrag['Daten'])) {
|
|
$grund = 'Noch keine Meldung';
|
|
} elseif (time() - (int) $eintrag['EmpfangenAm'] > $timeout) {
|
|
$grund = 'Meldung ist veraltet';
|
|
} elseif (($eintrag['Daten']['Betriebsart'] ?? null) !== $betriebsart) {
|
|
$grund = 'Leistungsangebot gehoert nicht zur Betriebsart ' . $betriebsart;
|
|
}
|
|
|
|
if ($grund !== '') {
|
|
$synchronisiert = false;
|
|
$detail = $this->verbraucherDetail($instanzID, $grund, $eintrag);
|
|
$nichtGeregelt[] = $detail;
|
|
$stoerungen[] = $detail['Name'] . ': ' . $grund . '.';
|
|
try {
|
|
$this->sendeManagerdaten($instanzID, $betriebsart, null);
|
|
} catch (Throwable $fehler) {
|
|
$stoerungen[] = $detail['Name'] . ': Betriebsart konnte nicht uebermittelt werden: '
|
|
. $fehler->getMessage();
|
|
}
|
|
continue;
|
|
}
|
|
$aktuell[] = ['InstanzID' => $instanzID, 'Daten' => $eintrag['Daten']];
|
|
}
|
|
|
|
$verfuegbar = array_filter($aktuell, static fn (array $eintrag): bool => $eintrag['Daten']['Verfuegbar']);
|
|
$this->setzeDiagnosewert('VerbraucherVerfuegbar', count($verfuegbar));
|
|
|
|
return $aktuell;
|
|
}
|
|
|
|
private function setzeNichtGeregelteVerbraucher(string $grund): void
|
|
{
|
|
try {
|
|
$ids = $this->konfigurierteAktiveVerbraucherIDs();
|
|
} catch (Throwable $fehler) {
|
|
$ids = [];
|
|
$grund .= '; Verbraucherzuordnung ungueltig: ' . $fehler->getMessage();
|
|
}
|
|
$cache = $this->leseJsonAttribut('VerbraucherCache');
|
|
$nichtGeregelt = [];
|
|
$stoerungen = [];
|
|
foreach ($ids as $instanzID) {
|
|
$detail = $this->verbraucherDetail(
|
|
$instanzID,
|
|
$grund,
|
|
$cache[(string) $instanzID] ?? null
|
|
);
|
|
$nichtGeregelt[] = $detail;
|
|
$stoerungen[] = $detail['Name'] . ': ' . $grund . '.';
|
|
}
|
|
|
|
$this->setzeDiagnosewert('VerbraucherAnzahl', count($ids));
|
|
$this->setzeDiagnosewert('VerbraucherVerfuegbar', 0);
|
|
$this->setzeDiagnosewert('Verbraucherstatus', json_encode([
|
|
'Betriebsart' => ManagerRegler::BETRIEBSART_INAKTIV,
|
|
'Synchronisiert' => false,
|
|
'Verbraucher' => [],
|
|
'NichtGeregelt' => $nichtGeregelt,
|
|
], JSON_THROW_ON_ERROR));
|
|
$this->setzeDiagnosewert(
|
|
'NichtGeregelteGeraete',
|
|
$this->nichtGeregelteGeraeteText($nichtGeregelt)
|
|
);
|
|
$this->aktualisiereStoerungen([], $stoerungen);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $cacheEintrag
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function verbraucherDetail(
|
|
int $instanzID,
|
|
string $grund,
|
|
?array $cacheEintrag
|
|
): array {
|
|
$name = 'Instanz ' . $instanzID;
|
|
$typ = 'Unbekannter Verbraucher';
|
|
$instanzStatus = null;
|
|
if (IPS_InstanceExists($instanzID)) {
|
|
$name = IPS_GetName($instanzID);
|
|
$instanz = IPS_GetInstance($instanzID);
|
|
$instanzStatus = (int) ($instanz['InstanceStatus'] ?? 0);
|
|
$modulID = strtoupper((string) ($instanz['ModuleInfo']['ModuleID'] ?? ''));
|
|
$typ = self::VERBRAUCHER_LIZENZEN[$modulID]['Name'] ?? $typ;
|
|
}
|
|
|
|
$daten = is_array($cacheEintrag['Daten'] ?? null)
|
|
? $cacheEintrag['Daten']
|
|
: [];
|
|
$empfangenAm = is_int($cacheEintrag['EmpfangenAm'] ?? null)
|
|
? $cacheEintrag['EmpfangenAm']
|
|
: null;
|
|
|
|
return [
|
|
'InstanzID' => $instanzID,
|
|
'Name' => $name,
|
|
'Typ' => $typ,
|
|
'Grund' => $grund,
|
|
'InstanzStatus' => $instanzStatus,
|
|
'LetzteMeldung' => $empfangenAm !== null ? date(DATE_ATOM, $empfangenAm) : null,
|
|
'GemeldeteBetriebsart' => is_string($daten['Betriebsart'] ?? null)
|
|
? $daten['Betriebsart']
|
|
: null,
|
|
'Verfuegbar' => is_bool($daten['Verfuegbar'] ?? null)
|
|
? $daten['Verfuegbar']
|
|
: null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $nichtGeregelt
|
|
*/
|
|
private function nichtGeregelteGeraeteText(array $nichtGeregelt): string
|
|
{
|
|
if ($nichtGeregelt === []) {
|
|
return 'Alle aktiven Verbraucher werden geregelt.';
|
|
}
|
|
|
|
$zeilen = [];
|
|
foreach ($nichtGeregelt as $detail) {
|
|
$teile = [
|
|
(string) ($detail['Name'] ?? 'Unbekanntes Geraet'),
|
|
(string) ($detail['Typ'] ?? 'Unbekannter Typ'),
|
|
(string) ($detail['Grund'] ?? 'Grund unbekannt'),
|
|
];
|
|
if (is_string($detail['LetzteMeldung'] ?? null)) {
|
|
$teile[] = 'letzte Meldung: ' . $detail['LetzteMeldung'];
|
|
}
|
|
if (is_bool($detail['Verfuegbar'] ?? null)) {
|
|
$teile[] = 'verfuegbar: ' . ($detail['Verfuegbar'] ? 'ja' : 'nein');
|
|
}
|
|
$zeilen[] = implode(' | ', $teile);
|
|
}
|
|
|
|
return implode("\n", $zeilen);
|
|
}
|
|
|
|
/** @return list<int> */
|
|
private function aktiveVerbraucherIDs(): array
|
|
{
|
|
$ids = $this->konfigurierteAktiveVerbraucherIDs();
|
|
$this->pruefeVerbraucherlizenzen($ids);
|
|
|
|
return $ids;
|
|
}
|
|
|
|
/** @return list<int> */
|
|
private function konfigurierteAktiveVerbraucherIDs(): array
|
|
{
|
|
if (!$this->ReadPropertyBoolean('AutomatischeSuche')) {
|
|
return $this->leseAktiveVerbraucherIDs('VerbraucherZuordnung');
|
|
}
|
|
|
|
$gespeichert = json_decode(
|
|
$this->ReadPropertyString('AutomatischeVerbraucherZuordnung'),
|
|
true,
|
|
512,
|
|
JSON_THROW_ON_ERROR
|
|
);
|
|
if ($gespeichert === []) {
|
|
// Bestehende Installationen behalten ihre Auswahl, bis die neue Liste gespeichert wurde.
|
|
return $this->leseAktiveVerbraucherIDs('VerbraucherZuordnung');
|
|
}
|
|
|
|
return $this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung');
|
|
}
|
|
|
|
/**
|
|
* @param list<int> $ids
|
|
*/
|
|
private function pruefeVerbraucherlizenzen(array $ids): void
|
|
{
|
|
[$mengen, $namen] = $this->verbraucherLizenzbelegung($ids);
|
|
|
|
try {
|
|
Lizenzpruefung::pruefeVerbrauchermengen($this->leseLizenzLease(), $mengen);
|
|
} catch (InvalidArgumentException $fehler) {
|
|
foreach ($mengen as $kennung => $verwendet) {
|
|
$lizenziert = Lizenzpruefung::berechtigungsMenge(
|
|
$this->leseLizenzLease(),
|
|
$kennung
|
|
);
|
|
if ($verwendet > $lizenziert) {
|
|
throw new InvalidArgumentException(sprintf(
|
|
'%s: %d aktiviert, aber nur %d lizenziert. Bitte Auswahl reduzieren oder Lizenz erweitern.',
|
|
$namen[$kennung],
|
|
$verwendet,
|
|
$lizenziert
|
|
), 0, $fehler);
|
|
}
|
|
}
|
|
throw $fehler;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param list<int> $ids
|
|
* @return array{0: array<string, int>, 1: array<string, string>}
|
|
*/
|
|
private function verbraucherLizenzbelegung(array $ids): array
|
|
{
|
|
$mengen = [];
|
|
$namen = [];
|
|
foreach ($ids as $instanzID) {
|
|
if (!IPS_InstanceExists($instanzID)) {
|
|
throw new InvalidArgumentException(
|
|
'Die Verbraucherinstanz ' . $instanzID . ' existiert nicht.'
|
|
);
|
|
}
|
|
$instanz = IPS_GetInstance($instanzID);
|
|
$modulID = strtoupper((string) ($instanz['ModuleInfo']['ModuleID'] ?? ''));
|
|
$lizenz = self::VERBRAUCHER_LIZENZEN[$modulID] ?? null;
|
|
if (!is_array($lizenz)) {
|
|
throw new InvalidArgumentException(
|
|
'Instanz ' . $instanzID . ' ist kein lizenzierbarer Enelix-Verbraucher.'
|
|
);
|
|
}
|
|
$kennung = $lizenz['Katalog'];
|
|
$mengen[$kennung] = ($mengen[$kennung] ?? 0) + 1;
|
|
$namen[$kennung] = $lizenz['Name'];
|
|
}
|
|
|
|
return [$mengen, $namen];
|
|
}
|
|
|
|
/** @return list<int> */
|
|
private function leseAktiveVerbraucherIDs(string $property): array
|
|
{
|
|
$zuordnung = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR);
|
|
if (!is_array($zuordnung)) {
|
|
throw new InvalidArgumentException($property . ' muss eine JSON-Liste sein.');
|
|
}
|
|
|
|
return $this->aktiveVerbraucherIDsAusZuordnung($zuordnung);
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $zuordnung
|
|
* @return list<int>
|
|
*/
|
|
private function aktiveVerbraucherIDsAusZuordnung(array $zuordnung): array
|
|
{
|
|
$ids = [];
|
|
foreach ($zuordnung as $eintrag) {
|
|
if (!is_array($eintrag)) {
|
|
throw new InvalidArgumentException('Jede Verbraucherzuordnung muss ein Objekt sein.');
|
|
}
|
|
$instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null;
|
|
if (!is_int($instanzID) || $instanzID <= 0 || !is_bool($eintrag['Aktiv'] ?? null)) {
|
|
throw new InvalidArgumentException('Verbraucherzuordnung benoetigt InstanzID und Aktiv.');
|
|
}
|
|
if ($eintrag['Aktiv']) {
|
|
$ids[] = $instanzID;
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($ids));
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>>|null $aktuelleZuordnung
|
|
* @return list<array{Name: string, InstanzID: int, Aktiv: bool}>
|
|
*/
|
|
private function automatischeVerbraucherFuerFormular(
|
|
?array $aktuelleZuordnung = null,
|
|
?int $suchbereichID = null
|
|
): array {
|
|
if ($aktuelleZuordnung === null) {
|
|
$gespeichert = json_decode(
|
|
$this->ReadPropertyString('AutomatischeVerbraucherZuordnung'),
|
|
true,
|
|
512,
|
|
JSON_THROW_ON_ERROR
|
|
);
|
|
$ausgewaehlt = $gespeichert === []
|
|
? $this->leseAktiveVerbraucherIDs('VerbraucherZuordnung')
|
|
: $this->aktiveVerbraucherIDsAusZuordnung($gespeichert);
|
|
} else {
|
|
$ausgewaehlt = $this->aktiveVerbraucherIDsAusZuordnung($aktuelleZuordnung);
|
|
}
|
|
|
|
$suchbereichID ??= $this->ReadPropertyInteger('SuchbereichID');
|
|
$werte = [];
|
|
foreach (array_keys(self::VERBRAUCHER_LIZENZEN) as $modulID) {
|
|
foreach (IPS_GetInstanceListByModuleID($modulID) as $instanzID) {
|
|
if (!$this->istImSuchbereich($instanzID, $suchbereichID)) {
|
|
continue;
|
|
}
|
|
$werte[] = [
|
|
'Name' => IPS_GetName($instanzID),
|
|
'InstanzID' => $instanzID,
|
|
'Aktiv' => in_array($instanzID, $ausgewaehlt, true),
|
|
];
|
|
}
|
|
}
|
|
|
|
usort($werte, static function (array $links, array $rechts): int {
|
|
$nameVergleich = strcasecmp($links['Name'], $rechts['Name']);
|
|
|
|
return $nameVergleich !== 0
|
|
? $nameVergleich
|
|
: $links['InstanzID'] <=> $rechts['InstanzID'];
|
|
});
|
|
|
|
return $werte;
|
|
}
|
|
|
|
private function istImSuchbereich(int $objektID, int $suchbereichID): bool
|
|
{
|
|
if ($suchbereichID <= 0) {
|
|
return true;
|
|
}
|
|
|
|
while ($objektID > 0) {
|
|
if ($objektID === $suchbereichID) {
|
|
return true;
|
|
}
|
|
$objektID = IPS_GetParent($objektID);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/** @return list<array{Monat: string, Grenze_W: float, MonatIndex: int}> */
|
|
private function monatsgrenzenFuerFormular(): array
|
|
{
|
|
$grenzen = $this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen'));
|
|
$werte = [];
|
|
foreach (self::MONATSNAMEN as $monat => $name) {
|
|
$werte[] = [
|
|
'Monat' => $name,
|
|
'Grenze_W' => $grenzen[$monat],
|
|
'MonatIndex' => $monat,
|
|
];
|
|
}
|
|
|
|
return $werte;
|
|
}
|
|
|
|
/** @param list<array<string, mixed>> $elemente */
|
|
private function setzeFormularParameter(
|
|
array &$elemente,
|
|
string $name,
|
|
string $parameter,
|
|
$wert
|
|
): bool {
|
|
foreach ($elemente as &$element) {
|
|
if (($element['name'] ?? null) === $name) {
|
|
$element[$parameter] = $wert;
|
|
|
|
return true;
|
|
}
|
|
if (isset($element['items'])
|
|
&& is_array($element['items'])
|
|
&& $this->setzeFormularParameter($element['items'], $name, $parameter, $wert)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
unset($element);
|
|
|
|
return false;
|
|
}
|
|
|
|
private function sendeManagerdaten(
|
|
int $instanzID,
|
|
string $betriebsart,
|
|
?int $sollleistung
|
|
): void {
|
|
if (!IPS_InstanceExists($instanzID)) {
|
|
throw new RuntimeException('Instanz existiert nicht.');
|
|
}
|
|
$paket = [
|
|
'Kopf' => [
|
|
'Version' => Nachrichtenvertrag::VERSION,
|
|
'AbsenderID' => $this->InstanceID,
|
|
'EmpfaengerID' => $instanzID,
|
|
'Zeitpunkt' => time(),
|
|
],
|
|
'Betriebsart' => $betriebsart,
|
|
'Sollleistung_W' => $sollleistung,
|
|
];
|
|
Nachrichtenvertrag::pruefeManagerdaten($paket);
|
|
IPS_RequestAction($instanzID, 'ManagerdatenEmpfangen', json_encode($paket, JSON_THROW_ON_ERROR));
|
|
$this->protokolliere('Managerdaten', [
|
|
'InstanzID' => $instanzID,
|
|
'Betriebsart' => $betriebsart,
|
|
'Sollleistung_W' => $sollleistung,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param list<array{InstanzID: int, Daten: array<string, mixed>}> $verbraucher
|
|
* @param list<string> $stoerungen
|
|
*/
|
|
private function aktualisiereStoerungen(array $verbraucher, array $stoerungen): void
|
|
{
|
|
foreach ($verbraucher as $eintrag) {
|
|
foreach ($eintrag['Daten']['Zustand'] as $zustand) {
|
|
if ($zustand['Art'] === 'Stoerung' && $zustand['Wert'] === true) {
|
|
$stoerungen[] = 'Instanz ' . $eintrag['InstanzID'] . ': '
|
|
. ($zustand['Text'] ?? $zustand['Kennung']);
|
|
}
|
|
}
|
|
}
|
|
$stoerungen = array_values(array_unique($stoerungen));
|
|
$this->setzeDiagnosewert('Sammelstoerung', $stoerungen !== []);
|
|
$this->setzeDiagnosewert('Stoertext', implode("\n", $stoerungen));
|
|
}
|
|
|
|
private function registriereNetzleistungsmeldung(): void
|
|
{
|
|
$alt = $this->ReadAttributeInteger('RegistrierteNetzleistungVariableID');
|
|
$neu = $this->ReadPropertyInteger('NetzleistungVariableID');
|
|
if ($alt > 0 && $alt !== $neu) {
|
|
$this->UnregisterMessage($alt, self::VM_UPDATE);
|
|
}
|
|
if ($neu > 0 && $neu !== $alt) {
|
|
$this->RegisterMessage($neu, self::VM_UPDATE);
|
|
}
|
|
$this->WriteAttributeInteger('RegistrierteNetzleistungVariableID', $neu);
|
|
}
|
|
|
|
private function aktualisiereVariablen(): void
|
|
{
|
|
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
|
|
$this->RegisterVariableBoolean(
|
|
'NetzleistungGueltig',
|
|
'Netzleistung gueltig',
|
|
'~Switch',
|
|
40
|
|
);
|
|
$this->RegisterVariableFloat(
|
|
'WirksameLastspitzengrenze',
|
|
'Wirksame Lastspitzengrenze',
|
|
'',
|
|
50
|
|
);
|
|
$this->RegisterVariableFloat('Verteilbudget', 'Verteilbudget', '', 60);
|
|
$this->RegisterVariableInteger('VerbraucherAnzahl', 'Verbraucher Anzahl', '', 70);
|
|
$this->RegisterVariableInteger(
|
|
'VerbraucherVerfuegbar',
|
|
'Verbraucher verfuegbar',
|
|
'',
|
|
80
|
|
);
|
|
$this->RegisterVariableString('Verbraucherstatus', 'Verbraucherstatus', '', 90);
|
|
$this->RegisterVariableString(
|
|
'NichtGeregelteGeraete',
|
|
'Nicht geregelte Geraete',
|
|
'',
|
|
95
|
|
);
|
|
$this->RegisterVariableString('Monatsgrenzen', 'Monatsgrenzen', '', 100);
|
|
$this->RegisterVariableString('Prognosestatus', 'Prognosestatus', '', 110);
|
|
$this->RegisterVariableString('SDLStatus', 'SDL-Status', '', 120);
|
|
$this->RegisterVariableString('Lizenzstatus', 'Lizenzstatus', '', 130);
|
|
$this->RegisterVariableString(
|
|
'Stoerueberwachungsstatus',
|
|
'Stoerueberwachungsstatus',
|
|
'',
|
|
140
|
|
);
|
|
$this->RegisterVariableBoolean('Sammelstoerung', 'Sammelstoerung', '~Alert', 150);
|
|
$this->RegisterVariableString('Stoertext', 'Stoertext', '', 160);
|
|
|
|
return;
|
|
}
|
|
|
|
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
|
|
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
|
|
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
|
|
$this->UnregisterVariable($ident);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @param bool|float|int|string $wert */
|
|
private function setzeDiagnosewert(string $ident, $wert): void
|
|
{
|
|
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
|
|
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
|
|
$this->SetValue($ident, $wert);
|
|
}
|
|
}
|
|
|
|
private function initialisiereLizenzInstallationID(): string
|
|
{
|
|
$installationID = trim($this->ReadAttributeString('LizenzInstallationID'));
|
|
if ($installationID !== '') {
|
|
return $installationID;
|
|
}
|
|
|
|
$installationID = $this->erzeugeUuidV4();
|
|
$this->WriteAttributeString('LizenzInstallationID', $installationID);
|
|
|
|
return $installationID;
|
|
}
|
|
|
|
private function aktualisiereLizenzfreigabe(string $lizenzcode, bool $erzwingen): bool
|
|
{
|
|
$normalisiert = Lizenzpruefung::normalisiereCode($lizenzcode);
|
|
if (!Lizenzpruefung::istLizenzcodeGueltig($normalisiert)) {
|
|
$fehler = $normalisiert === '' ? 'Lizenzcode fehlt.' : 'Lizenzcode ist ungueltig.';
|
|
$this->WriteAttributeString('LizenzLetzterFehler', $fehler);
|
|
$this->setzeDiagnosewert('Lizenzstatus', $fehler);
|
|
|
|
return false;
|
|
}
|
|
|
|
$installationID = $this->initialisiereLizenzInstallationID();
|
|
$codeHash = hash('sha256', $normalisiert);
|
|
$gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash');
|
|
if ($gespeicherterHash !== '' && !hash_equals($gespeicherterHash, $codeHash)) {
|
|
$this->WriteAttributeString('PrognoseInstallationsToken', '');
|
|
}
|
|
$lease = $this->leseLizenzLease();
|
|
$codePasst = $gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash);
|
|
$lastspitzenmodus = $this->ReadPropertyInteger('Lastspitzenmodus');
|
|
$basisGueltig = $codePasst && Lizenzpruefung::istLeaseGueltig(
|
|
$lease,
|
|
$installationID,
|
|
0
|
|
);
|
|
$gueltig = $codePasst && Lizenzpruefung::istLeaseGueltig(
|
|
$lease,
|
|
$installationID,
|
|
$lastspitzenmodus
|
|
);
|
|
$berechtigungenVeraltet = $basisGueltig
|
|
&& !$this->lizenzBerechtigungenPassen($lease, $lastspitzenmodus);
|
|
$geraetezugangFehlt = $this->hatAnlagentopologie()
|
|
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '';
|
|
$routineFaellig = (!$gueltig && !$berechtigungenVeraltet)
|
|
|| $geraetezugangFehlt
|
|
|| Lizenzpruefung::erneuerungFaellig($lease);
|
|
$jetzt = time();
|
|
|
|
if (!$erzwingen) {
|
|
if (!$routineFaellig && !$berechtigungenVeraltet) {
|
|
$this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode));
|
|
|
|
return $gueltig;
|
|
}
|
|
if ($routineFaellig
|
|
&& $jetzt < $this->ReadAttributeInteger('LizenzNaechsterVersuch')
|
|
) {
|
|
$this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode));
|
|
|
|
return $gueltig;
|
|
}
|
|
if ($berechtigungenVeraltet
|
|
&& $jetzt < $this->ReadAttributeInteger(
|
|
'LizenzBerechtigungenNaechsterVersuch'
|
|
)
|
|
) {
|
|
$this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode));
|
|
|
|
return $gueltig;
|
|
}
|
|
}
|
|
|
|
if ($routineFaellig || $erzwingen) {
|
|
$this->WriteAttributeInteger(
|
|
'LizenzNaechsterVersuch',
|
|
$jetzt + self::LIZENZ_RETRY_SEKUNDEN
|
|
);
|
|
}
|
|
if ($berechtigungenVeraltet || $erzwingen) {
|
|
$this->WriteAttributeInteger(
|
|
'LizenzBerechtigungenNaechsterVersuch',
|
|
$jetzt + self::LIZENZ_RETRY_SEKUNDEN
|
|
);
|
|
}
|
|
try {
|
|
$neueLease = $this->rufeLizenzserverAuf($normalisiert, $installationID);
|
|
$geraeteToken = $neueLease['deviceToken'] ?? null;
|
|
unset($neueLease['deviceToken']);
|
|
if ($geraetezugangFehlt
|
|
&& (!is_string($geraeteToken)
|
|
|| preg_match('/^[A-Za-z0-9_-]{43,128}$/', $geraeteToken) !== 1)
|
|
) {
|
|
throw new RuntimeException('Der Lizenzserver lieferte keinen gueltigen Geraetezugang.');
|
|
}
|
|
Lizenzpruefung::pruefeLease(
|
|
$neueLease,
|
|
$installationID,
|
|
$this->ReadPropertyInteger('Lastspitzenmodus')
|
|
);
|
|
if ($geraetezugangFehlt) {
|
|
$this->WriteAttributeString('PrognoseInstallationsToken', $geraeteToken);
|
|
}
|
|
$this->WriteAttributeString(
|
|
'LizenzLease',
|
|
json_encode($neueLease, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
|
|
);
|
|
$this->WriteAttributeString('LizenzcodeHash', $codeHash);
|
|
$naechsteErneuerung = Lizenzpruefung::erneuernAb($neueLease);
|
|
$this->WriteAttributeInteger('LizenzNaechsterVersuch', $naechsteErneuerung);
|
|
$this->WriteAttributeInteger(
|
|
'LizenzBerechtigungenNaechsterVersuch',
|
|
$naechsteErneuerung
|
|
);
|
|
$this->WriteAttributeString('LizenzLetzterFehler', '');
|
|
$this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode));
|
|
|
|
return true;
|
|
} catch (Throwable $fehler) {
|
|
$meldung = substr($fehler->getMessage(), 0, 500);
|
|
$this->WriteAttributeString('LizenzLetzterFehler', $meldung);
|
|
$this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode));
|
|
$this->protokolliere('Lizenzpruefung', $meldung);
|
|
|
|
return $gueltig;
|
|
}
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function leseLizenzLease(): array
|
|
{
|
|
try {
|
|
$lease = json_decode(
|
|
$this->ReadAttributeString('LizenzLease'),
|
|
true,
|
|
512,
|
|
JSON_THROW_ON_ERROR
|
|
);
|
|
|
|
return is_array($lease) ? $lease : [];
|
|
} catch (Throwable $fehler) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private function peakShavingLizenziert(): bool
|
|
{
|
|
$lizenzcode = Lizenzpruefung::normalisiereCode(
|
|
$this->ReadPropertyString('Lizenzcode')
|
|
);
|
|
if (!Lizenzpruefung::istLizenzcodeGueltig($lizenzcode)) {
|
|
return false;
|
|
}
|
|
|
|
$gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash');
|
|
if ($gespeicherterHash === ''
|
|
|| !hash_equals($gespeicherterHash, hash('sha256', $lizenzcode))
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
$lease = $this->leseLizenzLease();
|
|
if (!Lizenzpruefung::istLeaseGueltig(
|
|
$lease,
|
|
$this->ReadAttributeString('LizenzInstallationID'),
|
|
0
|
|
)) {
|
|
return false;
|
|
}
|
|
|
|
return Lizenzpruefung::berechtigungsMenge(
|
|
$lease,
|
|
Lizenzpruefung::MANAGER_PEAK
|
|
) > 0;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $lease
|
|
*/
|
|
private function lizenzBerechtigungenPassen(array $lease, int $lastspitzenmodus): bool
|
|
{
|
|
if ($lastspitzenmodus !== 0
|
|
&& Lizenzpruefung::berechtigungsMenge(
|
|
$lease,
|
|
Lizenzpruefung::MANAGER_PEAK
|
|
) < 1
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
[$mengen] = $this->verbraucherLizenzbelegung(
|
|
$this->konfigurierteAktiveVerbraucherIDs()
|
|
);
|
|
Lizenzpruefung::pruefeVerbrauchermengen($lease, $mengen);
|
|
|
|
return true;
|
|
} catch (Throwable $fehler) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return list<array{caption: string, value: int}>
|
|
*/
|
|
private function lastspitzenOptionen(bool $lizenziert): array
|
|
{
|
|
$optionen = [[
|
|
'caption' => $lizenziert
|
|
? 'Aus (Peak-Shaving-Lizenz verfuegbar)'
|
|
: 'Aus (Peak-Shaving-Lizenz fehlt)',
|
|
'value' => 0,
|
|
]];
|
|
if ($lizenziert) {
|
|
$optionen[] = ['caption' => 'Konstant', 'value' => 1];
|
|
$optionen[] = ['caption' => 'Monatlich', 'value' => 2];
|
|
}
|
|
|
|
return $optionen;
|
|
}
|
|
|
|
private function aktualisiereLastspitzenformular(): void
|
|
{
|
|
$lizenziert = $this->peakShavingLizenziert();
|
|
$modus = $lizenziert ? $this->ReadPropertyInteger('Lastspitzenmodus') : 0;
|
|
$this->UpdateFormField(
|
|
'Lastspitzenmodus',
|
|
'options',
|
|
json_encode($this->lastspitzenOptionen($lizenziert), JSON_THROW_ON_ERROR)
|
|
);
|
|
$this->UpdateFormField('Lastspitzenmodus', 'value', $modus);
|
|
$this->UpdateFormField(
|
|
'Lastspitzengrenze',
|
|
'visible',
|
|
$lizenziert && $modus === 1
|
|
);
|
|
$this->UpdateFormField(
|
|
'MonatsgrenzenUmschalten',
|
|
'visible',
|
|
$lizenziert && $modus === 2
|
|
);
|
|
if (!$lizenziert || $modus !== 2) {
|
|
$this->SetBuffer('MonatsgrenzenSichtbar', '0');
|
|
$this->UpdateFormField('Monatsgrenzen', 'visible', false);
|
|
}
|
|
}
|
|
|
|
private function lizenzStatustext(?string $lizenzcode = null): string
|
|
{
|
|
$code = Lizenzpruefung::normalisiereCode(
|
|
$lizenzcode ?? $this->ReadPropertyString('Lizenzcode')
|
|
);
|
|
if ($code === '') {
|
|
return 'Lizenzcode fehlt.';
|
|
}
|
|
if (!Lizenzpruefung::istLizenzcodeGueltig($code)) {
|
|
return 'Lizenzcode ist ungueltig.';
|
|
}
|
|
|
|
$codeHash = hash('sha256', $code);
|
|
$gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash');
|
|
$fehler = $this->ReadAttributeString('LizenzLetzterFehler');
|
|
if ($gespeicherterHash === '' || !hash_equals($gespeicherterHash, $codeHash)) {
|
|
return $fehler !== '' ? 'Nicht freigegeben: ' . $fehler : 'Lizenz noch nicht bestaetigt.';
|
|
}
|
|
|
|
$lease = $this->leseLizenzLease();
|
|
try {
|
|
Lizenzpruefung::pruefeLease(
|
|
$lease,
|
|
$this->ReadAttributeString('LizenzInstallationID'),
|
|
$this->ReadPropertyInteger('Lastspitzenmodus')
|
|
);
|
|
$this->aktiveVerbraucherIDs();
|
|
$status = ($lease['development'] ?? false) === true
|
|
? 'Aktiv (Entwicklung) - unbeschraenkt gueltig.'
|
|
: 'Aktiv - unbeschraenkt gueltig.';
|
|
if ($fehler !== '') {
|
|
$status .= ' Online-Pruefung fehlgeschlagen; lokale Freigabe endet am '
|
|
. date('d.m.Y H:i', Lizenzpruefung::offlineBis($lease))
|
|
. ', sofern keine Verbindung hergestellt wird. '
|
|
. 'Die gekaufte Lizenz bleibt unbeschraenkt gueltig. Fehler: '
|
|
. $fehler;
|
|
}
|
|
|
|
return $status;
|
|
} catch (Throwable $ungueltig) {
|
|
return 'Nicht freigegeben: ' . $ungueltig->getMessage();
|
|
}
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function rufeLizenzserverAuf(string $lizenzcode, string $installationID): array
|
|
{
|
|
$nutzlast = json_encode([
|
|
'code' => $lizenzcode,
|
|
'installationId' => $installationID,
|
|
'issueDeviceToken' => $this->hatAnlagentopologie()
|
|
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '',
|
|
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
|
$curl = curl_init(self::LIZENZ_ENDPOINT);
|
|
if ($curl === false) {
|
|
throw new RuntimeException('Die HTTPS-Anfrage konnte nicht vorbereitet werden.');
|
|
}
|
|
|
|
try {
|
|
curl_setopt_array($curl, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => $nutzlast,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
|
CURLOPT_TIMEOUT => 10,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Accept: application/json',
|
|
'Content-Type: application/json',
|
|
],
|
|
CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.1',
|
|
]);
|
|
$antwort = curl_exec($curl);
|
|
if ($antwort === false) {
|
|
throw new RuntimeException('Lizenzserver nicht erreichbar: ' . curl_error($curl));
|
|
}
|
|
if (strlen($antwort) > 65536) {
|
|
throw new RuntimeException('Die Antwort des Lizenzservers ist zu gross.');
|
|
}
|
|
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
|
} finally {
|
|
curl_close($curl);
|
|
}
|
|
|
|
try {
|
|
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (Throwable $fehler) {
|
|
throw new RuntimeException('Der Lizenzserver lieferte kein gueltiges JSON.', 0, $fehler);
|
|
}
|
|
if (!is_array($daten)) {
|
|
throw new RuntimeException('Der Lizenzserver lieferte kein JSON-Objekt.');
|
|
}
|
|
if ($httpStatus !== 200) {
|
|
$meldung = is_string($daten['error'] ?? null)
|
|
? $daten['error']
|
|
: 'Unbekannter Fehler';
|
|
throw new RuntimeException('Lizenzserver HTTP ' . $httpStatus . ': ' . $meldung);
|
|
}
|
|
|
|
return $daten;
|
|
}
|
|
|
|
private function erzeugeUuidV4(): string
|
|
{
|
|
$bytes = random_bytes(16);
|
|
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
|
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
|
$hex = bin2hex($bytes);
|
|
|
|
return sprintf(
|
|
'%s-%s-%s-%s-%s',
|
|
substr($hex, 0, 8),
|
|
substr($hex, 8, 4),
|
|
substr($hex, 12, 4),
|
|
substr($hex, 16, 4),
|
|
substr($hex, 20, 12)
|
|
);
|
|
}
|
|
|
|
private function aktualisiereAnschlussstatus(): void
|
|
{
|
|
foreach ([
|
|
'PrognoseAnschluss' => 'Prognosestatus',
|
|
'SDLAnschluss' => 'SDLStatus',
|
|
'StoermeldeAnschluss' => 'Stoerueberwachungsstatus',
|
|
] as $property => $variable) {
|
|
$konfiguration = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR);
|
|
$aktiv = is_array($konfiguration) && ($konfiguration['Aktiv'] ?? false) === true;
|
|
$this->setzeDiagnosewert($variable, $aktiv ? 'Fehler' : 'NichtVerwendet');
|
|
}
|
|
}
|
|
|
|
private function pruefeKonfiguration(): void
|
|
{
|
|
foreach (['MesswertMaxAlter', 'KeepAlive', 'VerbraucherTimeout'] as $property) {
|
|
if ($this->ReadPropertyInteger($property) <= 0) {
|
|
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
|
|
) {
|
|
throw new InvalidArgumentException('Umschaltdifferenz muss zwischen 0 und 100 liegen.');
|
|
}
|
|
$this->leseAktiveVerbraucherIDs('VerbraucherZuordnung');
|
|
$this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung');
|
|
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen'));
|
|
$this->leseAnlagentopologie();
|
|
foreach (['PrognoseAnschluss', 'SDLAnschluss', 'StoermeldeAnschluss'] as $property) {
|
|
$wert = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR);
|
|
if (!is_array($wert)) {
|
|
throw new InvalidArgumentException($property . ' muss ein JSON-Objekt sein.');
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function leseAnlagentopologie(): array
|
|
{
|
|
return Anlagentopologie::ausJson(
|
|
$this->ReadPropertyString('AnlagenWechselrichter'),
|
|
$this->ReadPropertyString('AnlagenPVFlaechen'),
|
|
$this->ReadPropertyString('AnlagenBatterien'),
|
|
$this->ReadAttributeString('LizenzInstallationID')
|
|
);
|
|
}
|
|
|
|
private function hatAnlagentopologie(): bool
|
|
{
|
|
$topologie = $this->leseAnlagentopologie();
|
|
|
|
return $topologie['inverters'] !== []
|
|
|| $topologie['pvArrays'] !== []
|
|
|| $topologie['batteries'] !== [];
|
|
}
|
|
|
|
private function prognoseTelemetrieKonfiguriert(): bool
|
|
{
|
|
return $this->ReadPropertyInteger('PrognosePVVariableID') > 0
|
|
&& $this->ReadPropertyInteger('PrognoseHausverbrauchVariableID') > 0
|
|
&& $this->ReadPropertyInteger('PrognoseSOCVariableID') > 0;
|
|
}
|
|
|
|
private function synchronisiereAnlagentopologie(): void
|
|
{
|
|
try {
|
|
$topologie = $this->leseAnlagentopologie();
|
|
if ($topologie['inverters'] === []
|
|
&& $topologie['pvArrays'] === []
|
|
&& $topologie['batteries'] === []
|
|
) {
|
|
$this->setzeDiagnosewert('Prognosestatus', 'NichtVerwendet');
|
|
return;
|
|
}
|
|
|
|
$token = trim($this->ReadAttributeString('PrognoseInstallationsToken'));
|
|
if ($token === '') {
|
|
throw new RuntimeException('Der Geraetezugang fuer die Prognose fehlt.');
|
|
}
|
|
$this->sendeAnlagentopologie($topologie, $token);
|
|
$this->setzeDiagnosewert('Prognosestatus', 'Verbunden');
|
|
} catch (Throwable $fehler) {
|
|
$meldung = substr($fehler->getMessage(), 0, 500);
|
|
$this->setzeDiagnosewert('Prognosestatus', 'Fehler: ' . $meldung);
|
|
$this->protokolliere('Prognosesynchronisation', $meldung);
|
|
}
|
|
}
|
|
|
|
/** @param array<string, mixed> $topologie */
|
|
private function sendeAnlagentopologie(array $topologie, string $token): void
|
|
{
|
|
$url = sprintf(
|
|
self::PROGNOSE_TOPOLOGIE_ENDPOINT,
|
|
rawurlencode($this->ReadAttributeString('LizenzInstallationID'))
|
|
);
|
|
$nutzlast = json_encode(
|
|
$topologie,
|
|
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
|
|
);
|
|
$curl = curl_init($url);
|
|
if ($curl === false) {
|
|
throw new RuntimeException('Die Prognosesynchronisation konnte nicht vorbereitet werden.');
|
|
}
|
|
|
|
try {
|
|
curl_setopt_array($curl, [
|
|
CURLOPT_CUSTOMREQUEST => 'PUT',
|
|
CURLOPT_POSTFIELDS => $nutzlast,
|
|
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 Prognosesynchronisation 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(
|
|
'Prognosesynchronisation HTTP ' . $httpStatus . ': ' . $meldung
|
|
);
|
|
}
|
|
}
|
|
|
|
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
|
|
{
|
|
$werte = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
|
if (!is_array($werte)) {
|
|
throw new InvalidArgumentException('Monatsgrenzen muss ein JSON-Objekt sein.');
|
|
}
|
|
$ergebnis = [];
|
|
if (isset($werte[0]) && is_array($werte[0])) {
|
|
foreach ($werte as $eintrag) {
|
|
if (!is_array($eintrag)) {
|
|
throw new InvalidArgumentException('Monatsgrenzen muss eine Liste von Monatswerten sein.');
|
|
}
|
|
$monat = $eintrag['MonatIndex'] ?? null;
|
|
$wert = $eintrag['Grenze_W'] ?? null;
|
|
if (!is_int($monat) || $monat < 1 || $monat > 12
|
|
|| (!is_int($wert) && !is_float($wert))
|
|
) {
|
|
throw new InvalidArgumentException('Jeder Monatswert benoetigt Monatsnummer und Grenze.');
|
|
}
|
|
$ergebnis[$monat] = (float) $wert;
|
|
}
|
|
} else {
|
|
for ($monat = 1; $monat <= 12; ++$monat) {
|
|
$wert = $werte[(string) $monat] ?? $werte[$monat] ?? null;
|
|
if (!is_int($wert) && !is_float($wert)) {
|
|
throw new InvalidArgumentException(
|
|
'Monatsgrenzen muss fuer jeden Monat einen Zahlenwert enthalten.'
|
|
);
|
|
}
|
|
$ergebnis[$monat] = (float) $wert;
|
|
}
|
|
}
|
|
if (count($ergebnis) !== 12) {
|
|
throw new InvalidArgumentException('Monatsgrenzen muss genau zwoelf Monatswerte enthalten.');
|
|
}
|
|
ksort($ergebnis);
|
|
|
|
return $ergebnis;
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function leseJsonAttribut(string $name): array
|
|
{
|
|
$wert = json_decode($this->ReadAttributeString($name), true, 512, JSON_THROW_ON_ERROR);
|
|
|
|
return is_array($wert) ? $wert : [];
|
|
}
|
|
|
|
private function standardMonatsgrenzen(): string
|
|
{
|
|
$werte = [];
|
|
foreach (self::MONATSNAMEN as $monat => $name) {
|
|
$werte[] = [
|
|
'Monat' => $name,
|
|
'Grenze_W' => 0.0,
|
|
'MonatIndex' => $monat,
|
|
];
|
|
}
|
|
|
|
return json_encode($werte, JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
/** @param mixed $daten */
|
|
private function protokolliere(string $bezeichnung, $daten): void
|
|
{
|
|
if ($this->ReadPropertyBoolean('LoggingEin')) {
|
|
$this->SendDebug($bezeichnung, is_string($daten) ? $daten : json_encode($daten), 0);
|
|
}
|
|
}
|
|
}
|