Tests / test (push) Failing after 1m12s
Add opt-in SDL measurement sources and independent visualization toggles. Exclude balancing power from household telemetry and new household energy. Preserve custom views and cover measurement, migration and Symcon 8.0 behavior.
3248 lines
128 KiB
PHP
3248 lines
128 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';
|
|
require_once __DIR__ . '/../libs/ManagerSDLTrait.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;
|
|
use \Belevo\EnelixEMS\ManagerSDLTrait;
|
|
|
|
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',
|
|
'SDLLeistungArchiv',
|
|
'SDLSOCArchiv',
|
|
];
|
|
private const ENERGIE_VARIABLEN = [
|
|
'PVEnergie',
|
|
'Hausenergie',
|
|
'NetzbezugEnergie',
|
|
'EinspeisungEnergie',
|
|
'BatterieLadenEnergie',
|
|
'BatterieEntladenEnergie',
|
|
'SDLLadenEnergie',
|
|
'SDLEntladenEnergie',
|
|
];
|
|
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->RegisterPropertyBoolean('SDLAktiv', false);
|
|
$this->RegisterPropertyInteger('SDLLeistungVariableID', 0);
|
|
$this->RegisterPropertyFloat('SDLLeistungsfaktor', 1.0);
|
|
$this->RegisterPropertyInteger('SDLSOCVariableID', 0);
|
|
$this->RegisterPropertyBoolean('SDLEnergieflussAnzeigen', false);
|
|
$this->RegisterPropertyBoolean('SDLDiagrammeAnzeigen', false);
|
|
$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->RegisterPropertyInteger('DiagrammPVModus', 0);
|
|
$this->RegisterPropertyInteger('DiagrammBatterieModus', 0);
|
|
$this->RegisterPropertyBoolean('LadestationenSeparatAnzeigen', true);
|
|
$this->RegisterPropertyBoolean('VerbraucherSeparatAnzeigen', true);
|
|
$this->RegisterPropertyBoolean('FunFactsAnzeigen', false);
|
|
$this->RegisterPropertyBoolean('EnergieflussAnzeigen', false);
|
|
$this->RegisterPropertyInteger('EnergieflussLeistungseinheit', 0);
|
|
|
|
$this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10);
|
|
$this->EnableAction('Aktiv');
|
|
$this->RegisterVariableString('Betriebsart', 'Betriebsart', '', 20);
|
|
$this->RegisterVariableFloat('Netzleistung', 'Netzleistung', '', 30);
|
|
|
|
$this->RegisterAttributeString('VerbraucherCache', '{}');
|
|
$this->RegisterAttributeString('VerbraucherEnergie', '{}');
|
|
$this->RegisterAttributeString('LetzteSollwerte', '{}');
|
|
$this->RegisterAttributeString('LetztePvGrenzen', '{}');
|
|
$this->RegisterAttributeString('Netzfahrplan', '{}');
|
|
$this->RegisterAttributeInteger('RegistrierteNetzleistungVariableID', 0);
|
|
$this->RegisterAttributeString('LizenzInstallationID', '');
|
|
$this->RegisterAttributeString('LizenzLease', '{}');
|
|
$this->RegisterAttributeString('LizenzcodeHash', '');
|
|
$this->RegisterAttributeInteger('LizenzNaechsterVersuch', 0);
|
|
$this->RegisterAttributeInteger('LizenzBerechtigungenNaechsterVersuch', 0);
|
|
$this->RegisterAttributeString('LizenzLetzterFehler', '');
|
|
$this->RegisterAttributeString('PrognoseInstallationsToken', '');
|
|
$this->RegisterAttributeString('StoerungsSnapshot', '{}');
|
|
$this->RegisterAttributeString('StoerungsGesendeterHash', '');
|
|
$this->RegisterAttributeInteger('StoerungsLetzterErfolg', 0);
|
|
$this->RegisterAttributeInteger('StoerungsNaechsterVersuch', 0);
|
|
$this->RegisterAttributeInteger('StoerungsFehlversuche', 0);
|
|
$this->RegisterAttributeInteger('MessungLetzterZeitstempel', 0);
|
|
$this->RegisterAttributeInteger('MessungLetzteLeistungsaufzeichnung', 0);
|
|
$this->RegisterAttributeInteger('MessungLetzteEnergieaufzeichnung', 0);
|
|
$this->RegisterAttributeString('MessungEnergiePuffer', '{}');
|
|
$this->RegisterAttributeString('MessungZaehlerstaende', '{}');
|
|
$this->RegisterAttributeString('EnergiediagrammKonfiguration', '{}');
|
|
$this->RegisterAttributeString('EnergieflussKonfiguration', '{}');
|
|
$this->RegisterAttributeBoolean('EnergieflussNeuErzeugen', false);
|
|
$this->RegisterAttributeString('SDLMesskonfiguration', '');
|
|
|
|
// 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(
|
|
'RegelungVerzoegert',
|
|
0,
|
|
"IPS_RequestAction(\$_IPS['TARGET'], 'RegelnVerzoegert', false);"
|
|
);
|
|
$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->synchronisiereSDLMesskonfiguration();
|
|
$this->synchronisiereEnergievisualisierungen();
|
|
$this->konfiguriereEnergiearchiv();
|
|
} catch (Throwable $fehler) {
|
|
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
|
|
$this->SetTimerInterval('Regelzyklus', 0);
|
|
$this->SetTimerInterval('VorgabenErneuern', 0);
|
|
$this->SetTimerInterval('KeepAlive', 0);
|
|
$this->SetTimerInterval('RegelungVerzoegert', 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('RegelungVerzoegert', 0);
|
|
$this->SetTimerInterval('PrognoseSenden', 0);
|
|
$this->SetTimerInterval(
|
|
'StoerungenSenden',
|
|
$this->ReadPropertyBoolean('StoerueberwachungAktiv') ? 30000 : 0
|
|
);
|
|
$messungAktiv = $this->messungKonfiguriert();
|
|
$this->SetTimerInterval('MesswerteAufzeichnen', $messungAktiv ? 60000 : 0);
|
|
if ($messungAktiv) {
|
|
$this->zeichneMesswerteAuf();
|
|
}
|
|
$this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen'));
|
|
$this->aktualisiereAnschlussstatus();
|
|
$geraetezugangFehlt = $this->geraetezugangBenoetigt()
|
|
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '';
|
|
if (!$this->aktualisiereLizenzfreigabe(
|
|
$this->ReadPropertyString('Lizenzcode'),
|
|
$geraetezugangFehlt
|
|
)) {
|
|
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
|
|
$this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG);
|
|
return;
|
|
}
|
|
try {
|
|
$this->aktiveVerbraucherIDs();
|
|
} catch (Throwable $fehler) {
|
|
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
|
|
$this->setzeDiagnosewert('Lizenzstatus', 'Nicht freigegeben: ' . $fehler->getMessage());
|
|
$this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG);
|
|
return;
|
|
}
|
|
$this->SetStatus(self::STATUS_AKTIV);
|
|
$this->synchronisiereAnlagentopologie();
|
|
if ($this->prognoseTelemetrieKonfiguriert()) {
|
|
$this->SetTimerInterval(
|
|
'PrognoseSenden',
|
|
$this->ReadPropertyInteger('PrognoseSendeintervall') * 1000
|
|
);
|
|
$this->sendePrognoseTelemetrie();
|
|
}
|
|
if ($this->ReadPropertyBoolean('StoerueberwachungAktiv')) {
|
|
$this->sendeStoerungsSnapshot(true);
|
|
}
|
|
}
|
|
|
|
public function GetConfigurationForm(): string
|
|
{
|
|
$formular = json_decode(
|
|
(string) file_get_contents(__DIR__ . '/form.json'),
|
|
true,
|
|
512,
|
|
JSON_THROW_ON_ERROR
|
|
);
|
|
$automatisch = $this->ReadPropertyBoolean('AutomatischeSuche');
|
|
$lastspitzenmodus = $this->ReadPropertyInteger('Lastspitzenmodus');
|
|
$peakShavingLizenziert = $this->peakShavingLizenziert();
|
|
$prognoseLizenziert = $this->prognoseLizenziert();
|
|
$netzfahrplanLizenziert = $this->berechtigungLizenziert(
|
|
Lizenzpruefung::NETZFAHRPLAN
|
|
);
|
|
$monatsgrenzenSichtbar = $peakShavingLizenziert
|
|
&& $lastspitzenmodus === 2
|
|
&& $this->GetBuffer('MonatsgrenzenSichtbar') === '1';
|
|
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'LizenzInstallationID',
|
|
'caption',
|
|
'Installations-ID: ' . $this->ReadAttributeString('LizenzInstallationID')
|
|
);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'LizenzInformation',
|
|
'caption',
|
|
$this->lizenzStatustext()
|
|
);
|
|
$this->setzeFormularParameter($formular['elements'], 'VerbraucherZuordnung', 'visible', !$automatisch);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'AutomatischeVerbraucherZuordnung',
|
|
'visible',
|
|
$automatisch
|
|
);
|
|
$this->setzeFormularParameter($formular['elements'], 'SuchbereichID', 'visible', $automatisch);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'VerbraucherAktualisieren',
|
|
'visible',
|
|
$automatisch
|
|
);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'AutomatischeVerbraucherZuordnung',
|
|
'values',
|
|
$this->automatischeVerbraucherFuerFormular()
|
|
);
|
|
$lastspitzenOptionen = $this->lastspitzenOptionen($peakShavingLizenziert);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'Lastspitzenmodus',
|
|
'options',
|
|
$lastspitzenOptionen
|
|
);
|
|
foreach (['PeakShavingTitel', 'EinspeisebegrenzungAktiv', 'Einspeisegrenze'] as $feld) {
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
$feld,
|
|
'visible',
|
|
$peakShavingLizenziert
|
|
);
|
|
}
|
|
$prognoseAktiv = $this->ReadPropertyBoolean('PrognoseAktiv');
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'PrognoseAktiv',
|
|
'enabled',
|
|
$prognoseLizenziert || $prognoseAktiv
|
|
);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'PrognoseLizenzInformation',
|
|
'caption',
|
|
$this->prognoseLizenzStatustext($prognoseAktiv, $prognoseLizenziert)
|
|
);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'NetzfahrplanAktiv',
|
|
'enabled',
|
|
$prognoseAktiv && $peakShavingLizenziert && $netzfahrplanLizenziert
|
|
);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'Lastspitzengrenze',
|
|
'visible',
|
|
$peakShavingLizenziert && $lastspitzenmodus === 1
|
|
);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'MonatsgrenzenUmschalten',
|
|
'visible',
|
|
$peakShavingLizenziert && $lastspitzenmodus === 2
|
|
);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'MonatsgrenzenUmschalten',
|
|
'caption',
|
|
$monatsgrenzenSichtbar ? 'Monatsgrenzen ausblenden' : 'Monatsgrenzen anzeigen'
|
|
);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'Monatsgrenzen',
|
|
'visible',
|
|
$monatsgrenzenSichtbar
|
|
);
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'Monatsgrenzen',
|
|
'values',
|
|
$this->monatsgrenzenFuerFormular()
|
|
);
|
|
|
|
$this->setzeFormularParameter(
|
|
$formular['elements'],
|
|
'SDLKonfiguration',
|
|
'visible',
|
|
$this->ReadPropertyBoolean('SDLAktiv')
|
|
);
|
|
|
|
return json_encode($formular, JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
public function AnlagentopologieExportieren(): string
|
|
{
|
|
return json_encode(
|
|
$this->leseAnlagentopologie(),
|
|
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION
|
|
);
|
|
}
|
|
|
|
public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void
|
|
{
|
|
if ((int) $senderID === $this->ReadPropertyInteger('NetzleistungVariableID')
|
|
&& (int) $nachricht === self::VM_UPDATE
|
|
) {
|
|
$this->aktualisiereEnergieflusswerte();
|
|
$this->regeln(false);
|
|
}
|
|
}
|
|
|
|
public function RequestAction($ident, $wert): void
|
|
{
|
|
switch ($ident) {
|
|
case 'Aktiv':
|
|
$this->SetValue('Aktiv', (bool) $wert);
|
|
$this->regeln(false);
|
|
return;
|
|
|
|
case 'Regeln':
|
|
$this->regeln((bool) $wert);
|
|
return;
|
|
|
|
case 'RegelnVerzoegert':
|
|
$this->SetTimerInterval('RegelungVerzoegert', 0);
|
|
$this->regeln(false);
|
|
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 'FormSDLAktiv':
|
|
$this->UpdateFormField('SDLKonfiguration', 'visible', (bool) $wert);
|
|
return;
|
|
|
|
case 'FormEnergyPieAnzeigen':
|
|
$this->synchronisiereEnergyPie((bool) $wert);
|
|
return;
|
|
|
|
case 'FormEnergiediagrammeAnzeigen':
|
|
$this->synchronisiereEnergiediagramme((bool) $wert);
|
|
return;
|
|
|
|
case 'FormFunFactsAnzeigen':
|
|
$this->synchronisiereFunFacts((bool) $wert);
|
|
return;
|
|
|
|
case 'FormEnergieflussAnzeigen':
|
|
$this->synchronisiereEnergiefluss((bool) $wert);
|
|
return;
|
|
|
|
case 'FormLizenzPruefen':
|
|
if (!is_string($wert)) {
|
|
throw new InvalidArgumentException('Der Lizenzcode muss als Text uebergeben werden.');
|
|
}
|
|
if ($this->aktualisiereLizenzfreigabe($wert, true)) {
|
|
$this->synchronisiereAnlagentopologie();
|
|
}
|
|
$this->UpdateFormField('LizenzInformation', 'caption', $this->lizenzStatustext($wert));
|
|
$this->aktualisiereLastspitzenformular($wert);
|
|
$this->aktualisierePrognoseformular($wert);
|
|
return;
|
|
|
|
case 'FormAutomatischeSuche':
|
|
$automatisch = (bool) $wert;
|
|
$this->UpdateFormField('VerbraucherZuordnung', 'visible', !$automatisch);
|
|
$this->UpdateFormField('AutomatischeVerbraucherZuordnung', 'visible', $automatisch);
|
|
$this->UpdateFormField('SuchbereichID', 'visible', $automatisch);
|
|
$this->UpdateFormField('VerbraucherAktualisieren', 'visible', $automatisch);
|
|
return;
|
|
|
|
case 'FormVerbraucherAktualisieren':
|
|
if (!is_string($wert)) {
|
|
throw new InvalidArgumentException('Suchparameter muessen als JSON uebergeben werden.');
|
|
}
|
|
$suchparameter = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
|
|
if (!is_array($suchparameter) || !is_array($suchparameter['Zuordnung'] ?? null)) {
|
|
throw new InvalidArgumentException('Suchparameter sind ungueltig.');
|
|
}
|
|
$suchbereichID = $suchparameter['SuchbereichID'] ?? 0;
|
|
if (!is_int($suchbereichID)) {
|
|
throw new InvalidArgumentException('SuchbereichID muss eine Ganzzahl sein.');
|
|
}
|
|
$werte = $this->automatischeVerbraucherFuerFormular(
|
|
$suchparameter['Zuordnung'],
|
|
$suchbereichID
|
|
);
|
|
$this->UpdateFormField(
|
|
'AutomatischeVerbraucherZuordnung',
|
|
'values',
|
|
json_encode($werte, JSON_THROW_ON_ERROR)
|
|
);
|
|
return;
|
|
|
|
case 'FormLastspitzenmodus':
|
|
$modus = (int) $wert;
|
|
if ($modus !== 0 && !$this->peakShavingLizenziert()) {
|
|
$this->UpdateFormField('Lastspitzenmodus', 'value', 0);
|
|
$this->UpdateFormField('Lastspitzengrenze', 'visible', false);
|
|
$this->UpdateFormField('MonatsgrenzenUmschalten', 'visible', false);
|
|
throw new InvalidArgumentException(
|
|
'Konstant und Monatlich benoetigen eine Peak-Shaving-Lizenz.'
|
|
);
|
|
}
|
|
$this->UpdateFormField('Lastspitzengrenze', 'visible', $modus === 1);
|
|
$this->UpdateFormField('MonatsgrenzenUmschalten', 'visible', $modus === 2);
|
|
if ($modus !== 2) {
|
|
$this->SetBuffer('MonatsgrenzenSichtbar', '0');
|
|
$this->UpdateFormField('Monatsgrenzen', 'visible', false);
|
|
$this->UpdateFormField(
|
|
'MonatsgrenzenUmschalten',
|
|
'caption',
|
|
'Monatsgrenzen anzeigen'
|
|
);
|
|
}
|
|
return;
|
|
|
|
case 'FormMonatsgrenzenUmschalten':
|
|
$sichtbar = $this->GetBuffer('MonatsgrenzenSichtbar') !== '1';
|
|
$this->SetBuffer('MonatsgrenzenSichtbar', $sichtbar ? '1' : '0');
|
|
$this->UpdateFormField('Monatsgrenzen', 'visible', $sichtbar);
|
|
$this->UpdateFormField(
|
|
'MonatsgrenzenUmschalten',
|
|
'caption',
|
|
$sichtbar ? 'Monatsgrenzen ausblenden' : 'Monatsgrenzen anzeigen'
|
|
);
|
|
return;
|
|
|
|
case 'VerbraucherdatenEmpfangen':
|
|
if (!is_string($wert)) {
|
|
throw new InvalidArgumentException('Verbraucherdaten muessen als JSON uebergeben werden.');
|
|
}
|
|
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
|
|
if (!is_array($daten)) {
|
|
throw new InvalidArgumentException('Verbraucherdaten muessen ein JSON-Objekt sein.');
|
|
}
|
|
$this->VerbraucherdatenEmpfangen($daten);
|
|
return;
|
|
|
|
}
|
|
|
|
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $daten
|
|
*/
|
|
public function VerbraucherdatenEmpfangen(array $daten): void
|
|
{
|
|
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
|
|
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
|
|
throw new InvalidArgumentException('Verbraucherdaten sind an eine andere Instanz adressiert.');
|
|
}
|
|
|
|
$absenderID = $daten['Kopf']['AbsenderID'];
|
|
if (!in_array($absenderID, $this->aktiveVerbraucherIDs(), true)) {
|
|
throw new InvalidArgumentException('Der Absender ist diesem Manager nicht aktiv zugeordnet.');
|
|
}
|
|
|
|
$jetzt = time();
|
|
$this->aktualisiereVerbraucherEnergie($absenderID, $daten, $jetzt);
|
|
|
|
$cache = $this->leseJsonAttribut('VerbraucherCache');
|
|
$cache[(string) $absenderID] = [
|
|
'EmpfangenAm' => $jetzt,
|
|
'Daten' => $daten,
|
|
];
|
|
$this->WriteAttributeString('VerbraucherCache', json_encode($cache, JSON_THROW_ON_ERROR));
|
|
$this->aktualisiereEnergieflusswerte();
|
|
$this->aktualisiereDiagrammMesswerte();
|
|
|
|
// Den synchronen Rueckruf in die noch meldende Verbraucherinstanz vermeiden.
|
|
$this->SetTimerInterval('RegelungVerzoegert', 100);
|
|
}
|
|
|
|
/** @param array<string, mixed> $daten */
|
|
private function aktualisiereVerbraucherEnergie(int $instanzID, array $daten, int $jetzt): void
|
|
{
|
|
$energien = $this->leseJsonAttribut('VerbraucherEnergie');
|
|
$bisher = $energien[(string) $instanzID] ?? [];
|
|
$energieWh = is_array($bisher) && is_numeric($bisher['Energie_Wh'] ?? null)
|
|
? (float) $bisher['Energie_Wh']
|
|
: 0.0;
|
|
$letzterZeitpunkt = is_array($bisher) && is_int($bisher['Zeitpunkt'] ?? null)
|
|
? $bisher['Zeitpunkt']
|
|
: 0;
|
|
$letzteLeistung = is_array($bisher) && is_numeric($bisher['Leistung_W'] ?? null)
|
|
? (float) $bisher['Leistung_W']
|
|
: 0.0;
|
|
|
|
if ($letzterZeitpunkt > 0 && $jetzt > $letzterZeitpunkt) {
|
|
$dauer = min(
|
|
$jetzt - $letzterZeitpunkt,
|
|
$this->ReadPropertyInteger('VerbraucherTimeout')
|
|
);
|
|
$energieWh += $letzteLeistung * $dauer / 3600.0;
|
|
}
|
|
|
|
$energien[(string) $instanzID] = [
|
|
'Energie_Wh' => $energieWh,
|
|
'Zeitpunkt' => $jetzt,
|
|
'Leistung_W' => $this->verbraucherleistung($daten),
|
|
];
|
|
$this->WriteAttributeString(
|
|
'VerbraucherEnergie',
|
|
json_encode($energien, JSON_THROW_ON_ERROR)
|
|
);
|
|
}
|
|
|
|
/** @param array<string, mixed> $daten */
|
|
private function verbraucherleistung(array $daten): float
|
|
{
|
|
if ($daten['Leistungsquelle'] !== Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN
|
|
&& is_numeric($daten['Istleistung_W'])
|
|
) {
|
|
return (float) $daten['Istleistung_W'];
|
|
}
|
|
|
|
return (float) (ManagerRegler::angenommenerSollwert($daten) ?? 0);
|
|
}
|
|
|
|
/** @return array<int, float> */
|
|
private function bezogeneVerbraucherEnergienWh(): array
|
|
{
|
|
$ergebnis = [];
|
|
foreach ($this->leseJsonAttribut('VerbraucherEnergie') as $instanzID => $eintrag) {
|
|
if (!is_array($eintrag) || !is_numeric($eintrag['Energie_Wh'] ?? null)) {
|
|
continue;
|
|
}
|
|
$ergebnis[(int) $instanzID] = (float) $eintrag['Energie_Wh'];
|
|
}
|
|
|
|
return $ergebnis;
|
|
}
|
|
|
|
private function regeln(bool $erneuern): void
|
|
{
|
|
if (!$this->aktualisiereLizenzfreigabe($this->ReadPropertyString('Lizenzcode'), false)) {
|
|
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
|
|
$this->setzeDiagnosewert('Verteilbudget', 0.0);
|
|
$this->setzeNichtGeregelteVerbraucher('Lizenz nicht freigegeben');
|
|
$this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG);
|
|
return;
|
|
}
|
|
try {
|
|
$this->aktiveVerbraucherIDs();
|
|
} catch (Throwable $fehler) {
|
|
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
|
|
$this->setzeDiagnosewert('Verteilbudget', 0.0);
|
|
$this->setzeDiagnosewert('Lizenzstatus', 'Nicht freigegeben: ' . $fehler->getMessage());
|
|
$this->setzeNichtGeregelteVerbraucher($fehler->getMessage());
|
|
$this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG);
|
|
return;
|
|
}
|
|
|
|
[$netzleistung, $netzleistungGueltig] = $this->leseNetzleistung();
|
|
$this->SetValue('Netzleistung', $netzleistung);
|
|
$this->setzeDiagnosewert('NetzleistungGueltig', $netzleistungGueltig);
|
|
|
|
$aktiv = (bool) $this->GetValue('Aktiv');
|
|
if (!$aktiv || !$netzleistungGueltig) {
|
|
if (!$aktiv) {
|
|
try {
|
|
$this->gibEinspeisebegrenzungFrei();
|
|
} catch (Throwable $fehler) {
|
|
$this->setzeDiagnosewert(
|
|
'Wechselrichterstatus',
|
|
'Ruecksetzen fehlgeschlagen: ' . substr($fehler->getMessage(), 0, 500)
|
|
);
|
|
}
|
|
}
|
|
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
|
|
$this->setzeDiagnosewert('Verteilbudget', 0.0);
|
|
$this->setzeNichtGeregelteVerbraucher(
|
|
$aktiv ? 'Netzleistungsmessung fehlt oder ist veraltet' : 'Manager ist deaktiviert'
|
|
);
|
|
$this->SetStatus($aktiv ? self::STATUS_MESSWERT_UNGUELTIG : self::STATUS_AKTIV);
|
|
return;
|
|
}
|
|
|
|
$lastspitzengrenze = ManagerRegler::wirksameLastspitzengrenze(
|
|
$this->ReadPropertyInteger('Lastspitzenmodus'),
|
|
$this->ReadPropertyFloat('Lastspitzengrenze'),
|
|
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')),
|
|
(int) date('n')
|
|
);
|
|
$bisherigeBetriebsart = (string) $this->GetValue('Betriebsart');
|
|
$betriebsart = ManagerRegler::bestimmeBetriebsart(
|
|
true,
|
|
true,
|
|
$netzleistung,
|
|
$bisherigeBetriebsart,
|
|
$lastspitzengrenze !== null,
|
|
$this->ReadPropertyFloat('SollwertSolarladen'),
|
|
$lastspitzengrenze ?? 0.0,
|
|
$this->ReadPropertyFloat('Umschaltdifferenz')
|
|
);
|
|
$this->SetValue('Betriebsart', $betriebsart);
|
|
if ($betriebsart !== $bisherigeBetriebsart) {
|
|
$this->WriteAttributeString('LetzteSollwerte', '{}');
|
|
}
|
|
|
|
$stoerungen = [];
|
|
$nichtGeregelt = [];
|
|
$synchronisiert = true;
|
|
$verbraucher = $this->aktuelleVerbraucher(
|
|
$stoerungen,
|
|
$nichtGeregelt,
|
|
$betriebsart,
|
|
$synchronisiert
|
|
);
|
|
$this->setzeDiagnosewert('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0);
|
|
$this->regleEinspeisung($netzleistung, $stoerungen, $erneuern);
|
|
|
|
$fahrplanVorgabe = $this->aktuelleNetzfahrplanVorgabe();
|
|
$zielnetzleistung = $betriebsart === ManagerRegler::BETRIEBSART_PEAK
|
|
? (float) $lastspitzengrenze
|
|
: ($fahrplanVorgabe ?? $this->ReadPropertyFloat('SollwertSolarladen'));
|
|
$ergebnis = ManagerRegler::verteile(
|
|
$netzleistung,
|
|
$zielnetzleistung,
|
|
$verbraucher,
|
|
$betriebsart === ManagerRegler::BETRIEBSART_PEAK ? 'PrioritaetPeak' : 'PrioritaetPV',
|
|
$this->bezogeneVerbraucherEnergienWh()
|
|
);
|
|
$this->setzeDiagnosewert('Verteilbudget', (float) $ergebnis['Verteilbudget_W']);
|
|
$this->setzeDiagnosewert('Verbraucherstatus', json_encode([
|
|
'Betriebsart' => $betriebsart,
|
|
'Synchronisiert' => $synchronisiert,
|
|
'Restabweichung_W' => $ergebnis['Restabweichung_W'],
|
|
'Verbraucher' => $verbraucher,
|
|
'NichtGeregelt' => $nichtGeregelt,
|
|
], JSON_THROW_ON_ERROR));
|
|
$this->setzeDiagnosewert(
|
|
'NichtGeregelteGeraete',
|
|
$nichtGeregelt === []
|
|
? 'Alle aktiven Verbraucher werden geregelt.'
|
|
: $this->nichtGeregelteGeraeteText($nichtGeregelt)
|
|
);
|
|
|
|
$letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte');
|
|
foreach ($ergebnis['Sollwerte'] as $instanzID => $sollleistung) {
|
|
if (!$erneuern && ($letzteSollwerte[(string) $instanzID] ?? null) === $sollleistung) {
|
|
continue;
|
|
}
|
|
try {
|
|
$this->sendeManagerdaten((int) $instanzID, $betriebsart, $sollleistung);
|
|
$letzteSollwerte[(string) $instanzID] = $sollleistung;
|
|
} catch (Throwable $fehler) {
|
|
$stoerungen[] = 'Sollwert an Instanz ' . $instanzID . ': ' . $fehler->getMessage();
|
|
}
|
|
}
|
|
$this->WriteAttributeString('LetzteSollwerte', json_encode($letzteSollwerte, JSON_THROW_ON_ERROR));
|
|
|
|
$this->aktualisiereStoerungen($verbraucher, $stoerungen);
|
|
$this->SetStatus(self::STATUS_AKTIV);
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $stoerungen
|
|
*/
|
|
private function regleEinspeisung(
|
|
float $netzleistung,
|
|
array &$stoerungen,
|
|
bool $erneuern
|
|
): void {
|
|
if (!$this->ReadPropertyBoolean('EinspeisebegrenzungAktiv')) {
|
|
try {
|
|
$this->gibEinspeisebegrenzungFrei();
|
|
$this->setzeDiagnosewert('Wechselrichterstatus', 'Nicht aktiv');
|
|
} catch (Throwable $fehler) {
|
|
$stoerungen[] = 'Einspeisebegrenzung zuruecksetzen: ' . $fehler->getMessage();
|
|
$this->setzeDiagnosewert(
|
|
'Wechselrichterstatus',
|
|
'Ruecksetzen fehlgeschlagen: ' . substr($fehler->getMessage(), 0, 500)
|
|
);
|
|
}
|
|
$this->setzeDiagnosewert('WirksameEinspeisegrenze', 0.0);
|
|
$this->setzeDiagnosewert('Abregelbedarf', 0.0);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$wechselrichter = $this->regelbareWechselrichter();
|
|
$letzteGrenzen = $this->leseJsonAttribut('LetztePvGrenzen');
|
|
$ergebnis = EinspeiseRegler::verteile(
|
|
$netzleistung,
|
|
$this->ReadPropertyFloat('Einspeisegrenze'),
|
|
$wechselrichter,
|
|
$letzteGrenzen
|
|
);
|
|
$konfiguration = [];
|
|
foreach ($wechselrichter as $eintrag) {
|
|
$konfiguration[$eintrag['ID']] = $eintrag;
|
|
}
|
|
foreach ($ergebnis['Grenzen_W'] as $id => $grenzeW) {
|
|
if (!$erneuern
|
|
&& isset($letzteGrenzen[$id])
|
|
&& abs((float) $letzteGrenzen[$id] - $grenzeW) < 0.5
|
|
) {
|
|
continue;
|
|
}
|
|
$eintrag = $konfiguration[$id];
|
|
$stellwert = EinspeiseRegler::stellwert(
|
|
$grenzeW,
|
|
(float) $eintrag['Nennleistung_W'],
|
|
$eintrag['Begrenzungsart']
|
|
);
|
|
RequestAction($eintrag['BegrenzungVariableID'], $stellwert);
|
|
}
|
|
$this->WriteAttributeString(
|
|
'LetztePvGrenzen',
|
|
json_encode($ergebnis['Grenzen_W'], JSON_THROW_ON_ERROR)
|
|
);
|
|
$this->setzeDiagnosewert(
|
|
'WirksameEinspeisegrenze',
|
|
$this->ReadPropertyFloat('Einspeisegrenze')
|
|
);
|
|
$this->setzeDiagnosewert('Abregelbedarf', $ergebnis['Abregelbedarf_W']);
|
|
$this->setzeDiagnosewert(
|
|
'Wechselrichterstatus',
|
|
json_encode($ergebnis, JSON_THROW_ON_ERROR)
|
|
);
|
|
} catch (Throwable $fehler) {
|
|
$stoerungen[] = 'Einspeisebegrenzung: ' . $fehler->getMessage();
|
|
$this->setzeDiagnosewert(
|
|
'Wechselrichterstatus',
|
|
'Fehler: ' . substr($fehler->getMessage(), 0, 500)
|
|
);
|
|
}
|
|
}
|
|
|
|
private function gibEinspeisebegrenzungFrei(): void
|
|
{
|
|
if ($this->leseJsonAttribut('LetztePvGrenzen') === []) {
|
|
return;
|
|
}
|
|
foreach ($this->regelbareWechselrichter() as $eintrag) {
|
|
RequestAction(
|
|
$eintrag['BegrenzungVariableID'],
|
|
EinspeiseRegler::stellwert(
|
|
(float) $eintrag['Nennleistung_W'],
|
|
(float) $eintrag['Nennleistung_W'],
|
|
$eintrag['Begrenzungsart']
|
|
)
|
|
);
|
|
}
|
|
$this->WriteAttributeString('LetztePvGrenzen', '{}');
|
|
}
|
|
|
|
/**
|
|
* @return list<array{
|
|
* ID: string,
|
|
* Nennleistung_W: float,
|
|
* Istleistung_W: float,
|
|
* BegrenzungVariableID: int,
|
|
* Begrenzungsart: string
|
|
* }>
|
|
*/
|
|
private function regelbareWechselrichter(): array
|
|
{
|
|
$liste = json_decode(
|
|
$this->ReadPropertyString('AnlagenWechselrichter'),
|
|
true,
|
|
512,
|
|
JSON_THROW_ON_ERROR
|
|
);
|
|
if (!is_array($liste)) {
|
|
throw new InvalidArgumentException('AnlagenWechselrichter muss eine JSON-Liste sein.');
|
|
}
|
|
|
|
$ergebnis = [];
|
|
foreach ($liste as $index => $eintrag) {
|
|
if (!is_array($eintrag)
|
|
|| !in_array($eintrag['Typ'] ?? null, ['pv', 'hybrid'], true)
|
|
) {
|
|
continue;
|
|
}
|
|
$istID = $eintrag['IstleistungVariableID'] ?? 0;
|
|
$begrenzungID = $eintrag['BegrenzungVariableID'] ?? 0;
|
|
if ($istID === 0 && $begrenzungID === 0) {
|
|
continue;
|
|
}
|
|
if (!is_int($istID) || !is_int($begrenzungID)
|
|
|| $istID <= 0 || $begrenzungID <= 0
|
|
|| !IPS_VariableExists($istID) || !IPS_VariableExists($begrenzungID)
|
|
) {
|
|
throw new InvalidArgumentException(
|
|
'Wechselrichter ' . ($index + 1) . ': Istleistung und Begrenzungsregister fehlen.'
|
|
);
|
|
}
|
|
$begrenzungsvariable = IPS_GetVariable($begrenzungID);
|
|
if ((int) ($begrenzungsvariable['VariableAction'] ?? 0) <= 0
|
|
&& (int) ($begrenzungsvariable['VariableCustomAction'] ?? 0) <= 0
|
|
) {
|
|
throw new InvalidArgumentException(
|
|
'Wechselrichter ' . ($index + 1) . ': Begrenzungsregister hat keine Aktion.'
|
|
);
|
|
}
|
|
$istleistung = GetValue($istID);
|
|
$nennleistung = $eintrag['ACNennleistung_kW'] ?? null;
|
|
$art = $eintrag['Begrenzungsart'] ?? 'percent';
|
|
if ((!is_int($istleistung) && !is_float($istleistung))
|
|
|| (!is_int($nennleistung) && !is_float($nennleistung))
|
|
|| !in_array($art, ['percent', 'watt'], true)
|
|
) {
|
|
throw new InvalidArgumentException(
|
|
'Wechselrichter ' . ($index + 1) . ': Regelparameter sind ungueltig.'
|
|
);
|
|
}
|
|
$ergebnis[] = [
|
|
'ID' => (string) ($eintrag['ID'] ?? ''),
|
|
'Nennleistung_W' => (float) $nennleistung * 1000.0,
|
|
'Istleistung_W' => max(0.0, (float) $istleistung),
|
|
'BegrenzungVariableID' => $begrenzungID,
|
|
'Begrenzungsart' => $art,
|
|
];
|
|
}
|
|
if ($ergebnis === []) {
|
|
throw new InvalidArgumentException('Kein PV-Wechselrichter mit Stellregister konfiguriert.');
|
|
}
|
|
|
|
return $ergebnis;
|
|
}
|
|
|
|
/** @return array{0: float, 1: bool} */
|
|
private function leseNetzleistung(): array
|
|
{
|
|
$variablenID = $this->ReadPropertyInteger('NetzleistungVariableID');
|
|
if ($variablenID <= 0 || !IPS_VariableExists($variablenID)) {
|
|
return [0.0, false];
|
|
}
|
|
|
|
$variable = IPS_GetVariable($variablenID);
|
|
if (time() - (int) $variable['VariableUpdated'] > $this->ReadPropertyInteger('MesswertMaxAlter')) {
|
|
return [0.0, false];
|
|
}
|
|
$wert = GetValue($variablenID);
|
|
if (!is_int($wert) && !is_float($wert)) {
|
|
return [0.0, false];
|
|
}
|
|
|
|
return [(float) $wert * $this->ReadPropertyFloat('Netzleistungsfaktor'), true];
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $stoerungen
|
|
* @param list<array<string, mixed>> $nichtGeregelt
|
|
* @return list<array{InstanzID: int, Daten: array<string, mixed>}>
|
|
*/
|
|
private function aktuelleVerbraucher(
|
|
array &$stoerungen,
|
|
array &$nichtGeregelt,
|
|
string $betriebsart,
|
|
bool &$synchronisiert
|
|
): array {
|
|
$ids = $this->aktiveVerbraucherIDs();
|
|
$this->setzeDiagnosewert('VerbraucherAnzahl', count($ids));
|
|
$cache = $this->leseJsonAttribut('VerbraucherCache');
|
|
$timeout = $this->ReadPropertyInteger('VerbraucherTimeout');
|
|
$aktuell = [];
|
|
|
|
foreach ($ids as $instanzID) {
|
|
$eintrag = $cache[(string) $instanzID] ?? null;
|
|
$grund = $this->verbraucherMeldegrund($eintrag, $betriebsart, $timeout);
|
|
$synchronisierungsfehler = '';
|
|
if (in_array($grund, ['Noch keine Meldung', 'Meldung ist veraltet'], true)) {
|
|
try {
|
|
$this->SetBuffer('VerbraucherSynchronisierungAktiv', '1');
|
|
IPS_RequestAction($instanzID, 'Melden', true);
|
|
} catch (Throwable $fehler) {
|
|
$synchronisierungsfehler = $fehler->getMessage();
|
|
} finally {
|
|
$this->SetBuffer('VerbraucherSynchronisierungAktiv', '0');
|
|
}
|
|
$cache = $this->leseJsonAttribut('VerbraucherCache');
|
|
$eintrag = $cache[(string) $instanzID] ?? null;
|
|
$grund = $this->verbraucherMeldegrund($eintrag, $betriebsart, $timeout);
|
|
}
|
|
|
|
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) {
|
|
$synchronisierungsfehler = $fehler->getMessage();
|
|
}
|
|
if ($synchronisierungsfehler !== '') {
|
|
$stoerungen[] = $detail['Name'] . ': Vollmeldung konnte nicht angefordert werden: '
|
|
. $synchronisierungsfehler;
|
|
}
|
|
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;
|
|
}
|
|
|
|
/** @param array<string, mixed>|null $eintrag */
|
|
private function verbraucherMeldegrund(
|
|
?array $eintrag,
|
|
string $betriebsart,
|
|
int $timeout
|
|
): string {
|
|
if (!isset($eintrag['EmpfangenAm'], $eintrag['Daten'])
|
|
|| !is_array($eintrag['Daten'])
|
|
) {
|
|
return 'Noch keine Meldung';
|
|
}
|
|
if (time() - (int) $eintrag['EmpfangenAm'] > $timeout) {
|
|
return 'Meldung ist veraltet';
|
|
}
|
|
if (($eintrag['Daten']['Betriebsart'] ?? null) !== $betriebsart) {
|
|
return 'Leistungsangebot gehoert nicht zur Betriebsart ' . $betriebsart;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
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',
|
|
'SDLLeistungArchiv' => 'SDL Istleistung',
|
|
];
|
|
$energievariablen = [
|
|
'PVEnergie' => 'PV-Energie',
|
|
'Hausenergie' => 'Hausenergie',
|
|
'NetzbezugEnergie' => 'Netzbezug Energie',
|
|
'EinspeisungEnergie' => 'Einspeisung Energie',
|
|
'BatterieLadenEnergie' => 'Batterie geladen',
|
|
'BatterieEntladenEnergie' => 'Batterie entladen',
|
|
'SDLLadenEnergie' => 'SDL geladen',
|
|
'SDLEntladenEnergie' => 'SDL 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;
|
|
}
|
|
|
|
$this->RegisterVariableFloat('SDLSOCArchiv', 'SDL Ladezustand', '~Intensity.100', 390);
|
|
IPS_SetHidden($this->GetIDForIdent('SDLSOCArchiv'), !$this->ReadPropertyBoolean('MesswerteAnzeigen'));
|
|
if ($this->ReadPropertyBoolean('SDLAktiv')) {
|
|
$this->RegisterVariableString('SDLStatus', 'SDL-Status', '', 120);
|
|
}
|
|
|
|
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) {
|
|
if ($ident === 'SDLStatus' && $this->ReadPropertyBoolean('SDLAktiv')) {
|
|
continue;
|
|
}
|
|
$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, SDL: 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');
|
|
$sdl = $this->aktuelleSDLLeistung();
|
|
|
|
return [
|
|
'PV' => $pv,
|
|
'Haus' => EnergieMessung::hausleistung($pv, $netz, $batterie, $sdl),
|
|
'Netz' => $netz,
|
|
'Batterie' => $batterie,
|
|
'SDL' => $sdl,
|
|
];
|
|
}
|
|
|
|
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(
|
|
float $sdlLaden = 0.0,
|
|
float $sdlEntladen = 0.0,
|
|
bool $hausVollstaendig = true
|
|
): ?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;
|
|
} elseif ($this->ReadPropertyBoolean('SDLAktiv')) {
|
|
$hausVollstaendig = false;
|
|
}
|
|
}
|
|
}
|
|
$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['SDLLadenEnergie'] = $sdlLaden;
|
|
$deltas['SDLEntladenEnergie'] = $sdlEntladen;
|
|
$deltas['Hausenergie'] = $hausVollstaendig ? EnergieMessung::hausenergie(
|
|
$deltas['PVEnergie'],
|
|
$deltas['NetzbezugEnergie'],
|
|
$deltas['EinspeisungEnergie'],
|
|
$deltas['BatterieLadenEnergie'],
|
|
$deltas['BatterieEntladenEnergie'],
|
|
$sdlLaden,
|
|
$sdlEntladen
|
|
) : 0.0;
|
|
|
|
return $deltas;
|
|
}
|
|
|
|
private function zeichneMesswerteAuf(): void
|
|
{
|
|
if (!$this->messungKonfiguriert()) {
|
|
return;
|
|
}
|
|
$jetzt = time();
|
|
try {
|
|
$leistungen = $this->leseMessleistungen();
|
|
} catch (Throwable $fehler) {
|
|
// Keine falsche Hauslast aufzeichnen oder ueber die Messluecke integrieren.
|
|
$this->WriteAttributeInteger('MessungLetzterZeitstempel', 0);
|
|
$this->WriteAttributeString('MessungZaehlerstaende', '{}');
|
|
$this->setzeDiagnosewert('SDLStatus', 'Fehler: ' . $fehler->getMessage());
|
|
$this->protokolliere('Energiemessung', $fehler->getMessage());
|
|
return;
|
|
}
|
|
$letzterZeitstempel = $this->ReadAttributeInteger('MessungLetzterZeitstempel');
|
|
$puffer = $this->leseJsonAttribut('MessungEnergiePuffer');
|
|
$dauer = $letzterZeitstempel > 0 ? max(0, $jetzt - $letzterZeitstempel) : 0;
|
|
$sdlIntervallGueltig = $letzterZeitstempel > 0 && $dauer <= 300;
|
|
$integriert = EnergieMessung::energieDeltas(
|
|
$leistungen,
|
|
$this->ReadPropertyBoolean('SDLAktiv') && !$sdlIntervallGueltig ? 0 : min(300, $dauer)
|
|
);
|
|
$energieDeltas = $this->leseEnergieZaehlerDeltas(
|
|
$integriert['SDLLadenEnergie'],
|
|
$integriert['SDLEntladenEnergie'],
|
|
!$this->ReadPropertyBoolean('SDLAktiv') || $sdlIntervallGueltig
|
|
);
|
|
if ($energieDeltas === null && $letzterZeitstempel > 0) {
|
|
$energieDeltas = $integriert;
|
|
}
|
|
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',
|
|
'SDLLeistungArchiv' => 'SDL',
|
|
] as $ident => $quelle) {
|
|
$this->SetValue($ident, $leistungen[$quelle]);
|
|
}
|
|
$this->WriteAttributeInteger('MessungLetzteLeistungsaufzeichnung', $jetzt);
|
|
$soc = $this->aktuelleSDLSOC();
|
|
if ($soc !== null) {
|
|
$this->SetValue('SDLSOCArchiv', $soc);
|
|
}
|
|
}
|
|
|
|
$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);
|
|
}
|
|
|
|
$this->aktualisiereDiagrammMesswerte(true);
|
|
$funFactsID = @IPS_GetObjectIDByIdent('EnergieFunFacts', $this->InstanceID);
|
|
if (is_int($funFactsID) && $funFactsID > 0 && IPS_VariableExists($funFactsID)) {
|
|
$this->aktualisiereFunFacts($funFactsID);
|
|
}
|
|
$this->aktualisiereEnergieflusswerte();
|
|
}
|
|
|
|
private function istNumerischeVariable(int $variableID): bool
|
|
{
|
|
if ($variableID <= 0 || !IPS_VariableExists($variableID)) {
|
|
return false;
|
|
}
|
|
$variable = IPS_GetVariable($variableID);
|
|
|
|
return in_array((int) ($variable['VariableType'] ?? -1), [1, 2], true);
|
|
}
|
|
|
|
/** @param bool|float|int|string $wert */
|
|
private function setzeDiagnosewert(string $ident, $wert): void
|
|
{
|
|
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
|
|
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
|
|
$this->SetValue($ident, $wert);
|
|
}
|
|
}
|
|
|
|
private function initialisiereLizenzInstallationID(): string
|
|
{
|
|
$installationID = trim($this->ReadAttributeString('LizenzInstallationID'));
|
|
if ($installationID !== '') {
|
|
return $installationID;
|
|
}
|
|
|
|
$installationID = $this->erzeugeUuidV4();
|
|
$this->WriteAttributeString('LizenzInstallationID', $installationID);
|
|
|
|
return $installationID;
|
|
}
|
|
|
|
private function aktualisiereLizenzfreigabe(string $lizenzcode, bool $erzwingen): bool
|
|
{
|
|
$normalisiert = Lizenzpruefung::normalisiereCode($lizenzcode);
|
|
if (!Lizenzpruefung::istLizenzcodeGueltig($normalisiert)) {
|
|
$fehler = $normalisiert === '' ? 'Lizenzcode fehlt.' : 'Lizenzcode ist ungueltig.';
|
|
$this->WriteAttributeString('LizenzLetzterFehler', $fehler);
|
|
$this->setzeDiagnosewert('Lizenzstatus', $fehler);
|
|
|
|
return false;
|
|
}
|
|
|
|
$installationID = $this->initialisiereLizenzInstallationID();
|
|
$codeHash = hash('sha256', $normalisiert);
|
|
$gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash');
|
|
if ($gespeicherterHash !== '' && !hash_equals($gespeicherterHash, $codeHash)) {
|
|
$this->WriteAttributeString('PrognoseInstallationsToken', '');
|
|
}
|
|
$lease = $this->leseLizenzLease();
|
|
$codePasst = $gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash);
|
|
$lastspitzenmodus = $this->lizenzPeakModus();
|
|
$basisGueltig = $codePasst && Lizenzpruefung::istLeaseGueltig(
|
|
$lease,
|
|
$installationID,
|
|
0
|
|
);
|
|
$leaseGueltig = $codePasst && Lizenzpruefung::istLeaseGueltig(
|
|
$lease,
|
|
$installationID,
|
|
$lastspitzenmodus
|
|
);
|
|
$berechtigungenPassen = $leaseGueltig
|
|
&& $this->lizenzBerechtigungenPassen($lease, $lastspitzenmodus);
|
|
$gueltig = $leaseGueltig && $berechtigungenPassen;
|
|
$berechtigungenVeraltet = $basisGueltig && !$berechtigungenPassen;
|
|
$geraetezugangFehlt = $this->geraetezugangBenoetigt()
|
|
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '';
|
|
$routineFaellig = (!$gueltig && !$berechtigungenVeraltet)
|
|
|| $geraetezugangFehlt
|
|
|| Lizenzpruefung::erneuerungFaellig($lease);
|
|
$jetzt = time();
|
|
|
|
if (!$erzwingen) {
|
|
if (!$routineFaellig && !$berechtigungenVeraltet) {
|
|
$this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode));
|
|
|
|
return $gueltig;
|
|
}
|
|
if ($routineFaellig
|
|
&& $jetzt < $this->ReadAttributeInteger('LizenzNaechsterVersuch')
|
|
) {
|
|
$this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode));
|
|
|
|
return $gueltig;
|
|
}
|
|
if ($berechtigungenVeraltet
|
|
&& $jetzt < $this->ReadAttributeInteger(
|
|
'LizenzBerechtigungenNaechsterVersuch'
|
|
)
|
|
) {
|
|
$this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode));
|
|
|
|
return $gueltig;
|
|
}
|
|
}
|
|
|
|
if ($routineFaellig || $erzwingen) {
|
|
$this->WriteAttributeInteger(
|
|
'LizenzNaechsterVersuch',
|
|
$jetzt + self::LIZENZ_RETRY_SEKUNDEN
|
|
);
|
|
}
|
|
if ($berechtigungenVeraltet || $erzwingen) {
|
|
$this->WriteAttributeInteger(
|
|
'LizenzBerechtigungenNaechsterVersuch',
|
|
$jetzt + self::LIZENZ_RETRY_SEKUNDEN
|
|
);
|
|
}
|
|
try {
|
|
$neueLease = $this->rufeLizenzserverAuf($normalisiert, $installationID);
|
|
$geraeteToken = $neueLease['deviceToken'] ?? null;
|
|
unset($neueLease['deviceToken']);
|
|
if ($geraetezugangFehlt
|
|
&& (!is_string($geraeteToken)
|
|
|| preg_match('/^[A-Za-z0-9_-]{43,128}$/', $geraeteToken) !== 1)
|
|
) {
|
|
throw new RuntimeException('Der Lizenzserver lieferte keinen gueltigen Geraetezugang.');
|
|
}
|
|
Lizenzpruefung::pruefeLease(
|
|
$neueLease,
|
|
$installationID,
|
|
$this->lizenzPeakModus()
|
|
);
|
|
if (!$this->lizenzBerechtigungenPassen(
|
|
$neueLease,
|
|
$this->lizenzPeakModus()
|
|
)) {
|
|
throw new InvalidArgumentException(
|
|
'Die Lizenz deckt die konfigurierten Funktionen oder Geraetemengen nicht ab.'
|
|
);
|
|
}
|
|
if ($geraetezugangFehlt) {
|
|
$this->WriteAttributeString('PrognoseInstallationsToken', $geraeteToken);
|
|
}
|
|
$this->WriteAttributeString(
|
|
'LizenzLease',
|
|
json_encode($neueLease, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
|
|
);
|
|
$this->WriteAttributeString('LizenzcodeHash', $codeHash);
|
|
$naechsteErneuerung = Lizenzpruefung::erneuernAb($neueLease);
|
|
$this->WriteAttributeInteger('LizenzNaechsterVersuch', $naechsteErneuerung);
|
|
$this->WriteAttributeInteger(
|
|
'LizenzBerechtigungenNaechsterVersuch',
|
|
$naechsteErneuerung
|
|
);
|
|
$this->WriteAttributeString('LizenzLetzterFehler', '');
|
|
$this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode));
|
|
|
|
return true;
|
|
} catch (Throwable $fehler) {
|
|
$meldung = substr($fehler->getMessage(), 0, 500);
|
|
$this->WriteAttributeString('LizenzLetzterFehler', $meldung);
|
|
$this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode));
|
|
$this->protokolliere('Lizenzpruefung', $meldung);
|
|
|
|
return $gueltig;
|
|
}
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function leseLizenzLease(): array
|
|
{
|
|
try {
|
|
$lease = json_decode(
|
|
$this->ReadAttributeString('LizenzLease'),
|
|
true,
|
|
512,
|
|
JSON_THROW_ON_ERROR
|
|
);
|
|
|
|
return is_array($lease) ? $lease : [];
|
|
} catch (Throwable $fehler) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private function lizenzPeakModus(): int
|
|
{
|
|
$modus = $this->ReadPropertyInteger('Lastspitzenmodus');
|
|
if ($modus !== 0) {
|
|
return $modus;
|
|
}
|
|
|
|
return $this->ReadPropertyBoolean('EinspeisebegrenzungAktiv') ? 1 : 0;
|
|
}
|
|
|
|
private function peakShavingLizenziert(?string $lizenzcode = null): bool
|
|
{
|
|
return $this->berechtigungLizenziert(Lizenzpruefung::MANAGER_PEAK, $lizenzcode);
|
|
}
|
|
|
|
private function prognoseLizenziert(?string $lizenzcode = null): bool
|
|
{
|
|
return $this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN, $lizenzcode)
|
|
|| ($this->berechtigungLizenziert(Lizenzpruefung::PROGNOSE_PV, $lizenzcode)
|
|
&& $this->berechtigungLizenziert(
|
|
Lizenzpruefung::PROGNOSE_VERBRAUCH,
|
|
$lizenzcode
|
|
));
|
|
}
|
|
|
|
/** @param array<string, mixed> $lease */
|
|
private function prognoseBerechtigungInLease(array $lease): bool
|
|
{
|
|
return Lizenzpruefung::hatPrognoseBerechtigung($lease);
|
|
}
|
|
|
|
private function berechtigungLizenziert(string $kennung, ?string $lizenzcode = null): bool
|
|
{
|
|
$lizenzcode = Lizenzpruefung::normalisiereCode(
|
|
$lizenzcode ?? $this->ReadPropertyString('Lizenzcode')
|
|
);
|
|
if (!Lizenzpruefung::istLizenzcodeGueltig($lizenzcode)) {
|
|
return false;
|
|
}
|
|
|
|
$gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash');
|
|
if ($gespeicherterHash === ''
|
|
|| !hash_equals($gespeicherterHash, hash('sha256', $lizenzcode))
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
$lease = $this->leseLizenzLease();
|
|
if (!Lizenzpruefung::istLeaseGueltig(
|
|
$lease,
|
|
$this->ReadAttributeString('LizenzInstallationID'),
|
|
0
|
|
)) {
|
|
return false;
|
|
}
|
|
|
|
return Lizenzpruefung::berechtigungsMenge($lease, $kennung) > 0;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $lease
|
|
*/
|
|
private function lizenzBerechtigungenPassen(array $lease, int $lastspitzenmodus): bool
|
|
{
|
|
if (($lastspitzenmodus !== 0 || $this->ReadPropertyBoolean('EinspeisebegrenzungAktiv'))
|
|
&& Lizenzpruefung::berechtigungsMenge(
|
|
$lease,
|
|
Lizenzpruefung::MANAGER_PEAK
|
|
) < 1
|
|
) {
|
|
return false;
|
|
}
|
|
if ($this->ReadPropertyBoolean('PrognoseAktiv')
|
|
&& !$this->prognoseBerechtigungInLease($lease)
|
|
) {
|
|
return false;
|
|
}
|
|
if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')
|
|
&& Lizenzpruefung::berechtigungsMenge(
|
|
$lease,
|
|
Lizenzpruefung::NETZFAHRPLAN
|
|
) < 1
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
[$mengen] = $this->verbraucherLizenzbelegung(
|
|
$this->konfigurierteAktiveVerbraucherIDs()
|
|
);
|
|
$pruefmengen = Lizenzpruefung::beruecksichtigeBestandslizenzFuerWaermepumpen(
|
|
$lease,
|
|
$mengen
|
|
);
|
|
Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen);
|
|
|
|
return true;
|
|
} catch (Throwable $fehler) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return list<array{caption: string, value: int}>
|
|
*/
|
|
private function lastspitzenOptionen(bool $lizenziert): array
|
|
{
|
|
$optionen = [[
|
|
'caption' => $lizenziert
|
|
? 'Aus (Peak-Shaving-Lizenz verfuegbar)'
|
|
: 'Aus (Peak-Shaving-Lizenz fehlt)',
|
|
'value' => 0,
|
|
]];
|
|
if ($lizenziert) {
|
|
$optionen[] = ['caption' => 'Konstant', 'value' => 1];
|
|
$optionen[] = ['caption' => 'Monatlich', 'value' => 2];
|
|
}
|
|
|
|
return $optionen;
|
|
}
|
|
|
|
private function aktualisiereLastspitzenformular(?string $lizenzcode = null): void
|
|
{
|
|
$lizenziert = $this->peakShavingLizenziert($lizenzcode);
|
|
$modus = $lizenziert ? $this->ReadPropertyInteger('Lastspitzenmodus') : 0;
|
|
$this->UpdateFormField(
|
|
'Lastspitzenmodus',
|
|
'options',
|
|
json_encode($this->lastspitzenOptionen($lizenziert), JSON_THROW_ON_ERROR)
|
|
);
|
|
$this->UpdateFormField('Lastspitzenmodus', 'value', $modus);
|
|
foreach (['PeakShavingTitel', 'EinspeisebegrenzungAktiv', 'Einspeisegrenze'] as $feld) {
|
|
$this->UpdateFormField($feld, 'visible', $lizenziert);
|
|
}
|
|
$this->UpdateFormField(
|
|
'Lastspitzengrenze',
|
|
'visible',
|
|
$lizenziert && $modus === 1
|
|
);
|
|
$this->UpdateFormField(
|
|
'MonatsgrenzenUmschalten',
|
|
'visible',
|
|
$lizenziert && $modus === 2
|
|
);
|
|
if (!$lizenziert || $modus !== 2) {
|
|
$this->SetBuffer('MonatsgrenzenSichtbar', '0');
|
|
$this->UpdateFormField('Monatsgrenzen', 'visible', false);
|
|
}
|
|
}
|
|
|
|
private function aktualisierePrognoseformular(?string $lizenzcode = null): void
|
|
{
|
|
$aktiv = $this->ReadPropertyBoolean('PrognoseAktiv');
|
|
$lizenziert = $this->prognoseLizenziert($lizenzcode);
|
|
$netzfahrplanLizenziert = $this->berechtigungLizenziert(
|
|
Lizenzpruefung::NETZFAHRPLAN,
|
|
$lizenzcode
|
|
);
|
|
$this->UpdateFormField('PrognoseAktiv', 'enabled', $lizenziert || $aktiv);
|
|
$this->UpdateFormField(
|
|
'PrognoseLizenzInformation',
|
|
'caption',
|
|
$this->prognoseLizenzStatustext($aktiv, $lizenziert)
|
|
);
|
|
$this->UpdateFormField(
|
|
'NetzfahrplanAktiv',
|
|
'enabled',
|
|
$aktiv && $netzfahrplanLizenziert
|
|
&& $this->peakShavingLizenziert($lizenzcode)
|
|
);
|
|
}
|
|
|
|
private function prognoseLizenzStatustext(bool $aktiv, bool $lizenziert): string
|
|
{
|
|
$managerID = $this->ReadAttributeString('LizenzInstallationID');
|
|
if (!$lizenziert) {
|
|
return 'Prognosen sind deaktiviert. Fuer die Manager-ID '
|
|
. $managerID
|
|
. ' ist keine Prognoseberechtigung vorhanden. Benoetigt wird entweder '
|
|
. 'PV-Ertragsprognose zusammen mit Verbrauchsprognose oder der '
|
|
. 'Intelligente Netzfahrplan. Nach der Erweiterung im Lizenzportal '
|
|
. 'die Lizenz erneut pruefen und binden.';
|
|
}
|
|
|
|
return ($aktiv ? 'Prognosen sind aktiviert. ' : 'Prognosen sind deaktiviert. ')
|
|
. 'Die passende Lizenz fuer die Manager-ID '
|
|
. $managerID
|
|
. ' ist vorhanden.';
|
|
}
|
|
|
|
private function lizenzStatustext(?string $lizenzcode = null): string
|
|
{
|
|
$code = Lizenzpruefung::normalisiereCode(
|
|
$lizenzcode ?? $this->ReadPropertyString('Lizenzcode')
|
|
);
|
|
if ($code === '') {
|
|
return 'Lizenzcode fehlt.';
|
|
}
|
|
if (!Lizenzpruefung::istLizenzcodeGueltig($code)) {
|
|
return 'Lizenzcode ist ungueltig.';
|
|
}
|
|
|
|
$codeHash = hash('sha256', $code);
|
|
$gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash');
|
|
$fehler = $this->ReadAttributeString('LizenzLetzterFehler');
|
|
if ($gespeicherterHash === '' || !hash_equals($gespeicherterHash, $codeHash)) {
|
|
return $fehler !== '' ? 'Nicht freigegeben: ' . $fehler : 'Lizenz noch nicht bestaetigt.';
|
|
}
|
|
|
|
$lease = $this->leseLizenzLease();
|
|
try {
|
|
Lizenzpruefung::pruefeLease(
|
|
$lease,
|
|
$this->ReadAttributeString('LizenzInstallationID'),
|
|
$this->lizenzPeakModus()
|
|
);
|
|
$this->aktiveVerbraucherIDs();
|
|
$status = ($lease['development'] ?? false) === true
|
|
? 'Aktiv (Entwicklung) - unbeschraenkt gueltig.'
|
|
: 'Aktiv - unbeschraenkt gueltig.';
|
|
if ($fehler !== '') {
|
|
$status .= ' Online-Pruefung fehlgeschlagen; lokale Freigabe endet am '
|
|
. date('d.m.Y H:i', Lizenzpruefung::offlineBis($lease))
|
|
. ', sofern keine Verbindung hergestellt wird. '
|
|
. 'Die gekaufte Lizenz bleibt unbeschraenkt gueltig. Fehler: '
|
|
. $fehler;
|
|
}
|
|
|
|
return $status;
|
|
} catch (Throwable $ungueltig) {
|
|
return 'Nicht freigegeben: ' . $ungueltig->getMessage();
|
|
}
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function rufeLizenzserverAuf(string $lizenzcode, string $installationID): array
|
|
{
|
|
$nutzlast = json_encode([
|
|
'code' => $lizenzcode,
|
|
'installationId' => $installationID,
|
|
'issueDeviceToken' => $this->geraetezugangBenoetigt()
|
|
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '',
|
|
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
|
$curl = curl_init(self::LIZENZ_ENDPOINT);
|
|
if ($curl === false) {
|
|
throw new RuntimeException('Die HTTPS-Anfrage konnte nicht vorbereitet werden.');
|
|
}
|
|
|
|
try {
|
|
curl_setopt_array($curl, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => $nutzlast,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
|
CURLOPT_TIMEOUT => 10,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Accept: application/json',
|
|
'Content-Type: application/json',
|
|
],
|
|
CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.1',
|
|
]);
|
|
$antwort = curl_exec($curl);
|
|
if ($antwort === false) {
|
|
throw new RuntimeException('Lizenzserver nicht erreichbar: ' . curl_error($curl));
|
|
}
|
|
if (strlen($antwort) > 65536) {
|
|
throw new RuntimeException('Die Antwort des Lizenzservers ist zu gross.');
|
|
}
|
|
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
|
} finally {
|
|
curl_close($curl);
|
|
}
|
|
|
|
try {
|
|
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (Throwable $fehler) {
|
|
throw new RuntimeException('Der Lizenzserver lieferte kein gueltiges JSON.', 0, $fehler);
|
|
}
|
|
if (!is_array($daten)) {
|
|
throw new RuntimeException('Der Lizenzserver lieferte kein JSON-Objekt.');
|
|
}
|
|
if ($httpStatus !== 200) {
|
|
$meldung = is_string($daten['error'] ?? null)
|
|
? $daten['error']
|
|
: 'Unbekannter Fehler';
|
|
throw new RuntimeException('Lizenzserver HTTP ' . $httpStatus . ': ' . $meldung);
|
|
}
|
|
|
|
return $daten;
|
|
}
|
|
|
|
private function erzeugeUuidV4(): string
|
|
{
|
|
$bytes = random_bytes(16);
|
|
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
|
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
|
$hex = bin2hex($bytes);
|
|
|
|
return sprintf(
|
|
'%s-%s-%s-%s-%s',
|
|
substr($hex, 0, 8),
|
|
substr($hex, 8, 4),
|
|
substr($hex, 12, 4),
|
|
substr($hex, 16, 4),
|
|
substr($hex, 20, 12)
|
|
);
|
|
}
|
|
|
|
private function aktualisiereAnschlussstatus(): void
|
|
{
|
|
$this->setzeDiagnosewert(
|
|
'Prognosestatus',
|
|
$this->ReadPropertyBoolean('PrognoseAktiv') ? 'Wartet' : 'NichtVerwendet'
|
|
);
|
|
$this->aktualisiereSDLStatus();
|
|
$this->setzeDiagnosewert(
|
|
'Stoerueberwachungsstatus',
|
|
$this->ReadPropertyBoolean('StoerueberwachungAktiv') ? 'Wartet' : 'NichtVerwendet'
|
|
);
|
|
}
|
|
|
|
private function pruefeKonfiguration(): void
|
|
{
|
|
$this->pruefeSDLKonfiguration();
|
|
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.');
|
|
}
|
|
}
|
|
foreach (['DiagrammPVModus', 'DiagrammBatterieModus'] as $property) {
|
|
if (!in_array($this->ReadPropertyInteger($property), [0, 1, 2], true)) {
|
|
throw new InvalidArgumentException($property . ' muss 0, 1 oder 2 sein.');
|
|
}
|
|
}
|
|
if (!in_array($this->ReadPropertyInteger('EnergieflussLeistungseinheit'), [0, 1], true)) {
|
|
throw new InvalidArgumentException(
|
|
'EnergieflussLeistungseinheit muss 0 oder 1 sein.'
|
|
);
|
|
}
|
|
$energieLoeschen = $this->ReadPropertyInteger('EnergieLoeschenMonate');
|
|
$energieVerdichten = $this->ReadPropertyInteger('EnergieVerdichtenMonate');
|
|
if ($energieLoeschen > 0 && $energieLoeschen <= $energieVerdichten) {
|
|
throw new InvalidArgumentException('Energie darf erst nach der taeglichen Verdichtung geloescht werden.');
|
|
}
|
|
$intervall = $this->ReadPropertyInteger('PrognoseSendeintervall');
|
|
if ($intervall < 60 || $intervall > 3600) {
|
|
throw new InvalidArgumentException('PrognoseSendeintervall muss zwischen 60 und 3600 Sekunden liegen.');
|
|
}
|
|
$stoerungsIntervall = $this->ReadPropertyInteger('StoerungsSendeintervall');
|
|
if ($stoerungsIntervall < 60 || $stoerungsIntervall > 3600) {
|
|
throw new InvalidArgumentException('StoerungsSendeintervall muss zwischen 60 und 3600 Sekunden liegen.');
|
|
}
|
|
if ($this->ReadPropertyFloat('Umschaltdifferenz') < 0
|
|
|| $this->ReadPropertyFloat('Umschaltdifferenz') > 100
|
|
) {
|
|
throw new InvalidArgumentException('Umschaltdifferenz muss zwischen 0 und 100 liegen.');
|
|
}
|
|
if ($this->ReadPropertyFloat('Einspeisegrenze') < 0.0) {
|
|
throw new InvalidArgumentException('Einspeisegrenze darf nicht negativ sein.');
|
|
}
|
|
if ($this->ReadPropertyBoolean('EinspeisebegrenzungAktiv')) {
|
|
$this->regelbareWechselrichter();
|
|
}
|
|
if ($this->ReadPropertyBoolean('PrognoseAktiv')
|
|
&& !$this->prognoseTelemetrieKonfiguriert()
|
|
) {
|
|
throw new InvalidArgumentException(
|
|
'Aktive Prognosen benoetigen Netzleistung, PV-Leistung und Batterie-Ladezustand.'
|
|
);
|
|
}
|
|
if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')
|
|
&& (!$this->ReadPropertyBoolean('PrognoseAktiv')
|
|
|| !$this->ReadPropertyBoolean('EinspeisebegrenzungAktiv')
|
|
|| !$this->prognoseTelemetrieKonfiguriert())
|
|
) {
|
|
throw new InvalidArgumentException(
|
|
'Der intelligente Netzfahrplan benoetigt Einspeisebegrenzung und Prognosetelemetrie.'
|
|
);
|
|
}
|
|
$this->leseAktiveVerbraucherIDs('VerbraucherZuordnung');
|
|
$this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung');
|
|
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen'));
|
|
$this->leseAnlagentopologie();
|
|
$this->pvMessquellen();
|
|
$this->batterieMessquellen();
|
|
$this->batterieSocQuellen();
|
|
$this->energieZaehlerquellen();
|
|
foreach (['PrognoseAnschluss', 'SDLAnschluss'] as $property) {
|
|
$wert = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR);
|
|
if (!is_array($wert)) {
|
|
throw new InvalidArgumentException($property . ' muss ein JSON-Objekt sein.');
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function leseAnlagentopologie(): array
|
|
{
|
|
return Anlagentopologie::ausJson(
|
|
$this->ReadPropertyString('AnlagenWechselrichter'),
|
|
$this->ReadPropertyString('AnlagenPVFlaechen'),
|
|
$this->ReadPropertyString('AnlagenBatterien'),
|
|
$this->ReadAttributeString('LizenzInstallationID')
|
|
);
|
|
}
|
|
|
|
private function geraetezugangBenoetigt(): bool
|
|
{
|
|
if ($this->ReadPropertyBoolean('PrognoseAktiv')
|
|
|| $this->ReadPropertyBoolean('StoerueberwachungAktiv')
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
$topologie = $this->leseAnlagentopologie();
|
|
} catch (Throwable $fehler) {
|
|
return false;
|
|
}
|
|
|
|
return $topologie['inverters'] !== []
|
|
|| $topologie['pvArrays'] !== []
|
|
|| $topologie['batteries'] !== [];
|
|
}
|
|
|
|
private function prognoseTelemetrieKonfiguriert(): bool
|
|
{
|
|
return $this->ReadPropertyBoolean('PrognoseAktiv')
|
|
&& $this->messquellenKonfiguriert()
|
|
&& $this->aktuelleBatterieSoc() !== null;
|
|
}
|
|
|
|
private function aktuelleNetzfahrplanVorgabe(): ?float
|
|
{
|
|
if (!$this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
|
|
return null;
|
|
}
|
|
$fahrplan = $this->leseJsonAttribut('Netzfahrplan');
|
|
if (($fahrplan['status'] ?? null) !== 'available'
|
|
|| !is_array($fahrplan['points'] ?? null)
|
|
|| !is_string($fahrplan['validUntil'] ?? null)
|
|
) {
|
|
return null;
|
|
}
|
|
$gueltigBis = strtotime($fahrplan['validUntil']);
|
|
if ($gueltigBis === false || $gueltigBis <= time()) {
|
|
return null;
|
|
}
|
|
|
|
return ManagerRegler::interpolierteFahrplanVorgabe(
|
|
$fahrplan['points'],
|
|
time()
|
|
);
|
|
}
|
|
|
|
private function synchronisiereAnlagentopologie(): void
|
|
{
|
|
try {
|
|
$topologie = $this->leseAnlagentopologie();
|
|
if ($topologie['inverters'] === []
|
|
&& $topologie['pvArrays'] === []
|
|
&& $topologie['batteries'] === []
|
|
) {
|
|
$this->setzeDiagnosewert('Prognosestatus', 'NichtVerwendet');
|
|
return;
|
|
}
|
|
|
|
$token = trim($this->ReadAttributeString('PrognoseInstallationsToken'));
|
|
if ($token === '') {
|
|
throw new RuntimeException('Der Geraetezugang fuer die Prognose fehlt.');
|
|
}
|
|
$this->sendeAnlagentopologie($topologie, $token);
|
|
$this->setzeDiagnosewert(
|
|
'Prognosestatus',
|
|
$this->ReadPropertyBoolean('PrognoseAktiv')
|
|
? 'Verbunden'
|
|
: 'Topologie synchronisiert'
|
|
);
|
|
} catch (Throwable $fehler) {
|
|
$meldung = substr($fehler->getMessage(), 0, 500);
|
|
$this->setzeDiagnosewert('Prognosestatus', 'Fehler: ' . $meldung);
|
|
$this->protokolliere('Prognosesynchronisation', $meldung);
|
|
}
|
|
}
|
|
|
|
/** @param array<string, mixed> $topologie */
|
|
private function sendeAnlagentopologie(array $topologie, string $token): void
|
|
{
|
|
$url = sprintf(
|
|
self::PROGNOSE_TOPOLOGIE_ENDPOINT,
|
|
rawurlencode($this->ReadAttributeString('LizenzInstallationID'))
|
|
);
|
|
$nutzlast = json_encode(
|
|
$topologie,
|
|
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
|
|
);
|
|
$curl = curl_init($url);
|
|
if ($curl === false) {
|
|
throw new RuntimeException('Die Prognosesynchronisation konnte nicht vorbereitet werden.');
|
|
}
|
|
|
|
try {
|
|
curl_setopt_array($curl, [
|
|
CURLOPT_CUSTOMREQUEST => 'PUT',
|
|
CURLOPT_POSTFIELDS => $nutzlast,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
|
CURLOPT_TIMEOUT => 10,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Accept: application/json',
|
|
'Authorization: Bearer ' . $token,
|
|
'Content-Type: application/json',
|
|
],
|
|
CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.2',
|
|
]);
|
|
$antwort = curl_exec($curl);
|
|
if ($antwort === false) {
|
|
throw new RuntimeException(
|
|
'Prognosedienst nicht erreichbar: ' . curl_error($curl)
|
|
);
|
|
}
|
|
if (strlen($antwort) > 65536) {
|
|
throw new RuntimeException('Die Antwort der Prognosesynchronisation ist zu gross.');
|
|
}
|
|
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
|
} finally {
|
|
curl_close($curl);
|
|
}
|
|
|
|
if ($httpStatus === 401 || $httpStatus === 403) {
|
|
$this->WriteAttributeString('PrognoseInstallationsToken', '');
|
|
}
|
|
if ($httpStatus !== 200) {
|
|
$meldung = 'Unbekannter Fehler';
|
|
try {
|
|
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
|
|
if (is_array($daten) && is_string($daten['error'] ?? null)) {
|
|
$meldung = $daten['error'];
|
|
}
|
|
} catch (Throwable $fehler) {
|
|
// Der HTTP-Status bleibt die fuehrende Fehlerinformation.
|
|
}
|
|
throw new RuntimeException(
|
|
'Prognosesynchronisation HTTP ' . $httpStatus . ': ' . $meldung
|
|
);
|
|
}
|
|
}
|
|
|
|
private function sendePrognoseTelemetrie(): void
|
|
{
|
|
if (!$this->prognoseTelemetrieKonfiguriert()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$token = trim($this->ReadAttributeString('PrognoseInstallationsToken'));
|
|
if ($token === '') {
|
|
throw new RuntimeException('Der Geraetezugang fuer die Prognose fehlt.');
|
|
}
|
|
|
|
$leistungen = $this->leseMessleistungen();
|
|
$soc = $this->aktuelleBatterieSoc();
|
|
if ($soc === null) {
|
|
throw new RuntimeException('Der Batterie-Ladezustand ist nicht verfuegbar.');
|
|
}
|
|
$werte = [
|
|
'PV' => $leistungen['PV'],
|
|
'Hausverbrauch' => $leistungen['Haus'],
|
|
'Netzleistung' => $leistungen['Netz'],
|
|
'SOC' => $soc,
|
|
];
|
|
foreach ($werte as $name => $wert) {
|
|
if (!is_int($wert) && !is_float($wert)) {
|
|
throw new RuntimeException('Der Prognosewert ' . $name . ' ist keine Zahl.');
|
|
}
|
|
$werte[$name] = (float) $wert;
|
|
if (!is_finite($werte[$name])) {
|
|
throw new RuntimeException('Der Prognosewert ' . $name . ' ist nicht endlich.');
|
|
}
|
|
}
|
|
if ($werte['PV'] < 0.0 || $werte['Hausverbrauch'] < 0.0
|
|
|| $werte['SOC'] < 0.0 || $werte['SOC'] > 100.0
|
|
) {
|
|
throw new RuntimeException('Mindestens ein Prognosewert liegt ausserhalb des gueltigen Bereichs.');
|
|
}
|
|
|
|
$installationID = $this->ReadAttributeString('LizenzInstallationID');
|
|
$nutzlast = [
|
|
'version' => '1.0',
|
|
'installationId' => $installationID,
|
|
'capturedAt' => gmdate('Y-m-d\TH:i:s\Z'),
|
|
'values' => $werte,
|
|
];
|
|
$this->uebertragePrognoseTelemetrie($installationID, $nutzlast, $token);
|
|
if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
|
|
$this->aktualisiereNetzfahrplan($installationID, $token);
|
|
}
|
|
$this->setzeDiagnosewert('Prognosestatus', 'Verbunden');
|
|
} catch (Throwable $fehler) {
|
|
$meldung = substr($fehler->getMessage(), 0, 500);
|
|
$this->setzeDiagnosewert('Prognosestatus', 'Fehler: ' . $meldung);
|
|
$this->protokolliere('Prognosetelemetrie', $meldung);
|
|
}
|
|
}
|
|
|
|
private function aktualisiereNetzfahrplan(string $installationID, string $token): void
|
|
{
|
|
$url = sprintf(
|
|
self::PROGNOSE_FAHRPLAN_ENDPOINT,
|
|
rawurlencode($installationID)
|
|
) . '?' . http_build_query([
|
|
'exportLimitW' => $this->ReadPropertyFloat('Einspeisegrenze'),
|
|
]);
|
|
$curl = curl_init($url);
|
|
if ($curl === false) {
|
|
throw new RuntimeException('Der Netzfahrplan konnte nicht vorbereitet werden.');
|
|
}
|
|
|
|
try {
|
|
curl_setopt_array($curl, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
|
CURLOPT_TIMEOUT => 10,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Accept: application/json',
|
|
'Authorization: Bearer ' . $token,
|
|
],
|
|
CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.2',
|
|
]);
|
|
$antwort = curl_exec($curl);
|
|
if ($antwort === false) {
|
|
throw new RuntimeException('Netzfahrplan nicht erreichbar: ' . curl_error($curl));
|
|
}
|
|
if (strlen($antwort) > 524288) {
|
|
throw new RuntimeException('Die Antwort des Netzfahrplans ist zu gross.');
|
|
}
|
|
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
|
} finally {
|
|
curl_close($curl);
|
|
}
|
|
|
|
if ($httpStatus === 401) {
|
|
$this->WriteAttributeString('PrognoseInstallationsToken', '');
|
|
}
|
|
if ($httpStatus !== 200) {
|
|
$meldung = 'Unbekannter Fehler';
|
|
try {
|
|
$fehlerdaten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
|
|
if (is_array($fehlerdaten) && is_string($fehlerdaten['error'] ?? null)) {
|
|
$meldung = $fehlerdaten['error'];
|
|
}
|
|
} catch (Throwable $fehler) {
|
|
// Der HTTP-Status bleibt die fuehrende Fehlerinformation.
|
|
}
|
|
throw new RuntimeException(
|
|
'Netzfahrplan HTTP ' . $httpStatus . ': ' . $meldung
|
|
);
|
|
}
|
|
|
|
$fahrplan = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
|
|
if (!is_array($fahrplan)
|
|
|| ($fahrplan['schemaVersion'] ?? null) !== 1
|
|
|| !is_array($fahrplan['points'] ?? null)
|
|
) {
|
|
throw new RuntimeException('Der Netzfahrplan hat ein ungueltiges Format.');
|
|
}
|
|
$this->WriteAttributeString(
|
|
'Netzfahrplan',
|
|
json_encode($fahrplan, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
|
|
);
|
|
}
|
|
|
|
/** @param array<string, mixed> $nutzlast */
|
|
private function uebertragePrognoseTelemetrie(
|
|
string $installationID,
|
|
array $nutzlast,
|
|
string $token
|
|
): void {
|
|
$url = sprintf(self::PROGNOSE_TELEMETRIE_ENDPOINT, rawurlencode($installationID));
|
|
$json = json_encode(
|
|
$nutzlast,
|
|
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION
|
|
);
|
|
$curl = curl_init($url);
|
|
if ($curl === false) {
|
|
throw new RuntimeException('Die Prognosetelemetrie konnte nicht vorbereitet werden.');
|
|
}
|
|
|
|
try {
|
|
curl_setopt_array($curl, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => $json,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
|
CURLOPT_TIMEOUT => 10,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Accept: application/json',
|
|
'Authorization: Bearer ' . $token,
|
|
'Content-Type: application/json',
|
|
],
|
|
CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.2',
|
|
]);
|
|
$antwort = curl_exec($curl);
|
|
if ($antwort === false) {
|
|
throw new RuntimeException('Prognosedienst nicht erreichbar: ' . curl_error($curl));
|
|
}
|
|
if (strlen($antwort) > 65536) {
|
|
throw new RuntimeException('Die Antwort der Prognosetelemetrie ist zu gross.');
|
|
}
|
|
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
|
} finally {
|
|
curl_close($curl);
|
|
}
|
|
|
|
if ($httpStatus === 401 || $httpStatus === 403) {
|
|
$this->WriteAttributeString('PrognoseInstallationsToken', '');
|
|
}
|
|
if ($httpStatus !== 200) {
|
|
$meldung = 'Unbekannter Fehler';
|
|
try {
|
|
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
|
|
if (is_array($daten) && is_string($daten['error'] ?? null)) {
|
|
$meldung = $daten['error'];
|
|
}
|
|
} catch (Throwable $fehler) {
|
|
// Der HTTP-Status bleibt die fuehrende Fehlerinformation.
|
|
}
|
|
throw new RuntimeException(
|
|
'Prognosetelemetrie HTTP ' . $httpStatus . ': ' . $meldung
|
|
);
|
|
}
|
|
}
|
|
|
|
/** @return array<int, float> */
|
|
private function dekodiereMonatsgrenzen(string $json): array
|
|
{
|
|
$werte = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
|
if (!is_array($werte)) {
|
|
throw new InvalidArgumentException('Monatsgrenzen muss ein JSON-Objekt sein.');
|
|
}
|
|
$ergebnis = [];
|
|
if (isset($werte[0]) && is_array($werte[0])) {
|
|
foreach ($werte as $eintrag) {
|
|
if (!is_array($eintrag)) {
|
|
throw new InvalidArgumentException('Monatsgrenzen muss eine Liste von Monatswerten sein.');
|
|
}
|
|
$monat = $eintrag['MonatIndex'] ?? null;
|
|
$wert = $eintrag['Grenze_W'] ?? null;
|
|
if (!is_int($monat) || $monat < 1 || $monat > 12
|
|
|| (!is_int($wert) && !is_float($wert))
|
|
) {
|
|
throw new InvalidArgumentException('Jeder Monatswert benoetigt Monatsnummer und Grenze.');
|
|
}
|
|
$ergebnis[$monat] = (float) $wert;
|
|
}
|
|
} else {
|
|
for ($monat = 1; $monat <= 12; ++$monat) {
|
|
$wert = $werte[(string) $monat] ?? $werte[$monat] ?? null;
|
|
if (!is_int($wert) && !is_float($wert)) {
|
|
throw new InvalidArgumentException(
|
|
'Monatsgrenzen muss fuer jeden Monat einen Zahlenwert enthalten.'
|
|
);
|
|
}
|
|
$ergebnis[$monat] = (float) $wert;
|
|
}
|
|
}
|
|
if (count($ergebnis) !== 12) {
|
|
throw new InvalidArgumentException('Monatsgrenzen muss genau zwoelf Monatswerte enthalten.');
|
|
}
|
|
ksort($ergebnis);
|
|
|
|
return $ergebnis;
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function leseJsonAttribut(string $name): array
|
|
{
|
|
$wert = json_decode($this->ReadAttributeString($name), true, 512, JSON_THROW_ON_ERROR);
|
|
|
|
return is_array($wert) ? $wert : [];
|
|
}
|
|
|
|
private function standardMonatsgrenzen(): string
|
|
{
|
|
$werte = [];
|
|
foreach (self::MONATSNAMEN as $monat => $name) {
|
|
$werte[] = [
|
|
'Monat' => $name,
|
|
'Grenze_W' => 0.0,
|
|
'MonatIndex' => $monat,
|
|
];
|
|
}
|
|
|
|
return json_encode($werte, JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
/** @param mixed $daten */
|
|
private function protokolliere(string $bezeichnung, $daten): void
|
|
{
|
|
if ($this->ReadPropertyBoolean('LoggingEin')) {
|
|
$this->SendDebug($bezeichnung, is_string($daten) ? $daten : json_encode($daten), 0);
|
|
}
|
|
}
|
|
}
|