[ '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->registriereNetzfahrplanV4(); $this->RegisterPropertyInteger('Rolle', 0); $this->RegisterPropertyInteger('NetzleistungVariableID', 0); $this->RegisterPropertyFloat('Netzleistungsfaktor', 1.0); $this->RegisterPropertyInteger('MesswertMaxAlter', 60); $this->RegisterPropertyString('VerbraucherZuordnung', '[]'); $this->RegisterPropertyString('AutomatischeVerbraucherZuordnung', '[]'); $this->RegisterPropertyBoolean('AutomatischeSuche', true); $this->RegisterPropertyInteger('SuchbereichID', 0); $this->RegisterPropertyInteger('KeepAlive', 60); $this->RegisterPropertyInteger('VerbraucherTimeout', 60); $this->RegisterPropertyInteger('Lastspitzenmodus', 0); $this->RegisterPropertyFloat('Lastspitzengrenze', 0.0); $this->RegisterPropertyString('Monatsgrenzen', $this->standardMonatsgrenzen()); $this->RegisterPropertyFloat('SollwertSolarladen', 0.0); $this->RegisterPropertyFloat('Umschaltdifferenz', 5.0); $this->RegisterPropertyBoolean('EinspeisebegrenzungAktiv', false); $this->RegisterPropertyFloat('Einspeisegrenze', 0.0); $this->RegisterPropertyBoolean('PrognoseAktiv', false); $this->RegisterPropertyBoolean('NetzfahrplanAktiv', false); $this->RegisterPropertyString('AnlagenWechselrichter', '[]'); $this->RegisterPropertyString('AnlagenPVFlaechen', '[]'); $this->RegisterPropertyString('AnlagenBatterien', '[]'); $this->RegisterPropertyInteger('NetzbezugEnergieVariableID', 0); $this->RegisterPropertyFloat('NetzbezugEnergiefaktor', 1.0); $this->RegisterPropertyInteger('NetzeinspeisungEnergieVariableID', 0); $this->RegisterPropertyFloat('NetzeinspeisungEnergiefaktor', 1.0); // Verdeckte Altproperties; Messquellen liegen neu ausschliesslich in der Anlagentopologie. $this->RegisterPropertyInteger('PrognosePVVariableID', 0); $this->RegisterPropertyInteger('PrognoseHausverbrauchVariableID', 0); $this->RegisterPropertyInteger('PrognoseSOCVariableID', 0); $this->RegisterPropertyInteger('PrognoseSendeintervall', 60); $this->RegisterPropertyString('PrognoseAnschluss', '{}'); $this->RegisterPropertyString('SDLAnschluss', '{}'); $this->RegisterPropertyString('Lizenzcode', ''); // Verdeckte Altproperty fuer bestehende Instanzkonfigurationen. $this->RegisterPropertyString('LizenzAnschluss', '{}'); // Verdeckte Altproperty fuer bestehende Instanzkonfigurationen. $this->RegisterPropertyString('StoermeldeAnschluss', '{}'); $this->RegisterPropertyBoolean('StoerueberwachungAktiv', false); $this->RegisterPropertyInteger('StoerungsSendeintervall', 300); $this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false); $this->RegisterPropertyBoolean('LoggingEin', false); $this->RegisterPropertyBoolean('EnergieaufzeichnungAktiv', true); // Verdeckte Altproperties; bestehende Konfigurationen bleiben lesbar. $this->RegisterPropertyInteger('MessungPVLeistungVariableID', 0); $this->RegisterPropertyInteger('MessungHausverbrauchLeistungVariableID', 0); $this->RegisterPropertyInteger('MessungBatterieleistungVariableID', 0); $this->RegisterPropertyFloat('MessungPVLeistungsfaktor', 1.0); $this->RegisterPropertyFloat('MessungHausverbrauchLeistungsfaktor', 1.0); $this->RegisterPropertyFloat('MessungBatterieleistungsfaktor', 1.0); $this->RegisterPropertyBoolean('MesswerteAnzeigen', false); $this->RegisterPropertyInteger('LeistungsaufzeichnungMinuten', 1); $this->RegisterPropertyInteger('LeistungLoeschenMonate', 12); $this->RegisterPropertyInteger('EnergieaufzeichnungMinuten', 5); $this->RegisterPropertyInteger('EnergieVerdichtenMonate', 12); $this->RegisterPropertyInteger('EnergieLoeschenMonate', 0); $this->RegisterPropertyBoolean('EnergyPieAnzeigen', false); $this->RegisterPropertyBoolean('EnergiediagrammeAnzeigen', false); $this->RegisterPropertyBoolean('FunFactsAnzeigen', false); $this->RegisterPropertyBoolean('EnergieflussAnzeigen', false); $this->RegisterPropertyInteger('EnergieflussLeistungseinheit', 0); $this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10); $this->EnableAction('Aktiv'); $this->RegisterVariableString('Betriebsart', 'Betriebsart', '', 20); $this->RegisterVariableFloat('Netzleistung', 'Netzleistung', '', 30); $this->RegisterAttributeString('VerbraucherCache', '{}'); $this->RegisterAttributeString('VerbraucherEnergie', '{}'); $this->RegisterAttributeString('LetzteSollwerte', '{}'); $this->RegisterAttributeString('LetztePvGrenzen', '{}'); $this->RegisterAttributeString('Netzfahrplan', '{}'); $this->RegisterAttributeInteger('RegistrierteNetzleistungVariableID', 0); $this->RegisterAttributeString('LizenzInstallationID', ''); $this->RegisterAttributeString('LizenzLease', '{}'); $this->RegisterAttributeString('LizenzcodeHash', ''); $this->RegisterAttributeInteger('LizenzNaechsterVersuch', 0); $this->RegisterAttributeInteger('LizenzBerechtigungenNaechsterVersuch', 0); $this->RegisterAttributeString('LizenzLetzterFehler', ''); $this->RegisterAttributeString('PrognoseInstallationsToken', ''); $this->RegisterAttributeString('StoerungsSnapshot', '{}'); $this->RegisterAttributeString('StoerungsGesendeterHash', ''); $this->RegisterAttributeInteger('StoerungsLetzterErfolg', 0); $this->RegisterAttributeInteger('StoerungsNaechsterVersuch', 0); $this->RegisterAttributeInteger('StoerungsFehlversuche', 0); $this->RegisterAttributeInteger('MessungLetzterZeitstempel', 0); $this->RegisterAttributeInteger('MessungLetzteLeistungsaufzeichnung', 0); $this->RegisterAttributeInteger('MessungLetzteEnergieaufzeichnung', 0); $this->RegisterAttributeString('MessungEnergiePuffer', '{}'); $this->RegisterAttributeString('MessungZaehlerstaende', '{}'); // Die alten Timer bleiben deaktiviert registriert, damit bestehende Instanzen sauber migrieren. $this->RegisterTimer('Regelzyklus', 0, ''); $this->RegisterTimer('VorgabenErneuern', 0, ''); $this->RegisterTimer( 'KeepAlive', 0, "IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', true);" ); $this->RegisterTimer( 'PrognoseSenden', 0, "IPS_RequestAction(\$_IPS['TARGET'], 'PrognoseSenden', true);" ); $this->RegisterTimer( 'StoerungenSenden', 0, "IPS_RequestAction(\$_IPS['TARGET'], 'StoerungenSenden', true);" ); $this->RegisterTimer( 'MesswerteAufzeichnen', 0, "IPS_RequestAction(\$_IPS['TARGET'], 'MesswerteAufzeichnen', true);" ); } public function ApplyChanges(): void { parent::ApplyChanges(); $this->SetTimerInterval('NetzfahrplanV4Senden', 0); $this->stoppeNetzfahrplanV4Vorschau(); $this->initialisiereLizenzInstallationID(); $this->aktualisiereVariablen(); try { $this->pruefeKonfiguration(); $this->konfiguriereEnergiearchiv(); $this->synchronisiereEnergievisualisierungen(); } catch (Throwable $fehler) { $this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG); $this->SetTimerInterval('Regelzyklus', 0); $this->SetTimerInterval('VorgabenErneuern', 0); $this->SetTimerInterval('KeepAlive', 0); $this->SetTimerInterval('PrognoseSenden', 0); $this->SetTimerInterval('StoerungenSenden', 0); $this->SetTimerInterval('MesswerteAufzeichnen', 0); $this->protokolliere('Konfiguration', $fehler->getMessage()); return; } $this->registriereNetzleistungsmeldung(); $this->SetTimerInterval('Regelzyklus', 0); $this->SetTimerInterval('VorgabenErneuern', 0); $this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000); $this->SetTimerInterval('PrognoseSenden', 0); $this->SetTimerInterval( 'StoerungenSenden', $this->ReadPropertyBoolean('StoerueberwachungAktiv') ? 30000 : 0 ); $messungAktiv = $this->messungKonfiguriert(); $this->SetTimerInterval('MesswerteAufzeichnen', $messungAktiv ? 60000 : 0); if ($messungAktiv) { $this->zeichneMesswerteAuf(); } $this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen')); $this->aktualisiereAnschlussstatus(); $geraetezugangFehlt = $this->geraetezugangBenoetigt() && $this->ReadAttributeString('PrognoseInstallationsToken') === ''; if (!$this->aktualisiereLizenzfreigabe( $this->ReadPropertyString('Lizenzcode'), $geraetezugangFehlt )) { $this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV); $this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG); return; } try { $this->aktiveVerbraucherIDs(); } catch (Throwable $fehler) { $this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV); $this->setzeDiagnosewert('Lizenzstatus', 'Nicht freigegeben: ' . $fehler->getMessage()); $this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG); return; } $this->SetStatus(self::STATUS_AKTIV); if ($this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) { $this->SetTimerInterval('NetzfahrplanV4Senden', 60000); } $this->konfiguriereNetzfahrplanV4Vorschau(); $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() ); $formular['elements'][] = $this->netzfahrplanV4EmpfangFormular(); 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 'NetzfahrplanV4Empfangen': $this->empfangeNetzfahrplanV4(); return; case 'NetzfahrplanV4VorschauAktualisieren': $this->aktualisiereNetzfahrplanV4Vorschau(); return; case 'NetzfahrplanV4Senden': $this->sendeNetzfahrplanV4(); return; case 'PrognoseSenden': $this->sendePrognoseTelemetrie(); return; case 'StoerungenSenden': $this->sendeStoerungsSnapshot(false); return; case 'MesswerteAufzeichnen': $this->zeichneMesswerteAuf(); return; case 'FormPrognoseAktiv': $aktiv = (bool) $wert; $lizenziert = $this->prognoseLizenziert(); $netzfahrplanLizenziert = $this->berechtigungLizenziert( Lizenzpruefung::NETZFAHRPLAN ); $this->UpdateFormField( 'PrognoseLizenzInformation', 'caption', $this->prognoseLizenzStatustext($aktiv, $lizenziert) ); $this->UpdateFormField( 'NetzfahrplanAktiv', 'enabled', $aktiv && $netzfahrplanLizenziert && $this->peakShavingLizenziert() ); if (!$aktiv) { $this->UpdateFormField('NetzfahrplanAktiv', 'value', false); } return; case 'FormEnergyPieAnzeigen': $this->synchronisiereEnergyPie((bool) $wert); return; case 'FormEnergiediagrammeAnzeigen': $this->synchronisiereEnergiediagramme((bool) $wert); return; case 'FormFunFactsAnzeigen': $this->synchronisiereFunFacts((bool) $wert); return; case 'FormEnergieflussAnzeigen': $this->synchronisiereEnergiefluss((bool) $wert); return; case 'FormLizenzPruefen': if (!is_string($wert)) { throw new InvalidArgumentException('Der Lizenzcode muss als Text uebergeben werden.'); } if ($this->aktualisiereLizenzfreigabe($wert, true)) { $this->synchronisiereAnlagentopologie(); } $this->UpdateFormField('LizenzInformation', 'caption', $this->lizenzStatustext($wert)); $this->aktualisiereLastspitzenformular($wert); $this->aktualisierePrognoseformular($wert); return; case 'FormAutomatischeSuche': $automatisch = (bool) $wert; $this->UpdateFormField('VerbraucherZuordnung', 'visible', !$automatisch); $this->UpdateFormField('AutomatischeVerbraucherZuordnung', 'visible', $automatisch); $this->UpdateFormField('SuchbereichID', 'visible', $automatisch); $this->UpdateFormField('VerbraucherAktualisieren', 'visible', $automatisch); return; case 'FormVerbraucherAktualisieren': if (!is_string($wert)) { throw new InvalidArgumentException('Suchparameter muessen als JSON uebergeben werden.'); } $suchparameter = json_decode($wert, true, 512, JSON_THROW_ON_ERROR); if (!is_array($suchparameter) || !is_array($suchparameter['Zuordnung'] ?? null)) { throw new InvalidArgumentException('Suchparameter sind ungueltig.'); } $suchbereichID = $suchparameter['SuchbereichID'] ?? 0; if (!is_int($suchbereichID)) { throw new InvalidArgumentException('SuchbereichID muss eine Ganzzahl sein.'); } $werte = $this->automatischeVerbraucherFuerFormular( $suchparameter['Zuordnung'], $suchbereichID ); $this->UpdateFormField( 'AutomatischeVerbraucherZuordnung', 'values', json_encode($werte, JSON_THROW_ON_ERROR) ); return; case 'FormLastspitzenmodus': $modus = (int) $wert; if ($modus !== 0 && !$this->peakShavingLizenziert()) { $this->UpdateFormField('Lastspitzenmodus', 'value', 0); $this->UpdateFormField('Lastspitzengrenze', 'visible', false); $this->UpdateFormField('MonatsgrenzenUmschalten', 'visible', false); throw new InvalidArgumentException( 'Konstant und Monatlich benoetigen eine Peak-Shaving-Lizenz.' ); } $this->UpdateFormField('Lastspitzengrenze', 'visible', $modus === 1); $this->UpdateFormField('MonatsgrenzenUmschalten', 'visible', $modus === 2); if ($modus !== 2) { $this->SetBuffer('MonatsgrenzenSichtbar', '0'); $this->UpdateFormField('Monatsgrenzen', 'visible', false); $this->UpdateFormField( 'MonatsgrenzenUmschalten', 'caption', 'Monatsgrenzen anzeigen' ); } return; case 'FormMonatsgrenzenUmschalten': $sichtbar = $this->GetBuffer('MonatsgrenzenSichtbar') !== '1'; $this->SetBuffer('MonatsgrenzenSichtbar', $sichtbar ? '1' : '0'); $this->UpdateFormField('Monatsgrenzen', 'visible', $sichtbar); $this->UpdateFormField( 'MonatsgrenzenUmschalten', 'caption', $sichtbar ? 'Monatsgrenzen ausblenden' : 'Monatsgrenzen anzeigen' ); return; case 'VerbraucherdatenEmpfangen': if (!is_string($wert)) { throw new InvalidArgumentException('Verbraucherdaten muessen als JSON uebergeben werden.'); } $daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR); if (!is_array($daten)) { throw new InvalidArgumentException('Verbraucherdaten muessen ein JSON-Objekt sein.'); } $this->VerbraucherdatenEmpfangen($daten); return; } throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident); } /** * @param array $daten */ public function VerbraucherdatenEmpfangen(array $daten): void { Nachrichtenvertrag::pruefeVerbraucherdaten($daten); if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) { throw new InvalidArgumentException('Verbraucherdaten sind an eine andere Instanz adressiert.'); } $absenderID = $daten['Kopf']['AbsenderID']; if (!in_array($absenderID, $this->aktiveVerbraucherIDs(), true)) { throw new InvalidArgumentException('Der Absender ist diesem Manager nicht aktiv zugeordnet.'); } $jetzt = time(); $this->aktualisiereVerbraucherEnergie($absenderID, $daten, $jetzt); $cache = $this->leseJsonAttribut('VerbraucherCache'); $cache[(string) $absenderID] = [ 'EmpfangenAm' => $jetzt, 'Daten' => $daten, ]; $this->WriteAttributeString('VerbraucherCache', json_encode($cache, JSON_THROW_ON_ERROR)); $this->aktualisiereEnergieflusswerte(); $this->regeln(false); } /** @param array $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 $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 */ 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 $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 */ 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 $stoerungen * @param list> $nichtGeregelt * @return list}> */ private function aktuelleVerbraucher( array &$stoerungen, array &$nichtGeregelt, string $betriebsart, bool &$synchronisiert ): array { $ids = $this->aktiveVerbraucherIDs(); $this->setzeDiagnosewert('VerbraucherAnzahl', count($ids)); $cache = $this->leseJsonAttribut('VerbraucherCache'); $timeout = $this->ReadPropertyInteger('VerbraucherTimeout'); $aktuell = []; foreach ($ids as $instanzID) { $eintrag = $cache[(string) $instanzID] ?? null; $grund = ''; if (!is_array($eintrag) || !isset($eintrag['EmpfangenAm'], $eintrag['Daten'])) { $grund = 'Noch keine Meldung'; } elseif (time() - (int) $eintrag['EmpfangenAm'] > $timeout) { $grund = 'Meldung ist veraltet'; } elseif (($eintrag['Daten']['Betriebsart'] ?? null) !== $betriebsart) { $grund = 'Leistungsangebot gehoert nicht zur Betriebsart ' . $betriebsart; } if ($grund !== '') { $synchronisiert = false; $detail = $this->verbraucherDetail($instanzID, $grund, $eintrag); $nichtGeregelt[] = $detail; $stoerungen[] = $detail['Name'] . ': ' . $grund . '.'; try { $this->sendeManagerdaten($instanzID, $betriebsart, null); } catch (Throwable $fehler) { $stoerungen[] = $detail['Name'] . ': Betriebsart konnte nicht uebermittelt werden: ' . $fehler->getMessage(); } continue; } $aktuell[] = ['InstanzID' => $instanzID, 'Daten' => $eintrag['Daten']]; } $verfuegbar = array_filter($aktuell, static fn (array $eintrag): bool => $eintrag['Daten']['Verfuegbar']); $this->setzeDiagnosewert('VerbraucherVerfuegbar', count($verfuegbar)); return $aktuell; } private function setzeNichtGeregelteVerbraucher(string $grund): void { try { $ids = $this->konfigurierteAktiveVerbraucherIDs(); } catch (Throwable $fehler) { $ids = []; $grund .= '; Verbraucherzuordnung ungueltig: ' . $fehler->getMessage(); } $cache = $this->leseJsonAttribut('VerbraucherCache'); $nichtGeregelt = []; $stoerungen = []; foreach ($ids as $instanzID) { $detail = $this->verbraucherDetail( $instanzID, $grund, $cache[(string) $instanzID] ?? null ); $nichtGeregelt[] = $detail; $stoerungen[] = $detail['Name'] . ': ' . $grund . '.'; } $this->setzeDiagnosewert('VerbraucherAnzahl', count($ids)); $this->setzeDiagnosewert('VerbraucherVerfuegbar', 0); $this->setzeDiagnosewert('Verbraucherstatus', json_encode([ 'Betriebsart' => ManagerRegler::BETRIEBSART_INAKTIV, 'Synchronisiert' => false, 'Verbraucher' => [], 'NichtGeregelt' => $nichtGeregelt, ], JSON_THROW_ON_ERROR)); $this->setzeDiagnosewert( 'NichtGeregelteGeraete', $this->nichtGeregelteGeraeteText($nichtGeregelt) ); $this->aktualisiereStoerungen([], $stoerungen); } /** * @param array|null $cacheEintrag * @return array */ 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> $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 */ private function aktiveVerbraucherIDs(): array { $ids = $this->konfigurierteAktiveVerbraucherIDs(); $this->pruefeVerbraucherlizenzen($ids); return $ids; } /** @return list */ 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 $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 $ids * @return array{0: array, 1: array} */ 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 */ 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> $zuordnung * @return list */ 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>|null $aktuelleZuordnung * @return list */ 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 */ 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> $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}> $verbraucher * @param list $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 $nutzlast */ private function uebertrageStoerungsSnapshot( string $installationID, array $nutzlast, string $token ): void { $url = sprintf(self::STOERUNGS_ENDPOINT, rawurlencode($installationID)); $json = json_encode($nutzlast, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); $curl = curl_init($url); if ($curl === false) { throw new RuntimeException('Die Stoerungsuebertragung konnte nicht vorbereitet werden.'); } try { curl_setopt_array($curl, [ CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_POSTFIELDS => $json, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 10, CURLOPT_HTTPHEADER => [ 'Accept: application/json', 'Authorization: Bearer ' . $token, 'Content-Type: application/json', ], CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.3', ]); $antwort = curl_exec($curl); if ($antwort === false) { throw new RuntimeException( 'Stoerungsdienst nicht erreichbar: ' . curl_error($curl) ); } if (strlen($antwort) > 65536) { throw new RuntimeException('Die Antwort der Stoerungsuebertragung ist zu gross.'); } $httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE); } finally { curl_close($curl); } if ($httpStatus === 401 || $httpStatus === 403) { $this->WriteAttributeString('PrognoseInstallationsToken', ''); } if ($httpStatus !== 200) { $meldung = 'Unbekannter Fehler'; try { $daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR); if (is_array($daten) && is_string($daten['error'] ?? null)) { $meldung = $daten['error']; } } catch (Throwable $fehler) { // Der HTTP-Status bleibt die fuehrende Fehlerinformation. } throw new RuntimeException( 'Stoerungsuebertragung HTTP ' . $httpStatus . ': ' . $meldung ); } } private function registriereNetzleistungsmeldung(): void { $alt = $this->ReadAttributeInteger('RegistrierteNetzleistungVariableID'); $neu = $this->ReadPropertyInteger('NetzleistungVariableID'); if ($alt > 0 && $alt !== $neu) { $this->UnregisterMessage($alt, self::VM_UPDATE); } if ($neu > 0 && $neu !== $alt) { $this->RegisterMessage($neu, self::VM_UPDATE); } $this->WriteAttributeInteger('RegistrierteNetzleistungVariableID', $neu); } private function aktualisiereVariablen(): void { $leistungsvariablen = [ 'PVLeistungArchiv' => 'PV-Leistung', 'HausverbrauchLeistungArchiv' => 'Hausverbrauch', 'NetzleistungArchiv' => 'Netzleistung Aufzeichnung', 'BatterieleistungArchiv' => 'Batterieleistung', ]; $energievariablen = [ 'PVEnergie' => 'PV-Energie', 'Hausenergie' => 'Hausenergie', 'NetzbezugEnergie' => 'Netzbezug Energie', 'EinspeisungEnergie' => 'Einspeisung Energie', 'BatterieLadenEnergie' => 'Batterie geladen', 'BatterieEntladenEnergie' => 'Batterie entladen', ]; $position = 200; foreach ($leistungsvariablen as $ident => $name) { $this->RegisterVariableFloat($ident, $name, '~Power', $position); IPS_SetHidden($this->GetIDForIdent($ident), !$this->ReadPropertyBoolean('MesswerteAnzeigen')); $position += 10; } foreach ($energievariablen as $ident => $name) { $this->RegisterVariableFloat($ident, $name, '~Electricity', $position); IPS_SetHidden($this->GetIDForIdent($ident), !$this->ReadPropertyBoolean('MesswerteAnzeigen')); $position += 10; } if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) { $this->RegisterVariableBoolean( 'NetzleistungGueltig', 'Netzleistung gueltig', '~Switch', 40 ); $this->RegisterVariableFloat( 'WirksameLastspitzengrenze', 'Wirksame Lastspitzengrenze', '', 50 ); $this->RegisterVariableFloat( 'WirksameEinspeisegrenze', 'Wirksame Einspeisegrenze', '', 52 ); $this->RegisterVariableFloat('Abregelbedarf', 'Abregelbedarf', '', 54); $this->RegisterVariableString( 'Wechselrichterstatus', 'Wechselrichterstatus', '', 56 ); $this->RegisterVariableFloat('Verteilbudget', 'Verteilbudget', '', 60); $this->RegisterVariableInteger('VerbraucherAnzahl', 'Verbraucher Anzahl', '', 70); $this->RegisterVariableInteger( 'VerbraucherVerfuegbar', 'Verbraucher verfuegbar', '', 80 ); $this->RegisterVariableString('Verbraucherstatus', 'Verbraucherstatus', '', 90); $this->RegisterVariableString( 'NichtGeregelteGeraete', 'Nicht geregelte Geraete', '', 95 ); $this->RegisterVariableString('Monatsgrenzen', 'Monatsgrenzen', '', 100); $this->RegisterVariableString('Prognosestatus', 'Prognosestatus', '', 110); $this->RegisterVariableString('SDLStatus', 'SDL-Status', '', 120); $this->RegisterVariableString('Lizenzstatus', 'Lizenzstatus', '', 130); $this->RegisterVariableString( 'Stoerueberwachungsstatus', 'Stoerueberwachungsstatus', '', 140 ); $this->RegisterVariableBoolean('Sammelstoerung', 'Sammelstoerung', '~Alert', 150); $this->RegisterVariableString('Stoertext', 'Stoertext', '', 160); return; } foreach (self::DIAGNOSE_VARIABLEN as $ident) { $variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) { $this->UnregisterVariable($ident); } } } private function messungKonfiguriert(): bool { return $this->ReadPropertyBoolean('EnergieaufzeichnungAktiv') && $this->messquellenKonfiguriert(); } private function messquellenKonfiguriert(): bool { try { return $this->istNumerischeVariable($this->ReadPropertyInteger('NetzleistungVariableID')) && $this->pvMessquellen() !== []; } catch (Throwable $fehler) { return false; } } /** @return list */ 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 */ 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 */ 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>|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> */ private function leseAnlagenKonfigurationsliste(string $property): array { $liste = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR); if (!is_array($liste) || !array_is_list($liste)) { throw new InvalidArgumentException($property . ' muss eine JSON-Liste sein.'); } return array_values(array_filter($liste, 'is_array')); } /** @param mixed $wert */ private function anlagenMessfaktor($wert, string $bezeichnung): float { if (!is_int($wert) && !is_float($wert)) { throw new InvalidArgumentException($bezeichnung . ': Messfaktor muss numerisch sein.'); } $faktor = (float) $wert; if (!is_finite($faktor) || $faktor === 0.0) { throw new InvalidArgumentException($bezeichnung . ': Messfaktor muss endlich und ungleich 0 sein.'); } return $faktor; } /** * @param mixed $faktor * @return array{Schluessel: string, VariableID: int, Faktor: float} */ private function energieZaehlerquelle( string $bezeichnung, int $variableID, $faktor, string $schluessel ): array { if (!$this->istNumerischeVariable($variableID)) { throw new InvalidArgumentException( $bezeichnung . ' muss eine numerische Variable sein.' ); } if ((!is_int($faktor) && !is_float($faktor)) || !is_finite((float) $faktor) || (float) $faktor <= 0.0 ) { throw new InvalidArgumentException( $bezeichnung . ': Energiefaktor muss groesser als 0 sein.' ); } return [ 'Schluessel' => $schluessel, 'VariableID' => $variableID, 'Faktor' => (float) $faktor, ]; } /** @return array{PV: float, Haus: float, Netz: float, Batterie: float} */ private function leseMessleistungen(): array { $pv = 0.0; foreach ($this->pvMessquellen() as $quelle) { $pv += max(0.0, (float) GetValue($quelle['VariableID']) * $quelle['Faktor']); } $batterie = 0.0; foreach ($this->batterieMessquellen() as $quelle) { $batterie += (float) GetValue($quelle['VariableID']) * $quelle['Faktor']; } $netz = (float) GetValue($this->ReadPropertyInteger('NetzleistungVariableID')) * $this->ReadPropertyFloat('Netzleistungsfaktor'); return [ 'PV' => $pv, 'Haus' => EnergieMessung::hausleistung($pv, $netz, $batterie), 'Netz' => $netz, 'Batterie' => $batterie, ]; } private function aktuelleBatterieSoc(): ?float { $quellen = $this->batterieSocQuellen(); if ($quellen === []) { return $this->leseAnlagenKonfigurationsliste('AnlagenBatterien') === [] ? 0.0 : null; } $summe = 0.0; $gewicht = 0.0; foreach ($quellen as $quelle) { $wert = (float) GetValue($quelle['VariableID']); if (!is_finite($wert) || $wert < 0.0 || $wert > 100.0) { return null; } $summe += $wert * $quelle['Gewicht']; $gewicht += $quelle['Gewicht']; } return $gewicht > 0.0 ? $summe / $gewicht : null; } /** @return array|null */ private function leseEnergieZaehlerDeltas(): ?array { $quellen = $this->energieZaehlerquellen(); if ($quellen === null) { return null; } $alt = $this->leseJsonAttribut('MessungZaehlerstaende'); $neu = []; $deltas = []; $vollstaendigeBasis = true; foreach ($quellen as $ident => $identQuellen) { $deltas[$ident] = 0.0; foreach ($identQuellen as $quelle) { $schluessel = $quelle['Schluessel']; $stand = (float) GetValue($quelle['VariableID']) * $quelle['Faktor']; if (!is_finite($stand) || $stand < 0.0) { throw new RuntimeException( 'Energiezaehler ' . $schluessel . ' liefert keinen gueltigen Stand.' ); } $neu[$schluessel] = $stand; if (!isset($alt[$schluessel]) || !is_numeric($alt[$schluessel])) { $vollstaendigeBasis = false; continue; } $delta = $stand - (float) $alt[$schluessel]; if ($delta >= 0.0) { $deltas[$ident] += $delta; } } } $this->WriteAttributeString( 'MessungZaehlerstaende', json_encode($neu, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION) ); if (!$vollstaendigeBasis) { return array_fill_keys(self::ENERGIE_VARIABLEN, 0.0); } $deltas['Hausenergie'] = EnergieMessung::hausenergie( $deltas['PVEnergie'], $deltas['NetzbezugEnergie'], $deltas['EinspeisungEnergie'], $deltas['BatterieLadenEnergie'], $deltas['BatterieEntladenEnergie'] ); return $deltas; } private function zeichneMesswerteAuf(): void { if (!$this->messungKonfiguriert()) { return; } $jetzt = time(); $leistungen = $this->leseMessleistungen(); $letzterZeitstempel = $this->ReadAttributeInteger('MessungLetzterZeitstempel'); $puffer = $this->leseJsonAttribut('MessungEnergiePuffer'); $energieDeltas = $this->leseEnergieZaehlerDeltas(); if ($energieDeltas === null && $letzterZeitstempel > 0) { $sekunden = min(300, max(0, $jetzt - $letzterZeitstempel)); $energieDeltas = EnergieMessung::energieDeltas($leistungen, $sekunden); } foreach ($energieDeltas ?? [] as $ident => $delta) { $puffer[$ident] = max(0.0, (float) ($puffer[$ident] ?? 0.0)) + $delta; } $this->WriteAttributeInteger('MessungLetzterZeitstempel', $jetzt); $this->WriteAttributeString( 'MessungEnergiePuffer', json_encode($puffer, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION) ); $leistungsintervall = $this->ReadPropertyInteger('LeistungsaufzeichnungMinuten') * 60; $letzteLeistung = $this->ReadAttributeInteger('MessungLetzteLeistungsaufzeichnung'); if ($letzteLeistung === 0 || $jetzt - $letzteLeistung >= $leistungsintervall) { foreach ([ 'PVLeistungArchiv' => 'PV', 'HausverbrauchLeistungArchiv' => 'Haus', 'NetzleistungArchiv' => 'Netz', 'BatterieleistungArchiv' => 'Batterie', ] as $ident => $quelle) { $this->SetValue($ident, $leistungen[$quelle]); } $this->WriteAttributeInteger('MessungLetzteLeistungsaufzeichnung', $jetzt); } $energieintervall = $this->ReadPropertyInteger('EnergieaufzeichnungMinuten') * 60; $letzteEnergie = $this->ReadAttributeInteger('MessungLetzteEnergieaufzeichnung'); if ($letzteEnergie === 0) { $this->WriteAttributeInteger('MessungLetzteEnergieaufzeichnung', $jetzt); } elseif ($jetzt - $letzteEnergie >= $energieintervall) { foreach (self::ENERGIE_VARIABLEN as $ident) { $this->SetValue($ident, (float) $this->GetValue($ident) + (float) ($puffer[$ident] ?? 0.0)); } $this->WriteAttributeString('MessungEnergiePuffer', '{}'); $this->WriteAttributeInteger('MessungLetzteEnergieaufzeichnung', $jetzt); } $funFactsID = @IPS_GetObjectIDByIdent('EnergieFunFacts', $this->InstanceID); if (is_int($funFactsID) && $funFactsID > 0 && IPS_VariableExists($funFactsID)) { $this->aktualisiereFunFacts($funFactsID); } $this->aktualisiereEnergieflusswerte(); } private function istNumerischeVariable(int $variableID): bool { if ($variableID <= 0 || !IPS_VariableExists($variableID)) { return false; } $variable = IPS_GetVariable($variableID); return in_array((int) ($variable['VariableType'] ?? -1), [1, 2], true); } /** @param bool|float|int|string $wert */ private function setzeDiagnosewert(string $ident, $wert): void { $variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) { $this->SetValue($ident, $wert); } } private function initialisiereLizenzInstallationID(): string { $installationID = trim($this->ReadAttributeString('LizenzInstallationID')); if ($installationID !== '') { return $installationID; } $installationID = $this->erzeugeUuidV4(); $this->WriteAttributeString('LizenzInstallationID', $installationID); return $installationID; } private function aktualisiereLizenzfreigabe(string $lizenzcode, bool $erzwingen): bool { $normalisiert = Lizenzpruefung::normalisiereCode($lizenzcode); if (!Lizenzpruefung::istLizenzcodeGueltig($normalisiert)) { $fehler = $normalisiert === '' ? 'Lizenzcode fehlt.' : 'Lizenzcode ist ungueltig.'; $this->WriteAttributeString('LizenzLetzterFehler', $fehler); $this->setzeDiagnosewert('Lizenzstatus', $fehler); return false; } $installationID = $this->initialisiereLizenzInstallationID(); $codeHash = hash('sha256', $normalisiert); $gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash'); if ($gespeicherterHash !== '' && !hash_equals($gespeicherterHash, $codeHash)) { $this->WriteAttributeString('PrognoseInstallationsToken', ''); } $lease = $this->leseLizenzLease(); $codePasst = $gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash); $lastspitzenmodus = $this->lizenzPeakModus(); $basisGueltig = $codePasst && Lizenzpruefung::istLeaseGueltig( $lease, $installationID, 0 ); $leaseGueltig = $codePasst && Lizenzpruefung::istLeaseGueltig( $lease, $installationID, $lastspitzenmodus ); $berechtigungenPassen = $leaseGueltig && $this->lizenzBerechtigungenPassen($lease, $lastspitzenmodus); $gueltig = $leaseGueltig && $berechtigungenPassen; $berechtigungenVeraltet = $basisGueltig && !$berechtigungenPassen; $geraetezugangFehlt = $this->geraetezugangBenoetigt() && $this->ReadAttributeString('PrognoseInstallationsToken') === ''; $routineFaellig = (!$gueltig && !$berechtigungenVeraltet) || $geraetezugangFehlt || Lizenzpruefung::erneuerungFaellig($lease); $jetzt = time(); if (!$erzwingen) { if (!$routineFaellig && !$berechtigungenVeraltet) { $this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode)); return $gueltig; } if ($routineFaellig && $jetzt < $this->ReadAttributeInteger('LizenzNaechsterVersuch') ) { $this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode)); return $gueltig; } if ($berechtigungenVeraltet && $jetzt < $this->ReadAttributeInteger( 'LizenzBerechtigungenNaechsterVersuch' ) ) { $this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode)); return $gueltig; } } if ($routineFaellig || $erzwingen) { $this->WriteAttributeInteger( 'LizenzNaechsterVersuch', $jetzt + self::LIZENZ_RETRY_SEKUNDEN ); } if ($berechtigungenVeraltet || $erzwingen) { $this->WriteAttributeInteger( 'LizenzBerechtigungenNaechsterVersuch', $jetzt + self::LIZENZ_RETRY_SEKUNDEN ); } try { $neueLease = $this->rufeLizenzserverAuf($normalisiert, $installationID); $geraeteToken = $neueLease['deviceToken'] ?? null; unset($neueLease['deviceToken']); if ($geraetezugangFehlt && (!is_string($geraeteToken) || preg_match('/^[A-Za-z0-9_-]{43,128}$/', $geraeteToken) !== 1) ) { throw new RuntimeException('Der Lizenzserver lieferte keinen gueltigen Geraetezugang.'); } Lizenzpruefung::pruefeLease( $neueLease, $installationID, $this->lizenzPeakModus() ); if (!$this->lizenzBerechtigungenPassen( $neueLease, $this->lizenzPeakModus() )) { throw new InvalidArgumentException( 'Die Lizenz deckt die konfigurierten Funktionen oder Geraetemengen nicht ab.' ); } if ($geraetezugangFehlt) { $this->WriteAttributeString('PrognoseInstallationsToken', $geraeteToken); } $this->WriteAttributeString( 'LizenzLease', json_encode($neueLease, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) ); $this->WriteAttributeString('LizenzcodeHash', $codeHash); $naechsteErneuerung = Lizenzpruefung::erneuernAb($neueLease); $this->WriteAttributeInteger('LizenzNaechsterVersuch', $naechsteErneuerung); $this->WriteAttributeInteger( 'LizenzBerechtigungenNaechsterVersuch', $naechsteErneuerung ); $this->WriteAttributeString('LizenzLetzterFehler', ''); $this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode)); return true; } catch (Throwable $fehler) { $meldung = substr($fehler->getMessage(), 0, 500); $this->WriteAttributeString('LizenzLetzterFehler', $meldung); $this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode)); $this->protokolliere('Lizenzpruefung', $meldung); return $gueltig; } } /** @return array */ 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 $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 $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 */ 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 */ private function rufeLizenzserverAuf(string $lizenzcode, string $installationID): array { $nutzlast = json_encode([ 'code' => $lizenzcode, 'installationId' => $installationID, 'issueDeviceToken' => $this->geraetezugangBenoetigt() && $this->ReadAttributeString('PrognoseInstallationsToken') === '', ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); $curl = curl_init(self::LIZENZ_ENDPOINT); if ($curl === false) { throw new RuntimeException('Die HTTPS-Anfrage konnte nicht vorbereitet werden.'); } try { curl_setopt_array($curl, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $nutzlast, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 10, CURLOPT_HTTPHEADER => [ 'Accept: application/json', 'Content-Type: application/json', ], CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.1', ]); $antwort = curl_exec($curl); if ($antwort === false) { throw new RuntimeException('Lizenzserver nicht erreichbar: ' . curl_error($curl)); } if (strlen($antwort) > 65536) { throw new RuntimeException('Die Antwort des Lizenzservers ist zu gross.'); } $httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE); } finally { curl_close($curl); } try { $daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR); } catch (Throwable $fehler) { throw new RuntimeException('Der Lizenzserver lieferte kein gueltiges JSON.', 0, $fehler); } if (!is_array($daten)) { throw new RuntimeException('Der Lizenzserver lieferte kein JSON-Objekt.'); } if ($httpStatus !== 200) { $meldung = is_string($daten['error'] ?? null) ? $daten['error'] : 'Unbekannter Fehler'; throw new RuntimeException('Lizenzserver HTTP ' . $httpStatus . ': ' . $meldung); } return $daten; } private function erzeugeUuidV4(): string { $bytes = random_bytes(16); $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); $hex = bin2hex($bytes); return sprintf( '%s-%s-%s-%s-%s', substr($hex, 0, 8), substr($hex, 8, 4), substr($hex, 12, 4), substr($hex, 16, 4), substr($hex, 20, 12) ); } private function aktualisiereAnschlussstatus(): void { $this->setzeDiagnosewert( 'Prognosestatus', $this->ReadPropertyBoolean('PrognoseAktiv') ? 'Wartet' : 'NichtVerwendet' ); foreach (['SDLAnschluss' => 'SDLStatus'] as $property => $variable) { $konfiguration = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR); $aktiv = is_array($konfiguration) && ($konfiguration['Aktiv'] ?? false) === true; $this->setzeDiagnosewert($variable, $aktiv ? 'Fehler' : 'NichtVerwendet'); } $this->setzeDiagnosewert( 'Stoerueberwachungsstatus', $this->ReadPropertyBoolean('StoerueberwachungAktiv') ? 'Wartet' : 'NichtVerwendet' ); } private function pruefeKonfiguration(): void { foreach (['MesswertMaxAlter', 'KeepAlive', 'VerbraucherTimeout'] as $property) { if ($this->ReadPropertyInteger($property) <= 0) { throw new InvalidArgumentException($property . ' muss groesser als 0 sein.'); } } foreach (['LeistungsaufzeichnungMinuten', 'EnergieaufzeichnungMinuten'] as $property) { $wert = $this->ReadPropertyInteger($property); if (!in_array($wert, [1, 5, 60], true)) { throw new InvalidArgumentException($property . ' muss 1, 5 oder 60 Minuten betragen.'); } } foreach (['LeistungLoeschenMonate', 'EnergieVerdichtenMonate', 'EnergieLoeschenMonate'] as $property) { if ($this->ReadPropertyInteger($property) < 0) { throw new InvalidArgumentException($property . ' darf nicht negativ sein.'); } } $energieLoeschen = $this->ReadPropertyInteger('EnergieLoeschenMonate'); $energieVerdichten = $this->ReadPropertyInteger('EnergieVerdichtenMonate'); if ($energieLoeschen > 0 && $energieLoeschen <= $energieVerdichten) { throw new InvalidArgumentException('Energie darf erst nach der taeglichen Verdichtung geloescht werden.'); } $intervall = $this->ReadPropertyInteger('PrognoseSendeintervall'); if ($intervall < 60 || $intervall > 3600) { throw new InvalidArgumentException('PrognoseSendeintervall muss zwischen 60 und 3600 Sekunden liegen.'); } $stoerungsIntervall = $this->ReadPropertyInteger('StoerungsSendeintervall'); if ($stoerungsIntervall < 60 || $stoerungsIntervall > 3600) { throw new InvalidArgumentException('StoerungsSendeintervall muss zwischen 60 und 3600 Sekunden liegen.'); } if ($this->ReadPropertyFloat('Umschaltdifferenz') < 0 || $this->ReadPropertyFloat('Umschaltdifferenz') > 100 ) { throw new InvalidArgumentException('Umschaltdifferenz muss zwischen 0 und 100 liegen.'); } if ($this->ReadPropertyFloat('Einspeisegrenze') < 0.0) { throw new InvalidArgumentException('Einspeisegrenze darf nicht negativ sein.'); } if ($this->ReadPropertyBoolean('EinspeisebegrenzungAktiv')) { $this->regelbareWechselrichter(); } if ($this->ReadPropertyBoolean('PrognoseAktiv') && !$this->prognoseTelemetrieKonfiguriert() ) { throw new InvalidArgumentException( 'Aktive Prognosen benoetigen Netzleistung, PV-Leistung und Batterie-Ladezustand.' ); } if ($this->ReadPropertyBoolean('NetzfahrplanAktiv') && (!$this->ReadPropertyBoolean('PrognoseAktiv') || !$this->ReadPropertyBoolean('EinspeisebegrenzungAktiv') || !$this->prognoseTelemetrieKonfiguriert()) ) { throw new InvalidArgumentException( 'Der intelligente Netzfahrplan benoetigt Einspeisebegrenzung und Prognosetelemetrie.' ); } $this->leseAktiveVerbraucherIDs('VerbraucherZuordnung'); $this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung'); $this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')); $this->leseAnlagentopologie(); $this->pvMessquellen(); $this->batterieMessquellen(); $this->batterieSocQuellen(); $this->energieZaehlerquellen(); foreach (['PrognoseAnschluss', 'SDLAnschluss'] as $property) { $wert = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR); if (!is_array($wert)) { throw new InvalidArgumentException($property . ' muss ein JSON-Objekt sein.'); } } } /** @return array */ 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 $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 $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 */ 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 */ 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); } } }