'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', ]; private const DIAGNOSE_VARIABLEN = [ 'NetzleistungGueltig', 'WirksameLastspitzengrenze', 'Verteilbudget', 'VerbraucherAnzahl', 'VerbraucherVerfuegbar', 'Verbraucherstatus', 'Monatsgrenzen', 'Prognosestatus', 'SDLStatus', 'Lizenzstatus', 'Stoerueberwachungsstatus', 'Sammelstoerung', 'Stoertext', ]; public function Create(): void { parent::Create(); $this->RegisterPropertyInteger('Rolle', 0); $this->RegisterPropertyInteger('NetzleistungVariableID', 0); $this->RegisterPropertyFloat('Netzleistungsfaktor', 1.0); $this->RegisterPropertyInteger('MesswertMaxAlter', 60); $this->RegisterPropertyString('VerbraucherZuordnung', '[]'); $this->RegisterPropertyString('AutomatischeVerbraucherZuordnung', '[]'); $this->RegisterPropertyBoolean('AutomatischeSuche', true); $this->RegisterPropertyInteger('SuchbereichID', 0); $this->RegisterPropertyInteger('KeepAlive', 60); $this->RegisterPropertyInteger('VerbraucherTimeout', 60); $this->RegisterPropertyInteger('Lastspitzenmodus', 0); $this->RegisterPropertyFloat('Lastspitzengrenze', 0.0); $this->RegisterPropertyString('Monatsgrenzen', $this->standardMonatsgrenzen()); $this->RegisterPropertyFloat('SollwertSolarladen', 0.0); $this->RegisterPropertyFloat('Umschaltdifferenz', 5.0); $this->RegisterPropertyString('PrognoseAnschluss', '{}'); $this->RegisterPropertyString('SDLAnschluss', '{}'); $this->RegisterPropertyString('Lizenzcode', ''); // Verdeckte Altproperty fuer bestehende Instanzkonfigurationen. $this->RegisterPropertyString('LizenzAnschluss', '{}'); $this->RegisterPropertyString('StoermeldeAnschluss', '{}'); $this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false); $this->RegisterPropertyBoolean('LoggingEin', false); $this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10); $this->EnableAction('Aktiv'); $this->RegisterVariableString('Betriebsart', 'Betriebsart', '', 20); $this->RegisterVariableFloat('Netzleistung', 'Netzleistung', '', 30); $this->RegisterAttributeString('VerbraucherCache', '{}'); $this->RegisterAttributeString('LetzteSollwerte', '{}'); $this->RegisterAttributeInteger('RegistrierteNetzleistungVariableID', 0); $this->RegisterAttributeString('LizenzInstallationID', ''); $this->RegisterAttributeString('LizenzLease', '{}'); $this->RegisterAttributeString('LizenzcodeHash', ''); $this->RegisterAttributeInteger('LizenzNaechsterVersuch', 0); $this->RegisterAttributeString('LizenzLetzterFehler', ''); if ($this->ReadAttributeString('LizenzInstallationID') === '') { $this->WriteAttributeString('LizenzInstallationID', $this->erzeugeUuidV4()); } // 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);" ); } public function ApplyChanges(): void { parent::ApplyChanges(); $this->aktualisiereVariablen(); try { $this->pruefeKonfiguration(); } catch (Throwable $fehler) { $this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG); $this->SetTimerInterval('Regelzyklus', 0); $this->SetTimerInterval('VorgabenErneuern', 0); $this->SetTimerInterval('KeepAlive', 0); $this->protokolliere('Konfiguration', $fehler->getMessage()); return; } $this->registriereNetzleistungsmeldung(); $this->SetTimerInterval('Regelzyklus', 0); $this->SetTimerInterval('VorgabenErneuern', 0); $this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000); $this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen')); $this->aktualisiereAnschlussstatus(); if (!$this->aktualisiereLizenzfreigabe($this->ReadPropertyString('Lizenzcode'), false)) { $this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV); $this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG); return; } $this->SetStatus(self::STATUS_AKTIV); } 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'); $monatsgrenzenSichtbar = $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() ); $this->setzeFormularParameter( $formular['elements'], 'Lastspitzengrenze', 'visible', $lastspitzenmodus === 1 ); $this->setzeFormularParameter( $formular['elements'], 'MonatsgrenzenUmschalten', 'visible', $lastspitzenmodus === 2 ); $this->setzeFormularParameter( $formular['elements'], 'MonatsgrenzenUmschalten', 'caption', $monatsgrenzenSichtbar ? 'Monatsgrenzen ausblenden' : 'Monatsgrenzen anzeigen' ); $this->setzeFormularParameter( $formular['elements'], 'Monatsgrenzen', 'visible', $monatsgrenzenSichtbar ); $this->setzeFormularParameter( $formular['elements'], 'Monatsgrenzen', 'values', $this->monatsgrenzenFuerFormular() ); return json_encode($formular, JSON_THROW_ON_ERROR); } public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void { if ((int) $senderID === $this->ReadPropertyInteger('NetzleistungVariableID') && (int) $nachricht === self::VM_UPDATE ) { $this->regeln(false); } } public function RequestAction($ident, $wert): void { switch ($ident) { case 'Aktiv': $this->SetValue('Aktiv', (bool) $wert); $this->regeln(false); return; case 'Regeln': $this->regeln((bool) $wert); return; case 'FormLizenzPruefen': if (!is_string($wert)) { throw new InvalidArgumentException('Der Lizenzcode muss als Text uebergeben werden.'); } $this->aktualisiereLizenzfreigabe($wert, true); $this->UpdateFormField('LizenzInformation', 'caption', $this->lizenzStatustext($wert)); 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; $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.'); } $cache = $this->leseJsonAttribut('VerbraucherCache'); $cache[(string) $absenderID] = [ 'EmpfangenAm' => time(), 'Daten' => $daten, ]; $this->WriteAttributeString('VerbraucherCache', json_encode($cache, JSON_THROW_ON_ERROR)); $this->regeln(false); } private function regeln(bool $erneuern): void { if (!$this->aktualisiereLizenzfreigabe($this->ReadPropertyString('Lizenzcode'), false)) { $this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV); $this->setzeDiagnosewert('Verteilbudget', 0.0); $this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG); return; } [$netzleistung, $netzleistungGueltig] = $this->leseNetzleistung(); $this->SetValue('Netzleistung', $netzleistung); $this->setzeDiagnosewert('NetzleistungGueltig', $netzleistungGueltig); $aktiv = (bool) $this->GetValue('Aktiv'); if (!$aktiv || !$netzleistungGueltig) { $this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV); $this->setzeDiagnosewert('Verteilbudget', 0.0); $this->SetStatus($aktiv ? self::STATUS_MESSWERT_UNGUELTIG : self::STATUS_AKTIV); return; } $stoerungen = []; $verbraucher = $this->aktuelleVerbraucher($stoerungen); $lastspitzengrenze = ManagerRegler::wirksameLastspitzengrenze( $this->ReadPropertyInteger('Lastspitzenmodus'), $this->ReadPropertyFloat('Lastspitzengrenze'), $this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')), (int) date('n') ); $betriebsart = ManagerRegler::bestimmeBetriebsart( true, true, $netzleistung, (string) $this->GetValue('Betriebsart'), $lastspitzengrenze !== null, $this->ReadPropertyFloat('SollwertSolarladen'), $lastspitzengrenze ?? 0.0, $this->ReadPropertyFloat('Umschaltdifferenz') ); $zielnetzleistung = $betriebsart === ManagerRegler::BETRIEBSART_PEAK ? (float) $lastspitzengrenze : $this->ReadPropertyFloat('SollwertSolarladen'); $ergebnis = ManagerRegler::verteile( $netzleistung, $zielnetzleistung, $verbraucher, $betriebsart === ManagerRegler::BETRIEBSART_PEAK ? 'PrioritaetPeak' : 'PrioritaetPV' ); $this->SetValue('Betriebsart', $betriebsart); $this->setzeDiagnosewert('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0); $this->setzeDiagnosewert('Verteilbudget', (float) $ergebnis['Verteilbudget_W']); $this->setzeDiagnosewert('Verbraucherstatus', json_encode([ 'Betriebsart' => $betriebsart, 'Restabweichung_W' => $ergebnis['Restabweichung_W'], 'Verbraucher' => $verbraucher, ], JSON_THROW_ON_ERROR)); $letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte'); foreach ($ergebnis['Sollwerte'] as $instanzID => $sollleistung) { if (!$erneuern && ($letzteSollwerte[(string) $instanzID] ?? null) === $sollleistung) { continue; } try { $this->sendeSollwert((int) $instanzID, $sollleistung); $letzteSollwerte[(string) $instanzID] = $sollleistung; } catch (Throwable $fehler) { $stoerungen[] = 'Sollwert an Instanz ' . $instanzID . ': ' . $fehler->getMessage(); } } $this->WriteAttributeString('LetzteSollwerte', json_encode($letzteSollwerte, JSON_THROW_ON_ERROR)); $this->aktualisiereStoerungen($verbraucher, $stoerungen); $this->SetStatus(self::STATUS_AKTIV); } /** @return array{0: float, 1: bool} */ private function leseNetzleistung(): array { $variablenID = $this->ReadPropertyInteger('NetzleistungVariableID'); if ($variablenID <= 0 || !IPS_VariableExists($variablenID)) { return [0.0, false]; } $variable = IPS_GetVariable($variablenID); if (time() - (int) $variable['VariableUpdated'] > $this->ReadPropertyInteger('MesswertMaxAlter')) { return [0.0, false]; } $wert = GetValue($variablenID); if (!is_int($wert) && !is_float($wert)) { return [0.0, false]; } return [(float) $wert * $this->ReadPropertyFloat('Netzleistungsfaktor'), true]; } /** * @param list $stoerungen * @return list}> */ private function aktuelleVerbraucher(array &$stoerungen): 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; if (!is_array($eintrag) || !isset($eintrag['EmpfangenAm'], $eintrag['Daten'])) { $stoerungen[] = 'Noch keine Meldung von Instanz ' . $instanzID . '.'; continue; } if (time() - (int) $eintrag['EmpfangenAm'] > $timeout) { $stoerungen[] = 'Meldung von Instanz ' . $instanzID . ' ist veraltet.'; 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; } /** @return list */ private function aktiveVerbraucherIDs(): 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'); } /** @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 (self::VERBRAUCHER_MODUL_IDS 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 sendeSollwert(int $instanzID, 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(), ], 'Sollleistung_W' => $sollleistung, ]; Nachrichtenvertrag::pruefeManagerdaten($paket); IPS_RequestAction($instanzID, 'ManagerdatenEmpfangen', json_encode($paket, JSON_THROW_ON_ERROR)); $this->protokolliere('Sollwert', ['InstanzID' => $instanzID, 'Sollleistung_W' => $sollleistung]); } /** * @param list}> $verbraucher * @param list $stoerungen */ private function aktualisiereStoerungen(array $verbraucher, array $stoerungen): void { foreach ($verbraucher as $eintrag) { foreach ($eintrag['Daten']['Zustand'] as $zustand) { if ($zustand['Art'] === 'Stoerung' && $zustand['Wert'] === true) { $stoerungen[] = 'Instanz ' . $eintrag['InstanzID'] . ': ' . ($zustand['Text'] ?? $zustand['Kennung']); } } } $stoerungen = array_values(array_unique($stoerungen)); $this->setzeDiagnosewert('Sammelstoerung', $stoerungen !== []); $this->setzeDiagnosewert('Stoertext', implode("\n", $stoerungen)); } private function registriereNetzleistungsmeldung(): void { $alt = $this->ReadAttributeInteger('RegistrierteNetzleistungVariableID'); $neu = $this->ReadPropertyInteger('NetzleistungVariableID'); if ($alt > 0 && $alt !== $neu) { $this->UnregisterMessage($alt, self::VM_UPDATE); } if ($neu > 0 && $neu !== $alt) { $this->RegisterMessage($neu, self::VM_UPDATE); } $this->WriteAttributeInteger('RegistrierteNetzleistungVariableID', $neu); } private function aktualisiereVariablen(): void { if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) { $this->RegisterVariableBoolean( 'NetzleistungGueltig', 'Netzleistung gueltig', '~Switch', 40 ); $this->RegisterVariableFloat( 'WirksameLastspitzengrenze', 'Wirksame Lastspitzengrenze', '', 50 ); $this->RegisterVariableFloat('Verteilbudget', 'Verteilbudget', '', 60); $this->RegisterVariableInteger('VerbraucherAnzahl', 'Verbraucher Anzahl', '', 70); $this->RegisterVariableInteger( 'VerbraucherVerfuegbar', 'Verbraucher verfuegbar', '', 80 ); $this->RegisterVariableString('Verbraucherstatus', 'Verbraucherstatus', '', 90); $this->RegisterVariableString('Monatsgrenzen', 'Monatsgrenzen', '', 100); $this->RegisterVariableString('Prognosestatus', 'Prognosestatus', '', 110); $this->RegisterVariableString('SDLStatus', 'SDL-Status', '', 120); $this->RegisterVariableString('Lizenzstatus', 'Lizenzstatus', '', 130); $this->RegisterVariableString( 'Stoerueberwachungsstatus', 'Stoerueberwachungsstatus', '', 140 ); $this->RegisterVariableBoolean('Sammelstoerung', 'Sammelstoerung', '~Alert', 150); $this->RegisterVariableString('Stoertext', 'Stoertext', '', 160); return; } foreach (self::DIAGNOSE_VARIABLEN as $ident) { $variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) { $this->UnregisterVariable($ident); } } } /** @param bool|float|int|string $wert */ private function setzeDiagnosewert(string $ident, $wert): void { $variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) { $this->SetValue($ident, $wert); } } private function 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->ReadAttributeString('LizenzInstallationID'); if ($installationID === '') { $installationID = $this->erzeugeUuidV4(); $this->WriteAttributeString('LizenzInstallationID', $installationID); } $codeHash = hash('sha256', $normalisiert); $gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash'); $lease = $this->leseLizenzLease(); $codePasst = $gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash); $gueltig = $codePasst && Lizenzpruefung::istLeaseGueltig( $lease, $installationID, $this->ReadPropertyInteger('Lastspitzenmodus') ); $faellig = !$gueltig || Lizenzpruefung::erneuerungFaellig($lease); $jetzt = time(); if (!$erzwingen && (!$faellig || $jetzt < $this->ReadAttributeInteger('LizenzNaechsterVersuch')) ) { $this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode)); return $gueltig; } $this->WriteAttributeInteger('LizenzNaechsterVersuch', $jetzt + self::LIZENZ_RETRY_SEKUNDEN); try { $neueLease = $this->rufeLizenzserverAuf($normalisiert, $installationID); Lizenzpruefung::pruefeLease( $neueLease, $installationID, $this->ReadPropertyInteger('Lastspitzenmodus') ); $this->WriteAttributeString( 'LizenzLease', json_encode($neueLease, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) ); $this->WriteAttributeString('LizenzcodeHash', $codeHash); $this->WriteAttributeInteger( 'LizenzNaechsterVersuch', Lizenzpruefung::erneuernAb($neueLease) ); $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 lizenzStatustext(?string $lizenzcode = null): string { $code = Lizenzpruefung::normalisiereCode( $lizenzcode ?? $this->ReadPropertyString('Lizenzcode') ); if ($code === '') { return 'Lizenzcode fehlt.'; } if (!Lizenzpruefung::istLizenzcodeGueltig($code)) { return 'Lizenzcode ist ungueltig.'; } $codeHash = hash('sha256', $code); $gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash'); $fehler = $this->ReadAttributeString('LizenzLetzterFehler'); if ($gespeicherterHash === '' || !hash_equals($gespeicherterHash, $codeHash)) { return $fehler !== '' ? 'Nicht freigegeben: ' . $fehler : 'Lizenz noch nicht bestaetigt.'; } $lease = $this->leseLizenzLease(); try { Lizenzpruefung::pruefeLease( $lease, $this->ReadAttributeString('LizenzInstallationID'), $this->ReadPropertyInteger('Lastspitzenmodus') ); $status = ($lease['development'] ?? false) === true ? 'Aktiv (Entwicklung)' : 'Aktiv'; $status .= ' bis ' . date('d.m.Y H:i', Lizenzpruefung::offlineBis($lease)); if ($fehler !== '') { $status .= '; Erneuerung fehlgeschlagen: ' . $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, ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); $curl = curl_init(self::LIZENZ_ENDPOINT); if ($curl === false) { throw new RuntimeException('Die HTTPS-Anfrage konnte nicht vorbereitet werden.'); } try { curl_setopt_array($curl, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $nutzlast, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 10, CURLOPT_HTTPHEADER => [ 'Accept: application/json', 'Content-Type: application/json', ], CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.1', ]); $antwort = curl_exec($curl); if ($antwort === false) { throw new RuntimeException('Lizenzserver nicht erreichbar: ' . curl_error($curl)); } if (strlen($antwort) > 65536) { throw new RuntimeException('Die Antwort des Lizenzservers ist zu gross.'); } $httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE); } finally { curl_close($curl); } try { $daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR); } catch (Throwable $fehler) { throw new RuntimeException('Der Lizenzserver lieferte kein gueltiges JSON.', 0, $fehler); } if (!is_array($daten)) { throw new RuntimeException('Der Lizenzserver lieferte kein JSON-Objekt.'); } if ($httpStatus !== 200) { $meldung = is_string($daten['error'] ?? null) ? $daten['error'] : 'Unbekannter Fehler'; throw new RuntimeException('Lizenzserver HTTP ' . $httpStatus . ': ' . $meldung); } return $daten; } private function erzeugeUuidV4(): string { $bytes = random_bytes(16); $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); $hex = bin2hex($bytes); return sprintf( '%s-%s-%s-%s-%s', substr($hex, 0, 8), substr($hex, 8, 4), substr($hex, 12, 4), substr($hex, 16, 4), substr($hex, 20, 12) ); } private function aktualisiereAnschlussstatus(): void { foreach ([ 'PrognoseAnschluss' => 'Prognosestatus', 'SDLAnschluss' => 'SDLStatus', 'StoermeldeAnschluss' => 'Stoerueberwachungsstatus', ] as $property => $variable) { $konfiguration = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR); $aktiv = is_array($konfiguration) && ($konfiguration['Aktiv'] ?? false) === true; $this->setzeDiagnosewert($variable, $aktiv ? 'Fehler' : 'NichtVerwendet'); } } private function pruefeKonfiguration(): void { foreach (['MesswertMaxAlter', 'KeepAlive', 'VerbraucherTimeout'] as $property) { if ($this->ReadPropertyInteger($property) <= 0) { throw new InvalidArgumentException($property . ' muss groesser als 0 sein.'); } } if ($this->ReadPropertyFloat('Umschaltdifferenz') < 0 || $this->ReadPropertyFloat('Umschaltdifferenz') > 100 ) { throw new InvalidArgumentException('Umschaltdifferenz muss zwischen 0 und 100 liegen.'); } $this->leseAktiveVerbraucherIDs('VerbraucherZuordnung'); $this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung'); $this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')); foreach (['PrognoseAnschluss', 'SDLAnschluss', 'StoermeldeAnschluss'] as $property) { $wert = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR); if (!is_array($wert)) { throw new InvalidArgumentException($property . ' muss ein JSON-Objekt sein.'); } } } /** @return array */ 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); } } }