Files
Enelix-EMS/Manager/module.php
T
2026-10-01 12:16:10 +00:00

3115 lines
122 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/EinspeiseRegler.php';
require_once __DIR__ . '/../libs/Lizenzpruefung.php';
require_once __DIR__ . '/../libs/Anlagentopologie.php';
require_once __DIR__ . '/../libs/StoerungsSnapshot.php';
require_once __DIR__ . '/../libs/EnergieMessung.php';
require_once __DIR__ . '/../libs/ManagerEnergieTrait.php';
use Belevo\EnelixEMS\Anlagentopologie;
use Belevo\EnelixEMS\EinspeiseRegler;
use Belevo\EnelixEMS\EnergieMessung;
use Belevo\EnelixEMS\ManagerEnergieTrait;
use Belevo\EnelixEMS\Lizenzpruefung;
use Belevo\EnelixEMS\ManagerRegler;
use Belevo\EnelixEMS\ManagerSchnittstelle;
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\StoerungsSnapshot;
class Manager extends IPSModule implements ManagerSchnittstelle
{
use ManagerEnergieTrait;
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 PROGNOSE_FAHRPLAN_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/schedule';
private const STOERUNGS_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/faults';
private const LIZENZ_RETRY_SEKUNDEN = 3600;
private const STOERUNGS_RETRY_MAX_SEKUNDEN = 900;
private const VM_UPDATE = 10603;
private const ARCHIVE_MODULE_ID = '{43192F0B-135B-4CE7-A0A7-1475603F3060}';
private const ENERGY_PIE_MODULE_ID = '{F73B8F8D-5E36-4B23-B379-4632A517A1D6}';
private const ENERGY_FLOW_MODULE_ID = '{BA90FDB3-C203-8F26-B4D6-F3DB99905724}';
private const LEISTUNGS_VARIABLEN = [
'PVLeistungArchiv',
'HausverbrauchLeistungArchiv',
'NetzleistungArchiv',
'BatterieleistungArchiv',
];
private const ENERGIE_VARIABLEN = [
'PVEnergie',
'Hausenergie',
'NetzbezugEnergie',
'EinspeisungEnergie',
'BatterieLadenEnergie',
'BatterieEntladenEnergie',
];
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' => 'heat_pump',
'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',
'WirksameEinspeisegrenze',
'Abregelbedarf',
'Wechselrichterstatus',
'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->RegisterPropertyBoolean('EinspeisebegrenzungAktiv', false);
$this->RegisterPropertyFloat('Einspeisegrenze', 0.0);
$this->RegisterPropertyBoolean('PrognoseAktiv', false);
$this->RegisterPropertyBoolean('NetzfahrplanAktiv', false);
$this->RegisterPropertyString('AnlagenWechselrichter', '[]');
$this->RegisterPropertyString('AnlagenPVFlaechen', '[]');
$this->RegisterPropertyString('AnlagenBatterien', '[]');
$this->RegisterPropertyInteger('NetzbezugEnergieVariableID', 0);
$this->RegisterPropertyFloat('NetzbezugEnergiefaktor', 1.0);
$this->RegisterPropertyInteger('NetzeinspeisungEnergieVariableID', 0);
$this->RegisterPropertyFloat('NetzeinspeisungEnergiefaktor', 1.0);
// Verdeckte Altproperties; Messquellen liegen neu ausschliesslich in der Anlagentopologie.
$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', '{}');
// Verdeckte Altproperty fuer bestehende Instanzkonfigurationen.
$this->RegisterPropertyString('StoermeldeAnschluss', '{}');
$this->RegisterPropertyBoolean('StoerueberwachungAktiv', false);
$this->RegisterPropertyInteger('StoerungsSendeintervall', 300);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
$this->RegisterPropertyBoolean('LoggingEin', false);
$this->RegisterPropertyBoolean('EnergieaufzeichnungAktiv', true);
// Verdeckte Altproperties; bestehende Konfigurationen bleiben lesbar.
$this->RegisterPropertyInteger('MessungPVLeistungVariableID', 0);
$this->RegisterPropertyInteger('MessungHausverbrauchLeistungVariableID', 0);
$this->RegisterPropertyInteger('MessungBatterieleistungVariableID', 0);
$this->RegisterPropertyFloat('MessungPVLeistungsfaktor', 1.0);
$this->RegisterPropertyFloat('MessungHausverbrauchLeistungsfaktor', 1.0);
$this->RegisterPropertyFloat('MessungBatterieleistungsfaktor', 1.0);
$this->RegisterPropertyBoolean('MesswerteAnzeigen', false);
$this->RegisterPropertyInteger('LeistungsaufzeichnungMinuten', 1);
$this->RegisterPropertyInteger('LeistungLoeschenMonate', 12);
$this->RegisterPropertyInteger('EnergieaufzeichnungMinuten', 5);
$this->RegisterPropertyInteger('EnergieVerdichtenMonate', 12);
$this->RegisterPropertyInteger('EnergieLoeschenMonate', 0);
$this->RegisterPropertyBoolean('EnergyPieAnzeigen', false);
$this->RegisterPropertyBoolean('EnergiediagrammeAnzeigen', false);
$this->RegisterPropertyBoolean('FunFactsAnzeigen', false);
$this->RegisterPropertyBoolean('EnergieflussAnzeigen', false);
$this->RegisterPropertyInteger('EnergieflussLeistungseinheit', 0);
$this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10);
$this->EnableAction('Aktiv');
$this->RegisterVariableString('Betriebsart', 'Betriebsart', '', 20);
$this->RegisterVariableFloat('Netzleistung', 'Netzleistung', '', 30);
$this->RegisterAttributeString('VerbraucherCache', '{}');
$this->RegisterAttributeString('VerbraucherEnergie', '{}');
$this->RegisterAttributeString('LetzteSollwerte', '{}');
$this->RegisterAttributeString('LetztePvGrenzen', '{}');
$this->RegisterAttributeString('Netzfahrplan', '{}');
$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', '');
$this->RegisterAttributeString('StoerungsSnapshot', '{}');
$this->RegisterAttributeString('StoerungsGesendeterHash', '');
$this->RegisterAttributeInteger('StoerungsLetzterErfolg', 0);
$this->RegisterAttributeInteger('StoerungsNaechsterVersuch', 0);
$this->RegisterAttributeInteger('StoerungsFehlversuche', 0);
$this->RegisterAttributeInteger('MessungLetzterZeitstempel', 0);
$this->RegisterAttributeInteger('MessungLetzteLeistungsaufzeichnung', 0);
$this->RegisterAttributeInteger('MessungLetzteEnergieaufzeichnung', 0);
$this->RegisterAttributeString('MessungEnergiePuffer', '{}');
$this->RegisterAttributeString('MessungZaehlerstaende', '{}');
// 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);"
);
$this->RegisterTimer(
'StoerungenSenden',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'StoerungenSenden', true);"
);
$this->RegisterTimer(
'MesswerteAufzeichnen',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'MesswerteAufzeichnen', true);"
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->initialisiereLizenzInstallationID();
$this->aktualisiereVariablen();
try {
$this->pruefeKonfiguration();
$this->konfiguriereEnergiearchiv();
$this->synchronisiereEnergievisualisierungen();
} 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->SetTimerInterval('StoerungenSenden', 0);
$this->SetTimerInterval('MesswerteAufzeichnen', 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->SetTimerInterval(
'StoerungenSenden',
$this->ReadPropertyBoolean('StoerueberwachungAktiv') ? 30000 : 0
);
$messungAktiv = $this->messungKonfiguriert();
$this->SetTimerInterval('MesswerteAufzeichnen', $messungAktiv ? 60000 : 0);
if ($messungAktiv) {
$this->zeichneMesswerteAuf();
}
$this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen'));
$this->aktualisiereAnschlussstatus();
$geraetezugangFehlt = $this->geraetezugangBenoetigt()
&& $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();
}
if ($this->ReadPropertyBoolean('StoerueberwachungAktiv')) {
$this->sendeStoerungsSnapshot(true);
}
}
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();
$prognoseLizenziert = $this->prognoseLizenziert();
$netzfahrplanLizenziert = $this->berechtigungLizenziert(
Lizenzpruefung::NETZFAHRPLAN
);
$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
);
foreach (['PeakShavingTitel', 'EinspeisebegrenzungAktiv', 'Einspeisegrenze'] as $feld) {
$this->setzeFormularParameter(
$formular['elements'],
$feld,
'visible',
$peakShavingLizenziert
);
}
$prognoseAktiv = $this->ReadPropertyBoolean('PrognoseAktiv');
$this->setzeFormularParameter(
$formular['elements'],
'PrognoseAktiv',
'enabled',
$prognoseLizenziert || $prognoseAktiv
);
$this->setzeFormularParameter(
$formular['elements'],
'PrognoseLizenzInformation',
'caption',
$this->prognoseLizenzStatustext($prognoseAktiv, $prognoseLizenziert)
);
$this->setzeFormularParameter(
$formular['elements'],
'NetzfahrplanAktiv',
'enabled',
$prognoseAktiv && $peakShavingLizenziert && $netzfahrplanLizenziert
);
$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->aktualisiereEnergieflusswerte();
$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 'StoerungenSenden':
$this->sendeStoerungsSnapshot(false);
return;
case 'MesswerteAufzeichnen':
$this->zeichneMesswerteAuf();
return;
case 'FormPrognoseAktiv':
$aktiv = (bool) $wert;
$lizenziert = $this->prognoseLizenziert();
$netzfahrplanLizenziert = $this->berechtigungLizenziert(
Lizenzpruefung::NETZFAHRPLAN
);
$this->UpdateFormField(
'PrognoseLizenzInformation',
'caption',
$this->prognoseLizenzStatustext($aktiv, $lizenziert)
);
$this->UpdateFormField(
'NetzfahrplanAktiv',
'enabled',
$aktiv && $netzfahrplanLizenziert && $this->peakShavingLizenziert()
);
if (!$aktiv) {
$this->UpdateFormField('NetzfahrplanAktiv', 'value', false);
}
return;
case 'FormEnergyPieAnzeigen':
$this->synchronisiereEnergyPie((bool) $wert);
return;
case 'FormEnergiediagrammeAnzeigen':
$this->synchronisiereEnergiediagramme((bool) $wert);
return;
case 'FormFunFactsAnzeigen':
$this->synchronisiereFunFacts((bool) $wert);
return;
case 'FormEnergieflussAnzeigen':
$this->synchronisiereEnergiefluss((bool) $wert);
return;
case 'FormLizenzPruefen':
if (!is_string($wert)) {
throw new InvalidArgumentException('Der Lizenzcode muss als Text uebergeben werden.');
}
if ($this->aktualisiereLizenzfreigabe($wert, true)) {
$this->synchronisiereAnlagentopologie();
}
$this->UpdateFormField('LizenzInformation', 'caption', $this->lizenzStatustext($wert));
$this->aktualisiereLastspitzenformular($wert);
$this->aktualisierePrognoseformular($wert);
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.');
}
$jetzt = time();
$this->aktualisiereVerbraucherEnergie($absenderID, $daten, $jetzt);
$cache = $this->leseJsonAttribut('VerbraucherCache');
$cache[(string) $absenderID] = [
'EmpfangenAm' => $jetzt,
'Daten' => $daten,
];
$this->WriteAttributeString('VerbraucherCache', json_encode($cache, JSON_THROW_ON_ERROR));
$this->aktualisiereEnergieflusswerte();
$this->regeln(false);
}
/** @param array<string, mixed> $daten */
private function aktualisiereVerbraucherEnergie(int $instanzID, array $daten, int $jetzt): void
{
$energien = $this->leseJsonAttribut('VerbraucherEnergie');
$bisher = $energien[(string) $instanzID] ?? [];
$energieWh = is_array($bisher) && is_numeric($bisher['Energie_Wh'] ?? null)
? (float) $bisher['Energie_Wh']
: 0.0;
$letzterZeitpunkt = is_array($bisher) && is_int($bisher['Zeitpunkt'] ?? null)
? $bisher['Zeitpunkt']
: 0;
$letzteLeistung = is_array($bisher) && is_numeric($bisher['Leistung_W'] ?? null)
? (float) $bisher['Leistung_W']
: 0.0;
if ($letzterZeitpunkt > 0 && $jetzt > $letzterZeitpunkt) {
$dauer = min(
$jetzt - $letzterZeitpunkt,
$this->ReadPropertyInteger('VerbraucherTimeout')
);
$energieWh += $letzteLeistung * $dauer / 3600.0;
}
$energien[(string) $instanzID] = [
'Energie_Wh' => $energieWh,
'Zeitpunkt' => $jetzt,
'Leistung_W' => $this->verbraucherleistung($daten),
];
$this->WriteAttributeString(
'VerbraucherEnergie',
json_encode($energien, JSON_THROW_ON_ERROR)
);
}
/** @param array<string, mixed> $daten */
private function verbraucherleistung(array $daten): float
{
if ($daten['Leistungsquelle'] !== Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN
&& is_numeric($daten['Istleistung_W'])
) {
return (float) $daten['Istleistung_W'];
}
return (float) (ManagerRegler::angenommenerSollwert($daten) ?? 0);
}
/** @return array<int, float> */
private function bezogeneVerbraucherEnergienWh(): array
{
$ergebnis = [];
foreach ($this->leseJsonAttribut('VerbraucherEnergie') as $instanzID => $eintrag) {
if (!is_array($eintrag) || !is_numeric($eintrag['Energie_Wh'] ?? null)) {
continue;
}
$ergebnis[(int) $instanzID] = (float) $eintrag['Energie_Wh'];
}
return $ergebnis;
}
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) {
if (!$aktiv) {
try {
$this->gibEinspeisebegrenzungFrei();
} catch (Throwable $fehler) {
$this->setzeDiagnosewert(
'Wechselrichterstatus',
'Ruecksetzen fehlgeschlagen: ' . substr($fehler->getMessage(), 0, 500)
);
}
}
$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);
$this->regleEinspeisung($netzleistung, $stoerungen, $erneuern);
$fahrplanVorgabe = $this->aktuelleNetzfahrplanVorgabe();
$zielnetzleistung = $betriebsart === ManagerRegler::BETRIEBSART_PEAK
? (float) $lastspitzengrenze
: ($fahrplanVorgabe ?? $this->ReadPropertyFloat('SollwertSolarladen'));
$ergebnis = ManagerRegler::verteile(
$netzleistung,
$zielnetzleistung,
$verbraucher,
$betriebsart === ManagerRegler::BETRIEBSART_PEAK ? 'PrioritaetPeak' : 'PrioritaetPV',
$this->bezogeneVerbraucherEnergienWh()
);
$this->setzeDiagnosewert('Verteilbudget', (float) $ergebnis['Verteilbudget_W']);
$this->setzeDiagnosewert('Verbraucherstatus', json_encode([
'Betriebsart' => $betriebsart,
'Synchronisiert' => $synchronisiert,
'Restabweichung_W' => $ergebnis['Restabweichung_W'],
'Verbraucher' => $verbraucher,
'NichtGeregelt' => $nichtGeregelt,
], JSON_THROW_ON_ERROR));
$this->setzeDiagnosewert(
'NichtGeregelteGeraete',
$nichtGeregelt === []
? 'Alle aktiven Verbraucher werden geregelt.'
: $this->nichtGeregelteGeraeteText($nichtGeregelt)
);
$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);
}
/**
* @param list<string> $stoerungen
*/
private function regleEinspeisung(
float $netzleistung,
array &$stoerungen,
bool $erneuern
): void {
if (!$this->ReadPropertyBoolean('EinspeisebegrenzungAktiv')) {
try {
$this->gibEinspeisebegrenzungFrei();
$this->setzeDiagnosewert('Wechselrichterstatus', 'Nicht aktiv');
} catch (Throwable $fehler) {
$stoerungen[] = 'Einspeisebegrenzung zuruecksetzen: ' . $fehler->getMessage();
$this->setzeDiagnosewert(
'Wechselrichterstatus',
'Ruecksetzen fehlgeschlagen: ' . substr($fehler->getMessage(), 0, 500)
);
}
$this->setzeDiagnosewert('WirksameEinspeisegrenze', 0.0);
$this->setzeDiagnosewert('Abregelbedarf', 0.0);
return;
}
try {
$wechselrichter = $this->regelbareWechselrichter();
$letzteGrenzen = $this->leseJsonAttribut('LetztePvGrenzen');
$ergebnis = EinspeiseRegler::verteile(
$netzleistung,
$this->ReadPropertyFloat('Einspeisegrenze'),
$wechselrichter,
$letzteGrenzen
);
$konfiguration = [];
foreach ($wechselrichter as $eintrag) {
$konfiguration[$eintrag['ID']] = $eintrag;
}
foreach ($ergebnis['Grenzen_W'] as $id => $grenzeW) {
if (!$erneuern
&& isset($letzteGrenzen[$id])
&& abs((float) $letzteGrenzen[$id] - $grenzeW) < 0.5
) {
continue;
}
$eintrag = $konfiguration[$id];
$stellwert = EinspeiseRegler::stellwert(
$grenzeW,
(float) $eintrag['Nennleistung_W'],
$eintrag['Begrenzungsart']
);
RequestAction($eintrag['BegrenzungVariableID'], $stellwert);
}
$this->WriteAttributeString(
'LetztePvGrenzen',
json_encode($ergebnis['Grenzen_W'], JSON_THROW_ON_ERROR)
);
$this->setzeDiagnosewert(
'WirksameEinspeisegrenze',
$this->ReadPropertyFloat('Einspeisegrenze')
);
$this->setzeDiagnosewert('Abregelbedarf', $ergebnis['Abregelbedarf_W']);
$this->setzeDiagnosewert(
'Wechselrichterstatus',
json_encode($ergebnis, JSON_THROW_ON_ERROR)
);
} catch (Throwable $fehler) {
$stoerungen[] = 'Einspeisebegrenzung: ' . $fehler->getMessage();
$this->setzeDiagnosewert(
'Wechselrichterstatus',
'Fehler: ' . substr($fehler->getMessage(), 0, 500)
);
}
}
private function gibEinspeisebegrenzungFrei(): void
{
if ($this->leseJsonAttribut('LetztePvGrenzen') === []) {
return;
}
foreach ($this->regelbareWechselrichter() as $eintrag) {
RequestAction(
$eintrag['BegrenzungVariableID'],
EinspeiseRegler::stellwert(
(float) $eintrag['Nennleistung_W'],
(float) $eintrag['Nennleistung_W'],
$eintrag['Begrenzungsart']
)
);
}
$this->WriteAttributeString('LetztePvGrenzen', '{}');
}
/**
* @return list<array{
* ID: string,
* Nennleistung_W: float,
* Istleistung_W: float,
* BegrenzungVariableID: int,
* Begrenzungsart: string
* }>
*/
private function regelbareWechselrichter(): array
{
$liste = json_decode(
$this->ReadPropertyString('AnlagenWechselrichter'),
true,
512,
JSON_THROW_ON_ERROR
);
if (!is_array($liste)) {
throw new InvalidArgumentException('AnlagenWechselrichter muss eine JSON-Liste sein.');
}
$ergebnis = [];
foreach ($liste as $index => $eintrag) {
if (!is_array($eintrag)
|| !in_array($eintrag['Typ'] ?? null, ['pv', 'hybrid'], true)
) {
continue;
}
$istID = $eintrag['IstleistungVariableID'] ?? 0;
$begrenzungID = $eintrag['BegrenzungVariableID'] ?? 0;
if ($istID === 0 && $begrenzungID === 0) {
continue;
}
if (!is_int($istID) || !is_int($begrenzungID)
|| $istID <= 0 || $begrenzungID <= 0
|| !IPS_VariableExists($istID) || !IPS_VariableExists($begrenzungID)
) {
throw new InvalidArgumentException(
'Wechselrichter ' . ($index + 1) . ': Istleistung und Begrenzungsregister fehlen.'
);
}
$begrenzungsvariable = IPS_GetVariable($begrenzungID);
if ((int) ($begrenzungsvariable['VariableAction'] ?? 0) <= 0
&& (int) ($begrenzungsvariable['VariableCustomAction'] ?? 0) <= 0
) {
throw new InvalidArgumentException(
'Wechselrichter ' . ($index + 1) . ': Begrenzungsregister hat keine Aktion.'
);
}
$istleistung = GetValue($istID);
$nennleistung = $eintrag['ACNennleistung_kW'] ?? null;
$art = $eintrag['Begrenzungsart'] ?? 'percent';
if ((!is_int($istleistung) && !is_float($istleistung))
|| (!is_int($nennleistung) && !is_float($nennleistung))
|| !in_array($art, ['percent', 'watt'], true)
) {
throw new InvalidArgumentException(
'Wechselrichter ' . ($index + 1) . ': Regelparameter sind ungueltig.'
);
}
$ergebnis[] = [
'ID' => (string) ($eintrag['ID'] ?? ''),
'Nennleistung_W' => (float) $nennleistung * 1000.0,
'Istleistung_W' => max(0.0, (float) $istleistung),
'BegrenzungVariableID' => $begrenzungID,
'Begrenzungsart' => $art,
];
}
if ($ergebnis === []) {
throw new InvalidArgumentException('Kein PV-Wechselrichter mit Stellregister konfiguriert.');
}
return $ergebnis;
}
/** @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);
$lease = $this->leseLizenzLease();
$pruefmengen = Lizenzpruefung::beruecksichtigeBestandslizenzFuerWaermepumpen(
$lease,
$mengen
);
try {
Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen);
} catch (InvalidArgumentException $fehler) {
foreach ($pruefmengen as $kennung => $verwendet) {
$lizenziert = Lizenzpruefung::berechtigungsMenge($lease, $kennung);
if ($verwendet > $lizenziert) {
$name = $namen[$kennung]
?? ($kennung === 'consumer_single' ? 'Waermepumpe (Bestandslizenz)' : $kennung);
throw new InvalidArgumentException(sprintf(
'%s: %d aktiviert, aber nur %d lizenziert. Bitte Auswahl reduzieren oder Lizenz erweitern.',
$name,
$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
{
$meldungen = [];
foreach (array_values(array_unique($stoerungen)) as $meldung) {
$meldung = trim($meldung);
if ($meldung === '') {
continue;
}
$meldungen[] = [
'sourceType' => 'manager',
'sourceId' => 'ips:' . $this->InstanceID,
'sourceName' => IPS_GetName($this->InstanceID),
'code' => 'Manager.' . substr(hash('sha256', $meldung), 0, 16),
'severity' => 'error',
'message' => $meldung,
];
}
foreach ($verbraucher as $eintrag) {
foreach ($eintrag['Daten']['Zustand'] as $zustand) {
if ($zustand['Art'] !== 'Stoerung' || $zustand['Wert'] !== true) {
continue;
}
$instanzID = $eintrag['InstanzID'];
$text = trim((string) ($zustand['Text'] ?? $zustand['Kennung']));
$meldungen[] = [
'sourceType' => 'consumer',
'sourceId' => 'ips:' . $instanzID,
'sourceName' => IPS_InstanceExists($instanzID)
? IPS_GetName($instanzID)
: 'Instanz ' . $instanzID,
'code' => (string) $zustand['Kennung'],
'severity' => 'error',
'message' => $text !== '' ? $text : (string) $zustand['Kennung'],
];
}
}
$snapshot = StoerungsSnapshot::erstellen(
$this->initialisiereLizenzInstallationID(),
$this->InstanceID,
$this->stoerungsRolle(),
(bool) $this->GetValue('Aktiv'),
$meldungen
);
$this->WriteAttributeString(
'StoerungsSnapshot',
json_encode($snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
);
$texte = array_map(
static fn (array $meldung): string => $meldung['sourceName'] . ': ' . $meldung['message'],
$snapshot['faults']
);
$this->setzeDiagnosewert('Sammelstoerung', $texte !== []);
$this->setzeDiagnosewert('Stoertext', implode("\n", $texte));
$this->sendeStoerungsSnapshot(false);
}
private function stoerungsRolle(): string
{
return [
0 => 'standalone',
1 => 'main',
2 => 'submanager',
][$this->ReadPropertyInteger('Rolle')] ?? 'standalone';
}
private function sendeStoerungsSnapshot(bool $erzwingen): void
{
if (!$this->ReadPropertyBoolean('StoerueberwachungAktiv')) {
$this->setzeDiagnosewert('Stoerueberwachungsstatus', 'NichtVerwendet');
return;
}
try {
$snapshot = $this->leseJsonAttribut('StoerungsSnapshot');
if (($snapshot['version'] ?? null) !== StoerungsSnapshot::VERSION) {
// Erst ein real erfasster Vollsnapshot darf bestehende Portalstoerungen aufloesen.
$this->setzeDiagnosewert('Stoerueberwachungsstatus', 'Wartet');
return;
}
$hash = StoerungsSnapshot::zustandHash($snapshot);
$jetzt = time();
if (!$erzwingen && $jetzt < $this->ReadAttributeInteger('StoerungsNaechsterVersuch')) {
return;
}
if (!$erzwingen
&& hash_equals($this->ReadAttributeString('StoerungsGesendeterHash'), $hash)
&& ($jetzt - $this->ReadAttributeInteger('StoerungsLetzterErfolg'))
< $this->ReadPropertyInteger('StoerungsSendeintervall')
) {
return;
}
$token = trim($this->ReadAttributeString('PrognoseInstallationsToken'));
if ($token === '') {
$this->aktualisiereLizenzfreigabe(
$this->ReadPropertyString('Lizenzcode'),
true
);
$token = trim($this->ReadAttributeString('PrognoseInstallationsToken'));
}
if ($token === '') {
$lizenzfehler = trim($this->ReadAttributeString('LizenzLetzterFehler'));
throw new RuntimeException(
'Der Geraetezugang fuer die Stoerueberwachung fehlt.'
. ($lizenzfehler !== '' ? ' Lizenzpruefung: ' . $lizenzfehler : '')
);
}
$snapshot['capturedAt'] = gmdate('Y-m-d\TH:i:s\Z', $jetzt);
$this->uebertrageStoerungsSnapshot(
$this->ReadAttributeString('LizenzInstallationID'),
$snapshot,
$token
);
$this->WriteAttributeString('StoerungsGesendeterHash', $hash);
$this->WriteAttributeInteger('StoerungsLetzterErfolg', $jetzt);
$this->WriteAttributeInteger('StoerungsNaechsterVersuch', 0);
$this->WriteAttributeInteger('StoerungsFehlversuche', 0);
$this->setzeDiagnosewert('Stoerueberwachungsstatus', 'Verbunden');
} catch (Throwable $fehler) {
$versuche = $this->ReadAttributeInteger('StoerungsFehlversuche') + 1;
$wartezeit = min(
self::STOERUNGS_RETRY_MAX_SEKUNDEN,
30 * (2 ** min($versuche - 1, 5))
);
$meldung = substr($fehler->getMessage(), 0, 500);
$this->WriteAttributeInteger('StoerungsFehlversuche', $versuche);
$this->WriteAttributeInteger('StoerungsNaechsterVersuch', time() + $wartezeit);
$this->setzeDiagnosewert('Stoerueberwachungsstatus', 'Fehler: ' . $meldung);
$this->protokolliere('Stoerueberwachung', $meldung);
}
}
/** @param array<string, mixed> $nutzlast */
private function uebertrageStoerungsSnapshot(
string $installationID,
array $nutzlast,
string $token
): void {
$url = sprintf(self::STOERUNGS_ENDPOINT, rawurlencode($installationID));
$json = json_encode($nutzlast, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Die Stoerungsuebertragung konnte nicht vorbereitet werden.');
}
try {
curl_setopt_array($curl, [
CURLOPT_CUSTOMREQUEST => 'PUT',
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.3',
]);
$antwort = curl_exec($curl);
if ($antwort === false) {
throw new RuntimeException(
'Stoerungsdienst nicht erreichbar: ' . curl_error($curl)
);
}
if (strlen($antwort) > 65536) {
throw new RuntimeException('Die Antwort der Stoerungsuebertragung 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(
'Stoerungsuebertragung HTTP ' . $httpStatus . ': ' . $meldung
);
}
}
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
{
$leistungsvariablen = [
'PVLeistungArchiv' => 'PV-Leistung',
'HausverbrauchLeistungArchiv' => 'Hausverbrauch',
'NetzleistungArchiv' => 'Netzleistung Aufzeichnung',
'BatterieleistungArchiv' => 'Batterieleistung',
];
$energievariablen = [
'PVEnergie' => 'PV-Energie',
'Hausenergie' => 'Hausenergie',
'NetzbezugEnergie' => 'Netzbezug Energie',
'EinspeisungEnergie' => 'Einspeisung Energie',
'BatterieLadenEnergie' => 'Batterie geladen',
'BatterieEntladenEnergie' => 'Batterie entladen',
];
$position = 200;
foreach ($leistungsvariablen as $ident => $name) {
$this->RegisterVariableFloat($ident, $name, '~Power', $position);
IPS_SetHidden($this->GetIDForIdent($ident), !$this->ReadPropertyBoolean('MesswerteAnzeigen'));
$position += 10;
}
foreach ($energievariablen as $ident => $name) {
$this->RegisterVariableFloat($ident, $name, '~Electricity', $position);
IPS_SetHidden($this->GetIDForIdent($ident), !$this->ReadPropertyBoolean('MesswerteAnzeigen'));
$position += 10;
}
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
$this->RegisterVariableBoolean(
'NetzleistungGueltig',
'Netzleistung gueltig',
'~Switch',
40
);
$this->RegisterVariableFloat(
'WirksameLastspitzengrenze',
'Wirksame Lastspitzengrenze',
'',
50
);
$this->RegisterVariableFloat(
'WirksameEinspeisegrenze',
'Wirksame Einspeisegrenze',
'',
52
);
$this->RegisterVariableFloat('Abregelbedarf', 'Abregelbedarf', '', 54);
$this->RegisterVariableString(
'Wechselrichterstatus',
'Wechselrichterstatus',
'',
56
);
$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);
}
}
}
private function messungKonfiguriert(): bool
{
return $this->ReadPropertyBoolean('EnergieaufzeichnungAktiv')
&& $this->messquellenKonfiguriert();
}
private function messquellenKonfiguriert(): bool
{
try {
return $this->istNumerischeVariable($this->ReadPropertyInteger('NetzleistungVariableID'))
&& $this->pvMessquellen() !== [];
} catch (Throwable $fehler) {
return false;
}
}
/** @return list<array{VariableID: int, Faktor: float}> */
private function pvMessquellen(): array
{
$wechselrichter = $this->leseAnlagenKonfigurationsliste('AnlagenWechselrichter');
$pvFlaechen = $this->leseAnlagenKonfigurationsliste('AnlagenPVFlaechen');
$verwendeteWechselrichter = [];
foreach ($pvFlaechen as $pvFlaeche) {
$id = trim((string) ($pvFlaeche['WechselrichterID'] ?? ''));
if ($id !== '') {
$verwendeteWechselrichter[$id] = true;
}
}
$quellen = [];
foreach ($wechselrichter as $index => $eintrag) {
$typ = (string) ($eintrag['Typ'] ?? '');
$id = trim((string) ($eintrag['ID'] ?? ''));
if ($verwendeteWechselrichter !== []) {
if (!isset($verwendeteWechselrichter[$id])) {
continue;
}
} elseif (!in_array($typ, ['pv', 'hybrid'], true)) {
continue;
}
$variableID = $eintrag['IstleistungVariableID'] ?? 0;
if ($variableID === 0) {
continue;
}
if (!is_int($variableID) || !$this->istNumerischeVariable($variableID)) {
throw new InvalidArgumentException(
'Wechselrichter ' . ($index + 1) . ': Istleistung muss eine numerische Variable sein.'
);
}
$quellen[] = [
'VariableID' => $variableID,
'Faktor' => $this->anlagenMessfaktor(
$eintrag['Istleistungsfaktor'] ?? 1.0,
'Wechselrichter ' . ($index + 1)
),
];
}
return $quellen;
}
/** @return list<array{VariableID: int, Faktor: float}> */
private function batterieMessquellen(): array
{
$quellen = [];
foreach ($this->leseAnlagenKonfigurationsliste('AnlagenBatterien') as $index => $eintrag) {
$variableID = $eintrag['LeistungVariableID'] ?? 0;
if ($variableID === 0) {
continue;
}
if (!is_int($variableID) || !$this->istNumerischeVariable($variableID)) {
throw new InvalidArgumentException(
'Batterie ' . ($index + 1) . ': Leistung muss eine numerische Variable sein.'
);
}
$quellen[] = [
'VariableID' => $variableID,
'Faktor' => $this->anlagenMessfaktor(
$eintrag['Leistungsfaktor'] ?? 1.0,
'Batterie ' . ($index + 1)
),
];
}
return $quellen;
}
/** @return list<array{VariableID: int, Gewicht: float}> */
private function batterieSocQuellen(): array
{
$quellen = [];
foreach ($this->leseAnlagenKonfigurationsliste('AnlagenBatterien') as $index => $eintrag) {
$variableID = $eintrag['SOCVariableID'] ?? 0;
if ($variableID === 0) {
continue;
}
if (!is_int($variableID) || !$this->istNumerischeVariable($variableID)) {
throw new InvalidArgumentException(
'Batterie ' . ($index + 1) . ': SOC muss eine numerische Variable sein.'
);
}
$kapazitaet = $eintrag['Nutzkapazitaet_kWh'] ?? 0.0;
$quellen[] = [
'VariableID' => $variableID,
'Gewicht' => is_numeric($kapazitaet) && (float) $kapazitaet > 0.0
? (float) $kapazitaet
: 1.0,
];
}
return $quellen;
}
/**
* @return array<string, list<array{Schluessel: string, VariableID: int, Faktor: float}>>|null
*/
private function energieZaehlerquellen(): ?array
{
$wechselrichter = $this->leseAnlagenKonfigurationsliste('AnlagenWechselrichter');
$pvFlaechen = $this->leseAnlagenKonfigurationsliste('AnlagenPVFlaechen');
$batterien = $this->leseAnlagenKonfigurationsliste('AnlagenBatterien');
$pvWechselrichter = [];
foreach ($pvFlaechen as $pvFlaeche) {
$id = trim((string) ($pvFlaeche['WechselrichterID'] ?? ''));
if ($id !== '') {
$pvWechselrichter[$id] = true;
}
}
$hatZaehler = $this->ReadPropertyInteger('NetzbezugEnergieVariableID') > 0
|| $this->ReadPropertyInteger('NetzeinspeisungEnergieVariableID') > 0;
foreach ($wechselrichter as $eintrag) {
$hatZaehler = $hatZaehler || (int) ($eintrag['ErzeugungsenergieVariableID'] ?? 0) > 0;
}
foreach ($batterien as $eintrag) {
$hatZaehler = $hatZaehler
|| (int) ($eintrag['LadeenergieVariableID'] ?? 0) > 0
|| (int) ($eintrag['EntladeenergieVariableID'] ?? 0) > 0;
}
if (!$hatZaehler) {
return null;
}
$quellen = [
'PVEnergie' => [],
'NetzbezugEnergie' => [],
'EinspeisungEnergie' => [],
'BatterieLadenEnergie' => [],
'BatterieEntladenEnergie' => [],
];
$netz = [
'NetzbezugEnergie' => [
$this->ReadPropertyInteger('NetzbezugEnergieVariableID'),
$this->ReadPropertyFloat('NetzbezugEnergiefaktor'),
'Netzbezug',
],
'EinspeisungEnergie' => [
$this->ReadPropertyInteger('NetzeinspeisungEnergieVariableID'),
$this->ReadPropertyFloat('NetzeinspeisungEnergiefaktor'),
'Netzeinspeisung',
],
];
foreach ($netz as $ident => [$variableID, $faktor, $name]) {
$quellen[$ident][] = $this->energieZaehlerquelle(
$name,
$variableID,
$faktor,
strtolower($ident)
);
}
foreach ($wechselrichter as $index => $eintrag) {
$typ = (string) ($eintrag['Typ'] ?? '');
$id = trim((string) ($eintrag['ID'] ?? ''));
if ($pvWechselrichter !== []) {
if (!isset($pvWechselrichter[$id])) {
continue;
}
} elseif (!in_array($typ, ['pv', 'hybrid'], true)) {
continue;
}
$quellen['PVEnergie'][] = $this->energieZaehlerquelle(
'Wechselrichter ' . ($index + 1) . ': Erzeugungsenergie',
(int) ($eintrag['ErzeugungsenergieVariableID'] ?? 0),
$eintrag['Erzeugungsenergiefaktor'] ?? 1.0,
'pv:' . $id
);
}
if ($quellen['PVEnergie'] === []) {
throw new InvalidArgumentException('Mindestens ein PV-Erzeugungszaehler ist erforderlich.');
}
foreach ($batterien as $index => $eintrag) {
$id = trim((string) ($eintrag['ID'] ?? ''));
$faktor = $eintrag['Energiefaktor'] ?? 1.0;
$quellen['BatterieLadenEnergie'][] = $this->energieZaehlerquelle(
'Batterie ' . ($index + 1) . ': Ladeenergie',
(int) ($eintrag['LadeenergieVariableID'] ?? 0),
$faktor,
'batterie-laden:' . $id
);
$quellen['BatterieEntladenEnergie'][] = $this->energieZaehlerquelle(
'Batterie ' . ($index + 1) . ': Entladeenergie',
(int) ($eintrag['EntladeenergieVariableID'] ?? 0),
$faktor,
'batterie-entladen:' . $id
);
}
return $quellen;
}
/** @return list<array<string, mixed>> */
private function leseAnlagenKonfigurationsliste(string $property): array
{
$liste = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR);
if (!is_array($liste) || !array_is_list($liste)) {
throw new InvalidArgumentException($property . ' muss eine JSON-Liste sein.');
}
return array_values(array_filter($liste, 'is_array'));
}
/** @param mixed $wert */
private function anlagenMessfaktor($wert, string $bezeichnung): float
{
if (!is_int($wert) && !is_float($wert)) {
throw new InvalidArgumentException($bezeichnung . ': Messfaktor muss numerisch sein.');
}
$faktor = (float) $wert;
if (!is_finite($faktor) || $faktor === 0.0) {
throw new InvalidArgumentException($bezeichnung . ': Messfaktor muss endlich und ungleich 0 sein.');
}
return $faktor;
}
/**
* @param mixed $faktor
* @return array{Schluessel: string, VariableID: int, Faktor: float}
*/
private function energieZaehlerquelle(
string $bezeichnung,
int $variableID,
$faktor,
string $schluessel
): array {
if (!$this->istNumerischeVariable($variableID)) {
throw new InvalidArgumentException(
$bezeichnung . ' muss eine numerische Variable sein.'
);
}
if ((!is_int($faktor) && !is_float($faktor))
|| !is_finite((float) $faktor)
|| (float) $faktor <= 0.0
) {
throw new InvalidArgumentException(
$bezeichnung . ': Energiefaktor muss groesser als 0 sein.'
);
}
return [
'Schluessel' => $schluessel,
'VariableID' => $variableID,
'Faktor' => (float) $faktor,
];
}
/** @return array{PV: float, Haus: float, Netz: float, Batterie: float} */
private function leseMessleistungen(): array
{
$pv = 0.0;
foreach ($this->pvMessquellen() as $quelle) {
$pv += max(0.0, (float) GetValue($quelle['VariableID']) * $quelle['Faktor']);
}
$batterie = 0.0;
foreach ($this->batterieMessquellen() as $quelle) {
$batterie += (float) GetValue($quelle['VariableID']) * $quelle['Faktor'];
}
$netz = (float) GetValue($this->ReadPropertyInteger('NetzleistungVariableID'))
* $this->ReadPropertyFloat('Netzleistungsfaktor');
return [
'PV' => $pv,
'Haus' => EnergieMessung::hausleistung($pv, $netz, $batterie),
'Netz' => $netz,
'Batterie' => $batterie,
];
}
private function aktuelleBatterieSoc(): ?float
{
$quellen = $this->batterieSocQuellen();
if ($quellen === []) {
return $this->leseAnlagenKonfigurationsliste('AnlagenBatterien') === [] ? 0.0 : null;
}
$summe = 0.0;
$gewicht = 0.0;
foreach ($quellen as $quelle) {
$wert = (float) GetValue($quelle['VariableID']);
if (!is_finite($wert) || $wert < 0.0 || $wert > 100.0) {
return null;
}
$summe += $wert * $quelle['Gewicht'];
$gewicht += $quelle['Gewicht'];
}
return $gewicht > 0.0 ? $summe / $gewicht : null;
}
/** @return array<string, float>|null */
private function leseEnergieZaehlerDeltas(): ?array
{
$quellen = $this->energieZaehlerquellen();
if ($quellen === null) {
return null;
}
$alt = $this->leseJsonAttribut('MessungZaehlerstaende');
$neu = [];
$deltas = [];
$vollstaendigeBasis = true;
foreach ($quellen as $ident => $identQuellen) {
$deltas[$ident] = 0.0;
foreach ($identQuellen as $quelle) {
$schluessel = $quelle['Schluessel'];
$stand = (float) GetValue($quelle['VariableID']) * $quelle['Faktor'];
if (!is_finite($stand) || $stand < 0.0) {
throw new RuntimeException(
'Energiezaehler ' . $schluessel . ' liefert keinen gueltigen Stand.'
);
}
$neu[$schluessel] = $stand;
if (!isset($alt[$schluessel]) || !is_numeric($alt[$schluessel])) {
$vollstaendigeBasis = false;
continue;
}
$delta = $stand - (float) $alt[$schluessel];
if ($delta >= 0.0) {
$deltas[$ident] += $delta;
}
}
}
$this->WriteAttributeString(
'MessungZaehlerstaende',
json_encode($neu, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)
);
if (!$vollstaendigeBasis) {
return array_fill_keys(self::ENERGIE_VARIABLEN, 0.0);
}
$deltas['Hausenergie'] = EnergieMessung::hausenergie(
$deltas['PVEnergie'],
$deltas['NetzbezugEnergie'],
$deltas['EinspeisungEnergie'],
$deltas['BatterieLadenEnergie'],
$deltas['BatterieEntladenEnergie']
);
return $deltas;
}
private function zeichneMesswerteAuf(): void
{
if (!$this->messungKonfiguriert()) {
return;
}
$jetzt = time();
$leistungen = $this->leseMessleistungen();
$letzterZeitstempel = $this->ReadAttributeInteger('MessungLetzterZeitstempel');
$puffer = $this->leseJsonAttribut('MessungEnergiePuffer');
$energieDeltas = $this->leseEnergieZaehlerDeltas();
if ($energieDeltas === null && $letzterZeitstempel > 0) {
$sekunden = min(300, max(0, $jetzt - $letzterZeitstempel));
$energieDeltas = EnergieMessung::energieDeltas($leistungen, $sekunden);
}
foreach ($energieDeltas ?? [] as $ident => $delta) {
$puffer[$ident] = max(0.0, (float) ($puffer[$ident] ?? 0.0)) + $delta;
}
$this->WriteAttributeInteger('MessungLetzterZeitstempel', $jetzt);
$this->WriteAttributeString(
'MessungEnergiePuffer',
json_encode($puffer, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)
);
$leistungsintervall = $this->ReadPropertyInteger('LeistungsaufzeichnungMinuten') * 60;
$letzteLeistung = $this->ReadAttributeInteger('MessungLetzteLeistungsaufzeichnung');
if ($letzteLeistung === 0 || $jetzt - $letzteLeistung >= $leistungsintervall) {
foreach ([
'PVLeistungArchiv' => 'PV',
'HausverbrauchLeistungArchiv' => 'Haus',
'NetzleistungArchiv' => 'Netz',
'BatterieleistungArchiv' => 'Batterie',
] as $ident => $quelle) {
$this->SetValue($ident, $leistungen[$quelle]);
}
$this->WriteAttributeInteger('MessungLetzteLeistungsaufzeichnung', $jetzt);
}
$energieintervall = $this->ReadPropertyInteger('EnergieaufzeichnungMinuten') * 60;
$letzteEnergie = $this->ReadAttributeInteger('MessungLetzteEnergieaufzeichnung');
if ($letzteEnergie === 0) {
$this->WriteAttributeInteger('MessungLetzteEnergieaufzeichnung', $jetzt);
} elseif ($jetzt - $letzteEnergie >= $energieintervall) {
foreach (self::ENERGIE_VARIABLEN as $ident) {
$this->SetValue($ident, (float) $this->GetValue($ident) + (float) ($puffer[$ident] ?? 0.0));
}
$this->WriteAttributeString('MessungEnergiePuffer', '{}');
$this->WriteAttributeInteger('MessungLetzteEnergieaufzeichnung', $jetzt);
}
$funFactsID = @IPS_GetObjectIDByIdent('EnergieFunFacts', $this->InstanceID);
if (is_int($funFactsID) && $funFactsID > 0 && IPS_VariableExists($funFactsID)) {
$this->aktualisiereFunFacts($funFactsID);
}
$this->aktualisiereEnergieflusswerte();
}
private function istNumerischeVariable(int $variableID): bool
{
if ($variableID <= 0 || !IPS_VariableExists($variableID)) {
return false;
}
$variable = IPS_GetVariable($variableID);
return in_array((int) ($variable['VariableType'] ?? -1), [1, 2], true);
}
/** @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->lizenzPeakModus();
$basisGueltig = $codePasst && Lizenzpruefung::istLeaseGueltig(
$lease,
$installationID,
0
);
$leaseGueltig = $codePasst && Lizenzpruefung::istLeaseGueltig(
$lease,
$installationID,
$lastspitzenmodus
);
$berechtigungenPassen = $leaseGueltig
&& $this->lizenzBerechtigungenPassen($lease, $lastspitzenmodus);
$gueltig = $leaseGueltig && $berechtigungenPassen;
$berechtigungenVeraltet = $basisGueltig && !$berechtigungenPassen;
$geraetezugangFehlt = $this->geraetezugangBenoetigt()
&& $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->lizenzPeakModus()
);
if (!$this->lizenzBerechtigungenPassen(
$neueLease,
$this->lizenzPeakModus()
)) {
throw new InvalidArgumentException(
'Die Lizenz deckt die konfigurierten Funktionen oder Geraetemengen nicht ab.'
);
}
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 lizenzPeakModus(): int
{
$modus = $this->ReadPropertyInteger('Lastspitzenmodus');
if ($modus !== 0) {
return $modus;
}
return $this->ReadPropertyBoolean('EinspeisebegrenzungAktiv') ? 1 : 0;
}
private function peakShavingLizenziert(?string $lizenzcode = null): bool
{
return $this->berechtigungLizenziert(Lizenzpruefung::MANAGER_PEAK, $lizenzcode);
}
private function prognoseLizenziert(?string $lizenzcode = null): bool
{
return $this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN, $lizenzcode)
|| ($this->berechtigungLizenziert(Lizenzpruefung::PROGNOSE_PV, $lizenzcode)
&& $this->berechtigungLizenziert(
Lizenzpruefung::PROGNOSE_VERBRAUCH,
$lizenzcode
));
}
/** @param array<string, mixed> $lease */
private function prognoseBerechtigungInLease(array $lease): bool
{
return Lizenzpruefung::hatPrognoseBerechtigung($lease);
}
private function berechtigungLizenziert(string $kennung, ?string $lizenzcode = null): bool
{
$lizenzcode = Lizenzpruefung::normalisiereCode(
$lizenzcode ?? $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, $kennung) > 0;
}
/**
* @param array<string, mixed> $lease
*/
private function lizenzBerechtigungenPassen(array $lease, int $lastspitzenmodus): bool
{
if (($lastspitzenmodus !== 0 || $this->ReadPropertyBoolean('EinspeisebegrenzungAktiv'))
&& Lizenzpruefung::berechtigungsMenge(
$lease,
Lizenzpruefung::MANAGER_PEAK
) < 1
) {
return false;
}
if ($this->ReadPropertyBoolean('PrognoseAktiv')
&& !$this->prognoseBerechtigungInLease($lease)
) {
return false;
}
if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')
&& Lizenzpruefung::berechtigungsMenge(
$lease,
Lizenzpruefung::NETZFAHRPLAN
) < 1
) {
return false;
}
try {
[$mengen] = $this->verbraucherLizenzbelegung(
$this->konfigurierteAktiveVerbraucherIDs()
);
$pruefmengen = Lizenzpruefung::beruecksichtigeBestandslizenzFuerWaermepumpen(
$lease,
$mengen
);
Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen);
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(?string $lizenzcode = null): void
{
$lizenziert = $this->peakShavingLizenziert($lizenzcode);
$modus = $lizenziert ? $this->ReadPropertyInteger('Lastspitzenmodus') : 0;
$this->UpdateFormField(
'Lastspitzenmodus',
'options',
json_encode($this->lastspitzenOptionen($lizenziert), JSON_THROW_ON_ERROR)
);
$this->UpdateFormField('Lastspitzenmodus', 'value', $modus);
foreach (['PeakShavingTitel', 'EinspeisebegrenzungAktiv', 'Einspeisegrenze'] as $feld) {
$this->UpdateFormField($feld, 'visible', $lizenziert);
}
$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 aktualisierePrognoseformular(?string $lizenzcode = null): void
{
$aktiv = $this->ReadPropertyBoolean('PrognoseAktiv');
$lizenziert = $this->prognoseLizenziert($lizenzcode);
$netzfahrplanLizenziert = $this->berechtigungLizenziert(
Lizenzpruefung::NETZFAHRPLAN,
$lizenzcode
);
$this->UpdateFormField('PrognoseAktiv', 'enabled', $lizenziert || $aktiv);
$this->UpdateFormField(
'PrognoseLizenzInformation',
'caption',
$this->prognoseLizenzStatustext($aktiv, $lizenziert)
);
$this->UpdateFormField(
'NetzfahrplanAktiv',
'enabled',
$aktiv && $netzfahrplanLizenziert
&& $this->peakShavingLizenziert($lizenzcode)
);
}
private function prognoseLizenzStatustext(bool $aktiv, bool $lizenziert): string
{
$managerID = $this->ReadAttributeString('LizenzInstallationID');
if (!$lizenziert) {
return 'Prognosen sind deaktiviert. Fuer die Manager-ID '
. $managerID
. ' ist keine Prognoseberechtigung vorhanden. Benoetigt wird entweder '
. 'PV-Ertragsprognose zusammen mit Verbrauchsprognose oder der '
. 'Intelligente Netzfahrplan. Nach der Erweiterung im Lizenzportal '
. 'die Lizenz erneut pruefen und binden.';
}
return ($aktiv ? 'Prognosen sind aktiviert. ' : 'Prognosen sind deaktiviert. ')
. 'Die passende Lizenz fuer die Manager-ID '
. $managerID
. ' ist vorhanden.';
}
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->lizenzPeakModus()
);
$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->geraetezugangBenoetigt()
&& $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
{
$this->setzeDiagnosewert(
'Prognosestatus',
$this->ReadPropertyBoolean('PrognoseAktiv') ? 'Wartet' : 'NichtVerwendet'
);
foreach (['SDLAnschluss' => 'SDLStatus'] 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');
}
$this->setzeDiagnosewert(
'Stoerueberwachungsstatus',
$this->ReadPropertyBoolean('StoerueberwachungAktiv') ? 'Wartet' : '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.');
}
}
foreach (['LeistungsaufzeichnungMinuten', 'EnergieaufzeichnungMinuten'] as $property) {
$wert = $this->ReadPropertyInteger($property);
if (!in_array($wert, [1, 5, 60], true)) {
throw new InvalidArgumentException($property . ' muss 1, 5 oder 60 Minuten betragen.');
}
}
foreach (['LeistungLoeschenMonate', 'EnergieVerdichtenMonate', 'EnergieLoeschenMonate'] as $property) {
if ($this->ReadPropertyInteger($property) < 0) {
throw new InvalidArgumentException($property . ' darf nicht negativ sein.');
}
}
$energieLoeschen = $this->ReadPropertyInteger('EnergieLoeschenMonate');
$energieVerdichten = $this->ReadPropertyInteger('EnergieVerdichtenMonate');
if ($energieLoeschen > 0 && $energieLoeschen <= $energieVerdichten) {
throw new InvalidArgumentException('Energie darf erst nach der taeglichen Verdichtung geloescht werden.');
}
$intervall = $this->ReadPropertyInteger('PrognoseSendeintervall');
if ($intervall < 60 || $intervall > 3600) {
throw new InvalidArgumentException('PrognoseSendeintervall muss zwischen 60 und 3600 Sekunden liegen.');
}
$stoerungsIntervall = $this->ReadPropertyInteger('StoerungsSendeintervall');
if ($stoerungsIntervall < 60 || $stoerungsIntervall > 3600) {
throw new InvalidArgumentException('StoerungsSendeintervall muss zwischen 60 und 3600 Sekunden liegen.');
}
if ($this->ReadPropertyFloat('Umschaltdifferenz') < 0
|| $this->ReadPropertyFloat('Umschaltdifferenz') > 100
) {
throw new InvalidArgumentException('Umschaltdifferenz muss zwischen 0 und 100 liegen.');
}
if ($this->ReadPropertyFloat('Einspeisegrenze') < 0.0) {
throw new InvalidArgumentException('Einspeisegrenze darf nicht negativ sein.');
}
if ($this->ReadPropertyBoolean('EinspeisebegrenzungAktiv')) {
$this->regelbareWechselrichter();
}
if ($this->ReadPropertyBoolean('PrognoseAktiv')
&& !$this->prognoseTelemetrieKonfiguriert()
) {
throw new InvalidArgumentException(
'Aktive Prognosen benoetigen Netzleistung, PV-Leistung und Batterie-Ladezustand.'
);
}
if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')
&& (!$this->ReadPropertyBoolean('PrognoseAktiv')
|| !$this->ReadPropertyBoolean('EinspeisebegrenzungAktiv')
|| !$this->prognoseTelemetrieKonfiguriert())
) {
throw new InvalidArgumentException(
'Der intelligente Netzfahrplan benoetigt Einspeisebegrenzung und Prognosetelemetrie.'
);
}
$this->leseAktiveVerbraucherIDs('VerbraucherZuordnung');
$this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung');
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen'));
$this->leseAnlagentopologie();
$this->pvMessquellen();
$this->batterieMessquellen();
$this->batterieSocQuellen();
$this->energieZaehlerquellen();
foreach (['PrognoseAnschluss', 'SDLAnschluss'] 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 geraetezugangBenoetigt(): bool
{
if ($this->ReadPropertyBoolean('PrognoseAktiv')
|| $this->ReadPropertyBoolean('StoerueberwachungAktiv')
) {
return true;
}
try {
$topologie = $this->leseAnlagentopologie();
} catch (Throwable $fehler) {
return false;
}
return $topologie['inverters'] !== []
|| $topologie['pvArrays'] !== []
|| $topologie['batteries'] !== [];
}
private function prognoseTelemetrieKonfiguriert(): bool
{
return $this->ReadPropertyBoolean('PrognoseAktiv')
&& $this->messquellenKonfiguriert()
&& $this->aktuelleBatterieSoc() !== null;
}
private function aktuelleNetzfahrplanVorgabe(): ?float
{
if (!$this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
return null;
}
$fahrplan = $this->leseJsonAttribut('Netzfahrplan');
if (($fahrplan['status'] ?? null) !== 'available'
|| !is_array($fahrplan['points'] ?? null)
|| !is_string($fahrplan['validUntil'] ?? null)
) {
return null;
}
$gueltigBis = strtotime($fahrplan['validUntil']);
if ($gueltigBis === false || $gueltigBis <= time()) {
return null;
}
return ManagerRegler::interpolierteFahrplanVorgabe(
$fahrplan['points'],
time()
);
}
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',
$this->ReadPropertyBoolean('PrognoseAktiv')
? 'Verbunden'
: 'Topologie synchronisiert'
);
} 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.');
}
$leistungen = $this->leseMessleistungen();
$soc = $this->aktuelleBatterieSoc();
if ($soc === null) {
throw new RuntimeException('Der Batterie-Ladezustand ist nicht verfuegbar.');
}
$werte = [
'PV' => $leistungen['PV'],
'Hausverbrauch' => $leistungen['Haus'],
'Netzleistung' => $leistungen['Netz'],
'SOC' => $soc,
];
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.');
}
}
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);
if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
$this->aktualisiereNetzfahrplan($installationID, $token);
}
$this->setzeDiagnosewert('Prognosestatus', 'Verbunden');
} catch (Throwable $fehler) {
$meldung = substr($fehler->getMessage(), 0, 500);
$this->setzeDiagnosewert('Prognosestatus', 'Fehler: ' . $meldung);
$this->protokolliere('Prognosetelemetrie', $meldung);
}
}
private function aktualisiereNetzfahrplan(string $installationID, string $token): void
{
$url = sprintf(
self::PROGNOSE_FAHRPLAN_ENDPOINT,
rawurlencode($installationID)
) . '?' . http_build_query([
'exportLimitW' => $this->ReadPropertyFloat('Einspeisegrenze'),
]);
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Der Netzfahrplan konnte nicht vorbereitet werden.');
}
try {
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Authorization: Bearer ' . $token,
],
CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.2',
]);
$antwort = curl_exec($curl);
if ($antwort === false) {
throw new RuntimeException('Netzfahrplan nicht erreichbar: ' . curl_error($curl));
}
if (strlen($antwort) > 524288) {
throw new RuntimeException('Die Antwort des Netzfahrplans ist zu gross.');
}
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
} finally {
curl_close($curl);
}
if ($httpStatus === 401) {
$this->WriteAttributeString('PrognoseInstallationsToken', '');
}
if ($httpStatus !== 200) {
$meldung = 'Unbekannter Fehler';
try {
$fehlerdaten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
if (is_array($fehlerdaten) && is_string($fehlerdaten['error'] ?? null)) {
$meldung = $fehlerdaten['error'];
}
} catch (Throwable $fehler) {
// Der HTTP-Status bleibt die fuehrende Fehlerinformation.
}
throw new RuntimeException(
'Netzfahrplan HTTP ' . $httpStatus . ': ' . $meldung
);
}
$fahrplan = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($fahrplan)
|| ($fahrplan['schemaVersion'] ?? null) !== 1
|| !is_array($fahrplan['points'] ?? null)
) {
throw new RuntimeException('Der Netzfahrplan hat ein ungueltiges Format.');
}
$this->WriteAttributeString(
'Netzfahrplan',
json_encode($fahrplan, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
);
}
/** @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);
}
}
}