diff --git a/Manager/form.json b/Manager/form.json index 2a7a119..5fce8a4 100644 --- a/Manager/form.json +++ b/Manager/form.json @@ -26,6 +26,13 @@ "name": "LizenzPruefen", "caption": "Lizenz pruefen und binden", "onClick": "IPS_RequestAction($id, 'FormLizenzPruefen', $Lizenzcode);" + }, + { + "type": "Button", + "name": "LizenzVerwalten", + "caption": "Lizenz verwalten", + "link": true, + "onClick": "echo 'https://license.enelix.ch';" } ] }, @@ -296,6 +303,6 @@ {"code": 102, "icon": "active", "caption": "Aktiv"}, {"code": 201, "icon": "inactive", "caption": "Netzleistungsmessung fehlt oder ist veraltet"}, {"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"}, - {"code": 203, "icon": "error", "caption": "Lizenz fehlt oder ist abgelaufen"} + {"code": 203, "icon": "error", "caption": "Lizenz nicht freigegeben oder Verbraucherkontingent ueberschritten"} ] } diff --git a/Manager/module.php b/Manager/module.php index 8944ff5..b021e5f 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -21,14 +21,35 @@ class Manager extends IPSModule implements ManagerSchnittstelle private const LIZENZ_ENDPOINT = 'https://license.enelix.ch/api/v1/licenses/activate'; private const LIZENZ_RETRY_SEKUNDEN = 3600; private const VM_UPDATE = 10603; - private const VERBRAUCHER_MODUL_IDS = [ - '{B7C54AF4-AD7D-4FE4-B75D-203693906251}', - '{15879A4E-D0C2-4495-83DE-46E1E462591E}', - '{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}', - '{0D94913C-0F31-4C29-A685-6EB4AE58E55D}', - '{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}', - '{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}', - '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}', + private const VERBRAUCHER_LIZENZEN = [ + '{B7C54AF4-AD7D-4FE4-B75D-203693906251}' => [ + 'Katalog' => 'boiler_multi', + 'Name' => 'Boiler mehrstufig', + ], + '{15879A4E-D0C2-4495-83DE-46E1E462591E}' => [ + 'Katalog' => 'consumer_single', + 'Name' => 'Verbraucher einstufig', + ], + '{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}' => [ + 'Katalog' => 'consumer_single', + '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', @@ -41,6 +62,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle 'VerbraucherAnzahl', 'VerbraucherVerfuegbar', 'Verbraucherstatus', + 'NichtGeregelteGeraete', 'Monatsgrenzen', 'Prognosestatus', 'SDLStatus', @@ -90,6 +112,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->RegisterAttributeString('LizenzLease', '{}'); $this->RegisterAttributeString('LizenzcodeHash', ''); $this->RegisterAttributeInteger('LizenzNaechsterVersuch', 0); + $this->RegisterAttributeInteger('LizenzBerechtigungenNaechsterVersuch', 0); $this->RegisterAttributeString('LizenzLetzterFehler', ''); if ($this->ReadAttributeString('LizenzInstallationID') === '') { $this->WriteAttributeString('LizenzInstallationID', $this->erzeugeUuidV4()); @@ -133,6 +156,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle $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); } @@ -146,7 +177,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle ); $automatisch = $this->ReadPropertyBoolean('AutomatischeSuche'); $lastspitzenmodus = $this->ReadPropertyInteger('Lastspitzenmodus'); - $monatsgrenzenSichtbar = $lastspitzenmodus === 2 + $peakShavingLizenziert = $this->peakShavingLizenziert(); + $monatsgrenzenSichtbar = $peakShavingLizenziert + && $lastspitzenmodus === 2 && $this->GetBuffer('MonatsgrenzenSichtbar') === '1'; $this->setzeFormularParameter( @@ -181,17 +214,24 @@ class Manager extends IPSModule implements ManagerSchnittstelle 'values', $this->automatischeVerbraucherFuerFormular() ); + $lastspitzenOptionen = $this->lastspitzenOptionen($peakShavingLizenziert); + $this->setzeFormularParameter( + $formular['elements'], + 'Lastspitzenmodus', + 'options', + $lastspitzenOptionen + ); $this->setzeFormularParameter( $formular['elements'], 'Lastspitzengrenze', 'visible', - $lastspitzenmodus === 1 + $peakShavingLizenziert && $lastspitzenmodus === 1 ); $this->setzeFormularParameter( $formular['elements'], 'MonatsgrenzenUmschalten', 'visible', - $lastspitzenmodus === 2 + $peakShavingLizenziert && $lastspitzenmodus === 2 ); $this->setzeFormularParameter( $formular['elements'], @@ -242,6 +282,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle } $this->aktualisiereLizenzfreigabe($wert, true); $this->UpdateFormField('LizenzInformation', 'caption', $this->lizenzStatustext($wert)); + $this->aktualisiereLastspitzenformular(); return; case 'FormAutomatischeSuche': @@ -277,6 +318,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle 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) { @@ -346,6 +395,17 @@ class Manager extends IPSModule implements ManagerSchnittstelle 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; } @@ -358,6 +418,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle if (!$aktiv || !$netzleistungGueltig) { $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; } @@ -385,8 +448,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle } $stoerungen = []; + $nichtGeregelt = []; $synchronisiert = true; - $verbraucher = $this->aktuelleVerbraucher($stoerungen, $betriebsart, $synchronisiert); + $verbraucher = $this->aktuelleVerbraucher( + $stoerungen, + $nichtGeregelt, + $betriebsart, + $synchronisiert + ); $this->setzeDiagnosewert('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0); if (!$synchronisiert) { $this->setzeDiagnosewert('Verteilbudget', 0.0); @@ -394,7 +463,12 @@ class Manager extends IPSModule implements ManagerSchnittstelle 'Betriebsart' => $betriebsart, 'Synchronisiert' => false, 'Verbraucher' => $verbraucher, + 'NichtGeregelt' => $nichtGeregelt, ], JSON_THROW_ON_ERROR)); + $this->setzeDiagnosewert( + 'NichtGeregelteGeraete', + $this->nichtGeregelteGeraeteText($nichtGeregelt) + ); $this->aktualisiereStoerungen($verbraucher, $stoerungen); $this->SetStatus(self::STATUS_AKTIV); return; @@ -415,7 +489,12 @@ class Manager extends IPSModule implements ManagerSchnittstelle 'Synchronisiert' => true, 'Restabweichung_W' => $ergebnis['Restabweichung_W'], 'Verbraucher' => $verbraucher, + 'NichtGeregelt' => [], ], JSON_THROW_ON_ERROR)); + $this->setzeDiagnosewert( + 'NichtGeregelteGeraete', + 'Alle aktiven Verbraucher werden geregelt.' + ); $letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte'); foreach ($ergebnis['Sollwerte'] as $instanzID => $sollleistung) { @@ -457,10 +536,12 @@ class Manager extends IPSModule implements ManagerSchnittstelle /** * @param list $stoerungen + * @param list> $nichtGeregelt * @return list}> */ private function aktuelleVerbraucher( array &$stoerungen, + array &$nichtGeregelt, string $betriebsart, bool &$synchronisiert ): array { @@ -483,11 +564,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle if ($grund !== '') { $synchronisiert = false; - $stoerungen[] = $grund . ' von Instanz ' . $instanzID . '.'; + $detail = $this->verbraucherDetail($instanzID, $grund, $eintrag); + $nichtGeregelt[] = $detail; + $stoerungen[] = $detail['Name'] . ': ' . $grund . '.'; try { $this->sendeManagerdaten($instanzID, $betriebsart, null); } catch (Throwable $fehler) { - $stoerungen[] = 'Betriebsart an Instanz ' . $instanzID . ': ' . $fehler->getMessage(); + $stoerungen[] = $detail['Name'] . ': Betriebsart konnte nicht uebermittelt werden: ' + . $fehler->getMessage(); } continue; } @@ -500,8 +584,124 @@ class Manager extends IPSModule implements ManagerSchnittstelle 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'); @@ -521,6 +721,64 @@ class Manager extends IPSModule implements ManagerSchnittstelle return $this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung'); } + /** + * @param list $ids + */ + private function pruefeVerbraucherlizenzen(array $ids): void + { + [$mengen, $namen] = $this->verbraucherLizenzbelegung($ids); + + try { + Lizenzpruefung::pruefeVerbrauchermengen($this->leseLizenzLease(), $mengen); + } catch (InvalidArgumentException $fehler) { + foreach ($mengen as $kennung => $verwendet) { + $lizenziert = Lizenzpruefung::berechtigungsMenge( + $this->leseLizenzLease(), + $kennung + ); + if ($verwendet > $lizenziert) { + throw new InvalidArgumentException(sprintf( + '%s: %d aktiviert, aber nur %d lizenziert. Bitte Auswahl reduzieren oder Lizenz erweitern.', + $namen[$kennung], + $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 { @@ -579,7 +837,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle $suchbereichID ??= $this->ReadPropertyInteger('SuchbereichID'); $werte = []; - foreach (self::VERBRAUCHER_MODUL_IDS as $modulID) { + foreach (array_keys(self::VERBRAUCHER_LIZENZEN) as $modulID) { foreach (IPS_GetInstanceListByModuleID($modulID) as $instanzID) { if (!$this->istImSuchbereich($instanzID, $suchbereichID)) { continue; @@ -743,6 +1001,12 @@ class Manager extends IPSModule implements ManagerSchnittstelle 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); @@ -796,23 +1060,59 @@ class Manager extends IPSModule implements ManagerSchnittstelle $gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash'); $lease = $this->leseLizenzLease(); $codePasst = $gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash); + $lastspitzenmodus = $this->ReadPropertyInteger('Lastspitzenmodus'); + $basisGueltig = $codePasst && Lizenzpruefung::istLeaseGueltig( + $lease, + $installationID, + 0 + ); $gueltig = $codePasst && Lizenzpruefung::istLeaseGueltig( $lease, $installationID, - $this->ReadPropertyInteger('Lastspitzenmodus') + $lastspitzenmodus ); - $faellig = !$gueltig || Lizenzpruefung::erneuerungFaellig($lease); + $berechtigungenVeraltet = $basisGueltig + && !$this->lizenzBerechtigungenPassen($lease, $lastspitzenmodus); + $routineFaellig = (!$gueltig && !$berechtigungenVeraltet) + || Lizenzpruefung::erneuerungFaellig($lease); $jetzt = time(); - if (!$erzwingen - && (!$faellig || $jetzt < $this->ReadAttributeInteger('LizenzNaechsterVersuch')) - ) { - $this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode)); + if (!$erzwingen) { + if (!$routineFaellig && !$berechtigungenVeraltet) { + $this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode)); - return $gueltig; + 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; + } } - $this->WriteAttributeInteger('LizenzNaechsterVersuch', $jetzt + self::LIZENZ_RETRY_SEKUNDEN); + 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); Lizenzpruefung::pruefeLease( @@ -825,9 +1125,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle json_encode($neueLease, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) ); $this->WriteAttributeString('LizenzcodeHash', $codeHash); + $naechsteErneuerung = Lizenzpruefung::erneuernAb($neueLease); + $this->WriteAttributeInteger('LizenzNaechsterVersuch', $naechsteErneuerung); $this->WriteAttributeInteger( - 'LizenzNaechsterVersuch', - Lizenzpruefung::erneuernAb($neueLease) + 'LizenzBerechtigungenNaechsterVersuch', + $naechsteErneuerung ); $this->WriteAttributeString('LizenzLetzterFehler', ''); $this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode)); @@ -860,6 +1162,108 @@ class Manager extends IPSModule implements ManagerSchnittstelle } } + private function peakShavingLizenziert(): bool + { + $lizenzcode = Lizenzpruefung::normalisiereCode( + $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, + Lizenzpruefung::MANAGER_PEAK + ) > 0; + } + + /** + * @param array $lease + */ + private function lizenzBerechtigungenPassen(array $lease, int $lastspitzenmodus): bool + { + if ($lastspitzenmodus !== 0 + && Lizenzpruefung::berechtigungsMenge( + $lease, + Lizenzpruefung::MANAGER_PEAK + ) < 1 + ) { + return false; + } + + try { + [$mengen] = $this->verbraucherLizenzbelegung( + $this->konfigurierteAktiveVerbraucherIDs() + ); + Lizenzpruefung::pruefeVerbrauchermengen($lease, $mengen); + + 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(): void + { + $lizenziert = $this->peakShavingLizenziert(); + $modus = $lizenziert ? $this->ReadPropertyInteger('Lastspitzenmodus') : 0; + $this->UpdateFormField( + 'Lastspitzenmodus', + 'options', + json_encode($this->lastspitzenOptionen($lizenziert), JSON_THROW_ON_ERROR) + ); + $this->UpdateFormField('Lastspitzenmodus', 'value', $modus); + $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 lizenzStatustext(?string $lizenzcode = null): string { $code = Lizenzpruefung::normalisiereCode( @@ -886,12 +1290,16 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->ReadAttributeString('LizenzInstallationID'), $this->ReadPropertyInteger('Lastspitzenmodus') ); + $this->aktiveVerbraucherIDs(); $status = ($lease['development'] ?? false) === true - ? 'Aktiv (Entwicklung)' - : 'Aktiv'; - $status .= ' bis ' . date('d.m.Y H:i', Lizenzpruefung::offlineBis($lease)); + ? 'Aktiv (Entwicklung) - unbeschraenkt gueltig.' + : 'Aktiv - unbeschraenkt gueltig.'; if ($fehler !== '') { - $status .= '; Erneuerung fehlgeschlagen: ' . $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; diff --git a/libs/Lizenzpruefung.php b/libs/Lizenzpruefung.php index 5098886..553633f 100644 --- a/libs/Lizenzpruefung.php +++ b/libs/Lizenzpruefung.php @@ -82,6 +82,62 @@ final class Lizenzpruefung } } + /** + * Liefert die aufsummierte aktive Berechtigung fuer einen Katalogeintrag. + * + * @param array $lease + */ + public static function berechtigungsMenge(array $lease, string $kennung): int + { + $berechtigungen = $lease['entitlements'] ?? null; + if (!is_array($berechtigungen)) { + return 0; + } + + $menge = 0; + foreach ($berechtigungen as $berechtigung) { + if (!is_array($berechtigung) + || ($berechtigung['status'] ?? null) !== 'active' + || !is_int($berechtigung['quantity'] ?? null) + || $berechtigung['quantity'] < 1 + ) { + continue; + } + $berechtigungsKennung = $berechtigung['catalog_key'] + ?? $berechtigung['sku'] + ?? null; + if ($berechtigungsKennung === $kennung) { + $menge += $berechtigung['quantity']; + } + } + + return $menge; + } + + /** + * @param array $lease + * @param array $verwendeteMengen + */ + public static function pruefeVerbrauchermengen(array $lease, array $verwendeteMengen): void + { + foreach ($verwendeteMengen as $kennung => $verwendet) { + if ($verwendet < 0) { + throw new InvalidArgumentException( + 'Die verwendete Menge fuer ' . $kennung . ' darf nicht negativ sein.' + ); + } + $lizenziert = self::berechtigungsMenge($lease, $kennung); + if ($verwendet > $lizenziert) { + throw new InvalidArgumentException(sprintf( + 'Lizenzkontingent fuer %s ueberschritten: %d verwendet, %d lizenziert.', + $kennung, + $verwendet, + $lizenziert + )); + } + } + } + /** * @param array $lease */ diff --git a/tests/LizenzpruefungTest.php b/tests/LizenzpruefungTest.php index 56857d6..4646463 100644 --- a/tests/LizenzpruefungTest.php +++ b/tests/LizenzpruefungTest.php @@ -104,6 +104,75 @@ final class LizenzpruefungTest extends TestCase ); } + public function testZweiLizenzierteBoilerWerdenFreigegeben(): void + { + $lease = $this->lease('manager_standard'); + $lease['entitlements'][] = [ + 'sku' => 'ENX-BOILER-MULTI', + 'catalog_key' => 'boiler_multi', + 'quantity' => 2, + 'status' => 'active', + ]; + + self::assertSame(2, Lizenzpruefung::berechtigungsMenge($lease, 'boiler_multi')); + Lizenzpruefung::pruefeVerbrauchermengen($lease, ['boiler_multi' => 2]); + + self::assertTrue(true); + } + + public function testDritterBoilerBeiZweiLizenzenWirdAbgelehnt(): void + { + $lease = $this->lease('manager_standard'); + $lease['entitlements'][] = [ + 'sku' => 'ENX-BOILER-MULTI', + 'catalog_key' => 'boiler_multi', + 'quantity' => 2, + 'status' => 'active', + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Lizenzkontingent fuer boiler_multi ueberschritten: 3 verwendet, 2 lizenziert.' + ); + Lizenzpruefung::pruefeVerbrauchermengen($lease, ['boiler_multi' => 3]); + } + + public function testVerbraucherkontingenteWerdenJeTypSummiertUndGetrenntGeprueft(): void + { + $lease = $this->lease('manager_standard'); + $lease['entitlements'] = array_merge($lease['entitlements'], [ + [ + 'catalog_key' => 'boiler_multi', + 'quantity' => 1, + 'status' => 'active', + ], + [ + 'catalog_key' => 'boiler_multi', + 'quantity' => 1, + 'status' => 'active', + ], + [ + 'catalog_key' => 'battery', + 'quantity' => 1, + 'status' => 'active', + ], + [ + 'catalog_key' => 'battery', + 'quantity' => 10, + 'status' => 'revoked', + ], + ]); + + self::assertSame(2, Lizenzpruefung::berechtigungsMenge($lease, 'boiler_multi')); + self::assertSame(1, Lizenzpruefung::berechtigungsMenge($lease, 'battery')); + Lizenzpruefung::pruefeVerbrauchermengen($lease, [ + 'boiler_multi' => 2, + 'battery' => 1, + ]); + + self::assertTrue(true); + } + public function testLizenzcodeWirdWieAufDemServerNormalisiert(): void { self::assertSame( diff --git a/tests/ManagerModulstrukturTest.php b/tests/ManagerModulstrukturTest.php index f323241..fcb9141 100644 --- a/tests/ManagerModulstrukturTest.php +++ b/tests/ManagerModulstrukturTest.php @@ -52,6 +52,7 @@ final class ManagerModulstrukturTest extends TestCase 'LoggingEin', 'Lizenzcode', 'LizenzPruefen', + 'LizenzVerwalten', 'LizenzInstallationID', 'LizenzInformation', 'VerbraucherAktualisieren', @@ -69,6 +70,8 @@ final class ManagerModulstrukturTest extends TestCase self::assertStringContainsString($bereich, $json); } self::assertSame('Lizenzierung', $formular['elements'][0]['caption']); + self::assertStringContainsString('https://license.enelix.ch', $json); + self::assertStringContainsString('"link":true', $json); self::assertStringContainsString( "'SuchbereichID' => \$SuchbereichID", $json @@ -111,6 +114,44 @@ final class ManagerModulstrukturTest extends TestCase self::assertStringContainsString("RegisterAttributeString('LizenzcodeHash'", $inhalt); self::assertStringContainsString('STATUS_LIZENZ_UNGUELTIG', $inhalt); self::assertStringContainsString('aktualisiereLizenzfreigabe(', $inhalt); + self::assertStringContainsString('pruefeVerbraucherlizenzen(', $inhalt); + self::assertStringContainsString('lizenzBerechtigungenPassen(', $inhalt); + self::assertStringContainsString('peakShavingLizenziert(', $inhalt); + self::assertStringContainsString('Aus (Peak-Shaving-Lizenz fehlt)', $inhalt); + self::assertStringContainsString('Aus (Peak-Shaving-Lizenz verfuegbar)', $inhalt); + self::assertStringContainsString( + 'Konstant und Monatlich benoetigen eine Peak-Shaving-Lizenz.', + $inhalt + ); + foreach (['battery', 'buffer_storage', 'ev_charger', 'consumer_single', 'boiler_multi'] as $katalog) { + self::assertStringContainsString("'Katalog' => '" . $katalog . "'", $inhalt); + } + foreach ([ + '{B7C54AF4-AD7D-4FE4-B75D-203693906251}', + '{15879A4E-D0C2-4495-83DE-46E1E462591E}', + '{0D94913C-0F31-4C29-A685-6EB4AE58E55D}', + '{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}', + '{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}', + '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}', + ] as $modulID) { + self::assertStringContainsString("'" . $modulID . "' =>", $inhalt); + } + self::assertStringContainsString("'Name' => 'Pufferspeicher'", $inhalt); + self::assertStringContainsString("'Name' => 'Batteriespeicher'", $inhalt); + self::assertStringContainsString( + 'Bitte Auswahl reduzieren oder Lizenz erweitern.', + $inhalt + ); + foreach ([ + "'NichtGeregelt'", + "'NichtGeregelteGeraete'", + "'InstanzStatus'", + "'LetzteMeldung'", + "'GemeldeteBetriebsart'", + "'Verfuegbar'", + ] as $detailfeld) { + self::assertStringContainsString($detailfeld, $inhalt); + } self::assertStringNotContainsString("'publicKey' =>", $inhalt); } diff --git a/tests/Symcon/modules/Manager.php b/tests/Symcon/modules/Manager.php index 31ea31f..34a83b3 100644 --- a/tests/Symcon/modules/Manager.php +++ b/tests/Symcon/modules/Manager.php @@ -163,7 +163,7 @@ return static function (TestContext $test): void { if ($lizenzcode === '') { $test->runCase('Manager bleibt ohne Lizenz sicher gesperrt', static function ( TestContext $test - ) use ($managerId): void { + ) use ($managerId, $verbraucherstatus): void { IPS_RequestAction($managerId, 'Aktiv', true); IPS_RequestAction($managerId, 'Regeln', true); $test->assertInstanceStatus($managerId, 203); @@ -171,6 +171,21 @@ return static function (TestContext $test): void { 'Inaktiv', GetValue($test->objectByIdent('Betriebsart', $managerId)) ); + + $daten = $verbraucherstatus(); + $test->assertSame(false, $daten['Synchronisiert'] ?? null); + $test->assertSame(3, count($daten['NichtGeregelt'] ?? [])); + foreach ($daten['NichtGeregelt'] as $detail) { + $test->assertTrue(is_int($detail['InstanzID'] ?? null)); + $test->assertTrue(is_string($detail['Name'] ?? null)); + $test->assertTrue(is_string($detail['Typ'] ?? null)); + $test->assertSame('Lizenz nicht freigegeben', $detail['Grund'] ?? null); + } + $detailtext = (string) GetValue( + $test->objectByIdent('NichtGeregelteGeraete', $managerId) + ); + $test->assertTrue(str_contains($detailtext, 'Boiler')); + $test->assertTrue(str_contains($detailtext, 'Lizenz nicht freigegeben')); }); return;