3047 lines
120 KiB
PHP
3047 lines
120 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->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10);
|
|
$this->EnableAction('Aktiv');
|
|
$this->RegisterVariableString('Betriebsart', 'Betriebsart', '', 20);
|
|
$this->RegisterVariableFloat('Netzleistung', 'Netzleistung', '', 30);
|
|
|
|
$this->RegisterAttributeString('VerbraucherCache', '{}');
|
|
$this->RegisterAttributeString('LetzteSollwerte', '{}');
|
|
$this->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->ReadPropertyBoolean('PrognoseAktiv')
|
|
|| $this->ReadPropertyBoolean('StoerueberwachungAktiv'))
|
|
&& $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->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.');
|
|
}
|
|
$this->aktualisiereLizenzfreigabe($wert, true);
|
|
$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.');
|
|
}
|
|
|
|
$cache = $this->leseJsonAttribut('VerbraucherCache');
|
|
$cache[(string) $absenderID] = [
|
|
'EmpfangenAm' => time(),
|
|
'Daten' => $daten,
|
|
];
|
|
$this->WriteAttributeString('VerbraucherCache', json_encode($cache, JSON_THROW_ON_ERROR));
|
|
$this->regeln(false);
|
|
}
|
|
|
|
private function regeln(bool $erneuern): void
|
|
{
|
|
if (!$this->aktualisiereLizenzfreigabe($this->ReadPropertyString('Lizenzcode'), false)) {
|
|
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
|
|
$this->setzeDiagnosewert('Verteilbudget', 0.0);
|
|
$this->setzeNichtGeregelteVerbraucher('Lizenz nicht freigegeben');
|
|
$this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG);
|
|
return;
|
|
}
|
|
try {
|
|
$this->aktiveVerbraucherIDs();
|
|
} catch (Throwable $fehler) {
|
|
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
|
|
$this->setzeDiagnosewert('Verteilbudget', 0.0);
|
|
$this->setzeDiagnosewert('Lizenzstatus', 'Nicht freigegeben: ' . $fehler->getMessage());
|
|
$this->setzeNichtGeregelteVerbraucher($fehler->getMessage());
|
|
$this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG);
|
|
return;
|
|
}
|
|
|
|
[$netzleistung, $netzleistungGueltig] = $this->leseNetzleistung();
|
|
$this->SetValue('Netzleistung', $netzleistung);
|
|
$this->setzeDiagnosewert('NetzleistungGueltig', $netzleistungGueltig);
|
|
|
|
$aktiv = (bool) $this->GetValue('Aktiv');
|
|
if (!$aktiv || !$netzleistungGueltig) {
|
|
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->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);
|
|
}
|
|
}
|
|
|
|
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->ReadPropertyBoolean('PrognoseAktiv')
|
|
|| $this->ReadPropertyBoolean('StoerueberwachungAktiv'))
|
|
&& $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->ReadPropertyBoolean('PrognoseAktiv')
|
|
|| $this->ReadPropertyBoolean('StoerueberwachungAktiv'))
|
|
&& $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 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;
|
|
}
|
|
|
|
$jetzt = time();
|
|
$naechsterAbstand = PHP_INT_MAX;
|
|
$vorgabe = null;
|
|
foreach ($fahrplan['points'] as $punkt) {
|
|
if (!is_array($punkt)
|
|
|| !is_string($punkt['time'] ?? null)
|
|
|| (!is_int($punkt['gridTargetW'] ?? null)
|
|
&& !is_float($punkt['gridTargetW'] ?? null))
|
|
) {
|
|
continue;
|
|
}
|
|
$zeitpunkt = strtotime($punkt['time']);
|
|
if ($zeitpunkt === false) {
|
|
continue;
|
|
}
|
|
$abstand = abs($zeitpunkt - $jetzt);
|
|
if ($abstand < $naechsterAbstand) {
|
|
$naechsterAbstand = $abstand;
|
|
$vorgabe = (float) $punkt['gridTargetW'];
|
|
}
|
|
}
|
|
|
|
return $naechsterAbstand <= 1800 ? $vorgabe : null;
|
|
}
|
|
|
|
private function synchronisiereAnlagentopologie(): void
|
|
{
|
|
if (!$this->ReadPropertyBoolean('PrognoseAktiv')) {
|
|
$this->setzeDiagnosewert('Prognosestatus', 'NichtVerwendet');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$topologie = $this->leseAnlagentopologie();
|
|
if ($topologie['inverters'] === []
|
|
&& $topologie['pvArrays'] === []
|
|
&& $topologie['batteries'] === []
|
|
) {
|
|
$this->setzeDiagnosewert('Prognosestatus', 'NichtVerwendet');
|
|
return;
|
|
}
|
|
|
|
$token = trim($this->ReadAttributeString('PrognoseInstallationsToken'));
|
|
if ($token === '') {
|
|
throw new RuntimeException('Der Geraetezugang fuer die Prognose fehlt.');
|
|
}
|
|
$this->sendeAnlagentopologie($topologie, $token);
|
|
$this->setzeDiagnosewert('Prognosestatus', 'Verbunden');
|
|
} catch (Throwable $fehler) {
|
|
$meldung = substr($fehler->getMessage(), 0, 500);
|
|
$this->setzeDiagnosewert('Prognosestatus', 'Fehler: ' . $meldung);
|
|
$this->protokolliere('Prognosesynchronisation', $meldung);
|
|
}
|
|
}
|
|
|
|
/** @param array<string, mixed> $topologie */
|
|
private function sendeAnlagentopologie(array $topologie, string $token): void
|
|
{
|
|
$url = sprintf(
|
|
self::PROGNOSE_TOPOLOGIE_ENDPOINT,
|
|
rawurlencode($this->ReadAttributeString('LizenzInstallationID'))
|
|
);
|
|
$nutzlast = json_encode(
|
|
$topologie,
|
|
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
|
|
);
|
|
$curl = curl_init($url);
|
|
if ($curl === false) {
|
|
throw new RuntimeException('Die Prognosesynchronisation konnte nicht vorbereitet werden.');
|
|
}
|
|
|
|
try {
|
|
curl_setopt_array($curl, [
|
|
CURLOPT_CUSTOMREQUEST => 'PUT',
|
|
CURLOPT_POSTFIELDS => $nutzlast,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
|
CURLOPT_TIMEOUT => 10,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Accept: application/json',
|
|
'Authorization: Bearer ' . $token,
|
|
'Content-Type: application/json',
|
|
],
|
|
CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.2',
|
|
]);
|
|
$antwort = curl_exec($curl);
|
|
if ($antwort === false) {
|
|
throw new RuntimeException(
|
|
'Prognosedienst nicht erreichbar: ' . curl_error($curl)
|
|
);
|
|
}
|
|
if (strlen($antwort) > 65536) {
|
|
throw new RuntimeException('Die Antwort der Prognosesynchronisation ist zu gross.');
|
|
}
|
|
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
|
} finally {
|
|
curl_close($curl);
|
|
}
|
|
|
|
if ($httpStatus === 401 || $httpStatus === 403) {
|
|
$this->WriteAttributeString('PrognoseInstallationsToken', '');
|
|
}
|
|
if ($httpStatus !== 200) {
|
|
$meldung = 'Unbekannter Fehler';
|
|
try {
|
|
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
|
|
if (is_array($daten) && is_string($daten['error'] ?? null)) {
|
|
$meldung = $daten['error'];
|
|
}
|
|
} catch (Throwable $fehler) {
|
|
// Der HTTP-Status bleibt die fuehrende Fehlerinformation.
|
|
}
|
|
throw new RuntimeException(
|
|
'Prognosesynchronisation HTTP ' . $httpStatus . ': ' . $meldung
|
|
);
|
|
}
|
|
}
|
|
|
|
private function sendePrognoseTelemetrie(): void
|
|
{
|
|
if (!$this->prognoseTelemetrieKonfiguriert()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$token = trim($this->ReadAttributeString('PrognoseInstallationsToken'));
|
|
if ($token === '') {
|
|
throw new RuntimeException('Der Geraetezugang fuer die Prognose fehlt.');
|
|
}
|
|
|
|
$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);
|
|
}
|
|
}
|
|
}
|